@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.
- package/_i18n/messages.properties +17 -0
- package/cds-plugin.js +94 -77
- package/lib/agents/markdown/deep-agent.js +1 -1
- package/lib/agents/middleware/hitl-decision-note-injector.js +18 -0
- package/lib/agents/middleware/hitl.js +2 -2
- package/lib/agents/middleware/index.js +1 -1
- package/lib/agents/middleware/quota-enforcer.js +17 -8
- package/lib/agents/middleware/remote-mcp.js +3 -2
- package/lib/agents/middleware/status-update.js +1 -1
- package/lib/agents/middleware/tool-wrap.js +5 -6
- package/lib/agents/quota-enforcer-at-start.js +21 -18
- package/lib/agents/summarize-on-timeout.js +18 -13
- package/lib/compile.js +2 -3
- package/lib/config/local.js +94 -0
- package/lib/eval/eval-run.js +11 -6
- package/lib/index.js +9 -12
- package/lib/models/aicore.js +91 -127
- package/lib/models/anthropic.js +10 -67
- package/lib/preview/chat.html +62 -33
- package/lib/telemetry/chat-tracing.js +13 -3
- package/lib/telemetry/metrics.js +6 -0
- package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +1 -1
- package/lib/telemetry/mlflow/prompts.js +6 -2
- package/lib/utils/caching.js +132 -0
- package/lib/utils/message-handling.js +14 -0
- package/lib/utils/utils.js +4 -9
- package/package.json +7 -5
- package/srv/handlers/chat.js +4 -4
- package/srv/handlers/graph-executor/hitl.js +381 -0
- package/srv/handlers/graph-executor.js +99 -336
- package/srv/handlers/index.js +4 -3
- package/srv/handlers/mcp-tools.js +3 -2
- package/srv/handlers/{sub-agent-tools.js → subagent-tools.js} +43 -22
- package/srv/handlers/tools.js +35 -9
- 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
|
+
}
|