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