@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
@@ -49,10 +49,19 @@ export async function triggerCleanup(serviceName) {
49
49
  LOG.debug(`triggerCleanup: srv.schedule not available (CDS < 9). Skipping cleanup scheduling.`)
50
50
  return
51
51
  }
52
- serviceMap.set(serviceName, Date.now())
53
- const MAX_TIMEOUT = 2_147_483_647
54
- const delay = Math.min(ttlMs + MS_OF_A_DAY, MAX_TIMEOUT)
55
- await srv.schedule("cleanupTasks", {}).after(delay)
52
+ const now = Date.now()
53
+ serviceMap.set(serviceName, now)
54
+ const delay = ttlMs + MS_OF_A_DAY
55
+ const scheduled = srv.schedule("cleanupTasks", {})
56
+ const taskName = `cleanupTasks-${srv.name}-${new Date().toDateString().substring(0, 10)}`
57
+ // .as in cds10
58
+ if (typeof scheduled.as === "function") await scheduled.as(taskName).after(delay)
59
+ // .asTask in cds9.9
60
+ else if (typeof scheduled.asTask === "function") await scheduled.asTask(taskName).after(delay)
61
+ // cds9 < 9.9 has no task naming API
62
+ else {
63
+ LOG.error("Cannot schedule cleanup of old tasks. Please update your @sap/cds version.")
64
+ }
56
65
  }
57
66
 
