@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
@@ -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 }
@@ -19,6 +19,7 @@ import {
19
19
  resumeHitl,
20
20
  resumeTimeoutHitl,
21
21
  } from "./graph-executor/hitl.js"
22
+ import { registerShutdownHook } from "./graph-executor/crash-handler.js"
22
23
 
23
24
  const LOG = cds.log("agents")
24
25
 
@@ -145,6 +146,9 @@ class GraphExecutor {
145
146
  this._recursionLimit = options.recursionLimit ?? null
146
147
  /** @type {Map<string, AbortController>} per-task abort controllers */
147
148
  this._abortControllers = new Map()
149
+ /** @type {Map<string, string | undefined>} task tenant by task ID */
150
+ this._taskTenants = new Map()
151
+ registerShutdownHook(this)
148
152
  }
149
153
 
150
154
  /**
@@ -199,6 +203,7 @@ class GraphExecutor {
199
203
  // final visual (collapse to the last turn's bubble at task completion).
200
204
  let currentMsgId = null
201
205
  let thinkingCount = 0
206
+ let thinkingMsgId = null
202
207
  // Holds a trailing fragment of the previous chunk that is a prefix of a known
203
208
  // pseudonym hash. Prepended to the next chunk so split hashes are resolved correctly.
204
209
  let pendingPrefix = ""
@@ -247,6 +252,9 @@ class GraphExecutor {
247
252
  pendingPrefix = ""
248
253
  if (!raw) continue
249
254
 
255
+ if (thinkingMsgId !== null && currentMsgId !== thinkingMsgId) thinkingCount++
256
+ thinkingMsgId = currentMsgId
257
+
250
258
  // Hashes look like name-8hexchars and never contain spaces.
251
259
  // On non-last chunks, slice last token and append to next chunk
252
260
  // to avoid unresolved boundaries
@@ -275,7 +283,6 @@ class GraphExecutor {
275
283
  parts: [{ kind: "text", text }],
276
284
  },
277
285
  })
278
- if (lastChunk) thinkingCount++
279
286
  tokenCount++
280
287
  } else if (mode === "updates") {
281
288
  // The updates stream yields per-node deltas — { <node>: { messages: [oneNewMessage] } },
@@ -382,8 +389,6 @@ class GraphExecutor {
382
389
  reason,
383
390
  checkpointer: this._graph?.checkpointer,
384
391
  getModel: () => this._srv.send("buildModel"),
385
- // Summary runs after graph abort, so no execution-time grace is needed.
386
- timeout: 10_000,
387
392
  }),
388
393
  )
389
394
  }
@@ -397,6 +402,7 @@ class GraphExecutor {
397
402
  // Cooperative cancellation: per-task AbortController
398
403
  const controller = new AbortController()
399
404
  this._abortControllers.set(taskId, controller)
405
+ this._taskTenants.set(taskId, cds.context?.tenant)
400
406
 
401
407
  // A2A context for tracing
402
408
  if (!cds.context) {
@@ -406,6 +412,7 @@ class GraphExecutor {
406
412
  cds.context["agent.context.id"] = contextId
407
413
  cds.context["agent.service"] = serviceName
408
414
  cds.context["agent.eventBus"] = eventBus
415
+ cds.context["agent.request.metadata"] = requestContext.userMessage?.metadata ?? {}
409
416
 
410
417
  // REVISIT: Resolve graph early for pseudonymizeUserMessage. Mid-term move into beforeAgent together with Audit & Telemetry which rely on it
411
418
  const graph = await this._resolveGraph()
@@ -910,16 +917,19 @@ class GraphExecutor {
910
917
  eventBus._graphResult = { messages: result.messages || [] }
911
918
  }
912
919
 
920
+ const usageMeta =
921
+ usageData?.total_tokens > 0 ? { "sap.cds.agents.token-usage": usageData } : undefined
913
922
  eventBus.publish({
914
923
  kind: "status-update",
915
924
  taskId,
916
925
  contextId,
917
926
  status: {
918
927
  state: "completed",
919
- message: agentMessage(output),
928
+ message: agentMessage(output, undefined, usageMeta),
920
929
  timestamp: new Date().toISOString(),
921
930
  },
922
931
  final: true,
932
+ metadata: usageMeta,
923
933
  })
924
934
  } catch (err) {
925
935
  // Aborted (client disconnect or tasks/cancel) — publish canceled, not failed
@@ -1049,6 +1059,7 @@ class GraphExecutor {
1049
1059
  setSpanAttrs(rootSpan, linkTraceToPrompt())
1050
1060
 
1051
1061
  this._abortControllers.delete(taskId)
1062
+ this._taskTenants.delete(taskId)
1052
1063
  metrics.concurrentExecutions.add(-1, mAttrs)
1053
1064
 
1054
1065
  // Update task record with usage data (non-blocking, best effort)
@@ -1146,13 +1157,16 @@ function aggregateUsageData(messages) {
1146
1157
  cache_creation_input_tokens: 0,
1147
1158
  cache_read_input_tokens: 0,
1148
1159
  reasoning_tokens: 0,
1160
+ context_tokens: 0,
1149
1161
  }
1150
1162
  for (let i = 0; i < messages.length; i++) {
1151
1163
  if (!messages[i].usage_metadata) continue
1152
1164
  const innerRes = convertUsageData(messages[i].usage_metadata)
1153
1165
  Object.keys(innerRes).forEach((k) => {
1154
- if (innerRes[k] != null) result[k] += innerRes[k]
1166
+ if (k in result && innerRes[k] != null) result[k] += innerRes[k]
1155
1167
  })
1168
+ if (innerRes.input_tokens != null)
1169
+ result.context_tokens = innerRes.input_tokens + (innerRes.output_tokens || 0)
1156
1170
  }
1157
1171
  return result
1158
1172
  }
@@ -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 name = srv?.options?.agent?.llm || srv?.definition?.["@agent.llm"] || "llm"
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) tool.name = `${prefix}${tool.name}`
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(", ")}`,
@@ -78,9 +78,9 @@ function formatToolResult({ text, files }) {
78
78
  /**
79
79
  * Wrap an A2A client as a LangChain tool the agent can call.
80
80
  */
