@cap-js/agents 0.9.5 → 0.9.7

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 (61) hide show
  1. package/README.md +2 -131
  2. package/_i18n/messages_ar.properties +48 -0
  3. package/_i18n/messages_bg.properties +48 -0
  4. package/_i18n/messages_cs.properties +48 -0
  5. package/_i18n/messages_da.properties +48 -0
  6. package/_i18n/messages_de.properties +48 -0
  7. package/_i18n/messages_el.properties +48 -0
  8. package/_i18n/messages_en.properties +48 -0
  9. package/_i18n/messages_es.properties +48 -0
  10. package/_i18n/messages_es_MX.properties +48 -0
  11. package/_i18n/messages_fi.properties +48 -0
  12. package/_i18n/messages_fr.properties +48 -0
  13. package/_i18n/messages_he.properties +48 -0
  14. package/_i18n/messages_hr.properties +48 -0
  15. package/_i18n/messages_hu.properties +48 -0
  16. package/_i18n/messages_it.properties +48 -0
  17. package/_i18n/messages_ja.properties +48 -0
  18. package/_i18n/messages_kk.properties +48 -0
  19. package/_i18n/messages_ko.properties +48 -0
  20. package/_i18n/messages_ms.properties +48 -0
  21. package/_i18n/messages_nl.properties +48 -0
  22. package/_i18n/messages_no.properties +48 -0
  23. package/_i18n/messages_pl.properties +48 -0
  24. package/_i18n/messages_pt.properties +48 -0
  25. package/_i18n/messages_ro.properties +48 -0
  26. package/_i18n/messages_ru.properties +48 -0
  27. package/_i18n/messages_sh.properties +48 -0
  28. package/_i18n/messages_sk.properties +48 -0
  29. package/_i18n/messages_sl.properties +48 -0
  30. package/_i18n/messages_sv.properties +48 -0
  31. package/_i18n/messages_th.properties +48 -0
  32. package/_i18n/messages_tr.properties +48 -0
  33. package/_i18n/messages_uk.properties +48 -0
  34. package/_i18n/messages_vi.properties +48 -0
  35. package/_i18n/messages_zh_CN.properties +48 -0
  36. package/_i18n/messages_zh_TW.properties +48 -0
  37. package/cds-plugin.js +2 -0
  38. package/lib/agents/middleware/index.js +2 -0
  39. package/lib/agents/middleware/masking.js +124 -0
  40. package/lib/agents/middleware/remote-mcp.js +5 -3
  41. package/lib/compile.js +2 -6
  42. package/lib/masking/index.js +90 -0
  43. package/lib/masking/store.js +80 -0
  44. package/lib/masking/structured/findElements.js +459 -0
  45. package/lib/masking/structured/index.js +170 -0
  46. package/lib/masking/unstructured/dpi.js +84 -0
  47. package/lib/masking/unstructured/hana.js +64 -0
  48. package/lib/masking/unstructured/index.js +52 -0
  49. package/lib/preview/chat.html +66 -30
  50. package/lib/protocol/agent-card.js +2 -6
  51. package/lib/protocol/persistence/cleanup.js +13 -4
  52. package/lib/telemetry/chat-tracing.js +7 -2
  53. package/lib/telemetry/mlflow/tracing.js +3 -3
  54. package/lib/telemetry/span-masking.js +165 -0
  55. package/lib/telemetry/tool-tracing.js +4 -4
  56. package/lib/utils/markdown.js +2 -2
  57. package/lib/utils/utils.js +2 -34
  58. package/package.json +4 -2
  59. package/srv/handlers/graph-executor.js +52 -17
  60. package/srv/handlers/system-prompt.js +7 -0
  61. package/srv/handlers/tools.js +4 -3
