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(...) installs SpillingContextEditingMiddleware (main_agent/middleware/context_editing/middleware.py). When context exceeds 0.55 × max_input_tokens (see context_editing/builder.py), older ToolMessage contents are persisted to the tool_output_spills table first and replaced with a spill_<uuid> placeholder; the main agent can read them back via read_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
ChatLiteLLM instance, so every subagent model call still passes through ChatLiteLLM._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 order tool/assistant → user with <document>). Unlike main-agent spills, the original content is gone forever.
- Pair-splitting. Admission truncates message content independently, so a
ToolMessage can be cut while its originating AIMessage.tool_calls args remain intact — or vice versa. Some providers reject these malformed sequences; in the extreme, preserve_protected_content=True leads to ContextOverflowError after 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_tokens and workspace_id through subagent_dependencies in main_agent/middleware/stack.py (build_main_agent_deepagent_middleware).
- In
subagents/shared/subagent_builder.py::pack_subagent, build one SpillingContextEditingMiddleware instance per subagent via the existing build_context_editing_mw(flags, max_input_tokens, tools=<subagent's tools>, workspace_id) and insert it after tool_call_limit (mirroring the main-agent stack, where context editing precedes permission). The factory already returns None when the flag is off or max_input_tokens is 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_editing flag — 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) under resolve_max_input_tokens.
Test plan
- Assembly matrix: flag ×
max_input_tokens (4 combinations) → middleware present/absent as expected.
- Per-instance isolation: two
pack_subagent calls yield distinct SpillingContextEditingMiddleware instances.
- Trigger behavior: oversized tool-output message sequence → rows in
tool_output_spills with *::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.
Summary
The orchestrator's middleware stack has a lossless context-defense layer that subagents don't get. Long-running specialists (deep
web_crawlerchains, multi-pagegoogle_search, etc.) currently fall back to character-level truncation inadmit_langchain_messages, which is lossy, can splitAIMessage.tool_calls/ToolMessagepairs, 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_onefrom the spec built insubagents/shared/subagent_builder.py::pack_subagent):PatchToolCallsMiddleware. No context editing, no compaction.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)
trim_messages_to_fit_context, priority ordertool/assistant→userwith<document>). Unlike main-agent spills, the original content is gone forever.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.{parent_thread}::task:{tool_call_id}) grow without any cleanup policy.Concrete scenario: a
web_crawlersubagent crawling a long article chain accumulates several largeToolMessages 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:
max_input_tokensandworkspace_idthroughsubagent_dependenciesinmain_agent/middleware/stack.py(build_main_agent_deepagent_middleware).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:
Spill rows land under the subagent's
thread_id({parent}::task:{tool_call_id}, set insubagents/shared/invocation.py::subagent_invoke_config), so per-invocation isolation and deterministicuuid5spill ids come for free.Why a per-subagent instance (not the shared stack)
build_subagent_middleware_stackreturns one dict shared by all subagents, and batchtask(tasks=[...])fan-out runs several subagents concurrently.SpillingContextEditingMiddlewarebufferspending_spillsper instance (SpillToBackendEdit.pending_spills, drained inawrap_model_call), so a single shared instance would let concurrent runs drain each other's rows. Building it insidepack_subagent(one call per subagent spec) avoids the problem entirely.Gating & scope
enable_context_editingflag — off by default keeps current behavior unchanged; on, subagents become symmetric with the main agent.StateBackend), subagent checkpoint pruning, unifying the three budget thresholds (compaction / context editing / admission) underresolve_max_input_tokens.Test plan
max_input_tokens(4 combinations) → middleware present/absent as expected.pack_subagentcalls yield distinctSpillingContextEditingMiddlewareinstances.tool_output_spillswith*::task:*thread ids, placeholders substituted, protected tool names (prune_tool_names.py) exempt.Questions
plans/may not show internal tracks)?enable_context_editing, or introduce a separate subagent-scoped flag?Thanks for the great architecture — happy to adjust the proposal to fit your roadmap.