Wire DataPart on the A2A outbound completion path - #45
Conversation
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.
Manual verification — outbound DataPart (
|
|
|
||
| // 1. LangGraph structured output (responseFormat) → DataPart, plus text when present. | ||
| if (result.structuredResponse && typeof result.structuredResponse === "object") { | ||
| return { text, data: result.structuredResponse } |
There was a problem hiding this comment.
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
| if (result.output) { | ||
| if (typeof result.output === "object" && !Array.isArray(result.output)) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
|
||
| // emit_data_part: stateless structured-output emitter; not file I/O, so always | ||
| // available (independent of the fileIO gate below). | ||
| tools.push(createEmitDataPartTool()) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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,partsToMessageContentinlib/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 whendatais a plain object, but the only call site that ever passesdatais the HITL interrupt path. Every terminal path (completed / canceled / failed) and theresponseartifact 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 (theresult.outputobject path was additionally malformed: an object would be returned as-is, producing a TextPart with an object astext).graph-executor.js:1082) callsagentMessage(output)with nodataargument.responseartifact (graph-executor.js:894-896) hardcodesparts: [{ kind: "text", text: output }].Path 2 — tool-result content →
artifact-update:graph-executor.js:911-984) walks eachToolMessage.contentstring for embedded{"kind":"file"JSON and publishes those as FilePartartifact-updateevents, 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}defaultOutputMappermay now return either a plain string (TextPart only, backward compatible) or{text, data}when the result carries structured data:result.structuredResponse(LangGraphresponseFormat){ text: <last-msg text or "">, data: result.structuredResponse }result.outputis a plain object{ text: "", data: result.output }(was malformed TextPart)result.outputis a stringJSON.stringify(result)as text — unchangedCustom
outputMapperfunctions passed viaGraphExecutoroptions may also return{text, data}— the call site normalizes both shapes.Completion call site normalization (
graph-executor.js:866-878):outputstays a plain string, so mlflow spans, the audit log, and the logging path are unaffected.outputDatarides the DataPart.Shared
messagePartshelper ensures the completed status message and theresponseartifact build theirpartsarray identically, keeping the single v0.3{kind:"data"}emit branch DRY. Both now callmessageParts(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). AfterJSON.parse, routing is byartifact.kind: file objects follow the existing path (including byte cap and_fromEmitFileParttagging); data objects go into a newdataArtifactsarray and are published asdata-${i}artifact-updateevents after the FilePart loop.emit_data_parttool (new, insrv/handlers/tools.js) is the structured-data companion toemit_file_part. It returnsJSON.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 thefileIOsubsystem 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 insrv/(a grep forkind: "data"insrv/returned exactly one hit before this change —agentMessageline 107 — and the outbound path has nocontent.$casehandling or REVISIT markers). No@a2a-js/sdkversion change (still^0.3.12). The inbound utilities (firstDataPart) already read both wire shapes, so the round-trip works today.Changes
srv/handlers/graph-executor.jsmessagePartshelper;agentMessagerefactored to use it;defaultOutputMapperextended; completion call site normalization; completed message +responseartifact emit; scanner generalized +dataArtifactspublish loopsrv/handlers/tools.jscreateEmitDataPartTooladded and registeredtests/integration/graph-executor-unit.test.jstests/projects/travel/…/skills/itinerary-export/SKILL.mdtests/projects/travel/travel-agent/{requests.http, srv/travel-agent/AGENTS.md}data-*verification; AGENTS.md "Structured Data" notetests/projects/travel/README.mdtests/hybrid/travel-sample-e2e.test.jsTest coverage
All new cases are in
tests/integration/graph-executor-unit.test.jsusing the existing capturing-event-bus +withCtx+fakeGraphpattern:defaultOutputMapper— structured cases:result.structuredResponse→{text, data}; plain-objectresult.output→{text:"", data}; string paths unchanged.outputMapperreturning{text, data}→ capturedstate:"completed"event has both a TextPart and a{kind:"data"}Part.responseartifact-update also carries the DataPart (streaming clients).firstDataPart(completed.status.message.parts)returns the original object — proves the B→A data hand-off with the plugin's own inbound utility.firstDataPartreturnsundefined.{"kind":"data"}→data-*artifact-update published.{"kind":"file"}and{"kind":"data"}→ bothfile-*anddata-*artifacts published.emit_data_parttool:tool.invoke({data: {…}})→JSON.parseyields{kind:"data", data}.npm test— 350 passed, 1 skipped (35 test files, no regressions).Sample showcase —
itinerary-exportskill (travel sample)To make the feature tangible, the
travelsample gains a dedicated skill that showcases DataPart the same wayfile-based-planningshowcases FilePart. The sample now exercises all three A2A Part types, one skill each:itinerary-summaryfile-based-planningwrite_file('/outputs/…')itinerary-export(new)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 adata-*artifact, and it also rides alongside the human-readable TextPart. A calling agent recovers the object with the plugin's inboundfirstDataPart(parts)utility, demonstrating the B→A hand-off end-to-end (config-free:emit_data_partis registered unconditionally, nofileIOorresponseFormatneeded).requests.httprequest #6 drives it, with atasks/getfollow-up to inspect thedata-0artifact. Like the FilePart scenario, the emission fires only under a real LLM (--profile hybrid); dev-mode mocks won't trigger it.