@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.
- package/README.md +2 -131
- package/_i18n/messages.properties +2 -0
- 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 +15 -10
- package/lib/agents/middleware/content-filter.js +4 -0
- package/lib/agents/middleware/hitl-decision-note-injector.js +18 -6
- package/lib/agents/middleware/hitl.js +17 -6
- package/lib/agents/middleware/index.js +3 -1
- package/lib/agents/middleware/masking.js +127 -0
- package/lib/agents/middleware/remote-mcp.js +10 -5
- package/lib/agents/middleware/status-update.js +209 -76
- package/lib/agents/middleware/tool-wrap.js +1 -2
- package/lib/agents/summarize-on-timeout.js +5 -2
- package/lib/config/local.js +121 -8
- package/lib/eval/eval-run.js +50 -4
- 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 +72 -0
- package/lib/masking/unstructured/index.js +52 -0
- package/lib/models/aicore.js +16 -3
- package/lib/models/openai.js +9 -0
- package/lib/preview/chat.html +623 -119
- package/lib/protocol/persistence/cleanup.js +13 -4
- package/lib/telemetry/chat-tracing.js +9 -2
- package/lib/telemetry/mlflow/evaluation.js +83 -4
- package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +39 -5
- package/lib/telemetry/mlflow/exporter/MlflowExporter.js +43 -4
- package/lib/telemetry/mlflow/index.js +1 -7
- package/lib/telemetry/mlflow/prompts.js +15 -11
- 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 +3 -10
- package/lib/utils/resilience.js +2 -1
- package/lib/utils/toml.js +67 -0
- package/lib/utils/usage.js +34 -0
- package/lib/utils/utils.js +43 -1
- package/package.json +33 -26
- package/srv/handlers/graph-executor/crash-handler.js +47 -0
- package/srv/handlers/graph-executor/hitl.js +27 -0
- package/srv/handlers/graph-executor.js +69 -20
- package/srv/handlers/index.js +6 -1
- package/srv/handlers/mcp-tools.js +11 -2
- package/srv/handlers/subagent-tools.js +13 -4
- package/srv/handlers/system-prompt.js +30 -20
- package/srv/handlers/tools.js +14 -13
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
import {
|
|
3
|
+
shouldHash,
|
|
4
|
+
personalDataElements,
|
|
5
|
+
discoverElementsToBeMasked,
|
|
6
|
+
hasPersonalDataAnnotations,
|
|
7
|
+
hasPersonalDataAnnotation,
|
|
8
|
+
actionReturnType,
|
|
9
|
+
} from "./findElements.js"
|
|
10
|
+
|
|
11
|
+
export { shouldHash, personalDataElements, discoverElementsToBeMasked, hasPersonalDataAnnotations }
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check whether masking is needed for a service — local model + remote MCP models.
|
|
15
|
+
*/
|
|
16
|
+
export function needsMasking(serviceName) {
|
|
17
|
+
const srv = cds.services?.[serviceName]
|
|
18
|
+
const model = cds.context?.model ?? srv?.model
|
|
19
|
+
if (model && hasPersonalDataAnnotations(model, serviceName)) return true
|
|
20
|
+
for (const { serviceName: remoteName } of Object.values(cds.context?.__mcpDynamicTools ?? {})) {
|
|
21
|
+
const remoteSrv = cds.services?.[remoteName]
|
|
22
|
+
const remoteModel = remoteSrv?.model
|
|
23
|
+
if (remoteModel && hasPersonalDataAnnotations(remoteModel, remoteName)) return true
|
|
24
|
+
}
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function pseudonymizeData(data, annotatedFields, session) {
|
|
29
|
+
if (!annotatedFields.size) return
|
|
30
|
+
const rows = Array.isArray(data) ? data : data ? [data] : []
|
|
31
|
+
for (const row of rows) {
|
|
32
|
+
if (!row || typeof row !== "object") continue
|
|
33
|
+
for (const field of annotatedFields) {
|
|
34
|
+
if (Array.isArray(field) && field.length > 1) {
|
|
35
|
+
// Multi-segment path: nested ({address:{street}}) or CAP-flattened ({address_street}).
|
|
36
|
+
const [first, ...rest] = field
|
|
37
|
+
if (first in row && row[first] != null && typeof row[first] === "object") {
|
|
38
|
+
pseudonymizeData(row[first], new Set([rest.length === 1 ? rest[0] : rest]), session)
|
|
39
|
+
} else {
|
|
40
|
+
const flat = field.join("_")
|
|
41
|
+
if (flat in row && row[flat] != null) {
|
|
42
|
+
const v = row[flat]
|
|
43
|
+
row[flat] = Array.isArray(v)
|
|
44
|
+
? v.map((x) => (x == null ? x : session.pseudonymize(x, flat)))
|
|
45
|
+
: session.pseudonymize(v, flat)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
// Plain name or single-element path array ["nicknames"].
|
|
50
|
+
const name = Array.isArray(field) ? field[0] : field
|
|
51
|
+
if (!(name in row) || row[name] == null) continue
|
|
52
|
+
const value = row[name]
|
|
53
|
+
// Scalar array — hash each element.
|
|
54
|
+
row[name] = Array.isArray(value)
|
|
55
|
+
? value.map((v) => (v == null ? v : session.pseudonymize(v, name)))
|
|
56
|
+
: session.pseudonymize(value, name)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function resolveArgs(value, session) {
|
|
63
|
+
if (!session?._hashToOriginal?.size) return value
|
|
64
|
+
if (typeof value === "string") return session.resolveText(value)
|
|
65
|
+
if (Array.isArray(value)) {
|
|
66
|
+
const mapped = value.map((v) => resolveArgs(v, session))
|
|
67
|
+
return mapped.every((v, i) => v === value[i]) ? value : mapped
|
|
68
|
+
}
|
|
69
|
+
if (value && typeof value === "object") {
|
|
70
|
+
let changed = false
|
|
71
|
+
const out = {}
|
|
72
|
+
for (const [k, v] of Object.entries(value)) {
|
|
73
|
+
const resolved = resolveArgs(v, session)
|
|
74
|
+
out[k] = resolved
|
|
75
|
+
if (resolved !== v) changed = true
|
|
76
|
+
}
|
|
77
|
+
return changed ? out : value
|
|
78
|
+
}
|
|
79
|
+
return value
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Walk a CDS type definition recursively alongside `data`, hashing PII leaves.
|
|
83
|
+
// Covers action/function return shapes: named type refs, structs, arrays, scalar arrays.
|
|
84
|
+
// Depth guard (20) prevents runaway on self-referential types.
|
|
85
|
+
function pseudonymizeByType(data, typeDef, propName, session, model, strictMasking, depth = 0) {
|
|
86
|
+
if (data == null || typeDef == null || depth > 20) return data
|
|
87
|
+
|
|
88
|
+
// Named type ref → resolve then recurse; unresolved scalar → hash if PII.
|
|
89
|
+
if (typeDef.type && !typeDef.elements && !typeDef.items) {
|
|
90
|
+
const resolved = model.definitions?.[typeDef.type]
|
|
91
|
+
if (resolved)
|
|
92
|
+
return pseudonymizeByType(data, resolved, propName, session, model, strictMasking, depth + 1)
|
|
93
|
+
if (shouldHash(typeDef) && hasPersonalDataAnnotation(typeDef)) {
|
|
94
|
+
if (!strictMasking && typeDef["@Common.Masked"] === false) return data
|
|
95
|
+
return session.pseudonymize(data, propName)
|
|
96
|
+
}
|
|
97
|
+
return data
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Array: `many String @PersonalData` carries the annotation on the arrayed node, not
|
|
101
|
+
// on items — detect and hash each scalar directly; otherwise recurse per item.
|
|
102
|
+
if (typeDef.items) {
|
|
103
|
+
if (!Array.isArray(data)) return data
|
|
104
|
+
const scalarPii =
|
|
105
|
+
hasPersonalDataAnnotation(typeDef) &&
|
|
106
|
+
!typeDef.items.elements &&
|
|
107
|
+
!typeDef.items.items &&
|
|
108
|
+
shouldHash(typeDef.items)
|
|
109
|
+
if (scalarPii && (strictMasking || typeDef["@Common.Masked"] !== false))
|
|
110
|
+
return data.map((item) => (item == null ? item : session.pseudonymize(item, propName)))
|
|
111
|
+
return data.map((item) =>
|
|
112
|
+
pseudonymizeByType(item, typeDef.items, propName, session, model, strictMasking, depth + 1),
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Struct → recurse into each element.
|
|
117
|
+
if (typeDef.elements) {
|
|
118
|
+
if (typeof data !== "object" || Array.isArray(data)) return data
|
|
119
|
+
for (const [name, elDef] of Object.entries(typeDef.elements)) {
|
|
120
|
+
if (!(name in data) || data[name] == null) continue
|
|
121
|
+
data[name] = pseudonymizeByType(
|
|
122
|
+
data[name],
|
|
123
|
+
elDef,
|
|
124
|
+
name,
|
|
125
|
+
session,
|
|
126
|
+
model,
|
|
127
|
+
strictMasking,
|
|
128
|
+
depth + 1,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
return data
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Inline scalar leaf.
|
|
135
|
+
if (shouldHash(typeDef) && hasPersonalDataAnnotation(typeDef)) {
|
|
136
|
+
if (!strictMasking && typeDef["@Common.Masked"] === false) return data
|
|
137
|
+
return session.pseudonymize(data, propName)
|
|
138
|
+
}
|
|
139
|
+
return data
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Pseudonymize PII in a decoded tool result object (mutates in place).
|
|
143
|
+
// Queries: CQL-derived field discovery. Actions/functions: recursive type walk.
|
|
144
|
+
export function pseudonymizeToolResult({
|
|
145
|
+
decoded,
|
|
146
|
+
toolName,
|
|
147
|
+
cql,
|
|
148
|
+
strictMasking = false,
|
|
149
|
+
session,
|
|
150
|
+
srv,
|
|
151
|
+
model,
|
|
152
|
+
}) {
|
|
153
|
+
if (!decoded || !session || !srv || !model) return
|
|
154
|
+
const payload = decoded?.data ?? decoded?.result
|
|
155
|
+
if (payload == null) return
|
|
156
|
+
|
|
157
|
+
if (toolName === "query" || toolName.endsWith("_query")) {
|
|
158
|
+
const annotatedFields = discoverElementsToBeMasked(model, srv, cql, strictMasking)
|
|
159
|
+
if (!annotatedFields.size) return
|
|
160
|
+
pseudonymizeData(payload, annotatedFields, session)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Action/function: walk return-type definition. Top-level scalar has no element
|
|
165
|
+
// name → use tool name as hash prefix.
|
|
166
|
+
const returnType = actionReturnType(model, srv, toolName)
|
|
167
|
+
if (!returnType) return
|
|
168
|
+
const key = decoded.data != null ? "data" : "result"
|
|
169
|
+
decoded[key] = pseudonymizeByType(payload, returnType, toolName, session, model, strictMasking)
|
|
170
|
+
}
|
|
@@ -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,72 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
import { generatePseudonymTag } from "../store.js"
|
|
3
|
+
import { circuitBreaker, retry } from "../../utils/resilience.js"
|
|
4
|
+
|
|
5
|
+
const LOG = cds.log("agents")
|
|
6
|
+
|
|
7
|
+
// 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
|
|
8
|
+
const DPP_CATEGORIES = new Map([
|
|
9
|
+
["PERSON", "person"],
|
|
10
|
+
["URI:EMAIL", "email"],
|
|
11
|
+
["PHONE", "phone"],
|
|
12
|
+
["ADDRESS", "address"],
|
|
13
|
+
["URI:URL", "url"],
|
|
14
|
+
["URI:IP", "ip"],
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
const PAL_CONNECTIVITY_ERROR = "73003604"
|
|
18
|
+
const retryMw = retry(5, {
|
|
19
|
+
shouldRetry: (err) => String(err?.message ?? "").includes(PAL_CONNECTIVITY_ERROR),
|
|
20
|
+
})
|
|
21
|
+
const circuitBreakerMw = circuitBreaker()
|
|
22
|
+
|
|
23
|
+
export async function anonymize(text, seed) {
|
|
24
|
+
if (!text || !seed) return { text }
|
|
25
|
+
try {
|
|
26
|
+
const resilientPseudonymize = circuitBreakerMw({
|
|
27
|
+
fn: retryMw({ fn: pseudonymizeText }),
|
|
28
|
+
context: { uri: "hana-script-server" },
|
|
29
|
+
})
|
|
30
|
+
return await resilientPseudonymize({ text, seed })
|
|
31
|
+
} catch (err) {
|
|
32
|
+
LOG.error(`SAP HANA Cloud NLS based pseudonymization disabled due to error: `, err)
|
|
33
|
+
return { text }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function pseudonymizeText({ text, seed }) {
|
|
38
|
+
const res = await cds.run(buildPalCallSql(), [text])
|
|
39
|
+
const entities = res.changes[1]
|
|
40
|
+
const piiEntities = entities
|
|
41
|
+
.filter((e) => e.TYPE === "ner" && DPP_CATEGORIES.has(e.ENTITY))
|
|
42
|
+
// Sort back to front so offsets stay valid after replacement
|
|
43
|
+
.sort((a, b) => b.GLOBAL_OFFSET - a.GLOBAL_OFFSET)
|
|
44
|
+
|
|
45
|
+
const mappings = []
|
|
46
|
+
const seen = new Set()
|
|
47
|
+
let result = text
|
|
48
|
+
for (const row of piiEntities) {
|
|
49
|
+
if (result.slice(row.GLOBAL_OFFSET, row.GLOBAL_OFFSET + row.TOKEN.length) !== row.TOKEN)
|
|
50
|
+
continue
|
|
51
|
+
const category = DPP_CATEGORIES.get(row.ENTITY)
|
|
52
|
+
const tag = generatePseudonymTag(seed, row.TOKEN, category)
|
|
53
|
+
result = `${result.slice(0, row.GLOBAL_OFFSET)}${tag}${result.slice(row.GLOBAL_OFFSET + row.TOKEN.length)}`
|
|
54
|
+
if (!seen.has(row.TOKEN)) {
|
|
55
|
+
seen.add(row.TOKEN)
|
|
56
|
+
const hash = tag.slice(category.length + 1)
|
|
57
|
+
mappings.push([tag, row.TOKEN])
|
|
58
|
+
mappings.push([hash, row.TOKEN])
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { text: result, mappings, textAnalysisResults: entities }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildPalCallSql() {
|
|
65
|
+
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
|
|
66
|
+
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;
|
|
67
|
+
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;
|
|
68
|
+
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);
|
|
69
|
+
textAnalysisResults =
|
|
70
|
+
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;
|
|
71
|
+
END;`
|
|
72
|
+
}
|
|
@@ -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/models/aicore.js
CHANGED
|
@@ -11,6 +11,11 @@ import {
|
|
|
11
11
|
withPromptCachingOptions,
|
|
12
12
|
withPromptCachingParams,
|
|
13
13
|
} from "../utils/caching.js"
|
|
14
|
+
import {
|
|
15
|
+
isAdditiveCacheUsageModel,
|
|
16
|
+
normalizeAdditiveCacheUsage,
|
|
17
|
+
normalizeAdditiveCacheUsageChunk,
|
|
18
|
+
} from "../utils/usage.js"
|
|
14
19
|
import { syncSystemPrompt } from "../telemetry/mlflow/prompts.js"
|
|
15
20
|
|
|
16
21
|
const LOG = cds.log("agents")
|
|
@@ -29,8 +34,12 @@ function noTemp0Support(model) {
|
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
const llmError = (err) => {
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
if (err.rootCause?.message.match(/Could not find service credentials for AI Core/)) {
|
|
38
|
+
throw err.rootCause
|
|
39
|
+
} else {
|
|
40
|
+
LOG.error(`AI Core request failed!`, err.rootCause || err)
|
|
41
|
+
cds.error(cds.i18n.messages.at("LLM_UNAVAILABLE"))
|
|
42
|
+
}
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
class _InstrumentedOrchestrationClient extends OrchestrationClient {
|
|
@@ -83,6 +92,8 @@ class _InstrumentedOrchestrationClient extends OrchestrationClient {
|
|
|
83
92
|
// works for every agent regardless of this flag (deep or managed alike).
|
|
84
93
|
// Default on; `streaming: false` opts out.
|
|
85
94
|
streaming: streaming !== false,
|
|
95
|
+
// p-retry from langchain = 0, retry for AI Core completion is still set to 2
|
|
96
|
+
maxRetries: 0,
|
|
86
97
|
onFailedAttempt: (err) => {
|
|
87
98
|
// Abort retries when circuit breaker is open (otherwise pRetry delays ~30-60s)
|
|
88
99
|
if (err.code === "EOPENBREAKER" || err.message === "Breaker is open") {
|
|
@@ -108,7 +119,8 @@ class _InstrumentedOrchestrationClient extends OrchestrationClient {
|
|
|
108
119
|
opts = _withMiddleware(this, opts)
|
|
109
120
|
const prepared = _prepareMessages(messages, { flatten, model, opts })
|
|
110
121
|
try {
|
|
111
|
-
|
|
122
|
+
const result = await super._generate(prepared.inputMessages, prepared.opts, runManager)
|
|
123
|
+
return isAdditiveCacheUsageModel(model) ? normalizeAdditiveCacheUsage(result) : result
|
|
112
124
|
} catch (err) {
|
|
113
125
|
llmError(err)
|
|
114
126
|
}
|
|
@@ -135,6 +147,7 @@ class _InstrumentedOrchestrationClient extends OrchestrationClient {
|
|
|
135
147
|
let turnHasToolCall = false
|
|
136
148
|
try {
|
|
137
149
|
for await (const chunk of super._streamResponseChunks(inputMessages, opts, runManager)) {
|
|
150
|
+
if (isAdditiveCacheUsageModel(model)) normalizeAdditiveCacheUsageChunk(chunk)
|
|
138
151
|
if (chunk.message?.tool_call_chunks?.length > 0) turnHasToolCall = true
|
|
139
152
|
const text = chunk.text || extractTextFromContentBlocks(chunk)
|
|
140
153
|
const reasoning = extractReasoningFromChunk(chunk)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ChatOpenAI } from "@langchain/openai"
|
|
2
|
+
|
|
3
|
+
export default class ChatOpenAIService extends ChatOpenAI {
|
|
4
|
+
constructor(name, options = {}) {
|
|
5
|
+
const { credentials = {} } = options
|
|
6
|
+
super({ ...options, configuration: credentials })
|
|
7
|
+
this.name = name
|
|
8
|
+
}
|
|
9
|
+
}
|