@cap-js/agents 0.9.3 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/_i18n/messages.properties +17 -0
  2. package/cds-plugin.js +94 -77
  3. package/lib/agents/markdown/deep-agent.js +1 -1
  4. package/lib/agents/middleware/hitl-decision-note-injector.js +18 -0
  5. package/lib/agents/middleware/hitl.js +2 -2
  6. package/lib/agents/middleware/index.js +1 -1
  7. package/lib/agents/middleware/quota-enforcer.js +17 -8
  8. package/lib/agents/middleware/remote-mcp.js +3 -2
  9. package/lib/agents/middleware/status-update.js +1 -1
  10. package/lib/agents/middleware/tool-wrap.js +5 -6
  11. package/lib/agents/quota-enforcer-at-start.js +21 -18
  12. package/lib/agents/summarize-on-timeout.js +18 -13
  13. package/lib/compile.js +2 -3
  14. package/lib/config/local.js +94 -0
  15. package/lib/eval/eval-run.js +11 -6
  16. package/lib/index.js +9 -12
  17. package/lib/models/aicore.js +91 -127
  18. package/lib/models/anthropic.js +10 -67
  19. package/lib/preview/chat.html +62 -33
  20. package/lib/telemetry/chat-tracing.js +13 -3
  21. package/lib/telemetry/metrics.js +6 -0
  22. package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +1 -1
  23. package/lib/telemetry/mlflow/prompts.js +6 -2
  24. package/lib/utils/caching.js +132 -0
  25. package/lib/utils/message-handling.js +14 -0
  26. package/lib/utils/utils.js +4 -9
  27. package/package.json +7 -5
  28. package/srv/handlers/chat.js +4 -4
  29. package/srv/handlers/graph-executor/hitl.js +381 -0
  30. package/srv/handlers/graph-executor.js +99 -336
  31. package/srv/handlers/index.js +4 -3
  32. package/srv/handlers/mcp-tools.js +3 -2
  33. package/srv/handlers/{sub-agent-tools.js → subagent-tools.js} +43 -22
  34. package/srv/handlers/tools.js +35 -9
  35. package/lib/agents/middleware/hitl-edit-note-injector.js +0 -18
@@ -1,6 +1,6 @@
1
1
  import cds from "@sap/cds"
2
2
  import { short, audit, ms4 } from "../../lib/utils/utils.js"
3
- import { partsToText, buildChatMessages, firstDataPart } from "../../lib/utils/message-handling.js"
3
+ import { agentMessage, partsToText, buildChatMessages } from "../../lib/utils/message-handling.js"
4
4
  import * as metrics from "../../lib/telemetry/metrics.js"
5
5
  import { mlflowAttrs, mlflowTraceAttrs, setSpanAttrs } from "../../lib/telemetry/mlflow/index.js"
6
6
  import { CdsFileStore } from "../../lib/protocol/persistence/file-store.js"
@@ -9,6 +9,14 @@ import { convertUsageData } from "../../lib/telemetry/chat-tracing.js"
9
9
  import { triggerCleanup } from "../../lib/protocol/persistence/cleanup.js"
10
10
  import { COLLECT_RESULT } from "./chat.js"
11
11
  import { linkTraceToPrompt } from "../../lib/telemetry/mlflow/tracing.js"
12
+ import {
13
+ handleHitlInterrupt,
14
+ isTimeoutHitl,
15
+ publishTimeoutHitl,
16
+ requiresHitl,
17
+ resumeHitl,
18
+ resumeTimeoutHitl,
19
+ } from "./graph-executor/hitl.js"
12
20
 
13
21
  const LOG = cds.log("agents")
14
22
 
@@ -104,18 +112,6 @@ function defaultOutputMapper(result) {
104
112
  return JSON.stringify(result)
105
113
  }
106
114
 
107
- // Construct a spec-compliant A2A Message; when `data` is a plain object, append it as a DataPart.
108
- function agentMessage(text, data) {
109
- const parts = [{ kind: "text", text }]
110
- if (data && typeof data === "object") parts.push({ kind: "data", data })
111
- return {
112
- kind: "message",
113
- messageId: cds.utils.uuid(),
114
- role: "agent",
115
- parts,
116
- }
117
- }
118
-
119
115
  /**
120
116
  * Extract user text from A2A message parts.
121
117
  */
