@cap-js/agents 0.9.2 → 0.9.4
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/README.md +2 -0
- package/cds-plugin.js +2 -2
- package/lib/agents/middleware/content-filter.js +3 -2
- 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 +11 -0
- package/lib/agents/middleware/quota-enforcer.js +9 -0
- package/lib/agents/middleware/remote-mcp.js +62 -0
- package/lib/agents/middleware/tool-wrap.js +52 -0
- package/lib/compile.js +6 -2
- package/lib/eval/Judge.js +239 -0
- package/lib/eval/eval-describe.js +69 -0
- package/lib/eval/eval-run.js +147 -0
- package/lib/eval/index.js +6 -0
- package/lib/eval/metrics.js +53 -0
- package/lib/eval/span-collector.js +50 -0
- package/lib/index.js +2 -1
- package/lib/models/aicore.js +23 -6
- package/lib/models/anthropic.js +27 -12
- package/lib/preview/chat.html +373 -66
- package/lib/protocol/agent-card.js +6 -2
- package/lib/sidecar.js +1 -1
- package/lib/telemetry/chat-tracing.js +39 -8
- package/lib/telemetry/mlflow/credentials.js +79 -0
- package/lib/telemetry/mlflow/evaluation.js +38 -0
- package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +75 -0
- package/lib/telemetry/mlflow/exporter/MlflowExporter.js +115 -0
- package/lib/telemetry/mlflow/exporter/index.js +23 -0
- package/lib/telemetry/mlflow/index.js +16 -0
- package/lib/telemetry/mlflow/prompts.js +123 -0
- package/lib/telemetry/mlflow/tracing.js +264 -0
- package/lib/telemetry/tool-tracing.js +27 -54
- package/lib/telemetry/tracing.js +3 -1
- package/lib/utils/markdown.js +1 -11
- package/lib/utils/message-handling.js +14 -0
- package/lib/utils/resilience.js +133 -0
- package/lib/utils/utils.js +17 -0
- package/package.json +26 -14
- package/srv/handlers/chat.js +278 -0
- package/srv/handlers/graph-executor/hitl.js +263 -0
- package/srv/handlers/graph-executor.js +100 -286
- package/srv/handlers/index.js +5 -1
- package/srv/handlers/mcp-tools.js +7 -45
- package/srv/handlers/sub-agent-tools.js +30 -12
- package/srv/handlers/tools.js +39 -5
- package/index.js +0 -0
- package/lib/agents/middleware/hitl-edit-note-injector.js +0 -18
- package/lib/telemetry/mlflow.js +0 -290
- /package/{index.cds → srv/entities.cds} +0 -0
|
@@ -1,12 +1,15 @@
|
|
|
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
|
-
import { mlflowAttrs, mlflowTraceAttrs, setSpanAttrs } from "../../lib/telemetry/mlflow.js"
|
|
5
|
+
import { mlflowAttrs, mlflowTraceAttrs, setSpanAttrs } from "../../lib/telemetry/mlflow/index.js"
|
|
6
6
|
import { CdsFileStore } from "../../lib/protocol/persistence/file-store.js"
|
|
7
7
|
import { formatFileSize, sanitizeFilename } from "./tools.js"
|
|
8
8
|
import { convertUsageData } from "../../lib/telemetry/chat-tracing.js"
|
|
9
9
|
import { triggerCleanup } from "../../lib/protocol/persistence/cleanup.js"
|
|
10
|
+
import { COLLECT_RESULT } from "./chat.js"
|
|
11
|
+
import { linkTraceToPrompt } from "../../lib/telemetry/mlflow/tracing.js"
|
|
12
|
+
import { handleHitlInterrupt, requiresHitl, resumeHitl } from "./graph-executor/hitl.js"
|
|
10
13
|
|
|
11
14
|
const LOG = cds.log("agents")
|
|
12
15
|
|
|
@@ -102,18 +105,6 @@ function defaultOutputMapper(result) {
|
|
|
102
105
|
return JSON.stringify(result)
|
|
103
106
|
}
|
|
104
107
|
|
|
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
108
|
/**
|
|
118
109
|
* Extract user text from A2A message parts.
|
|
119
110
|
*/
|
|
@@ -121,147 +112,6 @@ function extractText(requestContext) {
|
|
|
121
112
|
return partsToText(requestContext.userMessage?.parts)
|
|
122
113
|
}
|
|
123
114
|
|
|
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
115
|
/**
|
|
266
116
|
* GraphExecutor wraps a compiled LangGraph graph as an A2A AgentExecutor.
|
|
267
117
|
*
|
|
@@ -334,16 +184,14 @@ class GraphExecutor {
|
|
|
334
184
|
|
|
335
185
|
let tokenCount = 0
|
|
336
186
|
let finalState = null
|
|
337
|
-
// Track the current turn (langchain message id)
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
// event-level replace with empty text to wipe the leaked preamble, then skip
|
|
344
|
-
// all further text from this turn.
|
|
187
|
+
// Track the current turn (langchain message id). Each new turn opens a fresh
|
|
188
|
+
// bubble on the client via `append:false`; subsequent tokens of the same turn
|
|
189
|
+
// are `append:true` (accumulate). Anthropic-style turns can stream a text
|
|
190
|
+
// preamble BEFORE their tool_use block ("Let me first look up …") — we let
|
|
191
|
+
// that reasoning text stream normally; the client is responsible for the
|
|
192
|
+
// final visual (collapse to the last turn's bubble at task completion).
|
|
345
193
|
let currentMsgId = null
|
|
346
|
-
let
|
|
194
|
+
let thinkingCount = 0
|
|
347
195
|
|
|
348
196
|
try {
|
|
349
197
|
if (typeof graph.stream !== "function" || cds.env.agents?.streaming === false) {
|
|
@@ -379,30 +227,11 @@ class GraphExecutor {
|
|
|
379
227
|
|
|
380
228
|
if (msgChunk.id && msgChunk.id !== currentMsgId) {
|
|
381
229
|
currentMsgId = msgChunk.id
|
|
382
|
-
turnHasToolCall = false
|
|
383
230
|
tokenCount = 0
|
|
384
231
|
}
|
|
385
232
|
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
233
|
+
const lastChunk =
|
|
234
|
+
!!msgChunk.additional_kwargs?.intermediate_results?.llm?.choices[0].finish_reason
|
|
406
235
|
|
|
407
236
|
const text = messageText(msgChunk?.content)
|
|
408
237
|
if (!text) continue
|
|
@@ -415,12 +244,13 @@ class GraphExecutor {
|
|
|
415
244
|
taskId,
|
|
416
245
|
contextId,
|
|
417
246
|
append: tokenCount > 0,
|
|
418
|
-
lastChunk:
|
|
247
|
+
lastChunk: lastChunk,
|
|
419
248
|
artifact: {
|
|
420
|
-
artifactId:
|
|
249
|
+
artifactId: `thinking-${thinkingCount}`,
|
|
421
250
|
parts: [{ kind: "text", text }],
|
|
422
251
|
},
|
|
423
252
|
})
|
|
253
|
+
if (lastChunk) thinkingCount++
|
|
424
254
|
tokenCount++
|
|
425
255
|
} else if (mode === "updates") {
|
|
426
256
|
// The updates stream yields per-node deltas — { <node>: { messages: [oneNewMessage] } },
|
|
@@ -699,6 +529,12 @@ class GraphExecutor {
|
|
|
699
529
|
}),
|
|
700
530
|
)
|
|
701
531
|
setSpanAttrs(rootSpan, mlflowTraceAttrs())
|
|
532
|
+
// Required for MLFLow run linking
|
|
533
|
+
const evalRunId = cds.context?.["_mlflow.evalRunId"]
|
|
534
|
+
if (evalRunId) {
|
|
535
|
+
rootSpan.setAttribute("mlflow.sourceRun", evalRunId)
|
|
536
|
+
wfSpan.setAttribute("mlflow.sourceRun", evalRunId)
|
|
537
|
+
}
|
|
702
538
|
}
|
|
703
539
|
|
|
704
540
|
let usageData
|
|
@@ -728,51 +564,16 @@ class GraphExecutor {
|
|
|
728
564
|
const t0 = Date.now()
|
|
729
565
|
|
|
730
566
|
if (isResume) {
|
|
731
|
-
|
|
732
|
-
|
|
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(
|
|
567
|
+
result = await resumeHitl({
|
|
568
|
+
requestContext,
|
|
767
569
|
graph,
|
|
768
|
-
new Command(commandArgs),
|
|
769
570
|
config,
|
|
770
571
|
eventBus,
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
)
|
|
775
|
-
result
|
|
572
|
+
signal: controller.signal,
|
|
573
|
+
stream: (input, signal) =>
|
|
574
|
+
this._streamWithPublish(graph, input, config, eventBus, taskId, contextId, signal),
|
|
575
|
+
})
|
|
576
|
+
if (!result) return
|
|
776
577
|
} else {
|
|
777
578
|
const inputMapper = this._inputMapper || defaultInputMapper
|
|
778
579
|
const rawInput = await inputMapper(requestContext)
|
|
@@ -793,56 +594,28 @@ class GraphExecutor {
|
|
|
793
594
|
// (interrupt-only results may have no `messages` — treat as empty)
|
|
794
595
|
usageData = aggregateUsageData(result.messages || [])
|
|
795
596
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
service: serviceName,
|
|
597
|
+
const duration = ((Date.now() - t0) / 1000).toFixed(1) + "s"
|
|
598
|
+
if (requiresHitl(result)) {
|
|
599
|
+
handleHitlInterrupt({
|
|
600
|
+
result,
|
|
601
|
+
requestContext,
|
|
602
|
+
eventBus,
|
|
603
|
+
serviceName,
|
|
804
604
|
duration,
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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(),
|
|
605
|
+
onInputRequired: (description) => {
|
|
606
|
+
if (!wfSpan) return
|
|
607
|
+
wfSpan.setAttribute("agent.outcome", "input-required")
|
|
608
|
+
const outputs = {
|
|
609
|
+
choices: [{ message: { role: "assistant", content: description } }],
|
|
610
|
+
}
|
|
611
|
+
setSpanAttrs(wfSpan, mlflowAttrs("AGENT", { outputs }))
|
|
612
|
+
const rootSpan = cds.context?.["_mlflow.rootSpan"]
|
|
613
|
+
if (rootSpan) setSpanAttrs(rootSpan, mlflowAttrs("CHAIN", { outputs }))
|
|
838
614
|
},
|
|
839
|
-
final: true,
|
|
840
615
|
})
|
|
841
|
-
eventBus.finished()
|
|
842
616
|
return
|
|
843
617
|
}
|
|
844
618
|
|
|
845
|
-
const duration = ((Date.now() - t0) / 1000).toFixed(1) + "s"
|
|
846
619
|
const outputMapper = this._outputMapper || defaultOutputMapper
|
|
847
620
|
const output = outputMapper(result) || "I could not generate a response."
|
|
848
621
|
|
|
@@ -878,8 +651,7 @@ class GraphExecutor {
|
|
|
878
651
|
service: serviceName,
|
|
879
652
|
duration,
|
|
880
653
|
tokenUsage: usageData,
|
|
881
|
-
toolCalls:
|
|
882
|
-
output: output?.slice(0, 2000),
|
|
654
|
+
toolCalls: toolCallsShortened(result.messages),
|
|
883
655
|
task: requestContext.task,
|
|
884
656
|
},
|
|
885
657
|
})
|
|
@@ -906,6 +678,9 @@ class GraphExecutor {
|
|
|
906
678
|
// 1. emit_file_part tool calls (default graph) — JSON in toolResults/messages
|
|
907
679
|
// 2. write_file '/outputs/*' via OutputsBackend (deep agent) — CDS rows
|
|
908
680
|
const fileArtifacts = []
|
|
681
|
+
// DataParts embedded in tool-result content.
|
|
682
|
+
// Published as their own `data-*` artifact-update events below.
|
|
683
|
+
const dataArtifacts = []
|
|
909
684
|
const maxFileBytes = cds.env.agents.fileIO.maxOutputFileSizeBytes
|
|
910
685
|
|
|
911
686
|
// Artifacts from emit_file_part are this agent's own outputs — they must be
|
|
@@ -929,7 +704,10 @@ class GraphExecutor {
|
|
|
929
704
|
const content = typeof msg.content === "string" ? msg.content : ""
|
|
930
705
|
let pos = 0
|
|
931
706
|
while (pos < content.length) {
|
|
932
|
-
|
|
707
|
+
// Find the earliest next FilePart or DataPart marker.
|
|
708
|
+
const fileAt = content.indexOf('{"kind":"file"', pos)
|
|
709
|
+
const dataAt = content.indexOf('{"kind":"data"', pos)
|
|
710
|
+
const start = fileAt === -1 ? dataAt : dataAt === -1 ? fileAt : Math.min(fileAt, dataAt)
|
|
933
711
|
if (start === -1) break
|
|
934
712
|
// Walk forward tracking depth and quoted strings so that '}' inside
|
|
935
713
|
// a string value (e.g. a filename like "result_{final}.csv") does not
|
|
@@ -958,6 +736,11 @@ class GraphExecutor {
|
|
|
958
736
|
const raw = content.slice(start, i + 1)
|
|
959
737
|
try {
|
|
960
738
|
const artifact = JSON.parse(raw)
|
|
739
|
+
if (artifact.kind === "data") {
|
|
740
|
+
dataArtifacts.push(artifact)
|
|
741
|
+
pos = i + 1
|
|
742
|
+
continue
|
|
743
|
+
}
|
|
961
744
|
// Apply the same per-file size cap as Source 2. Decode-length is
|
|
962
745
|
// computed by Buffer.byteLength (zero allocation — pure formula
|
|
963
746
|
// over string length + padding) so an oversized blob never pins
|
|
@@ -1078,6 +861,32 @@ class GraphExecutor {
|
|
|
1078
861
|
})
|
|
1079
862
|
}
|
|
1080
863
|
|
|
864
|
+
dataArtifacts.forEach((artifact, i) => {
|
|
865
|
+
if (artifact.data == null || typeof artifact.data !== "object") {
|
|
866
|
+
LOG.warn("skipping malformed data artifact", {
|
|
867
|
+
conversation: short(contextId),
|
|
868
|
+
service: serviceName,
|
|
869
|
+
})
|
|
870
|
+
return
|
|
871
|
+
}
|
|
872
|
+
LOG.info("data emitted", { conversation: short(contextId), service: serviceName })
|
|
873
|
+
eventBus.publish({
|
|
874
|
+
kind: "artifact-update",
|
|
875
|
+
taskId,
|
|
876
|
+
contextId,
|
|
877
|
+
artifact: {
|
|
878
|
+
artifactId: `data-${i}`,
|
|
879
|
+
parts: [{ kind: "data", data: artifact.data }],
|
|
880
|
+
},
|
|
881
|
+
})
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
// Programmatic .chat() path: stash graph result on eventBus so chat.js
|
|
885
|
+
// can read messages without an extra checkpoint roundtrip.
|
|
886
|
+
if (eventBus[COLLECT_RESULT]) {
|
|
887
|
+
eventBus._graphResult = { messages: result.messages || [] }
|
|
888
|
+
}
|
|
889
|
+
|
|
1081
890
|
eventBus.publish({
|
|
1082
891
|
kind: "status-update",
|
|
1083
892
|
taskId,
|
|
@@ -1245,6 +1054,10 @@ class GraphExecutor {
|
|
|
1245
1054
|
final: true,
|
|
1246
1055
|
})
|
|
1247
1056
|
} finally {
|
|
1057
|
+
// setSpanAttrs must happen at the end for the linking as else prompt might not yet have been created
|
|
1058
|
+
const rootSpan = cds.context["_mlflow.rootSpan"]
|
|
1059
|
+
setSpanAttrs(rootSpan, linkTraceToPrompt())
|
|
1060
|
+
|
|
1248
1061
|
this._abortControllers.delete(taskId)
|
|
1249
1062
|
metrics.concurrentExecutions.add(-1, mAttrs)
|
|
1250
1063
|
|
|
@@ -1330,16 +1143,7 @@ class GraphExecutor {
|
|
|
1330
1143
|
}
|
|
1331
1144
|
}
|
|
1332
1145
|
|
|
1333
|
-
export {
|
|
1334
|
-
GraphExecutor,
|
|
1335
|
-
messageText,
|
|
1336
|
-
defaultOutputMapper,
|
|
1337
|
-
agentMessage,
|
|
1338
|
-
parseResumeDecision,
|
|
1339
|
-
decisionTypeOf,
|
|
1340
|
-
extractInterruptData,
|
|
1341
|
-
composeEditNote,
|
|
1342
|
-
}
|
|
1146
|
+
export { GraphExecutor, messageText, defaultOutputMapper, agentMessage }
|
|
1343
1147
|
|
|
1344
1148
|
/**
|
|
1345
1149
|
* @param {[import('@langchain/core/messages').Message]} messages
|
|
@@ -1372,3 +1176,13 @@ function totalToolCalls(messages) {
|
|
|
1372
1176
|
return acc
|
|
1373
1177
|
}, 0)
|
|
1374
1178
|
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* @param {[import('@langchain/core/messages').Message]} messages
|
|
1182
|
+
*/
|
|
1183
|
+
function toolCallsShortened(messages) {
|
|
1184
|
+
return messages.reduce((acc, val) => {
|
|
1185
|
+
if (val.type === "tool") acc.push(val.name)
|
|
1186
|
+
return acc
|
|
1187
|
+
}, [])
|
|
1188
|
+
}
|
package/srv/handlers/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { buildSystemPrompt } from "./system-prompt.js"
|
|
|
4
4
|
import buildMiddleware from "../../lib/agents/middleware/index.js"
|
|
5
5
|
import { partsToText } from "../../lib/utils/message-handling.js"
|
|
6
6
|
import { cleanupExpiredTasks } from "../../lib/protocol/persistence/cleanup.js"
|
|
7
|
+
import { registerChat } from "./chat.js"
|
|
7
8
|
|
|
8
9
|
const LOG = cds.log("agents")
|
|
9
10
|
|
|
@@ -91,7 +92,8 @@ export default function registerDefaultAgentHandlers(srv) {
|
|
|
91
92
|
// Default buildModel: cds.connect.to('llm'), configurable via @agent.llm
|
|
92
93
|
srv.on("buildModel", async (req) => {
|
|
93
94
|
const name = srv?.options?.agent?.llm || srv?.definition?.["@agent.llm"] || "llm"
|
|
94
|
-
|
|
95
|
+
const options = cds.requires[name] ?? {}
|
|
96
|
+
let { kind, impl } = options
|
|
95
97
|
if (!impl) impl = cds.requires.kinds[kind]?.impl
|
|
96
98
|
if (!impl) throw new Error("No service implementation found for " + name)
|
|
97
99
|
const { default: LLMProvider } = await import(impl)
|
|
@@ -175,4 +177,6 @@ export default function registerDefaultAgentHandlers(srv) {
|
|
|
175
177
|
srv.on("cleanupTasks", async () => {
|
|
176
178
|
await cleanupExpiredTasks(srv.name)
|
|
177
179
|
})
|
|
180
|
+
|
|
181
|
+
registerChat(srv)
|
|
178
182
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import cds from "@sap/cds"
|
|
2
|
-
import { MultiServerMCPClient } from "@langchain/mcp-adapters"
|
|
3
2
|
import { generateTools } from "./tools.js"
|
|
4
3
|
import { toolName } from "../../lib/utils/utils.js"
|
|
5
4
|
|
|
@@ -42,48 +41,26 @@ async function resolveDestination(destinationName, dest, localUrl) {
|
|
|
42
41
|
}
|
|
43
42
|
}
|
|
44
43
|
|
|
45
|
-
/**
|
|
46
|
-
* Wrap MCP tool invocations to convert errors into plain string results.
|
|
47
|
-
* deepagents' wrapToolCall middleware marks errors thrown inside it as
|
|
48
|
-
* "middleware errors", which LangChain's ToolNode re-throws rather than
|
|
49
|
-
* converting to a ToolMessage (see ToolNode.js: isMiddlewareError check).
|
|
50
|
-
* MCP tool schema validation errors therefore crash the graph instead of
|
|
51
|
-
* being fed back to the LLM as recoverable feedback.
|
|
52
|
-
*/
|
|
53
|
-
function wrapToolsWithErrorHandling(tools, serviceName) {
|
|
54
|
-
return tools.map((tool) => {
|
|
55
|
-
const original = tool.invoke.bind(tool)
|
|
56
|
-
tool.invoke = async (args, config) => {
|
|
57
|
-
try {
|
|
58
|
-
return await original(args, config)
|
|
59
|
-
} catch (err) {
|
|
60
|
-
LOG.warn(`MCP tool "${tool.name}" error: ${err.message}`, { service: serviceName })
|
|
61
|
-
return `Error: ${err.message}`
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return tool
|
|
65
|
-
})
|
|
66
|
-
}
|
|
67
|
-
|
|
68
44
|
export async function buildMcpToolsLocally(serviceName) {
|
|
69
45
|
const srv = cds.services[serviceName]
|
|
70
46
|
const tools = generateTools(srv)
|
|
71
47
|
const prefix = toolName(`${serviceName}_`)
|
|
72
48
|
for (const tool of tools) tool.name = `${prefix}${tool.name}`
|
|
73
49
|
|
|
74
|
-
LOG.
|
|
50
|
+
LOG.debug(
|
|
75
51
|
`Got ${tools.length} MCP tools from ${serviceName}: ${tools.map((t) => t.name).join(", ")}`,
|
|
76
52
|
)
|
|
77
53
|
return tools
|
|
78
54
|
}
|
|
79
55
|
|
|
80
56
|
/**
|
|
81
|
-
* Build
|
|
82
|
-
* Resolves the destination URL and auth
|
|
83
|
-
*
|
|
57
|
+
* Build a dynamic MCP placeholder from a CAP MCP connection.
|
|
58
|
+
* Resolves the destination URL and auth-header factory; the actual tools/list
|
|
59
|
+
* call is deferred to remoteMcpMiddleware which runs per-request with the
|
|
60
|
+
* current user's credentials.
|
|
84
61
|
*
|
|
85
62
|
* @param {string} serviceName - cds.requires service key
|
|
86
|
-
* @returns {Promise<
|
|
63
|
+
* @returns {Promise<{ _mcpDynamic: true, mcpUrl: string, resolveHeaders: () => Promise<object> }>}
|
|
87
64
|
*/
|
|
88
65
|
export async function buildMcpToolsFromConnection(serviceName) {
|
|
89
66
|
let endpoints = cds.service.endpoints4({
|
|
@@ -136,22 +113,7 @@ export async function buildMcpToolsFromConnection(serviceName) {
|
|
|
136
113
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
|
137
114
|
}
|
|
138
115
|
|
|
139
|
-
|
|
140
|
-
mcpServers: {
|
|
141
|
-
[serviceName]: { url: mcpUrl },
|
|
142
|
-
},
|
|
143
|
-
beforeToolCall: async () => ({ headers: await resolveHeaders() }),
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
const tools = await client.getTools()
|
|
147
|
-
const prefix = toolName(`${serviceName}_`)
|
|
148
|
-
for (const tool of tools) tool.name = `${prefix}${tool.name}`
|
|
149
|
-
|
|
150
|
-
LOG.info(
|
|
151
|
-
`Got ${tools.length} MCP tools from ${serviceName}: ${tools.map((t) => t.name).join(", ")}`,
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
return wrapToolsWithErrorHandling(tools, serviceName)
|
|
116
|
+
return { _mcpDynamic: true, mcpUrl, serviceName, resolveHeaders }
|
|
155
117
|
}
|
|
156
118
|
|
|
157
119
|
export async function buildMcpTools(serviceName) {
|