Skip to content

WayFlow `user_envelope` contract (Phase 348 / V5.1-09)

This page closes the v5.0 Phase 332 deferral: how WayFlow’s agent runtime should forward HITL resume payloads that carry artifact attachments so cinatra can de-envelope them on the receiving side and the LLM sees only the plain text field.

The cinatra side of the contract has been complete since v5.0 Phase 329c (the parseUserEnvelope parser at src/app/api/llm-bridge/user-envelope.ts). Phase 348 ratifies it as the v5.1 contract and pins the back-compat invariant with regression tests under that label.

The WayFlow side is a Python runtime change handled by the operator during the v5.1 cutover — out-of-codebase here.

A2A messages are text-only by design. The chat / WayFlow round-trip that resumes a paused agent run carries the user’s structured-form submission as a JSON-stringified payload inside the parts[].text slot. v5.0 added artifact attachments to that submission shape:

// userResponse (resume payload, JSON-stringified into A2A parts[].text):
{
"text": "Approved with edits to draft #4.",
"attachments": [
{
"artifactId": "art_…",
"representationRevisionId": "rep_…",
"digest": "sha256:…",
"mime": "application/pdf",
"originKind": "upload"
}
]
}

A2A is text-only, so this envelope must ride inside the text payload — the model itself should still see only "Approved with edits to draft #4.", not the whole envelope. The bridge route at /api/llm-bridge is where the de-enveloping happens.

The receiving bridge opts into envelope parsing via body.user_envelope:

body.user_envelopebridge behavior
trueparse body.user as {text, attachments?}; LLM sees only text
false or absentbyte-identical pass-through of body.user — the legacy invariant

The opt-in is the safety latch: any old caller that hasn’t been updated (and any malicious-looking JSON-shaped string the user might type) remains untouched.

The WayFlow agent runtime (agent_loader.py, out-of-codebase here) must forward body.user_envelope=true on the bridge call when the upstream resume payload itself parses as the envelope shape. This is the one-line opt-in:

# agent_loader.py (WayFlow side; pseudocode)
resume_text = sendTask_part.text # the JSON-stringified envelope
bridge_body = {
"user": resume_text,
"user_envelope": True, # ← Phase 348 opt-in
# …other bridge fields
}

Until WayFlow is updated, cinatra MUST behave byte-identically to the v5.0 legacy path: body.user_envelope absent → body.user flows through verbatim, attachments must come from the top-level body.attachments slot (the only existing v5.0 path).

SurfaceWhat it guarantees
src/app/api/llm-bridge/user-envelope.tsparseUserEnvelope(rawUser, enabled, topLevelAttachments?) — opt-in JSON parse + merged cap 20
src/app/api/llm-bridge/route.tscalls parseUserEnvelope with enabled = body.user_envelope === true
packages/agents/src/review-task-actions.ts (lines ~270–295)the comment block documenting the contract end-to-end

Regression-pinned at src/app/api/llm-bridge/__tests__/user-envelope.test.ts (legacy-invariant cases ride under the explicit Phase 348 label). The invariant is:

An A2A resume payload arriving with user_envelope absent (= every WayFlow runtime that has NOT taken the Phase 348 Python step) MUST reach the model byte-identical to the v5.0 path. Any divergence is a regression, NOT a Phase 348 win.

Failure modes (and the parser’s fail-closed posture)

Section titled “Failure modes (and the parser’s fail-closed posture)”

parseUserEnvelope(_, true, _) is strict-schema. When the caller has set body.user_envelope=true and body.user does not parse as the envelope shape, the parser throws (UserEnvelopeParseError) — never silently falls back to plain text. The bridge route translates that throw to a 400 so the WayFlow side can fix the payload rather than confusing the model with a half-parsed envelope.

  • Back-compat invariant (every enabled=false case): src/app/api/llm-bridge/__tests__/user-envelope.test.ts
  • End-to-end attachment wiring through the bridge: src/app/api/llm-bridge/__tests__/attachments-wiring.test.ts