@cap-js/agents 0.9.1 → 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.
Files changed (48) hide show
  1. package/README.md +2 -1
  2. package/cds-plugin.js +2 -2
  3. package/lib/agents/markdown/backends/outputs-backend.js +16 -12
  4. package/lib/agents/markdown/backends/readonly-backend.js +48 -0
  5. package/lib/agents/markdown/backends/uploads-backend.js +30 -13
  6. package/lib/agents/markdown/deep-agent.js +15 -26
  7. package/lib/agents/middleware/content-filter.js +3 -2
  8. package/lib/agents/middleware/index.js +11 -0
  9. package/lib/agents/middleware/remote-mcp.js +62 -0
  10. package/lib/agents/middleware/tool-wrap.js +52 -0
  11. package/lib/compile.js +6 -2
  12. package/lib/eval/Judge.js +239 -0
  13. package/lib/eval/eval-describe.js +69 -0
  14. package/lib/eval/eval-run.js +147 -0
  15. package/lib/eval/index.js +6 -0
  16. package/lib/eval/metrics.js +53 -0
  17. package/lib/eval/span-collector.js +50 -0
  18. package/lib/index.js +4 -3
  19. package/lib/models/aicore.js +23 -6
  20. package/lib/preview/chat.html +394 -69
  21. package/lib/protocol/agent-card.js +6 -2
  22. package/lib/protocol/persistence/checkpoint-saver.js +4 -1
  23. package/lib/protocol/persistence/cleanup.js +84 -0
  24. package/lib/sidecar.js +1 -1
  25. package/lib/telemetry/chat-tracing.js +61 -8
  26. package/lib/telemetry/mlflow/credentials.js +79 -0
  27. package/lib/telemetry/mlflow/evaluation.js +38 -0
  28. package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +75 -0
  29. package/lib/telemetry/mlflow/exporter/MlflowExporter.js +115 -0
  30. package/lib/telemetry/mlflow/exporter/index.js +23 -0
  31. package/lib/telemetry/mlflow/index.js +16 -0
  32. package/lib/telemetry/mlflow/prompts.js +123 -0
  33. package/lib/telemetry/mlflow/tracing.js +264 -0
  34. package/lib/telemetry/tool-tracing.js +27 -54
  35. package/lib/telemetry/tracing.js +3 -1
  36. package/lib/utils/markdown.js +1 -11
  37. package/lib/utils/resilience.js +133 -0
  38. package/lib/utils/utils.js +17 -0
  39. package/package.json +24 -15
  40. package/{index.cds → srv/entities.cds} +11 -0
  41. package/srv/handlers/chat.js +278 -0
  42. package/srv/handlers/graph-executor.js +48 -37
  43. package/srv/handlers/index.js +8 -0
  44. package/srv/handlers/mcp-tools.js +11 -47
  45. package/srv/handlers/sub-agent-tools.js +12 -3
  46. package/srv/handlers/tools.js +9 -5
  47. package/index.js +0 -0
  48. package/lib/telemetry/mlflow.js +0 -290
