Skip to content

Pass attachments to subagents directly and set subagent mode - #649

Open
hanna-paasivirta wants to merge 12 commits into
mainfrom
subagent-context
Open

Pass attachments to subagents directly and set subagent mode#649
hanna-paasivirta wants to merge 12 commits into
mainfrom
subagent-context

Conversation

@hanna-paasivirta

@hanna-paasivirta hanna-paasivirta commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Short Description

Attachments (run logs, dataclips) now reach subagents as the exact text the user sent. Before, only the planner's own message got through, so a subagent saw a summary of a log, or nothing. Attachments also no longer stick to the conversation history, and oversized ones are refused rather than quietly cut down.

Fixes #643

Implementation Details

The router used to splice attachments into the user's message as text, and nothing carried them further if the planner was called. So a user attaching a run log and asking "why did the last two steps fail?" got the planner's summary of the log instead of the log. The workflow YAML already travels as a payload field; now attachments do too.

Each agent receives them the way it already receives that kind of context.

  • job_chat is unchanged — byte-identical to main. It already had context.log / context.input / context.output, rendering as <run_logs> / <input> / <output>. Attachments map onto those rather than getting a second channel.
  • workflow_chat and the planner have no such fields, so they take an attachments payload field and render it where their other context goes. With none, workflow_chat's prompt is byte-identical to before.
  • Five attachment types, three context fields. input_dataclip is a step's input and run_input is the whole run's, so both want input. When a field has two sources both are kept and labelled with their type; with one source it is unlabelled, exactly as before. Unlabelled with two would tell the model a run's input is the step's.
  • An attachment whose type is outside those five is not passed on, and is reported (see Sentry below). A new type needs one line in the mapping.

Who decides what a subagent gets depends on the route.

  • The router forwards everything to whichever single subagent it picks. There is no one else to judge.
  • The planner names, per call, which attachments that subagent must read itself, so a step told "change state.patients to state.cases" is not billed for a log the planner already read. Both tools take a required attachments list.
  • What travels is the original content. The planner is told that reading an attachment does not pass it on, and describing it is not the same as sending it.
  • If it withholds wrongly, the subagent truthfully says it has no log to a user who attached one. So the planner is also told: if a subagent says it lacks something you hold, call again with it rather than relaying the complaint.

Attachments no longer stick to the history.

  • The enriched string used to be what got saved, so an attached log became a permanent turn, re-sent forever with nothing marking it stale.
  • They now belong to the turn they arrived on. A log that still applies later has to be sent again.
  • The payload shape is unchanged; the behaviour is. If Lightning already attaches the current run's log each turn, nothing changes for it. Documented in PAYLOAD_SPEC.md.

Planner-invoked subagents were running in production mode.

  • call_job_agent never set subagent: True, and put the YAML in context["workflow_yaml"], which Payload.from_dict does not read. So job_chat ran under the production scope prompt ("You ONLY help with job code..."), with no <workflow_structure> block and no inspect_job_code tool. The router's direct route set both correctly.
  • call_workflow_agent had the same defect, keeping its "save your workflow and go to the Inspector" instruction. Both now run in subagent mode.
  • Subagent mode gives job_chat an escalation tool whose target is the planner, which is now the caller. format_subagent_result_for_llm turns that handover into the reason the agent could not finish, for the planner to act on.

Oversized attachments are refused, never trimmed. Shortening one would mean answering from evidence the user thinks we read in full. Context Apollo injects itself, like adaptor docs, is a different case and is still truncated.

  • Over 250,000 characters across all attachments, the turn is rejected with 400 ATTACHMENT_TOO_LARGE before any model is called. Reading long logs properly is a separate future project, Add tools for reading long logs and other attachments #651.
  • This is the user-facing path, not a last resort: Lightning sends without pre-checking and rephrases what comes back, so details carries total_characters, limit_characters and largest_attachment. On /stream it arrives as an SSE error event under a 200, so match on type, not HTTP status.
  • The limit is what a subagent prompt can hold once the window, the max_tokens reserve, the static prompt and adaptor docs are accounted for. The arithmetic sits next to the constant.

Typed attachment content, and the order to ship in. Lightning is moving content from always-a-string to typed per type — an array of lines for a log, an object for a dataclip.

  • Nothing breaks, since every read was already str()-wrapped. But str() on a list gives Python repr, so a log would arrive as one single-quoted line. attachment_text renders by shape instead: lines joined with newlines, objects as indented JSON, strings untouched.
  • It accepts both shapes, so Apollo should go first. Not a blocker — if Lightning lands first nothing errors, the cost is only that log analysis gets quietly worse.

Sentry now says what kind of failure it was. This is the change with reach beyond global_chat, so it is the one to read closely.

  • Every ApolloError gets an apollo_error_type tag and its code/details on an apollo_error context, so a class of failure can be counted with apollo_error_type:ATTACHMENT_TOO_LARGE rather than by matching message wording. That count is what decides whether Add tools for reading long logs and other attachments #651 is worth doing.
  • An error the caller caused goes in at warning: searchable, not paging. Anything we might have to fix stays at error.
  • Status code alone does not decide that. AUTH_ERROR (401) and RATE_LIMIT (429) mean Anthropic rejected or throttled Apollo's own key, so they are named in PROVIDER_FAILURE_TYPES and keep alerting. Worth checking that list is complete.
  • A dropped attachment is now reported at warning level too — only when it had real content, so a request with nothing attached stays silent. It is content the user sent that we did not pass on, which nobody finds out about otherwise.

Nothing in the two production services changes. Checked rather than assumed, since Lightning calls them directly:

  • job_chat has no source change at all — only its README and two new acceptance specs.
  • workflow_chat's built prompt hashes identically to main across all five modes (plain, errors, read-only, subagent, no YAML) when no attachments are sent, and its one production build_prompt caller passes every argument by keyword, so the new parameter cannot shift a positional.
  • util.py is additions only; no existing helper changed.
  • entry.py is the exception, and it affects every service — see Sentry above.

Tests

  • Unit — the mapping, selection, typed content, the size guard, the Sentry tagging, the subagent payloads.
  • Service — first tests to use this existing tier (defined in pyproject.toml and conftest.py, until now unused). The whole router → planner → job_chat chain with every LLM call scripted, and the attachment carries a canary, so arrival is checked on bytes rather than wording. Free to run.
  • Integration — whether a real planner names the right attachments, which no scripted test can cover. This caught a real prompt bug: the first wording made "have I already read it" the discriminator, and since the planner has always read the log it forwarded almost nothing.
  • Acceptance — one spec for the planner reasoning from a log across steps.

The four tiers are now described in services/testing/README.md.

Known and not fixed here

  • An attachment whose type is outside the five known ones is dropped on the job route, where main delivered it as text. It reports to Sentry but does not reach the model.
  • A handover between planner-invoked subagents could ping-pong until max_tool_calls.
  • Sentry frame locals still carry attachment content, which matters more now these events are kept on purpose. Wants its own issue.
  • A too-long prompt still surfaces differently per route (PROMPT_TOO_LONG, raw BAD_REQUEST, or a 500). Attachments can no longer cause it, but a long conversation still can.

AI Usage

Please disclose whether you've used AI in this work (it's cool, we just want to
know!):

  • Yes, I have used AI
  • No, I have not used AI

You can read more details in our
Responsible AI Policy

@hanna-paasivirta
hanna-paasivirta marked this pull request as ready for review August 26, 2026 17:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Global assistant: Subagents don't receive the context the planner was given

1 participant