Skip to content

Commit b77ae46

Browse files
zenshclaude
andcommitted
refactor: cut edge-case complexity and dedupe protocol/agent layers
Executes the optimization plan in CODE_REVIEW.md (sections 3-11): net ~-2,000 lines against the pre-plan working tree while keeping every security baseline (three-state ACL, labeled tokens, label-transition fact retraction, shared LLM budget) intact. - Includes the previously uncommitted 2026-07-11 P1/P2 fixes that survived the plan: tail-anchored recall-meta split (P1-1), label-transition retract_digested (P1-2), "formation" collection alias (P1-3), shared conversation-read guard (P2-1), shared LLM semaphore across HTTP+MCP (P2-2). - Reverts edge-case defenses per the accepted-risk register (CODE_REVIEW.md section 9): update snapshot-rollback, dual-sided formation/maintenance recheck, per-version poison-fuse map, indented code-block chunk rules, OKF drift patching / zero-growth replay / resource deletion propagation, eval harness_degradation + OptimizeAbort + transport degradations, CBOR error bodies, verify-failure audit events, digest retraction backfill, lazy census, and more. - Structural dedup: new src/authz.rs as the single HTTP+MCP authorization source (4 AuthzMode); shared RunnerHost / drive_runner_loop skeleton for formation/maintenance; MCP mirror types replaced by JsonSchema derives (schema structurally identical); eval spaces moved to a forked InMemory store; llm_semaphore into AppState; shared bearer/shard header parsing (fixes HTTP/MCP drift). - API.md/API_cn.md: error bodies are always JSON; OKF can no longer delete resource (use wiki_commit). Tests: 254 lib + 21 bin green; fmt/clippy clean. Deleted tests belong to the reverted defenses; security-baseline assertions unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6207243 commit b77ae46

24 files changed

Lines changed: 2513 additions & 3479 deletions

anda_brain/API.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- Supported serialization formats:
99
- Request: `Content-Type: application/json | application/cbor | text/markdown`
1010
- Response: `Accept: application/json | application/cbor | text/markdown`
11+
- Content negotiation applies to success bodies only; error response bodies are always JSON regardless of `Accept`
1112
- Most business endpoints return an RPC envelope: `RpcResponse<T>`
1213
- MCP clients can use the built-in Streamable HTTP endpoint: `/mcp/<space_id>`, or the local stdio server: `anda_brain mcp --space-id <space_id> [local|aws]`
1314