package/README.md CHANGED
@@ -54,7 +54,7 @@ Both approaches start from a CDS service annotated with `@agent` (as shown in [R
54
54
 
55
55
  ### Agentify Existing CAP Services
56
56
 
57
- With `@agent` alone, the plugin auto-generates tools from the service's entities and actions, creates a ReAct agent loop, and serves it as a remote agent — no code required.
57
+ With `@agent` alone, the plugin auto-generates tools from the service's entities and actions, creates a [ReAct agent](https://arxiv.org/abs/2210.03629) loop, and serves it as a remote agent — no code required.
58
58
 
59
59
  ```cds
60
60
  @agent
@@ -135,6 +135,7 @@ The following capabilities are experimental and documented separately. Their pub
135
135
  - [Configuration](.docs/configuration.md) — using multiple models, global and per-service settings, file I/O, and push notifications
136
136
  - [Quota Enforcement](.docs/quota.md) — configurable rate limits and resource quotas
137
137
  - [Audit Logging](.docs/audit-logging.md) — immutable audit trail of agent decisions and tool usage
138
+ - [Data Privacy](.docs/data-privacy.md) — deletion of message history
138
139
  - [Telemetry](.docs/telemetry.md) — OpenTelemetry metrics, tracing, and MLflow export
139
140
  - [Content Filter](.docs/content-filter.md) — SAP AI Core content filtering and prompt injection shielding
140
141
 
package/cds-plugin.js CHANGED
@@ -4,7 +4,7 @@ const LOG = cds.log("agents")
4
4
  import { patchLangChain } from "./lib/telemetry/tracing.js"
5
5
  import cds_compile_to_a2a from "./lib/compile.js"
6
6
  import registerDefaultAgentHandlers from "./srv/handlers/index.js"
7
- import { slugified } from "./lib/utils/markdown.js"
7
+ import { slugified } from "./lib/utils/utils.js"
8
8
 
9
9
  cds.compile.to.a2a = cds_compile_to_a2a
10
10
 
@@ -96,7 +96,7 @@ cds.on("served", async () => {
96
96
  }
97
97
 
98
98
  if (cds.env.agents?.mlflow) {
99
- const { setupMlflowExporter } = await import("./lib/telemetry/mlflow.js")
99
+ const { setupMlflowExporter } = await import("./lib/telemetry/mlflow/index.js")
100
100
  setupMlflowExporter()
101
101
  }
102
102
  })
@@ -9,6 +9,7 @@
9
9
  *
10
10
  */
11
11
 
12
+ import { getConfig } from "@langchain/langgraph"
12
13
  import { isTextMime, globToRegex } from "./mime-utils.js"
13
14
 
14
15
  function inferMimeType(name) {
@@ -38,11 +39,14 @@ function inferMimeType(name) {
38
39
  }
39
40
 
40
41
  export class OutputsBackend {
41
- constructor(taskId, fileStore) {
42
- this.taskId = taskId
42
+ constructor(fileStore) {
43
43
  this.fileStore = fileStore
44
44
  }
45
45
 
46
+ _taskId() {
47
+ return getConfig()?.configurable?._taskId || ""
48
+ }
49
+
46
50
  // CompositeBackend strips the route prefix (/outputs/) before delegating,
47
51
  // so paths arrive here as either "/name" (stripped) or "/outputs/name" (direct).
48
52
  // _name() handles both forms.
@@ -54,19 +58,19 @@ export class OutputsBackend {
54
58
  const name = this._name(filePath)
55
59
  const mimeType = options.mimeType || inferMimeType(name)
56
60
  const buf = typeof content === "string" ? Buffer.from(content, "utf-8") : Buffer.from(content)
57
- await this.fileStore.saveOutputFile(this.taskId, name, mimeType, buf)
61
+ await this.fileStore.saveOutputFile(this._taskId(), name, mimeType, buf)
58
62
  return { success: true }
59
63
  }
60
64
 
61
65
  async exists(filePath) {
62
66
  const name = this._name(filePath)
63
- const files = await this.fileStore.listOutputFilesMeta(this.taskId)
67
+ const files = await this.fileStore.listOutputFilesMeta(this._taskId())
64
68
  return files.some((f) => f.name === name)
65
69
  }
66
70
 
67
71
  async stat(filePath) {
68
72
  const name = this._name(filePath)
69
- const files = await this.fileStore.listOutputFilesMeta(this.taskId)
73
+ const files = await this.fileStore.listOutputFilesMeta(this._taskId())
70
74
  const file = files.find((f) => f.name === name)
71
75
  if (!file) return null
72
76
  return { name: file.name, mimeType: file.mimeType, size: file.size }
@@ -75,12 +79,12 @@ export class OutputsBackend {
75
79
  // CompositeBackend re-prepends the route prefix to paths returned here,
76
80
  // so return bare "/<name>" paths (without /outputs/) to avoid double-prefix.
77
81
  async list(_dirPath) {
78
- const files = await this.fileStore.listOutputFilesMeta(this.taskId)
82
+ const files = await this.fileStore.listOutputFilesMeta(this._taskId())
79
83
  return files.map((f) => `/${f.name}`)
80
84
  }
81
85
 
82
86
  async ls(_path) {
83
- const files = await this.fileStore.listOutputFilesMeta(this.taskId)
87
+ const files = await this.fileStore.listOutputFilesMeta(this._taskId())
84
88
  return {
85
89
  files: files.map((f) => ({
86
90
  path: `/${f.name}`,
@@ -93,7 +97,7 @@ export class OutputsBackend {
93
97
 
94
98
  async glob(pattern, _path) {
95
99
  const re = globToRegex(pattern)
96
- const files = await this.fileStore.listOutputFilesMeta(this.taskId)
100
+ const files = await this.fileStore.listOutputFilesMeta(this._taskId())
97
101
  return {
98
102
  files: files
99
103
  .filter((f) => re.test(f.name))
@@ -103,7 +107,7 @@ export class OutputsBackend {
103
107
 
104
108
  async grep(pattern, _path, glob) {
105
109
  const reGlob = glob ? globToRegex(glob) : null
106
- const files = await this.fileStore.listOutputFiles(this.taskId)
110
+ const files = await this.fileStore.listOutputFiles(this._taskId())
107
111
  const matches = []
108
112
  for (const f of files) {
109
113
  if (reGlob && !reGlob.test(f.name)) continue
@@ -120,9 +124,9 @@ export class OutputsBackend {
120
124
 
121
125
  async read(filePath, offset = 0, limit = 100) {
122
126
  const name = this._name(filePath)
123
- const file = await this.fileStore.getOutputFile(this.taskId, name)
127
+ const file = await this.fileStore.getOutputFile(this._taskId(), name)
124
128
  if (!file) {
125
- const files = await this.fileStore.listOutputFilesMeta(this.taskId)
129
+ const files = await this.fileStore.listOutputFilesMeta(this._taskId())
126
130
  const available = files.map((f) => `/outputs/${f.name}`).join(", ") || "none"
127
131
  return { error: `File not found: ${filePath}. Available: ${available}` }
128
132
  }
@@ -141,7 +145,7 @@ export class OutputsBackend {
141
145
  // for the rationale. Currently unused by our shipped code paths; do not delete.
142
146
  async readRaw(filePath) {
143
147
  const name = this._name(filePath)
144
- const file = await this.fileStore.getOutputFile(this.taskId, name)
148
+ const file = await this.fileStore.getOutputFile(this._taskId(), name)
145
149
  if (!file) return { error: `File not found: ${filePath}` }
146
150
  return { bytes: file.bytes, mimeType: file.mimeType, size: file.size }
147
151
  }
@@ -0,0 +1,48 @@
1
+ import { FilesystemBackend } from "deepagents"
2
+
3
+ export class ReadonlyBackend {
4
+ constructor(options) {
5
+ // explicitly keep this internal (no class inheritance) so we do not expose write operations
6
+ this._fsBackend = new FilesystemBackend(options)
7
+ }
8
+
9
+ ls(path) {
10
+ return this._fsBackend.ls(path)
11
+ }
12
+
13
+ read(filePath, offset, limit) {
14
+ return this._fsBackend.read(filePath, offset, limit)
15
+ }
16
+
17
+ readRaw(filePath) {
18
+ return this._fsBackend.readRaw(filePath)
19
+ }
20
+
21
+ grep(pattern, path, glob, maxCount) {
22
+ return this._fsBackend.grep(pattern, path, glob, maxCount)
23
+ }
24
+
25
+ glob(pattern, path) {
26
+ return this._fsBackend.glob(pattern, path)
27
+ }
28
+
29
+ downloadFiles(paths) {
30
+ return this._fsBackend.downloadFiles(paths)
31
+ }
32
+
33
+ write() {
34
+ return { error: "read-only" }
35
+ }
36
+
37
+ edit() {
38
+ return { error: "read-only" }
39
+ }
40
+
41
+ delete() {
42
+ return { error: "read-only" }
43
+ }
44
+
45
+ uploadFiles() {
46
+ return { error: "read-only" }
47
+ }
48
+ }
@@ -9,13 +9,22 @@
9
9
  *
10
10
  */
11
11
 
12
+ import { getConfig } from "@langchain/langgraph"
12
13
  import { isTextMime, globToRegex } from "./mime-utils.js"
13
14
 
14
15
  export class UploadsBackend {
15
- constructor(contextId, fileStore, userId) {
16
- this.contextId = contextId
16
+ constructor(fileStore) {
17
17
  this.fileStore = fileStore
18
- this.userId = userId
18
+ }
19
+
20
+ _resolveContext() {
21
+ const config = getConfig()
22
+ const rawThreadId = config?.configurable?.thread_id || ""
23
+ const contextId = rawThreadId.includes(":")
24
+ ? rawThreadId.split(":").slice(1).join(":")
25
+ : rawThreadId
26
+ const userId = config?.configurable?._userId
27
+ return { contextId, userId }
19
28
  }
20
29
 
21
30
  // CompositeBackend strips the route prefix (/uploads/) before delegating,
@@ -26,12 +35,13 @@ export class UploadsBackend {
26
35
  }
27
36
 
28
37
  async read(filePath, offset = 0, limit = 100) {
38
+ const { contextId, userId } = this._resolveContext()
29
39
  const name = this._name(filePath)
30
- const file = await this.fileStore.getInputFile(this.contextId, name, this.userId)
40
+ const file = await this.fileStore.getInputFile(contextId, name, userId)
31
41
 
32
42
  if (!file) {
33
43
  const available =
34
- (await this.fileStore.listInputFiles(this.contextId, this.userId))
44
+ (await this.fileStore.listInputFiles(contextId, userId))
35
45
  .map((f) => `/uploads/${f.name}`)
36
46
  .join(", ") || "none"
37
47
  return { error: `File not found: ${filePath}. Available: ${available}` }
@@ -58,8 +68,9 @@ export class UploadsBackend {
58
68
  // throws `TypeError: backend.readRaw is not a function`. Currently unused
59
69
  // by our shipped code paths — do not delete as "dead".
60
70
  async readRaw(filePath) {
71
+ const { contextId, userId } = this._resolveContext()
61
72
  const name = this._name(filePath)
62
- const file = await this.fileStore.getInputFile(this.contextId, name, this.userId)
73
+ const file = await this.fileStore.getInputFile(contextId, name, userId)
63
74
  if (!file) return { error: `File not found: ${filePath}` }
64
75
  return { bytes: file.bytes, mimeType: file.mimeType, size: file.size }
65
76
  }
@@ -67,12 +78,14 @@ export class UploadsBackend {
67
78
  // CompositeBackend re-prepends the route prefix to paths returned here,
68
79
  // so return bare "/<name>" paths (without /uploads/) to avoid double-prefix.
69
80
  async list(_dirPath) {
70
- const files = await this.fileStore.listInputFiles(this.contextId, this.userId)
81
+ const { contextId, userId } = this._resolveContext()
82
+ const files = await this.fileStore.listInputFiles(contextId, userId)
71
83
  return files.map((f) => `/${f.name}`)
72
84
  }
73
85
 
74
86
  async ls(_path) {
75
- const files = await this.fileStore.listInputFiles(this.contextId, this.userId)
87
+ const { contextId, userId } = this._resolveContext()
88
+ const files = await this.fileStore.listInputFiles(contextId, userId)
76
89
  return {
77
90
  files: files.map((f) => ({
78
91
  path: `/${f.name}`,
@@ -84,8 +97,9 @@ export class UploadsBackend {
84
97
  }
85
98
 
86
99
  async glob(pattern, _path) {
100
+ const { contextId, userId } = this._resolveContext()
87
101
  const re = globToRegex(pattern)
88
- const files = await this.fileStore.listInputFiles(this.contextId, this.userId)
102
+ const files = await this.fileStore.listInputFiles(contextId, userId)
89
103
  return {
90
104
  files: files
91
105
  .filter((f) => re.test(f.name))
@@ -94,13 +108,14 @@ export class UploadsBackend {
94
108
  }
95
109
 
96
110
  async grep(pattern, _path, glob) {
111
+ const { contextId, userId } = this._resolveContext()
97
112
  const reGlob = glob ? globToRegex(glob) : null
98
- const files = await this.fileStore.listInputFiles(this.contextId, this.userId)
113
+ const files = await this.fileStore.listInputFiles(contextId, userId)
99
114
  const candidates = files.filter(
100
115
  (f) => (!reGlob || reGlob.test(f.name)) && isTextMime(f.mimeType),
101
116
  )
102
117
  const fetched = await Promise.all(
103
- candidates.map((f) => this.fileStore.getInputFile(this.contextId, f.name, this.userId)),
118
+ candidates.map((f) => this.fileStore.getInputFile(contextId, f.name, userId)),
104
119
  )
105
120
  const matches = []
106
121
  for (let i = 0; i < candidates.length; i++) {
@@ -121,14 +136,16 @@ export class UploadsBackend {
121
136
  }
122
137
 
123
138
  async exists(filePath) {
139
+ const { contextId, userId } = this._resolveContext()
124
140
  const name = this._name(filePath)
125
- const file = await this.fileStore.getInputFile(this.contextId, name, this.userId)
141
+ const file = await this.fileStore.getInputFile(contextId, name, userId)
126
142
  return !!file
127
143
  }
128
144
 
129
145
  async stat(filePath) {
146
+ const { contextId, userId } = this._resolveContext()
130
147
  const name = this._name(filePath)
131
- const file = await this.fileStore.getInputFile(this.contextId, name, this.userId)
148
+ const file = await this.fileStore.getInputFile(contextId, name, userId)
132
149
  if (!file) return null
133
150
  return { name: file.name, mimeType: file.mimeType, size: file.size }
134
151
  }
@@ -1,4 +1,5 @@
1
1
  import cds from "@sap/cds"
2
+ import { ReadonlyBackend } from "./backends/readonly-backend.js"
2
3
  const { fs, path } = cds.utils
3
4
 
4
5
  const LOG = cds.log("agents")
@@ -22,7 +23,6 @@ export async function createAutoDeepAgent(srv, agentDir) {
22
23
  createDeepAgent,
23
24
  StateBackend,
24
25
  CompositeBackend,
25
- FilesystemBackend,
26
26
  createMemoryMiddleware,
27
27
  createSkillsMiddleware,
28
28
  } = await import("deepagents")
@@ -33,37 +33,20 @@ export async function createAutoDeepAgent(srv, agentDir) {
33
33
  tools: tools.length,
34
34
  })
35
35
 
36
- let backend
36
+ const additionalBackends = {}
37
+
37
38
  if (cds.env.agents?.fileIO?.enabled) {
38
39
  const { CdsFileStore } = await import("../../protocol/persistence/file-store.js")
39
40
  const { UploadsBackend } = await import("./backends/uploads-backend.js")
40
41
  const { OutputsBackend } = await import("./backends/outputs-backend.js")
41
42
  const fileStore = new CdsFileStore()
42
- backend = (runtime) => {
43
- const rawThreadId = runtime?.configurable?.thread_id || ""
44
- const contextId = rawThreadId.includes(":")
45
- ? rawThreadId.split(":").slice(1).join(":")
46
- : rawThreadId
47
- const taskId = runtime?.configurable?._taskId || ""
48
- // GraphExecutor threads the request-entry user id as `_userId` so the
49
- // backend keeps user isolation even if cds.context drifts inside the
50
- // agent's tool callbacks. Falls back to cds.context for compatibility.
51
- const userId = runtime?.configurable?._userId
52
- return new CompositeBackend(
53
- new StateBackend(),
54
- {
55
- "/uploads/": new UploadsBackend(contextId, fileStore, userId),
56
- "/outputs/": new OutputsBackend(taskId, fileStore),
57
- },
58
- // Fallback to StateBackend for any other paths so deepagents'
59
- // built-in tools that read state still work.
60
- )
61
- }
62
- } else {
63
- backend = new StateBackend()
43
+ Object.assign(additionalBackends, {
44
+ "/uploads/": new UploadsBackend(fileStore),
45
+ "/outputs/": new OutputsBackend(fileStore),
46
+ })
64
47
  }
65
48
 
66
- const agentFsBackend = new FilesystemBackend({ rootDir: agentDir })
49
+ const agentFsBackend = new ReadonlyBackend({ rootDir: agentDir, virtualMode: true })
67
50
  const agentDirMiddleware = []
68
51
  if (fs.existsSync(path.join(agentDir, "AGENTS.md"))) {
69
52
  agentDirMiddleware.push(
@@ -74,14 +57,20 @@ export async function createAutoDeepAgent(srv, agentDir) {
74
57
  )
75
58
  }
76
59
  if (fs.existsSync(path.join(agentDir, "skills"))) {
60
+ additionalBackends["./skills/"] = additionalBackends["/skills/"] = new ReadonlyBackend({
61
+ rootDir: path.join(agentDir, "skills"),
62
+ virtualMode: true,
63
+ })
77
64
  agentDirMiddleware.push(
78
65
  createSkillsMiddleware({
79
66
  backend: agentFsBackend,
80
- sources: ["./skills/"],
67
+ sources: ["./skills"],
81
68
  }),
82
69
  )
83
70
  }
84
71
 
72
+ const backend = new CompositeBackend(new StateBackend(), additionalBackends)
73
+
85
74
  return createDeepAgent({
86
75
  model,
87
76
  tools,
@@ -2,7 +2,7 @@ import cds from "@sap/cds"
2
2
  import { createMiddleware } from "langchain"
3
3
  import { AIMessage } from "@langchain/core/messages"
4
4
  import { OrchestrationClient } from "@sap-ai-sdk/langchain"
5
- import { timeout, retry } from "@sap-cloud-sdk/resilience"
5
+ import { timeout, retry } from "../../utils/resilience.js"
6
6
  import { toSdkFilterFormat } from "../../../lib/models/aicore.js"
7
7
  import { audit } from "../../utils/utils.js"
8
8
  import { INSTRUMENTED } from "../../telemetry/tracing.js"
@@ -136,9 +136,10 @@ export async function contentFilterMiddleware(model) {
136
136
  }
137
137
  } else {
138
138
  // Fail closed: unrecognized errors (timeout, 5xx, network) block the message
139
- LOG.warn("Content filter unavailable — blocking input", {
139
+ LOG.error("Content filter unavailable — blocking input", {
140
140
  error: err.message,
141
141
  status,
142
+ data,
142
143
  })
143
144
  if (hasHumanMessage) {
144
145
  return {
@@ -7,7 +7,17 @@ export default async function buildMiddleware(srv, options = {}) {
7
7
  const { statusUpdateMiddleware } = await import("./status-update.js")
8
8
  const { humanInTheLoopMiddleware } = await import("./hitl.js")
9
9
  const { toolSelectionMiddleware } = await import("./tool-selection.js")
10
+ const { toolWrapMiddleware } = await import("./tool-wrap.js")
11
+
12
+ const hasDynamicMcp = tools?.some((t) => t._mcpDynamic) ?? false
13
+ let dynamicMcpMiddlewares = []
14
+ if (hasDynamicMcp) {
15
+ const { remoteMcpMiddleware } = await import("./remote-mcp.js")
16
+ dynamicMcpMiddlewares = [remoteMcpMiddleware()]
17
+ }
18
+
10
19
  return [
20
+ ...dynamicMcpMiddlewares,
11
21
  ...(await quotaEnforcerMiddleware()),
12
22
  await contentFilterMiddleware(model),
13
23
  await agentActionsMiddleware(),
@@ -15,5 +25,6 @@ export default async function buildMiddleware(srv, options = {}) {
15
25
  await statusUpdateMiddleware(),
16
26
  ...(await humanInTheLoopMiddleware(srv, tools)),
17
27
  toolSelectionMiddleware(),
28
+ toolWrapMiddleware(),
18
29
  ].filter(Boolean)
19
30
  }
@@ -0,0 +1,62 @@
1
+ import cds from "@sap/cds"
2
+ import { createMiddleware } from "langchain"
3
+ import { ToolMessage } from "@langchain/core/messages"
4
+ import { MultiServerMCPClient } from "@langchain/mcp-adapters"
5
+ import { toolName } from "../../utils/utils.js"
6
+
7
+ const LOG = cds.log("agents:mcp")
8
+
9
+ /**
10
+ * Middleware that resolves remote MCP tools per-request using the current user's auth headers.
11
+ * Tools are cached on cds.context.__mcpDynamicTools keyed by mcpUrl for the lifetime of the
12
+ * request so multi-turn ReAct loops don't issue a new tools/list call on every model invocation.
13
+ */
14
+ export function remoteMcpMiddleware() {
15
+ return createMiddleware({
16
+ name: "RemoteMcpMiddleware",
17
+
18
+ wrapModelCall: async (request, handler) => {
19
+ const cache = (cds.context.__mcpDynamicTools ??= {})
20
+ const placeholders = (request.tools ?? []).filter((t) => t._mcpDynamic)
21
+ await Promise.all(
22
+ placeholders.map(async ({ mcpUrl, serviceName, resolveHeaders }) => {
23
+ if (cache[mcpUrl]) return
24
+ const headers = await resolveHeaders()
25
+ const client = new MultiServerMCPClient({
26
+ mcpServers: { default: { transport: "http", url: mcpUrl, headers } },
27
+ })
28
+ const raw = await client.getTools()
29
+ for (const t of raw) t.name = toolName(`${serviceName}_${t.name}`)
30
+ cache[mcpUrl] = raw
31
+ LOG.debug(
32
+ `Got ${raw.length} MCP tools from ${mcpUrl}: ${raw.map((t) => t.name).join(", ")}`,
33
+ )
34
+ }),
35
+ )
36
+ const resolved = placeholders.flatMap(({ mcpUrl }) => cache[mcpUrl] ?? [])
37
+ const staticTools = (request.tools ?? []).filter((t) => !t._mcpDynamic)
38
+ return handler({ ...request, tools: [...staticTools, ...resolved] })
39
+ },
40
+
41
+ wrapToolCall: async (request, handler) => {
42
+ const allCached = Object.values(cds.context.__mcpDynamicTools ?? {}).flat()
43
+ const tool = allCached.find((t) => t.name === request.toolCall.name)
44
+ if (!tool) return handler(request)
45
+ try {
46
+ const output = await tool.invoke(request.toolCall.args)
47
+ return new ToolMessage({
48
+ name: request.toolCall.name,
49
+ content: typeof output === "string" ? output : JSON.stringify(output),
50
+ tool_call_id: request.toolCall.id,
51
+ })
52
+ } catch (err) {
53
+ LOG.warn(`MCP tool "${request.toolCall.name}" error: ${err.message}`)
54
+ return new ToolMessage({
55
+ name: request.toolCall.name,
56
+ content: `Error: ${err.message}`,
57
+ tool_call_id: request.toolCall.id,
58
+ })
59
+ }
60
+ },
61
+ })
62
+ }
@@ -0,0 +1,52 @@
1
+ import { createMiddleware } from "langchain"
2
+ import { ToolMessage } from "@langchain/core/messages"
3
+ import { isGraphInterrupt } from "@langchain/langgraph"
4
+ import cds from "@sap/cds"
5
+
6
+ const LOG = cds.log("agents")
7
+
8
+ /**
9
+ * Converts tool errors into error ToolMessages so the LLM can retry.
10
+ * Handles two paths: thrown errors (err.details appended when present)
11
+ * and tools returning artifact.isError=true (@cap-js/mcp action pattern).
12
+ */
13
+ export function toolWrapMiddleware() {
14
+ return createMiddleware({
15
+ name: "ToolWrapMiddleware",
16
+ wrapToolCall: async (request, handler) => {
17
+ const { name, id, args } = request.toolCall
18
+ try {
19
+ LOG.debug("[tool]", name, args)
20
+ const result = await handler(request)
21
+ if (ToolMessage.isInstance(result) && result.artifact?.isError === true) {
22
+ result.status = "error"
23
+ }
24
+ if (
25
+ ToolMessage.isInstance(result) &&
26
+ result.status !== "error" &&
27
+ typeof result.content === "string" &&
28
+ result.content.startsWith("Error:")
29
+ ) {
30
+ result.status = "error"
31
+ }
32
+ if (result?.status === "error") LOG.debug("[tool] error", name, result.content)
33
+ else LOG.debug("[tool] completed", name)
34
+ return result
35
+ } catch (err) {
36
+ if (isGraphInterrupt(err)) throw err
37
+ LOG.debug("[tool] error", name, err)
38
+ let content = `Error: ${err.message}`
39
+ if (Array.isArray(err.details) && err.details.length > 0) {
40
+ const lines = err.details.map((d) => `- ${d.message}`).join("\n")
41
+ content += `\n${lines}`
42
+ }
43
+ return new ToolMessage({
44
+ content,
45
+ tool_call_id: id ?? "",
46
+ name,
47
+ status: "error",
48
+ })
49
+ }
50
+ },
51
+ })
52
+ }
package/lib/compile.js CHANGED
@@ -1,7 +1,11 @@
1
1
  import cds from "@sap/cds"
2
- import { getDescription, getFilteredEntities, getFilteredActions } from "./utils/utils.js"
2
+ import {
3
+ getDescription,
4
+ getFilteredEntities,
5
+ getFilteredActions,
6
+ slugified,
7
+ } from "./utils/utils.js"
3
8
  import { buildAgentCard } from "./protocol/agent-card.js"
4
- import { slugified } from "./utils/markdown.js"
5
9
 
6
10
  const A2A_BASE_PATH = "/a2a"
7
11