cap: resolve @agent and @agent:model mentions via CapModelCatalog

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 18:01:34 +01:00
co-authored by Claude Sonnet 5.5
parent 94c973d782
commit c5acc2249f
10 changed files with 620 additions and 6 deletions
+1
View File
@@ -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.
+1
View File
@@ -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)))
+10
View File
@@ -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),
+67 -5
View File
@@ -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);
+8 -1
View File
@@ -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
+41
View File
@@ -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
+28
View File
@@ -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
+304
View File
@@ -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
+86
View File
@@ -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
+74
View File
@@ -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;
}