@@ -123,147 +119,6 @@ function extractText(requestContext) {
123
119
  return partsToText(requestContext.userMessage?.parts)
124
120
  }
125
121
 
126
- // Extract the first inbound DataPart's opaque `data` object, or undefined if none.
127
- function extractData(requestContext) {
128
- return firstDataPart(requestContext.userMessage?.parts)
129
- }
130
-
131
- /**
132
- * Parse user's resume text into a HITL decision.
133
- * Maps to the format expected by deepagents' humanInTheLoopMiddleware.
134
- */
135
- function parseResumeDecision(userText) {
136
- const t = userText.trim()
137
- if (/^(approve|yes|confirm|ok)$/i.test(t)) {
138
- return { decisions: [{ type: "approve" }] }
139
- }
140
- if (/^edit$/i.test(t)) {
141
- // Bare edit — structured edits (with args) arrive via the DataPart path.
142
- return { decisions: [{ type: "edit" }] }
143
- }
144
- return { decisions: [{ type: "reject", message: userText }] }
145
- }
146
-
147
- // Best-effort decision label for logging/audit; opaque DataPart resumes fall back to "data".
148
- function decisionTypeOf(resume) {
149
- return resume?.decisions?.[0]?.type ?? "data"
150
- }
151
-
152
- /**
153
- * Extract the human-readable description from an interrupt payload.
154
- * Accepts either a graph result (with __interrupt__) or a GraphInterrupt error (with .interrupts).
155
- * Handles both deepagents' humanInTheLoopMiddleware format and raw interrupt() calls.
156
- */
157
- function extractInterruptDescription(resultOrErr) {
158
- const interrupt = resultOrErr.__interrupt__?.[0] || resultOrErr.interrupts?.[0]
159
- const payload = interrupt?.value
160
- if (!payload) return "This action requires your approval. Reply 'approve' or 'reject'."
161
-
162
- // deepagents' humanInTheLoopMiddleware: { actionRequests: [{ description }], reviewConfigs }
163
- if (payload.actionRequests?.length > 0) {
164
- return (
165
- payload.actionRequests[0].description || `Approve action: ${payload.actionRequests[0].name}?`
166
- )
167
- }
168
-
169
- // Raw interrupt(value) - value is a string or object
170
- if (typeof payload === "string") return payload
171
- return JSON.stringify(payload)
172
- }
173
-
174
- /**
175
- * Extract the raw structured interrupt payload for opaque carry on a DataPart.
176
- * Returns the payload ONLY when it is a plain object; arrays and strings are
177
- * carried by the TextPart alone. Payload is app-defined; the plugin never
178
- * interprets it.
179
- */
180
- function extractInterruptData(resultOrErr) {
181
- const interrupt = resultOrErr.__interrupt__?.[0] || resultOrErr.interrupts?.[0]
182
- const payload = interrupt?.value
183
- if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined
184
- return payload
185
- }
186
-
187
- // Order-invariant JSON serializer for structural arg comparison.
188
- function canonicalJSON(value) {
189
- if (value === null || typeof value !== "object") return JSON.stringify(value)
190
- if (Array.isArray(value)) return "[" + value.map(canonicalJSON).join(",") + "]"
191
- const keys = Object.keys(value).sort()
192
- return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalJSON(value[k])).join(",") + "}"
193
- }
194
-
195
- // Firm note describing HITL edits so the model doesn't apologize on the next turn.
196
- function composeEditNote(originals, resume) {
197
- const decisions = resume?.decisions
198
- if (!Array.isArray(decisions) || decisions.length === 0) return undefined
199
-
200
- const consumed = new Set()
201
- const takeByName = (name) => {
202
- for (let j = 0; j < originals.length; j++) {
203
- if (!consumed.has(j) && originals[j]?.name === name) {
204
- consumed.add(j)
205
- return originals[j]
206
- }
207
- }
208
- return undefined
209
- }
210
- const takeNextUnconsumed = () => {
211
- for (let j = 0; j < originals.length; j++) {
212
- if (!consumed.has(j)) {
213
- consumed.add(j)
214
- return originals[j]
215
- }
216
- }
217
- return undefined
218
- }
219
-
220
- const changes = []
221
- for (const d of decisions) {
222
- if (d?.type !== "edit" || !d.editedAction) continue
223
- const editedName = d.editedAction.name
224
- const editedArgs = d.editedAction.args
225
- const orig = takeByName(editedName) ?? takeNextUnconsumed()
226
- if (!orig) continue
227
- if (orig.name === editedName && canonicalJSON(orig.args) === canonicalJSON(editedArgs)) continue
228
- changes.push({
229
- from: { name: orig.name, args: orig.args },
230
- to: { name: editedName, args: editedArgs },
231
- })
232
- }
233
- if (changes.length === 0) return undefined
234
-
235
- const lines = changes.map(
236
- (c) =>
237
- `- \`${c.from.name}(${JSON.stringify(c.from.args)})\` → \`${c.to.name}(${JSON.stringify(c.to.args)})\``,
238
- )
239
- return [
240
- "The user reviewed your proposed tool call(s) in the human-in-the-loop approval flow and edited them before execution. This is intentional user action, NOT a mistake on your part. Do NOT apologize or say you made an error.",
241
- "",
242
- "Edits applied:",
243
- ...lines,
244
- "",
245
- "Proceed as if the edited values are what the user actually wants. Describe the outcome of the executed call accurately.",
246
- ].join("\n")
247
- }
248
-
249
- // Reads the pre-interrupt AI's tool_calls from the checkpointer (still un-mutated at resume time).
250
- async function getPreInterruptToolCalls(graph, config) {
251
- try {
252
- if (typeof graph.getState !== "function") return []
253
- const state = await graph.getState(config)
254
- const messages = state?.values?.messages ?? []
255
- for (let i = messages.length - 1; i >= 0; i--) {
256
- const m = messages[i]
257
- if (m?.tool_calls?.length) {
258
- return m.tool_calls.map((tc) => ({ id: tc.id, name: tc.name, args: tc.args }))
259
- }
260
- }
261
- return []
262
- } catch {
263
- return []
264
- }
265
- }
266
-
267
122
  /**
268
123
  * GraphExecutor wraps a compiled LangGraph graph as an A2A AgentExecutor.
269
124
  *
@@ -297,7 +152,7 @@ class GraphExecutor {
297
152
  abort(taskId) {
298
153
  const controller = this._abortControllers.get(taskId)
299
154
  if (controller && !controller.signal.aborted) {
300
- LOG.info("aborting", { task: short(taskId), service: this._srv.name })
155
+ LOG.info(this._srv.name, "aborting", { task: short(taskId) })
301
156
  controller.abort()
302
157
  }
303
158
  }
@@ -315,7 +170,7 @@ class GraphExecutor {
315
170
  const { CdsCheckpointSaver } =
316
171
  await import("../../lib/protocol/persistence/checkpoint-saver.js")
317
172
  resolved.checkpointer = new CdsCheckpointSaver()
318
- LOG.debug("Auto-injected CdsCheckpointSaver", { service: this._srv.name })
173
+ LOG.debug(this._srv.name, "- auto-injected CdsCheckpointSaver")
319
174
  }
320
175
  this._graph = resolved
321
176
  return this._graph
@@ -326,12 +181,10 @@ class GraphExecutor {
326
181
  * per-token artifact-update SSE events as LLM tokens arrive.
327
182
  */
