@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.
Files changed (49) hide show
  1. package/README.md +2 -0
  2. package/cds-plugin.js +2 -2
  3. package/lib/agents/middleware/content-filter.js +3 -2
  4. package/lib/agents/middleware/hitl-decision-note-injector.js +18 -0
  5. package/lib/agents/middleware/hitl.js +2 -2
  6. package/lib/agents/middleware/index.js +11 -0
  7. package/lib/agents/middleware/quota-enforcer.js +9 -0
  8. package/lib/agents/middleware/remote-mcp.js +62 -0
  9. package/lib/agents/middleware/tool-wrap.js +52 -0
  10. package/lib/compile.js +6 -2
  11. package/lib/eval/Judge.js +239 -0
  12. package/lib/eval/eval-describe.js +69 -0
  13. package/lib/eval/eval-run.js +147 -0
  14. package/lib/eval/index.js +6 -0
  15. package/lib/eval/metrics.js +53 -0
  16. package/lib/eval/span-collector.js +50 -0
  17. package/lib/index.js +2 -1
  18. package/lib/models/aicore.js +23 -6
  19. package/lib/models/anthropic.js +27 -12
  20. package/lib/preview/chat.html +373 -66
  21. package/lib/protocol/agent-card.js +6 -2
  22. package/lib/sidecar.js +1 -1
  23. package/lib/telemetry/chat-tracing.js +39 -8
  24. package/lib/telemetry/mlflow/credentials.js +79 -0
  25. package/lib/telemetry/mlflow/evaluation.js +38 -0
  26. package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +75 -0
  27. package/lib/telemetry/mlflow/exporter/MlflowExporter.js +115 -0
  28. package/lib/telemetry/mlflow/exporter/index.js +23 -0
  29. package/lib/telemetry/mlflow/index.js +16 -0
  30. package/lib/telemetry/mlflow/prompts.js +123 -0
  31. package/lib/telemetry/mlflow/tracing.js +264 -0
  32. package/lib/telemetry/tool-tracing.js +27 -54
  33. package/lib/telemetry/tracing.js +3 -1
  34. package/lib/utils/markdown.js +1 -11
  35. package/lib/utils/message-handling.js +14 -0
  36. package/lib/utils/resilience.js +133 -0
  37. package/lib/utils/utils.js +17 -0
  38. package/package.json +26 -14
  39. package/srv/handlers/chat.js +278 -0
  40. package/srv/handlers/graph-executor/hitl.js +263 -0
  41. package/srv/handlers/graph-executor.js +100 -286
  42. package/srv/handlers/index.js +5 -1
  43. package/srv/handlers/mcp-tools.js +7 -45
  44. package/srv/handlers/sub-agent-tools.js +30 -12
  45. package/srv/handlers/tools.js +39 -5
  46. package/index.js +0 -0
  47. package/lib/agents/middleware/hitl-edit-note-injector.js +0 -18
  48. package/lib/telemetry/mlflow.js +0 -290
  49. /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, firstDataPart } from "../../lib/utils/message-handling.js"
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) 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.
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 turnHasToolCall = false
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
- // 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
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: false,
247
+ lastChunk: lastChunk,
419
248
  artifact: {
420
- artifactId: "response",
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
- 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(
567
+ result = await resumeHitl({
568
+ requestContext,
767
569
  graph,
768
- new Command(commandArgs),
769
570
  config,
770
571
  eventBus,
771
- taskId,
772
- contextId,
773
- controller.signal,
774
- )
775
- result = resumed.state
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
- 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,
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
- 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(),
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: totalToolCalls(result.messages),
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
- const start = content.indexOf('{"kind":"file"', pos)
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
+ }
@@ -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
- let { kind, impl, ...options } = cds.requires[name] ?? {}
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.info(
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 LangChain tools from a CAP MCP connection.
82
- * Resolves the destination URL and auth headers, connects to the MCP server,
83
- * and returns the tools wrapped with error handling for use in agent graphs.
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<import("@langchain/core/tools").StructuredTool[]>}
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
- const client = new MultiServerMCPClient({
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) {