@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
@@ -3,6 +3,8 @@
3
3
  agent_status_calling_tools = Calling {0}
4
4
  #XMSG Status info message send to the user client when an entity is being queried. {0} = entity name
5
5
  agent_status_querying = Querying {0}
6
+ #XMSG Status info message send to the user client when a service model is being inspected. {0} = service/entity name
7
+ agent_status_describing = Inspecting {0}
6
8
  #XMSG Status info message send to the user client after a tool result returned and the agent is processing it
7
9
  agent_status_processing_response = Processing tool response
8
10
  #XMSG Status info message send to the user client after multiple tool results returned and the agent is processing it
package/cds-plugin.js CHANGED
@@ -69,24 +69,27 @@ cds.on("bootstrap", (app) => {
69
69
 
70
70
  !(function cds_requires_llm() {
71
71
  cds.on("served", async () => {
72
- if (
73
- cds.requires.llm === "anthropic" ||
74
- cds.requires.llm?.kind === "anthropic" ||
75
- cds.requires.llm === "auto" ||
76
- cds.requires.llm?.kind === "auto"
77
- ) {
72
+ const kind = cds.requires.llm?.kind ?? cds.requires.llm
73
+ if (kind === "auto") {
78
74
  const { resolve_config } = await import("./lib/config/local.js")
79
- let options = cds.requires.llm
80
- if (options === "auto") options = { kind: "auto" }
81
- cds.env.requires.llm = resolve_config(options)
75
+ cds.env.requires.llm = resolve_config({ kind: "auto" })
76
+ }
77
+ if (kind === "anthropic") {
78
+ const { resolve_anthropic_config } = await import("./lib/config/local.js")
79
+ cds.env.requires.llm = resolve_anthropic_config(cds.requires.llm)
80
+ }
81
+ if (kind === "openai") {
82
+ const { resolve_openai_config } = await import("./lib/config/local.js")
83
+ cds.env.requires.llm = resolve_openai_config(cds.requires.llm)
82
84
  }
83
85
 
84
86
  const config = cds.requires.llm,
85
87
  credentials = {}
86
- const { url, destination, anthropicApiUrl, apiKey } = config?.credentials || {}
88
+ const { url, destination, anthropicApiUrl, baseURL, apiKey } = config?.credentials || {}
87
89
  if (url) credentials.url = url
88
90
  if (destination) credentials.destination = destination
89
91
  if (anthropicApiUrl) credentials.anthropicApiUrl = anthropicApiUrl
92
+ if (baseURL) credentials.baseURL = baseURL
90
93
  if (apiKey) credentials.apiKey = "***"
91
94
  LOG.info(`cds.connect.to 'llm' with:`, { ...config, credentials })
92
95
  })
@@ -47,6 +47,7 @@ export async function contentFilterMiddleware(model) {
47
47
  },
48
48
  {
49
49
  disableStreaming: true,
50
+ maxRetries: 0,
50
51
  customRequestConfig: {
51
52
  middleware: [timeout(10_000), retry(2)],
52
53
  },
@@ -112,6 +113,9 @@ export async function contentFilterMiddleware(model) {
112
113
  try {
113
114
  await inputFilterModel.invoke(filterMessages)
114
115
  } catch (err) {
116
+ if (err.rootCause?.message.match(/Could not find service credentials for AI Core/)) {
117
+ throw err.rootCause
118
+ }
115
119
  const { status, data } = extractErrorDetails(err)
116
120
  const isFilterBlock =
117
121
  /Filtering Module/i.test(data?.error?.location || "") && status === 400
@@ -2,17 +2,29 @@ import { createMiddleware } from "langchain"
2
2
  import { HumanMessage } from "@langchain/core/messages"
3
3
  import { z } from "zod"
4
4
 
5
- // Injects user HITL decisions before the model continues after a rejection or edit.
5
+ // Injects user HITL decisions (edit/reject) as a HumanMessage before the model
6
+ // continues, so the model treats them as deliberate user decisions rather than
7
+ // tool failures.
8
+ //
9
+ // LangChain's humanInTheLoop middleware routes these decisions differently:
10
+ // - edit → jumpTo undefined → normal flow → `beforeModel` runs. Inject + clear here.
11
+ // - reject → jumpTo "model" → re-enters the model node directly, SKIPPING all
12
+ // `beforeModel` hooks. `wrapModelCall` still runs, so it injects as a
13
+ // fallback when `beforeModel` did not consume the note.
14
+ //
15
+ // So we use `wrapModel` to inject the message and clear the note after the model call.
6
16
  export function hitlDecisionNoteInjectorMiddleware() {
7
17
  return createMiddleware({
8
18
  name: "hitlDecisionNoteInjectorMiddleware",
9
19
  stateSchema: z.object({ _hitlDecisionNote: z.string().optional() }),
10
- beforeModel: async (state) => {
20
+ wrapModelCall: async (request, handler) => {
21
+ const note = request.state?._hitlDecisionNote
22
+ if (!note) return handler(request)
23
+ return handler({ ...request, messages: [...(request.messages || []), new HumanMessage(note)] })
24
+ },
25
+ afterModel: async (state) => {
11
26
  if (!state._hitlDecisionNote) return
12
- return {
13
- messages: [new HumanMessage(state._hitlDecisionNote)],
14
- _hitlDecisionNote: undefined,
15
- }
27
+ return { _hitlDecisionNote: undefined }
16
28
  },
17
29
  })
18
30
  }
@@ -1,13 +1,24 @@
1
1
  import { humanInTheLoopMiddleware as hitl } from "langchain"
2
2
  import { hitlDecisionNoteInjectorMiddleware } from "./hitl-decision-note-injector.js"
3
3
 
4
- function buildHitlInterruptMap(srv, tools = []) {
4
+ const ALLOWED_DECISIONS = ["approve", "reject", "edit"]
5
+
6
+ const isHitlAction = (action) => action?.["@agent.hitl"] ?? action?.["@Common.IsActionCritical"]
7
+
8
+ export function buildHitlInterruptMap(srv, tools = []) {
5
9
  return tools.reduce((interruptOn, tool) => {
6
- if (
7
- srv.actions[tool.name]?.["@agent.hitl"] ??
8
- srv.actions[tool.name]?.["@Common.IsActionCritical"]
9
- ) {
10
- interruptOn[tool.name] = { allowedDecisions: ["approve", "reject", "edit"] }
10
+ // Per-action tool: its name IS the action name, so a static entry gates it.
11
+ if (isHitlAction(srv.actions[tool.name])) {
12
+ interruptOn[tool.name] = { allowedDecisions: ALLOWED_DECISIONS }
13
+ }
14
+ // Generic @cap-js/mcp "call" tool: one tool fronts every action, with the
15
+ // target action in args.action. The tool name matches no action, so gate
16
+ // per-call via `when`, which inspects the requested action at invoke time.
17
+ else if (tool.name === "call" && Object.values(srv.actions).some(isHitlAction)) {
18
+ interruptOn[tool.name] = {
19
+ allowedDecisions: ALLOWED_DECISIONS,
20
+ when: (request) => Boolean(isHitlAction(srv.actions[request.toolCall?.args?.action])),
21
+ }
11
22
  }
12
23
  return interruptOn
13
24
  }, {})
@@ -24,7 +24,7 @@ export default async function buildMiddleware(srv, options = {}) {
24
24
  await contentFilterMiddleware(model),
25
25
  await agentActionsMiddleware(),
26
26
  patchToolCallsMiddleware(),
27
- await statusUpdateMiddleware(),
27
+ await statusUpdateMiddleware(tools),
28
28
  ...(await humanInTheLoopMiddleware(srv, tools)),
29
29
  toolSelectionMiddleware(),
30
30
  toolWrapMiddleware(srv),
@@ -54,7 +54,7 @@ export function masking(srv) {
54
54
 
55
55
  // Decode TOON or JSON — pseudonymize operates on decoded objects
56
56
  const { decoded, format } = decodeContent(toolMessage.content)
57
- if (decoded) {
57
+ if (decoded && typeof decoded !== "string") {
58
58
  const result = await pseudonymize(
59
59
  {
60
60
  data: decoded,
@@ -81,8 +81,11 @@ export function masking(srv) {
81
81
  outMessage = new ToolMessage({ ...toolMessage, content: encoded })
82
82
  }
83
83
  } else {
84
- const session = cds.context["agent.pseudonyms"]
85
- outMessage = session.scrubText(toolMessage.content)
84
+ // Plain string (e.g. read_file output) or unparseable — scrub using session mappings.
85
+ const scrubbed = session.scrubText(toolMessage.content)
86
+ if (scrubbed !== toolMessage.content) {
87
+ outMessage = new ToolMessage({ ...toolMessage, content: scrubbed })
88
+ }
86
89
  }
87
90
  }
88
91
 
@@ -2,7 +2,7 @@ import cds from "@sap/cds"
2
2
  import { createMiddleware } from "langchain"
3
3
  import { ToolMessage } from "@langchain/core/messages"
4
4
  import { MultiServerMCPClient } from "@langchain/mcp-adapters"
5
- import { toolName } from "../../utils/utils.js"
5
+ import { toolName, mcpToolKind } from "../../utils/utils.js"
6
6
 
7
7
  const LOG = cds.log("agents:mcp")
8
8
 
@@ -27,7 +27,10 @@ export function remoteMcpMiddleware() {
27
27
  })
28
28
  let raw = await client.getTools()
29
29
  if (toolFilter?.length) raw = raw.filter((t) => toolFilter.includes(t.name))
30
- for (const t of raw) t.name = toolName(`${serviceName}_${t.name}`)
30
+ for (const t of raw) {
31
+ t.metadata = { ...t.metadata, serviceName, kind: mcpToolKind(t.name) }
32
+ t.name = toolName(`${serviceName}_${t.name}`)
33
+ }
31
34
  cache[mcpUrl] = { serviceName, tools: raw }
32
35
  LOG.debug(
33
36
  `Got ${raw.length} MCP tools from ${mcpUrl}: ${raw.map((t) => t.name).join(", ")}`,
@@ -1,63 +1,103 @@
1
1
  import cds from "@sap/cds"
2
2
  import { createMiddleware } from "langchain"
3
- import { ToolMessage } from "@langchain/core/messages"
3
+ import { AIMessage, ToolMessage } from "@langchain/core/messages"
4
+ import { resolvePseudonyms } from "../../masking/index.js"
5
+ import { serviceLabel, declaredServiceLabel, mcpToolKind } from "../../utils/utils.js"
4
6
 
5
7
  /**
6
- * Resolves a human-readable label for a tool call.
7
- *
8
- * - query tool → resolves entity label from CDS model: @Common.Label, @title, or i18n
9
- * - actions → resolves label from action definition
10
- * - fallback → tool name as-is
8
+ * Resolve the metadata for a tool call, or undefined when the tool is unknown.
9
+ * Static tools come from the precomputed `toolMeta` map; remote MCP tools are
10
+ * resolved per-request from cds.context.__mcpDynamicTools.
11
11
  */
12
- function resolveToolLabel(tc) {
13
- const serviceName = cds.context?.["agent.service"]
14
- const srv = serviceName && cds.services?.[serviceName]
15
- const model = cds.context?.model ?? cds.model
12
+ function resolveMeta(tc, toolMeta) {
13
+ const meta = toolMeta.get(tc.name)
14
+ if (meta) return meta
16
15
 
17
- // Query tool: extract entity target and resolve its label
18
- if (tc.name === "query" || tc.name?.endsWith("_query")) {
19
- let entityName = tc.args?.entity ? `${serviceName}.${tc.args.entity}` : undefined
20
-
21
- // SQL format: no entity arg — parse SQL to extract FROM target
22
- if (!entityName && tc.args?.cql) {
23
- try {
24
- const cqn = cds.parse.cql(tc.args.cql)
25
- const ref0 = cqn.SELECT?.from?.ref?.[0]
26
- const targetName = ref0?.id ?? ref0
27
- if (targetName && serviceName && !targetName.startsWith(serviceName + ".")) {
28
- entityName = `${serviceName}.${targetName}`
29
- } else {
30
- entityName = targetName
31
- }
32
- } catch {
33
- /* fallback to tc.name below */
34
- }
35
- }
36
- if (entityName) {
37
- const entityDef = model.definitions?.[entityName]
38
- if (entityDef) {
39
- const label = cds.i18n?.labels?.at(entityDef)
40
- if (label) return label
41
- }
42
- }
43
- // Fallback: strip service prefix if present
44
- if (entityName && srv?.name && entityName.startsWith(srv.name + ".")) {
45
- return entityName.slice(srv.name.length + 1)
16
+ const cache = cds.context?.__mcpDynamicTools
17
+ if (cache) {
18
+ for (const entry of Object.values(cache)) {
19
+ const t = entry.tools?.find((t) => t.name === tc.name)
20
+ if (t) return t.metadata
46
21
  }
47
- return entityName || tc.name
48
22
  }
23
+ return undefined
24
+ }
25
+
26
+ /**
27
+ * Resolve the kind ("query" | "describe" | "action" | "agent") for a tool call.
28
+ * Prefer the build-time `meta.kind` (classified from the unprefixed name, so it
29
+ * survives prefixing/truncation); else sniff `tc.name` for own-service tools.
30
+ */
31
+ function resolveToolKind(tc, meta) {
32
+ if (meta?.kind) return meta.kind
33
+ return mcpToolKind(tc.name)
34
+ }
35
+
36
+ /** Resolve an entity's display label (declared i18n label, else its simple name). */
37
+ function entityLabel(name, serviceName, model) {
38
+ const fq = serviceName && !name.startsWith(serviceName + ".") ? `${serviceName}.${name}` : name
39
+ const def = model?.definitions?.[fq]
40
+ return (
41
+ (def && cds.i18n?.labels?.at(def)) ||
42
+ (fq.includes(".") ? fq.slice(fq.lastIndexOf(".") + 1) : fq)
43
+ )
44
+ }
49
45
 
50
- // Action/function tools: resolve from service definition
51
- if (srv) {
52
- // Try as action on service
53
- const actionDef = model.definitions[`${srv.name}.${tc.name}`]
54
- if (actionDef) {
55
- const label = cds.i18n?.labels?.at(actionDef)
56
- if (label) return label
46
+ /** Resolve the query target: the `entity` arg, or the FROM target parsed from `cql`. */
47
+ function queryTarget(tc) {
48
+ if (tc.args?.entity) return tc.args.entity
49
+ if (tc.args?.cql) {
50
+ try {
51
+ const ref0 = cds.parse.cql(tc.args.cql).SELECT?.from?.ref?.[0]
52
+ return ref0?.id ?? ref0 ?? undefined
53
+ } catch {
54
+ /* no target derivable */
57
55
  }
58
56
  }
57
+ return undefined
58
+ }
59
+
60
+ /**
61
+ * Resolve a human-readable label for a tool call:
62
+ * - agent → declared service label, else the subagent's agent-card name
63
+ * - describe → service label (declared, else the FQ service name)
64
+ * - query → entity label, prefixed with the service only when it has a declared label
65
+ * - action → action label, prefixed with the service only when it has a declared label
66
+ */
67
+ function resolveToolLabel(tc, meta, kind = resolveToolKind(tc, meta)) {
68
+ const model = cds.context?.model ?? cds.model
69
+ const serviceName = meta?.serviceName ?? cds.context?.["agent.service"] // REVISIT: why are we using cds.context here?
70
+
71
+ if (kind === "agent") {
72
+ return declaredServiceLabel(meta?.serviceName) || meta?.agentName || tc.name
73
+ }
74
+
75
+ if (kind === "describe") {
76
+ let parts = []
77
+ if (tc.args?.entities) parts.push(...tc.args.entities.map(e => entityLabel(e, serviceName, model)))
78
+ if (tc.args?.actions) parts.push(...tc.args.actions.map(a => entityLabel(a, serviceName, model)))
79
+ if (!parts.length) parts.push(serviceLabel(serviceName) || tc.name)
80
+ return parts.join(", ")
81
+ }
82
+
83
+ const svcLabel = declaredServiceLabel(serviceName)
84
+ const withService = (detail) => (svcLabel ? `${svcLabel} · ${detail}` : detail)
85
+
86
+ if (kind === "query") {
87
+ const target = queryTarget(tc)
88
+ if (target) return withService(entityLabel(target, serviceName, model))
89
+ return svcLabel || tc.name
90
+ }
59
91
 
60
- return tc.name
92
+ // meta.actionName is the un-prefixed name for MCP action tools (tc.name is
93
+ // "{service}_{action}"); own-service tools already carry the bare name.
94
+ const actionName = meta?.actionName ?? tc.args?.action ?? tc.name
95
+ if (serviceName) {
96
+ const actionDef = model?.definitions?.[`${serviceName}.${actionName}`]
97
+ const declared = actionDef && cds.i18n?.labels?.at(actionDef) || actionName
98
+ if (declared) return withService(declared)
99
+ }
100
+ return withService(actionName)
61
101
  }
62
102
 
63
103
  /**
@@ -86,29 +126,106 @@ export function publishStatus(text) {
86
126
  }
87
127
 
88
128
  /**
89
- * beforeModel hook: emit "Processing tool response" when tools just finished.
129
+ * Reads tool-call visibility config from the current request's metadata.
130
+ * Controlled entirely by the client: presence of userMessage.metadata["tool-status-update"] enables it.
131
+ */
132
+ function toolCallConfig() {
133
+ const requestMeta = cds.context?.["agent.request.metadata"]?.["tool-status-update"]
134
+ return {
135
+ enabled: requestMeta !== undefined,
136
+ args: requestMeta?.args ?? true,
137
+ result: requestMeta?.result ?? true,
138
+ }
139
+ }
140
+
141
+ function serialize(v) {
142
+ return resolvePseudonyms(typeof v === "string" ? v : JSON.stringify(v))
143
+ }
144
+
145
+ /**
146
+ * Publishes an artifact-update for a tool call (start or end).
147
+ */
148
+ function publishToolCallArtifact(tc, { meta, status, lastChunk, result }) {
149
+ const eventBus = cds.context?.["agent.eventBus"]
150
+ const config = toolCallConfig()
151
+ if (!eventBus || !config.enabled) return
152
+
153
+ let kind = resolveToolKind(tc, meta)
154
+ let label = resolveToolLabel(tc, meta)
155
+ eventBus.publish({
156
+ kind: "artifact-update",
157
+ taskId: cds.context["agent.task.id"],
158
+ contextId: cds.context["agent.context.id"],
159
+ append: false,
160
+ lastChunk,
161
+ artifact: {
162
+ artifactId: `tool-call-${tc.id}`,
163
+ parts: [
164
+ {
165
+ kind: "data",
166
+ data: {
167
+ type: "tool-call",
168
+ name: tc.name,
169
+ kind,
170
+ label,
171
+ status,
172
+ ...(config.args && { args: serialize(tc.args) }),
173
+ ...(result !== undefined && config.result && { result: serialize(result) }),
174
+ },
175
+ },
176
+ ],
177
+ },
178
+ })
179
+ }
180
+
181
+ /**
182
+ * beforeModel hook: emit "Processing tool response" + tool-call completion events.
90
183
  */
91
- export function beforeModelHook(state) {
184
+ export function beforeModelHook(state, toolMeta) {
92
185
  if (!cds.context?.["agent.eventBus"]) return {}
93
186
 
94
187
  const msgs = state.messages
95
188
  if (!msgs?.length) return {}
96
- const lastMsg = msgs[msgs.length - 1]
97
- if (!ToolMessage.isInstance(lastMsg)) return {}
189
+ if (!ToolMessage.isInstance(msgs[msgs.length - 1])) return {}
98
190
 
99
- // Plural if second-to-last is also a ToolMessage
100
- const plural = msgs.length >= 2 && ToolMessage.isInstance(msgs[msgs.length - 2])
191
+ const firstToolIdx = msgs.findLastIndex((m) => !ToolMessage.isInstance(m)) + 1
192
+ const toolMsgs = msgs.slice(firstToolIdx)
193
+
194
+ const tcMap = {}
195
+ const msg = msgs.findLast((m) => AIMessage.isInstance(m) && m.tool_calls?.length)
196
+ if (msg) for (const tc of msg.tool_calls) tcMap[tc.id] = tc
197
+
198
+ for (const tm of toolMsgs) {
199
+ const tc = tcMap[tm.tool_call_id]
200
+ if (!tc) continue
201
+ const meta = resolveMeta(tc, toolMeta)
202
+ if (meta) {
203
+ publishToolCallArtifact(tc, {
204
+ meta,
205
+ status: tm.status === "error" ? "error" : "done",
206
+ lastChunk: true,
207
+ result: tm.content,
208
+ })
209
+ }
210
+ }
211
+
212
+ const plural = toolMsgs.length >= 2
101
213
  const key = plural ? "agent_status_processing_responses" : "agent_status_processing_response"
102
- const text = cds.i18n.messages.at(key)
103
- publishStatus(text)
214
+ publishStatus(cds.i18n.messages.at(key))
104
215
 
105
216
  return {}
106
217
  }
107
218
 
219
+ /** De-duplicate labels while preserving order. */
220
+ function uniqueLabels(calls) {
221
+ return [...new Set(calls.map(({ label }) => label))].join(", ")
222
+ }
223
+
108
224
  /**
109
- * afterModel hook: emit tool-call status updates (querying/calling).
225
+ * afterModel hook: emit tool-call status updates (querying/inspecting/calling)
226
+ * + tool-call start events.
110
227
  */
111
- export function afterModelHook(state) {
228
+ export function afterModelHook(state, toolMeta) {
112
229
  if (!cds.context?.["agent.eventBus"]) return {}
113
230
 
114
231
  const msgs = state.messages
@@ -118,36 +235,52 @@ export function afterModelHook(state) {
118
235
  const toolCalls = lastAI?.tool_calls
119
236
  if (!toolCalls?.length) return {}
120
237
 
121
- // Separate query calls from action calls
122
- const queryCalls = toolCalls.filter((tc) => tc.name === "query" || tc.name?.endsWith("_query"))
123
- const otherCalls = toolCalls.filter((tc) => tc.name !== "query" && !tc.name?.endsWith("_query"))
238
+ const resolved = toolCalls
239
+ .map((tc) => ({ tc, meta: resolveMeta(tc, toolMeta) }))
240
+ .filter(({ meta }) => meta)
241
+ .map(({ tc, meta }) => {
242
+ let kind = resolveToolKind(tc, meta)
243
+ let label = resolveToolLabel(tc, meta, kind)
244
+ return { tc, meta, kind, label }
245
+ })
246
+
247
+ const queryCalls = resolved.filter(({ kind }) => kind === "query")
248
+ const describeCalls = resolved.filter(({ kind }) => kind === "describe")
249
+ const otherCalls = resolved.filter(({ kind }) => kind !== "query" && kind !== "describe")
124
250
 
125
- // Emit "Querying {entities}" for query tools
126
251
  if (queryCalls.length) {
127
- const labels = queryCalls.map((tc) => resolveToolLabel(tc))
128
- const text = cds.i18n.messages.at("agent_status_querying", [labels.join(", ")])
129
- publishStatus(text)
252
+ publishStatus(cds.i18n.messages.at("agent_status_querying", [uniqueLabels(queryCalls)]))
253
+ }
254
+ if (describeCalls.length) {
255
+ publishStatus(cds.i18n.messages.at("agent_status_describing", [uniqueLabels(describeCalls)]))
130
256
  }
131
-
132
- // Emit "Calling {action labels}" for other tools
133
257
  if (otherCalls.length) {
134
- const labels = otherCalls.map((tc) => resolveToolLabel(tc))
135
- const text = cds.i18n.messages.at("agent_status_calling_tools", [labels.join(", ")])
136
- publishStatus(text)
258
+ publishStatus(cds.i18n.messages.at("agent_status_calling_tools", [uniqueLabels(otherCalls)]))
259
+ }
260
+
261
+ for (const { tc, meta } of resolved) {
262
+ publishToolCallArtifact(tc, { meta, status: "running", lastChunk: false })
137
263
  }
138
264
 
139
265
  return {}
140
266
  }
141
267
 
142
268
  /**
143
- * Middleware factory that emits non-final status-update events during agent execution:
144
- * - beforeModel: "Processing tool response" after tools finish
145
- * - afterModel: "Querying <entity>" / "Calling <action>" before tools are invoked
269
+ * Middleware emitting non-final status-update events during agent execution:
270
+ * - beforeModel: "Processing tool response" + tool-call completion events
271
+ * - afterModel: "Querying"/"Inspecting"/"Calling" + tool-call start events
272
+ *
273
+ * Static tools are captured here; remote MCP tools are resolved per-request from
274
+ * cds.context.__mcpDynamicTools by resolveMeta().
146
275
  */
147
- export async function statusUpdateMiddleware() {
276
+ export async function statusUpdateMiddleware(tools = []) {
277
+ // Presence in the map marks a tool as "known"; own-service tools carry no
278
+ // metadata (→ {}), so resolveToolKind sniffs their kind from the name.
279
+ const toolMeta = new Map(tools.map((t) => [t.name, t.metadata ?? {}]))
280
+
148
281
  return createMiddleware({
149
282
  name: "statusUpdateMiddleware",
150
- beforeModel: { hook: beforeModelHook },
151
- afterModel: { hook: afterModelHook },
283
+ beforeModel: { hook: (state) => beforeModelHook(state, toolMeta) },
284
+ afterModel: { hook: (state) => afterModelHook(state, toolMeta) },
152
285
  })
153
286
  }
@@ -14,9 +14,8 @@ export function toolWrapMiddleware(srv) {
14
14
  return createMiddleware({
15
15
  name: "ToolWrapMiddleware",
16
16
  wrapToolCall: async function (request, handler) {
17
- const { name, id, args } = request.toolCall
17
+ const { name, id } = request.toolCall
18
18
  try {
19
- LOG.debug(srv.name, "calling tool", name, args)
20
19
  const result = await handler(request)
21
20
  if (ToolMessage.isInstance(result) && result.artifact?.isError === true) {
22
21
  result.status = "error"
@@ -60,14 +60,17 @@ export async function summarizePartialWork({
60
60
  reason === "timeOut"
61
61
  ? `Agent task was not completed within its time limit. Write short, precise progress summary so user can decide whether to continue. State completed work and immediate next work. Do not claim work not shown. Start with: Agent did not finish within time! End with: Continue running or stop? No other questions to the user allowed!`
62
62
  : `Agent task was interrupted. Reason: ${reason}. Based on conversation history, provide brief summary of completed work and remaining work. Be concise.`
63
- summaryPrompt += `\n\n Conversation Snippet: \n\n ${conversationSnippet.trim()}`
63
+ summaryPrompt += `\n\n Conversation Snippet: \n\n`
64
64
 
65
65
  let summaryTimer
66
66
  try {
67
67
  const response = await Promise.race([
68
68
  (async () => {
69
+ const instruction = new HumanMessage(summaryPrompt)
70
+ instruction.name = "cap-js-agents-summarize"
69
71
  const model = await getModel()
70
- return model.invoke([new HumanMessage(summaryPrompt)])
72
+ // Two messages, so the first one can be logged as the prompt for summarization in MLflow
73
+ return model.invoke([instruction, new HumanMessage(conversationSnippet.trim())])
71
74
  })(),
72
75
  new Promise((_, reject) => {
73
76
  summaryTimer = setTimeout(() => reject(new Error("Summary LLM call timed out")), timeout)