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
25 changes: 14 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,31 @@ loosely while pre-1.0 (breaking changes can land on minor bumps).
## [Unreleased]

### Fixed
- **`/schedule` time math fail-closed.** `every hour` / `hourly` now use the
same overflow-checked adder as `every N hours` instead of `now + 3600`.
`mktime` failure no longer stores `next_fire_at = -1` (always due on
`list_due`); parse and `next_fire_for_recur` return 0 / an error so a
recurring recompute cannot tight-loop.
- **Remote TUI: recoverable SSE `error` is not a failed turn.** `RemoteSseTurnConsumer::finish` copied accumulated `error` event text even when the terminal `done` event had `ok: true` (e.g. catalog skip of a stored agent whose JSON failed validation). `done` is authoritative: success clears the result error; failure still prefers `done.error` and falls back to prior `error` events when that field is empty.
- **Unreadable TUI sessions are not empty.** `session_json_is_empty` no
- **Advise-gate cancel is not a bad bearer.** `POST /v1/advise/gate`
- **MCP string JSON-RPC ids.** `parse_response` now accepts a decimal-string
- **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.
- **Reconcile rollback no longer wipes the workspace on a failed restore.**
- **LaTeX math recursion depth.** `latex_math_to_plain` now stops converting
- **Secret key/token writes do not follow a planted dest symlink.**
- **Remote `--connect` base URL query/userinfo.** `normalize_api_base_url`
- **JSON numbers require a complete fraction and exponent.** `json_parse`
- **MCP registry `env` overrides parent keys.** Subprocess spawn skipped
- **Intent reconcile Phase B (JIT ΔS waves).** `POST /v1/reconcile` `mode=ensure`
- **A2A unary HTTP errors stay bounded.** `Client::rpc` no longer concatenates
- **Remote `--connect` DELETE/PATCH body cap.** Conversation delete and
- **Intent LLM prompt text cap.** `build_llm_user_prompt` now truncates
- **Remote TUI: recoverable SSE `error` is not a failed turn.** `RemoteSseTurnConsumer::finish` copied accumulated `error` event text even when the terminal `done` event had `ok: true` (e.g. catalog skip of a stored agent whose JSON failed validation). `done` is authoritative: success clears the result error; failure still prefers `done.error` and falls back to prior `error` events when that field is empty.
- **Unreadable TUI sessions are not empty.** `session_json_is_empty` no
- **Advise-gate cancel is not a bad bearer.** `POST /v1/advise/gate`
- **MCP string JSON-RPC ids.** `parse_response` now accepts a decimal-string
- **MCP registry writes do not follow a planted `.tmp` symlink.**
- **`/schedule` calendar dates.** `on YYYY-MM-DD` now rejects impossible
- **MCP registry `env` overrides parent keys.** Subprocess spawn skipped
- **`/fetch` uses the same SSRF hostname preflight as `/browse`.**
- **`/schedule` time math fail-closed.** `every hour` / `hourly` now use the
- **JSON numbers require a complete fraction and exponent.** `json_parse`

## [0.13.7] — 2026-09-21

Expand Down
6 changes: 5 additions & 1 deletion include/api_server.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<Orchestrator>
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,
Expand Down
8 changes: 8 additions & 0 deletions include/tenant_store.h
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,14 @@ class TenantStore {
std::vector<AgentRecord> 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<AgentRecord> 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,
Expand Down
25 changes: 18 additions & 7 deletions src/api_server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8088,7 +8088,8 @@ std::unique_ptr<Orchestrator>
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<Orchestrator> orch;
try {
orch = std::make_unique<Orchestrator>(opts.api_keys);
Expand All @@ -8100,7 +8101,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);
Expand Down Expand Up @@ -8172,7 +8177,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));
Expand Down Expand Up @@ -8345,7 +8351,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));
Expand Down Expand Up @@ -9834,8 +9841,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);
Expand Down Expand Up @@ -10549,9 +10559,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,
Expand Down
3 changes: 2 additions & 1 deletion src/scheduler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
14 changes: 14 additions & 0 deletions src/tenant_store.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3051,6 +3051,20 @@ TenantStore::list_agent_records_for_routing(int64_t tenant_id) const {
return out;
}

std::vector<AgentRecord>
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,
Expand Down
59 changes: 58 additions & 1 deletion tests/test_tenant_agents.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,6 +21,7 @@
#include <chrono>
#include <cstdio>
#include <filesystem>
#include <set>
#include <thread>

namespace fs = std::filesystem;
Expand Down Expand Up @@ -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<std::string> 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());
}
Loading