Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 90 additions & 16 deletions srv/handlers/graph-executor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

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

}

// 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

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.

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),
}
}

Expand Down Expand Up @@ -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 })

Expand Down Expand Up @@ -892,7 +928,7 @@ class GraphExecutor {
lastChunk: true,
artifact: {
artifactId: "response",
parts: [{ kind: "text", text: output }],
parts: messageParts(output, outputData),
},
})

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions srv/handlers/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -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())

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.


// 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().
Expand Down Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions tests/hybrid/travel-sample-e2e.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,14 @@ describe("@cap-js/agents - Travel Sample E2E", () => {
const skillIds = card.skills.map((s) => s.id).sort()
expect(
skillIds,
`expected four skills from skills/ scan, got: ${JSON.stringify(skillIds)}`,
).toEqual(["file-based-planning", "flight-booking", "itinerary-summary", "trip-planning"])
`expected five skills from skills/ scan, got: ${JSON.stringify(skillIds)}`,
).toEqual([
"file-based-planning",
"flight-booking",
"itinerary-export",
"itinerary-summary",
"trip-planning",
])

const trip = card.skills.find((s) => s.id === "trip-planning")
expect(trip.name).toMatch(/Trip Planning/i)
Expand Down
Loading