@cap-js/agents 0.9.2 → 0.9.3
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/index.js +11 -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/preview/chat.html +368 -65
- 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/resilience.js +133 -0
- package/lib/utils/utils.js +17 -0
- package/package.json +23 -14
- package/srv/handlers/chat.js +278 -0
- package/srv/handlers/graph-executor.js +43 -37
- package/srv/handlers/index.js +3 -0
- package/srv/handlers/mcp-tools.js +7 -45
- package/srv/handlers/sub-agent-tools.js +8 -1
- package/srv/handlers/tools.js +9 -5
- package/index.js +0 -0
- package/lib/telemetry/mlflow.js +0 -290
- /package/{index.cds → srv/entities.cds} +0 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
import { startCollection } from "../../lib/eval/span-collector.js"
|
|
3
|
+
import { metricsFromSpans } from "../../lib/eval/metrics.js"
|
|
4
|
+
import { getActiveRunState, logMlflowMetricsForResult } from "../../lib/eval/eval-run.js"
|
|
5
|
+
|
|
6
|
+
export const COLLECT_RESULT = Symbol.for("@cap-js/agents:chat:collect-result")
|
|
7
|
+
|
|
8
|
+
class NoopEventBus {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.events = []
|
|
11
|
+
this[COLLECT_RESULT] = true
|
|
12
|
+
this._graphResult = null
|
|
13
|
+
this._done = false
|
|
14
|
+
this.finished$ = new Promise((resolve, reject) => {
|
|
15
|
+
this._resolve = resolve
|
|
16
|
+
this._reject = reject
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
publish(event) {
|
|
21
|
+
this.events.push(event)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
finished() {
|
|
25
|
+
this._done = true
|
|
26
|
+
this._resolve?.()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
error(err) {
|
|
30
|
+
this._reject?.(err)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
getFinalText() {
|
|
34
|
+
for (let i = this.events.length - 1; i >= 0; i--) {
|
|
35
|
+
const e = this.events[i]
|
|
36
|
+
if (e.kind === "artifact-update" && e.lastChunk === true) {
|
|
37
|
+
const text = e.artifact?.parts
|
|
38
|
+
?.filter((p) => p.kind === "text")
|
|
39
|
+
.map((p) => p.text)
|
|
40
|
+
.join("")
|
|
41
|
+
if (text) return text
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (let i = this.events.length - 1; i >= 0; i--) {
|
|
45
|
+
const e = this.events[i]
|
|
46
|
+
if (e.kind === "status-update" && e.status?.message?.parts) {
|
|
47
|
+
const text = e.status.message.parts
|
|
48
|
+
.filter((p) => p.kind === "text")
|
|
49
|
+
.map((p) => p.text)
|
|
50
|
+
.join("")
|
|
51
|
+
if (text) return text
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return ""
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
getStatus() {
|
|
58
|
+
const e = this.events.at(-1)
|
|
59
|
+
if (e && e.kind === "status-update" && e.status?.state) {
|
|
60
|
+
const state = e.status.state
|
|
61
|
+
const msg = e.status?.message?.parts?.find((p) => p.kind === "text")?.text ?? ""
|
|
62
|
+
return { status: state, description: msg }
|
|
63
|
+
}
|
|
64
|
+
return { status: "completed", description: "" }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Derive toolCalls from graph messages by pairing AIMessage.tool_calls with ToolMessage results.
|
|
69
|
+
function toolCallsFromMessages(messages) {
|
|
70
|
+
if (!Array.isArray(messages) || messages.length === 0) return []
|
|
71
|
+
|
|
72
|
+
const resultById = new Map()
|
|
73
|
+
for (const msg of messages) {
|
|
74
|
+
if (msg.tool_call_id && msg.type === "tool") {
|
|
75
|
+
const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content)
|
|
76
|
+
resultById.set(msg.tool_call_id, content)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const entries = []
|
|
81
|
+
for (const msg of messages) {
|
|
82
|
+
if (!Array.isArray(msg.tool_calls) || msg.tool_calls.length === 0) continue
|
|
83
|
+
for (const tc of msg.tool_calls) {
|
|
84
|
+
const raw = resultById.get(tc.id)
|
|
85
|
+
let toolResult
|
|
86
|
+
try {
|
|
87
|
+
toolResult = raw !== undefined ? JSON.parse(raw) : undefined
|
|
88
|
+
} catch {
|
|
89
|
+
toolResult = raw
|
|
90
|
+
}
|
|
91
|
+
const entry = {
|
|
92
|
+
tool: tc.name,
|
|
93
|
+
args: tc.args ?? {},
|
|
94
|
+
outcome: "success",
|
|
95
|
+
...(toolResult !== undefined && { result: toolResult }),
|
|
96
|
+
}
|
|
97
|
+
attachCqn(entry)
|
|
98
|
+
entries.push(entry)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return entries
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* If entry.args.cql attaches cqn as hidden property.
|
|
106
|
+
* No-op when cql is absent or faulty.
|
|
107
|
+
*/
|
|
108
|
+
function attachCqn(entry) {
|
|
109
|
+
const cql = entry.args?.cql
|
|
110
|
+
if (typeof cql !== "string") return
|
|
111
|
+
try {
|
|
112
|
+
const cqn = cds.parse.cql(cql)
|
|
113
|
+
Object.defineProperty(entry, "cqn", {
|
|
114
|
+
value: cqn,
|
|
115
|
+
enumerable: false,
|
|
116
|
+
writable: false,
|
|
117
|
+
configurable: true,
|
|
118
|
+
})
|
|
119
|
+
} catch {
|
|
120
|
+
/* unparseable CQL — skip */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildRequestContext(query, opts = {}) {
|
|
125
|
+
const taskId = opts.taskId || cds.utils.uuid()
|
|
126
|
+
const contextId = opts.contextId || cds.utils.uuid()
|
|
127
|
+
const parts =
|
|
128
|
+
opts.parts ??
|
|
129
|
+
(typeof query === "string"
|
|
130
|
+
? [{ kind: "text", text: query }]
|
|
131
|
+
: (query?.parts ?? [{ kind: "text", text: String(query) }]))
|
|
132
|
+
return {
|
|
133
|
+
taskId,
|
|
134
|
+
contextId,
|
|
135
|
+
task: opts.task ?? null,
|
|
136
|
+
userMessage: {
|
|
137
|
+
kind: "message",
|
|
138
|
+
messageId: cds.utils.uuid(),
|
|
139
|
+
role: "user",
|
|
140
|
+
taskId,
|
|
141
|
+
contextId,
|
|
142
|
+
parts,
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Install srv.chat(query, previous?) on an @agent service for eval tests. */
|
|
148
|
+
export function registerChat(srv) {
|
|
149
|
+
srv.chat = async function chat(query, previous) {
|
|
150
|
+
// Resolve the small eval helper API:
|
|
151
|
+
// chat(query)
|
|
152
|
+
// chat(query, prevResult) — extract contextId + taskId for HITL resume
|
|
153
|
+
// chat(query, { _details: true }) — internal/test escape hatch outside test profile
|
|
154
|
+
let opts = {}
|
|
155
|
+
if (typeof previous === "string") {
|
|
156
|
+
throw new TypeError(
|
|
157
|
+
"agent.chat: second argument must be a previous chat result object or options object",
|
|
158
|
+
)
|
|
159
|
+
} else if (previous && typeof previous === "object") {
|
|
160
|
+
if ("text" in previous || "contextId" in previous) {
|
|
161
|
+
// prior chat() result — extract conversation ids and HITL state
|
|
162
|
+
opts = {
|
|
163
|
+
contextId: previous.contextId,
|
|
164
|
+
// mark as resume when prior result was input-required
|
|
165
|
+
...(previous.status === "input-required" && {
|
|
166
|
+
taskId: previous.taskId,
|
|
167
|
+
task: { id: previous.taskId, status: { state: "input-required" } },
|
|
168
|
+
}),
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
opts = { _details: previous._details === true }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const includeDetails = shouldIncludeChatDetails(opts)
|
|
176
|
+
const runState = includeDetails ? getActiveRunState() : null
|
|
177
|
+
|
|
178
|
+
// Open span collection session before execution so the processor
|
|
179
|
+
// captures all spans from this trace.
|
|
180
|
+
const collection = includeDetails ? await startCollection() : null
|
|
181
|
+
|
|
182
|
+
const { LangGraphExecutor } = await import("../langgraph-executor-srv.js")
|
|
183
|
+
const executor = LangGraphExecutor.for(srv)
|
|
184
|
+
const requestContext = buildRequestContext(query, opts)
|
|
185
|
+
const eventBus = new NoopEventBus()
|
|
186
|
+
const mlflowRunId = runState?.mlflowRunId
|
|
187
|
+
let traceId
|
|
188
|
+
|
|
189
|
+
const runInContext = async () => {
|
|
190
|
+
// Link the trace to the MLflow eval run — graph-executor reads this to set mlflow.sourceRun.
|
|
191
|
+
if (mlflowRunId && cds.context) cds.context["_mlflow.evalRunId"] = mlflowRunId
|
|
192
|
+
|
|
193
|
+
let execError = null
|
|
194
|
+
const execPromise = executor.execute(requestContext, eventBus).catch((err) => {
|
|
195
|
+
execError = err
|
|
196
|
+
if (!eventBus._done) eventBus.finished()
|
|
197
|
+
})
|
|
198
|
+
await Promise.race([eventBus.finished$, execPromise])
|
|
199
|
+
if (!eventBus._done) eventBus.finished()
|
|
200
|
+
if (execError) throw execError
|
|
201
|
+
|
|
202
|
+
if (includeDetails) {
|
|
203
|
+
const rootSpan = cds.context?.["_mlflow.rootSpan"]
|
|
204
|
+
if (rootSpan) traceId = rootSpan.spanContext?.()?.traceId
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (cds.context) {
|
|
209
|
+
await runInContext()
|
|
210
|
+
} else {
|
|
211
|
+
await cds._with(
|
|
212
|
+
new cds.EventContext({ tenant: "t0", user: new cds.User.Privileged() }),
|
|
213
|
+
runInContext,
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const { status, description } = eventBus.getStatus()
|
|
218
|
+
if (status === "failed")
|
|
219
|
+
throw new Error(`agent.chat: task failed — ${description || "no message"}`)
|
|
220
|
+
|
|
221
|
+
const text = eventBus.getFinalText()
|
|
222
|
+
const result = {
|
|
223
|
+
text,
|
|
224
|
+
contextId: requestContext.contextId,
|
|
225
|
+
taskId: requestContext.taskId,
|
|
226
|
+
status,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (includeDetails) {
|
|
230
|
+
const allMessages = eventBus._graphResult?.messages ?? []
|
|
231
|
+
|
|
232
|
+
// Find last HumanMessage whose text matches current query; return from there.
|
|
233
|
+
const queryText = String(query)
|
|
234
|
+
let turnStart = 0
|
|
235
|
+
for (let i = allMessages.length - 1; i >= 0; i--) {
|
|
236
|
+
const m = allMessages[i]
|
|
237
|
+
if (m.getType?.() === "human" || m._getType?.() === "human" || m.type === "human") {
|
|
238
|
+
const content = typeof m.content === "string" ? m.content : (m.content?.[0]?.text ?? "")
|
|
239
|
+
if (content.startsWith(queryText) || queryText.startsWith(content.trim())) {
|
|
240
|
+
turnStart = i
|
|
241
|
+
break
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const messages = allMessages.slice(turnStart)
|
|
246
|
+
const toolCalls = toolCallsFromMessages(messages)
|
|
247
|
+
|
|
248
|
+
const allSpans = collection.collect()
|
|
249
|
+
const spans = traceId
|
|
250
|
+
? allSpans.filter((s) => s.spanContext?.()?.traceId === traceId)
|
|
251
|
+
: allSpans
|
|
252
|
+
|
|
253
|
+
result.query = String(query)
|
|
254
|
+
result.traceId = traceId
|
|
255
|
+
result.toolCalls = toolCalls
|
|
256
|
+
result.messages = messages.map((m) => {
|
|
257
|
+
// Eval prompts expect text content, not content-block objects.
|
|
258
|
+
if (m.type === "ai") {
|
|
259
|
+
if (Array.isArray(m.content) && m.content[0]?.text) {
|
|
260
|
+
m.content = m.content[0].text
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return m
|
|
264
|
+
})
|
|
265
|
+
result.spans = spans
|
|
266
|
+
result.metrics = metricsFromSpans(spans)
|
|
267
|
+
if (runState) result._evalState = runState
|
|
268
|
+
// Post metrics to MLflow ootb — fire-and-forget.
|
|
269
|
+
logMlflowMetricsForResult(result, runState).catch(() => {})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return result
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function shouldIncludeChatDetails(opts = {}) {
|
|
277
|
+
return cds.env.profiles?.includes("test") || opts._details === true
|
|
278
|
+
}
|
|
@@ -2,11 +2,13 @@ import cds from "@sap/cds"
|
|
|
2
2
|
import { short, audit, ms4 } from "../../lib/utils/utils.js"
|
|
3
3
|
import { partsToText, buildChatMessages, firstDataPart } 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"
|
|
10
12
|
|
|
11
13
|
const LOG = cds.log("agents")
|
|
12
14
|
|
|
@@ -334,16 +336,14 @@ class GraphExecutor {
|
|
|
334
336
|
|
|
335
337
|
let tokenCount = 0
|
|
336
338
|
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.
|
|
339
|
+
// Track the current turn (langchain message id). Each new turn opens a fresh
|
|
340
|
+
// bubble on the client via `append:false`; subsequent tokens of the same turn
|
|
341
|
+
// are `append:true` (accumulate). Anthropic-style turns can stream a text
|
|
342
|
+
// preamble BEFORE their tool_use block ("Let me first look up …") — we let
|
|
343
|
+
// that reasoning text stream normally; the client is responsible for the
|
|
344
|
+
// final visual (collapse to the last turn's bubble at task completion).
|
|
345
345
|
let currentMsgId = null
|
|
346
|
-
let
|
|
346
|
+
let thinkingCount = 0
|
|
347
347
|
|
|
348
348
|
try {
|
|
349
349
|
if (typeof graph.stream !== "function" || cds.env.agents?.streaming === false) {
|
|
@@ -379,30 +379,11 @@ class GraphExecutor {
|
|
|
379
379
|
|
|
380
380
|
if (msgChunk.id && msgChunk.id !== currentMsgId) {
|
|
381
381
|
currentMsgId = msgChunk.id
|
|
382
|
-
turnHasToolCall = false
|
|
383
382
|
tokenCount = 0
|
|
384
383
|
}
|
|
385
384
|
|
|
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
|
|
385
|
+
const lastChunk =
|
|
386
|
+
!!msgChunk.additional_kwargs?.intermediate_results?.llm?.choices[0].finish_reason
|
|
406
387
|
|
|
407
388
|
const text = messageText(msgChunk?.content)
|
|
408
389
|
if (!text) continue
|
|
@@ -415,12 +396,13 @@ class GraphExecutor {
|
|
|
415
396
|
taskId,
|
|
416
397
|
contextId,
|
|
417
398
|
append: tokenCount > 0,
|
|
418
|
-
lastChunk:
|
|
399
|
+
lastChunk: lastChunk,
|
|
419
400
|
artifact: {
|
|
420
|
-
artifactId:
|
|
401
|
+
artifactId: `thinking-${thinkingCount}`,
|
|
421
402
|
parts: [{ kind: "text", text }],
|
|
422
403
|
},
|
|
423
404
|
})
|
|
405
|
+
if (lastChunk) thinkingCount++
|
|
424
406
|
tokenCount++
|
|
425
407
|
} else if (mode === "updates") {
|
|
426
408
|
// The updates stream yields per-node deltas — { <node>: { messages: [oneNewMessage] } },
|
|
@@ -699,6 +681,12 @@ class GraphExecutor {
|
|
|
699
681
|
}),
|
|
700
682
|
)
|
|
701
683
|
setSpanAttrs(rootSpan, mlflowTraceAttrs())
|
|
684
|
+
// Required for MLFLow run linking
|
|
685
|
+
const evalRunId = cds.context?.["_mlflow.evalRunId"]
|
|
686
|
+
if (evalRunId) {
|
|
687
|
+
rootSpan.setAttribute("mlflow.sourceRun", evalRunId)
|
|
688
|
+
wfSpan.setAttribute("mlflow.sourceRun", evalRunId)
|
|
689
|
+
}
|
|
702
690
|
}
|
|
703
691
|
|
|
704
692
|
let usageData
|
|
@@ -753,7 +741,6 @@ class GraphExecutor {
|
|
|
753
741
|
contextId,
|
|
754
742
|
service: serviceName,
|
|
755
743
|
decision,
|
|
756
|
-
userMessage: requestContext.userMessage,
|
|
757
744
|
},
|
|
758
745
|
})
|
|
759
746
|
// On edit, stash a diff note in state; the injector middleware prepends it next turn.
|
|
@@ -823,7 +810,7 @@ class GraphExecutor {
|
|
|
823
810
|
contextId,
|
|
824
811
|
service: serviceName,
|
|
825
812
|
description,
|
|
826
|
-
|
|
813
|
+
interruptData,
|
|
827
814
|
},
|
|
828
815
|
})
|
|
829
816
|
|
|
@@ -878,8 +865,7 @@ class GraphExecutor {
|
|
|
878
865
|
service: serviceName,
|
|
879
866
|
duration,
|
|
880
867
|
tokenUsage: usageData,
|
|
881
|
-
toolCalls:
|
|
882
|
-
output: output?.slice(0, 2000),
|
|
868
|
+
toolCalls: toolCallsShortened(result.messages),
|
|
883
869
|
task: requestContext.task,
|
|
884
870
|
},
|
|
885
871
|
})
|
|
@@ -1078,6 +1064,12 @@ class GraphExecutor {
|
|
|
1078
1064
|
})
|
|
1079
1065
|
}
|
|
1080
1066
|
|
|
1067
|
+
// Programmatic .chat() path: stash graph result on eventBus so chat.js
|
|
1068
|
+
// can read messages without an extra checkpoint roundtrip.
|
|
1069
|
+
if (eventBus[COLLECT_RESULT]) {
|
|
1070
|
+
eventBus._graphResult = { messages: result.messages || [] }
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1081
1073
|
eventBus.publish({
|
|
1082
1074
|
kind: "status-update",
|
|
1083
1075
|
taskId,
|
|
@@ -1245,6 +1237,10 @@ class GraphExecutor {
|
|
|
1245
1237
|
final: true,
|
|
1246
1238
|
})
|
|
1247
1239
|
} finally {
|
|
1240
|
+
// setSpanAttrs must happen at the end for the linking as else prompt might not yet have been created
|
|
1241
|
+
const rootSpan = cds.context["_mlflow.rootSpan"]
|
|
1242
|
+
setSpanAttrs(rootSpan, linkTraceToPrompt())
|
|
1243
|
+
|
|
1248
1244
|
this._abortControllers.delete(taskId)
|
|
1249
1245
|
metrics.concurrentExecutions.add(-1, mAttrs)
|
|
1250
1246
|
|
|
@@ -1372,3 +1368,13 @@ function totalToolCalls(messages) {
|
|
|
1372
1368
|
return acc
|
|
1373
1369
|
}, 0)
|
|
1374
1370
|
}
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* @param {[import('@langchain/core/messages').Message]} messages
|
|
1374
|
+
*/
|
|
1375
|
+
function toolCallsShortened(messages) {
|
|
1376
|
+
return messages.reduce((acc, val) => {
|
|
1377
|
+
if (val.type === "tool") acc.push(val.name)
|
|
1378
|
+
return acc
|
|
1379
|
+
}, [])
|
|
1380
|
+
}
|
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
|
|
|
@@ -175,4 +176,6 @@ export default function registerDefaultAgentHandlers(srv) {
|
|
|
175
176
|
srv.on("cleanupTasks", async () => {
|
|
176
177
|
await cleanupExpiredTasks(srv.name)
|
|
177
178
|
})
|
|
179
|
+
|
|
180
|
+
registerChat(srv)
|
|
178
181
|
}
|
|
@@ -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) {
|
|
@@ -2,7 +2,7 @@ import cds from "@sap/cds"
|
|
|
2
2
|
import { tool } from "@langchain/core/tools"
|
|
3
3
|
import { z } from "zod"
|
|
4
4
|
import { LangGraphExecutor } from "../langgraph-executor-srv.js"
|
|
5
|
-
import { toolName } from "../../lib/utils/utils.js"
|
|
5
|
+
import { short, toolName } from "../../lib/utils/utils.js"
|
|
6
6
|
|
|
7
7
|
const LOG = cds.log("agents:sub-agents")
|
|
8
8
|
|
|
@@ -164,6 +164,7 @@ export async function buildSubAgentToolLocally(serviceName) {
|
|
|
164
164
|
taskId,
|
|
165
165
|
contextId,
|
|
166
166
|
)
|
|
167
|
+
const truncated = message?.length > 80 ? message.slice(0, 80) + "..." : message
|
|
167
168
|
|
|
168
169
|
try {
|
|
169
170
|
// Run the sub-agent detached from the calling agent, in its own root
|
|
@@ -172,6 +173,12 @@ export async function buildSubAgentToolLocally(serviceName) {
|
|
|
172
173
|
await new Promise((resolve, reject) => {
|
|
173
174
|
cds
|
|
174
175
|
.spawn({ user: cds.context?.user, tenant: cds.context?.tenant }, async () => {
|
|
176
|
+
LOG.info("request", {
|
|
177
|
+
conversation: short(contextId),
|
|
178
|
+
service: srv.name,
|
|
179
|
+
text: truncated,
|
|
180
|
+
})
|
|
181
|
+
|
|
175
182
|
await executor.execute(requestContext, eventBus)
|
|
176
183
|
await done
|
|
177
184
|
})
|
package/srv/handlers/tools.js
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
executeCallActionTool,
|
|
12
12
|
executePerActionTool,
|
|
13
13
|
} from "@cap-js/mcp/lib/tools.js"
|
|
14
|
-
import { getFilteredEntities, getFilteredActions } from "../../lib/utils/utils.js"
|
|
14
|
+
import { getFilteredEntities, getFilteredActions, getAgentLogger } from "../../lib/utils/utils.js"
|
|
15
15
|
import { isTextMime } from "../../lib/agents/markdown/backends/mime-utils.js"
|
|
16
16
|
import { checkAuthorization } from "@cap-js/mcp/lib/auth.js"
|
|
17
17
|
|
|
@@ -31,6 +31,7 @@ function cachedAuth(srv) {
|
|
|
31
31
|
|
|
32
32
|
class GenericReadTool extends DynamicStructuredTool {
|
|
33
33
|
constructor(srv, entities) {
|
|
34
|
+
const log = getAgentLogger(srv)
|
|
34
35
|
const def = createGenericReadToolDefinition(Object.keys(entities), srv.name, "")
|
|
35
36
|
super({
|
|
36
37
|
name: def.name,
|
|
@@ -38,7 +39,7 @@ class GenericReadTool extends DynamicStructuredTool {
|
|
|
38
39
|
schema: def.inputSchema,
|
|
39
40
|
responseFormat: "content_and_artifact",
|
|
40
41
|
func: async (args) => {
|
|
41
|
-
return unwrap(await executeGenericReadTool(srv, entities, args, { log
|
|
42
|
+
return unwrap(await executeGenericReadTool(srv, entities, args, { log }))
|
|
42
43
|
},
|
|
43
44
|
})
|
|
44
45
|
this.srv = srv
|
|
@@ -67,6 +68,7 @@ class GenericReadTool extends DynamicStructuredTool {
|
|
|
67
68
|
|
|
68
69
|
class DescribeTool extends DynamicStructuredTool {
|
|
69
70
|
constructor(srv, entities, actions) {
|
|
71
|
+
const log = getAgentLogger(srv)
|
|
70
72
|
const def = createDescribeToolDefinition(
|
|
71
73
|
Object.keys(entities),
|
|
72
74
|
Object.keys(actions),
|
|
@@ -79,7 +81,7 @@ class DescribeTool extends DynamicStructuredTool {
|
|
|
79
81
|
schema: def.inputSchema,
|
|
80
82
|
responseFormat: "content_and_artifact",
|
|
81
83
|
func: async (args) => {
|
|
82
|
-
return unwrap(await executeDescribe(srv, entities, actions, args, { log
|
|
84
|
+
return unwrap(await executeDescribe(srv, entities, actions, args, { log }))
|
|
83
85
|
},
|
|
84
86
|
})
|
|
85
87
|
this.srv = srv
|
|
@@ -119,6 +121,7 @@ class DescribeTool extends DynamicStructuredTool {
|
|
|
119
121
|
|
|
120
122
|
class PerActionTool extends DynamicStructuredTool {
|
|
121
123
|
constructor(srv, actionName, action) {
|
|
124
|
+
const log = getAgentLogger(srv)
|
|
122
125
|
const def = createPerActionToolDefinition(actionName, action, srv.name, srv.model, "")
|
|
123
126
|
super({
|
|
124
127
|
name: def.name,
|
|
@@ -126,7 +129,7 @@ class PerActionTool extends DynamicStructuredTool {
|
|
|
126
129
|
schema: def.inputSchema,
|
|
127
130
|
responseFormat: "content_and_artifact",
|
|
128
131
|
func: async (args) => {
|
|
129
|
-
return unwrap(await executePerActionTool(srv, actionName, action, args, { log
|
|
132
|
+
return unwrap(await executePerActionTool(srv, actionName, action, args, { log }))
|
|
130
133
|
},
|
|
131
134
|
})
|
|
132
135
|
this.srv = srv
|
|
@@ -142,6 +145,7 @@ class PerActionTool extends DynamicStructuredTool {
|
|
|
142
145
|
|
|
143
146
|
class CallActionTool extends DynamicStructuredTool {
|
|
144
147
|
constructor(srv, actions) {
|
|
148
|
+
const log = getAgentLogger(srv)
|
|
145
149
|
const def = createCallActionToolDefinition(Object.keys(actions), srv.name, "")
|
|
146
150
|
super({
|
|
147
151
|
name: def.name,
|
|
@@ -149,7 +153,7 @@ class CallActionTool extends DynamicStructuredTool {
|
|
|
149
153
|
schema: def.inputSchema,
|
|
150
154
|
responseFormat: "content_and_artifact",
|
|
151
155
|
func: async (args) => {
|
|
152
|
-
return unwrap(await executeCallActionTool(srv, actions, args, { log
|
|
156
|
+
return unwrap(await executeCallActionTool(srv, actions, args, { log }))
|
|
153
157
|
},
|
|
154
158
|
})
|
|
155
159
|
this.srv = srv
|
package/index.js
DELETED
|
File without changes
|