@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.
- package/lib/agents/middleware/hitl-decision-note-injector.js +18 -0
- package/lib/agents/middleware/hitl.js +2 -2
- package/lib/agents/middleware/quota-enforcer.js +9 -0
- package/lib/models/anthropic.js +27 -12
- package/lib/preview/chat.html +17 -13
- package/lib/utils/message-handling.js +14 -0
- package/package.json +4 -1
- package/srv/handlers/chat.js +4 -4
- package/srv/handlers/graph-executor/hitl.js +263 -0
- package/srv/handlers/graph-executor.js +58 -250
- package/srv/handlers/index.js +2 -1
- package/srv/handlers/sub-agent-tools.js +22 -11
- package/srv/handlers/tools.js +30 -0
- package/lib/agents/middleware/hitl-edit-note-injector.js +0 -18
|
@@ -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 {
|
|
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 }),
|
|
19
|
+
return [hitl({ interruptOn }), hitlDecisionNoteInjectorMiddleware()]
|
|
20
20
|
}
|
package/lib/models/anthropic.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
56
|
+
let settings = JSON.parse (fs.readFileSync (settings_json,'utf8'))
|
|
47
57
|
// https://www.schemastore.org/claude-code-settings.json
|
|
48
|
-
|
|
49
|
-
|
|
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
|
package/lib/preview/chat.html
CHANGED
|
@@ -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
|
|
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(
|
|
646
|
+
function renderThinkingStep(update) {
|
|
647
|
+
const { artifactId, text, lastChunk, append } = update
|
|
646
648
|
const panel = ensureThinkingSummary()
|
|
647
|
-
const
|
|
648
|
-
if (
|
|
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.
|
|
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)
|
|
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
|
+
"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": [
|
package/srv/handlers/chat.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
720
|
-
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
)
|
|
762
|
-
result
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
|
|
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
|
package/srv/handlers/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
|
88
|
+
messageId,
|
|
82
89
|
parts: [{ kind: "text", text: message }],
|
|
83
90
|
},
|
|
84
91
|
})
|
|
85
|
-
|
|
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
|
|
88
|
-
return `Error communicating with ${
|
|
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(`
|
|
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
|
|
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")
|
package/srv/handlers/tools.js
CHANGED
|
@@ -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
|
-
}
|