From cbbc75e534679a80b890e2711c22e3ce3b46d509 Mon Sep 17 00:00:00 2001 From: Edwin Date: Wed, 15 Jul 2026 09:44:32 -0700 Subject: [PATCH] feat(context): stateful compact serving for agent context tools The construct_context / agentd_context payload is now deduplicated per serving process instead of relying on the model to echo etags back: - Shared compact layer in construct_protocol::agent_context, used by both construct-mcp and smith's native tool. Smith previously bypassed all compaction and pretty-printed the full context (policies, extension registry, full memory, full program run) on every call. - The serving process remembers what it sent: repeat calls collapse unchanged memory/program content to etag markers and send static fields (instructions, widget paths, reference hint) once. known_* etag echoes are still honored for restarted serving processes. - Program-run markdown and its static run reference (run instructions, smart clips) get independent etags, so a program edit no longer resends ~4KB of static instructions. - skip_memory:true lets an agent that just wrote its memory files skip the immediate resend of content it authored. - refresh:true resends everything; smith resets serve state natively on compact/prune, since the model no longer holds served content. - Spec 0095 updated with the serving contract. --- crates/adapter-smith/src/agent.rs | 25 +- crates/adapter-smith/src/interactive.rs | 13 +- .../src/tools/construct_daemon.rs | 17 +- crates/adapter-smith/src/tools/fs.rs | 3 + crates/adapter-smith/src/tools/mod.rs | 5 + crates/adapter-smith/src/tools/shell.rs | 3 + crates/mcp/src/lib.rs | 15 +- crates/mcp/src/tools.rs | 142 ++----- crates/protocol/src/agent_context.rs | 374 +++++++++++++++++- specs/0095-mcp-agent-context-budgets.md | 6 +- 10 files changed, 491 insertions(+), 112 deletions(-) diff --git a/crates/adapter-smith/src/agent.rs b/crates/adapter-smith/src/agent.rs index c977b1d6..23d9f0d3 100644 --- a/crates/adapter-smith/src/agent.rs +++ b/crates/adapter-smith/src/agent.rs @@ -377,6 +377,16 @@ const TOOL_OUTPUT_BUDGET: usize = 8_000; /// Build a fresh [`ToolCtx`] that shares the daemon `Client` with `src` /// when one has already been opened. Used to fan a turn's Safe tool /// calls into parallel tasks without each one re-connecting. +/// Forget what the `agentd_context` tool already served this model: called +/// whenever message history is compacted or pruned, since served-but-dropped +/// content must be sent again (spec 0095). +pub(crate) fn reset_context_serve(ctx: &ToolCtx) { + ctx.context_serve + .lock() + .unwrap_or_else(|p| p.into_inner()) + .reset(); +} + pub(crate) fn clone_tool_ctx(src: &ToolCtx) -> ToolCtx { let new_ctx = ToolCtx { cwd: src.cwd.clone(), @@ -384,6 +394,7 @@ pub(crate) fn clone_tool_ctx(src: &ToolCtx) -> ToolCtx { client: tokio::sync::OnceCell::new(), emit: src.emit.clone(), procs: src.procs.clone(), + context_serve: src.context_serve.clone(), sandbox: src.sandbox.clone(), sandbox_policy: src.sandbox_policy.clone(), }; @@ -627,6 +638,7 @@ pub async fn run( client: tokio::sync::OnceCell::new(), emit: Some(emit.clone()), procs: Arc::new(crate::tools::proc::ProcRegistry::default()), + context_serve: Arc::new(std::sync::Mutex::new(Default::default())), sandbox: announce_sandbox(&emit), sandbox_policy: crate::sandbox::SandboxPolicy::workspace_default(&cwd), }; @@ -835,6 +847,9 @@ pub async fn run( tracing::warn!(error = ?e, "auto-compact: persist rewrite failed"); } } + // The summarized history no longer holds what the + // context tool served; the next call must resend. + reset_context_serve(&tool_ctx); emit.emit(SessionEvent::ContextCompacted { kept_turns: outcome.kept_turn_pairs, dropped_turns: outcome.dropped_turn_pairs, @@ -849,7 +864,9 @@ pub async fn run( } } } - let _pruned = context::prune_to_budget(&mut messages, budget); + if context::prune_to_budget(&mut messages, budget) > 0 { + reset_context_serve(&tool_ctx); + } let mut sink = MessageSink { emit: &emit }; let turn = match crate::provider_watchdog::complete( @@ -878,7 +895,9 @@ pub async fn run( now_ms, ); let retry_budget = ((new_limit as f64) * context::UTILIZATION) as usize; - let _pruned = context::prune_to_budget(&mut messages, retry_budget); + if context::prune_to_budget(&mut messages, retry_budget) > 0 { + reset_context_serve(&tool_ctx); + } emit.emit(SessionEvent::Status { state: SessionState::Running, detail: Some(format!( @@ -1444,6 +1463,7 @@ async fn run_with_interrupt( let session_id = ctx.session_id.clone(); let emit = ctx.emit.clone(); let procs = ctx.procs.clone(); + let context_serve = ctx.context_serve.clone(); let sandbox = ctx.sandbox.clone(); let sandbox_policy = ctx.sandbox_policy.clone(); let client_cell = std::sync::Mutex::new(None::>); @@ -1457,6 +1477,7 @@ async fn run_with_interrupt( client: tokio::sync::OnceCell::new(), emit, procs, + context_serve, sandbox, sandbox_policy, }; diff --git a/crates/adapter-smith/src/interactive.rs b/crates/adapter-smith/src/interactive.rs index 803b2f20..a17bae1e 100644 --- a/crates/adapter-smith/src/interactive.rs +++ b/crates/adapter-smith/src/interactive.rs @@ -2184,6 +2184,7 @@ pub async fn run( client: tokio::sync::OnceCell::new(), emit: Some(emit.clone()), procs: std::sync::Arc::new(crate::tools::proc::ProcRegistry::default()), + context_serve: std::sync::Arc::new(std::sync::Mutex::new(Default::default())), sandbox: crate::agent::announce_sandbox(&emit), sandbox_policy: crate::sandbox::SandboxPolicy::workspace_default(&cwd), }; @@ -2582,6 +2583,7 @@ pub async fn run( tracing::warn!(error = ?e, "compact: persist rewrite failed"); } } + crate::agent::reset_context_serve(&tool_ctx); emit.emit(SessionEvent::ContextCompacted { kept_turns: outcome.kept_turn_pairs, dropped_turns: outcome.dropped_turn_pairs, @@ -2815,6 +2817,7 @@ pub async fn run( tracing::warn!(error = ?e, "auto-compact: persist rewrite failed"); } } + crate::agent::reset_context_serve(&tool_ctx); emit.emit(SessionEvent::ContextCompacted { kept_turns: outcome.kept_turn_pairs, dropped_turns: outcome.dropped_turn_pairs, @@ -2833,7 +2836,9 @@ pub async fn run( } } } - let _pruned = context::prune_to_budget(&mut messages, budget); + if context::prune_to_budget(&mut messages, budget) > 0 { + crate::agent::reset_context_serve(&tool_ctx); + } let mut sink = PtySink::new(&emit, pty_width, turn_started_at_ms); // Wrap the provider call so user typing during the // stream is fed to the editor and pressed-Enter lines @@ -2882,7 +2887,9 @@ pub async fn run( now_ms, ); let retry_budget = ((new_limit as f64) * context::UTILIZATION) as usize; - let _pruned = context::prune_to_budget(&mut messages, retry_budget); + if context::prune_to_budget(&mut messages, retry_budget) > 0 { + crate::agent::reset_context_serve(&tool_ctx); + } term.note(&format!( "(context overflow — relearned cap as {} tokens, retrying)", new_limit @@ -4428,6 +4435,7 @@ async fn run_with_supervisor( let session_id = ctx.session_id.clone(); let emit = ctx.emit.clone(); let procs = ctx.procs.clone(); + let context_serve = ctx.context_serve.clone(); let sandbox = ctx.sandbox.clone(); let sandbox_policy = ctx.sandbox_policy.clone(); let client_seed = ctx.client.get().cloned(); @@ -4439,6 +4447,7 @@ async fn run_with_supervisor( client: tokio::sync::OnceCell::new(), emit, procs, + context_serve, sandbox, sandbox_policy, }; diff --git a/crates/adapter-smith/src/tools/construct_daemon.rs b/crates/adapter-smith/src/tools/construct_daemon.rs index b5d2bbbf..0f44fece 100644 --- a/crates/adapter-smith/src/tools/construct_daemon.rs +++ b/crates/adapter-smith/src/tools/construct_daemon.rs @@ -43,15 +43,26 @@ impl Tool for Context { agent_context::TOOL_DESCRIPTION } fn schema(&self) -> Value { - json!({ "type": "object", "properties": {} }) + json!({ + "type": "object", + "properties": { + "refresh": { "type": "boolean", "description": "Resend static fields and unchanged content. Pass true when earlier agentd_context results may have been compacted out of your context." }, + "skip_memory": { "type": "boolean", "description": "Omit memory file contents you already hold verbatim (e.g. you just wrote them); paths and etags are still returned." }, + "include_reference": { "type": "boolean", "description": "Include the full memory/widget policy and Markdown extension reference." } + } + }) } fn risk(&self) -> ToolRisk { ToolRisk::Safe } - async fn run(&self, _input: Value, _ctx: &ToolCtx) -> Result { + async fn run(&self, input: Value, ctx: &ToolCtx) -> Result { + let request = agent_context::ContextRequest::from_args(&input); + let mut state = ctx.context_serve.lock().unwrap_or_else(|p| p.into_inner()); + let response = + agent_context::compact_response(agent_context::build_from_env(), &request, &mut state); Ok(ToolOutcome { ok: true, - output: serde_json::to_string_pretty(&agent_context::build_from_env())?, + output: serde_json::to_string(&response)?, }) } } diff --git a/crates/adapter-smith/src/tools/fs.rs b/crates/adapter-smith/src/tools/fs.rs index e328aac6..de81a70f 100644 --- a/crates/adapter-smith/src/tools/fs.rs +++ b/crates/adapter-smith/src/tools/fs.rs @@ -516,6 +516,7 @@ mod tests { client: tokio::sync::OnceCell::new(), emit: None, procs: std::sync::Arc::new(crate::tools::proc::ProcRegistry::default()), + context_serve: std::sync::Arc::new(std::sync::Mutex::new(Default::default())), sandbox: std::sync::Arc::new(crate::sandbox::Noop), sandbox_policy, } @@ -601,6 +602,7 @@ mod tests { client: tokio::sync::OnceCell::new(), emit: None, procs: std::sync::Arc::new(crate::tools::proc::ProcRegistry::default()), + context_serve: std::sync::Arc::new(std::sync::Mutex::new(Default::default())), sandbox: std::sync::Arc::new(sb), sandbox_policy: policy, }; @@ -669,6 +671,7 @@ mod tests { client: tokio::sync::OnceCell::new(), emit: None, procs: std::sync::Arc::new(crate::tools::proc::ProcRegistry::default()), + context_serve: std::sync::Arc::new(std::sync::Mutex::new(Default::default())), sandbox: std::sync::Arc::new(sb), sandbox_policy: policy, }; diff --git a/crates/adapter-smith/src/tools/mod.rs b/crates/adapter-smith/src/tools/mod.rs index 1427c632..5ca4b2f9 100644 --- a/crates/adapter-smith/src/tools/mod.rs +++ b/crates/adapter-smith/src/tools/mod.rs @@ -149,6 +149,11 @@ pub struct ToolCtx { /// earlier `shell` call started. Cloned (not re-created) by /// [`crate::agent::clone_tool_ctx`] and the parallel-call paths. pub procs: Arc, + /// What the `agentd_context` tool has already served this session's + /// model, so repeat calls omit it (spec 0095). Shared across every clone + /// of the context; reset when the message history is compacted or pruned + /// (the model no longer holds what was served). + pub context_serve: Arc>, /// OS sandbox backend for the session (`Noop` when disabled). Shared /// across every clone of the context — selected once at session start. pub sandbox: Arc, diff --git a/crates/adapter-smith/src/tools/shell.rs b/crates/adapter-smith/src/tools/shell.rs index 77bde99c..180d7414 100644 --- a/crates/adapter-smith/src/tools/shell.rs +++ b/crates/adapter-smith/src/tools/shell.rs @@ -301,6 +301,7 @@ mod tests { client: tokio::sync::OnceCell::new(), emit: None, procs: std::sync::Arc::new(crate::tools::proc::ProcRegistry::default()), + context_serve: std::sync::Arc::new(std::sync::Mutex::new(Default::default())), sandbox: std::sync::Arc::new(crate::sandbox::Noop), sandbox_policy, } @@ -334,6 +335,7 @@ mod tests { client: tokio::sync::OnceCell::new(), emit: None, procs: std::sync::Arc::new(crate::tools::proc::ProcRegistry::default()), + context_serve: std::sync::Arc::new(std::sync::Mutex::new(Default::default())), sandbox: std::sync::Arc::new(sb), sandbox_policy: policy, }; @@ -400,6 +402,7 @@ mod tests { client: tokio::sync::OnceCell::new(), emit: None, procs: std::sync::Arc::new(crate::tools::proc::ProcRegistry::default()), + context_serve: std::sync::Arc::new(std::sync::Mutex::new(Default::default())), sandbox: std::sync::Arc::new(sb), sandbox_policy: policy, }; diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index e889fc16..9bce4243 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -53,6 +53,9 @@ async fn run_inner( ) -> Result<()> { let mut stdin = BufReader::new(tokio::io::stdin()); let mut stdout = tokio::io::stdout(); + // One MCP server process serves one agent, so what the context tool has + // already sent that agent is process state (spec 0095). + let context_state = tools::ContextServeState::default(); loop { let raw = match transport::read_message(&mut stdin).await { @@ -74,7 +77,8 @@ async fn run_inner( client = new_client; } } - let resp = handle_request(&client, session_id.as_deref(), req).await; + let resp = + handle_request(&client, session_id.as_deref(), &context_state, req).await; let v = match serde_json::to_value(&resp) { Ok(v) => v, Err(_) => continue, @@ -91,7 +95,12 @@ async fn run_inner( } } -async fn handle_request(client: &Arc, session_id: Option<&str>, req: Request) -> Response { +async fn handle_request( + client: &Arc, + session_id: Option<&str>, + context_state: &tools::ContextServeState, + req: Request, +) -> Response { let id = req.id.clone(); let params = req.params.clone().unwrap_or(serde_json::Value::Null); @@ -110,7 +119,7 @@ async fn handle_request(client: &Arc, session_id: Option<&str>, req: Req }), ), "tools/list" => Response::ok(id, serde_json::json!({ "tools": tools::catalog() })), - "tools/call" => match tools::call(client, session_id, params).await { + "tools/call" => match tools::call(client, session_id, context_state, params).await { Ok(content) => Response::ok(id, content), Err(e) => Response::ok( id, diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 356d9df3..5ed2f6f7 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -135,92 +135,10 @@ fn render_terminal_text(bytes: &[u8], size: Option) parser.screen().contents().trim_end().to_string() } -fn content_etag(text: &str) -> String { - let mut hash = 0xcbf29ce484222325u64; - for byte in text.as_bytes() { - hash ^= u64::from(*byte); - hash = hash.wrapping_mul(0x100000001b3); - } - format!("{hash:x}") -} - -fn compact_memory_file( - file: Option, - known: Option<&str>, -) -> Option { - file.map(|file| { - let etag = content_etag(&file.content); - if known == Some(etag.as_str()) { - json!({ "path": file.path, "etag": etag, "unchanged": true }) - } else { - json!({ - "path": file.path, - "content": file.content, - "etag": etag, - "truncated": file.truncated, - "remaining_bytes": file.remaining_bytes, - }) - } - }) -} - -fn compact_context_response(mut context: agent_context::AgentdContext, args: &Value) -> Value { - let known_global = args.get("known_global").and_then(Value::as_str); - let known_project = args.get("known_project").and_then(Value::as_str); - let known_program = args.get("known_program").and_then(Value::as_str); - let include_reference = args - .get("include_reference") - .and_then(Value::as_bool) - .unwrap_or(false); - let global = compact_memory_file(context.global_memory.take(), known_global); - let project = compact_memory_file(context.project_memory.take(), known_project); - let program = context.program_run.take().map(|program| { - let encoded = serde_json::to_string(&program).unwrap_or_default(); - let etag = content_etag(&encoded); - if known_program == Some(etag.as_str()) { - json!({ - "session_id": program.session_id, - "program_version": program.program_version, - "etag": etag, - "unchanged": true, - }) - } else { - let mut value = serde_json::to_value(program).unwrap_or_else(|_| json!({})); - value["etag"] = json!(etag); - value - } - }); - let mut out = serde_json::Map::new(); - out.insert("session_id".into(), json!(context.session_id)); - out.insert("project_id".into(), json!(context.project_id)); - out.insert("instructions".into(), json!(context.instructions)); - if let Some(memory) = global { - out.insert("global_memory".into(), memory); - } - if let Some(memory) = project { - out.insert("project_memory".into(), memory); - } - if let Some(widgets) = context.session_widgets { - out.insert("session_widgets".into(), json!(widgets)); - } - if let Some(program) = program { - out.insert("program_run".into(), program); - } - if include_reference { - out.insert("memory_policy".into(), json!(context.memory_policy)); - out.insert("widget_policy".into(), json!(context.widget_policy)); - out.insert( - "markdown_extensions".into(), - json!(context.markdown_extensions), - ); - } else { - out.insert( - "reference".into(), - json!("Call again with include_reference:true for full memory/widget policy and Markdown extensions."), - ); - } - Value::Object(out) -} +/// Per-process memory of what the context tool already served — one MCP +/// server process serves one agent, so this is per-agent state. See +/// [`agent_context::ContextServeState`]. +pub type ContextServeState = std::sync::Mutex; fn diff_stats(patch: &str) -> Value { let mut files = Vec::new(); @@ -245,14 +163,13 @@ pub fn catalog() -> Vec { // ----- Read ----- tool( CONTEXT_TOOL_NAME, - "Load current construct memory, Program-run state, and widget paths. Call before each task. Pass returned etags later to omit unchanged content; request reference policy only when needed.", + "Load current construct memory, Program-run state, and widget paths. Call before each task. Repeat calls omit unchanged and already-served content automatically; everything omitted stays recoverable (memory by path, the program via construct_program_get).", json!({ "type": "object", "properties": { - "known_global": { "type": "string" }, - "known_project": { "type": "string" }, - "known_program": { "type": "string" }, - "include_reference": { "type": "boolean" } + "refresh": { "type": "boolean", "description": "Resend static fields and unchanged content. Pass true when earlier construct_context results may have been compacted out of your context." }, + "skip_memory": { "type": "boolean", "description": "Omit memory file contents you already hold verbatim (e.g. you just wrote them); paths and etags are still returned." }, + "include_reference": { "type": "boolean", "description": "Include the full memory/widget policy and Markdown extension reference." } } }), ), @@ -748,7 +665,12 @@ fn schema_obj(fields: &[(&str, &str, bool)]) -> Value { /// Dispatch a `tools/call` to the right method. Returns the full /// `tools/call` response payload (a `{content: [...], isError?}` object). -pub async fn call(client: &Arc, session_id: Option<&str>, params: Value) -> Result { +pub async fn call( + client: &Arc, + session_id: Option<&str>, + context_state: &ContextServeState, + params: Value, +) -> Result { let name = params .get("name") .and_then(|v| v.as_str()) @@ -770,7 +692,11 @@ pub async fn call(client: &Arc, session_id: Option<&str>, params: Value) let result_json: Value = match name.as_str() { // ----- Read ----- - CONTEXT_TOOL_NAME => compact_context_response(agent_context::build_from_env(), &args), + CONTEXT_TOOL_NAME => { + let request = agent_context::ContextRequest::from_args(&args); + let mut state = context_state.lock().unwrap_or_else(|p| p.into_inner()); + agent_context::compact_response(agent_context::build_from_env(), &request, &mut state) + } "construct_whoami" => json!({ "session_id": session_id }), "construct_list_sessions" => { let limit = args @@ -1778,7 +1704,10 @@ mod tests { } #[test] - fn context_etags_omit_unchanged_content_and_reference_policy_by_default() { + fn context_serve_state_omits_repeat_content_across_calls() { + // The dedup logic itself is covered in construct_protocol::agent_context; + // this pins the MCP dispatcher wiring: one process-level state, mutated + // across calls. let make_context = || agent_context::AgentdContext { session_id: Some("s1".into()), project_id: Some("p1".into()), @@ -1796,12 +1725,21 @@ mod tests { session_widgets: None, program_run: None, }; - let first = compact_context_response(make_context(), &json!({})); - let etag = first["global_memory"]["etag"].as_str().unwrap(); + let state = ContextServeState::default(); + let request = agent_context::ContextRequest::from_args(&json!({})); + let first = agent_context::compact_response( + make_context(), + &request, + &mut state.lock().unwrap(), + ); assert_eq!(first["global_memory"]["content"], "remember this"); assert!(first.get("memory_policy").is_none()); - let second = compact_context_response(make_context(), &json!({"known_global": etag})); + let second = agent_context::compact_response( + make_context(), + &request, + &mut state.lock().unwrap(), + ); assert_eq!(second["global_memory"]["unchanged"], true); assert!(second["global_memory"].get("content").is_none()); } @@ -1850,7 +1788,7 @@ mod tests { .get("description") .and_then(|description| description.as_str()) .unwrap_or_default() - .contains("Pass returned etags")); + .contains("omit unchanged and already-served content")); } #[test] @@ -1992,7 +1930,10 @@ mod tests { .pointer("/inputSchema/properties/revisions") .is_some()); assert!(find(CONTEXT_TOOL_NAME) - .pointer("/inputSchema/properties/known_global") + .pointer("/inputSchema/properties/refresh") + .is_some()); + assert!(find(CONTEXT_TOOL_NAME) + .pointer("/inputSchema/properties/skip_memory") .is_some()); let update = find("construct_program_update"); @@ -2200,6 +2141,7 @@ mod tests { let blocked = call( &client, Some("sparent"), + &ContextServeState::default(), json!({ "name": "construct_subagent_peek", "arguments": { "subagent_id": "sother" } @@ -2215,6 +2157,7 @@ mod tests { let legacy_name = call( &client, Some("sparent"), + &ContextServeState::default(), json!({ "name": "agentd_subagent_peek", "arguments": { "subagent_id": "ssub" } @@ -2242,6 +2185,7 @@ mod tests { let response = call( client, session_id, + &ContextServeState::default(), json!({ "name": name, "arguments": arguments }), ) .await diff --git a/crates/protocol/src/agent_context.rs b/crates/protocol/src/agent_context.rs index ec30b77e..c735fc93 100644 --- a/crates/protocol/src/agent_context.rs +++ b/crates/protocol/src/agent_context.rs @@ -31,7 +31,7 @@ pub const MCP_CONTEXT_ENV_VARS: &[&str] = &[ ]; pub const TOOL_DESCRIPTION: &str = - "Load construct global/project memory, pending program-run context, session widget paths, and operating context. Call this before starting any user task, before planning, and before using other tools. If program_run is present, treat it as the authoritative current program execution payload. Use the returned memory as durable context, follow its maintenance policy, update listed Markdown memory files with normal file tools when you learn durable information, and create/update session widgets when compact task status or actions would help the user."; + "Load construct global/project memory, pending program-run context, session widget paths, and operating context. Call this before starting any user task. If program_run is present, treat it as the authoritative current program execution payload. Use the returned memory as durable context, update listed Markdown memory files with normal file tools when you learn durable information, and create/update session widgets when compact task status or actions would help the user. Repeat calls omit static fields and content already served to you; pass refresh:true to resend everything (do this if earlier results were compacted out of your context), skip_memory:true to omit memory content you already hold, and include_reference:true for the full memory/widget policy and Markdown extension reference."; const WIDGET_POLICY: &[&str] = &[ "Use session widgets for compact task status, checklists, decision prompts, and action links that help the user monitor or steer the current session.", @@ -187,6 +187,224 @@ fn load_program_run_context(path: &Path) -> Option { serde_json::from_slice(&bytes).ok() } +// --------------------------------------------------------------------------- +// Compact serving (spec 0095): the dedup layer shared by `construct-mcp` and +// smith's native `agentd_context` tool. One serving process = one agent, so +// per-process state can omit anything already sent without requiring the +// model to round-trip etags. +// --------------------------------------------------------------------------- + +/// FNV-1a content hash used as an opaque etag for served context payloads. +pub fn content_etag(text: &str) -> String { + let mut hash = 0xcbf29ce484222325u64; + for byte in text.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{hash:x}") +} + +/// What this serving process has already sent to its agent. Must be reset +/// whenever the agent's context is compacted (the agent no longer holds what +/// was served): smith resets it natively on auto-compact, MCP agents pass +/// `refresh: true`. Everything omitted stays disk-recoverable — memory by +/// path, the program via the program-get tool — so a stale state degrades to +/// an extra fetch, never to data loss. +#[derive(Debug, Default, Clone)] +pub struct ContextServeState { + global_etag: Option, + project_etag: Option, + program_markdown_etag: Option, + run_reference_etag: Option, + static_served: bool, +} + +impl ContextServeState { + /// Forget everything served so far; the next response is complete again. + pub fn reset(&mut self) { + *self = Self::default(); + } +} + +/// Parsed arguments of one context-tool call. The `known_*` etag echoes are +/// the pre-state protocol; they are still honored so an agent whose serving +/// process restarted mid-conversation can keep suppressing content it holds. +#[derive(Debug, Default)] +pub struct ContextRequest { + pub include_reference: bool, + pub skip_memory: bool, + pub refresh: bool, + pub known_global: Option, + pub known_project: Option, + pub known_program: Option, +} + +impl ContextRequest { + pub fn from_args(args: &serde_json::Value) -> Self { + let flag = |key: &str| { + args.get(key) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + }; + let text = |key: &str| { + args.get(key) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }; + Self { + include_reference: flag("include_reference"), + skip_memory: flag("skip_memory"), + refresh: flag("refresh"), + known_global: text("known_global"), + known_project: text("known_project"), + known_program: text("known_program"), + } + } +} + +fn serve_memory_file( + file: Option, + known: Option<&str>, + served: &mut Option, + skip: bool, +) -> Option { + let file = file?; + let etag = content_etag(&file.content); + let already_held = known == Some(etag.as_str()) || served.as_deref() == Some(etag.as_str()); + let value = if already_held { + serde_json::json!({ "path": file.path, "etag": etag, "unchanged": true }) + } else if skip { + // The agent asserted it already holds current memory (e.g. it just + // wrote the file); trust that and mark the content as served. + serde_json::json!({ "path": file.path, "etag": etag, "omitted": true }) + } else { + let mut out = serde_json::Map::new(); + out.insert("path".into(), serde_json::json!(file.path)); + out.insert("content".into(), serde_json::json!(file.content)); + out.insert("etag".into(), serde_json::json!(etag)); + if file.truncated { + out.insert("truncated".into(), serde_json::json!(true)); + out.insert( + "remaining_bytes".into(), + serde_json::json!(file.remaining_bytes), + ); + } + serde_json::Value::Object(out) + }; + *served = Some(etag); + Some(value) +} + +fn serve_program_run( + run: Option, + req: &ContextRequest, + state: &mut ContextServeState, +) -> Option { + let run = run?; + // Two independent etags: the markdown changes every program edit, while + // the run instructions + smart-clip reference are static per daemon + // build. A monolithic etag would resend the static bulk on every edit. + let markdown_etag = content_etag(&run.markdown); + let reference_blob = + serde_json::to_string(&(&run.instructions, &run.smart_clips)).unwrap_or_default(); + let reference_etag = content_etag(&reference_blob); + + let mut out = serde_json::Map::new(); + out.insert("session_id".into(), serde_json::json!(run.session_id)); + out.insert( + "program_version".into(), + serde_json::json!(run.program_version), + ); + out.insert( + "program_updated_at_ms".into(), + serde_json::json!(run.program_updated_at_ms), + ); + out.insert("scope".into(), serde_json::json!(run.scope)); + out.insert("etag".into(), serde_json::json!(markdown_etag)); + let markdown_held = req.known_program.as_deref() == Some(markdown_etag.as_str()) + || state.program_markdown_etag.as_deref() == Some(markdown_etag.as_str()); + if markdown_held { + out.insert("unchanged".into(), serde_json::json!(true)); + } else { + out.insert("markdown".into(), serde_json::json!(run.markdown)); + } + if state.run_reference_etag.as_deref() != Some(reference_etag.as_str()) { + out.insert("instructions".into(), serde_json::json!(run.instructions)); + out.insert("smart_clips".into(), serde_json::json!(run.smart_clips)); + } + state.program_markdown_etag = Some(markdown_etag); + state.run_reference_etag = Some(reference_etag); + Some(serde_json::Value::Object(out)) +} + +/// Build the model-facing response for one context-tool call, updating the +/// serve state. Static fields (instructions, widget paths, the reference +/// hint) go out once per process; memory and program payloads carry etags and +/// collapse to `unchanged: true` when this process already served them. +pub fn compact_response( + mut context: AgentdContext, + req: &ContextRequest, + state: &mut ContextServeState, +) -> serde_json::Value { + if req.refresh { + state.reset(); + } + let global = serve_memory_file( + context.global_memory.take(), + req.known_global.as_deref(), + &mut state.global_etag, + req.skip_memory, + ); + let project = serve_memory_file( + context.project_memory.take(), + req.known_project.as_deref(), + &mut state.project_etag, + req.skip_memory, + ); + let program = serve_program_run(context.program_run.take(), req, state); + + let first_serve = !state.static_served; + let mut out = serde_json::Map::new(); + if let Some(id) = &context.session_id { + out.insert("session_id".into(), serde_json::json!(id)); + } + if let Some(id) = &context.project_id { + out.insert("project_id".into(), serde_json::json!(id)); + } + if first_serve { + out.insert("instructions".into(), serde_json::json!(context.instructions)); + if let Some(widgets) = context.session_widgets { + out.insert("session_widgets".into(), serde_json::json!(widgets)); + } + } + if let Some(memory) = global { + out.insert("global_memory".into(), memory); + } + if let Some(memory) = project { + out.insert("project_memory".into(), memory); + } + if let Some(program) = program { + out.insert("program_run".into(), program); + } + if req.include_reference { + out.insert("memory_policy".into(), serde_json::json!(context.memory_policy)); + out.insert("widget_policy".into(), serde_json::json!(context.widget_policy)); + out.insert( + "markdown_extensions".into(), + serde_json::json!(context.markdown_extensions), + ); + } else if first_serve { + out.insert( + "reference".into(), + serde_json::json!( + "Pass include_reference:true for memory/widget policy and Markdown extensions; refresh:true resends static fields and unchanged content (use after your context was compacted)." + ), + ); + } + state.static_served = true; + serde_json::Value::Object(out) +} + #[cfg(test)] mod tests { use super::*; @@ -342,6 +560,160 @@ mod tests { let _ = std::fs::remove_dir_all(tmp); } + fn sample_context() -> AgentdContext { + AgentdContext { + session_id: Some("s1".into()), + project_id: Some("p1".into()), + instructions: vec!["act".into()], + memory_policy: vec!["memory reference".into()], + widget_policy: vec!["widget reference".into()], + markdown_extensions: Vec::new(), + global_memory: Some(MemoryFile { + path: "/tmp/global.md".into(), + content: "remember this".into(), + truncated: false, + remaining_bytes: 0, + }), + project_memory: None, + session_widgets: Some(WidgetDirectory { + dir: "/tmp/widgets".into(), + glob: "*.md".into(), + title_source: "filename".into(), + action_link_scheme: "agentd:action/".into(), + }), + program_run: Some(ProgramRunContext { + session_id: "s1".into(), + program_version: 3, + program_updated_at_ms: 42, + scope: "full".into(), + instructions: vec!["run the program".into()], + smart_clips: vec![], + markdown: "# Plan\n\n- ship it".into(), + }), + } + } + + #[test] + fn first_serve_is_complete_repeat_serves_omit_served_content() { + let mut state = ContextServeState::default(); + let req = ContextRequest::default(); + + let first = compact_response(sample_context(), &req, &mut state); + assert_eq!(first["global_memory"]["content"], "remember this"); + assert_eq!(first["program_run"]["markdown"], "# Plan\n\n- ship it"); + assert_eq!(first["program_run"]["instructions"][0], "run the program"); + assert!(first.get("instructions").is_some()); + assert!(first.get("session_widgets").is_some()); + assert!(first.get("reference").is_some()); + assert!(first.get("memory_policy").is_none()); + + let second = compact_response(sample_context(), &req, &mut state); + assert_eq!(second["global_memory"]["unchanged"], true); + assert!(second["global_memory"].get("content").is_none()); + assert_eq!(second["program_run"]["unchanged"], true); + assert!(second["program_run"].get("markdown").is_none()); + assert!(second["program_run"].get("instructions").is_none()); + assert!(second.get("instructions").is_none()); + assert!(second.get("session_widgets").is_none()); + assert!(second.get("reference").is_none()); + // Identity and paths stay present so omitted content is recoverable. + assert_eq!(second["session_id"], "s1"); + assert_eq!(second["global_memory"]["path"], "/tmp/global.md"); + assert_eq!(second["program_run"]["program_version"], 3); + } + + #[test] + fn refresh_resends_everything() { + let mut state = ContextServeState::default(); + let _ = compact_response(sample_context(), &ContextRequest::default(), &mut state); + + let refreshed = compact_response( + sample_context(), + &ContextRequest { + refresh: true, + ..Default::default() + }, + &mut state, + ); + assert_eq!(refreshed["global_memory"]["content"], "remember this"); + assert!(refreshed.get("instructions").is_some()); + assert!(refreshed.get("session_widgets").is_some()); + assert_eq!(refreshed["program_run"]["markdown"], "# Plan\n\n- ship it"); + } + + #[test] + fn changed_markdown_resends_markdown_but_not_static_run_reference() { + let mut state = ContextServeState::default(); + let _ = compact_response(sample_context(), &ContextRequest::default(), &mut state); + + let mut context = sample_context(); + context.program_run.as_mut().unwrap().markdown = "# Plan\n\n- shipped".into(); + context.program_run.as_mut().unwrap().program_version = 4; + let second = compact_response(context, &ContextRequest::default(), &mut state); + assert_eq!(second["program_run"]["markdown"], "# Plan\n\n- shipped"); + assert_eq!(second["program_run"]["program_version"], 4); + assert!( + second["program_run"].get("instructions").is_none(), + "static run instructions must not ride along with markdown changes" + ); + assert!(second["program_run"].get("smart_clips").is_none()); + } + + #[test] + fn changed_memory_resends_content() { + let mut state = ContextServeState::default(); + let _ = compact_response(sample_context(), &ContextRequest::default(), &mut state); + + let mut context = sample_context(); + context.global_memory.as_mut().unwrap().content = "remember more".into(); + let second = compact_response(context, &ContextRequest::default(), &mut state); + assert_eq!(second["global_memory"]["content"], "remember more"); + } + + #[test] + fn skip_memory_omits_content_and_marks_it_served() { + let mut state = ContextServeState::default(); + let req = ContextRequest { + skip_memory: true, + ..Default::default() + }; + let first = compact_response(sample_context(), &req, &mut state); + assert_eq!(first["global_memory"]["omitted"], true); + assert!(first["global_memory"].get("content").is_none()); + assert_eq!(first["global_memory"]["path"], "/tmp/global.md"); + + // The agent asserted it holds this content; later calls treat it as served. + let second = compact_response(sample_context(), &ContextRequest::default(), &mut state); + assert_eq!(second["global_memory"]["unchanged"], true); + } + + #[test] + fn known_etag_echo_suppresses_content_with_fresh_state() { + let mut state = ContextServeState::default(); + let etag = content_etag("remember this"); + let req = ContextRequest { + known_global: Some(etag), + ..Default::default() + }; + let first = compact_response(sample_context(), &req, &mut state); + assert_eq!(first["global_memory"]["unchanged"], true); + assert!(first["global_memory"].get("content").is_none()); + } + + #[test] + fn include_reference_returns_policies() { + let mut state = ContextServeState::default(); + let req = ContextRequest { + include_reference: true, + ..Default::default() + }; + let response = compact_response(sample_context(), &req, &mut state); + assert_eq!(response["memory_policy"][0], "memory reference"); + assert_eq!(response["widget_policy"][0], "widget reference"); + assert!(response.get("markdown_extensions").is_some()); + assert!(response.get("reference").is_none()); + } + fn clear_env() { std::env::remove_var(ENV_SESSION_ID); std::env::remove_var(ENV_GLOBAL_MEMORY_FILE); diff --git a/specs/0095-mcp-agent-context-budgets.md b/specs/0095-mcp-agent-context-budgets.md index 05676a99..38b19dc3 100644 --- a/specs/0095-mcp-agent-context-budgets.md +++ b/specs/0095-mcp-agent-context-budgets.md @@ -1,7 +1,7 @@ # 0095-mcp-agent-context-budgets Status: accepted -Date: 2026-07-14 +Date: 2026-07-15 Area: protocol Scope: Model-facing MCP tools return bounded, compact projections instead of UI-oriented daemon payloads. @@ -16,7 +16,9 @@ Construct's MCP boundary is an agent-context API, not a transparent serializatio - PTY reads default to a bounded byte tail and expose offsets so callers can page backward deliberately. - Program reads return current Markdown once, compact block addressing/status, and no revision bodies by default. Program write/execute responses acknowledge the mutation and return only identities the caller could not already know. - Program verb/template listings expose selection metadata by default; internal prompts and template bodies are returned only when explicitly requested. -- Agent context omits long reference policies and the Markdown registry by default, and supports content etags so unchanged memory or Program-run payloads need not be repeated. +- Agent context omits long reference policies and the Markdown registry by default, and never repeats what its serving process already sent: the server remembers per process what it served (one serving process serves one agent), so unchanged memory and Program-run payloads collapse to an etag marker without requiring the model to echo etags back. Static payloads (standing instructions, widget paths, reference hints) are served once per process. Program-run content and its static run reference (run instructions, smart-clip syntax) are tracked independently, so a Program edit does not resend the static reference. +- Everything a serving process omits must remain recoverable out-of-band (memory by file path, the Program via its read tool), and the agent must have an explicit refresh escape that resends everything. This is the compaction contract: when the agent's own context is compacted, served-but-dropped content is re-obtainable; a harness that controls its own compaction resets the serve state itself, and external agents pass refresh. +- The compact agent-context serving behavior is identical across surfaces: MCP and any native harness context tool share one implementation. A native tool must not bypass the budget layer (no pretty-printed or unconditionally-full serializations). The daemon's rich IPC structs remain authoritative for interactive clients. MCP projections may intentionally have a different and breaking shape.