@cap-js/agents 0.9.3 → 0.9.5

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 (35) hide show
  1. package/_i18n/messages.properties +17 -0
  2. package/cds-plugin.js +94 -77
  3. package/lib/agents/markdown/deep-agent.js +1 -1
  4. package/lib/agents/middleware/hitl-decision-note-injector.js +18 -0
  5. package/lib/agents/middleware/hitl.js +2 -2
  6. package/lib/agents/middleware/index.js +1 -1
  7. package/lib/agents/middleware/quota-enforcer.js +17 -8
  8. package/lib/agents/middleware/remote-mcp.js +3 -2
  9. package/lib/agents/middleware/status-update.js +1 -1
  10. package/lib/agents/middleware/tool-wrap.js +5 -6
  11. package/lib/agents/quota-enforcer-at-start.js +21 -18
  12. package/lib/agents/summarize-on-timeout.js +18 -13
  13. package/lib/compile.js +2 -3
  14. package/lib/config/local.js +94 -0
  15. package/lib/eval/eval-run.js +11 -6
  16. package/lib/index.js +9 -12
  17. package/lib/models/aicore.js +91 -127
  18. package/lib/models/anthropic.js +10 -67
  19. package/lib/preview/chat.html +62 -33
  20. package/lib/telemetry/chat-tracing.js +13 -3
  21. package/lib/telemetry/metrics.js +6 -0
  22. package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +1 -1
  23. package/lib/telemetry/mlflow/prompts.js +6 -2
  24. package/lib/utils/caching.js +132 -0
  25. package/lib/utils/message-handling.js +14 -0
  26. package/lib/utils/utils.js +4 -9
  27. package/package.json +7 -5
  28. package/srv/handlers/chat.js +4 -4
  29. package/srv/handlers/graph-executor/hitl.js +381 -0
  30. package/srv/handlers/graph-executor.js +99 -336
  31. package/srv/handlers/index.js +4 -3
  32. package/srv/handlers/mcp-tools.js +3 -2
  33. package/srv/handlers/{sub-agent-tools.js → subagent-tools.js} +43 -22
  34. package/srv/handlers/tools.js +35 -9
  35. package/lib/agents/middleware/hitl-edit-note-injector.js +0 -18
