diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fc4e3ab..7f15e3e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ loosely while pre-1.0 (breaking changes can land on minor bumps). ## [Unreleased] ### Fixed +- **Event ingest hydrates the routed constitution.** `POST /v1/events` + attaches the selected agent's `agent_def` on the synthetic orchestrate + body. File-backed matches and tenant agents off the newest-200 catalog + page no longer 404 `agent not found`. When a file-backed id collides + with a tenant row, the file constitution is the one that runs (file + routing won). - **Nested artifact routes honor `:cid`.** `GET`/`DELETE` `/v1/conversations/:id/artifacts/:aid` (and `/raw`) 404 when the conversation is missing or TUI-origin, or when the artifact belongs diff --git a/docs/api/events.md b/docs/api/events.md index bebaa376..53d88d13 100644 --- a/docs/api/events.md +++ b/docs/api/events.md @@ -62,6 +62,8 @@ After that resolution, the same [intent engine](../concepts/intent.md) that fron Keep routing patterns distinct. If multiple agents in the same tier match an event, the first match in scan order wins (directory iteration for files; sorted `agent_id` for tenant agents). +After routing, Arbiter attaches that agent's constitution as inline `agent_def` on the orchestrate request. `/v1/orchestrate` only preloads the newest-200 tenant catalog and does not read disk-side `*.json` definitions, so without the hydrate a file-backed match or a tenant agent off that page would 404 `agent not found`. If a file-backed id collides with a tenant-stored row, the file constitution is the one that runs — file routing already won. + ## What the agent receives Arbiter presents the event to the selected agent as a normal turn: diff --git a/include/event_routing.h b/include/event_routing.h index a3425074..21ca6c66 100644 --- a/include/event_routing.h +++ b/include/event_routing.h @@ -30,4 +30,24 @@ std::string route_event( std::string route_event(const std::string& agents_dir, const std::string& event_type); +// Constitution JSON blob for the file-backed agent whose routing id +// (filename stem, or Constitution::name when it is a distinct id) equals +// agent_id. Empty if none match. POST /v1/events uses this to install the +// routed file agent as inline agent_def — handle_orchestrate only preloads +// the newest-200 tenant catalog and does not read agents_dir. +std::string file_backed_agent_def_json(const std::string& agents_dir, + const std::string& agent_id); + +// Constitution blob that POST /v1/events should attach as agent_def so +// orchestrate runs the agent routing selected (and stamps "id" to match +// the override). File-backed glob wins even when a tenant row shares the +// id — those can be different constitutions. Otherwise prefer the tenant +// blob (explicit agent / tenant routing), then fall back to agents_dir. +// Empty for "index" or when nothing can be loaded. +std::string event_ingest_agent_def_json( + const std::string& agent_id, + const std::string& agents_dir, + const std::string& tenant_def_json, + bool routed_from_file); + } // namespace arbiter diff --git a/src/api_server.cpp b/src/api_server.cpp index e0d16179..122d9311 100644 --- a/src/api_server.cpp +++ b/src/api_server.cpp @@ -23,6 +23,7 @@ #include "commands.h" #include "config.h" #include "constitution.h" +#include "event_routing.h" #include "context_compaction.h" #include "file_cap.h" #include "json.h" @@ -10922,10 +10923,17 @@ void handle_event_ingest(int fd, HttpRequest req, // Route: explicit override wins; otherwise file-backed agents first, // then tenant-stored agents (POST /v1/agents) by stable agent_id order. + // Remember which tier won so we hydrate *that* constitution — a + // file-backed id can collide with a tenant row that handle_orchestrate + // would otherwise install from the newest-200 catalog. std::string agent_id = agent; + std::string tenant_def_json; + bool routed_from_file = false; if (agent_id.empty()) { agent_id = route_event(opts.agents_dir, event_type); - if (agent_id.empty()) { + if (!agent_id.empty()) { + routed_from_file = true; + } else { // Full scan ordered by agent_id — not the newest-200 REST page. auto records = tenants.list_agent_records_for_routing(tenant.id); std::vector>> @@ -10945,8 +10953,17 @@ void handle_event_ingest(int fd, HttpRequest req, } } std::string matched = route_event(tenant_agents, event_type); - agent_id = matched.empty() ? std::string("index") : matched; + if (matched.empty()) { + agent_id = "index"; + } else { + agent_id = matched; + if (auto rec = tenants.get_agent_record(tenant.id, agent_id)) { + tenant_def_json = rec->agent_def_json; + } + } } + } else if (auto rec = tenants.get_agent_record(tenant.id, agent_id)) { + tenant_def_json = rec->agent_def_json; } // Format event as a natural-language message the agent can reason about. @@ -10958,9 +10975,26 @@ void handle_event_ingest(int fd, HttpRequest req, // handle_orchestrate reads req.body["message"] and uses agent_override // to target the routed agent; everything else (SSE, auth, writ execution) // flows through the existing path unchanged. + // + // Attach the routed constitution as inline agent_def. Orchestrate only + // preloads list_agent_records(..., 200) and never reads agents_dir, so + // a file-backed match or a tenant agent off that page would otherwise + // 404 "agent not found" — or, on an id clash, run the tenant blob. auto synth = jobj(); synth->as_object_mut()["message"] = jstr(message); synth->as_object_mut()["intent_source"] = jstr("event"); + const std::string def_json = event_ingest_agent_def_json( + agent_id, opts.agents_dir, tenant_def_json, routed_from_file); + if (!def_json.empty()) { + try { + auto def = json_parse(def_json); + if (def && def->is_object()) { + synth->as_object_mut()["agent_def"] = def; + } + } catch (...) { + // Leave unhydrated; orchestrate reports agent not found. + } + } req.body = json_serialize(*synth); handle_orchestrate(fd, req, opts, tenants, in_flight, diff --git a/src/event_routing.cpp b/src/event_routing.cpp index a0679087..e932a752 100644 --- a/src/event_routing.cpp +++ b/src/event_routing.cpp @@ -2,10 +2,13 @@ #include "event_routing.h" #include "constitution.h" +#include "json.h" #include +#include #include #include +#include #include #include @@ -47,4 +50,58 @@ std::string route_event(const std::string& agents_dir, return matched; } +std::string file_backed_agent_def_json(const std::string& agents_dir, + const std::string& agent_id) { + if (agent_id.empty() || !fs::is_directory(agents_dir)) return {}; + for (auto& entry : fs::directory_iterator(agents_dir)) { + if (entry.path().extension() != ".json") continue; + try { + auto config = Constitution::from_file(entry.path().string()); + if (file_backed_agent_id(config, entry.path().stem().string()) + != agent_id) { + continue; + } + std::ifstream in(entry.path()); + if (!in.is_open()) return {}; + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); + } catch (const std::exception& e) { + std::fprintf(stderr, "WARN: file_backed_agent_def skip %s: %s\n", + entry.path().c_str(), e.what()); + } + } + return {}; +} + +static std::string stamp_agent_def_id(const std::string& agent_def_json, + const std::string& agent_id) { + if (agent_def_json.empty() || agent_id.empty()) return {}; + try { + auto def = json_parse(agent_def_json); + if (!def || !def->is_object()) return {}; + def->as_object_mut()["id"] = jstr(agent_id); + return json_serialize(*def); + } catch (...) { + return {}; + } +} + +std::string event_ingest_agent_def_json( + const std::string& agent_id, + const std::string& agents_dir, + const std::string& tenant_def_json, + bool routed_from_file) { + if (agent_id.empty() || agent_id == "index") return {}; + std::string blob; + if (routed_from_file) { + blob = file_backed_agent_def_json(agents_dir, agent_id); + } else if (!tenant_def_json.empty()) { + blob = tenant_def_json; + } else { + blob = file_backed_agent_def_json(agents_dir, agent_id); + } + return stamp_agent_def_id(blob, agent_id); +} + } // namespace arbiter diff --git a/tests/test_route_event.cpp b/tests/test_route_event.cpp index 7669fd12..a2b6cdd8 100644 --- a/tests/test_route_event.cpp +++ b/tests/test_route_event.cpp @@ -4,6 +4,7 @@ #define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN #include "doctest.h" #include "event_routing.h" +#include "json.h" #include #include @@ -81,3 +82,103 @@ TEST_CASE("route_event uses filename stem when name is Title Case") { CHECK(route_event(dir, "sensor.temp.high") == "scout"); fs::remove_all(dir); } + +TEST_CASE("file_backed_agent_def_json loads matching stem and stamps nothing") { + const std::string dir = make_temp_agents_dir(); + write_agent(dir, "facilities", R"({ + "name": "facilities", + "model": "ollama/qwen", + "goal": "watch sensors", + "event_types": ["sensor.*"] + })"); + write_agent(dir, "coder", R"({ + "name": "coder", + "model": "ollama/qwen", + "goal": "write code" + })"); + const std::string blob = file_backed_agent_def_json(dir, "facilities"); + REQUIRE_FALSE(blob.empty()); + auto j = json_parse(blob); + REQUIRE(j); + REQUIRE(j->is_object()); + CHECK(j->get_string("goal") == "watch sensors"); + CHECK(file_backed_agent_def_json(dir, "missing").empty()); + CHECK(file_backed_agent_def_json("/no/such/agents", "facilities").empty()); + fs::remove_all(dir); +} + +TEST_CASE("file_backed_agent_def_json uses filename stem for Title Case names") { + const std::string dir = make_temp_agents_dir(); + write_agent(dir, "scout", R"({ + "name": "Scout", + "model": "ollama/qwen", + "goal": "recon", + "event_types": ["sensor.*"] + })"); + CHECK(file_backed_agent_def_json(dir, "Scout").empty()); + const std::string blob = file_backed_agent_def_json(dir, "scout"); + REQUIRE_FALSE(blob.empty()); + CHECK(json_parse(blob)->get_string("goal") == "recon"); + fs::remove_all(dir); +} + +TEST_CASE("event_ingest_agent_def_json prefers file blob when file routing won") { + const std::string dir = make_temp_agents_dir(); + write_agent(dir, "facilities", R"({ + "name": "facilities", + "model": "ollama/qwen", + "goal": "file goal", + "event_types": ["sensor.*"] + })"); + const std::string tenant = R"({ + "name": "facilities", + "model": "anthropic/claude", + "goal": "tenant goal" + })"; + const std::string blob = event_ingest_agent_def_json( + "facilities", dir, tenant, /*routed_from_file=*/true); + REQUIRE_FALSE(blob.empty()); + auto j = json_parse(blob); + CHECK(j->get_string("id") == "facilities"); + CHECK(j->get_string("goal") == "file goal"); + fs::remove_all(dir); +} + +TEST_CASE("event_ingest_agent_def_json prefers tenant blob unless file routed") { + const std::string dir = make_temp_agents_dir(); + write_agent(dir, "facilities", R"({ + "name": "facilities", + "model": "ollama/qwen", + "goal": "file goal", + "event_types": ["sensor.*"] + })"); + const std::string tenant = R"({ + "name": "facilities", + "model": "anthropic/claude", + "goal": "tenant goal" + })"; + const std::string blob = event_ingest_agent_def_json( + "facilities", dir, tenant, /*routed_from_file=*/false); + auto j = json_parse(blob); + CHECK(j->get_string("id") == "facilities"); + CHECK(j->get_string("goal") == "tenant goal"); + fs::remove_all(dir); +} + +TEST_CASE("event_ingest_agent_def_json falls back to file for explicit miss") { + const std::string dir = make_temp_agents_dir(); + write_agent(dir, "facilities", R"({ + "name": "facilities", + "model": "ollama/qwen", + "goal": "file goal", + "event_types": ["sensor.*"] + })"); + const std::string blob = event_ingest_agent_def_json( + "facilities", dir, /*tenant_def_json=*/"", /*routed_from_file=*/false); + auto j = json_parse(blob); + CHECK(j->get_string("id") == "facilities"); + CHECK(j->get_string("goal") == "file goal"); + CHECK(event_ingest_agent_def_json("index", dir, "", false).empty()); + CHECK(event_ingest_agent_def_json("ghost", dir, "", false).empty()); + fs::remove_all(dir); +}