@@ -0,0 +1,84 @@
1
+ import cds from "@sap/cds"
2
+
3
+ const PSEUDONYMIZE_PATH = "/anonymization/api/v1.0/unstructureddata/pseudonymize/text"
4
+ const TIMEOUT = 30_000
5
+ const DEFAULT_ENTITIES = [
6
+ "profile-person",
7
+ "profile-email",
8
+ "profile-phone",
9
+ "profile-address",
10
+ "profile-username-password",
11
+ "profile-nationalid",
12
+ "profile-iban",
13
+ "profile-ssn",
14
+ "profile-credit-card-number",
15
+ "profile-passport",
16
+ "profile-driverlicense",
17
+ ]
18
+
19
+ export async function anonymize(text) {
20
+ const destinationName = resolveDestination()
21
+ if (!destinationName || !text) return { text }
22
+ const result = await anonymizeText(text, destinationName)
23
+ return { text: result.text, mappings: result.mappings }
24
+ }
25
+
26
+ function resolveDestination() {
27
+ const required = cds.env.requires?.["data-anonymization"]
28
+ const destinationName = required?.credentials?.destination
29
+ if (!destinationName) return undefined
30
+ return destinationName
31
+ }
32
+
33
+ async function anonymizeText(text, destinationName) {
34
+ const payload = buildPayload(text)
35
+ const data = await postToDpi(payload, destinationName)
36
+ const mappings = extractPseudonymMappings(data.metadata)
37
+ // Rewrite DPI tags in the anonymized text from <<profile-person>:hash> to person-hash.
38
+ const result = data.result.replace(/<<(?:profile-)?([^>]+)>:([^>]+)>/g, "$1-$2")
39
+ return { text: result, mappings }
40
+ }
41
+
42
+ function buildPayload(text) {
43
+ const payload = new URLSearchParams()
44
+ payload.set("text", text)
45
+ payload.set("entities", DEFAULT_ENTITIES.join(";"))
46
+ payload.set("anonymization-method-per-profile", "")
47
+ payload.set("whitelist", "")
48
+ payload.set("enable-default-whitelist", "false")
49
+ return payload.toString()
50
+ }
51
+
52
+ function extractPseudonymMappings(data) {
53
+ const mappings = []
54
+ // DPI returns pseudonyms in the form "<<profile-person>:abc12345>".
55
+ // Convert to the standard tag format "person-abc12345" and also register
56
+ // the bare hash "abc12345" so the LLM can use it in queries.
57
+ for (const dpiTag of Object.keys(data)) {
58
+ const match = dpiTag.match(/^<<(?:profile-)?([^>]+)>:([^>]+)>$/)
59
+ if (!match) continue
60
+ const [, entityType, hash] = match
61
+ const tag = `${entityType}-${hash}`
62
+ const original = data[dpiTag].real_entity
63
+ mappings.push([tag, original])
64
+ mappings.push([hash, original])
65
+ }
66
+ return mappings
67
+ }
68
+ // API Docs: https://api.sap.com/api/sap-dpi-pseudonymization-v1/resource/Pseudonymization
69
+ // SAP Help: https://help.sap.com/docs/data-privacy-integration/development/api-endpoints-9956053a4e6447cfbaccb9d2dee35c11
70
+ async function postToDpi(payload, destinationName) {
71
+ const { executeHttpRequest } = await import("@sap-cloud-sdk/http-client")
72
+ const response = await executeHttpRequest(
73
+ { destinationName },
74
+ {
75
+ method: "post",
76
+ url: `${PSEUDONYMIZE_PATH}?priority=high`,
77
+ data: payload,
78
+ headers: { "content-type": "application/x-www-form-urlencoded" },
79
+ timeout: TIMEOUT,
80
+ },
81
+ { fetchCsrfToken: false },
82
+ )
83
+ return response.data
84
+ }
@@ -0,0 +1,64 @@
1
+ import cds from "@sap/cds"
2
+ import { generatePseudonymTag } from "../store.js"
3
+
4
+ const LOG = cds.log("agents")
5
+
6
+ // Full list of categories HANA detects: https://help.sap.com/docs/hana-cloud-database/sap-hana-cloud-sap-hana-database-predictive-analysis-library/descriptions-of-entity-types
7
+ const DPP_CATEGORIES = new Map([
8
+ ["PERSON", "person"],
9
+ ["URI:EMAIL", "email"],
10
+ ["PHONE", "phone"],
11
+ ["ADDRESS", "address"],
12
+ ["URI:URL", "url"],
13
+ ["URI:IP", "ip"],
14
+ ])
15
+
16
+ let scriptServerActive = true
17
+
18
+ export async function anonymize(text, seed) {
19
+ if (!scriptServerActive || !text || !seed) return { text }
20
+ try {
21
+ return await pseudonymizeText(text, seed)
22
+ } catch (err) {
23
+ LOG.error(`SAP HANA Cloud NLS based pseudonymization disabled due to error: `, err)
24
+ scriptServerActive = false
25
+ return { text }
26
+ }
27
+ }
28
+
29
+ async function pseudonymizeText(text, seed) {
30
+ const res = await cds.run(buildPalCallSql(), [text])
31
+ const entities = res.changes[1]
32
+ const piiEntities = entities
33
+ .filter((e) => e.TYPE === "ner" && DPP_CATEGORIES.has(e.ENTITY))
34
+ // Sort back to front so offsets stay valid after replacement
35
+ .sort((a, b) => b.GLOBAL_OFFSET - a.GLOBAL_OFFSET)
36
+
37
+ const mappings = []
38
+ const seen = new Set()
39
+ let result = text
40
+ for (const row of piiEntities) {
41
+ if (result.slice(row.GLOBAL_OFFSET, row.GLOBAL_OFFSET + row.TOKEN.length) !== row.TOKEN)
42
+ continue
43
+ const category = DPP_CATEGORIES.get(row.ENTITY)
44
+ const tag = generatePseudonymTag(seed, row.TOKEN, category)
45
+ result = `${result.slice(0, row.GLOBAL_OFFSET)}${tag}${result.slice(row.GLOBAL_OFFSET + row.TOKEN.length)}`
46
+ if (!seen.has(row.TOKEN)) {
47
+ seen.add(row.TOKEN)
48
+ const hash = tag.slice(category.length + 1)
49
+ mappings.push([tag, row.TOKEN])
50
+ mappings.push([hash, row.TOKEN])
51
+ }
52
+ }
53
+ return { text: result, mappings, textAnalysisResults: entities }
54
+ }
55
+
56
+ function buildPalCallSql() {
57
+ return `DO (IN text NVARCHAR(5000) => ?, OUT textAnalysisResults TABLE (ID NVARCHAR(36), "TYPE" NVARCHAR(3), SENTENCE_ID INTEGER, "TOKEN" NVARCHAR(5000), "ENTITY" NVARCHAR(1000), OFFSET INTEGER, GLOBAL_OFFSET INTEGER) => ?) BEGIN
58
+ lt_data = SELECT CAST(SYSUUID as NVARCHAR(36)) AS "ID", :text AS "CONTENT", CAST('' AS NVARCHAR(1000)) AS "LANGUAGE", CAST('ner' AS NVARCHAR(20)) AS "TASK" FROM DUMMY UNION SELECT CAST(SYSUUID as NVARCHAR(36)) AS "ID", :text AS "CONTENT", CAST('' AS NVARCHAR(1000)) AS "LANGUAGE", CAST('pos' AS NVARCHAR(20)) AS "TASK" FROM DUMMY;
59
+ lt_param = SELECT CAST('' AS NVARCHAR(256)) AS "PARAM_NAME", CAST(0 AS INTEGER) AS "INT_VALUE", CAST(0.0 AS DOUBLE) AS "DOUBLE_VALUE", CAST('' AS NVARCHAR(1000)) AS "STRING_VALUE" FROM DUMMY;
60
+ CALL _SYS_AFL.PAL_TEXT_ANALYSIS(:lt_data, :lt_param, lt_sentences, lt_pos, lt_ner, lt_doc_sentiment, lt_sentence_sentiment, lt_phrase_sentiment, lt_extra);
61
+ textAnalysisResults =
62
+ SELECT SYSUUID AS "ID", 'ner' AS "TYPE", "SENTENCE_ID", "TOKEN", "ENTITY", "OFFSET", "GLOBAL_OFFSET" FROM :lt_ner UNION SELECT SYSUUID AS "ID", 'pos' AS "TYPE", "SENTENCE_ID", "TOKEN", "ENTITY", "OFFSET", "GLOBAL_OFFSET" FROM :lt_pos;
63
+ END;`
64
+ }
@@ -0,0 +1,52 @@
1
+ import cds from "@sap/cds"
2
+ import { anonymize as hana } from "./hana.js"
3
+ import { anonymize as dpi } from "./dpi.js"
4
+ import { ensureSession, pseudonymize } from "../index.js"
5
+
6
+ export default async function anonymizeUnstructured(text, seed) {
7
+ const hanaResult = await hana(text, seed)
8
+ const dpiResult = await dpi(hanaResult.text)
9
+ return {
10
+ text: dpiResult.text,
11
+ mappings: [...(hanaResult.mappings ?? []), ...(dpiResult.mappings ?? [])],
12
+ textAnalysisResults: hanaResult.textAnalysisResults,
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Pseudonymize user message text parts before graph execution.
18
+ */
19
+ export async function pseudonymizeUserMessage(srv, requestContext, checkpointer, threadId) {
20
+ await ensureSession(checkpointer, threadId)
21
+ const session = cds.context?.["agent.pseudonyms"]
22
+ const parts = requestContext.userMessage?.parts
23
+ if (!session || !Array.isArray(parts)) return
24
+
25
+ const textParts = parts.filter((p) => (p.kind === "text" || (!p.kind && p.text)) && p.text)
26
+ if (!textParts.length) return
27
+
28
+ const results = await Promise.all(
29
+ textParts.map((p) =>
30
+ pseudonymize(
31
+ {
32
+ data: p.text,
33
+ type: "unstructured",
34
+ seed: session._seed,
35
+ },
36
+ srv,
37
+ ),
38
+ ),
39
+ )
40
+ for (let i = 0; i < textParts.length; i++) {
41
+ const r = results[i]
42
+ textParts[i].text = r.data
43
+ session.addMappings(r.mappings)
44
+ if (r.metadata?.textAnalysisResults?.length) {
45
+ session._textAnalysisResults = (session._textAnalysisResults ?? []).concat(
46
+ r.metadata.textAnalysisResults,
47
+ )
48
+ }
49
+ // Scrub any PII detected in prior turns that is not detected by HANA/DPI
50
+ textParts[i].text = session.scrubText(textParts[i].text)
51
+ }
52
+ }
@@ -96,6 +96,16 @@
96
96
  border-bottom-right-radius: 3px;
97
97
  white-space: pre-wrap;
98
98
  }
99
+ .msg.user.queued {
100
+ opacity: 0.65;
101
+ }
102
+ .msg.user.queued::after {
103
+ content: "Queued";
104
+ display: block;
105
+ margin-top: 4px;
106
+ font-size: 0.7em;
107
+ text-align: right;
108
+ }
99
109
  .msg.agent {
100
110
  align-self: flex-start;
101
111
  background: #d0e9ff;
@@ -246,6 +256,7 @@
246
256
 
247
257
  #prompt {
248
258
  flex: 1;
259
+ box-sizing: border-box;
249
260
  padding: 10px 14px;
250
261
  border: 1px solid #91adc5;
251
262
  border-radius: 8px;
@@ -288,7 +299,6 @@
288
299
  #send.cancel:hover {
289
300
  background: #c62828;
290
301
  }
291
-
292
302
  .approval-actions {
293
303
  display: flex;
294
304
  gap: 8px;
@@ -508,6 +518,8 @@
508
518
  const THINKING_SUMMARY_ANIMATION_MS = 160
509
519
 
510
520
  let busy = false
521
+ let processingQueue = false
522
+ const messageQueue = []
511
523
  let contextId = null
512
524
  let pendingTaskId = null
513
525
  let activeTaskId = null
@@ -547,13 +559,14 @@
547
559
 
548
560
  prompt.addEventListener("input", () => {
549
561
  prompt.style.height = "auto"
550
- prompt.style.height = Math.min(prompt.scrollHeight, 140) + "px"
562
+ const borderHeight = prompt.offsetHeight - prompt.clientHeight
563
+ prompt.style.height = Math.min(prompt.scrollHeight + borderHeight, 140) + "px"
564
+ renderComposerAction()
551
565
  })
552
566
  prompt.addEventListener("keydown", (e) => {
553
567
  if (e.key === "Enter" && !e.shiftKey) {
554
568
  e.preventDefault()
555
- if (busy) cancel()
556
- else send()
569
+ enqueueMessage()
557
570
  } else if (e.key === "ArrowUp" && prompt.selectionStart === 0) {
558
571
  e.preventDefault()
559
572
  if (historyIndex === -1) currentDraft = prompt.value
@@ -561,6 +574,7 @@
561
574
  historyIndex++
562
575
  prompt.value = inputHistory[inputHistory.length - 1 - historyIndex]
563
576
  prompt.selectionStart = prompt.selectionEnd = prompt.value.length
577
+ renderComposerAction()
564
578
  }
565
579
  } else if (e.key === "ArrowDown" && prompt.selectionEnd === prompt.value.length) {
566
580
  e.preventDefault()
@@ -568,26 +582,32 @@
568
582
  historyIndex--
569
583
  prompt.value = inputHistory[inputHistory.length - 1 - historyIndex]
570
584
  prompt.selectionStart = prompt.selectionEnd = prompt.value.length
585
+ renderComposerAction()
571
586
  } else if (historyIndex === 0) {
572
587
  historyIndex = -1
573
588
  prompt.value = currentDraft
574
589
  prompt.selectionStart = prompt.selectionEnd = prompt.value.length
590
+ renderComposerAction()
575
591
  }
576
592
  }
577
593
  })
