[Proposal] Extend context-editing spill protection to subagents — lossy admission truncation is their only context defense today
Summary
The orchestrator's middleware stack has a lossless context-defense layer that subagents don't get. Long-running specialists (deep web_crawler chains, multi-page google_search, etc.) currently fall back to character-level truncation in admit_langchain_messages, which is lossy, can split AIMessage.tool_calls / ToolMessage pairs, and leaves nothing recoverable. I'd like to propose a small (~200 line) PR that gives every subagent the same spill-based context editing the main agent already has, and I'm volunteering to implement it.
Current behavior
Main agent (build in surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/stack.py):
build_context_editing_mw(...)installsSpillingContextEditingMiddleware(main_agent/middleware/context_editing/middleware.py). When context exceeds0.55 × max_input_tokens(seecontext_editing/builder.py), olderToolMessagecontents are persisted to thetool_output_spillstable first and replaced with aspill_<uuid>placeholder; the main agent can read them back viaread_run/search_run.SurfSenseCompactionMiddleware(app/agents/chat/shared/middleware/compaction.py) additionally summarizes older history.
Subagents (compiled in main_agent/middleware/checkpointed_subagent_middleware/middleware.py::_compile_one from the spec built in subagents/shared/subagent_builder.py::pack_subagent):
- Middleware list is: todos, citation, retry, fallback, model_call_limit, tool_call_limit, per-subagent permission,
PatchToolCallsMiddleware. No context editing, no compaction. - Subagents share the same
ChatLiteLLMinstance, so every subagent model call still passes throughChatLiteLLM._admit_messages→admit_langchain_messages(app/services/context_admission.py). Admission is their only context defense.
Failure modes (observed consequences of the asymmetry)
- Lossy, unrecoverable truncation. When a long subagent run crosses the admission budget, tool outputs are truncated character-by-character (
trim_messages_to_fit_context, priority ordertool/assistant→userwith<document>). Unlike main-agent spills, the original content is gone forever. - Pair-splitting. Admission truncates message content independently, so a
ToolMessagecan be cut while its originatingAIMessage.tool_callsargs remain intact — or vice versa. Some providers reject these malformed sequences; in the extreme,preserve_protected_content=Trueleads toContextOverflowErrorafter emptying every removable message. - (Out of scope, listed for context) Subagent checkpoints (
{parent_thread}::task:{tool_call_id}) grow without any cleanup policy.
Concrete scenario: a web_crawler subagent crawling a long article chain accumulates several large ToolMessages mid-run. Today: admission hard-truncates them, the subagent continues with corrupted context, and the parent receives a final answer built on truncated data with no way to inspect what was lost.
Proposed change
Reuse the existing, battle-tested factory for subagents — no new abstractions:
- Thread
max_input_tokensandworkspace_idthroughsubagent_dependenciesinmain_agent/middleware/stack.py(build_main_agent_deepagent_middleware). - In
subagents/shared/subagent_builder.py::pack_subagent, build oneSpillingContextEditingMiddlewareinstance per subagent via the existingbuild_context_editing_mw(flags, max_input_tokens, tools=<subagent's tools>, workspace_id)and insert it aftertool_call_limit(mirroring the main-agent stack, where context editing precedes permission). The factory already returnsNonewhen the flag is off ormax_input_tokensis missing, so behavior stays identical by default.
Sketch:
# pack_subagent(), middleware assembly section
context_editing = build_context_editing_mw(
flags=dependencies["flags"],
max_input_tokens=dependencies.get("max_input_tokens"),
tools=tools,
workspace_id=dependencies.get("workspace_id"),
)
if context_editing is not None:
prepended.append(context_editing)# stack.py, subagent_dependencies construction
"max_input_tokens": max_input_tokens,
"workspace_id": workspace_id,Spill rows land under the subagent's thread_id ({parent}::task:{tool_call_id}, set in subagents/shared/invocation.py::subagent_invoke_config), so per-invocation isolation and deterministic uuid5 spill ids come for free.
Why a per-subagent instance (not the shared stack)
build_subagent_middleware_stack returns one dict shared by all subagents, and batch task(tasks=[...]) fan-out runs several subagents concurrently. SpillingContextEditingMiddleware buffers pending_spills per instance (SpillToBackendEdit.pending_spills, drained in awrap_model_call), so a single shared instance would let concurrent runs drain each other's rows. Building it inside pack_subagent (one call per subagent spec) avoids the problem entirely.
Gating & scope
- Gated by the existing
enable_context_editingflag — off by default keeps current behavior unchanged; on, subagents become symmetric with the main agent. - In scope: spill-based context editing for subagents + tests.
- Out of scope (possible follow-ups, happy to discuss): subagent compaction (summarization offload path conflicts with the shared
StateBackend), subagent checkpoint pruning, unifying the three budget thresholds (compaction / context editing / admission) underresolve_max_input_tokens.
Test plan
- Assembly matrix: flag ×
max_input_tokens(4 combinations) → middleware present/absent as expected. - Per-instance isolation: two
pack_subagentcalls yield distinctSpillingContextEditingMiddlewareinstances. - Trigger behavior: oversized tool-output message sequence → rows in
tool_output_spillswith*::task:*thread ids, placeholders substituted, protected tool names (prune_tool_names.py) exempt. - Existing main-agent tests must stay green (main path untouched).
Questions
- Would you be interested in accepting a PR along these lines?
- Is anything already planned for subagent context management (public board /
plans/may not show internal tracks)? - Preference on gating: reuse
enable_context_editing, or introduce a separate subagent-scoped flag?
Thanks for the great architecture — happy to adjust the proposal to fit your roadmap.
Source: MODSetter/SurfSense