@cap-js/agents 0.9.3 → 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.
@@ -0,0 +1,18 @@
1
+ import { createMiddleware } from "langchain"
2
+ import { HumanMessage } from "@langchain/core/messages"
3
+ import { z } from "zod"
4
+
5
+ // Injects user HITL decisions before the model continues after a rejection or edit.
6
+ export function hitlDecisionNoteInjectorMiddleware() {
7
+ return createMiddleware({
8
+ name: "hitlDecisionNoteInjectorMiddleware",
9
+ stateSchema: z.object({ _hitlDecisionNote: z.string().optional() }),
10
+ beforeModel: async (state) => {
11
+ if (!state._hitlDecisionNote) return
12
+ return {
13
+ messages: [new HumanMessage(state._hitlDecisionNote)],
14
+ _hitlDecisionNote: undefined,
15
+ }
16
+ },
17
+ })
18
+ }
@@ -1,5 +1,5 @@
1
1
  import { humanInTheLoopMiddleware as hitl } from "langchain"
2
- import { hitlEditNoteInjectorMiddleware } from "./hitl-edit-note-injector.js"
2
+ import { hitlDecisionNoteInjectorMiddleware } from "./hitl-decision-note-injector.js"
3
3
 
4
4
  function buildHitlInterruptMap(srv, tools = []) {
5
5
  return tools.reduce((interruptOn, tool) => {
@@ -16,5 +16,5 @@ function buildHitlInterruptMap(srv, tools = []) {
16
16
  export async function humanInTheLoopMiddleware(srv, tools) {
17
17
  const interruptOn = buildHitlInterruptMap(srv, tools)
18
18
  if (!Object.keys(interruptOn).length) return []
19
- return [hitl({ interruptOn }), hitlEditNoteInjectorMiddleware()]
19
+ return [hitl({ interruptOn }), hitlDecisionNoteInjectorMiddleware()]
20
20
  }
@@ -89,6 +89,15 @@ export async function quotaEnforcerMiddleware() {
89
89
  }
90
90
  },
91
91
  },
92
+ afterAgent: {
93
+ hook: () => {
94
+ return {
95
+ runModelCallCount: 0,
96
+ runTokenCount: 0,
97
+ runToolCallCount: 0,
98
+ }
99
+ },
100
+ },
92
101
  }),
93
102
  ]
94
103
  }
@@ -5,6 +5,7 @@ import os from 'node:os'
5
5
  import cds from '@sap/cds'
6
6
 
7
7
  const HOME = os.homedir() || process.env.HOME || process.env.USERPROFILE
8
+ const local = file => file.replace(HOME,'~')
8
9
  const LOG = cds.log('agents')
9
10
 
10
11
  /**
@@ -15,27 +16,36 @@ const LOG = cds.log('agents')
15
16
  */
16
17
  export default class ChatAnthropicService extends ChatAnthropic {
17
18
  constructor (name, options) {
18
- // REVISIT: may be better handled via options.credentials?
19
- let config = { ...options, ...fromEnv() }
19
+ let config = { ...options, ...options?.credentials, ...fromEnv() }
20
20
  if (!config.anthropicApiUrl) config = {
21
21
  ...fromClaude() || fromOpencode(),
22
22
  ...config
23
23
  }
24
- LOG.debug (`Using effective config for ChatAnthropic:`, config)
24
+ if (LOG._debug) {
25
+ const { kind, model, anthropicApiUrl, apiKey } = config
26
+ LOG.info (`Using effective config:`, {
27
+ kind,
28
+ model,
29
+ credentials: {
30
+ anthropicApiUrl,
31
+ apiKey: apiKey ? '***' : undefined
32
+ }
33
+ })
34
+ }
25
35
  super (config)
26
36
  this.name = name
27
37
  this.options = config
28
38
  }
29
39
  }
30
40
 