328
183
  async _streamWithPublish(graph, input, config, eventBus, taskId, contextId, signal) {
329
- const maxExecution = ms4(cds.env.agents?.pool?.maxExecutionTimePerTask || "5min")
330
- const grace = this._getGrace()
331
- const softTimeout = Math.max(maxExecution - grace, 1000)
184
+ const maxExecution = ms4(cds.env.agents?.quotas?.maxExecutionTimePerTask || "5min")
332
185
 
333
186
  const controller = new AbortController()
334
- const timeoutHandle = setTimeout(() => controller.abort(), softTimeout)
187
+ const timeoutHandle = setTimeout(() => controller.abort(), maxExecution)
335
188
  const combinedSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal
336
189
 
337
190
  let tokenCount = 0
@@ -423,8 +276,8 @@ class GraphExecutor {
423
276
  if (signal?.aborted) {
424
277
  throw new AbortError("Task execution aborted")
425
278
  }
426
- throw new TimeoutError(`Graph execution timed out after ${softTimeout / 1000}s`, {
427
- timeout: softTimeout,
279
+ throw new TimeoutError(`Graph execution timed out after ${maxExecution / 1000}s`, {
280
+ timeout: maxExecution,
428
281
  })
429
282
  }
430
283
  throw err
@@ -464,23 +317,13 @@ class GraphExecutor {
464
317
 
465
318
  return { state: finalState, tokenCount }
466
319
  }
467
- /**
468
- * Parse configured timeout grace period.
469
- */
470
- _getGrace() {
471
- return ms4(cds.env.agents?.pool?.timeoutGrace ?? "15s")
472
- }
473
-
474
320
  async _invokeWithTimeout(graph, input, config, signal) {
475
- const maxExecution = ms4(cds.env.agents?.pool?.maxExecutionTimePerTask || "5min")
476
- const grace = this._getGrace()
477
- // Soft timeout fires early to allow graceful summarization
478
- const softTimeout = Math.max(maxExecution - grace, 1000)
321
+ const maxExecution = ms4(cds.env.agents?.quotas?.maxExecutionTimePerTask || "5min")
479
322
 
480
323
  // Use explicit AbortController + setTimeout (reffed timer keeps event loop alive)
481
324
  // instead of AbortSignal.timeout() which uses an unreffed timer
482
325
  const timeoutController = new AbortController()
483
- const timer = setTimeout(() => timeoutController.abort(), softTimeout)
326
+ const timer = setTimeout(() => timeoutController.abort(), maxExecution)
484
327
 
485
328
  // Combine caller-provided abort signal with timeout
486
329
  const combinedSignal = signal
@@ -496,8 +339,8 @@ class GraphExecutor {
496
339
  if (signal?.aborted) {
497
340
  throw new AbortError("Task execution aborted")
498
341
  }
499
- throw new TimeoutError(`Graph execution timed out after ${softTimeout / 1000}s`, {
500
- timeout: softTimeout,
342
+ throw new TimeoutError(`Graph execution timed out after ${maxExecution / 1000}s`, {
343
+ timeout: maxExecution,
501
344
  })
502
345
  }
503
346
  throw err
@@ -511,7 +354,6 @@ class GraphExecutor {
511
354
  */
512
355
  async _summarizePartialWork(taskId, contextId, serviceName, reason) {
513
356
  const { summarizePartialWork } = await import("../../lib/agents/summarize-on-timeout.js")
514
- const grace = this._getGrace()
515
357
  return summarizePartialWork({
516
358
  taskId,
517
359
  contextId,
@@ -519,7 +361,8 @@ class GraphExecutor {
519
361
  reason,
520
362
  checkpointer: this._graph?.checkpointer,
521
363
  getModel: () => this._srv.send("buildModel"),
522
- timeout: Math.max(grace - 2000, grace * 0.8, 500),
364
+ // Summary runs after graph abort, so no execution-time grace is needed.
365
+ timeout: 10_000,
523
366
  })
524
367
  }
525
368
 
@@ -599,9 +442,8 @@ class GraphExecutor {
599
442
  cds.env.agents?.fileIO,
600
443
  )
601
444
  if (rejection) {
602
- LOG.warn("input file rejected", {
445
+ LOG.warn(serviceName, "-", "input file rejected", {
603
446
  conversation: short(contextId),
604
- service: serviceName,
605
447
  name: safeName,
606
448
  mimeType: safeMime,
607
449
  reason: rejection,
@@ -610,9 +452,8 @@ class GraphExecutor {
610
452
  }
611
453
  const buf = Buffer.from(file.bytes, "base64")
612
454
  await fileStore.saveInputFile(taskId, safeName, safeMime, buf)
613
- LOG.info("file uploaded", {
455
+ LOG.info(serviceName, "-", "file uploaded", {
614
456
  conversation: short(contextId),
615
- service: serviceName,
616
457
  name: safeName,
617
458
  mimeType: safeMime,
618
459
  size: buf.length,
@@ -716,50 +557,17 @@ class GraphExecutor {
716
557
  const t0 = Date.now()
717
558
 
718
559
  if (isResume) {
719
- const dataPart = extractData(requestContext)
720
- const userText = extractText(requestContext)
721
- // Relaxed guard: accept a DataPart-only resume OR non-empty text.
722
- if (dataPart === undefined && !userText.trim()) {
723
- throw new Error(cds.i18n.messages.at("RESUME_REQUIRES_TEXT"))
724
- }
725
- const { Command } = await import("@langchain/langgraph")
726
- // DataPart wins over text — a structured resume is self-describing and any
727
- // accompanying text is treated as incidental (e.g. a human-readable echo).
728
- const resume = dataPart !== undefined ? dataPart : parseResumeDecision(userText)
729
- const decision = decisionTypeOf(resume)
730
-
731
- LOG.debug("resuming", {
732
- conversation: short(contextId),
733
- service: serviceName,
734
- decision,
735
- })
736
-
737
- // Audit: task resumed with HITL decision
738
- audit("AgentTaskResumed", {
739
- data: {
740
- taskId,
741
- contextId,
742
- service: serviceName,
743
- decision,
744
- },
745
- })
746
- // On edit, stash a diff note in state; the injector middleware prepends it next turn.
747
- const commandArgs = { resume }
748
- if (decision === "edit") {
749
- const originals = await getPreInterruptToolCalls(graph, config)
750
- const editNote = composeEditNote(originals, resume)
751
- if (editNote) commandArgs.update = { _hitlEditNote: editNote }
752
- }
753
- const resumed = await this._streamWithPublish(
560
+ const resume = isTimeoutHitl(requestContext.task) ? resumeTimeoutHitl : resumeHitl
561
+ result = await resume({
562
+ requestContext,
754
563
  graph,
755
- new Command(commandArgs),
756
564
  config,
757
565
  eventBus,
758
- taskId,
759
- contextId,
760
- controller.signal,
761
- )
762
- result = resumed.state
566
+ signal: controller.signal,
567
+ stream: (input, signal) =>
568
+ this._streamWithPublish(graph, input, config, eventBus, taskId, contextId, signal),
569
+ })
570
+ if (!result) return
763
571
  } else {
764
572
  const inputMapper = this._inputMapper || defaultInputMapper
765
573
  const rawInput = await inputMapper(requestContext)
@@ -780,60 +588,32 @@ class GraphExecutor {
780
588
  // (interrupt-only results may have no `messages` — treat as empty)
781
589
  usageData = aggregateUsageData(result.messages || [])
782
590
 
783
- if (result?.__interrupt__?.length > 0) {
784
- const description = extractInterruptDescription(result)
785
- const interruptData = extractInterruptData(result)
786
-
787
- const duration = ((Date.now() - t0) / 1000).toFixed(1) + "s"
788
- LOG.info("input-required", {
789
- conversation: short(contextId),
790
- service: serviceName,
591
+ const duration = ((Date.now() - t0) / 1000).toFixed(1) + "s"
592
+ if (requiresHitl(result)) {
593
+ handleHitlInterrupt({
594
+ result,
595
+ requestContext,
596
+ eventBus,
597
+ serviceName,
791
598
  duration,
792
- })
793
-
794
- if (wfSpan) {
795
- wfSpan.setAttribute("agent.outcome", "input-required")
796
- const outputs = {
797
- choices: [{ message: { role: "assistant", content: description } }],
798
- }
799
- setSpanAttrs(wfSpan, mlflowAttrs("AGENT", { outputs }))
800
- const rootSpan = cds.context?.["_mlflow.rootSpan"]
801
- if (rootSpan) {
802
- setSpanAttrs(rootSpan, mlflowAttrs("CHAIN", { outputs }))
803
- }
804
- }
805
-
806
- // Audit: agent requires human input
807
- audit("AgentInputRequired", {
808
- data: {
809
- taskId,
810
- contextId,
811
- service: serviceName,
812
- description,
813
- interruptData,
599
+ onInputRequired: (description) => {
600
+ if (!wfSpan) return
601
+ wfSpan.setAttribute("agent.outcome", "input-required")
602
+ const outputs = {
603
+ choices: [{ message: { role: "assistant", content: description } }],
604
+ }
605
+ setSpanAttrs(wfSpan, mlflowAttrs("AGENT", { outputs }))
606
+ const rootSpan = cds.context?.["_mlflow.rootSpan"]
607
+ if (rootSpan) setSpanAttrs(rootSpan, mlflowAttrs("CHAIN", { outputs }))
814
608
  },
815
609
  })
816
-
817
- eventBus.publish({
818
- kind: "status-update",
819
- taskId,
820
- contextId,
821
- status: {
822
- state: "input-required",
823
- message: agentMessage(description, interruptData),
824
- timestamp: new Date().toISOString(),
825
- },
826
- final: true,
827
- })
828
- eventBus.finished()
829
610
  return
830
611
  }
831
612
 
832
- const duration = ((Date.now() - t0) / 1000).toFixed(1) + "s"
833
613
  const outputMapper = this._outputMapper || defaultOutputMapper
834
614
  const output = outputMapper(result) || "I could not generate a response."
835
615
 
836
- LOG.info("completed", { conversation: short(contextId), service: serviceName, duration })
616
+ LOG.info(serviceName, "completed", { conversation: short(contextId), duration })
837
617
 
838
618
  if (wfSpan) {
839
619
  wfSpan.setAttribute("agent.outcome", "completed")
@@ -892,6 +672,9 @@ class GraphExecutor {
892
672
  // 1. emit_file_part tool calls (default graph) — JSON in toolResults/messages
893
673
  // 2. write_file '/outputs/*' via OutputsBackend (deep agent) — CDS rows
894
674
  const fileArtifacts = []
675
+ // DataParts embedded in tool-result content.
676
+ // Published as their own `data-*` artifact-update events below.
677
+ const dataArtifacts = []
895
678
  const maxFileBytes = cds.env.agents.fileIO.maxOutputFileSizeBytes
896
679
 
897
680
  // Artifacts from emit_file_part are this agent's own outputs — they must be
@@ -915,7 +698,10 @@ class GraphExecutor {
915
698
  const content = typeof msg.content === "string" ? msg.content : ""
916
699
  let pos = 0
917
700
  while (pos < content.length) {
918
- const start = content.indexOf('{"kind":"file"', pos)
701
+ // Find the earliest next FilePart or DataPart marker.
702
+ const fileAt = content.indexOf('{"kind":"file"', pos)
703
+ const dataAt = content.indexOf('{"kind":"data"', pos)
704
+ const start = fileAt === -1 ? dataAt : dataAt === -1 ? fileAt : Math.min(fileAt, dataAt)
919
705
  if (start === -1) break
920
706
  // Walk forward tracking depth and quoted strings so that '}' inside
921
707
  // a string value (e.g. a filename like "result_{final}.csv") does not
@@ -944,6 +730,11 @@ class GraphExecutor {
944
730
  const raw = content.slice(start, i + 1)
945
731
  try {
946
732
  const artifact = JSON.parse(raw)
733
+ if (artifact.kind === "data") {
734
+ dataArtifacts.push(artifact)
735
+ pos = i + 1
736
+ continue
737
+ }
947
738
  // Apply the same per-file size cap as Source 2. Decode-length is
948
739
  // computed by Buffer.byteLength (zero allocation — pure formula
949
740
  // over string length + padding) so an oversized blob never pins
@@ -953,9 +744,8 @@ class GraphExecutor {
953
744
  ? Buffer.byteLength(artifact.file.bytes, "base64")
954
745
  : 0
955
746
  if (declaredBytes > maxFileBytes) {
956
- LOG.warn("emit_file_part artifact exceeds cap; skipping", {
747
+ LOG.warn(serviceName, "-", "emit_file_part artifact exceeds cap; skipping", {
957
748
  conversation: short(contextId),
958
- service: serviceName,
959
749
  name: artifact.file?.name,
960
750
  size: declaredBytes,
961
751
  cap: maxFileBytes,
@@ -980,9 +770,8 @@ class GraphExecutor {
980
770
  const outputMeta = await fileStore.listOutputFilesMeta(taskId)
981
771
  for (const meta of outputMeta) {
982
772
  if (meta.size > maxFileBytes) {
983
- LOG.warn("output file exceeds cap; skipping", {
773
+ LOG.warn(serviceName, "-", "output file exceeds cap; skipping", {
984
774
  conversation: short(contextId),
985
- service: serviceName,
986
775
  name: meta.name,
987
776
  size: meta.size,
988
777
  cap: maxFileBytes,
@@ -1001,7 +790,7 @@ class GraphExecutor {
1001
790
  // Exclude emit_file_part outputs — those are this agent's own artifacts, not
1002
791
  // downstream files, and re-persisting them would create spurious /uploads/ entries.
1003
792
  // Enforce the same size + MIME guard as inbound uploads so a malicious
1004
- // sub-agent cannot bypass the cap by echoing an oversized FilePart.
793
+ // subagent cannot bypass the cap by echoing an oversized FilePart.
1005
794
  await Promise.all(
1006
795
  fileArtifacts
1007
796
  .slice(0, source1Count)
@@ -1009,9 +798,8 @@ class GraphExecutor {
1009
798
  if (!fa.file?.bytes || !fa.file?.name || fa._fromEmitFilePart) return false
1010
799
  const rejection = checkInputFile(fa.file, cds.env.agents?.fileIO)
1011
800
  if (rejection) {
1012
- LOG.warn("downstream file re-persist rejected", {
801
+ LOG.warn(serviceName, "-", "downstream file re-persist rejected", {
1013
802
  conversation: short(contextId),
1014
- service: serviceName,
1015
803
  name: fa.file.name,
1016
804
  reason: rejection,
1017
805
  })
@@ -1031,9 +819,8 @@ class GraphExecutor {
1031
819
  // Capture source classification for the log line below.
1032
820
  for (const filePart of fileArtifacts) {
1033
821
  if (!filePart.file?.name) {
1034
- LOG.warn("skipping malformed file artifact", {
822
+ LOG.warn(serviceName, "-", "skipping malformed file artifact", {
1035
823
  conversation: short(contextId),
1036
- service: serviceName,
1037
824
  })
1038
825
  continue
1039
826
  }
@@ -1044,9 +831,8 @@ class GraphExecutor {
1044
831
  typeof filePart.file?.bytes === "string"
1045
832
  ? Buffer.byteLength(filePart.file.bytes, "base64")
1046
833
  : 0
1047
- LOG.info("file emitted", {
834
+ LOG.info(serviceName, "-", "file emitted", {
1048
835
  conversation: short(contextId),
1049
- service: serviceName,
1050
836
  name: safeName,
1051
837
  mimeType: filePart.file?.mimeType,
1052
838
  bytes: decodedSize,
@@ -1064,6 +850,25 @@ class GraphExecutor {
1064
850
  })
1065
851
  }
1066
852
 
853
+ dataArtifacts.forEach((artifact, i) => {
854
+ if (artifact.data == null || typeof artifact.data !== "object") {
855
+ LOG.warn(serviceName, "-", "skipping malformed data artifact", {
856
+ conversation: short(contextId),
857
+ })
858
+ return
859
+ }
860
+ LOG.info(serviceName, "-", "data emitted", { conversation: short(contextId) })
861
+ eventBus.publish({
862
+ kind: "artifact-update",
863
+ taskId,
864
+ contextId,
865
+ artifact: {
866
+ artifactId: `data-${i}`,
867
+ parts: [{ kind: "data", data: artifact.data }],
868
+ },
869
+ })
870
+ })
871
+
1067
872
  // Programmatic .chat() path: stash graph result on eventBus so chat.js
1068
873
  // can read messages without an extra checkpoint roundtrip.
1069
874
  if (eventBus[COLLECT_RESULT]) {
@@ -1085,7 +890,7 @@ class GraphExecutor {
1085
890
  // Aborted (client disconnect or tasks/cancel) — publish canceled, not failed
1086
891
  // Use name check (not instanceof) to also catch native DOMException AbortError from LangGraph
1087
892
  if (err.name === "AbortError") {
1088
- LOG.info("canceled", { conversation: short(contextId), service: serviceName })
893
+ LOG.info(serviceName, "-", "canceled", { conversation: short(contextId) })
1089
894
  if (wfSpan) wfSpan.setAttribute("agent.outcome", "canceled")
1090
895
 
1091
896
  audit("AgentTaskCanceled", {
@@ -1106,62 +911,34 @@ class GraphExecutor {
1106
911
  return
1107
912
  }
1108
913
 
1109
- // Timeout — attempt graceful summary of work-in-progress before reporting
914
+ // Timeout — pause at checkpoint. User decides whether to resume graph.
1110
915
  if (err.name === "TimeoutError") {
1111
- LOG.warn("timeout", { conversation: short(contextId), service: serviceName })
916
+ LOG.warn(serviceName, "-", "timeout", { conversation: short(contextId) })
1112
917
 
1113
- if (wfSpan) wfSpan.setAttribute("agent.outcome", "timeout")
1114
- metrics.errorsTotal.add(1, { ...mAttrs, "agent.error.code": "timeout" })
918
+ if (wfSpan) wfSpan.setAttribute("agent.outcome", "input-required")
1115
919
 
1116
920
  const summary = await this._summarizePartialWork(
1117
921
  taskId,
1118
922
  contextId,
1119
923
  serviceName,
1120
- "timed out",
924
+ "timeOut",
1121
925
  )
1122
926
 
1123
- audit("AgentTaskFailed", {
1124
- data: {
1125
- taskId,
1126
- contextId,
1127
- service: serviceName,
1128
- error: err.message,
1129
- errorCode: "timeout",
1130
- task: requestContext.task,
1131
- },
1132
- })
1133
-
1134
- eventBus.publish({
1135
- kind: "status-update",
1136
- taskId,
1137
- contextId,
1138
- status: {
1139
- state: "canceled",
1140
- message: agentMessage(summary),
1141
- timestamp: new Date().toISOString(),
1142
- },
1143
- final: true,
1144
- })
927
+ publishTimeoutHitl({ requestContext, eventBus, description: summary, serviceName })
1145
928
  return
1146
929
  }
1147
930
 
1148
931
  // Quota exceeded — summarize partial work instead of raw error
1149
932
  if (err.quotaExceeded) {
1150
- LOG.warn("quota exceeded", {
933
+ LOG.warn(serviceName, "-", "quota exceeded", {
1151
934
  conversation: short(contextId),
1152
- service: serviceName,
1153
935
  error: err.message,
1154
936
  })
1155
937
 
1156
938
  if (wfSpan) wfSpan.setAttribute("agent.outcome", "quota_exceeded")
1157
939
  metrics.errorsTotal.add(1, { ...mAttrs, "agent.error.code": "quota_exceeded" })
1158
940
 
1159
- const summary = await this._summarizePartialWork(
1160
- taskId,
1161
- contextId,
1162
- serviceName,
1163
- "quota exceeded",
1164
- )
941
+ const summary = await this._summarizePartialWork(taskId, contextId, serviceName, "quota")
1165
942
 
1166
943
  audit("AgentTaskFailed", {
1167
944
  data: {
@@ -1188,16 +965,11 @@ class GraphExecutor {
1188
965
  return
1189
966
  }
1190
967
 
1191
- LOG.error("failed", {
1192
- conversation: short(contextId),
1193
- service: serviceName,
1194
- error: err.message,
1195
- })
1196
- LOG.debug("failed stack", {
1197
- conversation: short(contextId),
1198
- service: serviceName,
1199
- stack: err.stack,
1200
- })
968
+ LOG.error(
969
+ Object.assign(err, {
970
+ conversation: short(contextId),
971
+ }),
972
+ )
1201
973
 
1202
974
  if (wfSpan) {
1203
975
  wfSpan.setAttribute("agent.outcome", "failed")
@@ -1326,16 +1098,7 @@ class GraphExecutor {
1326
1098
  }
1327
1099
  }
1328
1100
 
1329
- export {
1330
- GraphExecutor,
1331
- messageText,
1332
- defaultOutputMapper,
1333
- agentMessage,
1334
- parseResumeDecision,
1335
- decisionTypeOf,
1336
- extractInterruptData,
1337
- composeEditNote,
1338
- }
1101
+ export { GraphExecutor, messageText, defaultOutputMapper, agentMessage }
1339
1102
 
1340
1103
  /**
1341
1104
  * @param {[import('@langchain/core/messages').Message]} messages