@cap-js/agents 0.9.7 → 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/_i18n/messages.properties +2 -0
- package/cds-plugin.js +13 -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 +1 -1
- package/lib/agents/middleware/masking.js +6 -3
- package/lib/agents/middleware/remote-mcp.js +5 -2
- 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/unstructured/hana.js +13 -5
- package/lib/models/aicore.js +16 -3
- package/lib/models/openai.js +9 -0
- package/lib/preview/chat.html +561 -93
- package/lib/telemetry/chat-tracing.js +2 -0
- 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/utils/markdown.js +1 -8
- 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 +31 -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 +19 -5
- 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 -27
- package/srv/handlers/tools.js +14 -13
|
@@ -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)
|
|
@@ -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.
|
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
|
|
|
@@ -13,13 +13,6 @@ export function serviceSourceDir(srv) {
|
|
|
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"))
|
|
@@ -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,6 +61,7 @@
|
|
|
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",
|
|
@@ -61,6 +76,7 @@
|
|
|
61
76
|
"@cap-js/sqlite": "^3",
|
|
62
77
|
"@sap/cds-mtxs": "^4",
|
|
63
78
|
"@toon-format/toon": ">=2.3",
|
|
79
|
+
"@vitest/coverage-v8": "^5.0.1",
|
|
64
80
|
"acorn": "^8.18.0",
|
|
65
81
|
"deepagents": "^1.10.2",
|
|
66
82
|
"openevals": "^0.2.0"
|
|
@@ -186,6 +202,9 @@
|
|
|
186
202
|
"anthropic": {
|
|
187
203
|
"impl": "@cap-js/agents/lib/models/anthropic"
|
|
188
204
|
},
|
|
205
|
+
"openai": {
|
|
206
|
+
"impl": "@cap-js/agents/lib/models/openai"
|
|
207
|
+
},
|
|
189
208
|
"llm-mock": {
|
|
190
209
|
"impl": "@cap-js/agents/lib/models/mock"
|
|
191
210
|
}
|
|
@@ -222,19 +241,5 @@
|
|
|
222
241
|
"folders": {
|
|
223
242
|
"srvs": "srv/*"
|
|
224
243
|
}
|
|
225
|
-
}
|
|
226
|
-
"workspaces": [
|
|
227
|
-
"tests/projects/bookshop/",
|
|
228
|
-
"tests/projects/deep-agent/",
|
|
229
|
-
"tests/projects/mtx/mtx/sidecar/",
|
|
230
|
-
"tests/projects/telemetry-debug/",
|
|
231
|
-
"tests/projects/telemetry-v1/",
|
|
232
|
-
"tests/projects/travel/travel-agent/",
|
|
233
|
-
"tests/projects/travel/xflights/",
|
|
234
|
-
"tests/projects/travel/leisure-services/",
|
|
235
|
-
"tests/sidecar-projects/java-bookshop/agent/sidecar",
|
|
236
|
-
"tests/sidecar-projects/java-bookshop",
|
|
237
|
-
"tests/sidecar-projects/node-bookshop/agent/sidecar",
|
|
238
|
-
"tests/sidecar-projects/node-bookshop"
|
|
239
|
-
]
|
|
244
|
+
}
|
|
240
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()
|