578
594
  sendBtn.addEventListener("click", () => {
579
- if (busy) cancel()
580
- else send()
595
+ if (busy && !prompt.value.trim()) cancel()
596
+ else enqueueMessage()
581
597
  })
598
+ renderComposerAction()
582
599
 
583
- function addMessage(role, text) {
600
+ function addMessage(role, text, beforeEl) {
584
601
  const el = document.createElement("div")
585
602
  el.className = "msg " + role
586
603
  if (role === "agent" || role === "agent_thinking")
587
604
  el.innerHTML = md(text, { sanitize: true })
588
605
  else el.textContent = text
589
606
  if (role === "agent_thinking") el.className = "msg agent_thinking"
590
- messages.insertBefore(el, typing.parentElement === messages ? typing : statusText)
607
+ messages.insertBefore(
608
+ el,
609
+ beforeEl ?? (typing.parentElement === messages ? typing : statusText),
610
+ )
591
611
  messages.scrollTop = messages.scrollHeight
592
612
  return el
593
613
  }
@@ -869,25 +889,27 @@
869
889
 
870
890
  function setBusy(val) {
871
891
  busy = val
872
- prompt.disabled = val
873
892
  if (typing.parentElement !== messages) messages.insertBefore(typing, statusText)
874
893
  typing.style.display = val ? "block" : "none"
875
894
  dot.className = "status-dot" + (val ? " busy" : "")
876
- if (val) {
877
- sendBtn.textContent = "Cancel"
878
- sendBtn.classList.add("cancel")
879
- sendBtn.disabled = false
880
- } else {
881
- sendBtn.textContent = "Send"
882
- sendBtn.classList.remove("cancel")
883
- sendBtn.disabled = false
895
+ if (!val) {
884
896
  activeTaskId = null
885
897
  activeAbort = null
886
898
  statusText.textContent = ""
887
899
  }
900
+ renderComposerAction()
888
901
  messages.scrollTop = messages.scrollHeight
889
902
  }
890
903
 
904
+ function renderComposerAction() {
905
+ const stopsGeneration = busy && !prompt.value.trim()
906
+ sendBtn.textContent = stopsGeneration ? "Cancel" : "Send"
907
+ sendBtn.classList.toggle("cancel", stopsGeneration)
908
+ sendBtn.setAttribute("aria-label", stopsGeneration ? "Stop generating" : "Send message")
909
+ sendBtn.title = stopsGeneration ? "Stop generating" : "Send message"
910
+ sendBtn.disabled = !busy && !prompt.value.trim()
911
+ }
912
+
891
913
  async function resume(decision) {
892
914
  const taskId = pendingTaskId
893
915
  pendingTaskId = null
@@ -934,6 +956,7 @@
934
956
  } finally {
935
957
  setBusy(false)
936
958
  prompt.focus()
959
+ processQueue()
937
960
  }
938
961
  }
939
962
 
@@ -982,11 +1005,10 @@
982
1005
  finalizeThinking(replyEl, reply)
983
1006
  }