58
67
  /**
@@ -308,6 +308,8 @@ export function setLLMSpanStartAttrs(
308
308
  if (params?.temperature != null)
309
309
  span.setAttribute("gen_ai.request.temperature", params.temperature)
310
310
  if (params?.max_tokens != null) span.setAttribute("gen_ai.request.max_tokens", params.max_tokens)
311
+ const modelParams = pickModelParams(params)
312
+ if (modelParams) span.setAttribute("gen_ai.request.model_params", JSON.stringify(modelParams))
311
313
  if (cds.context?.["agent.context.id"])
312
314
  span.setAttribute("gen_ai.conversation.id", cds.context["agent.context.id"])
313
315
  if (streaming) span.setAttribute("gen_ai.request.stream", true)
@@ -316,7 +318,10 @@ export function setLLMSpanStartAttrs(
316
318
 
317
319
  // MLflow inputs: chat messages for Chat tab
318
320
  if ((cds.env.agents?.mlflow || LOG._debug) && messages) {
319
- const mlMessages = messages.map((m) => ({ role: toRole(m), content: extractText(m.content) }))
321
+ const mlMessages = messages.map((m) => ({
322
+ role: toRole(m),
323
+ content: extractText(m.content),
324
+ }))
320
325
  setSpanAttrs(span, mlflowAttrs("LLM", { model, provider, inputs: { messages: mlMessages } }))
321
326
  if (LOG._debug) {
322
327
  span.setAttribute("gen_ai.input.messages", JSON.stringify(messages.map((m) => m.content)))
@@ -367,7 +372,9 @@ export function setLLMSpanEndAttrs(span, { model, provider, tokenUsage, outputCo
367
372
  mlflowAttrs("LLM", {
368
373
  model,
369
374
  provider,
370
- outputs: { choices: [{ message: { role: "assistant", content: outputContent } }] },
375
+ outputs: {
376
+ choices: [{ message: { role: "assistant", content: outputContent } }],
377
+ },
371
378
  }),
372
379
  )
373
380
  }
@@ -6,17 +6,36 @@ export async function createEvalRun({ name } = {}) {
6
6
  if (!exporter) return null
7
7
  const creds = cds.env.requires?.mlflow?.credentials || {}
8
8
  const experimentId = creds.MLFLOW_EXPERIMENT_ID || process.env.MLFLOW_EXPERIMENT_ID || "0"
9
- return exporter.createRun(experimentId, name)
9
+ const tags = [
10
+ ...(await getSourceTags()),
11
+ { key: "mlflow.user", value: "@cap-js/agents evaluation" },
12
+ ]
13
+ return exporter.createRun(experimentId, name, tags)
10
14
  }
11
15
 
12
- export async function closeEvalRun(runId) {
16
+ export async function closeEvalRun({ mlflowRunId: runId, metricKeys = [], prompts = [] }) {
13
17
  if (!runId) return
14
- getMlflowExporter()?.closeRun(runId)
18
+ const exporter = getMlflowExporter()
19
+ if (!exporter) return
20
+ await logFinalMlflowMetrics(runId, metricKeys, exporter)
21
+ const uniquePrompts = []
22
+ for (const prompt of prompts) {
23
+ if (!uniquePrompts.some((p) => p.version === prompt.version && p.name === prompt.name)) {
24
+ uniquePrompts.push(prompt)
25
+ }
26
+ }
27
+ if (uniquePrompts.length)
28
+ await exporter.linkPromptVersionsToRun(runId, uniquePrompts).catch(() => {})
29
+ await exporter.closeRun(runId)
15
30
  }
16
31
 
17
32
  // Log a flat metrics object; null/undefined values are skipped.
18
- export async function logMlflowMetrics(runId, metrics) {
33
+ export async function logMlflowMetrics({ mlflowRunId: runId, metricKeys }, metrics) {
19
34
  if (!runId) return
35
+ metricKeys ??= new Set()
36
+ for (const [key, value] of Object.entries(metrics)) {
37
+ if (value != null) metricKeys.add(key)
38
+ }
20
39
  const exporter = getMlflowExporter()
21
40
  if (!exporter) return
22
41
  await Promise.allSettled(
@@ -26,6 +45,42 @@ export async function logMlflowMetrics(runId, metrics) {
26
45
  )
27
46
  }
28
47
 
48
+ export async function logMlflowRunMetadata(runId, metadata, exporter = getMlflowExporter()) {
49
+ if (!runId || !exporter || !metadata) return
50
+ const params = {
51
+ ...(metadata.model && { "llm.model": metadata.model }),
52
+ ...(metadata.provider && { "llm.provider": metadata.provider }),
53
+ }
54
+ for (const [key, value] of Object.entries(metadata.params ?? {})) {
55
+ if (value != null) params[`llm.param.${key}`] = _stringifyMlflowValue(value)
56
+ }
57
+
58
+ await Promise.allSettled(
59
+ Object.entries(params).map(([key, value]) => exporter.logParam(runId, key, value)),
60
+ )
61
+ }
62
+
63
+ const AVG_METRICS = { success_rate: 1, output_correctness: 1, latency_ms: 1 }
64
+
65
+ export async function logFinalMlflowMetrics(runId, metricKeys, exporter = getMlflowExporter()) {
66
+ if (!runId || !exporter) return
67
+ const keys = Array.from(metricKeys ?? []).concat(["success_rate", "output_correctness"])
68
+ await Promise.allSettled(
69
+ keys.map(async (key) => {
70
+ const history = await exporter.getMetricHistory(runId, key)
71
+ const values = history
72
+ .filter((metric) => metric?.step !== 1)
73
+ .map((metric) => Number(metric?.value))
74
+ .filter(Number.isFinite)
75
+ if (!values.length) return
76
+
77
+ const total = values.reduce((sum, value) => sum + value, 0)
78
+ const aggregate = AVG_METRICS[key] ? total / values.length : total
79
+ await exporter.logMetric(runId, key, aggregate, { step: 1 })
80
+ }),
81
+ )
82
+ }
83
+
29
84
  export async function postMlflowAssessment(
30
85
  traceId,
31
86
  score,
@@ -36,3 +91,27 @@ export async function postMlflowAssessment(
36
91
  ) {
37
92
  getMlflowExporter()?.postAssessment(traceId, score, rationale, assessmentName, sourceId, opts)
38
93
  }
94
+
95
+ function _stringifyMlflowValue(value) {
96
+ return typeof value === "string" ? value : JSON.stringify(value)
97
+ }
98
+
99
+ function isCI() {
100
+ return process.env.GITHUB_ACTIONS === "true"
101
+ }
102
+
103
+ async function getSourceTags() {
104
+ const serverUrl = process.env.GITHUB_SERVER_URL?.replace(/\/$/, "")
105
+ const sourceName =
106
+ serverUrl && process.env.GITHUB_REPOSITORY
107
+ ? `${serverUrl}/${process.env.GITHUB_REPOSITORY}`
108
+ : undefined
109
+ const branch = process.env.GITHUB_HEAD_REF
110
+ const commit = process.env.GITHUB_SHA
111
+ return [
112
+ sourceName && { key: "mlflow.source.name", value: sourceName },
113
+ { key: "mlflow.source.type", value: isCI() ? "JOB" : "LOCAL" },
114
+ branch && { key: "mlflow.source.git.branch", value: branch },
115
+ commit && { key: "mlflow.source.git.commit", value: commit },
116
+ ].filter(Boolean)
117
+ }
@@ -1,6 +1,28 @@
1
1
  import { MlflowExporter } from "./MlflowExporter.js"
2
2
 
3
3
  export class DatabricksExporter extends MlflowExporter {
4
+ async getMetricHistory(runId, key) {
5
+ const metrics = []
6
+ let pageToken
7
+ do {
8
+ const query = new URLSearchParams({
9
+ run_id: runId,
10
+ metric_key: key,
11
+ max_results: 1000,
12
+ ...(pageToken && { page_token: pageToken }),
13
+ }).toString()
14
+ // eslint-disable-next-line no-await-in-loop
15
+ const data = await this._fetch(
16
+ `/api/2.0/mlflow/metrics/get-history?${query}`,
17
+ undefined,
18
+ "GET",
19
+ )
20
+ metrics.push(...(data?.metrics ?? []))
21
+ pageToken = data?.next_page_token
22
+ } while (pageToken)
23
+ return metrics
24
+ }
25
+
4
26
  async postAssessment(
5
27
  traceId,
6
28
  score,
@@ -31,15 +53,27 @@ export class DatabricksExporter extends MlflowExporter {
31
53
  })
32
54
  }
33
55
 
56
+ async linkPromptVersionsToRun(runId, prompts = []) {
57
+ if (!prompts.length) return
58
+ await super.linkPromptVersionsToRun(runId, prompts)
59
+ await this._fetch("/api/2.0/mlflow/unity-catalog/prompt-versions/links-to-runs", {
60
+ prompt_versions: prompts,
61
+ run_ids: [runId],
62
+ })
63
+ }
64
+
34
65
  // Returns { tags: [{key,value}], latestVersion: {version, tags} | null }.
35
66
  async ensurePrompt(name, description) {
36
67
  let res = await this._fetch(
37
- `/mlflow/unity-catalog/prompts/${encodeURIComponent(name)}`,
68
+ `/api/2.0/mlflow/unity-catalog/prompts/${encodeURIComponent(name)}`,
38
69
  undefined,
39
70
  "GET",
40
71
  )
41
72
  if (!res) {
42
- res = await this._fetch("/mlflow/unity-catalog/prompts", { name, prompt: { description } })
73
+ res = await this._fetch("/api/2.0/mlflow/unity-catalog/prompts", {
74
+ name,
75
+ prompt: { description },
76
+ })
43
77
  }
44
78
  const latestVersion = await this._getLatestUcVersion(name)
45
79
  return { tags: res?.tags ?? [], latestVersion }
@@ -47,7 +81,7 @@ export class DatabricksExporter extends MlflowExporter {
47
81
 
48
82
  async createPromptVersion(name, description, tags = [], template = "") {
49
83
  const res = await this._fetch(
50
- `/mlflow/unity-catalog/prompts/${encodeURIComponent(name)}/versions`,
84
+ `/api/2.0/mlflow/unity-catalog/prompts/${encodeURIComponent(name)}/versions`,
51
85
  {
52
86
  prompt_version: { template, description, tags },
53
87
  },
@@ -56,7 +90,7 @@ export class DatabricksExporter extends MlflowExporter {
56
90
  }
57
91
 
58
92
  async setRegisteredModelTag(name, key, value) {
59
- await this._fetch(`/mlflow/unity-catalog/prompts/${encodeURIComponent(name)}/tags`, {
93
+ await this._fetch(`/api/2.0/mlflow/unity-catalog/prompts/${encodeURIComponent(name)}/tags`, {
60
94
  key,
61
95
  value,
62
96
  })
@@ -65,7 +99,7 @@ export class DatabricksExporter extends MlflowExporter {
65
99
  // Returns { version, tags } of the latest UC prompt version, or null.
66
100
  async _getLatestUcVersion(name) {
67
101
  const res = await this._fetch(
68
- `/mlflow/unity-catalog/prompts/${encodeURIComponent(name)}/versions/search`,
102
+ `/api/2.0/mlflow/unity-catalog/prompts/${encodeURIComponent(name)}/versions/search`,
69
103
  { max_results: 1 },
70
104
  )
71
105
  const pv = res?.prompt_versions?.[0]
@@ -29,12 +29,12 @@ export class MlflowExporter {
29
29
  }
30
30
  }
31
31
 
32
- async createRun(experimentId, name) {
32
+ async createRun(experimentId, name, tags) {
33
33
  const data = await this._fetch("/api/2.0/mlflow/runs/create", {
34
34
  experiment_id: experimentId,
35
35
  run_name: name || `eval-${new Date().toISOString()}`,
36
36
  start_time: Date.now(),
37
- tags: [{ key: "mlflow.source.type", value: "LOCAL" }],
37
+ tags,
38
38
  })
39
39
  return data?.run?.info?.run_id ?? null
40
40
  }
@@ -47,13 +47,52 @@ export class MlflowExporter {
47
47
  })
48
48
  }
49
49
 
50
- async logMetric(runId, key, value) {
50
+ async linkPromptVersionsToRun(runId, prompts = []) {
51
+ if (!prompts.length) return
52
+ await this._fetch("/api/2.0/mlflow/runs/set-tag", {
53
+ run_id: runId,
54
+ key: "mlflow.linkedPrompts",
55
+ value: JSON.stringify(prompts),
56
+ })
57
+ }
58
+
59
+ async getMetricHistory(runId, key) {
60
+ const metrics = []
61
+ let pageToken
62
+ do {
63
+ const query = new URLSearchParams({
64
+ run_id: runId,
65
+ metric_key: key,
66
+ max_results: 1000,
67
+ ...(pageToken && { page_token: pageToken }),
68
+ }).toString()
69
+ // eslint-disable-next-line no-await-in-loop
70
+ const data = await this._fetch(
71
+ `/api/2.0/mlflow/metrics/get-history?${query}`,
72
+ undefined,
73
+ "GET",
74
+ )
75
+ metrics.push(...(data?.metrics ?? []))
76
+ pageToken = data?.next_page_token
77
+ } while (pageToken)
78
+ return metrics
79
+ }
80
+
81
+ async logMetric(runId, key, value, { step = 0 } = {}) {
51
82
  await this._fetch("/api/2.0/mlflow/runs/log-metric", {
52
83
  run_id: runId,
53
84
  key,
54
85
  value,
55
86
  timestamp: Date.now(),
56
- step: 0,
87
+ step,
88
+ })
89
+ }
90
+
91
+ async logParam(runId, key, value) {
92
+ await this._fetch("/api/2.0/mlflow/runs/log-parameter", {
93
+ run_id: runId,
94
+ key,
95
+ value: String(value),
57
96
  })
58
97
  }
59
98
 
@@ -7,10 +7,4 @@ export {
7
7
  RoutingSpanProcessor,
8
8
  } from "./tracing.js"
9
9
  export { postMlflowAssessment, createEvalRun, closeEvalRun } from "./evaluation.js"
10
- export {
11
- syncPromptVersion,
12
- syncSystemPrompt,
13
- resolvePromptName,
14
- linkedPromptsAttr,
15
- hashPrompt,
16
- } from "./prompts.js"
10
+ export { syncPromptVersion, resolvePromptName, linkedPromptsAttr, hashPrompt } from "./prompts.js"
@@ -48,23 +48,27 @@ export function linkedPromptsAttr(promptName) {
48
48
  // Extracts the SystemMessage from prepared LLM messages and syncs it to MLflow.
49
49
  export function syncSystemPrompt(messages) {
50
50
  if (!cds.env.agents?.mlflow) return
51
- const srvName = cds.context?.["agent.service"]
52
- if (!srvName) return
53
- const sysMsg = messages?.find((m) => m.type === "system")
54
- if (!sysMsg) return
51
+ const msg = messages[0]
52
+ if (!msg) return
55
53
  const text =
56
- typeof sysMsg.content === "string"
57
- ? sysMsg.content
58
- : Array.isArray(sysMsg.content)
59
- ? sysMsg.content
54
+ typeof msg.content === "string"
55
+ ? msg.content
56
+ : Array.isArray(msg.content)
57
+ ? msg.content
60
58
  .filter((b) => b?.type === "text")
61
59
  .map((b) => b.text ?? "")
62
60
  .join("")
63
61
  : null
64
62
  if (!text) return
65
- const srv = cds.services[srvName]
66
- if (!srv) return
67
- syncPromptVersion(resolvePromptName(srv), text).catch(() => {})
63
+ let name = msg.name
64
+ if (!name) {
65
+ const srvName = cds.context?.["agent.service"]
66
+ if (!srvName) return
67
+ const srv = cds.services[srvName]
68
+ if (!srv) return
69
+ name = resolvePromptName(srv)
70
+ }
71
+ syncPromptVersion(name, text).catch(() => {})
68
72
  }
69
73
 
70
74
  // AGENTS.md path relative to cds.root, or srv.name when no AGENTS.md exists.
@@ -58,12 +58,12 @@ export function mlflowTraceAttrs() {
58
58
  const attrs = {
59
59
  // OTel semconv keys MLflow reads natively for user/session display
60
60
  "session.id": session,
61
- "user.id": user,
62
61
  // mlflow.traceTag.* prefix for custom trace tags
63
- "mlflow.traceTag.session": session,
64
- "mlflow.traceTag.user": user,
65
62
  "mlflow.traceTag.tenant": tenant,
66
63
  }
64
+ if (cds.env.agents?.masking?.resolveInTraces) {
65
+ attrs["user.id"] = user
66
+ }
67
67
  return attrs
68
68
  }
69
69
 
@@ -0,0 +1,165 @@
1
+ import cds from "@sap/cds"
2
+ import { decode, encode } from "@toon-format/toon"
3
+ import { pseudonymizeToolResult } from "../masking/structured/index.js"
4
+ import { resolveRemoteMcpTool } from "../masking/index.js"
5
+
6
+ const LOG = cds.log("agents")
7
+ const REGISTERED = Symbol.for("@cap-js/agents:trace-scrubber-registered")
8
+
9
+ /**
10
+ * Returns true when PII should be replaced with pseudonym tokens in spans.
11
+ * Returns false when tokens should be resolved back to originals (resolveInTraces mode).
12
+ */
13
+ function mustMaskValues() {
14
+ if (cds.env.agents?.masking?.resolveInTraces) {
15
+ LOG._debug &&
16
+ LOG.debug(
17
+ `Resolving pseudonymized values in OTEL spans because "cds.env.agents.masking.resolveInTraces" = true`,
18
+ )
19
+ return false
20
+ }
21
+ return true
22
+ }
23
+
24
+ function scrubValue(value, session) {
25
+ if (typeof value === "string") return session.scrubText(value)
26
+ if (Array.isArray(value)) return value.map((item) => scrubValue(item, session))
27
+ if (value !== null && typeof value === "object") {
28
+ for (const [k, v] of Object.entries(value)) value[k] = scrubValue(v, session)
29
+ return value
30
+ }
31
+ return value
32
+ }
33
+
34
+ function resolveValue(value, session) {
35
+ if (typeof value === "string") return session.resolveText(value)
36
+ if (Array.isArray(value)) return value.map((item) => resolveValue(item, session))
37
+ if (value !== null && typeof value === "object") {
38
+ for (const [k, v] of Object.entries(value)) value[k] = resolveValue(v, session)
39
+ return value
40
+ }
41
+ return value
42
+ }
43
+
44
+ function transformAttributes(attrs, session, mask) {
45
+ if (!attrs) return
46
+ const transform = mask ? scrubValue : resolveValue
47
+ for (const [key, value] of Object.entries(attrs)) attrs[key] = transform(value, session)
48
+ }
49
+
50
+ function parseJson(value) {
51
+ if (typeof value !== "string") return undefined
52
+ try {
53
+ return JSON.parse(value)
54
+ } catch {
55
+ return undefined
56
+ }
57
+ }
58
+
59
+ function scrubToolOutputs(span, session) {
60
+ const attrs = span.attributes
61
+ if (attrs?.["mlflow.spanType"] !== "TOOL") return
62
+ const rawOutputs = parseJson(attrs["mlflow.spanOutputs"])
63
+ // rawOutputs is the parsed value of the JSON-stringified tool outputs.
64
+ // Only TOON-encoded strings (the success path) can be scrubbed; skip objects (error case).
65
+ if (typeof rawOutputs !== "string") return
66
+ const inputs = parseJson(attrs["mlflow.spanInputs"]) ?? {}
67
+
68
+ const remote = resolveRemoteMcpTool(attrs["gen_ai.tool.call.id"])
69
+ const effectiveToolName = remote?.bareName ?? attrs["gen_ai.tool.call.id"]
70
+ const effectiveSrv = remote?.remoteSrv
71
+
72
+ const srvName = cds.context?.["agent.service"]
73
+ const defaultSrv = cds.services?.[srvName]
74
+ const args = {
75
+ toolName: effectiveToolName,
76
+ cql: inputs.cql,
77
+ strictMasking: true,
78
+ session,
79
+ srv: effectiveSrv ?? defaultSrv,
80
+ model: cds.context?.model ?? effectiveSrv?.model ?? defaultSrv?.model,
81
+ }
82
+
83
+ // Decode TOON or JSON, pseudonymize decoded object, re-encode
84
+ let scrubbed = rawOutputs
85
+ try {
86
+ const decoded = decode(rawOutputs)
87
+ pseudonymizeToolResult({ decoded, ...args })
88
+ scrubbed = encode(decoded)
89
+ } catch {
90
+ try {
91
+ const decoded = JSON.parse(rawOutputs)
92
+ pseudonymizeToolResult({ decoded, ...args })
93
+ scrubbed = JSON.stringify(decoded)
94
+ } catch {
95
+ // unparseable — leave unchanged
96
+ }
97
+ }
98
+ attrs["mlflow.spanOutputs"] = JSON.stringify(scrubbed)
99
+ if (attrs["gen_ai.tool.call.result"] === rawOutputs) attrs["gen_ai.tool.call.result"] = scrubbed
100
+ }
101
+
102
+ export class MaskingSpanProcessor {
103
+ onStart() {}
104
+
105
+ onEnd(span) {
106
+ if (!cds.env.agents?.masking) return
107
+ const session = cds.context?.["agent.pseudonyms"]
108
+ if (!session) return
109
+
110
+ const mask = mustMaskValues()
111
+ if (mask) scrubToolOutputs(span, session)
112
+
113
+ transformAttributes(span.attributes, session, mask)
114
+ for (const event of span.events ?? []) transformAttributes(event.attributes, session, mask)
115
+ if (span.status?.message) {
116
+ span.status.message = mask
117
+ ? session.scrubText(span.status.message)
118
+ : session.resolveText(span.status.message)
119
+ }
120
+ }
121
+
122
+ forceFlush() {
123
+ return Promise.resolve()
124
+ }
125
+
126
+ shutdown() {
127
+ return Promise.resolve()
128
+ }
129
+ }
130
+
131
+ function registerFirst(delegate, processor) {
132
+ // @opentelemetry/sdk-trace-base >= 2.0: internal MultiSpanProcessor holds processors in _spanProcessors
133
+ if (Array.isArray(delegate._activeSpanProcessor?._spanProcessors)) {
134
+ delegate._activeSpanProcessor._spanProcessors.unshift(processor)
135
+ return true
136
+ }
137
+ // @opentelemetry/sdk-trace-base ^1.x: BasicTracerProvider exposes _registeredSpanProcessors directly
138
+ if (Array.isArray(delegate._registeredSpanProcessors)) {
139
+ delegate._registeredSpanProcessors.unshift(processor)
140
+ return true
141
+ }
142
+ // @opentelemetry/sdk-trace-base ^1.x fallback: public addSpanProcessor API (appends, not prepends)
143
+ if (typeof delegate.addSpanProcessor === "function") {
144
+ delegate.addSpanProcessor(processor)
145
+ return true
146
+ }
147
+ return false
148
+ }
149
+
150
+ export async function setupTraceScrubbing() {
151
+ if (!cds.env.agents?.masking) return
152
+ try {
153
+ const { trace } = await import("@opentelemetry/api")
154
+ const provider = trace.getTracerProvider()
155
+ const delegate = provider.getDelegate?.() || provider
156
+ if (delegate[REGISTERED]) return
157
+ if (!registerFirst(delegate, new MaskingSpanProcessor())) {
158
+ LOG.warn("Trace scrubbing: no TracerProvider with span processor support")
159
+ return
160
+ }
161
+ delegate[REGISTERED] = true
162
+ } catch (err) {
163
+ LOG.warn("Trace scrubbing setup failed", { error: err.message })
164
+ }
165
+ }
@@ -50,9 +50,6 @@ export function _patchToolsProto(proto) {
50
50
  span.setAttribute("gen_ai.operation.name", "execute_tool")
51
51
  span.setAttribute("gen_ai.provider.name", "langchain")
52
52
  span.setAttribute("gen_ai.tool.call.id", toolName)
53
- const inputs = args?.args ?? args
54
- setSpanAttrs(span, mlflowAttrs("TOOL", { inputs, functionName: toolName }))
55
- if (LOG._debug) span.setAttribute("gen_ai.tool.call.arguments", JSON.stringify(inputs))
56
53
  }
