@cap-js/agents 0.9.7 → 0.9.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_i18n/messages.properties +2 -0
- package/cds-plugin.js +13 -10
- package/lib/agents/middleware/content-filter.js +4 -0
- package/lib/agents/middleware/hitl-decision-note-injector.js +18 -6
- package/lib/agents/middleware/hitl.js +17 -6
- package/lib/agents/middleware/index.js +1 -1
- package/lib/agents/middleware/masking.js +6 -3
- package/lib/agents/middleware/remote-mcp.js +5 -2
- package/lib/agents/middleware/status-update.js +209 -76
- package/lib/agents/middleware/tool-wrap.js +1 -2
- package/lib/agents/summarize-on-timeout.js +5 -2
- package/lib/config/local.js +121 -8
- package/lib/eval/eval-run.js +50 -4
- package/lib/masking/unstructured/hana.js +13 -5
- package/lib/models/aicore.js +16 -3
- package/lib/models/openai.js +9 -0
- package/lib/preview/chat.html +561 -93
- package/lib/telemetry/chat-tracing.js +2 -0
- package/lib/telemetry/mlflow/evaluation.js +83 -4
- package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +39 -5
- package/lib/telemetry/mlflow/exporter/MlflowExporter.js +43 -4
- package/lib/telemetry/mlflow/index.js +1 -7
- package/lib/telemetry/mlflow/prompts.js +15 -11
- package/lib/utils/markdown.js +1 -8
- package/lib/utils/resilience.js +2 -1
- package/lib/utils/toml.js +67 -0
- package/lib/utils/usage.js +34 -0
- package/lib/utils/utils.js +43 -1
- package/package.json +31 -26
- package/srv/handlers/graph-executor/crash-handler.js +47 -0
- package/srv/handlers/graph-executor/hitl.js +27 -0
- package/srv/handlers/graph-executor.js +19 -5
- package/srv/handlers/index.js +6 -1
- package/srv/handlers/mcp-tools.js +11 -2
- package/srv/handlers/subagent-tools.js +13 -4
- package/srv/handlers/system-prompt.js +30 -27
- package/srv/handlers/tools.js +14 -13
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
agent_status_calling_tools = Calling {0}
|
|
4
4
|
#XMSG Status info message send to the user client when an entity is being queried. {0} = entity name
|
|
5
5
|
agent_status_querying = Querying {0}
|
|
6
|
+
#XMSG Status info message send to the user client when a service model is being inspected. {0} = service/entity name
|
|
7
|
+
agent_status_describing = Inspecting {0}
|
|
6
8
|
#XMSG Status info message send to the user client after a tool result returned and the agent is processing it
|
|
7
9
|
agent_status_processing_response = Processing tool response
|
|
8
10
|
#XMSG Status info message send to the user client after multiple tool results returned and the agent is processing it
|
package/cds-plugin.js
CHANGED
|
@@ -69,24 +69,27 @@ cds.on("bootstrap", (app) => {
|
|
|
69
69
|
|
|
70
70
|
!(function cds_requires_llm() {
|
|
71
71
|
cds.on("served", async () => {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
cds.requires.llm?.kind === "anthropic" ||
|
|
75
|
-
cds.requires.llm === "auto" ||
|
|
76
|
-
cds.requires.llm?.kind === "auto"
|
|
77
|
-
) {
|
|
72
|
+
const kind = cds.requires.llm?.kind ?? cds.requires.llm
|
|
73
|
+
if (kind === "auto") {
|
|
78
74
|
const { resolve_config } = await import("./lib/config/local.js")
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
75
|
+
cds.env.requires.llm = resolve_config({ kind: "auto" })
|
|
76
|
+
}
|
|
77
|
+
if (kind === "anthropic") {
|
|
78
|
+
const { resolve_anthropic_config } = await import("./lib/config/local.js")
|
|
79
|
+
cds.env.requires.llm = resolve_anthropic_config(cds.requires.llm)
|
|
80
|
+
}
|
|
81
|
+
if (kind === "openai") {
|
|
82
|
+
const { resolve_openai_config } = await import("./lib/config/local.js")
|
|
83
|
+
cds.env.requires.llm = resolve_openai_config(cds.requires.llm)
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
const config = cds.requires.llm,
|
|
85
87
|
credentials = {}
|
|
86
|
-
const { url, destination, anthropicApiUrl, apiKey } = config?.credentials || {}
|
|
88
|
+
const { url, destination, anthropicApiUrl, baseURL, apiKey } = config?.credentials || {}
|
|
87
89
|
if (url) credentials.url = url
|
|
88
90
|
if (destination) credentials.destination = destination
|
|
89
91
|
if (anthropicApiUrl) credentials.anthropicApiUrl = anthropicApiUrl
|
|
92
|
+
if (baseURL) credentials.baseURL = baseURL
|
|
90
93
|
if (apiKey) credentials.apiKey = "***"
|
|
91
94
|
LOG.info(`cds.connect.to 'llm' with:`, { ...config, credentials })
|
|
92
95
|
})
|
|
@@ -47,6 +47,7 @@ export async function contentFilterMiddleware(model) {
|
|
|
47
47
|
},
|
|
48
48
|
{
|
|
49
49
|
disableStreaming: true,
|
|
50
|
+
maxRetries: 0,
|
|
50
51
|
customRequestConfig: {
|
|
51
52
|
middleware: [timeout(10_000), retry(2)],
|
|
52
53
|
},
|
|
@@ -112,6 +113,9 @@ export async function contentFilterMiddleware(model) {
|
|
|
112
113
|
try {
|
|
113
114
|
await inputFilterModel.invoke(filterMessages)
|
|
114
115
|
} catch (err) {
|
|
116
|
+
if (err.rootCause?.message.match(/Could not find service credentials for AI Core/)) {
|
|
117
|
+
throw err.rootCause
|
|
118
|
+
}
|
|
115
119
|
const { status, data } = extractErrorDetails(err)
|
|
116
120
|
const isFilterBlock =
|
|
117
121
|
/Filtering Module/i.test(data?.error?.location || "") && status === 400
|
|
@@ -2,17 +2,29 @@ import { createMiddleware } from "langchain"
|
|
|
2
2
|
import { HumanMessage } from "@langchain/core/messages"
|
|
3
3
|
import { z } from "zod"
|
|
4
4
|
|
|
5
|
-
// Injects user HITL decisions
|
|
5
|
+
// Injects user HITL decisions (edit/reject) as a HumanMessage before the model
|
|
6
|
+
// continues, so the model treats them as deliberate user decisions rather than
|
|
7
|
+
// tool failures.
|
|
8
|
+
//
|
|
9
|
+
// LangChain's humanInTheLoop middleware routes these decisions differently:
|
|
10
|
+
// - edit → jumpTo undefined → normal flow → `beforeModel` runs. Inject + clear here.
|
|
11
|
+
// - reject → jumpTo "model" → re-enters the model node directly, SKIPPING all
|
|
12
|
+
// `beforeModel` hooks. `wrapModelCall` still runs, so it injects as a
|
|
13
|
+
// fallback when `beforeModel` did not consume the note.
|
|
14
|
+
//
|
|
15
|
+
// So we use `wrapModel` to inject the message and clear the note after the model call.
|
|
6
16
|
export function hitlDecisionNoteInjectorMiddleware() {
|
|
7
17
|
return createMiddleware({
|
|
8
18
|
name: "hitlDecisionNoteInjectorMiddleware",
|
|
9
19
|
stateSchema: z.object({ _hitlDecisionNote: z.string().optional() }),
|
|
10
|
-
|
|
20
|
+
wrapModelCall: async (request, handler) => {
|
|
21
|
+
const note = request.state?._hitlDecisionNote
|
|
22
|
+
if (!note) return handler(request)
|
|
23
|
+
return handler({ ...request, messages: [...(request.messages || []), new HumanMessage(note)] })
|
|
24
|
+
},
|
|
25
|
+
afterModel: async (state) => {
|
|
11
26
|
if (!state._hitlDecisionNote) return
|
|
12
|
-
return {
|
|
13
|
-
messages: [new HumanMessage(state._hitlDecisionNote)],
|
|
14
|
-
_hitlDecisionNote: undefined,
|
|
15
|
-
}
|
|
27
|
+
return { _hitlDecisionNote: undefined }
|
|
16
28
|
},
|
|
17
29
|
})
|
|
18
30
|
}
|
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
import { humanInTheLoopMiddleware as hitl } from "langchain"
|
|
2
2
|
import { hitlDecisionNoteInjectorMiddleware } from "./hitl-decision-note-injector.js"
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
const ALLOWED_DECISIONS = ["approve", "reject", "edit"]
|
|
5
|
+
|
|
6
|
+
const isHitlAction = (action) => action?.["@agent.hitl"] ?? action?.["@Common.IsActionCritical"]
|
|
7
|
+
|
|
8
|
+
export function buildHitlInterruptMap(srv, tools = []) {
|
|
5
9
|
return tools.reduce((interruptOn, tool) => {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
// Per-action tool: its name IS the action name, so a static entry gates it.
|
|
11
|
+
if (isHitlAction(srv.actions[tool.name])) {
|
|
12
|
+
interruptOn[tool.name] = { allowedDecisions: ALLOWED_DECISIONS }
|
|
13
|
+
}
|
|
14
|
+
// Generic @cap-js/mcp "call" tool: one tool fronts every action, with the
|
|
15
|
+
// target action in args.action. The tool name matches no action, so gate
|
|
16
|
+
// per-call via `when`, which inspects the requested action at invoke time.
|
|
17
|
+
else if (tool.name === "call" && Object.values(srv.actions).some(isHitlAction)) {
|
|
18
|
+
interruptOn[tool.name] = {
|
|
19
|
+
allowedDecisions: ALLOWED_DECISIONS,
|
|
20
|
+
when: (request) => Boolean(isHitlAction(srv.actions[request.toolCall?.args?.action])),
|
|
21
|
+
}
|
|
11
22
|
}
|
|
12
23
|
return interruptOn
|
|
13
24
|
}, {})
|
|
@@ -24,7 +24,7 @@ export default async function buildMiddleware(srv, options = {}) {
|
|
|
24
24
|
await contentFilterMiddleware(model),
|
|
25
25
|
await agentActionsMiddleware(),
|
|
26
26
|
patchToolCallsMiddleware(),
|
|
27
|
-
await statusUpdateMiddleware(),
|
|
27
|
+
await statusUpdateMiddleware(tools),
|
|
28
28
|
...(await humanInTheLoopMiddleware(srv, tools)),
|
|
29
29
|
toolSelectionMiddleware(),
|
|
30
30
|
toolWrapMiddleware(srv),
|
|
@@ -54,7 +54,7 @@ export function masking(srv) {
|
|
|
54
54
|
|
|
55
55
|
// Decode TOON or JSON — pseudonymize operates on decoded objects
|
|
56
56
|
const { decoded, format } = decodeContent(toolMessage.content)
|
|
57
|
-
if (decoded) {
|
|
57
|
+
if (decoded && typeof decoded !== "string") {
|
|
58
58
|
const result = await pseudonymize(
|
|
59
59
|
{
|
|
60
60
|
data: decoded,
|
|
@@ -81,8 +81,11 @@ export function masking(srv) {
|
|
|
81
81
|
outMessage = new ToolMessage({ ...toolMessage, content: encoded })
|
|
82
82
|
}
|
|
83
83
|
} else {
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
// Plain string (e.g. read_file output) or unparseable — scrub using session mappings.
|
|
85
|
+
const scrubbed = session.scrubText(toolMessage.content)
|
|
86
|
+
if (scrubbed !== toolMessage.content) {
|
|
87
|
+
outMessage = new ToolMessage({ ...toolMessage, content: scrubbed })
|
|
88
|
+
}
|
|
86
89
|
}
|
|
87
90
|
}
|
|
88
91
|
|
|
@@ -2,7 +2,7 @@ import cds from "@sap/cds"
|
|
|
2
2
|
import { createMiddleware } from "langchain"
|
|
3
3
|
import { ToolMessage } from "@langchain/core/messages"
|
|
4
4
|
import { MultiServerMCPClient } from "@langchain/mcp-adapters"
|
|
5
|
-
import { toolName } from "../../utils/utils.js"
|
|
5
|
+
import { toolName, mcpToolKind } from "../../utils/utils.js"
|
|
6
6
|
|
|
7
7
|
const LOG = cds.log("agents:mcp")
|
|
8
8
|
|
|
@@ -27,7 +27,10 @@ export function remoteMcpMiddleware() {
|
|
|
27
27
|
})
|
|
28
28
|
let raw = await client.getTools()
|
|
29
29
|
if (toolFilter?.length) raw = raw.filter((t) => toolFilter.includes(t.name))
|
|
30
|
-
for (const t of raw)
|
|
30
|
+
for (const t of raw) {
|
|
31
|
+
t.metadata = { ...t.metadata, serviceName, kind: mcpToolKind(t.name) }
|
|
32
|
+
t.name = toolName(`${serviceName}_${t.name}`)
|
|
33
|
+
}
|
|
31
34
|
cache[mcpUrl] = { serviceName, tools: raw }
|
|
32
35
|
LOG.debug(
|
|
33
36
|
`Got ${raw.length} MCP tools from ${mcpUrl}: ${raw.map((t) => t.name).join(", ")}`,
|
|
@@ -1,63 +1,103 @@
|
|
|
1
1
|
import cds from "@sap/cds"
|
|
2
2
|
import { createMiddleware } from "langchain"
|
|
3
|
-
import { ToolMessage } from "@langchain/core/messages"
|
|
3
|
+
import { AIMessage, ToolMessage } from "@langchain/core/messages"
|
|
4
|
+
import { resolvePseudonyms } from "../../masking/index.js"
|
|
5
|
+
import { serviceLabel, declaredServiceLabel, mcpToolKind } from "../../utils/utils.js"
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
* - actions → resolves label from action definition
|
|
10
|
-
* - fallback → tool name as-is
|
|
8
|
+
* Resolve the metadata for a tool call, or undefined when the tool is unknown.
|
|
9
|
+
* Static tools come from the precomputed `toolMeta` map; remote MCP tools are
|
|
10
|
+
* resolved per-request from cds.context.__mcpDynamicTools.
|
|
11
11
|
*/
|
|
12
|
-
function
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
const model = cds.context?.model ?? cds.model
|
|
12
|
+
function resolveMeta(tc, toolMeta) {
|
|
13
|
+
const meta = toolMeta.get(tc.name)
|
|
14
|
+
if (meta) return meta
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (!entityName && tc.args?.cql) {
|
|
23
|
-
try {
|
|
24
|
-
const cqn = cds.parse.cql(tc.args.cql)
|
|
25
|
-
const ref0 = cqn.SELECT?.from?.ref?.[0]
|
|
26
|
-
const targetName = ref0?.id ?? ref0
|
|
27
|
-
if (targetName && serviceName && !targetName.startsWith(serviceName + ".")) {
|
|
28
|
-
entityName = `${serviceName}.${targetName}`
|
|
29
|
-
} else {
|
|
30
|
-
entityName = targetName
|
|
31
|
-
}
|
|
32
|
-
} catch {
|
|
33
|
-
/* fallback to tc.name below */
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
if (entityName) {
|
|
37
|
-
const entityDef = model.definitions?.[entityName]
|
|
38
|
-
if (entityDef) {
|
|
39
|
-
const label = cds.i18n?.labels?.at(entityDef)
|
|
40
|
-
if (label) return label
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
// Fallback: strip service prefix if present
|
|
44
|
-
if (entityName && srv?.name && entityName.startsWith(srv.name + ".")) {
|
|
45
|
-
return entityName.slice(srv.name.length + 1)
|
|
16
|
+
const cache = cds.context?.__mcpDynamicTools
|
|
17
|
+
if (cache) {
|
|
18
|
+
for (const entry of Object.values(cache)) {
|
|
19
|
+
const t = entry.tools?.find((t) => t.name === tc.name)
|
|
20
|
+
if (t) return t.metadata
|
|
46
21
|
}
|
|
47
|
-
return entityName || tc.name
|
|
48
22
|
}
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the kind ("query" | "describe" | "action" | "agent") for a tool call.
|
|
28
|
+
* Prefer the build-time `meta.kind` (classified from the unprefixed name, so it
|
|
29
|
+
* survives prefixing/truncation); else sniff `tc.name` for own-service tools.
|
|
30
|
+
*/
|
|
31
|
+
function resolveToolKind(tc, meta) {
|
|
32
|
+
if (meta?.kind) return meta.kind
|
|
33
|
+
return mcpToolKind(tc.name)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Resolve an entity's display label (declared i18n label, else its simple name). */
|
|
37
|
+
function entityLabel(name, serviceName, model) {
|
|
38
|
+
const fq = serviceName && !name.startsWith(serviceName + ".") ? `${serviceName}.${name}` : name
|
|
39
|
+
const def = model?.definitions?.[fq]
|
|
40
|
+
return (
|
|
41
|
+
(def && cds.i18n?.labels?.at(def)) ||
|
|
42
|
+
(fq.includes(".") ? fq.slice(fq.lastIndexOf(".") + 1) : fq)
|
|
43
|
+
)
|
|
44
|
+
}
|
|
49
45
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
46
|
+
/** Resolve the query target: the `entity` arg, or the FROM target parsed from `cql`. */
|
|
47
|
+
function queryTarget(tc) {
|
|
48
|
+
if (tc.args?.entity) return tc.args.entity
|
|
49
|
+
if (tc.args?.cql) {
|
|
50
|
+
try {
|
|
51
|
+
const ref0 = cds.parse.cql(tc.args.cql).SELECT?.from?.ref?.[0]
|
|
52
|
+
return ref0?.id ?? ref0 ?? undefined
|
|
53
|
+
} catch {
|
|
54
|
+
/* no target derivable */
|
|
57
55
|
}
|
|
58
56
|
}
|
|
57
|
+
return undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve a human-readable label for a tool call:
|
|
62
|
+
* - agent → declared service label, else the subagent's agent-card name
|
|
63
|
+
* - describe → service label (declared, else the FQ service name)
|
|
64
|
+
* - query → entity label, prefixed with the service only when it has a declared label
|
|
65
|
+
* - action → action label, prefixed with the service only when it has a declared label
|
|
66
|
+
*/
|
|
67
|
+
function resolveToolLabel(tc, meta, kind = resolveToolKind(tc, meta)) {
|
|
68
|
+
const model = cds.context?.model ?? cds.model
|
|
69
|
+
const serviceName = meta?.serviceName ?? cds.context?.["agent.service"] // REVISIT: why are we using cds.context here?
|
|
70
|
+
|
|
71
|
+
if (kind === "agent") {
|
|
72
|
+
return declaredServiceLabel(meta?.serviceName) || meta?.agentName || tc.name
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (kind === "describe") {
|
|
76
|
+
let parts = []
|
|
77
|
+
if (tc.args?.entities) parts.push(...tc.args.entities.map(e => entityLabel(e, serviceName, model)))
|
|
78
|
+
if (tc.args?.actions) parts.push(...tc.args.actions.map(a => entityLabel(a, serviceName, model)))
|
|
79
|
+
if (!parts.length) parts.push(serviceLabel(serviceName) || tc.name)
|
|
80
|
+
return parts.join(", ")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const svcLabel = declaredServiceLabel(serviceName)
|
|
84
|
+
const withService = (detail) => (svcLabel ? `${svcLabel} · ${detail}` : detail)
|
|
85
|
+
|
|
86
|
+
if (kind === "query") {
|
|
87
|
+
const target = queryTarget(tc)
|
|
88
|
+
if (target) return withService(entityLabel(target, serviceName, model))
|
|
89
|
+
return svcLabel || tc.name
|
|
90
|
+
}
|
|
59
91
|
|
|
60
|
-
|
|
92
|
+
// meta.actionName is the un-prefixed name for MCP action tools (tc.name is
|
|
93
|
+
// "{service}_{action}"); own-service tools already carry the bare name.
|
|
94
|
+
const actionName = meta?.actionName ?? tc.args?.action ?? tc.name
|
|
95
|
+
if (serviceName) {
|
|
96
|
+
const actionDef = model?.definitions?.[`${serviceName}.${actionName}`]
|
|
97
|
+
const declared = actionDef && cds.i18n?.labels?.at(actionDef) || actionName
|
|
98
|
+
if (declared) return withService(declared)
|
|
99
|
+
}
|
|
100
|
+
return withService(actionName)
|
|
61
101
|
}
|
|
62
102
|
|
|
63
103
|
/**
|
|
@@ -86,29 +126,106 @@ export function publishStatus(text) {
|
|
|
86
126
|
}
|
|
87
127
|
|
|
88
128
|
/**
|
|
89
|
-
*
|
|
129
|
+
* Reads tool-call visibility config from the current request's metadata.
|
|
130
|
+
* Controlled entirely by the client: presence of userMessage.metadata["tool-status-update"] enables it.
|
|
131
|
+
*/
|
|
132
|
+
function toolCallConfig() {
|
|
133
|
+
const requestMeta = cds.context?.["agent.request.metadata"]?.["tool-status-update"]
|
|
134
|
+
return {
|
|
135
|
+
enabled: requestMeta !== undefined,
|
|
136
|
+
args: requestMeta?.args ?? true,
|
|
137
|
+
result: requestMeta?.result ?? true,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function serialize(v) {
|
|
142
|
+
return resolvePseudonyms(typeof v === "string" ? v : JSON.stringify(v))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Publishes an artifact-update for a tool call (start or end).
|
|
147
|
+
*/
|
|
148
|
+
function publishToolCallArtifact(tc, { meta, status, lastChunk, result }) {
|
|
149
|
+
const eventBus = cds.context?.["agent.eventBus"]
|
|
150
|
+
const config = toolCallConfig()
|
|
151
|
+
if (!eventBus || !config.enabled) return
|
|
152
|
+
|
|
153
|
+
let kind = resolveToolKind(tc, meta)
|
|
154
|
+
let label = resolveToolLabel(tc, meta)
|
|
155
|
+
eventBus.publish({
|
|
156
|
+
kind: "artifact-update",
|
|
157
|
+
taskId: cds.context["agent.task.id"],
|
|
158
|
+
contextId: cds.context["agent.context.id"],
|
|
159
|
+
append: false,
|
|
160
|
+
lastChunk,
|
|
161
|
+
artifact: {
|
|
162
|
+
artifactId: `tool-call-${tc.id}`,
|
|
163
|
+
parts: [
|
|
164
|
+
{
|
|
165
|
+
kind: "data",
|
|
166
|
+
data: {
|
|
167
|
+
type: "tool-call",
|
|
168
|
+
name: tc.name,
|
|
169
|
+
kind,
|
|
170
|
+
label,
|
|
171
|
+
status,
|
|
172
|
+
...(config.args && { args: serialize(tc.args) }),
|
|
173
|
+
...(result !== undefined && config.result && { result: serialize(result) }),
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* beforeModel hook: emit "Processing tool response" + tool-call completion events.
|
|
90
183
|
*/
|
|
91
|
-
export function beforeModelHook(state) {
|
|
184
|
+
export function beforeModelHook(state, toolMeta) {
|
|
92
185
|
if (!cds.context?.["agent.eventBus"]) return {}
|
|
93
186
|
|
|
94
187
|
const msgs = state.messages
|
|
95
188
|
if (!msgs?.length) return {}
|
|
96
|
-
|
|
97
|
-
if (!ToolMessage.isInstance(lastMsg)) return {}
|
|
189
|
+
if (!ToolMessage.isInstance(msgs[msgs.length - 1])) return {}
|
|
98
190
|
|
|
99
|
-
|
|
100
|
-
const
|
|
191
|
+
const firstToolIdx = msgs.findLastIndex((m) => !ToolMessage.isInstance(m)) + 1
|
|
192
|
+
const toolMsgs = msgs.slice(firstToolIdx)
|
|
193
|
+
|
|
194
|
+
const tcMap = {}
|
|
195
|
+
const msg = msgs.findLast((m) => AIMessage.isInstance(m) && m.tool_calls?.length)
|
|
196
|
+
if (msg) for (const tc of msg.tool_calls) tcMap[tc.id] = tc
|
|
197
|
+
|
|
198
|
+
for (const tm of toolMsgs) {
|
|
199
|
+
const tc = tcMap[tm.tool_call_id]
|
|
200
|
+
if (!tc) continue
|
|
201
|
+
const meta = resolveMeta(tc, toolMeta)
|
|
202
|
+
if (meta) {
|
|
203
|
+
publishToolCallArtifact(tc, {
|
|
204
|
+
meta,
|
|
205
|
+
status: tm.status === "error" ? "error" : "done",
|
|
206
|
+
lastChunk: true,
|
|
207
|
+
result: tm.content,
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const plural = toolMsgs.length >= 2
|
|
101
213
|
const key = plural ? "agent_status_processing_responses" : "agent_status_processing_response"
|
|
102
|
-
|
|
103
|
-
publishStatus(text)
|
|
214
|
+
publishStatus(cds.i18n.messages.at(key))
|
|
104
215
|
|
|
105
216
|
return {}
|
|
106
217
|
}
|
|
107
218
|
|
|
219
|
+
/** De-duplicate labels while preserving order. */
|
|
220
|
+
function uniqueLabels(calls) {
|
|
221
|
+
return [...new Set(calls.map(({ label }) => label))].join(", ")
|
|
222
|
+
}
|
|
223
|
+
|
|
108
224
|
/**
|
|
109
|
-
* afterModel hook: emit tool-call status updates (querying/calling)
|
|
225
|
+
* afterModel hook: emit tool-call status updates (querying/inspecting/calling)
|
|
226
|
+
* + tool-call start events.
|
|
110
227
|
*/
|
|
111
|
-
export function afterModelHook(state) {
|
|
228
|
+
export function afterModelHook(state, toolMeta) {
|
|
112
229
|
if (!cds.context?.["agent.eventBus"]) return {}
|
|
113
230
|
|
|
114
231
|
const msgs = state.messages
|
|
@@ -118,36 +235,52 @@ export function afterModelHook(state) {
|
|
|
118
235
|
const toolCalls = lastAI?.tool_calls
|
|
119
236
|
if (!toolCalls?.length) return {}
|
|
120
237
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
238
|
+
const resolved = toolCalls
|
|
239
|
+
.map((tc) => ({ tc, meta: resolveMeta(tc, toolMeta) }))
|
|
240
|
+
.filter(({ meta }) => meta)
|
|
241
|
+
.map(({ tc, meta }) => {
|
|
242
|
+
let kind = resolveToolKind(tc, meta)
|
|
243
|
+
let label = resolveToolLabel(tc, meta, kind)
|
|
244
|
+
return { tc, meta, kind, label }
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const queryCalls = resolved.filter(({ kind }) => kind === "query")
|
|
248
|
+
const describeCalls = resolved.filter(({ kind }) => kind === "describe")
|
|
249
|
+
const otherCalls = resolved.filter(({ kind }) => kind !== "query" && kind !== "describe")
|
|
124
250
|
|
|
125
|
-
// Emit "Querying {entities}" for query tools
|
|
126
251
|
if (queryCalls.length) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
252
|
+
publishStatus(cds.i18n.messages.at("agent_status_querying", [uniqueLabels(queryCalls)]))
|
|
253
|
+
}
|
|
254
|
+
if (describeCalls.length) {
|
|
255
|
+
publishStatus(cds.i18n.messages.at("agent_status_describing", [uniqueLabels(describeCalls)]))
|
|
130
256
|
}
|
|
131
|
-
|
|
132
|
-
// Emit "Calling {action labels}" for other tools
|
|
133
257
|
if (otherCalls.length) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
258
|
+
publishStatus(cds.i18n.messages.at("agent_status_calling_tools", [uniqueLabels(otherCalls)]))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
for (const { tc, meta } of resolved) {
|
|
262
|
+
publishToolCallArtifact(tc, { meta, status: "running", lastChunk: false })
|
|
137
263
|
}
|
|
138
264
|
|
|
139
265
|
return {}
|
|
140
266
|
}
|
|
141
267
|
|
|
142
268
|
/**
|
|
143
|
-
* Middleware
|
|
144
|
-
* - beforeModel: "Processing tool response"
|
|
145
|
-
* - afterModel: "Querying
|
|
269
|
+
* Middleware emitting non-final status-update events during agent execution:
|
|
270
|
+
* - beforeModel: "Processing tool response" + tool-call completion events
|
|
271
|
+
* - afterModel: "Querying"/"Inspecting"/"Calling" + tool-call start events
|
|
272
|
+
*
|
|
273
|
+
* Static tools are captured here; remote MCP tools are resolved per-request from
|
|
274
|
+
* cds.context.__mcpDynamicTools by resolveMeta().
|
|
146
275
|
*/
|
|
147
|
-
export async function statusUpdateMiddleware() {
|
|
276
|
+
export async function statusUpdateMiddleware(tools = []) {
|
|
277
|
+
// Presence in the map marks a tool as "known"; own-service tools carry no
|
|
278
|
+
// metadata (→ {}), so resolveToolKind sniffs their kind from the name.
|
|
279
|
+
const toolMeta = new Map(tools.map((t) => [t.name, t.metadata ?? {}]))
|
|
280
|
+
|
|
148
281
|
return createMiddleware({
|
|
149
282
|
name: "statusUpdateMiddleware",
|
|
150
|
-
beforeModel: { hook: beforeModelHook },
|
|
151
|
-
afterModel: { hook: afterModelHook },
|
|
283
|
+
beforeModel: { hook: (state) => beforeModelHook(state, toolMeta) },
|
|
284
|
+
afterModel: { hook: (state) => afterModelHook(state, toolMeta) },
|
|
152
285
|
})
|
|
153
286
|
}
|
|
@@ -14,9 +14,8 @@ export function toolWrapMiddleware(srv) {
|
|
|
14
14
|
return createMiddleware({
|
|
15
15
|
name: "ToolWrapMiddleware",
|
|
16
16
|
wrapToolCall: async function (request, handler) {
|
|
17
|
-
const { name, id
|
|
17
|
+
const { name, id } = request.toolCall
|
|
18
18
|
try {
|
|
19
|
-
LOG.debug(srv.name, "calling tool", name, args)
|
|
20
19
|
const result = await handler(request)
|
|
21
20
|
if (ToolMessage.isInstance(result) && result.artifact?.isError === true) {
|
|
22
21
|
result.status = "error"
|
|
@@ -60,14 +60,17 @@ export async function summarizePartialWork({
|
|
|
60
60
|
reason === "timeOut"
|
|
61
61
|
? `Agent task was not completed within its time limit. Write short, precise progress summary so user can decide whether to continue. State completed work and immediate next work. Do not claim work not shown. Start with: Agent did not finish within time! End with: Continue running or stop? No other questions to the user allowed!`
|
|
62
62
|
: `Agent task was interrupted. Reason: ${reason}. Based on conversation history, provide brief summary of completed work and remaining work. Be concise.`
|
|
63
|
-
summaryPrompt += `\n\n Conversation Snippet: \n\n
|
|
63
|
+
summaryPrompt += `\n\n Conversation Snippet: \n\n`
|
|
64
64
|
|
|
65
65
|
let summaryTimer
|
|
66
66
|
try {
|
|
67
67
|
const response = await Promise.race([
|
|
68
68
|
(async () => {
|
|
69
|
+
const instruction = new HumanMessage(summaryPrompt)
|
|
70
|
+
instruction.name = "cap-js-agents-summarize"
|
|
69
71
|
const model = await getModel()
|
|
70
|
-
|
|
72
|
+
// Two messages, so the first one can be logged as the prompt for summarization in MLflow
|
|
73
|
+
return model.invoke([instruction, new HumanMessage(conversationSnippet.trim())])
|
|
71
74
|
})(),
|
|
72
75
|
new Promise((_, reject) => {
|
|
73
76
|
summaryTimer = setTimeout(() => reject(new Error("Summary LLM call timed out")), timeout)
|