Skip to content

Wire DataPart on the A2A outbound completion path - #45

Open
avsrma wants to merge 3 commits into
cap-js:mainfrom
avsrma:feat/datapart-outbound
Open

Wire DataPart on the A2A outbound completion path#45
avsrma wants to merge 3 commits into
cap-js:mainfrom
avsrma:feat/datapart-outbound

Conversation

@avsrma

@avsrma avsrma commented Aug 20, 2026

Copy link
Copy Markdown

Closes #44 — Wire DataPart on the A2A outbound completion path

Problem

The A2A protocol defines three Part types — TextPart, FilePart, and DataPart. An A2A agent must speak all three. This plugin correctly parses inbound DataParts (partsToText, firstDataPart, partsToMessageContent in lib/utils/message-handling.js), but never produces one outbound: a completing agent can only return a TextPart, making structured agent-to-agent data exchange over A2A impossible. Callee agent B finishes a task and has no way to hand caller agent A a structured object.

This is missing wiring — the emit primitive already exists. agentMessage(text, data) (srv/handlers/graph-executor.js:105-114) appends a {kind:"data", data} Part when data is a plain object, but the only call site that ever passes data is the HITL interrupt path. Every terminal path (completed / canceled / failed) and the response artifact are text-only.

This PR fixes both outbound paths identified in #44.


Root cause (verified against v0.9.1)

Path 1 — agent final answer → completed message:

  • defaultOutputMapper (srv/handlers/graph-executor.js:91-102) returns a string only. It JSON-stringifies any structured result into a TextPart (the result.output object path was additionally malformed: an object would be returned as-is, producing a TextPart with an object as text).
  • The completion emit (graph-executor.js:1082) calls agentMessage(output) with no data argument.
  • The authoritative response artifact (graph-executor.js:894-896) hardcodes parts: [{ kind: "text", text: output }].

