Skip to main content

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 materializes agentResponse.outboundArtifacts via OutboundArtifactMaterializer, gated by ai_reply_attachments_enabled (default ON), fail-open, called from both the non-streaming path (line 213) and the SSE done-frame path (line 648) via normalizeAgentDonePayload (line 764).
  • Frontend SideChatPanel.tsx AttachmentList renders whatever comes back.
  • api/test/expert-agent-thread-attachments.spec.ts already covers the NestJS side: flag gate, fail-open, normalizeAgentDonePayload artifact 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 on agentReply, which is built from msg.content (non-stream, main.py:674) or accumulated content_delta chunks (stream, main.py:1101-1103) β€” never from the reasoning channel. Good: this is exactly the surface the design must parse HW_ATTACHMENT directives from.
  • merge_reasoning() (chat_helpers.py:819-823) only touches reasoning/reasoning_content parts and is entirely orthogonal to the reply-content parsing β€” no interaction risk with attachment extraction as long as extraction runs on agentReply/msg.content before or alongside extract_hermes_payload, not on the reasoning stream.
  • Ordering constraint carried over from the main path: _extract_and_render_attachments() must run before extract_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_PROTOCOL to _security_prefix_for_consultation() or otherwise conflate it with SECURITY_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 keeps CLARIFICATION_POLICY and attachment_block as 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_delta into reply_parts WITHOUT yielding token events during accumulation; run _extract_and_render_attachments once the stream completes; then emit the cleaned text as either one large token event or a final done-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_ATTACHMENT opening delimiter, stop yielding token events and buffer silently until the matching HW_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_ATT mid-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, flagForReview set" β€” it must never blank the reply or 502 the request. This is the single highest-priority correctness property to test.
  • HW_ATTACHMENT_ENABLED=false kill switch: consult turns must degrade identically to today (no attachment block in prompt, _extract_and_render_attachments returns 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_attachments is rendering (post-buffering, pre-done), the existing /chat/stream cleanup 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_attachments renders into workspace.outbound_dir, which is already namespaced by org_id via WorkspaceManager.for_request() (per root CLAUDE.md BR-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_expert branch).
  • 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​

PathFileFunction / linesChange
Prompt (both)agent/prompts_loader.pybuild_system_prompt_with_meta, ~L467-485Append ATTACHMENT_PROTOCOL (+ image-gate text) inside the is_expert_consultation return branch, gated by attachments_enabled()
Prompt docstringagent/prompts_loader.pyL367-372Update comment β€” ATTACHMENT_PROTOCOL is no longer universally omitted for consults
Non-stream consultagent/main.py/chat handler, _is_expert branch, L666-697Insert _extract_and_render_attachments() call on agentReply before extract_hermes_payload; replace hardcoded outbound_artifacts = [] (L697) with persist_outbound_artifacts() + write_manifest()
Stream consultagent/main.py/chat/stream handler, _is_expert branch, L1076-1127Same insertion after agentReply = "".join(reply_parts) (L1111), before extract_hermes_payload (L1115); replace hardcoded outbound_artifacts = [] (L1127)
No changeagent/main.py_extract_and_render_attachments, L511-584Reused as-is β€” already role-agnostic (takes raw_response, workspace, org_id, no role param)
No changeagent/chat_helpers.pyextract_hermes_payload, merge_reasoningReused as-is β€” extraction runs before this, on cleaned text
No changeapi/src/conversations/expert-agent-thread.service.tsattachGeneratedFiles, normalizeAgentDonePayloadAlready correct; consumes outbound_artifacts once the agent stops hardcoding []

8. Test plan​

Agent evals (extend existing consult/attachment eval files):

  1. is_expert_consultation=True + attachments_enabled()=True β†’ prompt contains ATTACHMENT_PROTOCOL block (assert substring HW_ATTACHMENT present in build_system_prompt_with_meta(..., is_expert_consultation=True) output β€” currently absent, this is the regression pin for the bug this design fixes).
  2. HW_ATTACHMENT_ENABLED=false β†’ consult prompt still omits the block (kill-switch parity).
  3. Non-stream /chat consult turn: consult_complete mock returns content containing a valid HW_ATTACHMENT directive β†’ outbound_artifacts non-empty in the response, reply text has the directive block stripped.
  4. Same as #3, streaming: directive spans multiple content_delta chunks β†’ correctly reassembled and extracted at the done point; outbound_artifacts non-empty in the SSE done payload; and assert no token SSE 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.
  5. Malformed/unparseable directive in a consult turn β†’ flagForReview=True, reason set, reply text still delivered (fail-open β€” never blank the reply).
  6. 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_attachments returns (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.
  7. Image-gate: agent_context.image_generation_enabled=False on 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 / frontend AttachmentList β€” 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_context for attachments β€” reuse existing ai_reply_attachments_enabled (NestJS-side) and HW_ATTACHMENT_ENABLED (agent-side) gates unchanged.