From 407bbd251a4a5125c6e924f6c8bd5bccc454f1a9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 06:27:26 +0000 Subject: [PATCH] Extra-fetch the targeted stored agent past the newest-200 catalog page GET /v1/agents/:id already used get_agent_record. Orchestrate, A2A, and the scheduler installed only list_agent_records(200), so a stored agent that fell off that page 404'd as not found. Dispatch now extra-fetches the targeted id; sibling /agent and /parallel still use the REST page. Co-authored-by: Tyler Reckart --- CHANGELOG.md | 9 ++++++ include/api_server.h | 6 +++- include/tenant_store.h | 8 +++++ src/api_server.cpp | 25 ++++++++++----- src/scheduler.cpp | 3 +- src/tenant_store.cpp | 14 +++++++++ tests/test_tenant_agents.cpp | 59 +++++++++++++++++++++++++++++++++++- 7 files changed, 114 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dae12b43..fda07a75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ loosely while pre-1.0 (breaking changes can land on minor bumps). ## [Unreleased] +### Fixed +- **Dispatch of stored agents past newest-200.** `GET /v1/agents/:id` already + used `get_agent_record`. `/v1/orchestrate`, `/v1/agents/:id/chat`, A2A + `message/send`/`message/stream`, and the scheduler installed only + `list_agent_records(200)` (newest `updated_at`), so a targeted id that + fell off that page returned `agent not found` while GET succeeded. + Dispatch now extra-fetches the targeted id. Sibling `/agent`/`/parallel` + still resolve from the newest-200 page. + ## [0.13.4] — 2026-09-10 ### Fixed diff --git a/include/api_server.h b/include/api_server.h index 4ddc7eb1..46b31da2 100644 --- a/include/api_server.h +++ b/include/api_server.h @@ -207,12 +207,16 @@ struct ApiServerOptions { // background Scheduler. The returned Orchestrator is fully self-contained // and tenant-scoped; the caller invokes `orch->send(agent_id, prompt)` to // run one turn. On failure, returns null and populates `err_out`. +// +// `target_agent_id` is extra-fetched when it fell off the newest-200 +// catalog page so a stored agent GET /v1/agents/:id still finds can run. std::unique_ptr build_blocking_orchestrator(const ApiServerOptions& opts, TenantStore& tenants, const Tenant& tenant, std::string& err_out, - int64_t conversation_id = 0); + int64_t conversation_id = 0, + const std::string& target_agent_id = ""); // HTTP-visible conversation rows (excludes TUI-only sidebar threads). bool is_http_scoped_conversation(TenantStore& tenants, diff --git a/include/tenant_store.h b/include/tenant_store.h index 8afa1db1..e538976e 100644 --- a/include/tenant_store.h +++ b/include/tenant_store.h @@ -498,6 +498,14 @@ class TenantStore { std::vector list_agent_records_for_routing( int64_t tenant_id) const; + // Newest-200 catalog plus a targeted id that fell off that page. + // GET /v1/agents/:id uses get_agent_record; orchestrate / A2A / + // scheduler used to install only the REST list page and 404 a stored + // agent GET still found. Empty / "index" skip the extra fetch. + // Sibling /agent and /parallel still resolve from the newest-200 page. + std::vector list_agent_records_for_dispatch( + int64_t tenant_id, const std::string& target_agent_id) const; + // Wholesale replace. Bumps updated_at. Returns false if the row // doesn't exist for this tenant. bool update_agent_record(int64_t tenant_id, diff --git a/src/api_server.cpp b/src/api_server.cpp index 019b9b40..b8b55b76 100644 --- a/src/api_server.cpp +++ b/src/api_server.cpp @@ -7848,7 +7848,8 @@ std::unique_ptr build_a2a_orchestrator(const ApiServerOptions& opts, TenantStore& tenants, const Tenant& tenant, std::string& err_out, - int64_t conversation_id = 0) { + int64_t conversation_id = 0, + const std::string& target_agent_id = "") { std::unique_ptr orch; try { orch = std::make_unique(opts.api_keys); @@ -7860,7 +7861,11 @@ build_a2a_orchestrator(const ApiServerOptions& opts, orch->client().set_circuit_breaker(opts.circuit_breaker); orch->client().set_metrics(opts.metrics); - const auto records = tenants.list_agent_records(tenant.id, /*limit=*/200); + // Newest-200 plus the targeted id when it fell off that page (GET + // /v1/agents/:id already uses get_agent_record). Sibling /agent + // and /parallel still resolve from the REST list page. + const auto records = tenants.list_agent_records_for_dispatch( + tenant.id, target_agent_id); for (const auto& rec : records) { try { auto cfg = Constitution::from_json(rec.agent_def_json); @@ -7932,7 +7937,8 @@ void handle_a2a_message_send(int fd, const std::string context_id = resolve_a2a_context_id(user_msg); std::string init_err; - auto orch = build_a2a_orchestrator(opts, tenants, tenant, init_err); + auto orch = build_a2a_orchestrator(opts, tenants, tenant, init_err, + /*conversation_id=*/0, agent_id); if (!orch) { write_a2a_rpc(fd, a2a::make_error_response( rpc_id, a2a::RPC_INTERNAL_ERROR, init_err)); @@ -8105,7 +8111,8 @@ void handle_a2a_message_stream(int fd, const std::string context_id = resolve_a2a_context_id(user_msg); std::string init_err; - auto orch = build_a2a_orchestrator(opts, tenants, tenant, init_err); + auto orch = build_a2a_orchestrator(opts, tenants, tenant, init_err, + /*conversation_id=*/0, agent_id); if (!orch) { write_a2a_rpc(fd, a2a::make_error_response( rpc_id, a2a::RPC_INTERNAL_ERROR, init_err)); @@ -9594,8 +9601,11 @@ void handle_orchestrate(int fd, const HttpRequest& req, // can resolve sibling ids during this turn. A blob whose JSON has // gone bad (schema drift after an upgrade, manual DB poke) gets // skipped with a log line — the rest of the catalog still loads. + // Extra-fetch the targeted id when it fell off the newest-200 page + // (same gap GET /v1/agents/:id does not have). { - const auto records = tenants.list_agent_records(tenant.id, /*limit=*/200); + const auto records = tenants.list_agent_records_for_dispatch( + tenant.id, agent_id); for (const auto& rec : records) { try { auto cfg = Constitution::from_json(rec.agent_def_json); @@ -10309,9 +10319,10 @@ build_blocking_orchestrator(const ApiServerOptions& opts, TenantStore& tenants, const Tenant& tenant, std::string& err_out, - int64_t conversation_id) { + int64_t conversation_id, + const std::string& target_agent_id) { return build_a2a_orchestrator(opts, tenants, tenant, err_out, - conversation_id); + conversation_id, target_agent_id); } bool is_http_scoped_conversation(TenantStore& tenants, diff --git a/src/scheduler.cpp b/src/scheduler.cpp index 66d3d40c..ea565e28 100644 --- a/src/scheduler.cpp +++ b/src/scheduler.cpp @@ -265,7 +265,8 @@ bool Scheduler::fire_task(const TenantStore::ScheduledTask& task) { std::string init_err; auto orch = build_blocking_orchestrator(*opts_, *tenants_, *tenant_opt, - init_err, task.conversation_id); + init_err, task.conversation_id, + task.agent_id); if (!orch) { const int64_t completed_at = now_epoch(); diff --git a/src/tenant_store.cpp b/src/tenant_store.cpp index b48878fe..8e8ea0f6 100644 --- a/src/tenant_store.cpp +++ b/src/tenant_store.cpp @@ -3008,6 +3008,20 @@ TenantStore::list_agent_records_for_routing(int64_t tenant_id) const { return out; } +std::vector +TenantStore::list_agent_records_for_dispatch( + int64_t tenant_id, const std::string& target_agent_id) const { + auto out = list_agent_records(tenant_id, 200); + if (target_agent_id.empty() || target_agent_id == "index") return out; + for (const auto& rec : out) { + if (rec.agent_id == target_agent_id) return out; + } + if (auto extra = get_agent_record(tenant_id, target_agent_id)) { + out.push_back(std::move(*extra)); + } + return out; +} + bool TenantStore::update_agent_record(int64_t tenant_id, const std::string& agent_id, const std::string& name, diff --git a/tests/test_tenant_agents.cpp b/tests/test_tenant_agents.cpp index 24dc0a03..4af0d0d9 100644 --- a/tests/test_tenant_agents.cpp +++ b/tests/test_tenant_agents.cpp @@ -7,7 +7,8 @@ // DELETE /v1/agents/:id // and the per-request orchestrator's "install stored agents on every // /v1/orchestrate call so /agent and /parallel can resolve siblings by -// id" wiring. +// id" wiring — plus extra-fetch of a targeted id that fell off the +// newest-200 list page (list_agent_records_for_dispatch). // // HTTP-layer concerns (Constitution::from_json validation, agent_id // safety, "index" being reserved) are enforced inside api_server.cpp @@ -20,6 +21,7 @@ #include #include #include +#include #include namespace fs = std::filesystem; @@ -231,3 +233,58 @@ TEST_CASE("list_agent_records_for_routing returns all agents by agent_id") { CHECK(all.front().agent_id == "agent-000"); CHECK(all.back().agent_id == "agent-200"); } + +TEST_CASE("list_agent_records_for_dispatch extra-fetches a targeted id past newest-200") { + TempDb db; + TenantStore s; + s.open(db.path.string()); + const int64_t tid = make_tenant(s, "acme"); + const int64_t other = make_tenant(s, "other"); + + for (int i = 0; i < 201; ++i) { + char id[32]; + std::snprintf(id, sizeof(id), "agent-%03d", i); + REQUIRE(s.create_agent_record(tid, id, id, "r", "m", kBlobV1).has_value()); + } + REQUIRE(s.create_agent_record(other, "agent-000", "x", "r", "m", kBlobV1).has_value()); + REQUIRE(s.create_agent_record(other, "secret", "s", "r", "m", kBlobV1).has_value()); + + auto page = s.list_agent_records(tid, 200); + REQUIRE(page.size() == 200); + std::set on_page; + for (const auto& rec : page) on_page.insert(rec.agent_id); + std::string missing; + for (int i = 0; i < 201; ++i) { + char id[32]; + std::snprintf(id, sizeof(id), "agent-%03d", i); + if (!on_page.count(id)) { + missing = id; + break; + } + } + REQUIRE_FALSE(missing.empty()); + CHECK(s.get_agent_record(tid, missing).has_value()); + + auto dispatched = s.list_agent_records_for_dispatch(tid, missing); + REQUIRE(dispatched.size() == 201); + bool saw_missing = false; + for (const auto& rec : dispatched) { + if (rec.agent_id == missing) saw_missing = true; + } + CHECK(saw_missing); + + auto already_on_page = s.list_agent_records_for_dispatch(tid, page[0].agent_id); + CHECK(already_on_page.size() == 200); + int copies = 0; + for (const auto& rec : already_on_page) { + if (rec.agent_id == page[0].agent_id) ++copies; + } + CHECK(copies == 1); + + CHECK(s.list_agent_records_for_dispatch(tid, "").size() == 200); + CHECK(s.list_agent_records_for_dispatch(tid, "index").size() == 200); + CHECK(s.list_agent_records_for_dispatch(tid, "does-not-exist").size() == 200); + // Extra-fetch is tenant-scoped: another tenant's id is not appended. + CHECK(s.list_agent_records_for_dispatch(tid, "secret").size() == 200); + CHECK(s.get_agent_record(other, "secret").has_value()); +}