57
54
  const taskId = config?.configurable?._taskId || cds.context?.["agent.task.id"]
58
55
  let outcome
@@ -64,9 +61,12 @@ export function _patchToolsProto(proto) {
64
61
 
65
62
  const semanticError = result?.artifact?.isError === true
66
63
  outcome = semanticError ? "error" : "success"
67
- const outputs = result?.content ?? result
64
+ let outputs = result?.content ?? result
68
65
 
69
66
  if (span) {
67
+ const inputs = args?.args ?? args
68
+ setSpanAttrs(span, mlflowAttrs("TOOL", { inputs, functionName: toolName }))
69
+ if (LOG._debug) span.setAttribute("gen_ai.tool.call.arguments", JSON.stringify(inputs))
70
70
  span.setAttribute("gen_ai.tool.call.outcome", outcome)
71
71
  if (semanticError) {
72
72
  const msg = typeof outputs === "string" ? outputs : "tool returned isError"
@@ -1,5 +1,5 @@
1
1
  import cds from "@sap/cds"
2
- import { slugified } from "./utils.js"
2
+ import { effectiveDefinition, slugified } from "./utils.js"
3
3
  const { path, fs } = cds.utils
4
4
  const LOG = cds.log("agents")
5
5
 
@@ -7,19 +7,12 @@ const LOG = cds.log("agents")
7
7
  * Absolute filesystem directory of the `.cds` source file that defines `srv`.
8
8
  */
9
9
  export function serviceSourceDir(srv) {
10
- const file = srv?.definition?.$location?.file
10
+ const file = srv?.definition?.["@source"] || srv?.definition?.$location?.file
11
11
  if (!file) return undefined
12
- // $location.file is relative to cds.root
12
+ // Both @source and $location.file are relative to cds.root
13
13
  return path.dirname(path.join(cds.root, file))
14
14
  }
15
15
 
16
- /**
17
- * Get the effective service definition (feature-toggled if available, else base).
18
- */
19
- function effectiveDefinition(srv) {
20
- return cds.context?.model?.definitions?.[srv.name] || srv?.definition
21
- }
22
-
23
16
  /**
24
17
  * Resolve a path from a `@agent.directory` / `@agent.card` annotation against the
25
18
  * `.cds` source file's directory. Absolute paths are returned as-is.
@@ -40,7 +40,7 @@ export function timeout(ms = DEFAULT_TIMEOUT_MS) {
40
40
  }
41
41
 
42
42
  // Retries on 5xx / network errors; bails immediately on 4xx.
43
- export function retry(retries = DEFAULT_RETRIES) {
43
+ export function retry(retries = DEFAULT_RETRIES, { shouldRetry } = {}) {
44
44
  if (retries < 0) throw new Error("Number of retries must be greater or equal to 0.")
45
45
 
46
46
  return ({ fn }) =>
@@ -50,6 +50,7 @@ export function retry(retries = DEFAULT_RETRIES) {
50
50
  return await fn(arg) // eslint-disable-line no-await-in-loop
51
51
  } catch (error) {
52
52
  const status = error?.response?.status
53
+ if (shouldRetry && !shouldRetry(error)) throw error
53
54
  if (status == null)
54
55
  LOG.debug("HTTP request failed without a response status. Rethrowing.")
55
56
  else if (`${status}`.startsWith("4"))