81
- function createA2ATool(client, agentCard) {
81
+ function createA2ATool(client, agentCard, serviceName) {
82
82
  const subagent = agentCard.name
83
- return tool(
83
+ const t = tool(
84
84
  async ({ message }) => {
85
85
  try {
86
86
  const messageId = cds.utils.uuid()
@@ -116,6 +116,8 @@ function createA2ATool(client, agentCard) {
116
116
  }),
117
117
  },
118
118
  )
119
+ t.metadata = { ...t.metadata, kind: "agent", agentName: agentCard.name, serviceName }
120
+ return t
119
121
  }
120
122
 
121
123
  export async function buildSubAgentToolLocally(serviceName) {
@@ -130,7 +132,7 @@ export async function buildSubAgentToolLocally(serviceName) {
130
132
 
131
133
  const { RequestContext, DefaultExecutionEventBus } = await import("@a2a-js/sdk/server")
132
134
 
133
- return tool(
135
+ const localTool = tool(
134
136
  async ({ message }) => {
135
137
  const taskId = cds.utils.uuid()
136
138
  const contextId = cds.utils.uuid()
@@ -221,6 +223,13 @@ export async function buildSubAgentToolLocally(serviceName) {
221
223
  }),
222
224
  },
223
225
  )
226
+ localTool.metadata = {
227
+ ...localTool.metadata,
228
+ kind: "agent",
229
+ agentName: agentCard.name,
230
+ serviceName,
231
+ }
232
+ return localTool
224
233
  }
225
234
 
226
235
  export async function buildSubAgentToolFromConnection(serviceName) {
@@ -334,7 +343,7 @@ export async function buildSubAgentToolFromConnection(serviceName) {
334
343
  )
335
344
  const client = await factory.createFromAgentCard(agentCard)
336
345
 
337
- return createA2ATool(client, agentCard)
346
+ return createA2ATool(client, agentCard, serviceName)
338
347
  }
339
348
 
340
349
  export function buildSubAgentTool(serviceName) {
@@ -1,32 +1,35 @@
1
1
  import cds from "@sap/cds"
2
2
  import { getDescription } from "../../lib/utils/utils.js"
3
+ const LOG = cds.log("agents")
3
4
 
4
5
  export function buildSystemPrompt(srv) {
5
- const serviceDesc = getDescription(srv.definition) || `Service ${srv.name}`
6
-
7
- return [
8
- `You are an AI assistant for the "${srv.name}" service.`,
9
- serviceDesc,
10
- "",
11
- "Always use the provided tools to answer questions - do not make up data.",
12
- "Use the `describe` tool to get information about the service's entities and actions if needed.",
13
- "Use the `query` tool to read data from entities.",
14
- cds.env.agents?.per_action_tool !== false
15
- ? "Call action and function tools directly by name."
16
- : "Use the `call` tool to invoke actions and functions.",
17
- ...(cds.env.agents?.fileIO?.enabled
18
- ? [
19
- "When the user's message contains '[Uploaded files: ...]', use the `read_file` tool to read each listed file before answering.",
20
- "Use `emit_file_part` to return files in your response.",
21
- ]
22
- : []),
23
- "Be concise and helpful.",
24
- ...(cds.env.agents?.masking
25
- ? [
26
- `## How to handle pseudonymized values`,
27
- `Never comment on pseudonymization and that you had to work with pseudonymized values but use the pseudonymized values in responses like it would be regular values!`,
28
- `The user sees the actual values and thus should not be made aware of any pseudonymization that has taken place internally.`,
29
- ]
30
- : []),
31
- ].join("\n")
6
+
7
+ let text = `
8
+
9
+ You are an AI assistant for the local CAP service \`${srv.name}\`.
10
+ ${ getDescription(srv.definition) || '' }
11
+ Use the provided tools to answer questions:
12
+
13
+ - the \`describe\` tool to learn about the service's entities and actions.
14
+ - the \`query\` tool to read data from entities.
15
+ - the \`call\` tool to invoke actions, or individual action tools.
16
+
17
+ IMPORTANT: Don't guess element names when \`query\`ing entities.
18
+ Use the \`describe\` tool instead to find correct element names.
19
+
20
+ ${ cds.env.agents?.fileIO?.enabled ? `## File I/O
21
+ When the incomming request message contains '[Uploaded files: ...]',
22
+ use the \`read_file\` tool to read each listed file before answering.
23
+ Use the \`emit_file_part\` tool to return files in your response.` : ''}
24
+
25
+ ${ cds.env.agents?.masking ? `## How to handle pseudonymized values
26
+ Never comment on pseudonymization and that you had to work with pseudonymized values
27
+ but use the pseudonymized values in responses like it would be regular values!
28
+ The user sees the actual values and thus should not be made aware of any
29
+ pseudonymization that has taken place internally.` : ''}
30
+
31
+ `.replace(/ {4,}/g,'').trim()
32
+
33
+ if (LOG._debug) LOG.debug (srv.name, '-', "using system prompt:", '\n\n'+ text +'\n')
34
+ return text
32
35
  }
@@ -183,31 +183,32 @@ class CallActionTool extends DynamicStructuredTool {
183
183
  * @param {object} srv - CDS ApplicationService
184
184
  */
185
185
  export function generateTools(srv) {
186
- const entities = getFilteredEntities(srv)
187
- const actions = getFilteredActions(srv)
186
+ const entities = getFilteredEntities(srv), has_entities = Object.keys(entities).length > 0
187
+ const actions = getFilteredActions(srv), has_actions = Object.keys(actions).length > 0
188
188
 
189
189
  const tools = []
190
190
 
191
- // Query tool — one tool for reading all entities
192
- const entityNames = Object.keys(entities)
193
- if (entityNames.length > 0) {
194
- tools.push(new GenericReadTool(srv, entities))
195
- }
196
-
197
191
  // Describe tool — introspect service model
198
- const actionNames = Object.keys(actions)
199
- if (entityNames.length > 0 || actionNames.length > 0) {
192
+ if (has_entities || has_actions) {
193
+ LOG.debug(srv.name, '–', `adding generic 'describe' tool`)
200
194
  tools.push(new DescribeTool(srv, entities, actions))
201
195
  }
202
196
 
197
+ // Query tool — one tool for reading all entities
198
+ if (has_entities) {
199
+ LOG.debug(srv.name, '–', `adding generic 'query' entity tool`)
200
+ tools.push(new GenericReadTool(srv, entities))
201
+ }
202
+
203
203
  // Action/function tools — per-action (default) or combined call action
204
- const usePerActionTools = cds.env.agents?.per_action_tool !== false
205
- if (actionNames.length > 0) {
206
- if (usePerActionTools) {
204
+ if (has_actions) {
205
+ if (cds.env.mcp?.per_action_tool) {
207
206
  for (const [name, action] of Object.entries(actions)) {
207
+ LOG.debug(srv.name, '–', `adding specific tool to call action '${name}'`)
208
208
  tools.push(new PerActionTool(srv, name, action))
209
209
  }
210
210
  } else {
211
+ LOG.debug(srv.name, '–', `adding generic 'call' action tool`)
211
212
  tools.push(new CallActionTool(srv, actions))
212
213
  }
213
214
  }