@cap-js/agents 0.9.2 → 0.9.4
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/README.md +2 -0
- package/cds-plugin.js +2 -2
- package/lib/agents/middleware/content-filter.js +3 -2
- 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 +11 -0
- package/lib/agents/middleware/quota-enforcer.js +9 -0
- package/lib/agents/middleware/remote-mcp.js +62 -0
- package/lib/agents/middleware/tool-wrap.js +52 -0
- package/lib/compile.js +6 -2
- package/lib/eval/Judge.js +239 -0
- package/lib/eval/eval-describe.js +69 -0
- package/lib/eval/eval-run.js +147 -0
- package/lib/eval/index.js +6 -0
- package/lib/eval/metrics.js +53 -0
- package/lib/eval/span-collector.js +50 -0
- package/lib/index.js +2 -1
- package/lib/models/aicore.js +23 -6
- package/lib/models/anthropic.js +27 -12
- package/lib/preview/chat.html +373 -66
- package/lib/protocol/agent-card.js +6 -2
- package/lib/sidecar.js +1 -1
- package/lib/telemetry/chat-tracing.js +39 -8
- package/lib/telemetry/mlflow/credentials.js +79 -0
- package/lib/telemetry/mlflow/evaluation.js +38 -0
- package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +75 -0
- package/lib/telemetry/mlflow/exporter/MlflowExporter.js +115 -0
- package/lib/telemetry/mlflow/exporter/index.js +23 -0
- package/lib/telemetry/mlflow/index.js +16 -0
- package/lib/telemetry/mlflow/prompts.js +123 -0
- package/lib/telemetry/mlflow/tracing.js +264 -0
- package/lib/telemetry/tool-tracing.js +27 -54
- package/lib/telemetry/tracing.js +3 -1
- package/lib/utils/markdown.js +1 -11
- package/lib/utils/message-handling.js +14 -0
- package/lib/utils/resilience.js +133 -0
- package/lib/utils/utils.js +17 -0
- package/package.json +26 -14
- package/srv/handlers/chat.js +278 -0
- package/srv/handlers/graph-executor/hitl.js +263 -0
- package/srv/handlers/graph-executor.js +100 -286
- package/srv/handlers/index.js +5 -1
- package/srv/handlers/mcp-tools.js +7 -45
- package/srv/handlers/sub-agent-tools.js +30 -12
- package/srv/handlers/tools.js +39 -5
- package/index.js +0 -0
- package/lib/agents/middleware/hitl-edit-note-injector.js +0 -18
- package/lib/telemetry/mlflow.js +0 -290
- /package/{index.cds → srv/entities.cds} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cap-js/agents",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
4
4
|
"description": "CDS plugin for building agents",
|
|
5
5
|
"author": "SAP SE (https://www.sap.com)",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -11,16 +11,14 @@
|
|
|
11
11
|
},
|
|
12
12
|
"type": "module",
|
|
13
13
|
"exports": {
|
|
14
|
-
".": "./index.js",
|
|
15
14
|
"./package.json": "./package.json",
|
|
16
15
|
"./cds-plugin": "./cds-plugin.js",
|
|
17
|
-
"./
|
|
16
|
+
"./eval": "./lib/eval/index.js",
|
|
17
|
+
"./lib/index.cjs": "./lib/index.cjs",
|
|
18
18
|
"./lib/models/*": "./lib/models/*.js",
|
|
19
|
-
"./srv/
|
|
20
|
-
"./srv
|
|
21
|
-
"./lib/*": "./lib/*"
|
|
19
|
+
"./srv/entities.cds": "./srv/entities.cds",
|
|
20
|
+
"./srv/push-notification-srv.js": "./srv/push-notification-srv.js"
|
|
22
21
|
},
|
|
23
|
-
"main": "index.js",
|
|
24
22
|
"scripts": {
|
|
25
23
|
"lint": "npx eslint .",
|
|
26
24
|
"test": "CDS_ENV=test npx vitest run --config vitest.config.js",
|
|
@@ -30,15 +28,15 @@
|
|
|
30
28
|
"watch:hybrid": "DEBUG=agents cds bind --exec -- cds w tests/projects/bookshop",
|
|
31
29
|
"watch:with-claude": "cds w tests/projects/bookshop --profile with-claude",
|
|
32
30
|
"watch:deep-agent": "cds w tests/projects/deep-agent --profile hybrid",
|
|
31
|
+
"docs:audit": "node .scripts/generate-audit-docs.js",
|
|
32
|
+
"docs:audit:check": "node .scripts/generate-audit-docs.js --check",
|
|
33
33
|
"prettier": "npx -y prettier@3 --write .",
|
|
34
34
|
"prettier:check": "npx -y prettier@3 --check ."
|
|
35
35
|
},
|
|
36
36
|
"files": [
|
|
37
|
-
"
|
|
38
|
-
"index.cds",
|
|
37
|
+
"cds-plugin.js",
|
|
39
38
|
"lib",
|
|
40
39
|
"srv",
|
|
41
|
-
"cds-plugin.js",
|
|
42
40
|
"_i18n"
|
|
43
41
|
],
|
|
44
42
|
"dependencies": {
|
|
@@ -52,7 +50,6 @@
|
|
|
52
50
|
"@sap-ai-sdk/langchain": "^2.13.0",
|
|
53
51
|
"@sap-ai-sdk/orchestration": "^2.11.0",
|
|
54
52
|
"@sap-cloud-sdk/connectivity": "^4.7.0",
|
|
55
|
-
"@sap-cloud-sdk/resilience": "^4.7.0",
|
|
56
53
|
"deepagents": "^1.10.0",
|
|
57
54
|
"langchain": "^1.5.0",
|
|
58
55
|
"marked": "^18",
|
|
@@ -63,12 +60,15 @@
|
|
|
63
60
|
"@cap-js/sqlite": "^3",
|
|
64
61
|
"@sap/cds-mtxs": "^4",
|
|
65
62
|
"@toon-format/toon": ">=2.3",
|
|
66
|
-
"
|
|
63
|
+
"acorn": "^8.18.0",
|
|
64
|
+
"deepagents": "^1.10.2",
|
|
65
|
+
"openevals": "^0.2.0"
|
|
67
66
|
},
|
|
68
67
|
"peerDependencies": {
|
|
69
68
|
"@cap-js/audit-logging": ">=1",
|
|
70
69
|
"@cap-js/telemetry": ">=1",
|
|
71
|
-
"@sap/cds": ">=9"
|
|
70
|
+
"@sap/cds": ">=9",
|
|
71
|
+
"openevals": ">=0.2"
|
|
72
72
|
},
|
|
73
73
|
"peerDependenciesMeta": {
|
|
74
74
|
"@cap-js/telemetry": {
|
|
@@ -76,6 +76,9 @@
|
|
|
76
76
|
},
|
|
77
77
|
"@cap-js/audit-logging": {
|
|
78
78
|
"optional": true
|
|
79
|
+
},
|
|
80
|
+
"openevals": {
|
|
81
|
+
"optional": true
|
|
79
82
|
}
|
|
80
83
|
},
|
|
81
84
|
"engines": {
|
|
@@ -113,6 +116,12 @@
|
|
|
113
116
|
},
|
|
114
117
|
"persistAllCheckpointWrites": false,
|
|
115
118
|
"activeUsersInterval": "24h",
|
|
119
|
+
"circuitBreaker": {
|
|
120
|
+
"errorThresholdPercentage": 50,
|
|
121
|
+
"volumeThreshold": 10,
|
|
122
|
+
"resetTimeout": 30000,
|
|
123
|
+
"rollingCountTimeout": 10000
|
|
124
|
+
},
|
|
116
125
|
"params": {
|
|
117
126
|
"max_tokens": 4096,
|
|
118
127
|
"temperature": 0
|
|
@@ -156,7 +165,7 @@
|
|
|
156
165
|
},
|
|
157
166
|
"requires": {
|
|
158
167
|
"agents": {
|
|
159
|
-
"model": "@cap-js/agents"
|
|
168
|
+
"model": "@cap-js/agents/srv/entities.cds"
|
|
160
169
|
},
|
|
161
170
|
"agent-push-notifications": {
|
|
162
171
|
"impl": "@cap-js/agents/srv/push-notification-srv.js"
|
|
@@ -208,6 +217,9 @@
|
|
|
208
217
|
"_javaHcqlCompat": true
|
|
209
218
|
}
|
|
210
219
|
}
|
|
220
|
+
},
|
|
221
|
+
"folders": {
|
|
222
|
+
"srvs": "srv/*"
|
|
211
223
|
}
|
|
212
224
|
},
|
|
213
225
|
"workspaces": [
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
import { startCollection } from "../../lib/eval/span-collector.js"
|
|
3
|
+
import { metricsFromSpans } from "../../lib/eval/metrics.js"
|
|
4
|
+
import { getActiveRunState, logMlflowMetricsForResult } from "../../lib/eval/eval-run.js"
|
|
5
|
+
|
|
6
|
+
export const COLLECT_RESULT = Symbol.for("@cap-js/agents:chat:collect-result")
|
|
7
|
+
|
|
8
|
+
class NoopEventBus {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.events = []
|
|
11
|
+
this[COLLECT_RESULT] = true
|
|
12
|
+
this._graphResult = null
|
|
13
|
+
this._done = false
|
|
14
|
+
this.finished$ = new Promise((resolve, reject) => {
|
|
15
|
+
this._resolve = resolve
|
|
16
|
+
this._reject = reject
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
publish(event) {
|
|
21
|
+
this.events.push(event)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
finished() {
|
|
25
|
+
this._done = true
|
|
26
|
+
this._resolve?.()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
error(err) {
|
|
30
|
+
this._reject?.(err)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
getFinalText() {
|
|
34
|
+
for (let i = this.events.length - 1; i >= 0; i--) {
|
|
35
|
+
const e = this.events[i]
|
|
36
|
+
if (e.kind === "artifact-update" && e.lastChunk === true) {
|
|
37
|
+
const text = e.artifact?.parts
|
|
38
|
+
?.filter((p) => p.kind === "text")
|
|
39
|
+
.map((p) => p.text)
|
|
40
|
+
.join("")
|
|
41
|
+
if (text) return text
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (let i = this.events.length - 1; i >= 0; i--) {
|
|
45
|
+
const e = this.events[i]
|
|
46
|
+
if (e.kind === "status-update" && e.status?.message?.parts) {
|
|
47
|
+
const text = e.status.message.parts
|
|
48
|
+
.filter((p) => p.kind === "text")
|
|
49
|
+
.map((p) => p.text)
|
|
50
|
+
.join("")
|
|
51
|
+
if (text) return text
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return ""
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
getStatus() {
|
|
58
|
+
const e = this.events.at(-1)
|
|
59
|
+
if (e && e.kind === "status-update" && e.status?.state) {
|
|
60
|
+
const state = e.status.state
|
|
61
|
+
const msg = e.status?.message?.parts?.find((p) => p.kind === "text")?.text ?? ""
|
|
62
|
+
return { status: state, description: msg }
|
|
63
|
+
}
|
|
64
|
+
return { status: "completed", description: "" }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Derive toolCalls from graph messages by pairing AIMessage.tool_calls with ToolMessage results.
|
|
69
|
+
function toolCallsFromMessages(messages) {
|
|
70
|
+
if (!Array.isArray(messages) || messages.length === 0) return []
|
|
71
|
+
|
|
72
|
+
const resultById = new Map()
|
|
73
|
+
for (const msg of messages) {
|
|
74
|
+
if (msg.tool_call_id && msg.type === "tool") {
|
|
75
|
+
const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content)
|
|
76
|
+
resultById.set(msg.tool_call_id, { content, ...msg })
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const entries = []
|
|
81
|
+
for (const msg of messages) {
|
|
82
|
+
if (!Array.isArray(msg.tool_calls) || msg.tool_calls.length === 0) continue
|
|
83
|
+
for (const tc of msg.tool_calls) {
|
|
84
|
+
const raw = resultById.get(tc.id)
|
|
85
|
+
let toolResult
|
|
86
|
+
try {
|
|
87
|
+
toolResult = raw?.content !== undefined ? JSON.parse(raw.content) : undefined
|
|
88
|
+
} catch {
|
|
89
|
+
toolResult = raw?.content
|
|
90
|
+
}
|
|
91
|
+
const entry = {
|
|
92
|
+
tool: tc.name,
|
|
93
|
+
args: tc.args ?? {},
|
|
94
|
+
outcome: raw.status,
|
|
95
|
+
...(toolResult !== undefined && { result: toolResult }),
|
|
96
|
+
}
|
|
97
|
+
attachCqn(entry)
|
|
98
|
+
entries.push(entry)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return entries
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* If entry.args.cql attaches cqn as hidden property.
|
|
106
|
+
* No-op when cql is absent or faulty.
|
|
107
|
+
*/
|
|
108
|
+
function attachCqn(entry) {
|
|
109
|
+
const cql = entry.args?.cql
|
|
110
|
+
if (typeof cql !== "string") return
|
|
111
|
+
try {
|
|
112
|
+
const cqn = cds.parse.cql(cql)
|
|
113
|
+
Object.defineProperty(entry, "cqn", {
|
|
114
|
+
value: cqn,
|
|
115
|
+
enumerable: false,
|
|
116
|
+
writable: false,
|
|
117
|
+
configurable: true,
|
|
118
|
+
})
|
|
119
|
+
} catch {
|
|
120
|
+
/* unparseable CQL — skip */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildRequestContext(query, opts = {}) {
|
|
125
|
+
const taskId = opts.taskId || cds.utils.uuid()
|
|
126
|
+
const contextId = opts.contextId || cds.utils.uuid()
|
|
127
|
+
const parts =
|
|
128
|
+
opts.parts ??
|
|
129
|
+
(typeof query === "string"
|
|
130
|
+
? [{ kind: "text", text: query }]
|
|
131
|
+
: (query?.parts ?? [{ kind: "text", text: String(query) }]))
|
|
132
|
+
return {
|
|
133
|
+
taskId,
|
|
134
|
+
contextId,
|
|
135
|
+
task: opts.task ?? null,
|
|
136
|
+
userMessage: {
|
|
137
|
+
kind: "message",
|
|
138
|
+
messageId: cds.utils.uuid(),
|
|
139
|
+
role: "user",
|
|
140
|
+
taskId,
|
|
141
|
+
contextId,
|
|
142
|
+
parts,
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Install srv.chat(query, previous?) on an @agent service for eval tests. */
|
|
148
|
+
export function registerChat(srv) {
|
|
149
|
+
srv.chat = async function chat(query, previous) {
|
|
150
|
+
// Resolve the small eval helper API:
|
|
151
|
+
// chat(query)
|
|
152
|
+
// chat(query, prevResult) — extract contextId + taskId for HITL resume
|
|
153
|
+
// chat(query, { _details: true }) — internal/test escape hatch outside test profile
|
|
154
|
+
let opts = {}
|
|
155
|
+
if (typeof previous === "string") {
|
|
156
|
+
throw new TypeError(
|
|
157
|
+
"agent.chat: second argument must be a previous chat result object or options object",
|
|
158
|
+
)
|
|
159
|
+
} else if (previous && typeof previous === "object") {
|
|
160
|
+
if ("text" in previous || "contextId" in previous) {
|
|
161
|
+
// prior chat() result — extract conversation ids and HITL state
|
|
162
|
+
opts = {
|
|
163
|
+
contextId: previous.contextId,
|
|
164
|
+
// mark as resume when prior result was input-required
|
|
165
|
+
...(previous.status === "input-required" && {
|
|
166
|
+
taskId: previous.taskId,
|
|
167
|
+
task: { id: previous.taskId, status: { state: "input-required" } },
|
|
168
|
+
}),
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
opts = { _details: previous._details === true }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const includeDetails = shouldIncludeChatDetails(opts)
|
|
176
|
+
const runState = includeDetails ? getActiveRunState() : null
|
|
177
|
+
|
|
178
|
+
// Open span collection session before execution so the processor
|
|
179
|
+
// captures all spans from this trace.
|
|
180
|
+
const collection = includeDetails ? await startCollection() : null
|
|
181
|
+
|
|
182
|
+
const { LangGraphExecutor } = await import("../langgraph-executor-srv.js")
|
|
183
|
+
const executor = LangGraphExecutor.for(srv)
|
|
184
|
+
const requestContext = buildRequestContext(query, opts)
|
|
185
|
+
const eventBus = new NoopEventBus()
|
|
186
|
+
const mlflowRunId = runState?.mlflowRunId
|
|
187
|
+
let traceId
|
|
188
|
+
|
|
189
|
+
const runInContext = async () => {
|
|
190
|
+
// Link the trace to the MLflow eval run — graph-executor reads this to set mlflow.sourceRun.
|
|
191
|
+
if (mlflowRunId && cds.context) cds.context["_mlflow.evalRunId"] = mlflowRunId
|
|
192
|
+
|
|
193
|
+
let execError = null
|
|
194
|
+
const execPromise = executor.execute(requestContext, eventBus).catch((err) => {
|
|
195
|
+
execError = err
|
|
196
|
+
if (!eventBus._done) eventBus.finished()
|
|
197
|
+
})
|
|
198
|
+
await Promise.race([eventBus.finished$, execPromise])
|
|
199
|
+
if (!eventBus._done) eventBus.finished()
|
|
200
|
+
if (execError) throw execError
|
|
201
|
+
|
|
202
|
+
if (includeDetails) {
|
|
203
|
+
const rootSpan = cds.context?.["_mlflow.rootSpan"]
|
|
204
|
+
if (rootSpan) traceId = rootSpan.spanContext?.()?.traceId
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (cds.context) {
|
|
209
|
+
await runInContext()
|
|
210
|
+
} else {
|
|
211
|
+
await cds._with(
|
|
212
|
+
new cds.EventContext({ tenant: "t0", user: new cds.User.Privileged() }),
|
|
213
|
+
runInContext,
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const { status, description } = eventBus.getStatus()
|
|
218
|
+
if (status === "failed")
|
|
219
|
+
throw new Error(`agent.chat: task failed — ${description || "no message"}`)
|
|
220
|
+
|
|
221
|
+
const text = eventBus.getFinalText()
|
|
222
|
+
const result = {
|
|
223
|
+
text,
|
|
224
|
+
contextId: requestContext.contextId,
|
|
225
|
+
taskId: requestContext.taskId,
|
|
226
|
+
status,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (includeDetails) {
|
|
230
|
+
const allMessages = eventBus._graphResult?.messages ?? []
|
|
231
|
+
|
|
232
|
+
// Find last HumanMessage whose text matches current query; return from there.
|
|
233
|
+
const queryText = String(query)
|
|
234
|
+
let turnStart = 0
|
|
235
|
+
for (let i = allMessages.length - 1; i >= 0; i--) {
|
|
236
|
+
const m = allMessages[i]
|
|
237
|
+
if (m.getType?.() === "human" || m._getType?.() === "human" || m.type === "human") {
|
|
238
|
+
const content = typeof m.content === "string" ? m.content : (m.content?.[0]?.text ?? "")
|
|
239
|
+
if (content.startsWith(queryText) || queryText.startsWith(content.trim())) {
|
|
240
|
+
turnStart = i
|
|
241
|
+
break
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const messages = allMessages.slice(turnStart)
|
|
246
|
+
const toolCalls = toolCallsFromMessages(messages)
|
|
247
|
+
|
|
248
|
+
const allSpans = collection.collect()
|
|
249
|
+
const spans = traceId
|
|
250
|
+
? allSpans.filter((s) => s.spanContext?.()?.traceId === traceId)
|
|
251
|
+
: allSpans
|
|
252
|
+
|
|
253
|
+
result.query = String(query)
|
|
254
|
+
result.traceId = traceId
|
|
255
|
+
result.toolCalls = toolCalls
|
|
256
|
+
result.messages = messages.map((m) => {
|
|
257
|
+
// Eval prompts expect text content, not content-block objects.
|
|
258
|
+
if (m.type === "ai") {
|
|
259
|
+
if (Array.isArray(m.content) && m.content[0]?.text) {
|
|
260
|
+
m.content = m.content[0].text
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return m
|
|
264
|
+
})
|
|
265
|
+
result.spans = spans
|
|
266
|
+
result.metrics = metricsFromSpans(spans)
|
|
267
|
+
if (runState) result._evalState = runState
|
|
268
|
+
// Post metrics to MLflow ootb — fire-and-forget.
|
|
269
|
+
logMlflowMetricsForResult(result, runState).catch(() => {})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return result
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function shouldIncludeChatDetails(opts = {}) {
|
|
277
|
+
return cds.env.profiles?.includes("test") || opts._details === true
|
|
278
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
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
|
+
|
|
5
|
+
const LOG = cds.log("agents")
|
|
6
|
+
|
|
7
|
+
export const HITL_METADATA_KEY = "sap.cds.agents.hitl"
|
|
8
|
+
export const requiresHitl = (result) =>
|
|
9
|
+
result?.__interrupt__?.length > 0 || result?.interrupts?.length > 0
|
|
10
|
+
|
|
11
|
+
export function parseResumeDecision(userText) {
|
|
12
|
+
const t = userText.trim()
|
|
13
|
+
if (/^(approve|yes|confirm|ok)$/i.test(t)) return { decisions: [{ type: "approve" }] }
|
|
14
|
+
if (/^edit$/i.test(t)) return { decisions: [{ type: "edit" }] }
|
|
15
|
+
return {
|
|
16
|
+
decisions: [
|
|
17
|
+
{
|
|
18
|
+
type: "reject",
|
|
19
|
+
message: `The user rejected this particular tool invocation with the reason: ${userText}`,
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function decisionsForAudit(resume, actionRequests = []) {
|
|
26
|
+
if (!Array.isArray(resume?.decisions)) return [{ action: null, decision: resume }]
|
|
27
|
+
return resume.decisions.map((decision, index) => ({
|
|
28
|
+
action: actionRequests[index] ?? { index: index + 1 },
|
|
29
|
+
decision,
|
|
30
|
+
}))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function extractInterruptDescription(resultOrErr) {
|
|
34
|
+
const interrupt = resultOrErr.__interrupt__?.[0] || resultOrErr.interrupts?.[0]
|
|
35
|
+
const payload = interrupt?.value
|
|
36
|
+
if (!payload) return "This action requires your approval. Reply 'approve' or 'reject'."
|
|
37
|
+
if (payload.actionRequests?.length > 0) {
|
|
38
|
+
return (
|
|
39
|
+
payload.actionRequests[0].description || `Approve action: ${payload.actionRequests[0].name}?`
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
return typeof payload === "string" ? payload : JSON.stringify(payload)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function extractInterruptData(resultOrErr) {
|
|
46
|
+
const interrupt = resultOrErr.__interrupt__?.[0] || resultOrErr.interrupts?.[0]
|
|
47
|
+
const payload = interrupt?.value
|
|
48
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined
|
|
49
|
+
const actionRequests = mergeReviewConfigs(payload.actionRequests, payload.reviewConfigs)
|
|
50
|
+
return actionRequests === payload.actionRequests ? payload : { ...payload, actionRequests }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function mergeReviewConfigs(actionRequests, reviewConfigs) {
|
|
54
|
+
if (!Array.isArray(actionRequests) || !Array.isArray(reviewConfigs)) return actionRequests
|
|
55
|
+
const configsByAction = new Map(reviewConfigs.map((config) => [config.actionName, config]))
|
|
56
|
+
let changed = false
|
|
57
|
+
const requests = actionRequests.map((request) => {
|
|
58
|
+
const config = configsByAction.get(request.name)
|
|
59
|
+
if (!config) return request
|
|
60
|
+
changed = true
|
|
61
|
+
const { allowedDecisions, argsSchema } = config
|
|
62
|
+
return {
|
|
63
|
+
...request,
|
|
64
|
+
...(allowedDecisions === undefined ? {} : { allowedDecisions }),
|
|
65
|
+
...(argsSchema === undefined ? {} : { argsSchema }),
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
return changed ? requests : actionRequests
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function interruptActionCount(resultOrErr) {
|
|
72
|
+
const interrupts = resultOrErr.__interrupt__ || resultOrErr.interrupts || []
|
|
73
|
+
return interrupts.reduce(
|
|
74
|
+
(count, interrupt) => count + (interrupt?.value?.actionRequests?.length || 0),
|
|
75
|
+
0,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function interruptActionRequests(resultOrErr) {
|
|
80
|
+
const interrupts = resultOrErr.__interrupt__ || resultOrErr.interrupts || []
|
|
81
|
+
return interrupts.flatMap((interrupt) => interrupt?.value?.actionRequests || [])
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function composeHitlDecisionNote(actionRequests, resume) {
|
|
85
|
+
const decisions = resume?.decisions
|
|
86
|
+
if (!Array.isArray(decisions) || decisions.length === 0) return undefined
|
|
87
|
+
if (decisions.every((decision) => decision?.type === "approve")) return undefined
|
|
88
|
+
const consumed = new Set()
|
|
89
|
+
const takeByName = (name) => {
|
|
90
|
+
for (let index = 0; index < actionRequests.length; index++) {
|
|
91
|
+
if (!consumed.has(index) && actionRequests[index]?.name === name) {
|
|
92
|
+
consumed.add(index)
|
|
93
|
+
return actionRequests[index]
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
const action = (request) =>
|
|
99
|
+
"`" + (request?.name ?? "unknown action") + "(" + JSON.stringify(request?.args ?? {}) + ")`"
|
|
100
|
+
const lines = []
|
|
101
|
+
for (const [index, decision] of decisions.entries()) {
|
|
102
|
+
const original = actionRequests[index]
|
|
103
|
+
if (decision?.type === "edit") {
|
|
104
|
+
const matched = takeByName(decision.editedAction?.name) ?? original
|
|
105
|
+
lines.push("- User edited " + action(matched) + " to " + action(decision.editedAction) + ".")
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!lines.length) {
|
|
110
|
+
return undefined
|
|
111
|
+
}
|
|
112
|
+
return ["User HITL decisions (not tool failures):", ...lines].join("\n")
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function getPreInterruptToolCalls(graph, config) {
|
|
116
|
+
try {
|
|
117
|
+
if (typeof graph.getState !== "function") return []
|
|
118
|
+
const state = await graph.getState(config)
|
|
119
|
+
const messages = state?.values?.messages ?? []
|
|
120
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
121
|
+
const message = messages[i]
|
|
122
|
+
if (message?.tool_calls?.length) {
|
|
123
|
+
return message.tool_calls.map((call) => ({ id: call.id, name: call.name, args: call.args }))
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return []
|
|
127
|
+
} catch {
|
|
128
|
+
return []
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function getPendingHitlActionCount(graph, config) {
|
|
133
|
+
if (typeof graph.getState !== "function") {
|
|
134
|
+
throw new Error("Cannot resume HITL: graph state is unavailable.")
|
|
135
|
+
}
|
|
136
|
+
const state = await graph.getState(config)
|
|
137
|
+
const interrupts = state?.tasks?.flatMap((task) => task.interrupts || []) || []
|
|
138
|
+
const count = interrupts.reduce(
|
|
139
|
+
(total, interrupt) => total + (interrupt?.value?.actionRequests?.length || 1),
|
|
140
|
+
0,
|
|
141
|
+
)
|
|
142
|
+
if (count < 1) throw new Error("Cannot resume HITL: no pending actions found.")
|
|
143
|
+
return count
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function pendingHitlFromTask(task) {
|
|
147
|
+
const pending = task?.status?.message?.metadata?.[HITL_METADATA_KEY]
|
|
148
|
+
if (!Number.isInteger(pending?.actionCount) || pending.actionCount < 1) return undefined
|
|
149
|
+
if (!Array.isArray(pending.decisions)) return undefined
|
|
150
|
+
return pending
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function pendingActionRequests(task, pending) {
|
|
154
|
+
return (
|
|
155
|
+
pending?.actionRequests || firstDataPart(task?.status?.message?.parts)?.actionRequests || []
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function interruptDescriptionFromTask(task, pending) {
|
|
160
|
+
const action = pendingActionRequests(task, pending)[pending?.decisions?.length || 0]
|
|
161
|
+
if (action) return action.description || `Approve action: ${action.name}?`
|
|
162
|
+
return (
|
|
163
|
+
task?.status?.message?.parts?.find((part) => part.kind === "text")?.text ||
|
|
164
|
+
"This action requires your approval. Reply 'approve' or 'reject'."
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function publishInputRequired({ requestContext, eventBus, description, interruptData, pending }) {
|
|
169
|
+
const { taskId, contextId } = requestContext
|
|
170
|
+
eventBus.publish({
|
|
171
|
+
kind: "status-update",
|
|
172
|
+
taskId,
|
|
173
|
+
contextId,
|
|
174
|
+
status: {
|
|
175
|
+
state: "input-required",
|
|
176
|
+
message: agentMessage(description, interruptData, { [HITL_METADATA_KEY]: pending }),
|
|
177
|
+
timestamp: new Date().toISOString(),
|
|
178
|
+
},
|
|
179
|
+
final: true,
|
|
180
|
+
})
|
|
181
|
+
eventBus.finished()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function resumeHitl({ requestContext, graph, config, eventBus, stream, signal }) {
|
|
185
|
+
const { taskId, contextId } = requestContext
|
|
186
|
+
const dataPart = firstDataPart(requestContext.userMessage?.parts)
|
|
187
|
+
const userText = partsToText(requestContext.userMessage?.parts)
|
|
188
|
+
if (dataPart === undefined && !userText.trim()) {
|
|
189
|
+
throw new Error(cds.i18n.messages.at("RESUME_REQUIRES_TEXT"))
|
|
190
|
+
}
|
|
191
|
+
const { Command } = await import("@langchain/langgraph")
|
|
192
|
+
let resume = dataPart !== undefined ? dataPart : parseResumeDecision(userText)
|
|
193
|
+
let actionRequests = []
|
|
194
|
+
|
|
195
|
+
if (Array.isArray(resume?.decisions)) {
|
|
196
|
+
const pending = pendingHitlFromTask(requestContext.task)
|
|
197
|
+
const actionCount = pending?.actionCount ?? (await getPendingHitlActionCount(graph, config))
|
|
198
|
+
actionRequests = pendingActionRequests(requestContext.task, pending)
|
|
199
|
+
const decisions = [...(pending?.decisions || []), ...resume.decisions]
|
|
200
|
+
if (decisions.length < actionCount) {
|
|
201
|
+
const interruptData = firstDataPart(requestContext.task?.status?.message?.parts)
|
|
202
|
+
const nextPending = { ...pending, actionCount, decisions }
|
|
203
|
+
publishInputRequired({
|
|
204
|
+
requestContext,
|
|
205
|
+
eventBus,
|
|
206
|
+
description: interruptDescriptionFromTask(requestContext.task, nextPending),
|
|
207
|
+
interruptData,
|
|
208
|
+
pending: {
|
|
209
|
+
actionCount,
|
|
210
|
+
decisions,
|
|
211
|
+
actionRequests: pendingActionRequests(requestContext.task, nextPending),
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
return undefined
|
|
215
|
+
}
|
|
216
|
+
resume = { ...resume, decisions }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const decisions = decisionsForAudit(resume, actionRequests)
|
|
220
|
+
LOG.debug("resuming", { conversation: short(contextId), decisions })
|
|
221
|
+
audit("AgentTaskResumed", {
|
|
222
|
+
data: { taskId, contextId, service: cds.context?.["agent.service"], decisions },
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
const originalActions = actionRequests.length
|
|
226
|
+
? actionRequests
|
|
227
|
+
: await getPreInterruptToolCalls(graph, config)
|
|
228
|
+
const decisionNote = composeHitlDecisionNote(originalActions, resume)
|
|
229
|
+
const commandArgs = { resume }
|
|
230
|
+
if (decisionNote) commandArgs.update = { _hitlDecisionNote: decisionNote }
|
|
231
|
+
const resumed = await stream(new Command(commandArgs), signal)
|
|
232
|
+
return resumed.state
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function handleHitlInterrupt({
|
|
236
|
+
result,
|
|
237
|
+
requestContext,
|
|
238
|
+
eventBus,
|
|
239
|
+
serviceName,
|
|
240
|
+
duration,
|
|
241
|
+
onInputRequired,
|
|
242
|
+
}) {
|
|
243
|
+
const { taskId, contextId } = requestContext
|
|
244
|
+
const description = extractInterruptDescription(result)
|
|
245
|
+
const interruptData = extractInterruptData(result)
|
|
246
|
+
LOG.info("input-required", { conversation: short(contextId), service: serviceName, duration })
|
|
247
|
+
onInputRequired?.(description)
|
|
248
|
+
audit("AgentInputRequired", {
|
|
249
|
+
data: { taskId, contextId, service: serviceName, description, interruptData },
|
|
250
|
+
})
|
|
251
|
+
publishInputRequired({
|
|
252
|
+
requestContext,
|
|
253
|
+
eventBus,
|
|
254
|
+
description,
|
|
255
|
+
interruptData,
|
|
256
|
+
pending: {
|
|
257
|
+
actionCount: interruptActionCount(result),
|
|
258
|
+
decisions: [],
|
|
259
|
+
actionRequests: interruptData?.actionRequests || interruptActionRequests(result),
|
|
260
|
+
},
|
|
261
|
+
})
|
|
262
|
+
return true
|
|
263
|
+
}
|