@@ -0,0 +1,381 @@
1
+ import cds from "@sap/cds"
2
+ import { agentMessage, firstDataPart, partsToText } from "../../../lib/utils/message-handling.js"
3
+ import { audit, short } from "../../../lib/utils/utils.js"
4
+ import * as metrics from "../../../lib/telemetry/metrics.js"
5
+
6
+ const LOG = cds.log("agents")
7
+
8
+ export const HITL_METADATA_KEY = "sap.cds.agents.hitl"
9
+ export const TIMEOUT_HITL_METADATA_KEY = "sap.cds.agents.timeout-hitl"
10
+ export const INPUT_REQUIRED_METADATA_KEY = "sap.cds.agents.input-required"
11
+
12
+ function approvalOptions() {
13
+ return [
14
+ { value: "approve", label: cds.i18n.messages.at("HITL_APPROVE") },
15
+ { value: "reject", label: cds.i18n.messages.at("HITL_REJECT") },
16
+ ]
17
+ }
18
+
19
+ function timeoutOptions() {
20
+ return [
21
+ { value: "continue", label: cds.i18n.messages.at("HITL_CONTINUE") },
22
+ { value: "reject", label: cds.i18n.messages.at("HITL_STOP") },
23
+ ]
24
+ }
25
+ export const requiresHitl = (result) =>
26
+ result?.__interrupt__?.length > 0 || result?.interrupts?.length > 0
27
+
28
+ export function parseResumeDecision(userText) {
29
+ const t = userText.trim()
30
+ if (/^(approve|yes|confirm|ok)$/i.test(t)) return { decisions: [{ type: "approve" }] }
31
+ if (/^edit$/i.test(t)) return { decisions: [{ type: "edit" }] }
32
+ return {
33
+ decisions: [
34
+ {
35
+ type: "reject",
36
+ message: `The user rejected this particular tool invocation with the reason: ${userText}`,
37
+ },
38
+ ],
39
+ }
40
+ }
41
+
42
+ export function patchRejectMessage(dataPart) {
43
+ if (!Array.isArray(dataPart?.decisions)) return dataPart
44
+ return {
45
+ ...dataPart,
46
+ decisions: dataPart.decisions.map((decision) =>
47
+ decision?.type === "reject"
48
+ ? {
49
+ ...decision,
50
+ message: `The user rejected this particular tool invocation with the reason: ${decision.message ?? ""}`,
51
+ }
52
+ : decision,
53
+ ),
54
+ }
55
+ }
56
+
57
+ function decisionsForAudit(resume, actionRequests = []) {
58
+ if (!Array.isArray(resume?.decisions)) return [{ action: null, decision: resume }]
59
+ return resume.decisions.map((decision, index) => ({
60
+ action: actionRequests[index] ?? { index: index + 1 },
61
+ decision,
62
+ }))
63
+ }
64
+
65
+ function hitlMetricAttrs(serviceName, action, decision) {
66
+ return {
67
+ ...metrics.attrs(serviceName),
68
+ "agent.hitl.action": action?.name,
69
+ ...(decision && { "agent.hitl.decision": decision }),
70
+ }
71
+ }
72
+
73
+ function recordHitlDecisions(serviceName, actionRequests, resume, actionOffset = 0) {
74
+ if (!Array.isArray(resume?.decisions)) return
75
+ for (const [index, decision] of resume.decisions.entries()) {
76
+ if (!decision?.type) continue
77
+ metrics.hitlDecisions.add(
78
+ 1,
79
+ hitlMetricAttrs(serviceName, actionRequests[index + actionOffset], decision.type),
80
+ )
81
+ }
82
+ }
83
+
84
+ function extractInterruptDescription(resultOrErr) {
85
+ const interrupt = resultOrErr.__interrupt__?.[0] || resultOrErr.interrupts?.[0]
86
+ const payload = interrupt?.value
87
+ if (!payload) return "This action requires your approval. Reply 'approve' or 'reject'."
88
+ if (payload.actionRequests?.length > 0) {
89
+ return (
90
+ payload.actionRequests[0].description || `Approve action: ${payload.actionRequests[0].name}?`
91
+ )
92
+ }
93
+ return typeof payload === "string" ? payload : JSON.stringify(payload)
94
+ }
95
+
96
+ export function extractInterruptData(resultOrErr) {
97
+ const interrupt = resultOrErr.__interrupt__?.[0] || resultOrErr.interrupts?.[0]
98
+ const payload = interrupt?.value
99
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined
100
+ const actionRequests = mergeReviewConfigs(payload.actionRequests, payload.reviewConfigs)
101
+ return actionRequests === payload.actionRequests ? payload : { ...payload, actionRequests }
102
+ }
103
+
104
+ function mergeReviewConfigs(actionRequests, reviewConfigs) {
105
+ if (!Array.isArray(actionRequests) || !Array.isArray(reviewConfigs)) return actionRequests
106
+ const configsByAction = new Map(reviewConfigs.map((config) => [config.actionName, config]))
107
+ let changed = false
108
+ const requests = actionRequests.map((request) => {
109
+ const config = configsByAction.get(request.name)
110
+ if (!config) return request
111
+ changed = true
112
+ const { allowedDecisions, argsSchema } = config
113
+ return {
114
+ ...request,
115
+ ...(allowedDecisions === undefined ? {} : { allowedDecisions }),
116
+ ...(argsSchema === undefined ? {} : { argsSchema }),
117
+ }
118
+ })
119
+ return changed ? requests : actionRequests
120
+ }
121
+
122
+ function interruptActionCount(resultOrErr) {
123
+ const interrupts = resultOrErr.__interrupt__ || resultOrErr.interrupts || []
124
+ return interrupts.reduce(
125
+ (count, interrupt) => count + (interrupt?.value?.actionRequests?.length || 0),
126
+ 0,
127
+ )
128
+ }
129
+
130
+ function interruptActionRequests(resultOrErr) {
131
+ const interrupts = resultOrErr.__interrupt__ || resultOrErr.interrupts || []
132
+ return interrupts.flatMap((interrupt) => interrupt?.value?.actionRequests || [])
133
+ }
134
+
135
+ export function composeHitlDecisionNote(actionRequests, resume) {
136
+ const decisions = resume?.decisions
137
+ if (!Array.isArray(decisions) || decisions.length === 0) return undefined
138
+ if (decisions.every((decision) => decision?.type === "approve")) return undefined
139
+ const consumed = new Set()
140
+ const takeByName = (name) => {
141
+ for (let index = 0; index < actionRequests.length; index++) {
142
+ if (!consumed.has(index) && actionRequests[index]?.name === name) {
143
+ consumed.add(index)
144
+ return actionRequests[index]
145
+ }
146
+ }
147
+ return undefined
148
+ }
149
+ const action = (request) =>
150
+ "`" + (request?.name ?? "unknown action") + "(" + JSON.stringify(request?.args ?? {}) + ")`"
151
+ const lines = []
152
+ for (const [index, decision] of decisions.entries()) {
153
+ const original = actionRequests[index]
154
+ if (decision?.type === "edit") {
155
+ const matched = takeByName(decision.editedAction?.name) ?? original
156
+ lines.push("- User edited " + action(matched) + " to " + action(decision.editedAction) + ".")
157
+ continue
158
+ }
159
+ }
160
+ if (!lines.length) {
161
+ return undefined
162
+ }
163
+ return ["User HITL decisions (not tool failures):", ...lines].join("\n")
164
+ }
165
+
166
+ async function getPreInterruptToolCalls(graph, config) {
167
+ try {
168
+ if (typeof graph.getState !== "function") return []
169
+ const state = await graph.getState(config)
170
+ const messages = state?.values?.messages ?? []
171
+ for (let i = messages.length - 1; i >= 0; i--) {
172
+ const message = messages[i]
173
+ if (message?.tool_calls?.length) {
174
+ return message.tool_calls.map((call) => ({ id: call.id, name: call.name, args: call.args }))
175
+ }
176
+ }
177
+ return []
178
+ } catch {
179
+ return []
180
+ }
181
+ }
182
+
183
+ async function getPendingHitlActionCount(graph, config) {
184
+ if (typeof graph.getState !== "function") {
185
+ throw new Error("Cannot resume HITL: graph state is unavailable.")
186
+ }
187
+ const state = await graph.getState(config)
188
+ const interrupts = state?.tasks?.flatMap((task) => task.interrupts || []) || []
189
+ const count = interrupts.reduce(
190
+ (total, interrupt) => total + (interrupt?.value?.actionRequests?.length || 1),
191
+ 0,
192
+ )
193
+ if (count < 1) throw new Error("Cannot resume HITL: no pending actions found.")
194
+ return count
195
+ }
196
+
197
+ function pendingHitlFromTask(task) {
198
+ const pending = task?.status?.message?.metadata?.[HITL_METADATA_KEY]
199
+ if (!Number.isInteger(pending?.actionCount) || pending.actionCount < 1) return undefined
200
+ if (!Array.isArray(pending.decisions)) return undefined
201
+ return pending
202
+ }
203
+
204
+ function pendingActionRequests(task, pending) {
205
+ return (
206
+ pending?.actionRequests || firstDataPart(task?.status?.message?.parts)?.actionRequests || []
207
+ )
208
+ }
209
+
210
+ function interruptDescriptionFromTask(task, pending) {
211
+ const action = pendingActionRequests(task, pending)[pending?.decisions?.length || 0]
212
+ if (action) return action.description || `Approve action: ${action.name}?`
213
+ return (
214
+ task?.status?.message?.parts?.find((part) => part.kind === "text")?.text ||
215
+ "This action requires your approval. Reply 'approve' or 'reject'."
216
+ )
217
+ }
218
+
219
+ function publishInputRequired({ requestContext, eventBus, description, interruptData, pending }) {
220
+ const { taskId, contextId } = requestContext
221
+ eventBus.publish({
222
+ kind: "status-update",
223
+ taskId,
224
+ contextId,
225
+ status: {
226
+ state: "input-required",
227
+ message: agentMessage(description, interruptData, {
228
+ [HITL_METADATA_KEY]: pending,
229
+ [INPUT_REQUIRED_METADATA_KEY]: { options: approvalOptions() },
230
+ }),
231
+ timestamp: new Date().toISOString(),
232
+ },
233
+ final: true,
234
+ })
235
+ eventBus.finished()
236
+ }
237
+
238
+ export function isTimeoutHitl(task) {
239
+ return task?.status?.message?.metadata?.[TIMEOUT_HITL_METADATA_KEY] === true
240
+ }
241
+
242
+ export function publishTimeoutHitl({ requestContext, eventBus, description, serviceName }) {
243
+ const { taskId, contextId } = requestContext
244
+ LOG.info(serviceName, "-", "timeout awaiting decision", { conversation: short(contextId) })
245
+ audit("AgentInputRequired", {
246
+ data: { taskId, contextId, service: serviceName, reason: "timeout", description },
247
+ })
248
+ eventBus.publish({
249
+ kind: "status-update",
250
+ taskId,
251
+ contextId,
252
+ status: {
253
+ state: "input-required",
254
+ message: agentMessage(description, undefined, {
255
+ [TIMEOUT_HITL_METADATA_KEY]: true,
256
+ [INPUT_REQUIRED_METADATA_KEY]: { options: timeoutOptions() },
257
+ }),
258
+ timestamp: new Date().toISOString(),
259
+ },
260
+ final: true,
261
+ })
262
+ }
263
+
264
+ export async function resumeTimeoutHitl({ requestContext, eventBus, stream, signal }) {
265
+ const { taskId, contextId } = requestContext
266
+ const decision = partsToText(requestContext.userMessage?.parts).trim()
267
+ if (!decision) throw new Error(cds.i18n.messages.at("RESUME_REQUIRES_TEXT"))
268
+
269
+ if (/^(continue|approve|yes|confirm|ok)$/i.test(decision)) {
270
+ LOG.info("timeout continuation approved", { conversation: short(contextId) })
271
+ audit("AgentTaskResumed", {
272
+ data: { taskId, contextId, service: cds.context?.["agent.service"], reason: "timeout" },
273
+ })
274
+ const resumed = await stream(null, signal)
275
+ return resumed.state
276
+ }
277
+
278
+ LOG.info("timeout continuation declined", { conversation: short(contextId) })
279
+ audit("AgentTaskCanceled", {
280
+ data: { taskId, contextId, service: cds.context?.["agent.service"], reason: "timeout" },
281
+ })
282
+ eventBus.publish({
283
+ kind: "status-update",
284
+ taskId,
285
+ contextId,
286
+ status: {
287
+ state: "canceled",
288
+ message: agentMessage("Task stopped by user after timeout."),
289
+ timestamp: new Date().toISOString(),
290
+ },
291
+ final: true,
292
+ })
293
+ return undefined
294
+ }
295
+
296
+ export async function resumeHitl({ requestContext, graph, config, eventBus, stream, signal }) {
297
+ const { taskId, contextId } = requestContext
298
+ const dataPart = firstDataPart(requestContext.userMessage?.parts)
299
+ const userText = partsToText(requestContext.userMessage?.parts)
300
+ if (dataPart === undefined && !userText.trim()) {
301
+ throw new Error(cds.i18n.messages.at("RESUME_REQUIRES_TEXT"))
302
+ }
303
+ const { Command } = await import("@langchain/langgraph")
304
+ let resume = dataPart !== undefined ? patchRejectMessage(dataPart) : parseResumeDecision(userText)
305
+ let actionRequests = []
306
+
307
+ if (Array.isArray(resume?.decisions)) {
308
+ const pending = pendingHitlFromTask(requestContext.task)
309
+ const actionCount = pending?.actionCount ?? (await getPendingHitlActionCount(graph, config))
310
+ actionRequests = pendingActionRequests(requestContext.task, pending)
311
+ const priorDecisionCount = pending?.decisions?.length || 0
312
+ const decisions = [...(pending?.decisions || []), ...resume.decisions]
313
+ recordHitlDecisions(cds.context?.["agent.service"], actionRequests, resume, priorDecisionCount)
314
+ if (decisions.length < actionCount) {
315
+ const interruptData = firstDataPart(requestContext.task?.status?.message?.parts)
316
+ const nextPending = { ...pending, actionCount, decisions }
317
+ publishInputRequired({
318
+ requestContext,
319
+ eventBus,
320
+ description: interruptDescriptionFromTask(requestContext.task, nextPending),
321
+ interruptData,
322
+ pending: {
323
+ actionCount,
324
+ decisions,
325
+ actionRequests: pendingActionRequests(requestContext.task, nextPending),
326
+ },
327
+ })
328
+ return undefined
329
+ }
330
+ resume = { ...resume, decisions }
331
+ }
332
+
333
+ const decisions = decisionsForAudit(resume, actionRequests)
334
+ LOG.debug("resuming", { conversation: short(contextId), decisions })
335
+ audit("AgentTaskResumed", {
336
+ data: { taskId, contextId, service: cds.context?.["agent.service"], decisions },
337
+ })
338
+
339
+ const originalActions = actionRequests.length
340
+ ? actionRequests
341
+ : await getPreInterruptToolCalls(graph, config)
342
+ const decisionNote = composeHitlDecisionNote(originalActions, resume)
343
+ const commandArgs = { resume }
344
+ if (decisionNote) commandArgs.update = { _hitlDecisionNote: decisionNote }
345
+ const resumed = await stream(new Command(commandArgs), signal)
346
+ return resumed.state
347
+ }
348
+
349
+ export function handleHitlInterrupt({
350
+ result,
351
+ requestContext,
352
+ eventBus,
353
+ serviceName,
354
+ duration,
355
+ onInputRequired,
356
+ }) {
357
+ const { taskId, contextId } = requestContext
358
+ const description = extractInterruptDescription(result)
359
+ const interruptData = extractInterruptData(result)
360
+ const actionRequests = interruptData?.actionRequests || interruptActionRequests(result)
361
+ for (const action of actionRequests) {
362
+ metrics.hitlGates.add(1, hitlMetricAttrs(serviceName, action))
363
+ }
364
+ LOG.info(serviceName, "-", "input-required", { conversation: short(contextId), duration })
365
+ onInputRequired?.(description)
366
+ audit("AgentInputRequired", {
367
+ data: { taskId, contextId, service: serviceName, description, interruptData },
368
+ })
369
+ publishInputRequired({
370
+ requestContext,
371
+ eventBus,
372
+ description,
373
+ interruptData,
374
+ pending: {
375
+ actionCount: interruptActionCount(result),
376
+ decisions: [],
377
+ actionRequests: interruptData?.actionRequests || interruptActionRequests(result),
378
+ },
379
+ })
380
+ return true
381
+ }