@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.
Files changed (37) hide show
  1. package/_i18n/messages.properties +2 -0
  2. package/cds-plugin.js +13 -10
  3. package/lib/agents/middleware/content-filter.js +4 -0
  4. package/lib/agents/middleware/hitl-decision-note-injector.js +18 -6
  5. package/lib/agents/middleware/hitl.js +17 -6
  6. package/lib/agents/middleware/index.js +1 -1
  7. package/lib/agents/middleware/masking.js +6 -3
  8. package/lib/agents/middleware/remote-mcp.js +5 -2
  9. package/lib/agents/middleware/status-update.js +209 -76
  10. package/lib/agents/middleware/tool-wrap.js +1 -2
  11. package/lib/agents/summarize-on-timeout.js +5 -2
  12. package/lib/config/local.js +121 -8
  13. package/lib/eval/eval-run.js +50 -4
  14. package/lib/masking/unstructured/hana.js +13 -5
  15. package/lib/models/aicore.js +16 -3
  16. package/lib/models/openai.js +9 -0
  17. package/lib/preview/chat.html +561 -93
  18. package/lib/telemetry/chat-tracing.js +2 -0
  19. package/lib/telemetry/mlflow/evaluation.js +83 -4
  20. package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +39 -5
  21. package/lib/telemetry/mlflow/exporter/MlflowExporter.js +43 -4
  22. package/lib/telemetry/mlflow/index.js +1 -7
  23. package/lib/telemetry/mlflow/prompts.js +15 -11
  24. package/lib/utils/markdown.js +1 -8
  25. package/lib/utils/resilience.js +2 -1
  26. package/lib/utils/toml.js +67 -0
  27. package/lib/utils/usage.js +34 -0
  28. package/lib/utils/utils.js +43 -1
  29. package/package.json +31 -26
  30. package/srv/handlers/graph-executor/crash-handler.js +47 -0
  31. package/srv/handlers/graph-executor/hitl.js +27 -0
  32. package/srv/handlers/graph-executor.js +19 -5
  33. package/srv/handlers/index.js +6 -1
  34. package/srv/handlers/mcp-tools.js +11 -2
  35. package/srv/handlers/subagent-tools.js +13 -4
  36. package/srv/handlers/system-prompt.js +30 -27
  37. 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
- return exporter.createRun(experimentId, name)
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()?.closeRun(runId)
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", { name, prompt: { description } })
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: [{ key: "mlflow.source.type", value: "LOCAL" }],
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 logMetric(runId, key, value) {
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: 0,
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 srvName = cds.context?.["agent.service"]
52
- if (!srvName) return
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 sysMsg.content === "string"
57
- ? sysMsg.content
58
- : Array.isArray(sysMsg.content)
59
- ? sysMsg.content
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
- const srv = cds.services[srvName]
66
- if (!srv) return
67
- syncPromptVersion(resolvePromptName(srv), text).catch(() => {})
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.
@@ -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.
@@ -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
+ }
@@ -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.7",
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
- "prettier": "npx -y prettier@3 --write .",
34
- "prettier:check": "npx -y prettier@3 --check ."
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()