984
1007
 
985
- async function send() {
1008
+ function enqueueMessage() {
986
1009
  const text = prompt.value.trim()
987
- if (!text || busy) return
1010
+ if (!text) return
988
1011
 
989
- // Save to input history (skip if same as last entry)
990
1012
  if (inputHistory[inputHistory.length - 1] !== text) {
991
1013
  inputHistory.push(text)
992
1014
  if (inputHistory.length > 100) inputHistory.shift()
@@ -994,10 +1016,31 @@
994
1016
  }
995
1017
  historyIndex = -1
996
1018
  currentDraft = ""
997
-
998
- addMessage("user", text)
999
1019
  prompt.value = ""
1000
1020
  prompt.style.height = "auto"
1021
+ renderComposerAction()
1022
+ const el = addMessage("user", text, statusText)
1023
+ el.classList.add("queued")
1024
+ messageQueue.push({ text, el })
1025
+ processQueue()
1026
+ }
1027
+
1028
+ async function processQueue() {
1029
+ if (processingQueue || pendingTaskId) return
1030
+ processingQueue = true
1031
+ try {
1032
+ while (messageQueue.length && !pendingTaskId) {
1033
+ const message = messageQueue.shift()
1034
+ await sendMessage(message)
1035
+ }
1036
+ } finally {
1037
+ processingQueue = false
1038
+ }
1039
+ }
1040
+
1041
+ async function sendMessage({ text, el }) {
1042
+ el.classList.remove("queued")
1043
+ messages.insertBefore(el, typing.parentElement === messages ? typing : statusText)
1001
1044
  setBusy(true)
1002
1045
 
1003
1046
  const abort = new AbortController()
@@ -1202,11 +1245,8 @@
1202
1245
 
1203
1246
  async function cancel() {
1204
1247
  const taskId = activeTaskId
1248
+ activeAbort?.abort()
1205
1249
 
1206
- // Abort ongoing fetch
1207
- if (activeAbort) activeAbort.abort()
1208
-
1209
- // Send tasks/cancel if we have a taskId
1210
1250
  if (taskId) {
1211
1251
  fetch(AGENT_URL, {
1212
1252
  method: "POST",
@@ -1219,10 +1259,6 @@
1219
1259
  }),
1220
1260
  }).catch(() => {})
1221
1261
  }
1222
-
1223
- addMessage("agent", "⊘ Task canceled.")
1224
- setBusy(false)
1225
- prompt.focus()
1226
1262
  }
