@cap-js/agents 0.0.0 → 0.9.2

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 (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +154 -0
  3. package/_i18n/messages.properties +31 -0
  4. package/cds-plugin.js +140 -0
  5. package/index.cds +116 -0
  6. package/index.js +0 -0
  7. package/lib/agents/markdown/backends/mime-utils.js +37 -0
  8. package/lib/agents/markdown/backends/outputs-backend.js +156 -0
  9. package/lib/agents/markdown/backends/readonly-backend.js +48 -0
  10. package/lib/agents/markdown/backends/uploads-backend.js +160 -0
  11. package/lib/agents/markdown/deep-agent.js +82 -0
  12. package/lib/agents/middleware/agent-actions.js +18 -0
  13. package/lib/agents/middleware/content-filter.js +191 -0
  14. package/lib/agents/middleware/hitl-edit-note-injector.js +18 -0
  15. package/lib/agents/middleware/hitl.js +20 -0
  16. package/lib/agents/middleware/index.js +19 -0
  17. package/lib/agents/middleware/patch-tool-calls.js +51 -0
  18. package/lib/agents/middleware/quota-enforcer.js +94 -0
  19. package/lib/agents/middleware/status-update.js +153 -0
  20. package/lib/agents/middleware/tool-selection.js +22 -0
  21. package/lib/agents/quota-enforcer-at-start.js +198 -0
  22. package/lib/agents/summarize-on-timeout.js +91 -0
  23. package/lib/compile.js +55 -0
  24. package/lib/index.cjs +1 -0
  25. package/lib/index.js +422 -0
  26. package/lib/models/aicore.js +441 -0
  27. package/lib/models/anthropic.js +77 -0
  28. package/lib/models/mock.js +88 -0
  29. package/lib/preview/chat.html +897 -0
  30. package/lib/preview/preview.js +46 -0
  31. package/lib/protocol/agent-card.js +297 -0
  32. package/lib/protocol/persistence/checkpoint-saver.js +320 -0
  33. package/lib/protocol/persistence/cleanup.js +84 -0
  34. package/lib/protocol/persistence/file-store.js +209 -0
  35. package/lib/protocol/persistence/push-notification-store.js +59 -0
  36. package/lib/protocol/persistence/task-store.js +47 -0
  37. package/lib/protocol/push-notification-sender.js +57 -0
  38. package/lib/sidecar.js +162 -0
  39. package/lib/telemetry/active-users.js +106 -0
  40. package/lib/telemetry/chat-tracing.js +364 -0
  41. package/lib/telemetry/metrics.js +85 -0
  42. package/lib/telemetry/mlflow.js +290 -0
  43. package/lib/telemetry/tool-tracing.js +164 -0
  44. package/lib/telemetry/tracing.js +150 -0
  45. package/lib/utils/inner-auth.js +33 -0
  46. package/lib/utils/markdown.js +199 -0
  47. package/lib/utils/message-handling.js +155 -0
  48. package/lib/utils/utils.js +168 -0
  49. package/package.json +225 -2
  50. package/srv/graph-cache.js +82 -0
  51. package/srv/handlers/graph-executor.js +1374 -0
  52. package/srv/handlers/index.js +178 -0
  53. package/srv/handlers/mcp-tools.js +161 -0
  54. package/srv/handlers/sub-agent-tools.js +316 -0
  55. package/srv/handlers/system-prompt.js +25 -0
  56. package/srv/handlers/tools.js +366 -0
  57. package/srv/langgraph-executor-srv.js +70 -0
  58. package/srv/push-notification-srv.js +149 -0
@@ -0,0 +1,1374 @@
1
+ import cds from "@sap/cds"
2
+ import { short, audit, ms4 } from "../../lib/utils/utils.js"
3
+ import { partsToText, buildChatMessages, firstDataPart } from "../../lib/utils/message-handling.js"
4
+ import * as metrics from "../../lib/telemetry/metrics.js"
5
+ import { mlflowAttrs, mlflowTraceAttrs, setSpanAttrs } from "../../lib/telemetry/mlflow.js"
6
+ import { CdsFileStore } from "../../lib/protocol/persistence/file-store.js"
7
+ import { formatFileSize, sanitizeFilename } from "./tools.js"
8
+ import { convertUsageData } from "../../lib/telemetry/chat-tracing.js"
9
+ import { triggerCleanup } from "../../lib/protocol/persistence/cleanup.js"
10
+
11
+ const LOG = cds.log("agents")
12
+
13
+ /**
14
+ * Validate against the configured cap and MIME allowlist
15
+ */
16
+ function checkInputFile(file, cfg) {
17
+ const maxBytes = cfg?.maxInputFileSizeBytes
18
+ if (maxBytes > 0 && typeof file?.bytes === "string") {
19
+ const declared = Buffer.byteLength(file.bytes, "base64")
20
+ if (declared > maxBytes) {
21
+ return `exceeds size limit (${formatFileSize(declared)} > ${formatFileSize(maxBytes)})`
22
+ }
23
+ }
24
+ const allowed = cfg?.defaultInputModes
25
+ if (Array.isArray(allowed) && allowed.length > 0) {
26
+ const mime = file?.mimeType || "application/octet-stream"
27
+ if (!allowed.includes(mime)) {
28
+ return `mime type ${mime} not allowed`
29
+ }
30
+ }
31
+ return null
32
+ }
33
+
34
+ /**
35
+ * Thrown when a task execution is aborted (client disconnect or tasks/cancel).
36
+ */
37
+ class AbortError extends Error {
38
+ constructor(message) {
39
+ super(message)
40
+ this.name = "AbortError"
41
+ this.code = "ABORT_ERR"
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Thrown when graph execution exceeds the configured timeout.
47
+ * Carries partial state for graceful summarization.
48
+ */
49
+ class TimeoutError extends Error {
50
+ constructor(message, { timeout } = {}) {
51
+ super(message)
52
+ this.name = "TimeoutError"
53
+ this.code = "TIMEOUT_ERR"
54
+ this.timeout = timeout
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Default input mapper: extracts text from A2A message parts and wraps as HumanMessage.
60
+ *
61
+ * NOTE TO CUSTOM INPUTMAPPER AUTHORS: when fileIO is enabled and you replace
62
+ * this mapper, copy the `_fileManifest` handling below or files will be silently
63
+ * persisted but invisible to the model.
64
+ */
65
+ async function defaultInputMapper(requestContext) {
66
+ const { HumanMessage } = await import("@langchain/core/messages")
67
+ const text = partsToText(requestContext.userMessage?.parts)
68
+ const fullText = requestContext._fileManifest ? `${text}\n${requestContext._fileManifest}` : text
69
+ return { messages: [new HumanMessage(fullText)] }
70
+ }
71
+
72
+ /**
73
+ * Extract plain text from a LangChain message's `content`, which may be a string
74
+ * or an array of content blocks (`[{ type: "text", text }, ...]`). Non-text
75
+ * blocks (tool_call, reasoning, …) are dropped.
76
+ */
77
+ function messageText(content) {
78
+ if (typeof content === "string") return content
79
+ if (Array.isArray(content)) {
80
+ return content
81
+ .filter((b) => b?.type === "text" && b.text)
82
+ .map((b) => b.text)
83
+ .join("")
84
+ }
85
+ return ""
86
+ }
87
+
88
+ /**
89
+ * Default output mapper: extracts response text from graph result.
90
+ * Priority: last AI message content > result.output > JSON stringified result.
91
+ */
92
+ function defaultOutputMapper(result) {
93
+ // 1. Messages-based: last message content (standard LangChain pattern)
94
+ if (result.messages?.length > 0) {
95
+ const lastMsg = result.messages[result.messages.length - 1]
96
+ const text = messageText(lastMsg?.content)
97
+ if (text) return text
98
+ }
99
+ // 2. Output field (e.g. travel-sample pattern)
100
+ if (result.output) return result.output
101
+ // 3. Fallback
102
+ return JSON.stringify(result)
103
+ }
104
+
105
+ // Construct a spec-compliant A2A Message; when `data` is a plain object, append it as a DataPart.
106
+ function agentMessage(text, data) {
107
+ const parts = [{ kind: "text", text }]
108
+ if (data && typeof data === "object") parts.push({ kind: "data", data })
109
+ return {
110
+ kind: "message",
111
+ messageId: cds.utils.uuid(),
112
+ role: "agent",
113
+ parts,
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Extract user text from A2A message parts.
119
+ */
120
+ function extractText(requestContext) {
121
+ return partsToText(requestContext.userMessage?.parts)
122
+ }
123
+
124
+ // Extract the first inbound DataPart's opaque `data` object, or undefined if none.
125
+ function extractData(requestContext) {
126
+ return firstDataPart(requestContext.userMessage?.parts)
127
+ }
128
+
129
+ /**
130
+ * Parse user's resume text into a HITL decision.
131
+ * Maps to the format expected by deepagents' humanInTheLoopMiddleware.
132
+ */
133
+ function parseResumeDecision(userText) {
134
+ const t = userText.trim()
135
+ if (/^(approve|yes|confirm|ok)$/i.test(t)) {
136
+ return { decisions: [{ type: "approve" }] }
137
+ }
138
+ if (/^edit$/i.test(t)) {
139
+ // Bare edit — structured edits (with args) arrive via the DataPart path.
140
+ return { decisions: [{ type: "edit" }] }
141
+ }
142
+ return { decisions: [{ type: "reject", message: userText }] }
143
+ }
144
+
145
+ // Best-effort decision label for logging/audit; opaque DataPart resumes fall back to "data".
146
+ function decisionTypeOf(resume) {
147
+ return resume?.decisions?.[0]?.type ?? "data"
148
+ }
149
+
150
+ /**
151
+ * Extract the human-readable description from an interrupt payload.
152
+ * Accepts either a graph result (with __interrupt__) or a GraphInterrupt error (with .interrupts).
153
+ * Handles both deepagents' humanInTheLoopMiddleware format and raw interrupt() calls.
154
+ */
155
+ function extractInterruptDescription(resultOrErr) {
156
+ const interrupt = resultOrErr.__interrupt__?.[0] || resultOrErr.interrupts?.[0]
157
+ const payload = interrupt?.value
158
+ if (!payload) return "This action requires your approval. Reply 'approve' or 'reject'."
159
+
160
+ // deepagents' humanInTheLoopMiddleware: { actionRequests: [{ description }], reviewConfigs }
161
+ if (payload.actionRequests?.length > 0) {
162
+ return (
163
+ payload.actionRequests[0].description || `Approve action: ${payload.actionRequests[0].name}?`
164
+ )
165
+ }
166
+
167
+ // Raw interrupt(value) - value is a string or object
168
+ if (typeof payload === "string") return payload
169
+ return JSON.stringify(payload)
170
+ }
171
+
172
+ /**
173
+ * Extract the raw structured interrupt payload for opaque carry on a DataPart.
174
+ * Returns the payload ONLY when it is a plain object; arrays and strings are
175
+ * carried by the TextPart alone. Payload is app-defined; the plugin never
176
+ * interprets it.
177
+ */
178
+ function extractInterruptData(resultOrErr) {
179
+ const interrupt = resultOrErr.__interrupt__?.[0] || resultOrErr.interrupts?.[0]
180
+ const payload = interrupt?.value
181
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined
182
+ return payload
183
+ }
184
+
185
+ // Order-invariant JSON serializer for structural arg comparison.
186
+ function canonicalJSON(value) {
187
+ if (value === null || typeof value !== "object") return JSON.stringify(value)
188
+ if (Array.isArray(value)) return "[" + value.map(canonicalJSON).join(",") + "]"
189
+ const keys = Object.keys(value).sort()
190
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalJSON(value[k])).join(",") + "}"
191
+ }
192
+
193
+ // Firm note describing HITL edits so the model doesn't apologize on the next turn.
194
+ function composeEditNote(originals, resume) {
195
+ const decisions = resume?.decisions
196
+ if (!Array.isArray(decisions) || decisions.length === 0) return undefined
197
+
198
+ const consumed = new Set()
199
+ const takeByName = (name) => {
200
+ for (let j = 0; j < originals.length; j++) {
201
+ if (!consumed.has(j) && originals[j]?.name === name) {
202
+ consumed.add(j)
203
+ return originals[j]
204
+ }
205
+ }
206
+ return undefined
207
+ }
208
+ const takeNextUnconsumed = () => {
209
+ for (let j = 0; j < originals.length; j++) {
210
+ if (!consumed.has(j)) {
211
+ consumed.add(j)
212
+ return originals[j]
213
+ }
214
+ }
215
+ return undefined
216
+ }
217
+
218
+ const changes = []
219
+ for (const d of decisions) {
220
+ if (d?.type !== "edit" || !d.editedAction) continue
221
+ const editedName = d.editedAction.name
222
+ const editedArgs = d.editedAction.args
223
+ const orig = takeByName(editedName) ?? takeNextUnconsumed()
224
+ if (!orig) continue
225
+ if (orig.name === editedName && canonicalJSON(orig.args) === canonicalJSON(editedArgs)) continue
226
+ changes.push({
227
+ from: { name: orig.name, args: orig.args },
228
+ to: { name: editedName, args: editedArgs },
229
+ })
230
+ }
231
+ if (changes.length === 0) return undefined
232
+
233
+ const lines = changes.map(
234
+ (c) =>
235
+ `- \`${c.from.name}(${JSON.stringify(c.from.args)})\` → \`${c.to.name}(${JSON.stringify(c.to.args)})\``,
236
+ )
237
+ return [
238
+ "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.",
239
+ "",
240
+ "Edits applied:",
241
+ ...lines,
242
+ "",
243
+ "Proceed as if the edited values are what the user actually wants. Describe the outcome of the executed call accurately.",
244
+ ].join("\n")
245
+ }
246
+
247
+ // Reads the pre-interrupt AI's tool_calls from the checkpointer (still un-mutated at resume time).
248
+ async function getPreInterruptToolCalls(graph, config) {
249
+ try {
250
+ if (typeof graph.getState !== "function") return []
251
+ const state = await graph.getState(config)
252
+ const messages = state?.values?.messages ?? []
253
+ for (let i = messages.length - 1; i >= 0; i--) {
254
+ const m = messages[i]
255
+ if (m?.tool_calls?.length) {
256
+ return m.tool_calls.map((tc) => ({ id: tc.id, name: tc.name, args: tc.args }))
257
+ }
258
+ }
259
+ return []
260
+ } catch {
261
+ return []
262
+ }
263
+ }
264
+
265
+ /**
266
+ * GraphExecutor wraps a compiled LangGraph graph as an A2A AgentExecutor.
267
+ *
268
+ * Supports:
269
+ * - Single-turn and multi-turn conversations (via auto-injected CdsCheckpointSaver)
270
+ * - HITL (Human-in-the-Loop) via LangGraph's interrupt()/Command resume mechanism
271
+ * - Configurable timeout, input/output mappers
272
+ *
273
+ * Usage:
274
+ * Created by the default `buildGraph` event handler or by apps returning a
275
+ * compiled graph from their custom `buildGraph` handler.
276
+ */
277
+ class GraphExecutor {
278
+ constructor(graph, srv, options = {}) {
279
+ this._rawGraph = graph
280
+ this._graph = null
281
+ this._srv = srv
282
+ this._options = options
283
+ this._inputMapper = options.inputMapper || null
284
+ this._outputMapper = options.outputMapper || null
285
+ this._configMapper = options.configMapper || null
286
+ this._recursionLimit = options.recursionLimit ?? null
287
+ /** @type {Map<string, AbortController>} per-task abort controllers */
288
+ this._abortControllers = new Map()
289
+ }
290
+
291
+ /**
292
+ * Abort a running task execution. Called on client disconnect or tasks/cancel.
293
+ * Safe to call multiple times or for unknown taskIds.
294
+ */
295
+ abort(taskId) {
296
+ const controller = this._abortControllers.get(taskId)
297
+ if (controller && !controller.signal.aborted) {
298
+ LOG.info("aborting", { task: short(taskId), service: this._srv.name })
299
+ controller.abort()
300
+ }
301
+ }
302
+
303
+ async _resolveGraph() {
304
+ if (this._graph) return this._graph
305
+ const resolved = await this._rawGraph
306
+ if (!resolved || typeof resolved.invoke !== "function") {
307
+ throw new Error(
308
+ `buildGraph must return a compiled LangGraph graph (with an invoke() method). Got: ${typeof resolved}`,
309
+ )
310
+ }
311
+ // Auto-inject CdsCheckpointSaver if graph has no checkpointer (enables multi-turn + HITL)
312
+ if (!resolved.checkpointer && this._options?.checkpointer !== false) {
313
+ const { CdsCheckpointSaver } =
314
+ await import("../../lib/protocol/persistence/checkpoint-saver.js")
315
+ resolved.checkpointer = new CdsCheckpointSaver()
316
+ LOG.debug("Auto-injected CdsCheckpointSaver", { service: this._srv.name })
317
+ }
318
+ this._graph = resolved
319
+ return this._graph
320
+ }
321
+
322
+ /**
323
+ * Drive the graph with graph.stream() in "messages"+"updates" mode, publishing
324
+ * per-token artifact-update SSE events as LLM tokens arrive.
325
+ */
326
+ async _streamWithPublish(graph, input, config, eventBus, taskId, contextId, signal) {
327
+ const maxExecution = ms4(cds.env.agents?.pool?.maxExecutionTimePerTask || "5min")
328
+ const grace = this._getGrace()
329
+ const softTimeout = Math.max(maxExecution - grace, 1000)
330
+
331
+ const controller = new AbortController()
332
+ const timeoutHandle = setTimeout(() => controller.abort(), softTimeout)
333
+ const combinedSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal
334
+
335
+ let tokenCount = 0
336
+ let finalState = null
337
+ // Track the current turn (langchain message id) and whether it has emitted a
338
+ // tool call. Anthropic-style turns can stream a text preamble BEFORE their
339
+ // tool_use block ("Let me first look up …"); we can't tell in advance that
340
+ // such a turn is planning rather than the final answer, so we stream those
341
+ // tokens optimistically. Once we see tool_call_chunks for the same turn, we
342
+ // know retrospectively that the preamble was planning — emit an authoritative
343
+ // event-level replace with empty text to wipe the leaked preamble, then skip
344
+ // all further text from this turn.
345
+ let currentMsgId = null
346
+ let turnHasToolCall = false
347
+
348
+ try {
349
+ if (typeof graph.stream !== "function" || cds.env.agents?.streaming === false) {
350
+ const state = await this._invokeWithTimeout(graph, input, config, signal)
351
+ return { state, tokenCount: 0 }
352
+ }
353
+
354
+ const streamConfig = {
355
+ ...config,
356
+ streamMode: ["messages", "updates"],
357
+ signal: combinedSignal,
358
+ }
359
+
360
+ // ReactAgent.stream() and compiled StateGraph.stream() both return an
361
+ // AsyncIterable (ReactAgent wraps in a promise — normalise both).
362
+ const raw = graph.stream(input, streamConfig)
363
+ const iterable = raw && typeof raw[Symbol.asyncIterator] === "function" ? raw : await raw
364
+
365
+ for await (const chunk of iterable) {
366
+ // Multi-mode stream yields [mode, payload] tuples
367
+ if (!Array.isArray(chunk) || chunk.length < 2) continue
368
+ const [mode, payload] = chunk
369
+
370
+ if (mode === "messages") {
371
+ // payload is [AIMessageChunk, metadata]
372
+ const msgChunk = Array.isArray(payload) ? payload[0] : payload
373
+ const meta = Array.isArray(payload) ? payload[1] : undefined
374
+ if (msgChunk?.type !== "ai") continue
375
+ // Skip tokens from NESTED model calls (pipe is used as separator by langchain)
376
+ if (meta?.langgraph_checkpoint_ns?.includes("|")) continue
377
+ // Only stream tokens from the main agent model call.
378
+ if (meta?.langgraph_node && meta.langgraph_node !== "model_request") continue
379
+
380
+ if (msgChunk.id && msgChunk.id !== currentMsgId) {
381
+ currentMsgId = msgChunk.id
382
+ turnHasToolCall = false
383
+ tokenCount = 0
384
+ }
385
+
386
+ // Retroactively invalidate a leaked planning preamble. In a ReAct loop
387
+ // the model can emit "Let me look this up …" before its tool_use block
388
+ if (msgChunk.tool_call_chunks?.length && !turnHasToolCall) {
389
+ turnHasToolCall = true
390
+ if (tokenCount > 0) {
391
+ eventBus.publish({
392
+ kind: "artifact-update",
393
+ taskId,
394
+ contextId,
395
+ append: false,
396
+ lastChunk: false,
397
+ artifact: {
398
+ artifactId: "response",
399
+ parts: [{ kind: "text", text: "" }],
400
+ },
401
+ })
402
+ tokenCount = 0
403
+ }
404
+ }
405
+ if (turnHasToolCall) continue
406
+
407
+ const text = messageText(msgChunk?.content)
408
+ if (!text) continue
409
+ // A2A TaskArtifactUpdateEvent: `append` and `lastChunk` are event-level
410
+ // fields (siblings of `artifact`), NOT properties of `artifact`. The SDK's
411
+ // ResultManager reads event.append; nesting them leaves it undefined and
412
+ // forces replace-on-every-chunk instead of accumulation.
413
+ eventBus.publish({
414
+ kind: "artifact-update",
415
+ taskId,
416
+ contextId,
417
+ append: tokenCount > 0,
418
+ lastChunk: false,
419
+ artifact: {
420
+ artifactId: "response",
421
+ parts: [{ kind: "text", text }],
422
+ },
423
+ })
424
+ tokenCount++
425
+ } else if (mode === "updates") {
426
+ // The updates stream yields per-node deltas — { <node>: { messages: [oneNewMessage] } },
427
+ // NOT the accumulated conversation. Spreading these would leave finalState
428
+ // with only the last node's single message, losing earlier turns (e.g. the
429
+ // AIMessage carrying an emit_file_part tool_call and its ToolMessage result).
430
+ // We therefore only lift the ephemeral __interrupt__ signal here (HITL) and
431
+ // recover the full, reduced message list from the checkpoint after the stream.
432
+ if (payload && typeof payload === "object" && !Array.isArray(payload)) {
433
+ if (payload.__interrupt__ !== undefined) {
434
+ finalState = { ...finalState, __interrupt__: payload.__interrupt__ }
435
+ }
436
+ }
437
+ }
438
+ }
439
+ } catch (err) {
440
+ if (combinedSignal.aborted) {
441
+ if (signal?.aborted) {
442
+ throw new AbortError("Task execution aborted")
443
+ }
444
+ throw new TimeoutError(`Graph execution timed out after ${softTimeout / 1000}s`, {
445
+ timeout: softTimeout,
446
+ })
447
+ }
448
+ throw err
449
+ } finally {
450
+ clearTimeout(timeoutHandle)
451
+ }
452
+
453
+ // Recover the fully-reduced state from the checkpoint. The streamed updates only
454
+ // carry per-node message deltas, so channel_values is the authoritative source for
455
+ // the complete message list (required by the file-I/O scan below and outputMapper).
456
+ // The __interrupt__ captured from the updates stream is preserved — it is an
457
+ // ephemeral signal that may already be cleared from channel_values.
458
+ if (this._graph?.checkpointer) {
459
+ try {
460
+ const thread_id = config.configurable?.thread_id
461
+ let cp = await this._graph.checkpointer.getTuple({ configurable: { thread_id } })
462
+ if (!cp?.checkpoint?.channel_values && this._graph.checkpointer.latestNamespace) {
463
+ const ns = await this._graph.checkpointer.latestNamespace(thread_id)
464
+ if (ns) {
465
+ cp = await this._graph.checkpointer.getTuple({
466
+ configurable: { thread_id, checkpoint_ns: ns },
467
+ })
468
+ }
469
+ }
470
+ const channelValues = cp?.checkpoint?.channel_values
471
+ if (channelValues) {
472
+ const interrupt = finalState?.__interrupt__
473
+ finalState = {
474
+ ...channelValues,
475
+ ...(interrupt !== undefined && { __interrupt__: interrupt }),
476
+ }
477
+ }
478
+ } catch {
479
+ /* best-effort */
480
+ }
481
+ }
482
+
483
+ return { state: finalState, tokenCount }
484
+ }
485
+ /**
486
+ * Parse configured timeout grace period.
487
+ */
488
+ _getGrace() {
489
+ return ms4(cds.env.agents?.pool?.timeoutGrace ?? "15s")
490
+ }
491
+
492
+ async _invokeWithTimeout(graph, input, config, signal) {
493
+ const maxExecution = ms4(cds.env.agents?.pool?.maxExecutionTimePerTask || "5min")
494
+ const grace = this._getGrace()
495
+ // Soft timeout fires early to allow graceful summarization
496
+ const softTimeout = Math.max(maxExecution - grace, 1000)
497
+
498
+ // Use explicit AbortController + setTimeout (reffed timer keeps event loop alive)
499
+ // instead of AbortSignal.timeout() which uses an unreffed timer
500
+ const timeoutController = new AbortController()
501
+ const timer = setTimeout(() => timeoutController.abort(), softTimeout)
502
+
503
+ // Combine caller-provided abort signal with timeout
504
+ const combinedSignal = signal
505
+ ? AbortSignal.any([signal, timeoutController.signal])
506
+ : timeoutController.signal
507
+ // Pass signal to LangGraph — it checks between node executions
508
+ config.signal = combinedSignal
509
+ try {
510
+ return await graph.invoke(input, config)
511
+ } catch (err) {
512
+ if (combinedSignal.aborted) {
513
+ // Caller abort takes priority (both can be true simultaneously in a race)
514
+ if (signal?.aborted) {
515
+ throw new AbortError("Task execution aborted")
516
+ }
517
+ throw new TimeoutError(`Graph execution timed out after ${softTimeout / 1000}s`, {
518
+ timeout: softTimeout,
519
+ })
520
+ }
521
+ throw err
522
+ } finally {
523
+ clearTimeout(timer)
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Summarize partial work after forced interruption (timeout, quota, etc.).
529
+ */
530
+ async _summarizePartialWork(taskId, contextId, serviceName, reason) {
531
+ const { summarizePartialWork } = await import("../../lib/agents/summarize-on-timeout.js")
532
+ const grace = this._getGrace()
533
+ return summarizePartialWork({
534
+ taskId,
535
+ contextId,
536
+ serviceName,
537
+ reason,
538
+ checkpointer: this._graph?.checkpointer,
539
+ getModel: () => this._srv.send("buildModel"),
540
+ timeout: Math.max(grace - 2000, grace * 0.8, 500),
541
+ })
542
+ }
543
+
544
+ async execute(requestContext, eventBus) {
545
+ const { taskId, contextId } = requestContext
546
+ const serviceName = this._srv.name
547
+ const isResume = requestContext.task?.status?.state === "input-required"
548
+ const mAttrs = metrics.attrs(serviceName)
549
+
550
+ // Cooperative cancellation: per-task AbortController
551
+ const controller = new AbortController()
552
+ this._abortControllers.set(taskId, controller)
553
+
554
+ // A2A context for tracing
555
+ if (!cds.context) {
556
+ throw Error(`Agent ${serviceName} must be called with cds.context in place!`)
557
+ }
558
+ cds.context["agent.task.id"] = taskId
559
+ cds.context["agent.context.id"] = contextId
560
+ cds.context["agent.service"] = serviceName
561
+ cds.context["agent.eventBus"] = eventBus
562
+
563
+ metrics.concurrentExecutions.add(1, mAttrs)
564
+
565
+ if (!isResume) {
566
+ if (cds.context?.["agent.new.task"]) {
567
+ await INSERT.into("cap.agent.Tasks").entries({
568
+ taskId,
569
+ contextId,
570
+ state: "submitted",
571
+ data: JSON.stringify({
572
+ id: taskId,
573
+ contextId,
574
+ kind: "task",
575
+ status: { state: "submitted", timestamp: new Date().toISOString() },
576
+ }),
577
+ agentService: serviceName,
578
+ })
579
+ delete cds.context["agent.new.task"]
580
+ }
581
+
582
+ eventBus.publish({
583
+ kind: "task",
584
+ id: taskId,
585
+ contextId,
586
+ status: { state: "submitted", timestamp: new Date().toISOString() },
587
+ })
588
+
589
+ // Audit: task started
590
+ audit("AgentTaskStarted", {
591
+ data: { taskId, contextId, service: serviceName, userMessage: requestContext.userMessage },
592
+ })
593
+ // Lazy scheduling task deletion
594
+ cds.spawn({}, async () => {
595
+ await triggerCleanup(serviceName)
596
+ })
597
+ }
598
+
599
+ // ── File I/O: persist incoming FileParts to cap.agent.Tasks.inputFiles ──
600
+ // Build a manifest string so the LLM sees /uploads/<name> paths, not raw bytes.
601
+ const fileStore = cds.env.agents?.fileIO?.enabled ? new CdsFileStore() : null
602
+ if (fileStore && !isResume) {
603
+ const fileParts = requestContext.userMessage?.parts?.filter((p) => p.kind === "file") || []
604
+ const manifestLines = await Promise.all(
605
+ fileParts.map(async (fp) => {
606
+ const file = fp.file || fp
607
+ if (file.bytes) {
608
+ try {
609
+ // Sanitize: strips path components and unsafe characters so the name
610
+ // is a stable DB key, /uploads/ path fragment, and artifactId.
611
+ const safeName = sanitizeFilename(file.name)
612
+ const safeMime = file.mimeType || "application/octet-stream"
613
+ // Pre-decode guard: reject oversized or disallowed-mime uploads
614
+ // before allocating a Buffer for the base64 payload.
615
+ const rejection = checkInputFile(
616
+ { ...file, mimeType: safeMime },
617
+ cds.env.agents?.fileIO,
618
+ )
619
+ if (rejection) {
620
+ LOG.warn("input file rejected", {
621
+ conversation: short(contextId),
622
+ service: serviceName,
623
+ name: safeName,
624
+ mimeType: safeMime,
625
+ reason: rejection,
626
+ })
627
+ return `/uploads/${safeName} (rejected: ${rejection})`
628
+ }
629
+ const buf = Buffer.from(file.bytes, "base64")
630
+ await fileStore.saveInputFile(taskId, safeName, safeMime, buf)
631
+ LOG.info("file uploaded", {
632
+ conversation: short(contextId),
633
+ service: serviceName,
634
+ name: safeName,
635
+ mimeType: safeMime,
636
+ size: buf.length,
637
+ })
638
+ return `/uploads/${safeName} (${safeMime}, ${formatFileSize(buf.length)})`
639
+ } catch (err) {
640
+ LOG.error("Failed to persist uploaded file", { name: file.name, error: err.message })
641
+ return `/uploads/${sanitizeFilename(file.name)} (persist failed: ${err.message})`
642
+ }
643
+ } else if (file.uri) {
644
+ return `${file.uri} (${file.mimeType || "unknown"}, URI reference)`
645
+ }
646
+ return null
647
+ }),
648
+ )
649
+ const validLines = manifestLines.filter(Boolean)
650
+ if (validLines.length) {
651
+ requestContext._fileManifest = `[Uploaded files: ${validLines.join(", ")}]`
652
+ }
653
+ }
654
+
655
+ eventBus.publish({
656
+ kind: "status-update",
657
+ taskId,
658
+ contextId,
659
+ status: { state: "working", timestamp: new Date().toISOString() },
660
+ final: false,
661
+ })
662
+
663
+ const tracer = metrics.getTracer()
664
+ const runWorkflow = async (wfSpan) => {
665
+ if (wfSpan) {
666
+ wfSpan.setAttribute("gen_ai.operation.name", "invoke_agent")
667
+ wfSpan.setAttribute("gen_ai.agent.name", serviceName)
668
+ wfSpan.setAttribute("agent.task.id", taskId)
669
+ wfSpan.setAttribute("agent.context.id", contextId)
670
+ wfSpan.setAttribute("agent.service", serviceName)
671
+ // MLflow: workflow span carries AGENT type + inputs for the
672
+ // span-detail view
673
+ wfSpan.setAttribute("mlflow.message.format", "langchain-js")
674
+ setSpanAttrs(
675
+ wfSpan,
676
+ mlflowAttrs("AGENT", {
677
+ inputs: { messages: buildChatMessages(requestContext) },
678
+ functionName: serviceName,
679
+ }),
680
+ )
681
+ const rootSpan = cds.context["_mlflow.rootSpan"]
682
+ rootSpan.setAttribute("agent.task.id", cds.context["agent.task.id"])
683
+ rootSpan.setAttribute("agent.context.id", cds.context["agent.context.id"])
684
+ rootSpan.setAttribute("agent.service", cds.context["agent.service"])
685
+ // MLflow: trace correlation on the OTel root span.
686
+ // - mlflow.spanInputs → Request column in the trace list
687
+ // - session.id / user.id / mlflow.traceTag.* (via mlflowTraceAttrs) → session
688
+ // and tag columns in the trace list.
689
+ const userText = extractText(requestContext)
690
+ setSpanAttrs(
691
+ rootSpan,
692
+ mlflowAttrs("CHAIN", {
693
+ // In case rootSpan is HTTP keep its name, if no name yet given fallback to service
694
+ functionName: rootSpan.name ?? serviceName,
695
+ inputs:
696
+ userText !== undefined
697
+ ? { messages: [{ role: "user", content: userText }] }
698
+ : undefined,
699
+ }),
700
+ )
701
+ setSpanAttrs(rootSpan, mlflowTraceAttrs())
702
+ }
703
+
704
+ let usageData
705
+ let result
706
+ try {
707
+ const graph = await this._resolveGraph()
708
+
709
+ const extraConfig = this._configMapper ? await this._configMapper(requestContext) : {}
710
+ if (extraConfig !== null && extraConfig !== undefined && typeof extraConfig !== "object") {
711
+ throw new TypeError(`configMapper must return a plain object, got ${typeof extraConfig}`)
712
+ }
713
+ const config = {
714
+ // Prefer limit at construction time, then cds.env then defaults (standard langchain -> 25, deepagent -> 10_000).
715
+ recursionLimit: this._recursionLimit || cds.env.agents.recursionLimit || undefined,
716
+ configurable: {
717
+ ...extraConfig,
718
+ thread_id: `${serviceName}:${contextId}`,
719
+ _taskId: taskId,
720
+ _service: serviceName,
721
+ // Captured at request entry — backends/tools running inside graph
722
+ // callbacks should prefer this over cds.context, which can drift to
723
+ // "anonymous" across AsyncLocalStorage boundaries.
724
+ _userId: cds.context?.user?.id,
725
+ },
726
+ }
727
+
728
+ const t0 = Date.now()
729
+
730
+ if (isResume) {
731
+ const dataPart = extractData(requestContext)
732
+ const userText = extractText(requestContext)
733
+ // Relaxed guard: accept a DataPart-only resume OR non-empty text.
734
+ if (dataPart === undefined && !userText.trim()) {
735
+ throw new Error(cds.i18n.messages.at("RESUME_REQUIRES_TEXT"))
736
+ }
737
+ const { Command } = await import("@langchain/langgraph")
738
+ // DataPart wins over text — a structured resume is self-describing and any
739
+ // accompanying text is treated as incidental (e.g. a human-readable echo).
740
+ const resume = dataPart !== undefined ? dataPart : parseResumeDecision(userText)
741
+ const decision = decisionTypeOf(resume)
742
+
743
+ LOG.debug("resuming", {
744
+ conversation: short(contextId),
745
+ service: serviceName,
746
+ decision,
747
+ })
748
+
749
+ // Audit: task resumed with HITL decision
750
+ audit("AgentTaskResumed", {
751
+ data: {
752
+ taskId,
753
+ contextId,
754
+ service: serviceName,
755
+ decision,
756
+ userMessage: requestContext.userMessage,
757
+ },
758
+ })
759
+ // On edit, stash a diff note in state; the injector middleware prepends it next turn.
760
+ const commandArgs = { resume }
761
+ if (decision === "edit") {
762
+ const originals = await getPreInterruptToolCalls(graph, config)
763
+ const editNote = composeEditNote(originals, resume)
764
+ if (editNote) commandArgs.update = { _hitlEditNote: editNote }
765
+ }
766
+ const resumed = await this._streamWithPublish(
767
+ graph,
768
+ new Command(commandArgs),
769
+ config,
770
+ eventBus,
771
+ taskId,
772
+ contextId,
773
+ controller.signal,
774
+ )
775
+ result = resumed.state
776
+ } else {
777
+ const inputMapper = this._inputMapper || defaultInputMapper
778
+ const rawInput = await inputMapper(requestContext)
779
+ const { _toolMapOverride, ...input } = rawInput
780
+ if (_toolMapOverride) config.configurable._toolMapOverride = _toolMapOverride
781
+ const streamed = await this._streamWithPublish(
782
+ graph,
783
+ input,
784
+ config,
785
+ eventBus,
786
+ taskId,
787
+ contextId,
788
+ controller.signal,
789
+ )
790
+ result = streamed.state
791
+ }
792
+ // Capture result for usage tracking in finally block
793
+ // (interrupt-only results may have no `messages` — treat as empty)
794
+ usageData = aggregateUsageData(result.messages || [])
795
+
796
+ if (result?.__interrupt__?.length > 0) {
797
+ const description = extractInterruptDescription(result)
798
+ const interruptData = extractInterruptData(result)
799
+
800
+ const duration = ((Date.now() - t0) / 1000).toFixed(1) + "s"
801
+ LOG.info("input-required", {
802
+ conversation: short(contextId),
803
+ service: serviceName,
804
+ duration,
805
+ })
806
+
807
+ if (wfSpan) {
808
+ wfSpan.setAttribute("agent.outcome", "input-required")
809
+ const outputs = {
810
+ choices: [{ message: { role: "assistant", content: description } }],
811
+ }
812
+ setSpanAttrs(wfSpan, mlflowAttrs("AGENT", { outputs }))
813
+ const rootSpan = cds.context?.["_mlflow.rootSpan"]
814
+ if (rootSpan) {
815
+ setSpanAttrs(rootSpan, mlflowAttrs("CHAIN", { outputs }))
816
+ }
817
+ }
818
+
819
+ // Audit: agent requires human input
820
+ audit("AgentInputRequired", {
821
+ data: {
822
+ taskId,
823
+ contextId,
824
+ service: serviceName,
825
+ description,
826
+ userMessage: requestContext.userMessage,
827
+ },
828
+ })
829
+
830
+ eventBus.publish({
831
+ kind: "status-update",
832
+ taskId,
833
+ contextId,
834
+ status: {
835
+ state: "input-required",
836
+ message: agentMessage(description, interruptData),
837
+ timestamp: new Date().toISOString(),
838
+ },
839
+ final: true,
840
+ })
841
+ eventBus.finished()
842
+ return
843
+ }
844
+
845
+ const duration = ((Date.now() - t0) / 1000).toFixed(1) + "s"
846
+ const outputMapper = this._outputMapper || defaultOutputMapper
847
+ const output = outputMapper(result) || "I could not generate a response."
848
+
849
+ LOG.info("completed", { conversation: short(contextId), service: serviceName, duration })
850
+
851
+ if (wfSpan) {
852
+ wfSpan.setAttribute("agent.outcome", "completed")
853
+ setSpanAttrs(
854
+ wfSpan,
855
+ mlflowAttrs("AGENT", {
856
+ outputs: { choices: [{ message: { role: "assistant", content: output } }] },
857
+ functionName: serviceName,
858
+ }),
859
+ )
860
+ }
861
+ const rootSpan = cds.context?.["_mlflow.rootSpan"]
862
+ if (rootSpan) {
863
+ setSpanAttrs(
864
+ rootSpan,
865
+ mlflowAttrs("CHAIN", {
866
+ outputs: { choices: [{ message: { role: "assistant", content: output } }] },
867
+ }),
868
+ )
869
+ }
870
+
871
+ metrics.workflowsCompleted.add(1, mAttrs)
872
+
873
+ // Audit: task completed
874
+ audit("AgentTaskCompleted", {
875
+ data: {
876
+ taskId,
877
+ contextId,
878
+ service: serviceName,
879
+ duration,
880
+ tokenUsage: usageData,
881
+ toolCalls: totalToolCalls(result.messages),
882
+ output: output?.slice(0, 2000),
883
+ task: requestContext.task,
884
+ },
885
+ })
886
+
887
+ // Final artifact: authoritative full response text. Emitted as an event-level
888
+ // replace (append:false) so it never doubles the incrementally-streamed tokens
889
+ // and always leaves Task.artifacts with the complete, correct text — for both
890
+ // the streaming path (supersedes accumulated deltas) and the blocking path
891
+ // (the sole emit). lastChunk:true signals the response artifact is complete.
892
+ eventBus.publish({
893
+ kind: "artifact-update",
894
+ taskId,
895
+ contextId,
896
+ append: false,
897
+ lastChunk: true,
898
+ artifact: {
899
+ artifactId: "response",
900
+ parts: [{ kind: "text", text: output }],
901
+ },
902
+ })
903
+
904
+ // ── File I/O: collect output files from cap.agent.Tasks.outputFiles ──────
905
+ // Covers two sources:
906
+ // 1. emit_file_part tool calls (default graph) — JSON in toolResults/messages
907
+ // 2. write_file '/outputs/*' via OutputsBackend (deep agent) — CDS rows
908
+ const fileArtifacts = []
909
+ const maxFileBytes = cds.env.agents.fileIO.maxOutputFileSizeBytes
910
+
911
+ // Artifacts from emit_file_part are this agent's own outputs — they must be
912
+ // published as A2A FileParts but must NOT be re-persisted as inputFiles
913
+ // (that would make them reappear as /uploads/ entries next turn).
914
+ // ToolMessage.name is not set by the tool node, so derive the tool name
915
+ // by matching tool_call_id against AIMessage.tool_calls[].
916
+ const allMessages = result.messages || []
917
+ const emitFilePartCallIds = new Set()
918
+ for (const msg of allMessages) {
919
+ if (msg.tool_calls?.length > 0) {
920
+ for (const tc of msg.tool_calls) {
921
+ if (tc.name === "emit_file_part") emitFilePartCallIds.add(tc.id)
922
+ }
923
+ }
924
+ }
925
+ for (const msg of allMessages) {
926
+ const isFromEmitFilePart = !!(
927
+ msg.tool_call_id && emitFilePartCallIds.has(msg.tool_call_id)
928
+ )
929
+ const content = typeof msg.content === "string" ? msg.content : ""
930
+ let pos = 0
931
+ while (pos < content.length) {
932
+ const start = content.indexOf('{"kind":"file"', pos)
933
+ if (start === -1) break
934
+ // Walk forward tracking depth and quoted strings so that '}' inside
935
+ // a string value (e.g. a filename like "result_{final}.csv") does not
936
+ // prematurely close the object.
937
+ let depth = 0
938
+ let inString = false
939
+ let i = start
940
+ while (i < content.length) {
941
+ const ch = content[i]
942
+ if (inString) {
943
+ if (ch === "\\") {
944
+ i += 2 // skip escaped character — cannot be a structural char
945
+ continue
946
+ }
947
+ if (ch === '"') inString = false
948
+ } else {
949
+ if (ch === '"') inString = true
950
+ else if (ch === "{") depth++
951
+ else if (ch === "}") {
952
+ depth--
953
+ if (depth === 0) break
954
+ }
955
+ }
956
+ i++
957
+ }
958
+ const raw = content.slice(start, i + 1)
959
+ try {
960
+ const artifact = JSON.parse(raw)
961
+ // Apply the same per-file size cap as Source 2. Decode-length is
962
+ // computed by Buffer.byteLength (zero allocation — pure formula
963
+ // over string length + padding) so an oversized blob never pins
964
+ // memory just to be discarded.
965
+ const declaredBytes =
966
+ typeof artifact.file?.bytes === "string"
967
+ ? Buffer.byteLength(artifact.file.bytes, "base64")
968
+ : 0
969
+ if (declaredBytes > maxFileBytes) {
970
+ LOG.warn("emit_file_part artifact exceeds cap; skipping", {
971
+ conversation: short(contextId),
972
+ service: serviceName,
973
+ name: artifact.file?.name,
974
+ size: declaredBytes,
975
+ cap: maxFileBytes,
976
+ })
977
+ pos = i + 1
978
+ continue
979
+ }
980
+ // Tag emit_file_part artifacts so the re-persist step can exclude them.
981
+ // The tag is stripped before publishing so clients never see it.
982
+ if (isFromEmitFilePart) artifact._fromEmitFilePart = true
983
+ fileArtifacts.push(artifact)
984
+ } catch {
985
+ /* not valid JSON — skip */
986
+ }
987
+ pos = i + 1
988
+ }
989
+ }
990
+
991
+ // Source 2: output files written by deep agent via /outputs/ path.
992
+ if (fileStore) {
993
+ const source1Count = fileArtifacts.length
994
+ const outputMeta = await fileStore.listOutputFilesMeta(taskId)
995
+ for (const meta of outputMeta) {
996
+ if (meta.size > maxFileBytes) {
997
+ LOG.warn("output file exceeds cap; skipping", {
998
+ conversation: short(contextId),
999
+ service: serviceName,
1000
+ name: meta.name,
1001
+ size: meta.size,
1002
+ cap: maxFileBytes,
1003
+ })
1004
+ continue
1005
+ }
1006
+ // eslint-disable-next-line no-await-in-loop
1007
+ const f = await fileStore.getOutputFile(taskId, meta.name)
1008
+ if (!f) continue
1009
+ fileArtifacts.push({
1010
+ kind: "file",
1011
+ file: { name: f.name, mimeType: f.mimeType, bytes: f.bytes.toString("base64") },
1012
+ })
1013
+ }
1014
+ // Re-persist inline file artifacts from downstream agents to Tasks.inputFiles.
1015
+ // Exclude emit_file_part outputs — those are this agent's own artifacts, not
1016
+ // downstream files, and re-persisting them would create spurious /uploads/ entries.
1017
+ // Enforce the same size + MIME guard as inbound uploads so a malicious
1018
+ // sub-agent cannot bypass the cap by echoing an oversized FilePart.
1019
+ await Promise.all(
1020
+ fileArtifacts
1021
+ .slice(0, source1Count)
1022
+ .filter((fa) => {
1023
+ if (!fa.file?.bytes || !fa.file?.name || fa._fromEmitFilePart) return false
1024
+ const rejection = checkInputFile(fa.file, cds.env.agents?.fileIO)
1025
+ if (rejection) {
1026
+ LOG.warn("downstream file re-persist rejected", {
1027
+ conversation: short(contextId),
1028
+ service: serviceName,
1029
+ name: fa.file.name,
1030
+ reason: rejection,
1031
+ })
1032
+ return false
1033
+ }
1034
+ return true
1035
+ })
1036
+ .map((fa) => {
1037
+ const buf = Buffer.from(fa.file.bytes, "base64")
1038
+ const safeName = sanitizeFilename(fa.file.name)
1039
+ return fileStore.saveInputFile(taskId, safeName, fa.file.mimeType, buf)
1040
+ }),
1041
+ )
1042
+ }
1043
+
1044
+ // Strip internal tag before publishing — clients must not see _fromEmitFilePart.
1045
+ // Capture source classification for the log line below.
1046
+ for (const filePart of fileArtifacts) {
1047
+ if (!filePart.file?.name) {
1048
+ LOG.warn("skipping malformed file artifact", {
1049
+ conversation: short(contextId),
1050
+ service: serviceName,
1051
+ })
1052
+ continue
1053
+ }
1054
+ const source = filePart._fromEmitFilePart ? "emit_file_part" : "outputs/"
1055
+ delete filePart._fromEmitFilePart
1056
+ const safeName = sanitizeFilename(filePart.file.name)
1057
+ const decodedSize =
1058
+ typeof filePart.file?.bytes === "string"
1059
+ ? Buffer.byteLength(filePart.file.bytes, "base64")
1060
+ : 0
1061
+ LOG.info("file emitted", {
1062
+ conversation: short(contextId),
1063
+ service: serviceName,
1064
+ name: safeName,
1065
+ mimeType: filePart.file?.mimeType,
1066
+ bytes: decodedSize,
1067
+ source,
1068
+ })
1069
+ eventBus.publish({
1070
+ kind: "artifact-update",
1071
+ taskId,
1072
+ contextId,
1073
+ artifact: {
1074
+ artifactId: `file-${safeName}`,
1075
+ name: safeName,
1076
+ parts: [filePart],
1077
+ },
1078
+ })
1079
+ }
1080
+
1081
+ eventBus.publish({
1082
+ kind: "status-update",
1083
+ taskId,
1084
+ contextId,
1085
+ status: {
1086
+ state: "completed",
1087
+ message: agentMessage(output),
1088
+ timestamp: new Date().toISOString(),
1089
+ },
1090
+ final: true,
1091
+ })
1092
+ } catch (err) {
1093
+ // Aborted (client disconnect or tasks/cancel) — publish canceled, not failed
1094
+ // Use name check (not instanceof) to also catch native DOMException AbortError from LangGraph
1095
+ if (err.name === "AbortError") {
1096
+ LOG.info("canceled", { conversation: short(contextId), service: serviceName })
1097
+ if (wfSpan) wfSpan.setAttribute("agent.outcome", "canceled")
1098
+
1099
+ audit("AgentTaskCanceled", {
1100
+ data: { taskId, contextId, service: serviceName },
1101
+ })
1102
+
1103
+ eventBus.publish({
1104
+ kind: "status-update",
1105
+ taskId,
1106
+ contextId,
1107
+ status: {
1108
+ state: "canceled",
1109
+ message: agentMessage("Task canceled."),
1110
+ timestamp: new Date().toISOString(),
1111
+ },
1112
+ final: true,
1113
+ })
1114
+ return
1115
+ }
1116
+
1117
+ // Timeout — attempt graceful summary of work-in-progress before reporting
1118
+ if (err.name === "TimeoutError") {
1119
+ LOG.warn("timeout", { conversation: short(contextId), service: serviceName })
1120
+
1121
+ if (wfSpan) wfSpan.setAttribute("agent.outcome", "timeout")
1122
+ metrics.errorsTotal.add(1, { ...mAttrs, "agent.error.code": "timeout" })
1123
+
1124
+ const summary = await this._summarizePartialWork(
1125
+ taskId,
1126
+ contextId,
1127
+ serviceName,
1128
+ "timed out",
1129
+ )
1130
+
1131
+ audit("AgentTaskFailed", {
1132
+ data: {
1133
+ taskId,
1134
+ contextId,
1135
+ service: serviceName,
1136
+ error: err.message,
1137
+ errorCode: "timeout",
1138
+ task: requestContext.task,
1139
+ },
1140
+ })
1141
+
1142
+ eventBus.publish({
1143
+ kind: "status-update",
1144
+ taskId,
1145
+ contextId,
1146
+ status: {
1147
+ state: "canceled",
1148
+ message: agentMessage(summary),
1149
+ timestamp: new Date().toISOString(),
1150
+ },
1151
+ final: true,
1152
+ })
1153
+ return
1154
+ }
1155
+
1156
+ // Quota exceeded — summarize partial work instead of raw error
1157
+ if (err.quotaExceeded) {
1158
+ LOG.warn("quota exceeded", {
1159
+ conversation: short(contextId),
1160
+ service: serviceName,
1161
+ error: err.message,
1162
+ })
1163
+
1164
+ if (wfSpan) wfSpan.setAttribute("agent.outcome", "quota_exceeded")
1165
+ metrics.errorsTotal.add(1, { ...mAttrs, "agent.error.code": "quota_exceeded" })
1166
+
1167
+ const summary = await this._summarizePartialWork(
1168
+ taskId,
1169
+ contextId,
1170
+ serviceName,
1171
+ "quota exceeded",
1172
+ )
1173
+
1174
+ audit("AgentTaskFailed", {
1175
+ data: {
1176
+ taskId,
1177
+ contextId,
1178
+ service: serviceName,
1179
+ error: err.message,
1180
+ errorCode: "quota_exceeded",
1181
+ task: requestContext.task,
1182
+ },
1183
+ })
1184
+
1185
+ eventBus.publish({
1186
+ kind: "status-update",
1187
+ taskId,
1188
+ contextId,
1189
+ status: {
1190
+ state: "canceled",
1191
+ message: agentMessage(summary),
1192
+ timestamp: new Date().toISOString(),
1193
+ },
1194
+ final: true,
1195
+ })
1196
+ return
1197
+ }
1198
+
1199
+ LOG.error("failed", {
1200
+ conversation: short(contextId),
1201
+ service: serviceName,
1202
+ error: err.message,
1203
+ })
1204
+ LOG.debug("failed stack", {
1205
+ conversation: short(contextId),
1206
+ service: serviceName,
1207
+ stack: err.stack,
1208
+ })
1209
+
1210
+ if (wfSpan) {
1211
+ wfSpan.setAttribute("agent.outcome", "failed")
1212
+ wfSpan.setStatus({ code: 2, message: err.message })
1213
+ }
1214
+
1215
+ const errorCode = err.message?.includes("timed out") ? "timeout" : "execution_failed"
1216
+ metrics.errorsTotal.add(1, { ...mAttrs, "agent.error.code": errorCode })
1217
+
1218
+ // Audit: task failed
1219
+ audit("AgentTaskFailed", {
1220
+ data: {
1221
+ taskId,
1222
+ contextId,
1223
+ service: serviceName,
1224
+ error: err.message,
1225
+ errorCode,
1226
+ task: requestContext.task,
1227
+ },
1228
+ })
1229
+ // In production, don't reveal internal error details to clients (CDS pattern)
1230
+ const PROD = process.env.NODE_ENV === "production" || process.env.CDS_ENV === "prod"
1231
+ const errorMsg =
1232
+ PROD && err.$sanitize !== false
1233
+ ? cds.i18n.messages.at(500) || "Internal Server Error"
1234
+ : `Agent error: ${err.message}`
1235
+
1236
+ eventBus.publish({
1237
+ kind: "status-update",
1238
+ taskId,
1239
+ contextId,
1240
+ status: {
1241
+ state: "failed",
1242
+ message: agentMessage(errorMsg),
1243
+ timestamp: new Date().toISOString(),
1244
+ },
1245
+ final: true,
1246
+ })
1247
+ } finally {
1248
+ this._abortControllers.delete(taskId)
1249
+ metrics.concurrentExecutions.add(-1, mAttrs)
1250
+
1251
+ // Update task record with usage data (non-blocking, best effort)
1252
+ // When result is undefined (quota exceeded, timeout, abort), recover
1253
+ // messages from the checkpoint for usage tracking.
1254
+ const graph = this._graph
1255
+ cds.spawn(async () => {
1256
+ try {
1257
+ let messages = result?.messages
1258
+ if (!messages && graph?.checkpointer) {
1259
+ try {
1260
+ const thread_id = `${serviceName}:${contextId}`
1261
+ let cp = await graph.checkpointer.getTuple({ configurable: { thread_id } })
1262
+ if (!cp?.checkpoint?.channel_values && graph.checkpointer.latestNamespace) {
1263
+ const ns = await graph.checkpointer.latestNamespace(thread_id)
1264
+ if (ns) {
1265
+ cp = await graph.checkpointer.getTuple({
1266
+ configurable: { thread_id, checkpoint_ns: ns },
1267
+ })
1268
+ }
1269
+ }
1270
+ messages = cp?.checkpoint?.channel_values?.messages
1271
+ } catch {
1272
+ /* best-effort */
1273
+ }
1274
+ }
1275
+ const updates = { agentService: serviceName }
1276
+ if (usageData?.total_tokens != null) {
1277
+ updates.usageLlmTokens = usageData.total_tokens
1278
+ } else if (messages) {
1279
+ const recovered = aggregateUsageData(messages)
1280
+ if (recovered?.total_tokens) updates.usageLlmTokens = recovered.total_tokens
1281
+ }
1282
+ if (messages) updates.usageToolCalls = totalToolCalls(messages)
1283
+ await UPDATE("cap.agent.Tasks").where({ taskId }).with(updates)
1284
+ } catch (err) {
1285
+ LOG.debug("usage update failed", { conversation: short(contextId), error: err.message })
1286
+ }
1287
+ })
1288
+
1289
+ eventBus.finished()
1290
+ }
1291
+ }
1292
+
1293
+ if (tracer) {
1294
+ await tracer.startActiveSpan(`workflow CompiledStateGraph ${serviceName}`, async (wfSpan) => {
1295
+ if (!cds.context["_mlflow.rootSpan"]) {
1296
+ cds.context["_mlflow.rootSpan"] = wfSpan
1297
+ }
1298
+ try {
1299
+ await runWorkflow(wfSpan)
1300
+ } finally {
1301
+ wfSpan.end()
1302
+ }
1303
+ })
1304
+ } else {
1305
+ await runWorkflow(null)
1306
+ }
1307
+ }
1308
+
1309
+ async cancelTask(taskId, eventBus) {
1310
+ const wasRunning = this._abortControllers.has(taskId)
1311
+ this.abort(taskId)
1312
+
1313
+ if (!wasRunning) {
1314
+ audit("AgentTaskCanceled", {
1315
+ data: { taskId, service: this._srv.name },
1316
+ })
1317
+
1318
+ eventBus.publish({
1319
+ kind: "status-update",
1320
+ taskId,
1321
+ status: {
1322
+ state: "canceled",
1323
+ message: agentMessage("Task canceled."),
1324
+ timestamp: new Date().toISOString(),
1325
+ },
1326
+ final: true,
1327
+ })
1328
+ eventBus.finished()
1329
+ }
1330
+ }
1331
+ }
1332
+
1333
+ export {
1334
+ GraphExecutor,
1335
+ messageText,
1336
+ defaultOutputMapper,
1337
+ agentMessage,
1338
+ parseResumeDecision,
1339
+ decisionTypeOf,
1340
+ extractInterruptData,
1341
+ composeEditNote,
1342
+ }
1343
+
1344
+ /**
1345
+ * @param {[import('@langchain/core/messages').Message]} messages
1346
+ */
1347
+ function aggregateUsageData(messages) {
1348
+ const result = {
1349
+ input_tokens: 0,
1350
+ output_tokens: 0,
1351
+ total_tokens: 0,
1352
+ cache_creation_input_tokens: 0,
1353
+ cache_read_input_tokens: 0,
1354
+ reasoning_tokens: 0,
1355
+ }
1356
+ for (let i = 0; i < messages.length; i++) {
1357
+ if (!messages[i].usage_metadata) continue
1358
+ const innerRes = convertUsageData(messages[i].usage_metadata)
1359
+ Object.keys(innerRes).forEach((k) => {
1360
+ if (innerRes[k] != null) result[k] += innerRes[k]
1361
+ })
1362
+ }
1363
+ return result
1364
+ }
1365
+
1366
+ /**
1367
+ * @param {[import('@langchain/core/messages').Message]} messages
1368
+ */
1369
+ function totalToolCalls(messages) {
1370
+ return messages.reduce((acc, val) => {
1371
+ if (val.type === "tool") acc++
1372
+ return acc
1373
+ }, 0)
1374
+ }