31
- function fromEnv (env = process.env) {
41
+ function fromEnv (env = process.env, silent) {
32
42
  let any, config = {}
33
43
  if ((any = env.ANTHROPIC_BASE_URL)) config.anthropicApiUrl = any
34
44
  if ((any = env.ANTHROPIC_AUTH_TOKEN)) config.apiKey = any
35
45
  if ((any = env.ANTHROPIC_API_KEY)) config.apiKey = any
36
46
  if ((any = env.ANTHROPIC_MODEL)) config.model = any
37
47
  if (!Object.keys(config).length) return null
38
- LOG.debug (`Loaded Anthropic settings from env:`, config)
48
+ if (!silent) LOG.debug (`Loaded Anthropic settings from env:`, config)
39
49
  return config
40
50
  }
41
51
 
@@ -43,12 +53,17 @@ function fromClaude() {
43
53
  if ('cached' in fromClaude) return fromClaude.cached
44
54
  const settings_json = path.join (HOME,'.claude/settings.json')
45
55
  try {
46
- let conf = JSON.parse (fs.readFileSync (settings_json,'utf8'))
56
+ let settings = JSON.parse (fs.readFileSync (settings_json,'utf8'))
47
57
  // https://www.schemastore.org/claude-code-settings.json
48
- fromClaude.cached = conf = fromEnv (conf?.env)
49
- LOG.debug(`Loaded Claude settings from`, settings_json, ':', conf)
58
+
59
+ let conf = fromClaude.cached = fromEnv (settings?.env, 'silent')
60
+ if (!conf.model && settings.env) {
61
+ let family = (settings.model||'sonnet').toUpperCase()
62
+ conf.model = settings.env[`ANTHROPIC_DEFAULT_${family}_MODEL`] || settings?.model
63
+ }
64
+ LOG.debug(`Loaded Claude settings from`, local(settings_json), ':', conf)
50
65
  } catch {
51
- LOG.debug(`Failed loading Claude settings from`, settings_json)
66
+ LOG.debug(`Failed loading Claude settings from`, local(settings_json))
52
67
  fromClaude.cached = null
53
68
  }
54
69
  return fromClaude.cached
@@ -59,7 +74,7 @@ function fromOpencode() {
59
74
  const opencode_json = path.join (HOME,'.config/opencode/opencode.json')
60
75
  try {
61
76
  let conf = JSON.parse (fs.readFileSync (opencode_json,'utf8'))
62
- LOG.debug(`Loaded OpenCode settings from`, opencode_json)
77
+ LOG.debug(`Loaded OpenCode settings from`, local(opencode_json))
63
78
  // https://opencode.ai/config.json
64
79
  let o = conf?.provider?.anthropic?.options
65
80
  if (!o) return fromOpencode.cached = null
@@ -68,9 +83,9 @@ function fromOpencode() {
68
83
  if ((any = o.anthropicApiKey ?? o.apiKey)) config.apiKey = any
69
84
  if ((any = conf?.model)) config.model = any.replace('anthropic/','')
70
85
  fromOpencode.cached = Object.keys(config).length ? config : null
71
- LOG.debug(`Loaded OpenCode settings from`, opencode_json, ':', conf)
86
+ LOG.debug(`Loaded OpenCode settings from`, local(opencode_json), ':', conf)
72
87
  } catch {
73
- LOG.debug(`Failed loading OpenCode settings from`, opencode_json)
88
+ LOG.debug(`Failed loading OpenCode settings from`, local(opencode_json))
74
89
  fromOpencode.cached = null
75
90
  }
76
91
  return fromOpencode.cached
@@ -115,6 +115,7 @@
115
115
  color: #1d3e5a;
116
116
  border-bottom-left-radius: 3px;
117
117
  border-left: 3px solid #537492;
118
+ white-space: pre-wrap;
118
119
  }
119
120
  .msg.approval::before {
120
121
  content: "Action Required";
@@ -616,7 +617,7 @@
616
617
  function flushPendingThinkingUpdate() {
617
618
  const pending = pendingThinkingUpdate
618
619
  clearPendingThinkingUpdate()
619
- if (pending) renderThinkingStep(pending.artifactId, pending.text, pending.lastChunk)
620
+ if (pending) renderThinkingStep(pending)
620
621
  }
621
622
 
622
623
  function prefersReducedMotion() {
@@ -642,10 +643,17 @@
642
643
  else expandThinkingSummary(el)
643
644
  }
644
645
 
645
- function renderThinkingStep(artifactId, text, lastChunk) {
646
+ function renderThinkingStep(update) {
647
+ const { artifactId, text, lastChunk, append } = update
646
648
  const panel = ensureThinkingSummary()
647
- const isNewStep = artifactId !== currentThinkingArtifactId || currentThinkingClosed
648
- if (isNewStep) {
649
+ const idx = thinkingStepEls.findIndex((step) => step._artifactId === artifactId)
650
+ if (append && idx >= 0) {
651
+ thinkingTexts[idx] += text
652
+ thinkingStepEls[idx].innerHTML = md(thinkingTexts[idx])
653
+ } else if (artifactId === currentThinkingArtifactId && !currentThinkingClosed) {
654
+ thinkingTexts[idx] += "\n" + text
655
+ thinkingStepEls[idx].innerHTML = md(thinkingTexts[idx])
656
+ } else {
649
657
  const step = document.createElement("div")
650
658
  step.className = "thinking-step"
651
659
  step._artifactId = artifactId
@@ -657,17 +665,12 @@
657
665
  updateThinkingSummaryLabel()
658
666
  currentThinkingArtifactId = artifactId
659
667
  currentThinkingClosed = false
660
- } else {
661
- // Append to current open step for this artifactId
662
- const idx = thinkingStepEls.length - 1
663
- thinkingTexts[idx] += text
664
- thinkingStepEls[idx].innerHTML = md(thinkingTexts[idx])
665
668
  }
666
669
  if (lastChunk) currentThinkingClosed = true
667
670
  messages.scrollTop = messages.scrollHeight
668
671
  }
669
672
 
670
- function addThinkingStep(artifactId, text, lastChunk) {
673
+ function addThinkingStep(artifactId, text, lastChunk, append) {
671
674
  if (
672
675
  pendingThinkingUpdate &&
673
676
  pendingThinkingUpdate.artifactId === artifactId &&
@@ -677,7 +680,7 @@
677
680
  pendingThinkingUpdate.lastChunk = lastChunk
678
681
  } else {
679
682
  flushPendingThinkingUpdate()
680
- pendingThinkingUpdate = { artifactId, text, lastChunk }
683
+ pendingThinkingUpdate = { artifactId, text, lastChunk, append }
681
684
  }
682
685
 
683
686
  if (pendingThinkingTimer) clearTimeout(pendingThinkingTimer)
@@ -811,7 +814,7 @@
811
814
  function addApprovalPrompt(description) {
812
815
  const el = document.createElement("div")
813
816
  el.className = "msg approval"
814
- el.innerHTML = md(description || "The agent requires your approval to continue.")
817
+ el.textContent = description || "The agent requires your approval to continue."
815
818
  const actions = document.createElement("div")
816
819
  actions.className = "approval-actions"
817
820
 
@@ -1100,7 +1103,8 @@
1100
1103
 
1101
1104
  if (event.artifact?.artifactId?.startsWith("thinking")) {
1102
1105
  const lastChunk = event.lastChunk ?? false
1103
- if (text) addThinkingStep(event.artifact.artifactId, text, lastChunk)
1106
+ if (text)
1107
+ addThinkingStep(event.artifact.artifactId, text, lastChunk, event.append ?? false)
1104
1108
  return null
1105
1109
  }
1106
1110
 
@@ -1,3 +1,17 @@
1
+ import { randomUUID } from "node:crypto"
2
+
3
+ export function agentMessage(text, data, metadata) {
4
+ const parts = [{ kind: "text", text }]
5
+ if (data && typeof data === "object") parts.push({ kind: "data", data })
6
+ return {
7
+ kind: "message",
8
+ messageId: randomUUID(),
9
+ role: "agent",
10
+ parts,
11
+ ...(metadata && { metadata }),
12
+ }
13
+ }
14
+
1
15
  /**
2
16
  * A2A message part utilities — handle both wire formats:
3
17
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/agents",
3
- "version": "0.9.3",
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",
@@ -217,6 +217,9 @@
217
217
  "_javaHcqlCompat": true
218
218
  }
219
219
  }
220
+ },
221
+ "folders": {
222
+ "srvs": "srv/*"
220
223
  }
221
224
  },
222
225
  "workspaces": [
@@ -73,7 +73,7 @@ function toolCallsFromMessages(messages) {
73
73
  for (const msg of messages) {
74
74
  if (msg.tool_call_id && msg.type === "tool") {
75
75
  const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content)
76
- resultById.set(msg.tool_call_id, content)
76
+ resultById.set(msg.tool_call_id, { content, ...msg })
77
77
  }
78
78
  }
79
79
 
@@ -84,14 +84,14 @@ function toolCallsFromMessages(messages) {
84
84
  const raw = resultById.get(tc.id)
85
85
  let toolResult
86
86
  try {
87
- toolResult = raw !== undefined ? JSON.parse(raw) : undefined
87
+ toolResult = raw?.content !== undefined ? JSON.parse(raw.content) : undefined
88
88
  } catch {
89
- toolResult = raw
89
+ toolResult = raw?.content
90
90
  }
91
91
  const entry = {
92
92
  tool: tc.name,
93
93
  args: tc.args ?? {},
94
- outcome: "success",
94
+ outcome: raw.status,
95
95
  ...(toolResult !== undefined && { result: toolResult }),
96
96
  }
97
97
  attachCqn(entry)
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  import cds from "@sap/cds"
2
2
  import { short, audit, ms4 } from "../../lib/utils/utils.js"
3
- import { partsToText, buildChatMessages, firstDataPart } from "../../lib/utils/message-handling.js"
3
+ import { agentMessage, partsToText, buildChatMessages } from "../../lib/utils/message-handling.js"
4
4
  import * as metrics from "../../lib/telemetry/metrics.js"
5
5
  import { mlflowAttrs, mlflowTraceAttrs, setSpanAttrs } from "../../lib/telemetry/mlflow/index.js"
6
6
  import { CdsFileStore } from "../../lib/protocol/persistence/file-store.js"
@@ -9,6 +9,7 @@ import { convertUsageData } from "../../lib/telemetry/chat-tracing.js"
9
9
  import { triggerCleanup } from "../../lib/protocol/persistence/cleanup.js"
10
10
  import { COLLECT_RESULT } from "./chat.js"
11
11
  import { linkTraceToPrompt } from "../../lib/telemetry/mlflow/tracing.js"
12
+ import { handleHitlInterrupt, requiresHitl, resumeHitl } from "./graph-executor/hitl.js"
12
13
 
13
14
  const LOG = cds.log("agents")
14
15
 
@@ -104,18 +105,6 @@ function defaultOutputMapper(result) {
104
105
  return JSON.stringify(result)
105
106
  }
106
107
 
107
- // Construct a spec-compliant A2A Message; when `data` is a plain object, append it as a DataPart.
108
- function agentMessage(text, data) {
109
- const parts = [{ kind: "text", text }]
110
- if (data && typeof data === "object") parts.push({ kind: "data", data })
111
- return {
112
- kind: "message",
113
- messageId: cds.utils.uuid(),
114
- role: "agent",
115
- parts,
116
- }
117
- }
118
-
119
108
  /**
120
109
  * Extract user text from A2A message parts.
121
110
  */
@@ -123,147 +112,6 @@ function extractText(requestContext) {
123
112
  return partsToText(requestContext.userMessage?.parts)
124
113
  }
125
114
 
126
- // Extract the first inbound DataPart's opaque `data` object, or undefined if none.
127
- function extractData(requestContext) {
128
- return firstDataPart(requestContext.userMessage?.parts)
129
- }
130
-
131
- /**
132
- * Parse user's resume text into a HITL decision.
133
- * Maps to the format expected by deepagents' humanInTheLoopMiddleware.
134
- */
135
- function parseResumeDecision(userText) {
136
- const t = userText.trim()
137
- if (/^(approve|yes|confirm|ok)$/i.test(t)) {
138
- return { decisions: [{ type: "approve" }] }
139
- }
140
- if (/^edit$/i.test(t)) {
141
- // Bare edit — structured edits (with args) arrive via the DataPart path.
142
- return { decisions: [{ type: "edit" }] }
143
- }
144
- return { decisions: [{ type: "reject", message: userText }] }
145
- }
146
-
147
- // Best-effort decision label for logging/audit; opaque DataPart resumes fall back to "data".
148
- function decisionTypeOf(resume) {
149
- return resume?.decisions?.[0]?.type ?? "data"
150
- }
151
-
152
- /**
153
- * Extract the human-readable description from an interrupt payload.
154
- * Accepts either a graph result (with __interrupt__) or a GraphInterrupt error (with .interrupts).
155
- * Handles both deepagents' humanInTheLoopMiddleware format and raw interrupt() calls.
156
- */
157
- function extractInterruptDescription(resultOrErr) {
158
- const interrupt = resultOrErr.__interrupt__?.[0] || resultOrErr.interrupts?.[0]
159
- const payload = interrupt?.value
160
- if (!payload) return "This action requires your approval. Reply 'approve' or 'reject'."
161
-
162
- // deepagents' humanInTheLoopMiddleware: { actionRequests: [{ description }], reviewConfigs }
163
- if (payload.actionRequests?.length > 0) {
164
- return (
165
- payload.actionRequests[0].description || `Approve action: ${payload.actionRequests[0].name}?`
166
- )
167
- }
168
-
169
- // Raw interrupt(value) - value is a string or object
170
- if (typeof payload === "string") return payload
171
- return JSON.stringify(payload)
172
- }
173
-
174
- /**
175
- * Extract the raw structured interrupt payload for opaque carry on a DataPart.
176
- * Returns the payload ONLY when it is a plain object; arrays and strings are
177
- * carried by the TextPart alone. Payload is app-defined; the plugin never
178
- * interprets it.
179
- */
180
- function extractInterruptData(resultOrErr) {
181
- const interrupt = resultOrErr.__interrupt__?.[0] || resultOrErr.interrupts?.[0]
182
- const payload = interrupt?.value
183
- if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined
184
- return payload
185
- }
186
-
187
- // Order-invariant JSON serializer for structural arg comparison.
188
- function canonicalJSON(value) {
189
- if (value === null || typeof value !== "object") return JSON.stringify(value)
190
- if (Array.isArray(value)) return "[" + value.map(canonicalJSON).join(",") + "]"
191
- const keys = Object.keys(value).sort()
192
- return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalJSON(value[k])).join(",") + "}"
193
- }
194
-
195
- // Firm note describing HITL edits so the model doesn't apologize on the next turn.
196
- function composeEditNote(originals, resume) {
197
- const decisions = resume?.decisions
198
- if (!Array.isArray(decisions) || decisions.length === 0) return undefined
199
-
200
- const consumed = new Set()
201
- const takeByName = (name) => {
202
- for (let j = 0; j < originals.length; j++) {
203
- if (!consumed.has(j) && originals[j]?.name === name) {
204
- consumed.add(j)
205
- return originals[j]
206
- }
207
- }
208
- return undefined
209
- }
210
- const takeNextUnconsumed = () => {
211
- for (let j = 0; j < originals.length; j++) {
212
- if (!consumed.has(j)) {
213
- consumed.add(j)
214
- return originals[j]
215
- }
216
- }
217
- return undefined
218
- }
219
-
220
- const changes = []
221
- for (const d of decisions) {
222
- if (d?.type !== "edit" || !d.editedAction) continue
223
- const editedName = d.editedAction.name
224
- const editedArgs = d.editedAction.args
225
- const orig = takeByName(editedName) ?? takeNextUnconsumed()
226
- if (!orig) continue
227
- if (orig.name === editedName && canonicalJSON(orig.args) === canonicalJSON(editedArgs)) continue
228
- changes.push({
229
- from: { name: orig.name, args: orig.args },
230
- to: { name: editedName, args: editedArgs },
231
- })
232
- }
233
- if (changes.length === 0) return undefined
234
-
235
- const lines = changes.map(
236
- (c) =>
237
- `- \`${c.from.name}(${JSON.stringify(c.from.args)})\` → \`${c.to.name}(${JSON.stringify(c.to.args)})\``,
238
- )
239
- return [
240
- "The user reviewed your proposed tool call(s) in the human-in-the-loop approval flow and edited them before execution. This is intentional user action, NOT a mistake on your part. Do NOT apologize or say you made an error.",
241
- "",
242
- "Edits applied:",
243
- ...lines,
244
- "",
245
- "Proceed as if the edited values are what the user actually wants. Describe the outcome of the executed call accurately.",
246
- ].join("\n")
247
- }
248
-
249
- // Reads the pre-interrupt AI's tool_calls from the checkpointer (still un-mutated at resume time).
250
- async function getPreInterruptToolCalls(graph, config) {
251
- try {
252
- if (typeof graph.getState !== "function") return []
253
- const state = await graph.getState(config)
254
- const messages = state?.values?.messages ?? []
255
- for (let i = messages.length - 1; i >= 0; i--) {
256
- const m = messages[i]
257
- if (m?.tool_calls?.length) {
258
- return m.tool_calls.map((tc) => ({ id: tc.id, name: tc.name, args: tc.args }))
259
- }
260
- }
261
- return []
262
- } catch {
263
- return []
264
- }
265
- }
266
-
267
115
  /**
268
116
  * GraphExecutor wraps a compiled LangGraph graph as an A2A AgentExecutor.
269
117
  *
@@ -716,50 +564,16 @@ class GraphExecutor {
716
564
  const t0 = Date.now()
717
565
 
718
566
  if (isResume) {
719
- const dataPart = extractData(requestContext)
720
- const userText = extractText(requestContext)
721
- // Relaxed guard: accept a DataPart-only resume OR non-empty text.
722
- if (dataPart === undefined && !userText.trim()) {
723
- throw new Error(cds.i18n.messages.at("RESUME_REQUIRES_TEXT"))
724
- }
725
- const { Command } = await import("@langchain/langgraph")
726
- // DataPart wins over text — a structured resume is self-describing and any
727
- // accompanying text is treated as incidental (e.g. a human-readable echo).
728
- const resume = dataPart !== undefined ? dataPart : parseResumeDecision(userText)
729
- const decision = decisionTypeOf(resume)
730
-
731
- LOG.debug("resuming", {
732
- conversation: short(contextId),
733
- service: serviceName,
734
- decision,
735
- })
736
-
737
- // Audit: task resumed with HITL decision
738
- audit("AgentTaskResumed", {
739
- data: {
740
- taskId,
741
- contextId,
742
- service: serviceName,
743
- decision,
744
- },
745
- })
746
- // On edit, stash a diff note in state; the injector middleware prepends it next turn.
747
- const commandArgs = { resume }
748
- if (decision === "edit") {
749
- const originals = await getPreInterruptToolCalls(graph, config)
750
- const editNote = composeEditNote(originals, resume)
751
- if (editNote) commandArgs.update = { _hitlEditNote: editNote }
752
- }
753
- const resumed = await this._streamWithPublish(
567
+ result = await resumeHitl({
568
+ requestContext,
754
569
  graph,
755
- new Command(commandArgs),
756
570
  config,
757
571
  eventBus,
758
- taskId,
759
- contextId,
760
- controller.signal,
761
- )
762
- result = resumed.state
572
+ signal: controller.signal,
573
+ stream: (input, signal) =>
574
+ this._streamWithPublish(graph, input, config, eventBus, taskId, contextId, signal),
575
+ })
576
+ if (!result) return
763
577
  } else {
764
578
  const inputMapper = this._inputMapper || defaultInputMapper
765
579
  const rawInput = await inputMapper(requestContext)
@@ -780,56 +594,28 @@ class GraphExecutor {
780
594
  // (interrupt-only results may have no `messages` — treat as empty)
781
595
  usageData = aggregateUsageData(result.messages || [])
782
596
 
783
- if (result?.__interrupt__?.length > 0) {
784
- const description = extractInterruptDescription(result)
785
- const interruptData = extractInterruptData(result)
786
-
787
- const duration = ((Date.now() - t0) / 1000).toFixed(1) + "s"
788
- LOG.info("input-required", {
789
- conversation: short(contextId),
790
- service: serviceName,
597
+ const duration = ((Date.now() - t0) / 1000).toFixed(1) + "s"
598
+ if (requiresHitl(result)) {
599
+ handleHitlInterrupt({
600
+ result,
601
+ requestContext,
602
+ eventBus,
603
+ serviceName,
791
604
  duration,
792
- })
793
-
794
- if (wfSpan) {
795
- wfSpan.setAttribute("agent.outcome", "input-required")
796
- const outputs = {
797
- choices: [{ message: { role: "assistant", content: description } }],
798
- }
799
- setSpanAttrs(wfSpan, mlflowAttrs("AGENT", { outputs }))
800
- const rootSpan = cds.context?.["_mlflow.rootSpan"]
801
- if (rootSpan) {
802
- setSpanAttrs(rootSpan, mlflowAttrs("CHAIN", { outputs }))
803
- }
804
- }
805
-
806
- // Audit: agent requires human input
807
- audit("AgentInputRequired", {
808
- data: {
809
- taskId,
810
- contextId,
811
- service: serviceName,
812
- description,
813
- interruptData,
814
- },
815
- })
816
-
817
- eventBus.publish({
818
- kind: "status-update",
819
- taskId,
820
- contextId,
821
- status: {
822
- state: "input-required",
823
- message: agentMessage(description, interruptData),
824
- timestamp: new Date().toISOString(),
605
+ onInputRequired: (description) => {
606
+ if (!wfSpan) return
607
+ wfSpan.setAttribute("agent.outcome", "input-required")
608
+ const outputs = {
609
+ choices: [{ message: { role: "assistant", content: description } }],
610
+ }
611
+ setSpanAttrs(wfSpan, mlflowAttrs("AGENT", { outputs }))
612
+ const rootSpan = cds.context?.["_mlflow.rootSpan"]
613
+ if (rootSpan) setSpanAttrs(rootSpan, mlflowAttrs("CHAIN", { outputs }))
825
614
  },
826
- final: true,
827
615
  })
828
- eventBus.finished()
829
616
  return
830
617
  }
831
618
 
832
- const duration = ((Date.now() - t0) / 1000).toFixed(1) + "s"
833
619
  const outputMapper = this._outputMapper || defaultOutputMapper
834
620
  const output = outputMapper(result) || "I could not generate a response."
835
621
 
@@ -892,6 +678,9 @@ class GraphExecutor {
892
678
  // 1. emit_file_part tool calls (default graph) — JSON in toolResults/messages
893
679
  // 2. write_file '/outputs/*' via OutputsBackend (deep agent) — CDS rows
894
680
  const fileArtifacts = []
681
+ // DataParts embedded in tool-result content.
682
+ // Published as their own `data-*` artifact-update events below.
683
+ const dataArtifacts = []
895
684
  const maxFileBytes = cds.env.agents.fileIO.maxOutputFileSizeBytes
896
685
 
897
686
  // Artifacts from emit_file_part are this agent's own outputs — they must be
@@ -915,7 +704,10 @@ class GraphExecutor {
915
704
  const content = typeof msg.content === "string" ? msg.content : ""
916
705
  let pos = 0
917
706
  while (pos < content.length) {
918
- const start = content.indexOf('{"kind":"file"', pos)
707
+ // Find the earliest next FilePart or DataPart marker.
708
+ const fileAt = content.indexOf('{"kind":"file"', pos)
709
+ const dataAt = content.indexOf('{"kind":"data"', pos)
710
+ const start = fileAt === -1 ? dataAt : dataAt === -1 ? fileAt : Math.min(fileAt, dataAt)
919
711
  if (start === -1) break
920
712
  // Walk forward tracking depth and quoted strings so that '}' inside
921
713
  // a string value (e.g. a filename like "result_{final}.csv") does not
@@ -944,6 +736,11 @@ class GraphExecutor {
944
736
  const raw = content.slice(start, i + 1)
945
737
  try {
946
738
  const artifact = JSON.parse(raw)
739
+ if (artifact.kind === "data") {
740
+ dataArtifacts.push(artifact)
741
+ pos = i + 1
742
+ continue
743
+ }
947
744
  // Apply the same per-file size cap as Source 2. Decode-length is
948
745
  // computed by Buffer.byteLength (zero allocation — pure formula
949
746
  // over string length + padding) so an oversized blob never pins
@@ -1064,6 +861,26 @@ class GraphExecutor {
1064
861
  })
1065
862
  }
1066
863
 
864
+ dataArtifacts.forEach((artifact, i) => {
865
+ if (artifact.data == null || typeof artifact.data !== "object") {
866
+ LOG.warn("skipping malformed data artifact", {
867
+ conversation: short(contextId),
868
+ service: serviceName,
869
+ })
870
+ return
871
+ }
872
+ LOG.info("data emitted", { conversation: short(contextId), service: serviceName })
873
+ eventBus.publish({
874
+ kind: "artifact-update",
875
+ taskId,
876
+ contextId,
877
+ artifact: {
878
+ artifactId: `data-${i}`,
879
+ parts: [{ kind: "data", data: artifact.data }],
880
+ },
881
+ })
882
+ })
883
+
1067
884
  // Programmatic .chat() path: stash graph result on eventBus so chat.js
1068
885
  // can read messages without an extra checkpoint roundtrip.
1069
886
  if (eventBus[COLLECT_RESULT]) {
@@ -1326,16 +1143,7 @@ class GraphExecutor {
1326
1143
  }
1327
1144
  }
1328
1145
 
1329
- export {
1330
- GraphExecutor,
1331
- messageText,
1332
- defaultOutputMapper,
1333
- agentMessage,
1334
- parseResumeDecision,
1335
- decisionTypeOf,
1336
- extractInterruptData,
1337
- composeEditNote,
1338
- }
1146
+ export { GraphExecutor, messageText, defaultOutputMapper, agentMessage }
1339
1147
 
1340
1148
  /**
1341
1149
  * @param {[import('@langchain/core/messages').Message]} messages
@@ -92,7 +92,8 @@ export default function registerDefaultAgentHandlers(srv) {
92
92
  // Default buildModel: cds.connect.to('llm'), configurable via @agent.llm
93
93
  srv.on("buildModel", async (req) => {
94
94
  const name = srv?.options?.agent?.llm || srv?.definition?.["@agent.llm"] || "llm"
95
- let { kind, impl, ...options } = cds.requires[name] ?? {}
95
+ const options = cds.requires[name] ?? {}
96
+ let { kind, impl } = options
96
97
  if (!impl) impl = cds.requires.kinds[kind]?.impl
97
98
  if (!impl) throw new Error("No service implementation found for " + name)
98
99
  const { default: LLMProvider } = await import(impl)
@@ -4,8 +4,9 @@ import { z } from "zod"
4
4
  import { LangGraphExecutor } from "../langgraph-executor-srv.js"
5
5
  import { short, toolName } from "../../lib/utils/utils.js"
6
6
 
7
- const LOG = cds.log("agents:sub-agents")
7
+ const LOG = cds.log("agents:sub-agents|agents|sub-agents")
8
8
 
9
+ import { inspect } from "util"
9
10
  /**
10
11
  * Extract text and file parts from an A2A response (task or message).
11
12
  */
@@ -15,7 +16,7 @@ function extractResult(result) {
15
16
  const text = []
16
17
  const files = []
17
18
 
18
- const processParts = (parts = []) => {
19
+ const processParts = (parts) => {
19
20
  for (const part of parts) {
20
21
  if (part.kind === "text") text.push(part.text)
21
22
  else if (part.kind === "file") files.push(part)
@@ -23,8 +24,11 @@ function extractResult(result) {
23
24
  }
24
25
 
25
26
  if (result.kind === "task") {
26
- processParts(result.status?.message?.parts)
27
- for (const artifact of result.artifacts || []) processParts(artifact.parts)
27
+ if (result.artifacts)
28
+ for (let artifact of result.artifacts) {
29
+ if (!artifact.artifactId.startsWith("thinking")) processParts(artifact.parts)
30
+ }
31
+ else if (result.status?.message) processParts(result.status.message.parts)
28
32
  if (text.length === 0 && files.length === 0) {
29
33
  return { text: `Task ${result.id}: ${result.status?.state || "unknown"}`, files: [] }
30
34
  }
@@ -71,21 +75,29 @@ function formatToolResult({ text, files }) {
71
75
  * Wrap an A2A client as a LangChain tool the agent can call.
72
76
  */
73
77
  function createA2ATool(client, agentCard) {
78
+ const subagent = agentCard.name
74
79
  return tool(
75
80
  async ({ message }) => {
76
81
  try {
82
+ const messageId = cds.utils.uuid()
83
+ LOG.info(`Sending message to ${subagent}`, { messageId }, "\n\n" + message + "\n")
77
84
  const result = await client.sendMessage({
78
85
  message: {
79
86
  kind: "message",
80
87
  role: "user",
81
- messageId: cds.utils.uuid(),
88
+ messageId,
82
89
  parts: [{ kind: "text", text: message }],
83
90
  },
84
91
  })
85
- return formatToolResult(extractResult(result))
92
+ if (LOG._debug)
93
+ LOG.trace(`Raw results from ${subagent}`, inspect(result, { depth: null, colors: true }))
94
+ let response = formatToolResult(extractResult(result))
95
+ if (response)
96
+ LOG.info(`Got response from ${subagent}`, { messageId }, "\n\n" + response + "\n")
97
+ return response
86
98
  } catch (err) {
87
- LOG.warn("Sub-agent tool error", { agent: agentCard.name, error: err.message })
88
- return `Error communicating with ${agentCard.name}: ${err.message}`
99
+ LOG.warn("Sub agent tool error", { subagent, error: err.message })
100
+ return `Error communicating with ${subagent}: ${err.message}`
89
101
  }
90
102
  },
91
103
  {
@@ -106,7 +118,7 @@ export async function buildSubAgentToolLocally(serviceName) {
106
118
 
107
119
  const { generateAgentCard } = await import("../../lib/protocol/agent-card.js")
108
120
  const agentCard = generateAgentCard(srv)
109
- LOG.info(`Wired local sub-agent "${agentCard.name}" (${serviceName})`)
121
+ LOG.info(`Connecting to sub agent ${serviceName}`, "(local)")
110
122
 
111
123
  const { RequestContext, DefaultExecutionEventBus } = await import("@a2a-js/sdk/server")
112
124
 
@@ -280,7 +292,7 @@ export async function buildSubAgentToolFromConnection(serviceName) {
280
292
 
281
293
  const path = typeof credentials === "object" ? credentials?.path : null
282
294
  const base = agentBaseUrl.replace(/\/$/, "") + (path ? `/${path.replace(/^\//, "")}` : "")
283
- LOG.info(`Connecting to sub-agent at ${base}`)
295
+ LOG.info(`Connecting to sub agent ${serviceName}`, { at: base })
284
296
 
285
297
  // revisit: a2a agents may be tenant specific, card per tenant?
286
298
  const initialHeaders = await resolveHeaders()
@@ -294,7 +306,6 @@ export async function buildSubAgentToolFromConnection(serviceName) {
294
306
  )
295
307
  }
296
308
  const agentCard = await cardRes.json()
297
- LOG.info(`Connected to sub-agent "${agentCard.name}" (${serviceName})`)
298
309
 
299
310
  const { ClientFactory, ClientFactoryOptions, JsonRpcTransportFactory, RestTransportFactory } =
300
311
  await import("@a2a-js/sdk/client")
@@ -222,6 +222,10 @@ export function generateTools(srv) {
222
222
  tools.push(createEmitFilePartTool())
223
223
  }
224
224
 
225
+ if (cds.env.agents?.emitDataParts) {
226
+ tools.push(createEmitDataPartTool())
227
+ }
228
+
225
229
  return tools
226
230
  }
227
231
 
@@ -368,3 +372,29 @@ export function createReadFileTool(fileStore, contextId, userId) {
368
372
  },
369
373
  )
370
374
  }
375
+
376
+ /**
377
+ * Create a tool that emits a DataPart in the A2A response.
378
+ * The executor's toolResults collection detects `kind: "data"`
379
+ * and emits the tool result as a data part.
380
+ */
381
+ export function createEmitDataPartTool() {
382
+ return tool(
383
+ async ({ data, mediaType }) => {
384
+ return {
385
+ kind: "data",
386
+ data,
387
+ mediaType: mediaType ?? "application/json",
388
+ }
389
+ },
390
+ {
391
+ name: "emit_data_part",
392
+ description: "Emit a structured A2A DataPart. Only use when instructed.",
393
+ schema: z.object({
394
+ // A2A DataPart is specified to be an object in A2A 0.3
395
+ // https://a2a-protocol.org/v0.3.0/specification/#653-datapart-object
396
+ data: z.looseObject().describe("Structured object"),
397
+ }),
398
+ },
399
+ )
400
+ }
@@ -1,18 +0,0 @@
1
- import { createMiddleware } from "langchain"
2
- import { HumanMessage } from "@langchain/core/messages"
3
- import { z } from "zod"
4
-
5
- // Injects a HumanMessage for a pending _hitlEditNote before the next model turn.
6
- export function hitlEditNoteInjectorMiddleware() {
7
- return createMiddleware({
8
- name: "hitlEditNoteInjectorMiddleware",
9
- stateSchema: z.object({ _hitlEditNote: z.string().optional() }),
10
- beforeModel: async (state) => {
11
- if (!state._hitlEditNote) return
12
- return {
13
- messages: [new HumanMessage(state._hitlEditNote)],
14
- _hitlEditNote: undefined,
15
- }
16
- },
17
- })
18
- }