@@ -602,27 +603,27 @@ When `ED25519_PUBKEYS` is set, configure the remote MCP client with an `Authoriz
602603
### GET `/v1/{space_id}/conversations/{conversation_id}?collection=<collection>`
603604

604605
- Purpose: Get a single conversation detail
605-
- Auth: SpaceToken/CWT `read` (public spaces are unauthenticated; private spaces require a valid token); tokens restricted by ACL labels are rejected with `403` — conversations persist the full agent runner history, which is not label-scoped
606+
- Auth: SpaceToken/CWT `read` (public spaces are unauthenticated; private spaces require a valid token); tokens restricted by ACL labels are rejected with `403` — conversations persist the full agent runner history, which is not label-scoped; `collection=recall` additionally rejects anonymous access on public spaces with `403` (recall runs from a private era may embed labeled wiki content)
606607
- Query:
607-
- `collection?: string` // "recall" or "maintenance" for non-default conversation collections; unknown values are rejected with `400`
608+
- `collection?: string` // "formation" (default), "recall" or "maintenance"; unknown values are rejected with `400`
608609
- Response: `RpcResponse<Conversation>`
609610

610611
### GET `/v1/{space_id}/conversations/{conversation_id}/delta?collection=<collection>&messages_offset=<n>&artifacts_offset=<n>`
611612

612613
- Purpose: Get incremental conversation updates after client-side offsets
613-
- Auth: SpaceToken/CWT `read` (public spaces are unauthenticated; private spaces require a valid token); tokens restricted by ACL labels are rejected with `403`
614+
- Auth: SpaceToken/CWT `read` (public spaces are unauthenticated; private spaces require a valid token); tokens restricted by ACL labels are rejected with `403` (`collection=recall`: anonymous access on public spaces is also rejected)
614615
- Query:
615-
- `collection?: string` // "recall" or "maintenance" for non-default conversation collections; unknown values are rejected with `400`
616+
- `collection?: string` // "formation" (default), "recall" or "maintenance"; unknown values are rejected with `400`
616617
- `messages_offset?: number` // returns only messages after this offset, defaults to `0`
617618
- `artifacts_offset?: number` // returns only artifacts after this offset, defaults to `0`
618619
- Response: `RpcResponse<ConversationDelta>`
619620

620621
### GET `/v1/{space_id}/conversations?collection=<collection>&cursor=<cursor>&limit=<n>`
621622

622623
- Purpose: List conversations with pagination
623-
- Auth: SpaceToken/CWT `read` (public spaces are unauthenticated; private spaces require a valid token); tokens restricted by ACL labels are rejected with `403`
624+
- Auth: SpaceToken/CWT `read` (public spaces are unauthenticated; private spaces require a valid token); tokens restricted by ACL labels are rejected with `403` (`collection=recall`: anonymous access on public spaces is also rejected)
624625
- Query:
625-
- `collection?: string` // "recall" or "maintenance" for non-default conversation collections; unknown values are rejected with `400`
626+
- `collection?: string` // "formation" (default), "recall" or "maintenance"; unknown values are rejected with `400`
626627
- `cursor?: string`
627628
- `limit?: number`
628629
- Response: `RpcResponse<Conversation[]>` (next page cursor is returned via `next_cursor`)
@@ -827,3 +828,4 @@ if (recall.error) {
827828
- Authentication failure: HTTP `401`, response body is `RpcError`
828829
- Invalid request/parameters: HTTP `400`, response body is `RpcError`
829830
- Success: HTTP `200`, response body is usually `RpcResponse<T>`
831+
- Error response bodies are always JSON, even when the request asked for `application/cbor` or `text/markdown` (this includes the wiki `409` conflict body carrying `error.data.current_version`); only success bodies follow the `Accept` header

anda_brain/API_cn.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- 支持的序列化格式:
99
- 请求:`Content-Type: application/json | application/cbor | text/markdown`
1010
- 响应:`Accept: application/json | application/cbor | text/markdown`
11+
- 内容协商仅作用于成功响应体;错误响应体始终为 JSON,与 `Accept` 无关
1112
- 大多数业务接口都会返回 RPC 包装后的结构体:`RpcResponse<T>`
1213
- MCP 客户端可使用内置的支持流式传输的 HTTP MCP 端点:`/mcp/<space_id>`,也可以使用本地 stdio server:`anda_brain mcp --space-id <space_id> [local|aws]`
1314

@@ -602,27 +603,27 @@ MCP_AUTH_TOKEN="$SPACE_TOKEN" \
602603
### GET `/v1/{space_id}/conversations/{conversation_id}?collection=<collection>`
603604

604605
- 作用:获取单条会话详情
605-
- 鉴权:SpaceToken/CWT `read`(公开空间免鉴权,私有空间需有效 token);带 ACL 标签限制的 token 返回 `403`——会话持久化了完整的 agent 运行历史,不受标签过滤
606+
- 鉴权:SpaceToken/CWT `read`(公开空间免鉴权,私有空间需有效 token);带 ACL 标签限制的 token 返回 `403`——会话持久化了完整的 agent 运行历史,不受标签过滤`collection=recall` 对公开空间的匿名访问也返回 `403`(私有期的 recall 运行可能内嵌 labeled wiki 内容)
606607
- Query:
607-
- `collection?: string` // "recall" 或 "maintenance" 指定非默认会话集合;未知值返回 `400`
608+
- `collection?: string` // "formation"(默认)、"recall" 或 "maintenance";未知值返回 `400`
608609
- 响应:`RpcResponse<Conversation>`
609610

610611
### GET `/v1/{space_id}/conversations/{conversation_id}/delta?collection=<collection>&messages_offset=<n>&artifacts_offset=<n>`
611612

612613
- 作用:按客户端已消费的 offset 获取会话增量更新
613-
- 鉴权:SpaceToken/CWT `read`(公开空间免鉴权,私有空间需有效 token);带 ACL 标签限制的 token 返回 `403`
614+
- 鉴权:SpaceToken/CWT `read`(公开空间免鉴权,私有空间需有效 token);带 ACL 标签限制的 token 返回 `403``collection=recall`:公开空间匿名访问同样拒绝)
614615
- Query:
615-
- `collection?: string` // "recall" 或 "maintenance" 指定非默认会话集合;未知值返回 `400`
616+
- `collection?: string` // "formation"(默认)、"recall" 或 "maintenance";未知值返回 `400`
616617
- `messages_offset?: number` // 仅返回该偏移量之后的新消息,默认 `0`
617618
- `artifacts_offset?: number` // 仅返回该偏移量之后的新 artifacts,默认 `0`
618619
- 响应:`RpcResponse<ConversationDelta>`
619620

620621
### GET `/v1/{space_id}/conversations?collection=<collection>&cursor=<cursor>&limit=<n>`
621622

622623
- 作用:分页列出会话
623-
- 鉴权:SpaceToken/CWT `read`(公开空间免鉴权,私有空间需有效 token);带 ACL 标签限制的 token 返回 `403`
624+
- 鉴权:SpaceToken/CWT `read`(公开空间免鉴权,私有空间需有效 token);带 ACL 标签限制的 token 返回 `403``collection=recall`:公开空间匿名访问同样拒绝)
624625
- Query:
625-
- `collection?: string` // "recall" 或 "maintenance" 指定非默认会话集合;未知值返回 `400`
626+
- `collection?: string` // "formation"(默认)、"recall" 或 "maintenance";未知值返回 `400`
626627
- `cursor?: string`
627628
- `limit?: number`
628629
- 响应:`RpcResponse<Conversation[]>`(并通过 `next_cursor` 给出下一页游标)
@@ -830,3 +831,4 @@ if (recall.error) {
830831
- 认证失败:HTTP `401`,响应体为 `RpcError`
831832
- 参数错误:HTTP `400`,响应体为 `RpcError`
832833
- 成功时:HTTP `200`,响应体通常为 `RpcResponse<T>`
834+
- 错误响应体始终为 JSON,即使请求指定了 `application/cbor``text/markdown`(包括携带 `error.data.current_version` 的 wiki `409` 冲突响应体);只有成功响应体遵循 `Accept` 协商

anda_brain/src/agents.rs

Lines changed: 196 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use anda_db::schema::DocumentId;
88
use anda_engine::{
99
context::CompletionRunner,
1010
memory::{Conversation, ConversationStatus},
11+
unix_ms,
1112
};
1213
use parking_lot::RwLock;
1314
use std::collections::VecDeque;
@@ -42,6 +43,199 @@ const COMPACTION_CONTINUE_PROMPT: &str = "Continue the active memory-agent work
4243
/// persisted conversation.
4344
pub(super) const PERSIST_EVERY_N_TURNS: usize = 5;
4445

46+
/// Hard guardrails for the formation/maintenance runner loops (review P2-3).
47+
///
48+
/// A model that keeps emitting tool calls without converging would otherwise
49+
/// run forever: the runner compacts every ~81 turns and simply continues, so
50+
/// the agent's processing flag stays set and the space's memory system stalls
51+
/// (formation's queue freezes; maintenance blocks formation entirely). Both
52+
/// agents legitimately need far more turns than recall
53+
/// (`RECALL_MAX_MODEL_TURNS` = 7 + 180s), so these caps are intentionally
54+
/// loose. Exceeding either budget marks the conversation Failed through the
55+
/// host's `mark_failed`, which reuses each agent's existing failure path
56+
/// (formation: the Failed retry path; maintenance: releases the processing
57+
/// slot through the guard/hook flow). Budgets are checked between turns so an
58+
/// in-flight KIP write turn is never cancelled halfway.
59+
pub(super) const RUNNER_MAX_MODEL_TURNS: usize = 200;
60+
pub(super) const RUNNER_MAX_WALL_CLOCK_MS: u64 = 30 * 60 * 1000;
61+
62+
/// Control flow returned by [`RunnerHost::after_turn`].
63+
pub(super) enum RunnerFlow {
64+
/// Keep looping (the host may have queued a follow-up).
65+
Continue,
66+
/// The conversation converged; leave the runner loop cleanly.
67+
Break,
68+
}
69+
70+
/// Per-agent seams of the shared formation/maintenance runner loop
71+
/// ([`drive_runner_loop`]). Everything else — budget guards, the compaction
72+
/// arm, turn accounting, `append_runner_history`, the status three-state, the
73+
/// persistence throttle, the single failure exit with usage backfill and the
74+
/// tail Working flush — is identical between the two agents and lives in the
75+
/// driver. recall deliberately does not use this skeleton: its timeout
76+
/// wrapper and failed_output semantics do not fit these seams.
77+
pub(super) trait RunnerHost {
78+
/// Agent label used in budget-exceeded failure reasons.
79+
fn label(&self) -> &'static str;
80+
81+
/// The agent's completed-conversation ring served as model context.
82+
fn history(&self) -> &RwLock<VecDeque<Document>>;
83+
84+
/// Persists the current full conversation snapshot; failures are logged
85+
/// by the host and must not interrupt the processing loop.
86+
async fn persist_snapshot(&self, conversation: &Conversation);
87+
88+
/// Marks the conversation Failed with `reason` and persists it.
89+
async fn mark_failed(&self, conversation: &mut Conversation, reason: String);
90+
91+
/// Host-specific convergence predicate for the current turn (formation
92+
/// also treats an idle runner as done once the review pass has run).
93+
fn turn_is_done(&self, runner: &CompletionRunner) -> bool;
94+
95+
/// Runs on every successful (non-failed) turn, before the completed
96+
/// history push (formation clears `failed_reason` so the Failed-retry
97+
/// path converges to a clean snapshot; maintenance leaves it untouched).
98+
fn on_turn_success(&self, conversation: &mut Conversation);
99+
100+
/// Runs at the end of every non-terminal turn, after persistence. The
101+
/// host decides whether the loop ends now (formation injects the pending
102+
/// review follow-up when the runner idles and otherwise breaks as soon as
103+
/// it is done) or defers to the runner's own `Ok(None)` exit on the next
104+
/// iteration (maintenance).
105+
fn after_turn(&mut self, runner: &mut CompletionRunner, is_done: bool) -> RunnerFlow;
106+
}
107+
108+
/// Shared runner loop driving one formation/maintenance conversation to a
109+
/// terminal state. Every failure exits through the labeled break so usage
110+
/// backfill and `mark_failed` live at exactly one place below the loop.
111+
pub(super) async fn drive_runner_loop<H: RunnerHost>(
112+
host: &mut H,
113+
runner: &mut CompletionRunner,
114+
conversation: &mut Conversation,
115+
) {
116+
let started_at_ms = unix_ms();
117+
let mut replace_initial_input = true;
118+
let mut persisted_runner_history_len = 0;
119+
let mut total_model_turns = 0usize;
120+
let mut accounted_runner_turns = 0usize;
121+
let mut unpersisted_turns = 0usize;
122+
let failure: Option<String> = 'run: {
123+
loop {
124+
// Guardrails against a non-converging tool loop; see
125+
// RUNNER_MAX_MODEL_TURNS. Exceeding a budget takes the host's
126+
// existing mark_failed path.
127+
if total_model_turns >= RUNNER_MAX_MODEL_TURNS {
128+
break 'run Some(format!(
129+
"{} exceeded model turn limit of {}",
130+
host.label(),
131+
RUNNER_MAX_MODEL_TURNS
132+
));
133+
}
134+
if unix_ms().saturating_sub(started_at_ms) >= RUNNER_MAX_WALL_CLOCK_MS {
135+
break 'run Some(format!(
136+
"{} exceeded wall-clock budget of {} seconds",
137+
host.label(),
138+
RUNNER_MAX_WALL_CLOCK_MS / 1000
139+
));
140+
}
141+
142+
match compact_runner_if_needed(runner).await {
143+
Ok(true) => {
144+
// The compaction handoff consumed one model turn, and the
145+
// replacement runner restarts its own turn counter.
146+
total_model_turns = total_model_turns.saturating_add(1);
147+
accounted_runner_turns = runner.turns();
148+
persisted_runner_history_len = 0;
149+
replace_initial_input = false;
150+
}
151+
Ok(false) => {}
152+
Err(err) => break 'run Some(format!("CompletionRunner error: {err:?}")),
153+
}
154+
155+
match runner.next().await {
156+
Ok(None) => break 'run None,
157+
Ok(Some(res)) => {
158+
let runner_turns = runner.turns();
159+
total_model_turns = total_model_turns
160+
.saturating_add(runner_turns.saturating_sub(accounted_runner_turns));
161+
accounted_runner_turns = runner_turns;
162+
163+
let now_ms = unix_ms();
164+
let is_done = host.turn_is_done(runner);
165+
166+
append_runner_history(
167+
conversation,
168+
&res.chat_history,
169+
&mut persisted_runner_history_len,
170+
&mut replace_initial_input,
171+
);
172+
173+
conversation.status = if res.failed_reason.is_some() {
174+
ConversationStatus::Failed
175+
} else if is_done {
176+
ConversationStatus::Completed
177+
} else {
178+
ConversationStatus::Working
179+
};
180+
conversation.usage = res.usage;
181+
conversation.updated_at = now_ms;
182+
183+
if let Some(failed_reason) = res.failed_reason {
184+
conversation.failed_reason = Some(failed_reason);
185+
} else {
186+
host.on_turn_success(conversation);
187+
push_completed_history(host.history(), conversation, 2);
188+
}
189+
190+
// Persisting rewrites the full message array (O(turns^2)
191+
// over a session), so intermediate Working turns are
192+
// throttled; terminal statuses always persist. See
193+
// PERSIST_EVERY_N_TURNS.
194+
unpersisted_turns = unpersisted_turns.saturating_add(1);
195+
if conversation.status != ConversationStatus::Working
196+
|| unpersisted_turns >= PERSIST_EVERY_N_TURNS
197+
{
198+
host.persist_snapshot(conversation).await;
199+
unpersisted_turns = 0;
200+
}
201+
202+
if conversation.status == ConversationStatus::Cancelled
203+
|| conversation.status == ConversationStatus::Failed
204+
{
205+
break 'run None;
206+
}
207+
208+
if let RunnerFlow::Break = host.after_turn(runner, is_done) {
209+
break 'run None;
210+
}
211+
}
212+
Err(err) => break 'run Some(format!("CompletionRunner error: {err:?}")),
213+
}
214+
}
215+
};
216+
217+
// Single failure exit. The usage snapshot happens after the error
218+
// occurred: failure can strike after usage was accumulated but before it
219+
// was copied from a runner output (e.g. a compaction handoff consumed ~a
220+
// full context window of input tokens and the very next call errors), so
221+
// the runner's running total is backfilled here, like recall does, or
222+
// those tokens vanish from the agent's usage ledger. Success exits must
223+
// NOT backfill: the runner's final_output/final_idle_output mem::take
224+
// total_usage, so the last `res.usage` already carries the full total.
225+
if let Some(reason) = failure {
226+
conversation.usage = runner.total_usage().clone();
227+
host.mark_failed(conversation, reason).await;
228+
}
229+
230+
// Terminal and failure exits above always persist (any non-Working
231+
// status forces a write, and mark_failed writes its own snapshot), so
232+
// only a Working exit — e.g. the runner returning `Ok(None)` — can still
233+
// hold turns skipped by the throttle.
234+
if unpersisted_turns > 0 && conversation.status == ConversationStatus::Working {
235+
host.persist_snapshot(conversation).await;
236+
}
237+
}
238+
45239
fn queued_runner_tokens(runner: &CompletionRunner) -> u64 {
46240
runner
47241
.steering_message_iter()
@@ -52,21 +246,15 @@ fn queued_runner_tokens(runner: &CompletionRunner) -> u64 {
52246

53247
pub(super) async fn compact_runner_if_needed(
54248
runner: &mut CompletionRunner,
55-
extra_pending_tokens: u64,
56-
continue_after_compaction: bool,
57249
) -> Result<bool, BoxError> {
58-
if !runner
59-
.needs_compaction_with(|| queued_runner_tokens(runner).saturating_add(extra_pending_tokens))
60-
{
250+
if !runner.needs_compaction_with(|| queued_runner_tokens(runner)) {
61251
return Ok(false);
62252
}
63253

64254
let (mut compacted, output) = runner.handoff(None).await?;
65255
compacted.accumulate(&output.usage);
66256
compacted.accumulate_tools_usage(&output.tools_usage);
67-
if continue_after_compaction {
68-
compacted.follow_up(ContentPart::from(COMPACTION_CONTINUE_PROMPT.to_string()));
69-
}
257+
compacted.follow_up(ContentPart::from(COMPACTION_CONTINUE_PROMPT.to_string()));
70258
*runner = compacted;
71259
Ok(true)
72260
}

0 commit comments

Comments
 (0)