cap: resolve @agent and @agent:model mentions via CapModelCatalog
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased] - 2026-10-25
|
||||
- `@agent`, `@agent:model` and bare `@model` mentions resolve against CAP agents via the new `CapModelCatalog` (manifest `[models]` list or `--cap-models` probe); turns run through `CapMention`; failures print a per-agent report of status, binary and models. Test: `build/capmcp/cap_model_catalog_test`. Spec: docs/cap.md.
|
||||
- Build and test integration after the parallel module ports. Not released: `make test` is not green (see below), so VERSION stays 1.44.2 and no tag was made.
|
||||
- Makefile rewritten: objects in `build/obj`, archive `build/libdetz.a` (no main.cpp), one test binary per delivered test source (foundation, store, cli, util/config, agent diff/e2e/sched, shell, cap/mcp unit/probe/cli, misc, tui spec/shot/script/rows/chat/grr, mplex x11), `make test`, `make lint` (real `-Werror -fsyntax-only` over src, main.cpp and tests, no `|| true`), `make parity`, `make parity-check` (docs/wiring/verify.md).
|
||||
- `tests/run_all.py`: runs all 32 steps (C++ unit binaries plus the python differential drivers), logs to `build/test-logs/`, prints a summary table, exits 1 on any failure.
|
||||
|
||||
@@ -86,6 +86,7 @@ $(eval $(call TESTBIN,$(BUILD)/shell_test,tests/shell_test.cpp,$(SHELL_INC)))
|
||||
$(eval $(call TESTBIN,$(BUILD)/capmcp/capmcp_unit,tests/capmcp_unit.cpp))
|
||||
$(eval $(call TESTBIN,$(BUILD)/capmcp/capmcp_probe,tests/capmcp_probe.cpp))
|
||||
$(eval $(call TESTBIN,$(BUILD)/capmcp/capmcp_cli,tests/capmcp_cli.cpp))
|
||||
$(eval $(call TESTBIN,$(BUILD)/capmcp/cap_model_catalog_test,tests/cap_model_catalog_test.cpp))
|
||||
# misc (skills, sched, telegram, rag, install)
|
||||
$(eval $(call TESTBIN,$(BUILD)/misc/misc_test,tests/misc_test.cpp,$(MISC_INC)))
|
||||
$(eval $(call TESTBIN,$(BUILD)/misc/misc_driver,tests/misc_driver.cpp,$(MISC_INC)))
|
||||
|
||||
@@ -84,6 +84,16 @@ hooks: `docs/wiring/capmcp.md`.
|
||||
- Terminal `terminal/output` keeps the `read(65536)` blocking-chunk behavior.
|
||||
- Signals: SIGINT/SIGTERM set a flag polled by waits (`CapInterrupt`).
|
||||
|
||||
## Model catalog and `@agent` mentions
|
||||
|
||||
`CapModelCatalog` (`src/cap/cap_model_catalog.*`) lists agents from the `*.toml` manifests in the registry user and system dirs (cap `CAP_AGENT_MAX` 64) and checks each: manifest parses, binary on PATH. Status: `ready`, `no_binary`, `bad_manifest`, `no_manifest`. Entries are cached `CAP_CATALOG_TTL` (60) seconds; entries load in parallel.
|
||||
|
||||
Model protocol (tai-specific extension, absent from the Python app): `[models] list = [...]` and `default` in the manifest, plus an optional probe, `[models] probe = ["bin", "--cap-models"]` or `capabilities.models = true` for `<binary> --cap-models`. Probe output: JSON array of ids or `{id|name|model}` objects, `{"models": [...], "default": "..."}`, or one id per line. Bounds: `CAP_MODEL_PROBE_TIMEOUT` 5 s, `CAP_MODEL_PROBE_MAX_BYTES` 1 MiB, `CAP_MODEL_MAX` 500 per agent. A failed probe is recorded on the entry, never thrown. Tai's own manifest is unchanged and declares no models.
|
||||
|
||||
Mention grammar (after bots, providers, backends, known model ids): `@agent`, `@agent:model` (split at the first `:`), or a bare model id found on exactly one ready agent. Model matching: exact, case-insensitive exact, unique prefix, unique substring; ambiguity is an error. An agent that publishes no list passes the model through with a note. With a prompt the turn runs on that agent (`CapMention`, one cached `CapSession` per agent/model/cwd, model sent via `startup.model_arg`); without a prompt the agent is pinned (`Agent::agent_pin`) until `@auto`. Every failure prints the reason plus the full per-agent report (status, binary, manifest, binding, models, default, probe error); an unknown tag appends the same report.
|
||||
|
||||
Gaps: no TUI completion for agents yet; `tai --cap-models` is not implemented on tai's own side.
|
||||
|
||||
## Verification
|
||||
|
||||
`tests/capmcp_diff.py` (JSON, TOML, str(), float repr, regex vs CPython),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "../llm/helpers.h"
|
||||
#include "../llm/py.h"
|
||||
#include "../llm/term.h"
|
||||
#include "../cap/cap_mention.h"
|
||||
#include "consts.h"
|
||||
#include "data.h"
|
||||
#include "text.h"
|
||||
@@ -606,14 +607,39 @@ void Agent::phase(const std::string& name, const std::string& detail) {
|
||||
std::string Agent::mention_error(const std::string& tag) {
|
||||
std::vector<std::string> names;
|
||||
for (const auto& item : store->list_bots(profile).items()) { names.push_back(sv(item.get("name"))); }
|
||||
return "unknown bot @" + tag + " (available: " + py::join(names, ", ") + "; providers: " + py::join(llm::ProviderLabels(), ", ") +
|
||||
"; @path references a file)";
|
||||
std::string out = "unknown bot @" + tag + " (available: " + py::join(names, ", ") + "; providers: " + py::join(llm::ProviderLabels(), ", ") +
|
||||
"; @path references a file)";
|
||||
try { out += "\n" + cap_mention().catalog().report(); } catch (...) {}
|
||||
return out;
|
||||
}
|
||||
|
||||
detz::cap::CapMention& Agent::cap_mention() {
|
||||
if (!cap_mention_) { cap_mention_ = std::make_shared<detz::cap::CapMention>(); }
|
||||
return *cap_mention_;
|
||||
}
|
||||
|
||||
std::string Agent::run_agent(const std::string& agent_name, const std::string& model, const std::string& rest,
|
||||
const std::string& original, bool loud) {
|
||||
detz::cap::CapSelection sel;
|
||||
sel.ok = true;
|
||||
sel.agent = agent_name;
|
||||
sel.model = model;
|
||||
std::string answer = cap_mention().ask(sel, rest, dir_key());
|
||||
if (loud) { print_out(answer); }
|
||||
store->log_event(profile, "user", "message", store->redact(original, profile));
|
||||
messages.push(msg("user", original));
|
||||
json::Value reply = msg("assistant", answer);
|
||||
reply.set("backend", "cap:" + agent_name);
|
||||
messages.push(reply);
|
||||
if (persist) { store->save_session(profile, messages, bot, ctx_id); }
|
||||
return answer;
|
||||
}
|
||||
|
||||
std::string Agent::mention_confirm(const std::string& provider, const std::string& model, const std::string& bot_sel,
|
||||
bool cleared, const std::string& directory) {
|
||||
bool cleared, const std::string& directory, const std::string& agent_sel) {
|
||||
std::vector<std::string> bits;
|
||||
if (cleared && provider.empty() && model.empty()) { bits.push_back("pins cleared"); }
|
||||
if (!agent_sel.empty()) { bits.push_back("cap agent pinned to " + agent_sel); }
|
||||
if (!provider.empty()) { bits.push_back("provider pinned to " + provider); }
|
||||
if (!model.empty()) { bits.push_back("model pinned to " + model); }
|
||||
if (!bot_sel.empty()) { bits.push_back("switched to bot '" + bot_sel + "'"); }
|
||||
@@ -636,10 +662,24 @@ ResolvedTag Agent::resolve_tag(const std::string& tag, const std::string& direct
|
||||
auto ids = known_model_ids(config, &chat.health);
|
||||
if (ids.count(word)) { return {"model", word, ""}; }
|
||||
if (lowered == llm::kMentionAuto) { return {"auto", llm::kMentionAuto, ""}; }
|
||||
try {
|
||||
auto& catalog = cap_mention().catalog();
|
||||
if (catalog.isAgent(word)) {
|
||||
auto sel = catalog.resolve(word);
|
||||
if (sel.ok) { return {"agent", sel.agent, sel.model}; }
|
||||
return {"agent_error", sel.error, ""};
|
||||
}
|
||||
} catch (...) {
|
||||
}
|
||||
std::string core = py::rstrip_chars(word, ",;:!?)");
|
||||
std::string expanded = expanduser(core);
|
||||
std::string path = (!expanded.empty() && expanded[0] == '/') ? expanded : directory + "/" + expanded;
|
||||
if (!core.empty() && path_exists(path)) { return {"file", abspath(path), ""}; }
|
||||
try {
|
||||
auto sel = cap_mention().catalog().resolve(word);
|
||||
if (sel.ok) { return {"agent", sel.agent, sel.model}; }
|
||||
} catch (...) {
|
||||
}
|
||||
return {"unknown", word, ""};
|
||||
}
|
||||
|
||||
@@ -649,15 +689,21 @@ std::pair<std::optional<std::string>, std::optional<std::string>> Agent::apply_m
|
||||
std::optional<std::string> sel_provider, sel_model, sel_bot;
|
||||
bool cleared = false;
|
||||
std::vector<std::string> files;
|
||||
std::optional<std::pair<std::string, std::string>> sel_agent;
|
||||
bool loud = !capture && !quiet;
|
||||
for (const auto& tag : tags) {
|
||||
ResolvedTag r = resolve_tag(tag, directory);
|
||||
if (r.kind == "agent_error") {
|
||||
if (loud) { print_out(r.value); }
|
||||
return {text, r.value};
|
||||
}
|
||||
if (r.kind == "agent") { sel_agent = std::make_pair(r.value, r.value2); continue; }
|
||||
if (r.kind == "unknown") {
|
||||
std::string error = mention_error(tag);
|
||||
if (loud) { print_out(error); }
|
||||
return {text, error};
|
||||
}
|
||||
if (r.kind == "auto") { cleared = true; sel_provider.reset(); sel_model.reset(); continue; }
|
||||
if (r.kind == "auto") { cleared = true; sel_provider.reset(); sel_model.reset(); sel_agent.reset(); agent_pin.clear(); continue; }
|
||||
if (r.kind == "file") { files.push_back(r.value); continue; }
|
||||
if (r.kind == "route") { sel_provider = r.value; sel_model = r.value2; continue; }
|
||||
if (r.kind == "provider") { sel_provider = r.value; }
|
||||
@@ -679,7 +725,23 @@ std::pair<std::optional<std::string>, std::optional<std::string>> Agent::apply_m
|
||||
if (!py::strip(prompt).empty()) { parts.push_back(prompt); }
|
||||
prompt = py::join(parts, " ");
|
||||
}
|
||||
if (tags.empty()) { return {prompt, std::nullopt}; }
|
||||
if (tags.empty()) {
|
||||
if (!agent_pin.empty() && !py::strip(prompt).empty()) {
|
||||
auto colon = agent_pin.find(':');
|
||||
return {std::nullopt, run_agent(agent_pin.substr(0, colon), colon == std::string::npos ? "" : agent_pin.substr(colon + 1), prompt, text, loud)};
|
||||
}
|
||||
return {prompt, std::nullopt};
|
||||
}
|
||||
if (sel_agent.has_value()) {
|
||||
std::string label = sel_agent->first + (sel_agent->second.empty() ? "" : ":" + sel_agent->second);
|
||||
if (py::strip(prompt).empty()) {
|
||||
agent_pin = label;
|
||||
std::string confirm = mention_confirm(sel_provider.value_or(""), sel_model.value_or(""), sel_bot.value_or(""), cleared, directory, label);
|
||||
if (loud) { print_out(confirm); }
|
||||
return {std::nullopt, confirm};
|
||||
}
|
||||
return {std::nullopt, run_agent(sel_agent->first, sel_agent->second, prompt, text, loud)};
|
||||
}
|
||||
if (py::strip(prompt).empty()) {
|
||||
if (sel_bot.has_value() && *sel_bot != bot) { switch_bot(*sel_bot, true); }
|
||||
std::string confirm = mention_confirm(sel_provider.value_or(""), sel_model.value_or(""), sel_bot.value_or(""), cleared, directory);
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
namespace detz::cap { class CapMention; }
|
||||
namespace detz::agent {
|
||||
|
||||
// prompter(kind, text, options) -> answer; kind is "approve", "ask" or "ask".
|
||||
@@ -101,11 +102,16 @@ class Agent {
|
||||
// ---- mentions ----
|
||||
std::string mention_error(const std::string& tag);
|
||||
std::string mention_confirm(const std::string& provider, const std::string& model, const std::string& bot_sel,
|
||||
bool cleared, const std::string& directory);
|
||||
bool cleared, const std::string& directory, const std::string& agent_sel = "");
|
||||
// (prompt text or nullopt, result or nullopt)
|
||||
std::pair<std::optional<std::string>, std::optional<std::string>> apply_mentions(const std::string& text, bool capture = false,
|
||||
int max_steps = MAX_STEPS);
|
||||
ResolvedTag resolve_tag(const std::string& tag, const std::string& directory);
|
||||
// CAP agent mentions: @agent, @agent:model, or a bare model id unique to one agent.
|
||||
detz::cap::CapMention& cap_mention();
|
||||
std::string run_agent(const std::string& agent, const std::string& model, const std::string& rest,
|
||||
const std::string& original, bool loud = false);
|
||||
std::string agent_pin; // "agent[:model]" pinned by a bare mention, cleared by @auto
|
||||
|
||||
// ---- the loop ----
|
||||
std::string run_turn(const std::string& text, bool capture = false, int max_steps = MAX_STEPS, bool routed = false);
|
||||
@@ -130,6 +136,7 @@ class Agent {
|
||||
int64_t turn_tools_{0};
|
||||
struct LastEvent { std::string name, verb, target; };
|
||||
std::optional<LastEvent> last_event_;
|
||||
std::shared_ptr<detz::cap::CapMention> cap_mention_;
|
||||
};
|
||||
|
||||
int& total_step_cap(); // Consts.TOTAL_STEP_CAP, live-patchable
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
#include "cap_mention.h"
|
||||
|
||||
namespace detz::cap {
|
||||
|
||||
CapMention::~CapMention() { closeAll(); }
|
||||
|
||||
void CapMention::closeAll() {
|
||||
for (auto& s : sessions_) {
|
||||
try { s.second->close(); } catch (...) {}
|
||||
}
|
||||
sessions_.clear();
|
||||
}
|
||||
|
||||
std::string CapMention::ask(const CapSelection& sel, const std::string& prompt, const std::string& cwd) {
|
||||
const std::string key = sel.agent + "\n" + sel.model + "\n" + cwd;
|
||||
const std::string who = sel.agent + (sel.model.empty() ? "" : ":" + sel.model);
|
||||
try {
|
||||
auto it = sessions_.find(key);
|
||||
if (it == sessions_.end()) {
|
||||
CapManifest manifest = catalog_.manifest(sel.agent);
|
||||
const std::string urn = "cap://" + sel.agent + "-1";
|
||||
auto session = std::make_unique<CapSession>(urn, CapDriver::create(manifest, urn, cwd, sel.model));
|
||||
session->start();
|
||||
it = sessions_.emplace(key, std::move(session)).first;
|
||||
}
|
||||
CapTurnResult result = it->second->ask(prompt);
|
||||
if (result.state() != "done" && result.text().empty()) {
|
||||
sessions_.erase(key);
|
||||
return "cap agent " + who + " ended the turn in state '" + result.state() + "' without an answer";
|
||||
}
|
||||
return result.text();
|
||||
} catch (const std::exception& exc) {
|
||||
sessions_.erase(key);
|
||||
return "cap agent " + who + " failed: " + exc.what() + "\n" + catalog_.report();
|
||||
} catch (...) {
|
||||
sessions_.erase(key);
|
||||
return "cap agent " + who + " failed with an unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace detz::cap
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
// CapMention: runs a turn on an external CAP agent picked by an `@agent[:model]`
|
||||
// mention. Owns the catalog and one live session per (agent, model); a failed
|
||||
// session is dropped so the next mention starts clean. Never throws.
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "cap_model_catalog.h"
|
||||
#include "cap_session.h"
|
||||
|
||||
namespace detz::cap {
|
||||
|
||||
class CapMention {
|
||||
public:
|
||||
CapMention() = default;
|
||||
~CapMention();
|
||||
CapModelCatalog& catalog() { return catalog_; }
|
||||
// Returns the agent's answer, or a one-line failure text prefixed "cap agent".
|
||||
std::string ask(const CapSelection& sel, const std::string& prompt, const std::string& cwd);
|
||||
void closeAll();
|
||||
|
||||
private:
|
||||
CapModelCatalog catalog_;
|
||||
std::map<std::string, std::unique_ptr<CapSession>> sessions_;
|
||||
};
|
||||
|
||||
} // namespace detz::cap
|
||||
@@ -0,0 +1,304 @@
|
||||
#include "cap_model_catalog.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <future>
|
||||
#include <set>
|
||||
|
||||
#include "cap_error.h"
|
||||
#include "wire_os.h"
|
||||
#include "wire_proc.h"
|
||||
|
||||
namespace detz::cap {
|
||||
|
||||
using detz::wire::WireOs;
|
||||
using detz::wire::WireProc;
|
||||
|
||||
namespace {
|
||||
|
||||
std::string lower(std::string s) {
|
||||
for (char& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string trim(const std::string& s) {
|
||||
size_t a = s.find_first_not_of(" \t\r\n");
|
||||
if (a == std::string::npos) return "";
|
||||
return s.substr(a, s.find_last_not_of(" \t\r\n") - a + 1);
|
||||
}
|
||||
|
||||
std::string idOf(const Json& item) {
|
||||
if (item.isString()) return trim(item.str());
|
||||
if (!item.isObject()) return "";
|
||||
for (const char* key : {"id", "name", "model"}) {
|
||||
Json v = item.get(key);
|
||||
if (v.isString() && !trim(v.str()).empty()) return trim(v.str());
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
void addUnique(std::vector<std::string>& into, const std::string& id) {
|
||||
if (id.empty() || into.size() >= CAP_MODEL_MAX) return;
|
||||
if (std::find(into.begin(), into.end(), id) == into.end()) into.push_back(id);
|
||||
}
|
||||
|
||||
std::string quoted(const std::string& s) { return "'" + s + "'"; }
|
||||
|
||||
} // namespace
|
||||
|
||||
CapModelCatalog::CapModelCatalog(CapRegistry registry, Prober prober)
|
||||
: registry_(std::move(registry)), prober_(prober ? std::move(prober) : Prober(&CapModelCatalog::defaultProbe)) {}
|
||||
|
||||
std::string CapModelCatalog::defaultProbe(const std::vector<std::string>& argv, double timeout) {
|
||||
WireProc::Options options;
|
||||
options.argv = argv;
|
||||
options.in = WireProc::Stream::Devnull;
|
||||
WireProc::Completed done = WireProc::run(options, timeout);
|
||||
if (done.returncode != 0) throw std::runtime_error("exited " + std::to_string(done.returncode));
|
||||
if (done.out.size() > CAP_MODEL_PROBE_MAX_BYTES) done.out.resize(CAP_MODEL_PROBE_MAX_BYTES);
|
||||
return done.out;
|
||||
}
|
||||
|
||||
std::vector<std::string> CapModelCatalog::agentNames() const {
|
||||
std::set<std::string> names;
|
||||
for (const std::string& dir : {registry_.userDir(), registry_.systemDir()}) {
|
||||
try {
|
||||
for (const std::string& file : WireOs::listdir(dir)) {
|
||||
if (file.size() > 5 && file.compare(file.size() - 5, 5, ".toml") == 0) names.insert(file.substr(0, file.size() - 5));
|
||||
}
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
std::vector<std::string> out(names.begin(), names.end());
|
||||
if (out.size() > CAP_AGENT_MAX) out.resize(CAP_AGENT_MAX);
|
||||
return out;
|
||||
}
|
||||
|
||||
CapManifest CapModelCatalog::manifest(const std::string& name) const { return registry_.resolve(name); }
|
||||
|
||||
void CapModelCatalog::probeInto(CapAgentEntry& e, const CapManifest& m) const {
|
||||
Json list = m.get("models.list");
|
||||
if (list.isArray()) for (const Json& item : list.items()) addUnique(e.models, idOf(item));
|
||||
Json def = m.get("models.default");
|
||||
if (def.isString()) e.defaultModel = trim(def.str());
|
||||
bool fromManifest = !e.models.empty();
|
||||
std::vector<std::string> argv;
|
||||
Json probe = m.get("models.probe");
|
||||
if (probe.isArray()) {
|
||||
for (const Json& a : probe.items()) if (a.isString()) argv.push_back(a.str());
|
||||
} else if (m.get("capabilities.models").truthy()) {
|
||||
argv = {e.binary, "--cap-models"};
|
||||
}
|
||||
bool fromProbe = false;
|
||||
if (!argv.empty()) {
|
||||
try {
|
||||
std::string out = prober_(argv, CAP_MODEL_PROBE_TIMEOUT);
|
||||
std::vector<std::string> found;
|
||||
std::string text = trim(out), dflt;
|
||||
Json parsed;
|
||||
bool isJson = false;
|
||||
if (!text.empty() && (text[0] == '[' || text[0] == '{')) {
|
||||
try { parsed = Json::parse(text); isJson = true; } catch (...) {}
|
||||
}
|
||||
if (isJson) {
|
||||
const Json* arr = parsed.isArray() ? &parsed : parsed.find("models");
|
||||
if (parsed.isObject()) {
|
||||
Json d = parsed.get("default");
|
||||
if (d.isString()) dflt = trim(d.str());
|
||||
}
|
||||
if (arr != nullptr && arr->isArray()) for (const Json& item : arr->items()) addUnique(found, idOf(item));
|
||||
} else {
|
||||
size_t pos = 0;
|
||||
while (pos <= text.size()) {
|
||||
size_t nl = text.find('\n', pos);
|
||||
std::string line = trim(text.substr(pos, nl == std::string::npos ? std::string::npos : nl - pos));
|
||||
if (!line.empty() && line[0] != '#') addUnique(found, line);
|
||||
if (nl == std::string::npos) break;
|
||||
pos = nl + 1;
|
||||
}
|
||||
}
|
||||
if (found.empty()) {
|
||||
e.modelError = "probe " + quoted(argv.back()) + " printed no models";
|
||||
} else {
|
||||
for (const std::string& id : found) addUnique(e.models, id);
|
||||
if (e.defaultModel.empty()) e.defaultModel = dflt;
|
||||
fromProbe = true;
|
||||
}
|
||||
} catch (const std::exception& exc) {
|
||||
e.modelError = "probe failed: " + std::string(exc.what());
|
||||
} catch (...) {
|
||||
e.modelError = "probe failed";
|
||||
}
|
||||
}
|
||||
e.modelSource = fromManifest && fromProbe ? "manifest+probe" : fromProbe ? "probe" : fromManifest ? "manifest" : "none";
|
||||
}
|
||||
|
||||
CapAgentEntry CapModelCatalog::load(const std::string& name) const {
|
||||
CapAgentEntry e;
|
||||
e.name = name;
|
||||
e.loadedAt = WireOs::now();
|
||||
e.modelSource = "none";
|
||||
try {
|
||||
CapManifest m = registry_.resolve(name);
|
||||
e.manifestSource = m.source();
|
||||
e.binary = m.binary();
|
||||
try { e.binding = m.binding(); } catch (const std::exception& exc) { e.binding = "none (" + std::string(exc.what()) + ")"; }
|
||||
if (WireOs::which(e.binary).empty()) {
|
||||
e.status = "no_binary";
|
||||
e.error = "binary " + quoted(e.binary) + " is not on PATH or not executable";
|
||||
try { probeInto(e, m); } catch (...) {} // static list still helps the report
|
||||
e.modelError.clear();
|
||||
return e;
|
||||
}
|
||||
e.status = "ready";
|
||||
probeInto(e, m);
|
||||
} catch (const CapError& exc) {
|
||||
e.status = exc.symbol == "cap_manifest_missing" ? "no_manifest" : "bad_manifest";
|
||||
e.error = exc.what();
|
||||
} catch (const std::exception& exc) {
|
||||
e.status = "bad_manifest";
|
||||
e.error = exc.what();
|
||||
} catch (...) {
|
||||
e.status = "bad_manifest";
|
||||
e.error = "unknown failure while loading the manifest";
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
CapAgentEntry CapModelCatalog::entry(const std::string& name) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
auto it = cache_.find(name);
|
||||
if (it != cache_.end() && WireOs::now() - it->second.loadedAt < CAP_CATALOG_TTL) return it->second;
|
||||
}
|
||||
CapAgentEntry e = load(name);
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
cache_[name] = e;
|
||||
return e;
|
||||
}
|
||||
|
||||
std::vector<CapAgentEntry> CapModelCatalog::entries() {
|
||||
std::vector<std::future<CapAgentEntry>> jobs;
|
||||
for (const std::string& n : agentNames()) jobs.push_back(std::async(std::launch::async, [this, n] { return entry(n); }));
|
||||
std::vector<CapAgentEntry> out;
|
||||
for (auto& job : jobs) out.push_back(job.get());
|
||||
return out;
|
||||
}
|
||||
|
||||
void CapModelCatalog::invalidate() {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
cache_.clear();
|
||||
}
|
||||
|
||||
std::string CapModelCatalog::findName(const std::string& word) const {
|
||||
std::string w = lower(trim(word));
|
||||
if (w.empty()) return "";
|
||||
for (const std::string& n : agentNames()) if (lower(n) == w) return n;
|
||||
return "";
|
||||
}
|
||||
|
||||
bool CapModelCatalog::isAgent(const std::string& spec) const {
|
||||
size_t colon = spec.find(':');
|
||||
return !findName(colon == std::string::npos ? spec : spec.substr(0, colon)).empty();
|
||||
}
|
||||
|
||||
// Exact, case-insensitive exact, unique prefix, unique substring. Returns the
|
||||
// id, or "" with `problem` set (ambiguity or no match).
|
||||
std::string CapModelCatalog::match(const CapAgentEntry& e, const std::string& want, std::string& problem) {
|
||||
problem.clear();
|
||||
for (const std::string& id : e.models) if (id == want) return id;
|
||||
std::string w = lower(want);
|
||||
for (const std::string& id : e.models) if (lower(id) == w) return id;
|
||||
for (int mode = 0; mode < 2; ++mode) {
|
||||
std::vector<std::string> hits;
|
||||
for (const std::string& id : e.models) {
|
||||
std::string l = lower(id);
|
||||
if (mode == 0 ? l.compare(0, w.size(), w) == 0 : l.find(w) != std::string::npos) hits.push_back(id);
|
||||
}
|
||||
if (hits.size() == 1) return hits[0];
|
||||
if (hits.size() > 1) {
|
||||
problem = "model " + quoted(want) + " is ambiguous on " + e.name + ": ";
|
||||
for (size_t i = 0; i < hits.size() && i < 10; ++i) problem += (i ? ", " : "") + hits[i];
|
||||
return "";
|
||||
}
|
||||
}
|
||||
problem = "agent " + quoted(e.name) + " has no model " + quoted(want);
|
||||
return "";
|
||||
}
|
||||
|
||||
CapSelection CapModelCatalog::resolve(const std::string& spec) {
|
||||
CapSelection sel;
|
||||
std::string s = trim(spec);
|
||||
auto fail = [&](const std::string& why) {
|
||||
sel.ok = false;
|
||||
sel.error = why + "\n" + report();
|
||||
return sel;
|
||||
};
|
||||
if (s.empty()) return fail("empty agent mention");
|
||||
size_t colon = s.find(':');
|
||||
std::string head = colon == std::string::npos ? s : s.substr(0, colon);
|
||||
std::string name = findName(head);
|
||||
if (!name.empty()) {
|
||||
CapAgentEntry e = entry(name);
|
||||
if (!e.ready()) return fail("agent " + quoted(name) + " is not usable: " + e.error);
|
||||
sel.agent = name;
|
||||
std::string want = colon == std::string::npos ? "" : trim(s.substr(colon + 1));
|
||||
if (want.empty()) { sel.ok = true; return sel; }
|
||||
if (e.models.empty()) {
|
||||
sel.ok = true;
|
||||
sel.model = want;
|
||||
sel.note = "agent " + name + " publishes no model list" + (e.modelError.empty() ? "" : " (" + e.modelError + ")") +
|
||||
", " + quoted(want) + " passed through unverified";
|
||||
return sel;
|
||||
}
|
||||
std::string problem, id = match(e, want, problem);
|
||||
if (id.empty()) return fail(problem);
|
||||
sel.ok = true;
|
||||
sel.model = id;
|
||||
return sel;
|
||||
}
|
||||
if (colon != std::string::npos) return fail("unknown agent " + quoted(head));
|
||||
// Bare model: valid when exactly one ready agent offers it.
|
||||
std::vector<std::pair<std::string, std::string>> hits;
|
||||
for (const CapAgentEntry& e : entries()) {
|
||||
if (!e.ready() || e.models.empty()) continue;
|
||||
std::string problem, id = match(e, s, problem);
|
||||
if (!id.empty()) hits.push_back({e.name, id});
|
||||
}
|
||||
if (hits.size() == 1) {
|
||||
sel.ok = true;
|
||||
sel.agent = hits[0].first;
|
||||
sel.model = hits[0].second;
|
||||
return sel;
|
||||
}
|
||||
if (hits.size() > 1) {
|
||||
std::string who;
|
||||
for (auto& h : hits) who += (who.empty() ? "" : ", ") + h.first + ":" + h.second;
|
||||
return fail("model " + quoted(s) + " exists on several agents, pick one: " + who);
|
||||
}
|
||||
return fail("no agent or model matches " + quoted(s));
|
||||
}
|
||||
|
||||
std::string CapModelCatalog::formatReport(const std::vector<CapAgentEntry>& all) {
|
||||
if (all.empty()) return "cap agents: none found (put <name>.toml manifests in ~/.config/cap/agents/)";
|
||||
std::string out = "cap agents (" + std::to_string(all.size()) + "), use @agent or @agent:model:";
|
||||
for (const CapAgentEntry& e : all) {
|
||||
out += "\n " + e.name + " [" + e.status + "]";
|
||||
if (!e.binding.empty()) out += " binding=" + e.binding;
|
||||
if (!e.binary.empty()) out += " binary=" + e.binary;
|
||||
if (!e.manifestSource.empty()) out += " manifest=" + e.manifestSource;
|
||||
if (!e.error.empty()) out += "\n problem: " + e.error;
|
||||
if (e.models.empty()) {
|
||||
out += "\n models: none known" + (e.modelError.empty() ? std::string(" (any id is passed through)") : " (" + e.modelError + ")");
|
||||
} else {
|
||||
out += "\n models (" + std::to_string(e.models.size()) + ", from " + e.modelSource + "): ";
|
||||
for (size_t i = 0; i < e.models.size(); ++i) out += (i ? ", " : "") + e.models[i];
|
||||
if (!e.defaultModel.empty()) out += "\n default: " + e.defaultModel;
|
||||
if (!e.modelError.empty()) out += "\n probe: " + e.modelError;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string CapModelCatalog::report() { return formatReport(entries()); }
|
||||
|
||||
} // namespace detz::cap
|
||||
@@ -0,0 +1,86 @@
|
||||
#pragma once
|
||||
// CapModelCatalog: which CAP agents exist on this machine and which models each
|
||||
// of them offers, so `@agent` and `@agent:model` mentions resolve in one place.
|
||||
//
|
||||
// Model discovery protocol (docs/cap.md "Model catalog"):
|
||||
// 1. `[models] list = [...]` and `default = "..."` in the agent manifest.
|
||||
// 2. `[models] probe = ["bin", "--cap-models"]` (or `capabilities.models = true`
|
||||
// for `<binary> --cap-models`): the agent prints a JSON array of ids or
|
||||
// objects {id|name|model}, or {"models": [...], "default": "..."}, or one id
|
||||
// per line. Probes are bounded by CAP_MODEL_PROBE_TIMEOUT seconds.
|
||||
// Every failure lands in the agent's entry (status, error); nothing throws out.
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "cap_registry.h"
|
||||
|
||||
namespace detz::cap {
|
||||
|
||||
inline constexpr double CAP_MODEL_PROBE_TIMEOUT = 5.0; // seconds per probe
|
||||
inline constexpr size_t CAP_MODEL_PROBE_MAX_BYTES = 1u << 20;
|
||||
inline constexpr size_t CAP_MODEL_MAX = 500; // models kept per agent
|
||||
inline constexpr size_t CAP_AGENT_MAX = 64; // agents kept in the catalog
|
||||
inline constexpr double CAP_CATALOG_TTL = 60.0; // seconds an entry stays cached
|
||||
|
||||
struct CapAgentEntry {
|
||||
std::string name;
|
||||
std::string status; // ready | no_binary | bad_manifest | no_manifest
|
||||
std::string error; // why status is not ready
|
||||
std::string binary;
|
||||
std::string manifestSource;
|
||||
std::string binding;
|
||||
std::vector<std::string> models;
|
||||
std::string defaultModel;
|
||||
std::string modelSource; // manifest | probe | manifest+probe | none
|
||||
std::string modelError; // probe failure, empty when fine
|
||||
double loadedAt{0};
|
||||
bool ready() const { return status == "ready"; }
|
||||
};
|
||||
|
||||
struct CapSelection {
|
||||
bool ok{false};
|
||||
std::string agent;
|
||||
std::string model; // empty: the agent's own default
|
||||
std::string note; // set when the model could not be verified
|
||||
std::string error; // detailed report when !ok
|
||||
};
|
||||
|
||||
class CapModelCatalog {
|
||||
public:
|
||||
// Runs argv, returns stdout; throws std::exception on failure or timeout.
|
||||
using Prober = std::function<std::string(const std::vector<std::string>& argv, double timeout)>;
|
||||
|
||||
explicit CapModelCatalog(CapRegistry registry = CapRegistry(), Prober prober = nullptr);
|
||||
|
||||
std::vector<std::string> agentNames() const;
|
||||
CapAgentEntry entry(const std::string& name);
|
||||
std::vector<CapAgentEntry> entries();
|
||||
CapManifest manifest(const std::string& name) const;
|
||||
|
||||
// True when the first word of `spec` (before ':') names a catalog agent.
|
||||
bool isAgent(const std::string& spec) const;
|
||||
// `agent`, `agent:model`, or a bare model id found on exactly one agent.
|
||||
CapSelection resolve(const std::string& spec);
|
||||
// Detailed per-agent listing used by every failure message.
|
||||
std::string report();
|
||||
static std::string formatReport(const std::vector<CapAgentEntry>& all);
|
||||
|
||||
void invalidate();
|
||||
|
||||
private:
|
||||
static std::string defaultProbe(const std::vector<std::string>& argv, double timeout);
|
||||
CapAgentEntry load(const std::string& name) const;
|
||||
void probeInto(CapAgentEntry& e, const CapManifest& m) const;
|
||||
static std::string match(const CapAgentEntry& e, const std::string& want, std::string& problem);
|
||||
std::string findName(const std::string& word) const;
|
||||
|
||||
CapRegistry registry_;
|
||||
Prober prober_;
|
||||
std::mutex mu_;
|
||||
std::map<std::string, CapAgentEntry> cache_;
|
||||
};
|
||||
|
||||
} // namespace detz::cap
|
||||
@@ -0,0 +1,74 @@
|
||||
// CapModelCatalog: agent checks, model protocol (manifest list + probe), matching, reports.
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
#include "cap_model_catalog.h"
|
||||
#include "wire_os.h"
|
||||
|
||||
using namespace detz::cap;
|
||||
|
||||
static int failures = 0;
|
||||
#define CHECK(c) do { if (!(c)) { std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #c); ++failures; } } while (0)
|
||||
|
||||
static void put(const std::string& dir, const std::string& name, const std::string& binary, const std::string& extra) {
|
||||
std::ofstream(dir + "/" + name + ".toml") << "[agent]\nname = \"" << name << "\"\nbinary = \"" << binary
|
||||
<< "\"\nprofiles = [\"coding\"]\n[startup]\ncommand = [\"" << binary << "\"]\nready_when = {pattern = \"x\"}\n"
|
||||
<< "[pty]\ncols = 80\nrows = 24\n[capabilities]\nstreaming_output = true\n" << extra;
|
||||
}
|
||||
|
||||
int main() {
|
||||
char tmpl[] = "/tmp/capcatXXXXXX";
|
||||
std::string dir = mkdtemp(tmpl);
|
||||
put(dir, "alpha", "sh", "[models]\nlist = [\"alpha-large\", \"alpha-small\", \"shared-1\"]\ndefault = \"alpha-small\"\n");
|
||||
put(dir, "beta", "sh", "[models]\nprobe = [\"beta\", \"--cap-models\"]\n");
|
||||
put(dir, "gone", "no-such-binary-xyz", "");
|
||||
std::ofstream(dir + "/bad.toml") << "garbage\n";
|
||||
int probes = 0;
|
||||
CapModelCatalog cat(CapRegistry(dir, dir + "/none"), [&](const std::vector<std::string>& argv, double) -> std::string {
|
||||
++probes;
|
||||
if (argv[0] != "beta") throw std::runtime_error("unexpected probe");
|
||||
return "{\"models\":[{\"id\":\"beta-one\"},\"shared-1\"],\"default\":\"beta-one\"}";
|
||||
});
|
||||
|
||||
CHECK(cat.agentNames().size() == 4);
|
||||
CHECK(cat.entry("alpha").ready() && cat.entry("alpha").models.size() == 3);
|
||||
CHECK(cat.entry("beta").modelSource == "probe" && cat.entry("beta").defaultModel == "beta-one");
|
||||
CHECK(cat.entry("gone").status == "no_binary");
|
||||
CHECK(cat.entry("bad").status == "bad_manifest");
|
||||
CHECK(probes == 1); // cached
|
||||
|
||||
CapSelection s = cat.resolve("alpha");
|
||||
CHECK(s.ok && s.agent == "alpha" && s.model.empty());
|
||||
s = cat.resolve("ALPHA:alpha-s");
|
||||
CHECK(s.ok && s.model == "alpha-small"); // case-insensitive agent, unique prefix
|
||||
s = cat.resolve("alpha:alpha");
|
||||
CHECK(!s.ok && s.error.find("ambiguous") != std::string::npos);
|
||||
s = cat.resolve("alpha:zzz");
|
||||
CHECK(!s.ok && s.error.find("alpha [ready]") != std::string::npos && s.error.find("beta-one") != std::string::npos);
|
||||
s = cat.resolve("gone");
|
||||
CHECK(!s.ok && s.error.find("not on PATH") != std::string::npos && s.error.find("bad [bad_manifest]") != std::string::npos);
|
||||
s = cat.resolve("beta-one"); // bare model, unique agent
|
||||
CHECK(s.ok && s.agent == "beta" && s.model == "beta-one");
|
||||
s = cat.resolve("shared-1"); // on two agents
|
||||
CHECK(!s.ok && s.error.find("several agents") != std::string::npos);
|
||||
s = cat.resolve("nothing");
|
||||
CHECK(!s.ok && s.error.find("cap agents (4)") != std::string::npos);
|
||||
CHECK(cat.isAgent("alpha:x") && !cat.isAgent("x"));
|
||||
|
||||
put(dir, "open", "sh", ""); // no list: pass through with a note
|
||||
cat.invalidate();
|
||||
s = cat.resolve("open:anything");
|
||||
CHECK(s.ok && s.model == "anything" && !s.note.empty());
|
||||
|
||||
CapModelCatalog failing(CapRegistry(dir, dir + "/none"), [](const std::vector<std::string>&, double) -> std::string {
|
||||
throw std::runtime_error("timed out");
|
||||
});
|
||||
CapAgentEntry b = failing.entry("beta");
|
||||
CHECK(b.ready() && b.models.empty() && b.modelError.find("timed out") != std::string::npos);
|
||||
|
||||
if (std::system(("rm -rf " + dir).c_str()) != 0) std::printf("cleanup failed\n");
|
||||
std::printf(failures ? "FAILED %d\n" : "cap_model_catalog_test OK\n", failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user