@cap-js/agents 0.9.6 → 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.
- package/README.md +2 -131
- package/_i18n/messages_ar.properties +48 -0
- package/_i18n/messages_bg.properties +48 -0
- package/_i18n/messages_cs.properties +48 -0
- package/_i18n/messages_da.properties +48 -0
- package/_i18n/messages_de.properties +48 -0
- package/_i18n/messages_el.properties +48 -0
- package/_i18n/messages_en.properties +48 -0
- package/_i18n/messages_es.properties +48 -0
- package/_i18n/messages_es_MX.properties +48 -0
- package/_i18n/messages_fi.properties +48 -0
- package/_i18n/messages_fr.properties +48 -0
- package/_i18n/messages_he.properties +48 -0
- package/_i18n/messages_hr.properties +48 -0
- package/_i18n/messages_hu.properties +48 -0
- package/_i18n/messages_it.properties +48 -0
- package/_i18n/messages_ja.properties +48 -0
- package/_i18n/messages_kk.properties +48 -0
- package/_i18n/messages_ko.properties +48 -0
- package/_i18n/messages_ms.properties +48 -0
- package/_i18n/messages_nl.properties +48 -0
- package/_i18n/messages_no.properties +48 -0
- package/_i18n/messages_pl.properties +48 -0
- package/_i18n/messages_pt.properties +48 -0
- package/_i18n/messages_ro.properties +48 -0
- package/_i18n/messages_ru.properties +48 -0
- package/_i18n/messages_sh.properties +48 -0
- package/_i18n/messages_sk.properties +48 -0
- package/_i18n/messages_sl.properties +48 -0
- package/_i18n/messages_sv.properties +48 -0
- package/_i18n/messages_th.properties +48 -0
- package/_i18n/messages_tr.properties +48 -0
- package/_i18n/messages_uk.properties +48 -0
- package/_i18n/messages_vi.properties +48 -0
- package/_i18n/messages_zh_CN.properties +48 -0
- package/_i18n/messages_zh_TW.properties +48 -0
- package/cds-plugin.js +2 -0
- package/lib/agents/middleware/index.js +2 -0
- package/lib/agents/middleware/masking.js +124 -0
- package/lib/agents/middleware/remote-mcp.js +5 -3
- package/lib/masking/index.js +90 -0
- package/lib/masking/store.js +80 -0
- package/lib/masking/structured/findElements.js +459 -0
- package/lib/masking/structured/index.js +170 -0
- package/lib/masking/unstructured/dpi.js +84 -0
- package/lib/masking/unstructured/hana.js +64 -0
- package/lib/masking/unstructured/index.js +52 -0
- package/lib/preview/chat.html +66 -30
- package/lib/protocol/persistence/cleanup.js +13 -4
- package/lib/telemetry/chat-tracing.js +7 -2
- package/lib/telemetry/mlflow/tracing.js +3 -3
- package/lib/telemetry/span-masking.js +165 -0
- package/lib/telemetry/tool-tracing.js +4 -4
- package/lib/utils/markdown.js +2 -2
- package/package.json +3 -1
- package/srv/handlers/graph-executor.js +52 -17
- package/srv/handlers/system-prompt.js +7 -0
|
@@ -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
|
+
}
|
package/lib/preview/chat.html
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
1008
|
+
function enqueueMessage() {
|
|
986
1009
|
const text = prompt.value.trim()
|
|
987
|
-
if (!text
|
|
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>
|
|
@@ -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
|
-
|
|
53
|
-
|
|
54
|
-
const delay =
|
|
55
|
-
|
|
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) => ({
|
|
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: {
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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"
|
package/lib/utils/markdown.js
CHANGED
|
@@ -7,9 +7,9 @@ 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
|
|
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
|
|