Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/api/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions include/event_routing.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
38 changes: 36 additions & 2 deletions src/api_server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<std::pair<std::string, std::vector<std::string>>>
Expand All @@ -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.
Expand All @@ -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,
Expand Down
57 changes: 57 additions & 0 deletions src/event_routing.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

#include "event_routing.h"
#include "constitution.h"
#include "json.h"

#include <cstdio>
#include <fstream>
#include <filesystem>
#include <fnmatch.h>
#include <sstream>
#include <utility>
#include <vector>

Expand Down Expand Up @@ -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 {};
}
Comment thread
cursor[bot] marked this conversation as resolved.

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
101 changes: 101 additions & 0 deletions tests/test_route_event.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "doctest.h"
#include "event_routing.h"
#include "json.h"

#include <cstdlib>
#include <filesystem>
Expand Down Expand Up @@ -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);
}
Loading