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