@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.
- package/LICENSE +201 -0
- package/README.md +154 -0
- package/_i18n/messages.properties +31 -0
- package/cds-plugin.js +140 -0
- package/index.cds +116 -0
- package/index.js +0 -0
- package/lib/agents/markdown/backends/mime-utils.js +37 -0
- package/lib/agents/markdown/backends/outputs-backend.js +156 -0
- package/lib/agents/markdown/backends/readonly-backend.js +48 -0
- package/lib/agents/markdown/backends/uploads-backend.js +160 -0
- package/lib/agents/markdown/deep-agent.js +82 -0
- package/lib/agents/middleware/agent-actions.js +18 -0
- package/lib/agents/middleware/content-filter.js +191 -0
- package/lib/agents/middleware/hitl-edit-note-injector.js +18 -0
- package/lib/agents/middleware/hitl.js +20 -0
- package/lib/agents/middleware/index.js +19 -0
- package/lib/agents/middleware/patch-tool-calls.js +51 -0
- package/lib/agents/middleware/quota-enforcer.js +94 -0
- package/lib/agents/middleware/status-update.js +153 -0
- package/lib/agents/middleware/tool-selection.js +22 -0
- package/lib/agents/quota-enforcer-at-start.js +198 -0
- package/lib/agents/summarize-on-timeout.js +91 -0
- package/lib/compile.js +55 -0
- package/lib/index.cjs +1 -0
- package/lib/index.js +422 -0
- package/lib/models/aicore.js +441 -0
- package/lib/models/anthropic.js +77 -0
- package/lib/models/mock.js +88 -0
- package/lib/preview/chat.html +897 -0
- package/lib/preview/preview.js +46 -0
- package/lib/protocol/agent-card.js +297 -0
- package/lib/protocol/persistence/checkpoint-saver.js +320 -0
- package/lib/protocol/persistence/cleanup.js +84 -0
- package/lib/protocol/persistence/file-store.js +209 -0
- package/lib/protocol/persistence/push-notification-store.js +59 -0
- package/lib/protocol/persistence/task-store.js +47 -0
- package/lib/protocol/push-notification-sender.js +57 -0
- package/lib/sidecar.js +162 -0
- package/lib/telemetry/active-users.js +106 -0
- package/lib/telemetry/chat-tracing.js +364 -0
- package/lib/telemetry/metrics.js +85 -0
- package/lib/telemetry/mlflow.js +290 -0
- package/lib/telemetry/tool-tracing.js +164 -0
- package/lib/telemetry/tracing.js +150 -0
- package/lib/utils/inner-auth.js +33 -0
- package/lib/utils/markdown.js +199 -0
- package/lib/utils/message-handling.js +155 -0
- package/lib/utils/utils.js +168 -0
- package/package.json +225 -2
- package/srv/graph-cache.js +82 -0
- package/srv/handlers/graph-executor.js +1374 -0
- package/srv/handlers/index.js +178 -0
- package/srv/handlers/mcp-tools.js +161 -0
- package/srv/handlers/sub-agent-tools.js +316 -0
- package/srv/handlers/system-prompt.js +25 -0
- package/srv/handlers/tools.js +366 -0
- package/srv/langgraph-executor-srv.js +70 -0
- 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
|
+
}
|