-
Notifications
You must be signed in to change notification settings - Fork 3
Wire DataPart on the A2A outbound completion path #45
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -85,31 +85,61 @@ function messageText(content) { | |
| } | ||
|
|
||
| /** | ||
| * Default output mapper: extracts response text from graph result. | ||
| * Priority: last AI message content > result.output > JSON stringified result. | ||
| * Default output mapper: derive the agent's final answer from the graph result. | ||
| * Returns either a plain string (TextPart only) or `{ text, data }` when the | ||
| * result carries structured data (emitted as a TextPart + DataPart). Structured | ||
| * data is recognized so it is no longer silently JSON-stringified into a TextPart. | ||
| * Priority: structuredResponse > last AI message text > result.output > JSON fallback. | ||
| */ | ||
| function defaultOutputMapper(result) { | ||
| // 1. Messages-based: last message content (standard LangChain pattern) | ||
| // Text from the last message (standard LangChain pattern), if any. | ||
| let text = "" | ||
| if (result.messages?.length > 0) { | ||
| const lastMsg = result.messages[result.messages.length - 1] | ||
| const text = messageText(lastMsg?.content) | ||
| if (text) return text | ||
| text = messageText(result.messages[result.messages.length - 1]?.content) || "" | ||
| } | ||
| // 2. Output field (e.g. travel-sample pattern) | ||
| if (result.output) return result.output | ||
| // 3. Fallback | ||
|
|
||
| // 1. LangGraph structured output (responseFormat) → DataPart, plus text when present. | ||
| if (result.structuredResponse && typeof result.structuredResponse === "object") { | ||
| return { text, data: result.structuredResponse } | ||
| } | ||
|
|
||
| // 2. Messages-based text. | ||
| if (text) return text | ||
|
|
||
| // 3. `output` field: a legacy AgentExecutor result or a custom StateGraph channel | ||
| // named `output`. Legacy AgentExecutor and every graph we ship write a string | ||
| // here → TextPart. A plain object is not a first-party LangChain/LangGraph | ||
| // default (canonical structured output arrives via structuredResponse, case 1), | ||
| // but a user-defined `output` channel could carry one — so handle it defensively | ||
| // as a DataPart (previously it was stuffed into a TextPart as an object — malformed). | ||
| if (result.output) { | ||
| if (typeof result.output === "object" && !Array.isArray(result.output)) { | ||
|
Comment on lines
+115
to
+116
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 On the object sub-branch itself, we've opted to keep it as deliberate defensive handling rather than a claimed first-party pattern. |
||
| return { text: "", data: result.output } | ||
| } | ||
| return result.output | ||
| } | ||
|
|
||
| // 4. Fallback — truly-unknown result, not agent-intended structured output. | ||
| return JSON.stringify(result) | ||
| } | ||
|
|
||
| // Build A2A message parts: always a TextPart; append a DataPart when `data` is a | ||
| // plain object (arrays/strings/null carry on the TextPart alone). Shared so the | ||
| // completed status message and the "response" artifact build identical parts from | ||
| // one place — keeping the single v0.3 `{kind:"data"}` emit branch DRY. | ||
| function messageParts(text, data) { | ||
| const parts = [{ kind: "text", text }] | ||
| if (data && typeof data === "object" && !Array.isArray(data)) parts.push({ kind: "data", data }) | ||
| return parts | ||
| } | ||
|
|
||
| // Construct a spec-compliant A2A Message; when `data` is a plain object, append it as a DataPart. | ||
| function agentMessage(text, data) { | ||
| const parts = [{ kind: "text", text }] | ||
| if (data && typeof data === "object") parts.push({ kind: "data", data }) | ||
| return { | ||
| kind: "message", | ||
| messageId: cds.utils.uuid(), | ||
| role: "agent", | ||
| parts, | ||
| parts: messageParts(text, data), | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -839,7 +869,13 @@ class GraphExecutor { | |
|
|
||
| const duration = ((Date.now() - t0) / 1000).toFixed(1) + "s" | ||
| const outputMapper = this._outputMapper || defaultOutputMapper | ||
| const output = outputMapper(result) || "I could not generate a response." | ||
| // The mapper may return a string (TextPart only) or { text, data } (TextPart + | ||
| // DataPart). Normalize: `output` stays a string for spans/audit/artifact text; | ||
| // `outputData`, when present, rides a DataPart on the completed message + artifact. | ||
| 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 | ||
|
|
||
| LOG.info("completed", { conversation: short(contextId), service: serviceName, duration }) | ||
|
|
||
|
|
@@ -892,7 +928,7 @@ class GraphExecutor { | |
| lastChunk: true, | ||
| artifact: { | ||
| artifactId: "response", | ||
| parts: [{ kind: "text", text: output }], | ||
| parts: messageParts(output, outputData), | ||
| }, | ||
| }) | ||
|
|
||
|
|
@@ -901,6 +937,10 @@ class GraphExecutor { | |
| // 1. emit_file_part tool calls (default graph) — JSON in toolResults/messages | ||
| // 2. write_file '/outputs/*' via OutputsBackend (deep agent) — CDS rows | ||
| const fileArtifacts = [] | ||
| // DataParts embedded in tool-result content (e.g. emit_data_part). Structured, | ||
| // opaque objects — no byte cap, no /uploads re-persist. Published as their own | ||
| // `data-*` artifact-update events below. | ||
| const dataArtifacts = [] | ||
| const maxFileBytes = cds.env.agents.fileIO.maxOutputFileSizeBytes | ||
|
|
||
| // Artifacts from emit_file_part are this agent's own outputs — they must be | ||
|
|
@@ -924,7 +964,11 @@ class GraphExecutor { | |
| const content = typeof msg.content === "string" ? msg.content : "" | ||
| let pos = 0 | ||
| while (pos < content.length) { | ||
| const start = content.indexOf('{"kind":"file"', pos) | ||
| // Find the earliest next FilePart or DataPart marker. The walker below is | ||
| // kind-agnostic; routing happens after JSON.parse via `artifact.kind`. | ||
| const fileAt = content.indexOf('{"kind":"file"', pos) | ||
| const dataAt = content.indexOf('{"kind":"data"', pos) | ||
| const start = fileAt === -1 ? dataAt : dataAt === -1 ? fileAt : Math.min(fileAt, dataAt) | ||
| if (start === -1) break | ||
| // Walk forward tracking depth and quoted strings so that '}' inside | ||
| // a string value (e.g. a filename like "result_{final}.csv") does not | ||
|
|
@@ -953,6 +997,12 @@ class GraphExecutor { | |
| const raw = content.slice(start, i + 1) | ||
| try { | ||
| const artifact = JSON.parse(raw) | ||
| if (artifact.kind === "data") { | ||
| // Opaque structured payload — surfaced verbatim as a DataPart artifact. | ||
| dataArtifacts.push(artifact) | ||
| pos = i + 1 | ||
| continue | ||
| } | ||
| // Apply the same per-file size cap as Source 2. Decode-length is | ||
| // computed by Buffer.byteLength (zero allocation — pure formula | ||
| // over string length + padding) so an oversized blob never pins | ||
|
|
@@ -1073,13 +1123,37 @@ class GraphExecutor { | |
| }) | ||
| } | ||
|
|
||
| // Publish DataParts collected from tool-result content as their own | ||
| // artifact-update events, so structured tool output surfaces to A2A clients | ||
| // (mirrors the FilePart path above; the completed message carries the agent's | ||
| // final-answer DataPart separately). | ||
| dataArtifacts.forEach((artifact, i) => { | ||
| if (artifact.data == null || typeof artifact.data !== "object") { | ||
| LOG.warn("skipping malformed data artifact", { | ||
| conversation: short(contextId), | ||
| service: serviceName, | ||
| }) | ||
| return | ||
| } | ||
| LOG.info("data emitted", { conversation: short(contextId), service: serviceName }) | ||
| eventBus.publish({ | ||
| kind: "artifact-update", | ||
| taskId, | ||
| contextId, | ||
| artifact: { | ||
| artifactId: `data-${i}`, | ||
| parts: [{ kind: "data", data: artifact.data }], | ||
| }, | ||
| }) | ||
| }) | ||
|
|
||
| eventBus.publish({ | ||
| kind: "status-update", | ||
| taskId, | ||
| contextId, | ||
| status: { | ||
| state: "completed", | ||
| message: agentMessage(output), | ||
| message: agentMessage(output, outputData), | ||
| timestamp: new Date().toISOString(), | ||
| }, | ||
| final: true, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -211,6 +211,10 @@ export function generateTools(srv) { | |
| } | ||
| } | ||
|
|
||
| // emit_data_part: stateless structured-output emitter; not file I/O, so always | ||
| // available (independent of the fileIO gate below). | ||
| tools.push(createEmitDataPartTool()) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the pointer — agreed that
The parallel with |
||
|
|
||
| // File tools — only when fileIO is enabled | ||
| // emit_file_part: stateless protocol emitter; safe to tools.push once at startup. | ||
| // read_file: per-request (needs contextId) — created on-demand via createReadFileTool(). | ||
|
|
@@ -288,6 +292,32 @@ export function createEmitFilePartTool() { | |
| ) | ||
| } | ||
|
|
||
| /** | ||
| * Create a tool that emits a structured object as a DataPart of the A2A response. | ||
| * Pure protocol emitter — the executor's toolResults collection loop parses the | ||
| * `kind:'data'` JSON from this tool and republishes it as a `data-*` artifact. | ||
| */ | ||
| export function createEmitDataPartTool() { | ||
| return tool( | ||
| async ({ data }) => { | ||
| LOG.info("emit_data_part", { | ||
| keys: data && typeof data === "object" ? Object.keys(data) : [], | ||
| }) | ||
| return JSON.stringify({ kind: "data", data }) | ||
| }, | ||
| { | ||
| name: "emit_data_part", | ||
| description: | ||
| "Emit a structured object as a DataPart of the A2A response, so a calling agent receives machine-readable data (not just text). Use for structured results the caller should consume programmatically.", | ||
| schema: z.object({ | ||
| data: z | ||
| .record(z.any()) | ||
| .describe("A JSON object to return to the caller as structured data"), | ||
| }), | ||
| }, | ||
| ) | ||
| } | ||
|
|
||
| /** | ||
| * Create a read_file tool scoped to the current conversation. | ||
| * For the default LangGraph path only — deepagents tools.pushs its own read_file | ||
|
|
||
There was a problem hiding this comment.
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).
structuredResponseis the result field in langchain for this kind of output: https://docs.langchain.com/oss/javascript/langchain/structured-output