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