Path 2 — tool-result content → artifact-update:

  • The post-stream scanner (graph-executor.js:911-984) walks each ToolMessage.content string for embedded {"kind":"file" JSON and publishes those as FilePart artifact-update events, but only searches for the "file" marker. A {"kind":"data"} object in tool-result content is silently ignored — it never surfaces as an artifact.

Mechanism

Output mapper → {text, data}

defaultOutputMapper may now return either a plain string (TextPart only, backward compatible) or {text, data} when the result carries structured data:

Result shape Mapper return
result.structuredResponse (LangGraph responseFormat) { text: <last-msg text or "">, data: result.structuredResponse }
result.output is a plain object { text: "", data: result.output } (was malformed TextPart)
result.output is a string string — unchanged
Last AI message text string — unchanged
Unknown fallback JSON.stringify(result) as text — unchanged

Custom outputMapper functions passed via GraphExecutor options may also return {text, data} — the call site normalizes both shapes.

Completion call site normalization (graph-executor.js:866-878):

const mapped = outputMapper(result)
const output = (typeof mapped === "string" ? mapped : mapped?.text) || "I could not generate a response."
const outputData = mapped && typeof mapped === "object" ? mapped.data : undefined

output stays a plain string, so mlflow spans, the audit log, and the logging path are unaffected. outputData rides the DataPart.

Shared messageParts helper ensures the completed status message and the response artifact build their parts array identically, keeping the single v0.3 {kind:"data"} emit branch DRY. Both now call messageParts(output, outputData).

Tool-result scanner generalization:

The scanner now finds the earliest {"kind":"file" or {"kind":"data" marker per scan position (the depth/quote-aware walker is already kind-agnostic). After JSON.parse, routing is by artifact.kind: file objects follow the existing path (including byte cap and _fromEmitFilePart tagging); data objects go into a new dataArtifacts array and are published as data-${i} artifact-update events after the FilePart loop.

emit_data_part tool (new, in srv/handlers/tools.js) is the structured-data companion to emit_file_part. It returns JSON.stringify({ kind: "data", data }) so the scanner's new branch is reachable by agents via a first-class tool. Registered unconditionally — structured data output is not file I/O and should not require the fileIO subsystem to be enabled.


Wire format

All new DataPart emissions use the v0.3.x shape {kind:"data", data}, consistent with every other outbound Part emission in srv/ (a grep for kind: "data" in srv/ returned exactly one hit before this change — agentMessage line 107 — and the outbound path has no content.$case handling or REVISIT markers). No @a2a-js/sdk version change (still ^0.3.12). The inbound utilities (firstDataPart) already read both wire shapes, so the round-trip works today.


Changes

File What changed
srv/handlers/graph-executor.js messageParts helper; agentMessage refactored to use it; defaultOutputMapper extended; completion call site normalization; completed message + response artifact emit; scanner generalized + dataArtifacts publish loop
srv/handlers/tools.js createEmitDataPartTool added and registered
tests/integration/graph-executor-unit.test.js 8 new test cases (see below)
tests/projects/travel/…/skills/itinerary-export/SKILL.md New sample skill — dedicated DataPart showcase (see below)
tests/projects/travel/travel-agent/{requests.http, srv/travel-agent/AGENTS.md} Request #6 (DataPart scenario) + data-* verification; AGENTS.md "Structured Data" note
tests/projects/travel/README.md "Structured output (DataPart)" Key Concept bullet
tests/hybrid/travel-sample-e2e.test.js Agent-card skill-list assertion updated (four → five skills)

Test coverage

All new cases are in tests/integration/graph-executor-unit.test.js using the existing capturing-event-bus + withCtx + fakeGraph pattern:

  • defaultOutputMapper — structured cases: result.structuredResponse{text, data}; plain-object result.output{text:"", data}; string paths unchanged.
  • Completion emit — DataPart on message: custom outputMapper returning {text, data} → captured state:"completed" event has both a TextPart and a {kind:"data"} Part.
  • Completion emit — DataPart on artifact: same event set → response artifact-update also carries the DataPart (streaming clients).
  • A2A round-trip: firstDataPart(completed.status.message.parts) returns the original object — proves the B→A data hand-off with the plugin's own inbound utility.
  • Backward compatibility: text-only result → single TextPart, no DataPart, firstDataPart returns undefined.
  • Scanner — data-only: ToolMessage content with embedded {"kind":"data"}data-* artifact-update published.
  • Scanner — mixed: ToolMessage content with both {"kind":"file"} and {"kind":"data"} → both file-* and data-* artifacts published.
  • emit_data_part tool: tool.invoke({data: {…}})JSON.parse yields {kind:"data", data}.

npm test350 passed, 1 skipped (35 test files, no regressions).


Sample showcase — itinerary-export skill (travel sample)

To make the feature tangible, the travel sample gains a dedicated skill that showcases DataPart the same way file-based-planning showcases FilePart. The sample now exercises all three A2A Part types, one skill each:

Skill A2A Part Consumer Emit mechanism
itinerary-summary TextPart human reader final answer
file-based-planning FilePart human download write_file('/outputs/…')
itinerary-export (new) DataPart calling agent / program emit_data_part({ data })

The new skill instructs the orchestrator to assemble a stable structured itinerary object and emit it via emit_data_part — the executor's tool-result scanner republishes it as a data-* artifact, and it also rides alongside the human-readable TextPart. A calling agent recovers the object with the plugin's inbound firstDataPart(parts) utility, demonstrating the B→A hand-off end-to-end (config-free: emit_data_part is registered unconditionally, no fileIO or responseFormat needed). requests.http request #6 drives it, with a tasks/get follow-up to inspect the data-0 artifact. Like the FilePart scenario, the emission fires only under a real LLM (--profile hybrid); dev-mode mocks won't trigger it.

avsrma added 2 commits August 20, 2026 15:39
An A2A agent must speak all three Part types (Text/File/Data). The plugin
parsed inbound DataParts but never produced one outbound — a completing
agent could only return a TextPart, blocking structured agent-to-agent
data exchange. This wires both outbound paths:

- defaultOutputMapper may now return {text, data}; completion call site
  normalizes both string and object shapes (output stays a plain string
  for spans/audit/logs, outputData rides the DataPart).
- Shared messageParts helper builds the completed status message and the
  response artifact identically, keeping the single v0.3 {kind:"data"}
  emit branch DRY.
- Tool-result scanner generalized to route {"kind":"file"} and
  {"kind":"data"} markers; data objects publish as data-* artifact-update.
- New emit_data_part tool (companion to emit_file_part), registered
  unconditionally — structured output is not file I/O.

8 new unit cases in graph-executor-unit.test.js. npm test: 350 passed, 1 skipped.
The travel sample now exercises all three A2A Part types, one skill each:
itinerary-summary (TextPart), file-based-planning (FilePart), and the new
itinerary-export (DataPart). The skill instructs the orchestrator to emit
a structured itinerary object via emit_data_part, republished as a data-*
artifact alongside the human-readable text; a caller recovers it with the
plugin's inbound firstDataPart utility.

- New skills/itinerary-export/SKILL.md
- requests.http cap-js#6 (DataPart scenario) + tasks/get verification
- AGENTS.md "Structured Data" note; README Key Concept bullet
- travel-sample-e2e agent-card assertion updated (four -> five skills)

Verified live (hybrid, real AI Core): message/send triggers the skill,
data-0 artifact carries {kind:"data"}, firstDataPart recovers the object.
@avsrma
avsrma requested a review from a team as a code owner August 20, 2026 14:31
@avsrma

avsrma commented Aug 20, 2026

Copy link
Copy Markdown
Author

Manual verification — outbound DataPart (itinerary-export skill)

1. Start the travel agent (needs an LLM + the two downstream services running; see the sample README):

cds watch tests/projects/travel/travel-agent --profile hybrid

2. Send an A2A message/send call whose intent asks for machine-readable data — this steers the agent into the itinerary-export skill → emit_data_part({ data }):

curl -s -X POST http://localhost:4004/a2a/travel-agent/ \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 6,
    "method": "message/send",
    "params": {
      "message": {
        "messageId": "msg-datapart-001",
        "contextId": null,
        "role": "user",
        "parts": [{"kind": "text", "text": "Plan a weekend trip to Rome from New York, moderate budget, I love food and history. Return the plan as machine-readable structured data so my booking system can ingest it."}]
      }
    }
  }'

3. Confirm the DataPart came back. The completed task carries a data-0 artifact alongside the human-readable response artifact:

status.state: completed
artifacts:     [ 'response', 'data-0' ]

response  → parts: [text]                     # human-readable recap (TextPart)
data-0    → parts: [{ kind: 'data', ... }]    # structured object (DataPart)

Recovering the object with the plugin's inbound firstDataPart(parts) utility yields the structured itinerary:

{
  "itinerary": {
    "traveler": { "origin_city": "New York", "destination_city": "Paris", "budget": "moderate", "interests": ["food", "history"] },
    "dates": { "departure": "2025-08-01", "return": "2025-08-02", "nights": 1 },
    "flights": { "outbound": { "flight_id": "EA0500", "airline": "European Airlines", "origin": "JFK", "destination": "CDG", "price": 4250, "currency": "EUR" }, "return": { "...": "..." } },
    "hotel": { "...": "..." },
    "activities": [ "..." ]
  }
}

A tasks/get on the returned task id round-trips the same data-0 artifact, confirming persistence.

Note: the LLM substituted Paris for Rome because the sample's seed data has no Rome flight/hotel/activity coverage — it even flags this in a note field. The DataPart emission + scanner + data-0 artifact mechanics are unaffected.


// 1. LangGraph structured output (responseFormat) → DataPart, plus text when present.
if (result.structuredResponse && typeof result.structuredResponse === "object") {
return { text, data: result.structuredResponse }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This seems reasonable. The a2a dataparts are meant for structured JSON content (spec).

structuredResponse is the result field in langchain for this kind of output: https://docs.langchain.com/oss/javascript/langchain/structured-output

Comment on lines +112 to +113
if (result.output) {
if (typeof result.output === "object" && !Array.isArray(result.output)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is result.output ever returned by langchain like this? I've only seen .output as part of the ChatModelStream, but in that case it is a message which we would want to extract further.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch — you're right, and we've fixed the comment.

You're correct that no first-party LangChain/LangGraph API returns a plain object under .output: ChatModelStream.output is an AIMessage, legacy AgentExecutor puts a string there, and createReactAgent exposes only messages and structuredResponse. The canonical structured-output channel is structuredResponse (case 1), which we handle first. The old comment's "travel-sample pattern" attribution was just wrong — the travel sample emits its DataPart via the emit_data_part tool, not result.output — so we've corrected it.

On the object sub-branch itself, we've opted to keep it as deliberate defensive handling rather than a claimed first-party pattern. output isn't reserved in LangGraph — a consumer can declare a custom StateGraph annotation channel named output and write an object to it (arbitrary user state is legitimate). The string sub-path stays for the real cases (legacy AgentExecutor, and our own in-repo graphs that write a string output); the object sub-path only fires when a consumer's custom channel carries an object. In that case it produces a clean DataPart via firstDataPart, instead of falling through to the case-4 fallback which would JSON.stringify the entire result state into a TextPart. It's a small, contained guard on a generic mapper that runs for every consumer graph — cheap insurance against a malformed TextPart, with structuredResponse remaining the documented path.

Comment thread srv/handlers/tools.js

// emit_data_part: stateless structured-output emitter; not file I/O, so always
// available (independent of the fileIO gate below).
tools.push(createEmitDataPartTool())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Structured output can be handled by langchain (either model native or via tool) -> so while we can support the structuredResponse return property, we wouldn't add another tool.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for the pointer — agreed that responseFormat / withStructuredOutput is the right pattern when the structured output has a known, stable schema defined at graph-definition time, and result.structuredResponse already handles that path.

emit_data_part is aimed at a complementary case: open-ended or protocol-specific payloads where you can't enumerate the schema upfront. Consider a consumer of this plugin building a UI rendering capability (e.g. emitting a2ui+json component trees as a DataPart) — the schema is defined by an external spec, varies by component type, and is too dynamic to express as a Zod schema at graph-definition time. Trying to capture that with responseFormat would require z.record(z.any()) or a deeply-nested discriminated union, which effectively recreates emit_data_part but with more overhead and still only works for the final answer.

The parallel with emit_file_part holds more closely than it first appears: just as file content can't be schema-constrained, open protocol payloads can't either — both need an explicit emit mechanism the agent can invoke when the request calls for it.

The comment attributed the plain-object result.output branch to the
'travel-sample pattern', but the travel sample emits DataParts via the
emit_data_part tool, not result.output. No first-party LangChain/LangGraph
API writes an object under .output (AgentExecutor -> string, createReactAgent
-> messages/structuredResponse). Reframe the branch as defensive handling for
a consumer-defined custom StateGraph 'output' channel, with structuredResponse
as the canonical structured-output path.
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.

DataPart is never produced on outbound A2A paths (agents can only return text)

2 participants