Design: Wire HW_ATTACHMENT into the ExpertβSpecialist consult path
- Issue: #4092 (design/investigation only β no production code changes in this PR)
- Parent: #4089 β ExpertβSpecialist side-chat cannot produce file attachments
- Status: Proposed
- Owners: agent runtime (
agent/), no NestJS changes required
1. Root-cause validation (at current HEAD)β
Two gaps confirmed against agent/prompts_loader.py and agent/main.py on dev (post-#4059/#4021 re-architecture):
1.1 Prompt gap β agent/prompts_loader.pyβ
build_system_prompt_with_meta() branches hard on is_expert_consultation:
# prompts_loader.py:467-485
if is_expert_consultation:
template = _strip_json_contract(template)
...
if is_expert_consultation:
security_block = _security_prefix_for_consultation()
return security_block + soul_block + specialist_persona_header + body, meta
attachment_block = ATTACHMENT_PROTOCOL if attachments_enabled() else ""
...
prompt = (
SECURITY_PREFIX + CLARIFICATION_POLICY + attachment_block + web_block
+ soul_block + specialist_persona_header + body
)
The is_expert_consultation=True branch returns before attachment_block is even computed. The model is never told the <<<HW_ATTACHMENT ... HW_ATTACHMENT>>> directive exists during a consult turn β confirmed, this is the exact early-return described in the issue (module docstring at prompts_loader.py:367-372 documents this as intentional: "the CLARIFICATION_POLICY and ATTACHMENT_PROTOCOL blocks are omitted (not relevant for internal chat)"). That comment is the artifact to change.
1.2 Extraction gap β agent/main.py, both consult branchesβ
Non-streaming (/chat, main.py:666-697):
if _is_expert:
...
completion = await consult_complete(messages)
...
agentReply, confidence, flag, reason = extract_hermes_payload(
agentReply, is_expert_consultation=True
)
...
outbound_artifacts = [] # main.py:697 β hardcoded
Streaming (/chat/stream, main.py:1076-1127): identical shape β consult_complete(messages, stream=True), token/reasoning deltas accumulated, extract_hermes_payload(..., is_expert_consultation=True), then outbound_artifacts = [] (main.py:1127).
Neither branch calls _extract_and_render_attachments() (defined main.py:511-584, called only in the non-expert else branches at lines 750 and 1177). Confirmed: this is the gap, and it is identical in both the sync and SSE consult paths.
1.3 Confirmed working, no change neededβ
- NestJS
ExpertAgentThreadService.attachGeneratedFiles()(api/src/conversations/expert-agent-thread.service.ts:1192+) already materializesagentResponse.outboundArtifactsviaOutboundArtifactMaterializer, gated byai_reply_attachments_enabled(default ON), fail-open, called from both the non-streaming path (line 213) and the SSEdone-frame path (line 648) vianormalizeAgentDonePayload(line 764). - Frontend
SideChatPanel.tsxAttachmentListrenders whatever comes back. api/test/expert-agent-thread-attachments.spec.tsalready covers the NestJS side: flag gate, fail-open,normalizeAgentDonePayloadartifact parsing.
Net: the entire consumer chain (NestJS β frontend) is ready and idle, waiting for outbound_artifacts to ever be non-empty out of the agent for a consult turn. This is a pure agent-side gap.
2. Reconciliation with #4059 (structured-output consult path)β
#4059 replaced the old <final>-scrape consult mechanism with llm.consult_complete() β a direct OpenRouter structured-output call where message.content is the reply and message.reasoning (or .reasoning_content) is the rationale channel, deliberately kept separate so the "Show rationale" UI split holds.
This matters for where attachment extraction can be inserted:
extract_hermes_payload(text, is_expert_consultation=True)operates onagentReply, which is built frommsg.content(non-stream, main.py:674) or accumulatedcontent_deltachunks (stream, main.py:1101-1103) β never from the reasoning channel. Good: this is exactly the surface the design must parseHW_ATTACHMENTdirectives from.merge_reasoning()(chat_helpers.py:819-823) only touchesreasoning/reasoning_contentparts and is entirely orthogonal to the reply-content parsing β no interaction risk with attachment extraction as long as extraction runs onagentReply/msg.contentbefore or alongsideextract_hermes_payload, not on the reasoning stream.- Ordering constraint carried over from the main path:
_extract_and_render_attachments()must run beforeextract_hermes_payload(), same as the non-expert branches (main.py:750β753, 1177β1180) β a large unstripped directive block would otherwise reach the prompt-echo guards (_looks_like_prompt_echo_strict) and could itself look like scaffold leakage, or simply pollute the prose the Expert reads if extraction fails to run first. - Do not append
ATTACHMENT_PROTOCOLto_security_prefix_for_consultation()or otherwise conflate it withSECURITY_PREFIXβ keep it a sibling block so the rule-#5 JSON-contract stripping (#3965) and the attachment protocol are independently toggleable, matching how the main path already keepsCLARIFICATION_POLICYandattachment_blockas separate concatenated segments.
3. Approachesβ
Approach A β Targeted patch (mirror main-path calls into both consult branches)β
Prompt side (prompts_loader.py): inside the is_expert_consultation branch, before the early return, conditionally append an attachment block:
if is_expert_consultation:
security_block = _security_prefix_for_consultation()
consult_attachment_block = ATTACHMENT_PROTOCOL if attachments_enabled() else ""
# Mirror the main path's per-tenant image gate (prompts_loader.py:492-498) β
# a Specialist with image generation disabled must not have that gate
# silently skipped just because the request came in via the consult surface.
if consult_attachment_block and agent_context and agent_context.get("image_generation_enabled") is False:
consult_attachment_block += (
"IMAGE GENERATION UNAVAILABLE: do NOT emit an HW_ATTACHMENT block with "
'format "image" in this reply. If the client asks for a picture, '
"illustration, or photo, tell them image generation is not available "
"right now. (Data charts via png and other file formats still work.)\n\n"
)
return security_block + consult_attachment_block + soul_block + specialist_persona_header + body, meta
This is not an optional nice-to-have β it must land in the same commit as the ATTACHMENT_PROTOCOL addition, not as a follow-up, since shipping the protocol without the gate would let a consult turn bypass a tenant's image-generation opt-out that the main path already enforces.
Extraction side (main.py, both /chat and /chat/stream): after building agentReply from msg.content / accumulated content_delta, and before extract_hermes_payload, call:
agentReply, _att_had_error, _att_error = _extract_and_render_attachments(
agentReply, workspace, req.org_id
)
agentReply, confidence, flag, reason = extract_hermes_payload(
agentReply, is_expert_consultation=True
)
if _att_had_error:
flag = True
reason = _att_error or reason
outbound_artifacts = file_exchange_client.persist_outbound_artifacts(
workspace, org_id=req.org_id, ...
)
file_exchange_client.write_manifest(workspace, inbound=inbound_artifacts, outbound=outbound_artifacts)
This is a ~15-line diff duplicated across 2 call sites (4 total counting stream+non-stream), reusing the exact same _extract_and_render_attachments function the main path already calls β no new abstraction, minimal surface area, easy to review and easy to Greptile-check.
Tradeoffs: duplication across 4 sites (2 prompt branches conceptually collapse to 1 if tweak; 2 extraction call sites are genuinely separate coroutines). Slight drift risk if a future change to the main path's attachment handling isn't mirrored.
Approach B β Unify consult/main attachment handling into one shared helperβ
Extract a single _finalize_reply_with_attachments(raw_text, workspace, org_id, is_expert_consultation) helper that both the main and consult branches (stream + non-stream, all 4 call sites) delegate to β it internally calls _extract_and_render_attachments, extract_hermes_payload, and returns (reply, confidence, flag, reason, outbound_artifacts) in one shot, with persist_outbound_artifacts/write_manifest left to the caller (workspace/manifest semantics already differ slightly by role).
Tradeoffs: removes duplication and centralizes future changes (e.g. a fifth call site), but touches the two already-working non-expert branches purely for refactor value β higher blast radius for a design that's supposed to be low-risk, and mixes a refactor concern into a feature-add PR, which regression-bot history in this repo (references/regression-bot-triage.md pattern) flags as exactly the kind of change that makes root-cause debugging harder later if it regresses.
Recommendation: Approach Aβ
Mirror the exact calls, don't refactor the working main-path branches. The 4089 issue is scoped to "wire attachments into consult," not "unify the two paths." Approach A has the smallest diff, is the easiest to Greptile/regression-test in isolation, and it doesn't touch the already-covered main-path branches at all (zero regression risk to feat/hermes-agent's largest existing surface). If a third consult-like surface appears later, revisit unification then β YAGNI now.
4. Streaming extraction: token suppression is mandatory, not just deferred extractionβ
The naive plan is broken and Greptile correctly caught it in review of this doc. The stream branch today yields each content_delta as a live SSE token event as it arrives (main.py:1101-1104):
content_delta = getattr(delta, "content", None)
if content_delta:
reply_parts.append(content_delta)
yield _sse("token", {"token": content_delta}) # <-- fires immediately, per-delta
Simply deferring extraction to the done-frame (as an initial draft of this section proposed) does not stop the raw <<<HW_ATTACHMENT ... HW_ATTACHMENT>>> block from being emitted to the client token-by-token before the done-frame's cleaned payload ever arrives β the tokens are already gone over SSE by the time post-processing runs. This is a real leak: the Expert's client would see the raw JSON-metadata-line + --- + raw file content mid-stream, then a "cleaned" final reply that contradicts what they just watched stream in.
Required design: buffer content deltas and withhold token SSE emission until the directive region is known to be closed, mirroring the class of protection the non-expert path gets for free by not streaming raw model output at all. Two viable variants, either acceptable for the follow-up implementation to choose between:
- (a) Full buffer-and-flush-once-closed (simplest, recommended default): accumulate
content_deltaintoreply_partsWITHOUT yieldingtokenevents during accumulation; run_extract_and_render_attachmentsonce the stream completes; then emit the cleaned text as either one largetokenevent or a finaldone-only payload (no incremental token events for consult turns at all). This sacrifices token-by-token UX for consult replies but guarantees zero leak β correctness over UX for a first cut, consistent with attachments already being an edge case for this surface today (currently zero consult turns produce attachments). - (b) Delimiter-aware suppression (better UX, more complex): stream tokens normally by default; the moment accumulated text contains the
<<<HW_ATTACHMENTopening delimiter, stop yieldingtokenevents and buffer silently until the matchingHW_ATTACHMENT>>>closing delimiter is seen (or the stream ends), then resume normal token streaming for any trailing prose and run extraction on the buffered span at the done-frame. Requires careful handling of: false-positive prefixes (partial<<<HW_ATTmid-chunk before enough characters have arrived to confirm/deny a match), and multiple directive blocks in one reply (repeat the suppress/resume cycle per block).
Recommendation: ship (a) first β it's a small, obviously-correct diff and attachments-bearing consult replies are not latency-sensitive (the client is already waiting on a materialized file). Revisit (b) only if product feedback says the loss of token streaming specifically on attachment-bearing consult turns is user-visible and matters (unlikely, given no consult turn produces attachments today, i.e. zero regression baseline).
Rejected: incremental extraction (i.e. attempting to parse+render partial directive blocks as deltas arrive). HW_ATTACHMENT directives are multi-line JSON+delimiter+raw-content blocks (see ATTACHMENT_PROTOCOL spec, prompts_loader.py:133-148); the render logic (extract_attachment_directives_ex, render_all_ex) is regex/structural over a complete string, not designed to run on partial input. This is a different axis from the token-leak fix above (that's about emission, this is about rendering) β both point to "must have the full text before doing anything file-related," but (a)/(b) above only address emission timing; rendering still only happens once, on the complete buffered text.
Conclusion: with (a), consult turns lose per-token streaming UX but gain a correctness guarantee (zero directive leakage); with (b), UX is mostly preserved at the cost of implementation complexity. Either way there is no latency regression versus current behavior β outbound_artifacts today is unconditionally empty and no attachment-aware UI work happens on /chat/stream at all yet, so today's baseline has zero attachment-bearing consult turns to regress against.
Interaction with delta.reasoning/delta.content split (#4059): unaffected β reasoning deltas are collected into reasoning_parts independently (main.py:1105-1110) and never touched by attachment extraction, which only operates on the joined reply_parts string.
5. Where attachments_enabled() / image-gate logic belongs for consultsβ
Reuse the existing agent-side env kill switch (HW_ATTACHMENT_ENABLED, prompts_loader.attachments_enabled()) for the consult path too β it's a single process-wide gate already consulted by _extract_and_render_attachments() (main.py:527) and by the prompt builder for the main path; there is no reason a consult turn should be able to render attachments if the operator has killed the feature agent-wide.
Per-tenant ai_reply_attachments_enabled stays purely NestJS-side, exactly as it is today: ExpertAgentThreadService.attachGeneratedFiles() (line 1202) already resolves this flag and no-ops (return message) when disabled. The agent does not need to know about it β this is the same fail-open division of responsibility the main path uses (agent always tries to produce outbound_artifacts; NestJS decides per-tenant whether to materialize/serve them). Do not duplicate the per-tenant flag check into the agent; that would require plumbing a new field through agent_context for zero behavioral gain, since NestJS already fully gates on its side.
Image-gate (agent_context.get("image_generation_enabled") is False β append the "do NOT emit an image" instruction, prompts_loader.py:492-498): mirror this into the consult branch's attachment block construction for parity β a Specialist who has image generation disabled for the org shouldn't have that gate silently skipped just because the Expert is asking via the internal consult surface instead of drafting a client reply. This is a small addition to the Approach A prompt diff (Β§3), not a new mechanism.
6. Blast radius and failure modesβ
- Fail-open is mandatory, mirroring the main path exactly:
_extract_and_render_attachments()already never raises (main.py:523-525 docstring) β any parse/render error strips the directive block and returns(cleaned, had_error=True, message). Wiring this into the consult branches means an attachment failure during a consult turn degrades to "reply text with the directive block stripped,flagForReviewset" β it must never blank the reply or 502 the request. This is the single highest-priority correctness property to test. HW_ATTACHMENT_ENABLED=falsekill switch: consult turns must degrade identically to today (no attachment block in prompt,_extract_and_render_attachmentsreturns immediately,outbound_artifacts=[]) β zero regression for operators who've killed the feature.- Streaming abort mid-attachment-render: if the client disconnects the SSE stream while
_extract_and_render_attachmentsis rendering (post-buffering, pre-done), the existing/chat/streamcleanup semantics apply unchanged β this is not a new failure mode, since the render call happens at the same point in the control flow the non-expert stream branch already occupies. - Cross-tenant isolation:
_extract_and_render_attachmentsrenders intoworkspace.outbound_dir, which is already namespaced byorg_idviaWorkspaceManager.for_request()(per rootCLAUDE.mdBR-14) for consult requests exactly as for main-path requests β no new isolation surface, since consult turns already materialize a workspace today (main.py:623-632 runs unconditionally, before the_is_expertbranch). - Cost/latency: rendering (matplotlib PNG, docx/pdf conversion) adds latency only to turns that actually request a file β no change to plain-text consult latency, since
attachments_enabled()short-circuits and the regex scan over a typical short consult reply is negligible.
7. Exact touchpointsβ
| Path | File | Function / lines | Change |
|---|---|---|---|
| Prompt (both) | agent/prompts_loader.py | build_system_prompt_with_meta, ~L467-485 | Append ATTACHMENT_PROTOCOL (+ image-gate text) inside the is_expert_consultation return branch, gated by attachments_enabled() |
| Prompt docstring | agent/prompts_loader.py | L367-372 | Update comment β ATTACHMENT_PROTOCOL is no longer universally omitted for consults |
| Non-stream consult | agent/main.py | /chat handler, _is_expert branch, L666-697 | Insert _extract_and_render_attachments() call on agentReply before extract_hermes_payload; replace hardcoded outbound_artifacts = [] (L697) with persist_outbound_artifacts() + write_manifest() |
| Stream consult | agent/main.py | /chat/stream handler, _is_expert branch, L1076-1127 | Same insertion after agentReply = "".join(reply_parts) (L1111), before extract_hermes_payload (L1115); replace hardcoded outbound_artifacts = [] (L1127) |
| No change | agent/main.py | _extract_and_render_attachments, L511-584 | Reused as-is β already role-agnostic (takes raw_response, workspace, org_id, no role param) |
| No change | agent/chat_helpers.py | extract_hermes_payload, merge_reasoning | Reused as-is β extraction runs before this, on cleaned text |
| No change | api/src/conversations/expert-agent-thread.service.ts | attachGeneratedFiles, normalizeAgentDonePayload | Already correct; consumes outbound_artifacts once the agent stops hardcoding [] |
8. Test planβ
Agent evals (extend existing consult/attachment eval files):
is_expert_consultation=True+attachments_enabled()=Trueβ prompt containsATTACHMENT_PROTOCOLblock (assert substringHW_ATTACHMENTpresent inbuild_system_prompt_with_meta(..., is_expert_consultation=True)output β currently absent, this is the regression pin for the bug this design fixes).HW_ATTACHMENT_ENABLED=falseβ consult prompt still omits the block (kill-switch parity).- Non-stream
/chatconsult turn:consult_completemock returns content containing a validHW_ATTACHMENTdirective βoutbound_artifactsnon-empty in the response, reply text has the directive block stripped. - Same as #3, streaming: directive spans multiple
content_deltachunks β correctly reassembled and extracted at the done point;outbound_artifactsnon-empty in the SSEdonepayload; and assert notokenSSE event emitted during the run contains any substring of<<<HW_ATTACHMENT/HW_ATTACHMENT>>>β this is the P1 leak Greptile flagged in review of this design and must be pinned as a regression test the moment the implementation lands. - Malformed/unparseable directive in a consult turn β
flagForReview=True, reason set, reply text still delivered (fail-open β never blank the reply). attachments_enabled()=False(agent kill switch) on a consult turn with a directive-shaped reply β directive passed through raw? No β must still fail-open per existing behavior:_extract_and_render_attachmentsreturns(raw_response, False, None)unchanged when disabled (main.py:527-528), so with the prompt-side gate also off the model shouldn't emit directives in the first place; pin this as a defense-in-depth test.- Image-gate:
agent_context.image_generation_enabled=Falseon a consult turn β prompt's consult attachment block includes the "do NOT emit an image" instruction (mirrors non-consult test at prompts_loader.py:492-498).
NestJS: no new tests required β test/expert-agent-thread-attachments.spec.ts already covers attachGeneratedFiles/normalizeAgentDonePayload against a nonzero outboundArtifacts array; that array will now actually be populated end-to-end. A single additive integration-style assertion (not required for this issue, follow-up implementation task's call) could confirm the full round-trip once the agent-side fix lands.
9. Non-goals β explicitβ
- No production/agent code changes in this PR. This document is the deliverable; the follow-up implementation issue executes Β§3 (Approach A) and Β§8.
- No changes to
api/src/conversations/expert-agent-thread.service.tsβ already correct. - No changes to
SideChatPanel.tsx/ frontendAttachmentListβ already correct. - No unification refactor of main-path vs. consult-path attachment handling (Approach B rejected, Β§3).
- No new per-tenant flag plumbing through
agent_contextfor attachments β reuse existingai_reply_attachments_enabled(NestJS-side) andHW_ATTACHMENT_ENABLED(agent-side) gates unchanged.