@cap-js/agents 0.0.0 → 0.9.1

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