From c5acc2249f01d111cacb6ddf21e0caf976f66cb5 Mon Sep 17 00:00:00 2001 From: retoor Date: Sun, 25 Oct 2026 18:01:34 +0100 Subject: [PATCH] cap: resolve @agent and @agent:model mentions via CapModelCatalog Co-Authored-By: Claude Sonnet 5.5 --- taicpp/CHANGELOG.md | 1 + taicpp/Makefile | 1 + taicpp/docs/cap.md | 10 + taicpp/src/agent/agent.cpp | 72 +++++- taicpp/src/agent/agent.h | 9 +- taicpp/src/cap/cap_mention.cpp | 41 ++++ taicpp/src/cap/cap_mention.h | 28 +++ taicpp/src/cap/cap_model_catalog.cpp | 304 ++++++++++++++++++++++++ taicpp/src/cap/cap_model_catalog.h | 86 +++++++ taicpp/tests/cap_model_catalog_test.cpp | 74 ++++++ 10 files changed, 620 insertions(+), 6 deletions(-) create mode 100644 taicpp/src/cap/cap_mention.cpp create mode 100644 taicpp/src/cap/cap_mention.h create mode 100644 taicpp/src/cap/cap_model_catalog.cpp create mode 100644 taicpp/src/cap/cap_model_catalog.h create mode 100644 taicpp/tests/cap_model_catalog_test.cpp diff --git a/taicpp/CHANGELOG.md b/taicpp/CHANGELOG.md index f9b6b8d..8c9365c 100644 --- a/taicpp/CHANGELOG.md +++ b/taicpp/CHANGELOG.md @@ -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. diff --git a/taicpp/Makefile b/taicpp/Makefile index 109d591..63de422 100644 --- a/taicpp/Makefile +++ b/taicpp/Makefile @@ -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))) diff --git a/taicpp/docs/cap.md b/taicpp/docs/cap.md index 248d9ce..2cf3ba1 100644 --- a/taicpp/docs/cap.md +++ b/taicpp/docs/cap.md @@ -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 ` --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), diff --git a/taicpp/src/agent/agent.cpp b/taicpp/src/agent/agent.cpp index d1c207c..b4d429d 100644 --- a/taicpp/src/agent/agent.cpp +++ b/taicpp/src/agent/agent.cpp @@ -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 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(); } + 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 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> Agent::apply_m std::optional sel_provider, sel_model, sel_bot; bool cleared = false; std::vector files; + std::optional> 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> 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); diff --git a/taicpp/src/agent/agent.h b/taicpp/src/agent/agent.h index f2c70ed..c1ee538 100644 --- a/taicpp/src/agent/agent.h +++ b/taicpp/src/agent/agent.h @@ -17,6 +17,7 @@ #include #include #include +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> 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 last_event_; + std::shared_ptr cap_mention_; }; int& total_step_cap(); // Consts.TOTAL_STEP_CAP, live-patchable diff --git a/taicpp/src/cap/cap_mention.cpp b/taicpp/src/cap/cap_mention.cpp new file mode 100644 index 0000000..cad6ec2 --- /dev/null +++ b/taicpp/src/cap/cap_mention.cpp @@ -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(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 diff --git a/taicpp/src/cap/cap_mention.h b/taicpp/src/cap/cap_mention.h new file mode 100644 index 0000000..a0b2913 --- /dev/null +++ b/taicpp/src/cap/cap_mention.h @@ -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 +#include +#include + +#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> sessions_; +}; + +} // namespace detz::cap diff --git a/taicpp/src/cap/cap_model_catalog.cpp b/taicpp/src/cap/cap_model_catalog.cpp new file mode 100644 index 0000000..c47b590 --- /dev/null +++ b/taicpp/src/cap/cap_model_catalog.cpp @@ -0,0 +1,304 @@ +#include "cap_model_catalog.h" + +#include +#include +#include + +#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(std::tolower(static_cast(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& 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& 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 CapModelCatalog::agentNames() const { + std::set 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 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 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 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 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 lock(mu_); + cache_[name] = e; + return e; +} + +std::vector CapModelCatalog::entries() { + std::vector> jobs; + for (const std::string& n : agentNames()) jobs.push_back(std::async(std::launch::async, [this, n] { return entry(n); })); + std::vector out; + for (auto& job : jobs) out.push_back(job.get()); + return out; +} + +void CapModelCatalog::invalidate() { + std::lock_guard 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 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> 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& all) { + if (all.empty()) return "cap agents: none found (put .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 diff --git a/taicpp/src/cap/cap_model_catalog.h b/taicpp/src/cap/cap_model_catalog.h new file mode 100644 index 0000000..447b01a --- /dev/null +++ b/taicpp/src/cap/cap_model_catalog.h @@ -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 ` --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 +#include +#include +#include +#include + +#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 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& argv, double timeout)>; + + explicit CapModelCatalog(CapRegistry registry = CapRegistry(), Prober prober = nullptr); + + std::vector agentNames() const; + CapAgentEntry entry(const std::string& name); + std::vector 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& all); + + void invalidate(); + + private: + static std::string defaultProbe(const std::vector& 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 cache_; +}; + +} // namespace detz::cap diff --git a/taicpp/tests/cap_model_catalog_test.cpp b/taicpp/tests/cap_model_catalog_test.cpp new file mode 100644 index 0000000..98648ac --- /dev/null +++ b/taicpp/tests/cap_model_catalog_test.cpp @@ -0,0 +1,74 @@ +// CapModelCatalog: agent checks, model protocol (manifest list + probe), matching, reports. +#include +#include +#include +#include + +#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& 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&, 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; +}