@cap-js/agents 0.9.6 → 0.9.8

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 (83) hide show
  1. package/README.md +2 -131
  2. package/_i18n/messages.properties +2 -0
  3. package/_i18n/messages_ar.properties +48 -0
  4. package/_i18n/messages_bg.properties +48 -0
  5. package/_i18n/messages_cs.properties +48 -0
  6. package/_i18n/messages_da.properties +48 -0
  7. package/_i18n/messages_de.properties +48 -0
  8. package/_i18n/messages_el.properties +48 -0
  9. package/_i18n/messages_en.properties +48 -0
  10. package/_i18n/messages_es.properties +48 -0
  11. package/_i18n/messages_es_MX.properties +48 -0
  12. package/_i18n/messages_fi.properties +48 -0
  13. package/_i18n/messages_fr.properties +48 -0
  14. package/_i18n/messages_he.properties +48 -0
  15. package/_i18n/messages_hr.properties +48 -0
  16. package/_i18n/messages_hu.properties +48 -0
  17. package/_i18n/messages_it.properties +48 -0
  18. package/_i18n/messages_ja.properties +48 -0
  19. package/_i18n/messages_kk.properties +48 -0
  20. package/_i18n/messages_ko.properties +48 -0
  21. package/_i18n/messages_ms.properties +48 -0
  22. package/_i18n/messages_nl.properties +48 -0
  23. package/_i18n/messages_no.properties +48 -0
  24. package/_i18n/messages_pl.properties +48 -0
  25. package/_i18n/messages_pt.properties +48 -0
  26. package/_i18n/messages_ro.properties +48 -0
  27. package/_i18n/messages_ru.properties +48 -0
  28. package/_i18n/messages_sh.properties +48 -0
  29. package/_i18n/messages_sk.properties +48 -0
  30. package/_i18n/messages_sl.properties +48 -0
  31. package/_i18n/messages_sv.properties +48 -0
  32. package/_i18n/messages_th.properties +48 -0
  33. package/_i18n/messages_tr.properties +48 -0
  34. package/_i18n/messages_uk.properties +48 -0
  35. package/_i18n/messages_vi.properties +48 -0
  36. package/_i18n/messages_zh_CN.properties +48 -0
  37. package/_i18n/messages_zh_TW.properties +48 -0
  38. package/cds-plugin.js +15 -10
  39. package/lib/agents/middleware/content-filter.js +4 -0
  40. package/lib/agents/middleware/hitl-decision-note-injector.js +18 -6
  41. package/lib/agents/middleware/hitl.js +17 -6
  42. package/lib/agents/middleware/index.js +3 -1
  43. package/lib/agents/middleware/masking.js +127 -0
  44. package/lib/agents/middleware/remote-mcp.js +10 -5
  45. package/lib/agents/middleware/status-update.js +209 -76
  46. package/lib/agents/middleware/tool-wrap.js +1 -2
  47. package/lib/agents/summarize-on-timeout.js +5 -2
  48. package/lib/config/local.js +121 -8
  49. package/lib/eval/eval-run.js +50 -4
  50. package/lib/masking/index.js +90 -0
  51. package/lib/masking/store.js +80 -0
  52. package/lib/masking/structured/findElements.js +459 -0
  53. package/lib/masking/structured/index.js +170 -0
  54. package/lib/masking/unstructured/dpi.js +84 -0
  55. package/lib/masking/unstructured/hana.js +72 -0
  56. package/lib/masking/unstructured/index.js +52 -0
  57. package/lib/models/aicore.js +16 -3
  58. package/lib/models/openai.js +9 -0
  59. package/lib/preview/chat.html +623 -119
  60. package/lib/protocol/persistence/cleanup.js +13 -4
  61. package/lib/telemetry/chat-tracing.js +9 -2
  62. package/lib/telemetry/mlflow/evaluation.js +83 -4
  63. package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +39 -5
  64. package/lib/telemetry/mlflow/exporter/MlflowExporter.js +43 -4
  65. package/lib/telemetry/mlflow/index.js +1 -7
  66. package/lib/telemetry/mlflow/prompts.js +15 -11
  67. package/lib/telemetry/mlflow/tracing.js +3 -3
  68. package/lib/telemetry/span-masking.js +165 -0
  69. package/lib/telemetry/tool-tracing.js +4 -4
  70. package/lib/utils/markdown.js +3 -10
  71. package/lib/utils/resilience.js +2 -1
  72. package/lib/utils/toml.js +67 -0
  73. package/lib/utils/usage.js +34 -0
  74. package/lib/utils/utils.js +43 -1
  75. package/package.json +33 -26
  76. package/srv/handlers/graph-executor/crash-handler.js +47 -0
  77. package/srv/handlers/graph-executor/hitl.js +27 -0
  78. package/srv/handlers/graph-executor.js +69 -20
  79. package/srv/handlers/index.js +6 -1
  80. package/srv/handlers/mcp-tools.js +11 -2
  81. package/srv/handlers/subagent-tools.js +13 -4
  82. package/srv/handlers/system-prompt.js +30 -20
  83. package/srv/handlers/tools.js +14 -13
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Minimal TOML parser supporting:
3
+ * - bare keys and quoted keys
4
+ * - string values (single- and double-quoted)
5
+ * - boolean values
6
+ * - standard tables ([section]) and dotted tables ([a.b])
7
+ * - comments (#)
8
+ */
9
+
10
+ export const toml = {
11
+ parse(src) {
12
+ const lines = src.split(/\r?\n/)
13
+ const root = {}
14
+ let current = root
15
+
16
+ for (let raw of lines) {
17
+ const line = raw.replace(/#.*$/, "").trim()
18
+ if (!line) continue
19
+
20
+ // table header
21
+ const tableMatch = line.match(/^\[([^\]]+)\]$/)
22
+ if (tableMatch) {
23
+ current = tableMatch[1]
24
+ .trim()
25
+ .split(".")
26
+ .reduce((obj, key) => {
27
+ key = key.trim()
28
+ if (!obj[key] || typeof obj[key] !== "object") obj[key] = {}
29
+ return obj[key]
30
+ }, root)
31
+ continue
32
+ }
33
+
34
+ // key = value
35
+ const kvMatch = line.match(/^([A-Za-z0-9_\-."']+)\s*=\s*(.+)$/)
36
+ if (!kvMatch) continue
37
+ const key = kvMatch[1].replace(/^['"]|['"]$/g, "")
38
+ const raw_val = kvMatch[2].trim()
39
+
40
+ let value
41
+ if (/^'''[\s\S]*'''$/.test(raw_val) || /^"""[\s\S]*"""$/.test(raw_val)) {
42
+ value = raw_val.slice(3, -3)
43
+ } else if (/^'[^']*'$/.test(raw_val)) {
44
+ value = raw_val.slice(1, -1)
45
+ } else if (/^"[^"]*"$/.test(raw_val)) {
46
+ value = raw_val
47
+ .slice(1, -1)
48
+ .replace(/\\n/g, "\n")
49
+ .replace(/\\t/g, "\t")
50
+ .replace(/\\"/g, '"')
51
+ .replace(/\\\\/g, "\\")
52
+ } else if (raw_val === "true") {
53
+ value = true
54
+ } else if (raw_val === "false") {
55
+ value = false
56
+ } else if (!isNaN(Number(raw_val))) {
57
+ value = Number(raw_val)
58
+ } else {
59
+ value = raw_val
60
+ }
61
+
62
+ current[key] = value
63
+ }
64
+
65
+ return root
66
+ },
67
+ }
@@ -0,0 +1,34 @@
1
+ import cds from "@sap/cds"
2
+ const NORMALIZED = Symbol.for("@cap-js/agents:additive-cache-usage-normalized")
3
+
4
+ export function isAdditiveCacheUsageModel(model) {
5
+ const name = String(model || "")
6
+ return /anthropic|claude/i.test(name) || /^amazon--/i.test(name)
7
+ }
8
+
9
+ export function normalizeAdditiveCacheUsage(result) {
10
+ for (const message of result?.generations?.map((g) => g.message) ?? []) {
11
+ normalizeAdditiveCacheUsageMetadata(message?.usage_metadata)
12
+ }
13
+ return result
14
+ }
15
+
16
+ export function normalizeAdditiveCacheUsageChunk(chunk) {
17
+ // Avoid adjusting usage in chunks when streaming is disabled, because normalizeAdditiveCacheUsage runs as well
18
+ if (!cds.env.agents.streaming) return chunk
19
+ normalizeAdditiveCacheUsageMetadata(chunk?.message?.usage_metadata)
20
+ return chunk
21
+ }
22
+
23
+ function normalizeAdditiveCacheUsageMetadata(usage) {
24
+ if (!usage || usage[NORMALIZED]) return
25
+ const cache =
26
+ (usage.input_token_details?.cache_read ?? 0) + (usage.input_token_details?.cache_creation ?? 0)
27
+ if (!cache) return
28
+
29
+ usage.input_tokens += cache
30
+ if (usage.total_tokens != null && usage.output_tokens != null) {
31
+ usage.total_tokens = usage.input_tokens + usage.output_tokens
32
+ }
33
+ Object.defineProperty(usage, NORMALIZED, { value: true })
34
+ }
@@ -15,6 +15,38 @@ export const slugified = (name) =>
15
15
  .replace(/([a-z0-9])([A-Z])/g, (_m, c, C) => c + "-" + C)
16
16
  .toLowerCase()
17
17
 
18
+ export function effectiveDefinition(srv) {
19
+ return cds.context?.model?.definitions?.[srv.name] || srv?.definition
20
+ }
21
+
22
+ /**
23
+ * Resolve an explicitly declared label for a CDS service (i18n label /
24
+ * @Common.Label / @title) — NOT the description or doc comment. Returns undefined
25
+ * when no label is declared, so callers can choose their own fallback.
26
+ */
27
+ export function declaredServiceLabel(serviceName, locale) {
28
+ if (!serviceName) return undefined
29
+ locale = locale || cds.context?.locale || "en"
30
+ const model = cds.context?.model ?? cds.model
31
+ const def = model?.definitions?.[serviceName]
32
+ if (!def) return undefined
33
+ return (
34
+ cds.i18n?.labels?.at(def, locale) ||
35
+ resolveI18n(def["@Common.Label"], locale) ||
36
+ resolveI18n(def["@title"], locale) ||
37
+ undefined
38
+ )
39
+ }
40
+
41
+ /**
42
+ * Resolve a display label for a CDS service. Uses the declared label when
43
+ * available, otherwise returns the real (fully-qualified) service name unchanged.
44
+ */
45
+ export function serviceLabel(serviceName, locale) {
46
+ if (!serviceName) return undefined
47
+ return declaredServiceLabel(serviceName, locale) || serviceName
48
+ }
49
+
18
50
  export function getDescription(obj, locale) {
19
51
  locale = locale || cds.context?.locale || "en"
20
52
 
@@ -40,6 +72,16 @@ export function getDescription(obj, locale) {
40
72
  return result || undefined
41
73
  }
42
74
 
75
+ /**
76
+ * Classify an (unprefixed) MCP tool name into a status-update kind:
77
+ * "query" (read), "describe" (model introspection), or "action" (everything else).
78
+ */
79
+ export function mcpToolKind(rawName) {
80
+ if (rawName === "query" || rawName.endsWith("_query")) return "query"
81
+ if (rawName === "describe" || rawName.endsWith("_describe")) return "describe"
82
+ return "call"
83
+ }
84
+
43
85
  /**
44
86
  * Sanitize an agent name into a valid LangChain/LLM tool name.
45
87
  */
@@ -67,7 +109,7 @@ export function short(id) {
67
109
  * Never blocks execution. Logs warning on failure.
68
110
  */
69
111
  export function audit(event, data) {
70
- if (!cds.requires["audit-log"]) return
112
+ if (!cds.env.requires["audit-log"]) return
71
113
  cds.connect
72
114
  .to("audit-log")
73
115
  .then((a) =>
package/package.json CHANGED
@@ -1,14 +1,34 @@
1
1
  {
2
2
  "name": "@cap-js/agents",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "description": "CDS plugin for building agents",
5
- "author": "SAP SE (https://www.sap.com)",
6
- "license": "Apache-2.0",
7
5
  "homepage": "https://cap.cloud.sap/",
6
+ "license": "Apache-2.0",
7
+ "author": "SAP SE (https://www.sap.com)",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/cap-js/agents.git"
11
11
  },
12
+ "workspaces": [
13
+ "tests/projects/bookshop/",
14
+ "tests/projects/deep-agent/",
15
+ "tests/projects/mtx/mtx/sidecar/",
16
+ "tests/projects/telemetry-debug/",
17
+ "tests/projects/telemetry-v1/",
18
+ "tests/projects/travel/travel-agent/",
19
+ "tests/projects/travel/xflights/",
20
+ "tests/projects/travel/leisure-services/",
21
+ "tests/sidecar-projects/java-bookshop/agent/sidecar",
22
+ "tests/sidecar-projects/java-bookshop",
23
+ "tests/sidecar-projects/node-bookshop/agent/sidecar",
24
+ "tests/sidecar-projects/node-bookshop"
25
+ ],
26
+ "files": [
27
+ "cds-plugin.js",
28
+ "lib",
29
+ "srv",
30
+ "_i18n"
31
+ ],
12
32
  "type": "module",
13
33
  "exports": {
14
34
  "./package.json": "./package.json",
@@ -30,15 +50,9 @@
30
50
  "watch:deep-agent": "cds w tests/projects/deep-agent --profile hybrid",
31
51
  "docs:audit": "node .scripts/generate-audit-docs.js",
32
52
  "docs:audit:check": "node .scripts/generate-audit-docs.js --check",
33
- "prettier": "npx -y prettier@3 --write .",
34
- "prettier:check": "npx -y prettier@3 --check ."
53
+ "format": "npx -y oxfmt@0.70.0",
54
+ "format:check": "npx -y oxfmt@0.70.0 --check"
35
55
  },
36
- "files": [
37
- "cds-plugin.js",
38
- "lib",
39
- "srv",
40
- "_i18n"
41
- ],
42
56
  "dependencies": {
43
57
  "@a2a-js/sdk": "^0.3.12",
44
58
  "@cap-js/attachments": ">=3",
@@ -47,9 +61,11 @@
47
61
  "@langchain/core": "^1",
48
62
  "@langchain/langgraph": "^1",
49
63
  "@langchain/mcp-adapters": "^1.1.3",
64
+ "@langchain/openai": "^1.5.13",
50
65
  "@sap-ai-sdk/langchain": "^2.13.0",
51
66
  "@sap-ai-sdk/orchestration": "^2.11.0",
52
67
  "@sap-cloud-sdk/connectivity": "^4.7.0",
68
+ "@sap-cloud-sdk/http-client": "^4.9.1",
53
69
  "deepagents": "^1.10.0",
54
70
  "langchain": "^1.5.0",
55
71
  "marked": "^18",
@@ -60,6 +76,7 @@
60
76
  "@cap-js/sqlite": "^3",
61
77
  "@sap/cds-mtxs": "^4",
62
78
  "@toon-format/toon": ">=2.3",
79
+ "@vitest/coverage-v8": "^5.0.1",
63
80
  "acorn": "^8.18.0",
64
81
  "deepagents": "^1.10.2",
65
82
  "openevals": "^0.2.0"
@@ -91,6 +108,7 @@
91
108
  "mlflow": false,
92
109
  "connect": "auto",
93
110
  "retention": "30d",
111
+ "masking": false,
94
112
  "[development]": {
95
113
  "pushNotifications": {
96
114
  "allowedDomains": [
@@ -184,6 +202,9 @@
184
202
  "anthropic": {
185
203
  "impl": "@cap-js/agents/lib/models/anthropic"
186
204
  },
205
+ "openai": {
206
+ "impl": "@cap-js/agents/lib/models/openai"
207
+ },
187
208
  "llm-mock": {
188
209
  "impl": "@cap-js/agents/lib/models/mock"
189
210
  }
@@ -220,19 +241,5 @@
220
241
  "folders": {
221
242
  "srvs": "srv/*"
222
243
  }
223
- },
224
- "workspaces": [
225
- "tests/projects/bookshop/",
226
- "tests/projects/deep-agent/",
227
- "tests/projects/mtx/mtx/sidecar/",
228
- "tests/projects/telemetry-debug/",
229
- "tests/projects/telemetry-v1/",
230
- "tests/projects/travel/travel-agent/",
231
- "tests/projects/travel/xflights/",
232
- "tests/projects/travel/leisure-services/",
233
- "tests/sidecar-projects/java-bookshop/agent/sidecar",
234
- "tests/sidecar-projects/java-bookshop",
235
- "tests/sidecar-projects/node-bookshop/agent/sidecar",
236
- "tests/sidecar-projects/node-bookshop"
237
- ]
244
+ }
238
245
  }
@@ -0,0 +1,47 @@
1
+ import cds from "@sap/cds"
2
+
3
+ const LOG = cds.log("agents")
4
+
5
+ // REVISIT: Check if in the future tasks can be picked up again after restart
6
+ async function markActiveTasksFailed() {
7
+ const tasksByTenant = new Map()
8
+
9
+ for (const executor of registerShutdownHook.executors) {
10
+ for (const taskId of executor._abortControllers.keys()) {
11
+ const tenant = executor._taskTenants.get(taskId)
12
+ const taskIds = tasksByTenant.get(tenant) || []
13
+ taskIds.push(taskId)
14
+ tasksByTenant.set(tenant, taskIds)
15
+ }
16
+ }
17
+
18
+ await Promise.all(
19
+ [...tasksByTenant].map(async ([tenant, taskIds]) => {
20
+ const update = () =>
21
+ UPDATE("cap.agent.Tasks")
22
+ .where({
23
+ taskId: { in: [...new Set(taskIds)] },
24
+ state: { in: ["submitted", "working", "input-required"] },
25
+ })
26
+ .set({ state: "failed" })
27
+
28
+ if (tenant) return cds.spawn({ tenant, user: cds.User.privileged }, update)
29
+ return update()
30
+ }),
31
+ )
32
+ }
33
+
34
+ export function registerShutdownHook(executor) {
35
+ registerShutdownHook.executors.add(executor)
36
+ if (registerShutdownHook.executors.size > 1) return
37
+
38
+ cds.on("shutdown", async () => {
39
+ try {
40
+ await markActiveTasksFailed()
41
+ } catch (err) {
42
+ LOG.error("Failed to mark active tasks as failed during shutdown", { error: err.message })
43
+ }
44
+ })
45
+ }
46
+
47
+ registerShutdownHook.executors = new Set()
@@ -156,6 +156,10 @@ export function composeHitlDecisionNote(actionRequests, resume) {
156
156
  lines.push("- User edited " + action(matched) + " to " + action(decision.editedAction) + ".")
157
157
  continue
158
158
  }
159
+ if (decision?.type === "reject") {
160
+ lines.push("- User explicitly rejected " + action(original) + ".")
161
+ continue
162
+ }
159
163
  }
160
164
  if (!lines.length) {
161
165
  return undefined
@@ -163,6 +167,28 @@ export function composeHitlDecisionNote(actionRequests, resume) {
163
167
  return ["User HITL decisions (not tool failures):", ...lines].join("\n")
164
168
  }
165
169
 
170
+ // The generic combined "call" tool carries its target action in args.action. HITL
171
+ // gates it per-call via a `when` predicate, so the interrupt only fires for a gated
172
+ // action. An edit must therefore not repoint args.action at a *different* action —
173
+ // that would run an un-gated action under the approval granted for the gated one.
174
+ // Editing the parameters is fine; the action itself is fixed.
175
+ const GENERIC_CALL_TOOL = "call"
176
+
177
+ export function guardHitlEdits(resume, actions = []) {
178
+ if (!Array.isArray(resume?.decisions)) return resume
179
+ resume.decisions.forEach((decision, index) => {
180
+ if (decision?.type !== "edit") return
181
+ const original = actions[index]
182
+ if (original?.name !== GENERIC_CALL_TOOL) return
183
+ const from = original.args?.action
184
+ const to = decision.editedAction?.args?.action
185
+ if (to !== undefined && from !== undefined && to !== from) {
186
+ throw new Error(`HITL edit must not change the gated action (expected "${from}", got "${to}").`)
187
+ }
188
+ })
189
+ return resume
190
+ }
191
+
166
192
  async function getPreInterruptToolCalls(graph, config) {
167
193
  try {
168
194
  if (typeof graph.getState !== "function") return []
@@ -339,6 +365,7 @@ export async function resumeHitl({ requestContext, graph, config, eventBus, stre
339
365
  const originalActions = actionRequests.length
340
366
  ? actionRequests
341
367
  : await getPreInterruptToolCalls(graph, config)
368
+ guardHitlEdits(resume, originalActions)
342
369
  const decisionNote = composeHitlDecisionNote(originalActions, resume)
343
370
  const commandArgs = { resume }
344
371
  if (decisionNote) commandArgs.update = { _hitlDecisionNote: decisionNote }
@@ -6,6 +6,8 @@ import { mlflowAttrs, mlflowTraceAttrs, setSpanAttrs } from "../../lib/telemetry
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
+ import { resolvePseudonyms } from "../../lib/masking/index.js"
10
+ import { pseudonymizeUserMessage } from "../../lib/masking/unstructured/index.js"
9
11
  import { triggerCleanup } from "../../lib/protocol/persistence/cleanup.js"
10
12
  import { COLLECT_RESULT } from "./chat.js"
11
13
  import { linkTraceToPrompt } from "../../lib/telemetry/mlflow/tracing.js"
@@ -17,6 +19,7 @@ import {
17
19
  resumeHitl,
18
20
  resumeTimeoutHitl,
19
21
  } from "./graph-executor/hitl.js"
22
+ import { registerShutdownHook } from "./graph-executor/crash-handler.js"
20
23
 
21
24
  const LOG = cds.log("agents")
22
25
 
@@ -143,6 +146,9 @@ class GraphExecutor {
143
146
  this._recursionLimit = options.recursionLimit ?? null
144
147
  /** @type {Map<string, AbortController>} per-task abort controllers */
145
148
  this._abortControllers = new Map()
149
+ /** @type {Map<string, string | undefined>} task tenant by task ID */
150
+ this._taskTenants = new Map()
151
+ registerShutdownHook(this)
146
152
  }
147
153
 
148
154
  /**
@@ -197,6 +203,10 @@ class GraphExecutor {
197
203
  // final visual (collapse to the last turn's bubble at task completion).
198
204
  let currentMsgId = null
199
205
  let thinkingCount = 0
206
+ let thinkingMsgId = null
207
+ // Holds a trailing fragment of the previous chunk that is a prefix of a known
208
+ // pseudonym hash. Prepended to the next chunk so split hashes are resolved correctly.
209
+ let pendingPrefix = ""
200
210
 
201
211
  try {
202
212
  if (typeof graph.stream !== "function" || cds.env.agents?.streaming === false) {
@@ -238,8 +248,26 @@ class GraphExecutor {
238
248
  const lastChunk =
239
249
  !!msgChunk.additional_kwargs?.intermediate_results?.llm?.choices[0].finish_reason
240
250
 
241
- const text = messageText(msgChunk?.content)
242
- if (!text) continue
251
+ const raw = pendingPrefix + (messageText(msgChunk?.content) ?? "")
252
+ pendingPrefix = ""
253
+ if (!raw) continue
254
+
255
+ if (thinkingMsgId !== null && currentMsgId !== thinkingMsgId) thinkingCount++
256
+ thinkingMsgId = currentMsgId
257
+
258
+ // Hashes look like name-8hexchars and never contain spaces.
259
+ // On non-last chunks, slice last token and append to next chunk
260
+ // to avoid unresolved boundaries
261
+ let toEmit = raw
262
+ if (!lastChunk) {
263
+ const lastSpace = raw.lastIndexOf(" ")
264
+ if (lastSpace !== -1 && lastSpace < raw.length - 1) {
265
+ pendingPrefix = raw.slice(lastSpace + 1)
266
+ toEmit = raw.slice(0, lastSpace + 1)
267
+ }
268
+ }
269
+
270
+ const text = resolvePseudonyms(toEmit)
243
271
  // A2A TaskArtifactUpdateEvent: `append` and `lastChunk` are event-level
244
272
  // fields (siblings of `artifact`), NOT properties of `artifact`. The SDK's
245
273
  // ResultManager reads event.append; nesting them leaves it undefined and
@@ -255,7 +283,6 @@ class GraphExecutor {
255
283
  parts: [{ kind: "text", text }],
256
284
  },
257
285
  })
258
- if (lastChunk) thinkingCount++
259
286
  tokenCount++
260
287
  } else if (mode === "updates") {
261
288
  // The updates stream yields per-node deltas — { <node>: { messages: [oneNewMessage] } },
@@ -354,16 +381,16 @@ class GraphExecutor {
354
381
  */
355
382
  async _summarizePartialWork(taskId, contextId, serviceName, reason) {
356
383
  const { summarizePartialWork } = await import("../../lib/agents/summarize-on-timeout.js")
357
- return summarizePartialWork({
358
- taskId,
359
- contextId,
360
- serviceName,
361
- reason,
362
- checkpointer: this._graph?.checkpointer,
363
- getModel: () => this._srv.send("buildModel"),
364
- // Summary runs after graph abort, so no execution-time grace is needed.
365
- timeout: 10_000,
366
- })
384
+ return resolvePseudonyms(
385
+ await summarizePartialWork({
386
+ taskId,
387
+ contextId,
388
+ serviceName,
389
+ reason,
390
+ checkpointer: this._graph?.checkpointer,
391
+ getModel: () => this._srv.send("buildModel"),
392
+ }),
393
+ )
367
394
  }
368
395
 
369
396
  async execute(requestContext, eventBus) {
@@ -375,6 +402,7 @@ class GraphExecutor {
375
402
  // Cooperative cancellation: per-task AbortController
376
403
  const controller = new AbortController()
377
404
  this._abortControllers.set(taskId, controller)
405
+ this._taskTenants.set(taskId, cds.context?.tenant)
378
406
 
379
407
  // A2A context for tracing
380
408
  if (!cds.context) {
@@ -384,6 +412,18 @@ class GraphExecutor {
384
412
  cds.context["agent.context.id"] = contextId
385
413
  cds.context["agent.service"] = serviceName
386
414
  cds.context["agent.eventBus"] = eventBus
415
+ cds.context["agent.request.metadata"] = requestContext.userMessage?.metadata ?? {}
416
+
417
+ // REVISIT: Resolve graph early for pseudonymizeUserMessage. Mid-term move into beforeAgent together with Audit & Telemetry which rely on it
418
+ const graph = await this._resolveGraph()
419
+ if (cds.env.agents.masking) {
420
+ await pseudonymizeUserMessage(
421
+ this._srv,
422
+ requestContext,
423
+ graph.checkpointer,
424
+ `${serviceName}:${contextId}`,
425
+ )
426
+ }
387
427
 
388
428
  metrics.concurrentExecutions.add(1, mAttrs)
389
429
 
@@ -533,8 +573,6 @@ class GraphExecutor {
533
573
  let usageData
534
574
  let result
535
575
  try {
536
- const graph = await this._resolveGraph()
537
-
538
576
  const extraConfig = this._configMapper ? await this._configMapper(requestContext) : {}
539
577
  if (extraConfig !== null && extraConfig !== undefined && typeof extraConfig !== "object") {
540
578
  throw new TypeError(`configMapper must return a plain object, got ${typeof extraConfig}`)
@@ -611,7 +649,7 @@ class GraphExecutor {
611
649
  }
612
650
 
613
651
  const outputMapper = this._outputMapper || defaultOutputMapper
614
- const output = outputMapper(result) || "I could not generate a response."
652
+ const output = resolvePseudonyms(outputMapper(result)) || "I could not generate a response."
615
653
 
616
654
  LOG.info(serviceName, "completed", { conversation: short(contextId), duration })
617
655
 
@@ -620,7 +658,9 @@ class GraphExecutor {
620
658
  setSpanAttrs(
621
659
  wfSpan,
622
660
  mlflowAttrs("AGENT", {
623
- outputs: { choices: [{ message: { role: "assistant", content: output } }] },
661
+ outputs: {
662
+ choices: [{ message: { role: "assistant", content: output } }],
663
+ },
624
664
  functionName: serviceName,
625
665
  }),
626
666
  )
@@ -630,7 +670,9 @@ class GraphExecutor {
630
670
  setSpanAttrs(
631
671
  rootSpan,
632
672
  mlflowAttrs("CHAIN", {
633
- outputs: { choices: [{ message: { role: "assistant", content: output } }] },
673
+ outputs: {
674
+ choices: [{ message: { role: "assistant", content: output } }],
675
+ },
634
676
  }),
635
677
  )
636
678
  }
@@ -875,16 +917,19 @@ class GraphExecutor {
875
917
  eventBus._graphResult = { messages: result.messages || [] }
876
918
  }
877
919
 
920
+ const usageMeta =
921
+ usageData?.total_tokens > 0 ? { "sap.cds.agents.token-usage": usageData } : undefined
878
922
  eventBus.publish({
879
923
  kind: "status-update",
880
924
  taskId,
881
925
  contextId,
882
926
  status: {
883
927
  state: "completed",
884
- message: agentMessage(output),
928
+ message: agentMessage(output, undefined, usageMeta),
885
929
  timestamp: new Date().toISOString(),
886
930
  },
887
931
  final: true,
932
+ metadata: usageMeta,
888
933
  })
889
934
  } catch (err) {
890
935
  // Aborted (client disconnect or tasks/cancel) — publish canceled, not failed
@@ -1014,6 +1059,7 @@ class GraphExecutor {
1014
1059
  setSpanAttrs(rootSpan, linkTraceToPrompt())
1015
1060
 
1016
1061
  this._abortControllers.delete(taskId)
1062
+ this._taskTenants.delete(taskId)
1017
1063
  metrics.concurrentExecutions.add(-1, mAttrs)
1018
1064
 
1019
1065
  // Update task record with usage data (non-blocking, best effort)
@@ -1111,13 +1157,16 @@ function aggregateUsageData(messages) {
1111
1157
  cache_creation_input_tokens: 0,
1112
1158
  cache_read_input_tokens: 0,
1113
1159
  reasoning_tokens: 0,
1160
+ context_tokens: 0,
1114
1161
  }
1115
1162
  for (let i = 0; i < messages.length; i++) {
1116
1163
  if (!messages[i].usage_metadata) continue
1117
1164
  const innerRes = convertUsageData(messages[i].usage_metadata)
1118
1165
  Object.keys(innerRes).forEach((k) => {
1119
- if (innerRes[k] != null) result[k] += innerRes[k]
1166
+ if (k in result && innerRes[k] != null) result[k] += innerRes[k]
1120
1167
  })
1168
+ if (innerRes.input_tokens != null)
1169
+ result.context_tokens = innerRes.input_tokens + (innerRes.output_tokens || 0)
1121
1170
  }
1122
1171
  return result
1123
1172
  }
@@ -5,6 +5,7 @@ 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
7
  import { registerChat } from "./chat.js"
8
+ import { effectiveDefinition } from "../../lib/utils/utils.js"
8
9
 
9
10
  const LOG = cds.log("agents")
10
11
 
@@ -91,12 +92,16 @@ export default function registerDefaultAgentHandlers(srv) {
91
92
 
92
93
  // Default buildModel: cds.connect.to('llm'), configurable via @agent.llm
93
94
  srv.on("buildModel", async (req) => {
94
- const name = srv?.options?.agent?.llm || srv?.definition?.["@agent.llm"] || "llm"
95
+ const def = effectiveDefinition(srv)
96
+ const name = def?.["@agent.llm"] || srv?.options?.agent?.llm || "llm"
95
97
  const options = cds.requires[name] ?? {}
96
98
  let { kind, impl } = options
97
99
  if (!impl) impl = cds.requires.kinds[kind]?.impl
98
100
  if (!impl) throw new Error("No service implementation found for " + name)
99
101
  const { default: LLMProvider } = await import(impl)
102
+ const { credentials, ...o } = options
103
+ if (credentials) o.credentials = '{ *** }'
104
+ LOG.debug (`Creating LLMProvider instance for cds.requires.${name} with options:`, o)
100
105
  return new LLMProvider(name, { ...options, ...req.data })
101
106
  })
102
107
 
@@ -1,6 +1,6 @@
1
1
  import cds from "@sap/cds"
2
2
  import { generateTools } from "./tools.js"
3
- import { toolName } from "../../lib/utils/utils.js"
3
+ import { toolName, mcpToolKind } from "../../lib/utils/utils.js"
4
4
 
5
5
  const LOG = cds.log("agents:mcp")
6
6
 
@@ -45,7 +45,16 @@ export async function buildMcpToolsLocally(serviceName) {
45
45
  const srv = cds.services[serviceName]
46
46
  const tools = generateTools(srv)
47
47
  const prefix = toolName(`${serviceName}_`)
48
- for (const tool of tools) tool.name = `${prefix}${tool.name}`
48
+ for (const tool of tools) {
49
+ // Keep PerActionTool's actionName so status-update can resolve the action's label.
50
+ tool.metadata = {
51
+ ...tool.metadata,
52
+ serviceName,
53
+ kind: mcpToolKind(tool.name),
54
+ ...(tool.actionName && { actionName: tool.actionName }),
55
+ }
56
+ tool.name = `${prefix}${tool.name}`
57
+ }
49
58
 
50
59
  LOG.debug(
51
60
  `Got ${tools.length} MCP tools from ${serviceName}: ${tools.map((t) => t.name).join(", ")}`,