1227
1263
  </script>
1228
1264
  </body>
@@ -1,12 +1,8 @@
1
1
  import cds from "@sap/cds"
2
2
  import { createRequire } from "node:module"
3
3
  const { path } = cds.utils
4
- import {
5
- getDescription,
6
- getFilteredEntities,
7
- getFilteredActions,
8
- slugified,
9
- } from "../utils/utils.js"
4
+ import { getFilteredEntities, getFilteredActions } from "@cap-js/mcp/lib/utils/tools-shared.js"
5
+ import { getDescription, slugified } from "../utils/utils.js"
10
6
  import {
11
7
  scanSkills,
12
8
  parseAgentMetadata,
@@ -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
  /**
@@ -316,7 +316,10 @@ export function setLLMSpanStartAttrs(
316
316
 
317
317
  // MLflow inputs: chat messages for Chat tab
318
318
  if ((cds.env.agents?.mlflow || LOG._debug) && messages) {
319
- const mlMessages = messages.map((m) => ({ role: toRole(m), content: extractText(m.content) }))
319
+ const mlMessages = messages.map((m) => ({
320
+ role: toRole(m),
321
+ content: extractText(m.content),
322
+ }))
320
323
  setSpanAttrs(span, mlflowAttrs("LLM", { model, provider, inputs: { messages: mlMessages } }))
321
324
  if (LOG._debug) {
322
325
  span.setAttribute("gen_ai.input.messages", JSON.stringify(messages.map((m) => m.content)))
@@ -367,7 +370,9 @@ export function setLLMSpanEndAttrs(span, { model, provider, tokenUsage, outputCo
367
370
  mlflowAttrs("LLM", {
368
371
  model,
369
372
  provider,
370
- outputs: { choices: [{ message: { role: "assistant", content: outputContent } }] },
373
+ outputs: {
374
+ choices: [{ message: { role: "assistant", content: outputContent } }],
375
+ },
371
376
  }),
372
377
  )
373
378
  }
@@ -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
+ }