@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.
Files changed (49) hide show
  1. package/README.md +2 -0
  2. package/cds-plugin.js +2 -2
  3. package/lib/agents/middleware/content-filter.js +3 -2
  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 +11 -0
  7. package/lib/agents/middleware/quota-enforcer.js +9 -0
  8. package/lib/agents/middleware/remote-mcp.js +62 -0
  9. package/lib/agents/middleware/tool-wrap.js +52 -0
  10. package/lib/compile.js +6 -2
  11. package/lib/eval/Judge.js +239 -0
  12. package/lib/eval/eval-describe.js +69 -0
  13. package/lib/eval/eval-run.js +147 -0
  14. package/lib/eval/index.js +6 -0
  15. package/lib/eval/metrics.js +53 -0
  16. package/lib/eval/span-collector.js +50 -0
  17. package/lib/index.js +2 -1
  18. package/lib/models/aicore.js +23 -6
  19. package/lib/models/anthropic.js +27 -12
  20. package/lib/preview/chat.html +373 -66
  21. package/lib/protocol/agent-card.js +6 -2
  22. package/lib/sidecar.js +1 -1
  23. package/lib/telemetry/chat-tracing.js +39 -8
  24. package/lib/telemetry/mlflow/credentials.js +79 -0
  25. package/lib/telemetry/mlflow/evaluation.js +38 -0
  26. package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +75 -0
  27. package/lib/telemetry/mlflow/exporter/MlflowExporter.js +115 -0
  28. package/lib/telemetry/mlflow/exporter/index.js +23 -0
  29. package/lib/telemetry/mlflow/index.js +16 -0
  30. package/lib/telemetry/mlflow/prompts.js +123 -0
  31. package/lib/telemetry/mlflow/tracing.js +264 -0
  32. package/lib/telemetry/tool-tracing.js +27 -54
  33. package/lib/telemetry/tracing.js +3 -1
  34. package/lib/utils/markdown.js +1 -11
  35. package/lib/utils/message-handling.js +14 -0
  36. package/lib/utils/resilience.js +133 -0
  37. package/lib/utils/utils.js +17 -0
  38. package/package.json +26 -14
  39. package/srv/handlers/chat.js +278 -0
  40. package/srv/handlers/graph-executor/hitl.js +263 -0
  41. package/srv/handlers/graph-executor.js +100 -286
  42. package/srv/handlers/index.js +5 -1
  43. package/srv/handlers/mcp-tools.js +7 -45
  44. package/srv/handlers/sub-agent-tools.js +30 -12
  45. package/srv/handlers/tools.js +39 -5
  46. package/index.js +0 -0
  47. package/lib/agents/middleware/hitl-edit-note-injector.js +0 -18
  48. package/lib/telemetry/mlflow.js +0 -290
  49. /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.2",
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
- "./index.cds": "./index.cds",
16
+ "./eval": "./lib/eval/index.js",
17
+ "./lib/index.cjs": "./lib/index.cjs",
18
18
  "./lib/models/*": "./lib/models/*.js",
19
- "./srv/langgraph-executor-srv": "./srv/langgraph-executor-srv.js",
20
- "./srv/*": "./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
- "index.js",
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
- "deepagents": "^1.10.2"
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
+ }