Skip to content

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

Merged
elias-ba merged 14 commits into
mainfrom
subagent-context
Sep 5, 2026
Merged

Pass attachments to subagents directly and set subagent mode#649
elias-ba merged 14 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

@elias-ba elias-ba left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work Hanna, and sorry this sat with me so long.

One change and two questions, all inline. The change is small: our own 403s from Anthropic currently come in at warning level, so they stop alerting.

The two questions are about intent rather than correctness, and I did not want to push a guess into your branch.

Comment thread services/entry.py Outdated
Comment thread services/util.py
Comment thread services/global_chat/subagent_caller.py
The only conflict was the global_chat README: main rewrote the sentence about
the tool-call budget while this branch added a paragraph about subagent mode
above it. Both kept.
build_focus_line returned "" whenever the caller could not say what the user
had on screen. The planner is exactly that caller: it knows which step it
asked for and nothing about the view, so it sent job_key and the sentence was
never built. The subagent then got a redacted workflow with no indication of
which step it was there to edit. It now names the editable step on its own
and claims nothing about the view. The router paths are unchanged.

FORBIDDEN joins PROVIDER_FAILURE_TYPES. It is only ever raised from
Anthropic's PermissionDeniedError, so it means our account rather than the
caller's, and it was going to Sentry at warning level with the caller errors.

BAD_REQUEST is deliberately left out: it also covers genuine caller mistakes
like echo's missing payload, so promoting it would page us for those.

@elias-ba elias-ba left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amazing piece of work @hanna-paasivirta. I am merging this one. I pushed commits to address 2 things:

  1. The focus line now names the editable step when there is no viewing, which is the planner's case, so a subagent finally learns which step it is there to edit. job_key was reaching prompt.py and becoming focused all along; it just could not render without viewing, so my earlier comment pointed at the wrong place. Sorry about that.
  2. FORBIDDEN is in PROVIDER_FAILURE_TYPES now. I left BAD_REQUEST out on purpose, since it also covers genuine caller mistakes and would page us for those.

On the attachments question, I checked lightning#5096: the 3 types you send are all mapped, so nothing is dropped today.

I also merged main in to clear the conflict. The only clash was the global_chat README, where main had rewritten the budget sentence under your new paragraph, and I kept both.

@elias-ba
elias-ba merged commit b8405ac into main Sep 5, 2026
2 checks passed
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

2 participants