@cap-js/agents 0.0.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +155 -0
  3. package/_i18n/messages.properties +31 -0
  4. package/cds-plugin.js +140 -0
  5. package/index.cds +105 -0
  6. package/index.js +0 -0
  7. package/lib/agents/markdown/backends/mime-utils.js +37 -0
  8. package/lib/agents/markdown/backends/outputs-backend.js +152 -0
  9. package/lib/agents/markdown/backends/uploads-backend.js +143 -0
  10. package/lib/agents/markdown/deep-agent.js +93 -0
  11. package/lib/agents/middleware/agent-actions.js +18 -0
  12. package/lib/agents/middleware/content-filter.js +191 -0
  13. package/lib/agents/middleware/hitl-edit-note-injector.js +18 -0
  14. package/lib/agents/middleware/hitl.js +20 -0
  15. package/lib/agents/middleware/index.js +19 -0
  16. package/lib/agents/middleware/patch-tool-calls.js +51 -0
  17. package/lib/agents/middleware/quota-enforcer.js +94 -0
  18. package/lib/agents/middleware/status-update.js +153 -0
  19. package/lib/agents/middleware/tool-selection.js +22 -0
  20. package/lib/agents/quota-enforcer-at-start.js +198 -0
  21. package/lib/agents/summarize-on-timeout.js +91 -0
  22. package/lib/compile.js +55 -0
  23. package/lib/index.cjs +1 -0
  24. package/lib/index.js +422 -0
  25. package/lib/models/aicore.js +441 -0
  26. package/lib/models/anthropic.js +77 -0
  27. package/lib/models/mock.js +88 -0
  28. package/lib/preview/chat.html +875 -0
  29. package/lib/preview/preview.js +46 -0
  30. package/lib/protocol/agent-card.js +297 -0
  31. package/lib/protocol/persistence/checkpoint-saver.js +317 -0
  32. package/lib/protocol/persistence/file-store.js +209 -0
  33. package/lib/protocol/persistence/push-notification-store.js +59 -0
  34. package/lib/protocol/persistence/task-store.js +47 -0
  35. package/lib/protocol/push-notification-sender.js +57 -0
  36. package/lib/sidecar.js +162 -0
  37. package/lib/telemetry/active-users.js +106 -0
  38. package/lib/telemetry/chat-tracing.js +342 -0
  39. package/lib/telemetry/metrics.js +85 -0
  40. package/lib/telemetry/mlflow.js +290 -0
  41. package/lib/telemetry/tool-tracing.js +164 -0
  42. package/lib/telemetry/tracing.js +150 -0
  43. package/lib/utils/inner-auth.js +33 -0
  44. package/lib/utils/markdown.js +199 -0
  45. package/lib/utils/message-handling.js +155 -0
  46. package/lib/utils/utils.js +168 -0
  47. package/package.json +225 -2
  48. package/srv/graph-cache.js +82 -0
  49. package/srv/handlers/graph-executor.js +1369 -0
  50. package/srv/handlers/index.js +173 -0
  51. package/srv/handlers/mcp-tools.js +159 -0
  52. package/srv/handlers/sub-agent-tools.js +314 -0
  53. package/srv/handlers/system-prompt.js +25 -0
  54. package/srv/handlers/tools.js +366 -0
  55. package/srv/langgraph-executor-srv.js +70 -0
  56. package/srv/push-notification-srv.js +149 -0
@@ -0,0 +1,173 @@
1
+ import cds from "@sap/cds"
2
+ import { generateTools, createReadFileTool } from "./tools.js"
3
+ import { buildSystemPrompt } from "./system-prompt.js"
4
+ import buildMiddleware from "../../lib/agents/middleware/index.js"
5
+ import { partsToText } from "../../lib/utils/message-handling.js"
6
+
7
+ const LOG = cds.log("agents")
8
+
9
+ /**
10
+ * Register default event handlers for agent graph building on an @agent service.
11
+ *
12
+ * Apps override by registering their own handlers in init() — FIFO semantics
13
+ * give app handlers (registered first) priority over these defaults.
14
+ */
15
+ export default function registerDefaultAgentHandlers(srv) {
16
+ // Default buildTools: generate tools from CDS model + any configured MCP connections.
17
+ // MCP connections are declared via @agent.mcps annotation on the service:
18
+ // @agent.mcps: [{ service: 'MyConnection' }, { service: 'AnotherConnection' }]
19
+ // Each service name must be defined as a cds.requires entry in package.json.
20
+ srv.on("buildTools", async () => {
21
+ const cdsTools = generateTools(srv)
22
+
23
+ // ── MCP servers and subagents ────────────────────────────────────────────────────────
24
+ // Connect to other @mcp services and @agent services
25
+ function canConnect(s) {
26
+ return (
27
+ s.name !== srv.name && (s.protocols?.mcp || s["@mcp"] || s.protocols?.agent || s["@agent"])
28
+ )
29
+ }
30
+
31
+ const connect =
32
+ srv.options?.agent?.connect ?? srv.definition["@agent.connect"] ?? cds.env.agents?.connect
33
+
34
+ // Allow for providing an mcp or a2a connection with cds.requires only -> no cds.model.services entry
35
+ // REVISIT: better method?
36
+ const additionalServices = Object.entries(cds.env.requires).map(([name, s]) => ({
37
+ name,
38
+ kind: s?.kind,
39
+ protocols: { [s?.kind]: true },
40
+ }))
41
+
42
+ const serviceMap = Object.fromEntries(
43
+ [...additionalServices, ...(cds.model?.services ?? [])].map((s) => [s.name, s]),
44
+ )
45
+ const services = Object.values(serviceMap)
46
+ const selected =
47
+ connect === "none"
48
+ ? []
49
+ : connect === "auto"
50
+ ? services?.filter(canConnect)
51
+ : connect === "mcp"
52
+ ? services?.filter((s) => s.name !== srv.name && (s.protocols?.mcp || s["@mcp"]))
53
+ : connect === "agent"
54
+ ? services?.filter((s) => s.name !== srv.name && (s.protocols?.agent || s["@agent"]))
55
+ : connect?.map((name) => serviceMap[name]).filter(Boolean) || []
56
+
57
+ if (selected.length === 0) return cdsTools
58
+
59
+ const mcpEntries = []
60
+ const agentEntries = []
61
+ for (const s of selected) {
62
+ if (s.protocols?.agent || s["@agent"]) agentEntries.push(s.name)
63
+ else if (s.protocols?.mcp || s["@mcp"]) mcpEntries.push(s.name)
64
+ else LOG.warn(`Agent ${srv.name} could not connect to ${s.name}, missing @mcp or @agent`)
65
+ }
66
+
67
+ const { buildMcpTools } = await import("./mcp-tools.js")
68
+ const { buildSubAgentTool } = await import("./sub-agent-tools.js")
69
+
70
+ const results = await Promise.allSettled([
71
+ ...mcpEntries.map((e) => buildMcpTools(e.service ?? e)),
72
+ ...agentEntries.map((e) => buildSubAgentTool(e.service ?? e)),
73
+ ])
74
+
75
+ const extraTools = []
76
+ for (const r of results) {
77
+ // MCP connections yield an array of tools; sub-agent connections yield a
78
+ // single tool. Normalize both so instrumentTools sees a flat tool list.
79
+ if (r.status === "fulfilled") {
80
+ if (Array.isArray(r.value)) extraTools.push(...r.value.filter(Boolean))
81
+ else if (r.value) extraTools.push(r.value)
82
+ } else LOG.warn("Failed to build external tools:", r.reason?.message ?? r.reason)
83
+ }
84
+
85
+ return [...cdsTools, ...extraTools]
86
+ })
87
+
88
+ srv.after("buildTools", (tools) => tools)
89
+
90
+ // Default buildModel: cds.connect.to('llm'), configurable via @agent.llm
91
+ srv.on("buildModel", async (req) => {
92
+ const name = srv?.options?.agent?.llm || srv?.definition?.["@agent.llm"] || "llm"
93
+ let { kind, impl, ...options } = cds.requires[name] ?? {}
94
+ if (!impl) impl = cds.requires.kinds[kind]?.impl
95
+ if (!impl) throw new Error("No service implementation found for " + name)
96
+ const { default: LLMProvider } = await import(impl)
97
+ return new LLMProvider(name, { ...options, ...req.data })
98
+ })
99
+
100
+ // Default buildSystemPrompt: build from service definition
101
+ srv.on("buildSystemPrompt", async () => {
102
+ return buildSystemPrompt(srv)
103
+ })
104
+
105
+ // Default buildMiddleware: quota enforcement, content filtering, agent_actions metric
106
+ srv.on("buildMiddleware", async (req) => {
107
+ return buildMiddleware(srv, req.data)
108
+ })
109
+
110
+ // Default buildGraph: if agent dir with AGENTS.md exists → auto-build deep agent.
111
+ // Otherwise orchestrates sub-events → wires ReAct agent via langchain's createAgent.
112
+ srv.on("buildGraph", async () => {
113
+ const { resolveAgentDir, isDeepAgentDir } = await import("../../lib/utils/markdown.js")
114
+ const agentDir = resolveAgentDir(srv)
115
+
116
+ // Auto-built deep agent from AGENTS.md + skills/ convention
117
+ if (agentDir && isDeepAgentDir(agentDir)) {
118
+ const { createAutoDeepAgent } = await import("../../lib/agents/markdown/deep-agent.js")
119
+ return createAutoDeepAgent(srv, agentDir)
120
+ }
121
+
122
+ // Standard ReAct agent via langchain's createAgent
123
+ const { createAgent } = await import("langchain")
124
+ const { CdsCheckpointSaver } =
125
+ await import("../../lib/protocol/persistence/checkpoint-saver.js")
126
+ const { GraphExecutor } = await import("./graph-executor.js")
127
+
128
+ const tools = await srv.send("buildTools")
129
+
130
+ // File-IO: add a read_file tool that resolves context at invocation time.
131
+ // cds.context["agent.context.id"] and user.id are set by GraphExecutor before invoke.
132
+ if (cds.env.agents?.fileIO?.enabled) {
133
+ const { CdsFileStore } = await import("../../lib/protocol/persistence/file-store.js")
134
+ const fileStore = new CdsFileStore()
135
+ const readFileTool = createReadFileTool(fileStore)
136
+ tools.push(readFileTool)
137
+ }
138
+
139
+ let model = await srv.send("buildModel", { tools })
140
+
141
+ const systemPrompt = await srv.send("buildSystemPrompt")
142
+ const middleware = await srv.send("buildMiddleware", { tools, model })
143
+
144
+ const checkpointer = new CdsCheckpointSaver()
145
+
146
+ const agent = createAgent({
147
+ model,
148
+ tools,
149
+ systemPrompt,
150
+ middleware,
151
+ checkpointer,
152
+ })
153
+
154
+ return new GraphExecutor(agent, srv, {
155
+ checkpointer: false, // already set on createAgent
156
+ // Standard ReAct agents have no built-in recursionLimit default (LangGraph's
157
+ // fallback is 25, which is too low for multi-tool tasks). Deep agents go through
158
+ // langgraph-executor-srv.js which passes no recursionLimit → deepagents' 10000 wins.
159
+ recursionLimit: cds.env.agents?.recursionLimit ?? 100,
160
+ inputMapper: async (requestContext) => {
161
+ const { HumanMessage } = await import("@langchain/core/messages")
162
+ const text = partsToText(requestContext.userMessage?.parts)
163
+
164
+ // Append file manifest injected by GraphExecutor.execute() (fileIO path)
165
+ const fullText = requestContext._fileManifest
166
+ ? `${text}\n${requestContext._fileManifest}`
167
+ : text
168
+
169
+ return { messages: [new HumanMessage(fullText)] }
170
+ },
171
+ })
172
+ })
173
+ }
@@ -0,0 +1,159 @@
1
+ import cds from "@sap/cds"
2
+ import { MultiServerMCPClient } from "@langchain/mcp-adapters"
3
+ import { generateTools } from "./tools.js"
4
+ import { toolName } from "../../lib/utils/utils.js"
5
+
6
+ const LOG = cds.log("agents:mcp")
7
+
8
+ /**
9
+ * Resolve URL and HTTP headers for a BTP destination using the Cloud SDK.
10
+ * Passes the incoming user JWT for OAuth2UserTokenExchange destinations.
11
+ * Falls back to CAP's authTokens if Cloud SDK resolution fails.
12
+ *
13
+ * @param {string} destinationName
14
+ * @param {object|string} dest - raw CAP destination value (for fallback)
15
+ * @param {string|null} localUrl - URL already known from CAP (fallback if Cloud SDK has none)
16
+ * @returns {Promise<{ url: string|null, headers: Record<string, string> }>}
17
+ */
18
+ async function resolveDestination(destinationName, dest, localUrl) {
19
+ try {
20
+ const { getDestination, buildHeadersForDestination, retrieveJwt } =
21
+ await import("@sap-cloud-sdk/connectivity")
22
+ const jwt = retrieveJwt(cds.context?.http?.req)
23
+ const resolvedDest = await getDestination({ destinationName, jwt })
24
+ if (!resolvedDest) return { url: localUrl, headers: {} }
25
+
26
+ const rawHeaders = await buildHeadersForDestination(resolvedDest)
27
+ // Cloud SDK returns lowercase header keys (e.g. "authorization") —
28
+ // normalize to title-case so HTTP clients handle them correctly.
29
+ const headers = Object.fromEntries(
30
+ Object.entries(rawHeaders).map(([k, v]) => [
31
+ k.replace(/(^|-)(.)/g, (_, sep, c) => sep + c.toUpperCase()),
32
+ v,
33
+ ]),
34
+ )
35
+ return { url: resolvedDest.url ?? localUrl, headers }
36
+ } catch (err) {
37
+ LOG.warn(
38
+ `Could not resolve destination "${destinationName}" via Cloud SDK, falling back to CAP destination: ${err.message}`,
39
+ )
40
+ const token = typeof dest === "object" ? dest?.authTokens?.[0]?.value : null
41
+ return { url: localUrl, headers: token ? { Authorization: `Bearer ${token}` } : {} }
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Wrap MCP tool invocations to convert errors into plain string results.
47
+ * deepagents' wrapToolCall middleware marks errors thrown inside it as
48
+ * "middleware errors", which LangChain's ToolNode re-throws rather than
49
+ * converting to a ToolMessage (see ToolNode.js: isMiddlewareError check).
50
+ * MCP tool schema validation errors therefore crash the graph instead of
51
+ * being fed back to the LLM as recoverable feedback.
52
+ */
53
+ function wrapToolsWithErrorHandling(tools, serviceName) {
54
+ return tools.map((tool) => {
55
+ const original = tool.invoke.bind(tool)
56
+ tool.invoke = async (args, config) => {
57
+ try {
58
+ return await original(args, config)
59
+ } catch (err) {
60
+ LOG.warn(`MCP tool "${tool.name}" error: ${err.message}`, { service: serviceName })
61
+ return `Error: ${err.message}`
62
+ }
63
+ }
64
+ return tool
65
+ })
66
+ }
67
+
68
+ export async function buildMcpToolsLocally(serviceName) {
69
+ const srv = cds.services[serviceName]
70
+ const tools = generateTools(srv)
71
+ const prefix = toolName(`${serviceName}_`)
72
+ for (const tool of tools) tool.name = `${prefix}${tool.name}`
73
+
74
+ LOG.info(
75
+ `Got ${tools.length} MCP tools from ${serviceName}: ${tools.map((t) => t.name).join(", ")}`,
76
+ )
77
+ return tools
78
+ }
79
+
80
+ /**
81
+ * Build LangChain tools from a CAP MCP connection.
82
+ * Resolves the destination URL and auth headers, connects to the MCP server,
83
+ * and returns the tools wrapped with error handling for use in agent graphs.
84
+ *
85
+ * @param {string} serviceName - cds.requires service key
86
+ * @returns {Promise<import("@langchain/core/tools").StructuredTool[]>}
87
+ */
88
+ export async function buildMcpToolsFromConnection(serviceName) {
89
+ let endpoints = cds.service.endpoints4({
90
+ name: serviceName,
91
+ definition: cds.model.services[serviceName],
92
+ })
93
+ endpoints = Object.fromEntries(endpoints.map((o) => [o.kind, o.path]))
94
+ const { credentials, kind } = cds.requires[serviceName]
95
+
96
+ // dest is either a string (BTP destination name) or an object { name, url, ... }
97
+ const destinationName = typeof credentials === "string" ? credentials : credentials?.name
98
+ const localUrl =
99
+ typeof credentials === "string"
100
+ ? null
101
+ : kind === undefined
102
+ ? credentials?.url?.replace("undefined", endpoints?.mcp)
103
+ : kind !== "mcp" && endpoints?.[kind]
104
+ ? credentials?.url.replace(endpoints[kind], endpoints.mcp)
105
+ : credentials?.url
106
+
107
+ if (!localUrl && !destinationName) {
108
+ throw new Error(
109
+ `[mcp-tools] Could not resolve URL or destination name for service "${serviceName}". Check cds.requires config.`,
110
+ )
111
+ }
112
+
113
+ // REVISIT: url resolution at runtime, potentially using RemoteService?
114
+ const { url } = destinationName
115
+ ? await resolveDestination(destinationName, credentials, localUrl)
116
+ : { url: localUrl }
117
+
118
+ if (!url) {
119
+ throw new Error(
120
+ `[mcp-tools] Could not resolve URL for service "${serviceName}" after destination lookup.`,
121
+ )
122
+ }
123
+
124
+ const mcpUrl = url.replace(/\/$/, "")
125
+ LOG.info(`Connecting to MCP server at ${mcpUrl}`)
126
+
127
+ const resolveHeaders = async () => {
128
+ if (destinationName) {
129
+ const { headers } = await resolveDestination(destinationName, credentials, url)
130
+ LOG.debug(`Resolved destination "${destinationName}"`, { headerKeys: Object.keys(headers) })
131
+ return headers
132
+ }
133
+ const token = credentials?.authTokens?.[0]?.value
134
+ return token ? { Authorization: `Bearer ${token}` } : {}
135
+ }
136
+
137
+ const client = new MultiServerMCPClient({
138
+ mcpServers: {
139
+ [serviceName]: { url: mcpUrl },
140
+ },
141
+ beforeToolCall: async () => ({ headers: await resolveHeaders() }),
142
+ })
143
+
144
+ const tools = await client.getTools()
145
+ const prefix = toolName(`${serviceName}_`)
146
+ for (const tool of tools) tool.name = `${prefix}${tool.name}`
147
+
148
+ LOG.info(
149
+ `Got ${tools.length} MCP tools from ${serviceName}: ${tools.map((t) => t.name).join(", ")}`,
150
+ )
151
+
152
+ return wrapToolsWithErrorHandling(tools, serviceName)
153
+ }
154
+
155
+ export async function buildMcpTools(serviceName) {
156
+ return cds.requires[serviceName]?.credentials
157
+ ? buildMcpToolsFromConnection(serviceName)
158
+ : buildMcpToolsLocally(serviceName)
159
+ }
@@ -0,0 +1,314 @@
1
+ import cds from "@sap/cds"
2
+ import { tool } from "@langchain/core/tools"
3
+ import { z } from "zod"
4
+ import { LangGraphExecutor } from "../langgraph-executor-srv.js"
5
+ import { toolName } from "../../lib/utils/utils.js"
6
+
7
+ const LOG = cds.log("agents:sub-agents")
8
+
9
+ /**
10
+ * Extract text and file parts from an A2A response (task or message).
11
+ */
12
+ function extractResult(result) {
13
+ if (!result) return { text: "No response from agent.", files: [] }
14
+
15
+ const text = []
16
+ const files = []
17
+
18
+ const processParts = (parts = []) => {
19
+ for (const part of parts) {
20
+ if (part.kind === "text") text.push(part.text)
21
+ else if (part.kind === "file") files.push(part)
22
+ }
23
+ }
24
+
25
+ if (result.kind === "task") {
26
+ processParts(result.status?.message?.parts)
27
+ for (const artifact of result.artifacts || []) processParts(artifact.parts)
28
+ if (text.length === 0 && files.length === 0) {
29
+ return { text: `Task ${result.id}: ${result.status?.state || "unknown"}`, files: [] }
30
+ }
31
+ } else if (result.kind === "message") {
32
+ processParts(result.parts)
33
+ } else {
34
+ return { text: JSON.stringify(result), files: [] }
35
+ }
36
+
37
+ return { text: text.join("\n") || "No response.", files }
38
+ }
39
+
40
+ /**
41
+ * Build the tool description from an agent card (or agent-card-like object).
42
+ */
43
+ function toolDescription(card) {
44
+ const skills = card.skills?.map((s) => `- ${s.name}: ${s.description}`).join("\n") || ""
45
+ return (
46
+ `Delegate to the "${card.name}" agent. ${card.description || ""}` +
47
+ (skills ? `\n\nSkills:\n${skills}` : "")
48
+ )
49
+ }
50
+
51
+ /**
52
+ * Format an extracted { text, files } pair into the string a tool returns.
53
+ *
54
+ * FileParts are serialized inline as {"kind":"file",...} JSON so the executor's
55
+ * file-collection loop (graph-executor.js) picks them up and re-publishes them
56
+ * as A2A artifacts, plus a human-readable manifest note for the model.
57
+ */
58
+ function formatToolResult({ text, files }) {
59
+ if (!files || files.length === 0) return text
60
+ const fileJson = files.map((f) => JSON.stringify({ kind: "file", file: f.file })).join("\n")
61
+ const manifest = files
62
+ .filter((f) => f.file?.bytes)
63
+ .map((f) => `/uploads/${f.file.name} (${f.file.mimeType})`)
64
+ const manifestNote = manifest.length
65
+ ? `\n[Files from downstream agent: ${manifest.join(", ")}]`
66
+ : ""
67
+ return `${text}${manifestNote}\n${fileJson}`
68
+ }
69
+
70
+ /**
71
+ * Wrap an A2A client as a LangChain tool the agent can call.
72
+ */
73
+ function createA2ATool(client, agentCard) {
74
+ return tool(
75
+ async ({ message }) => {
76
+ try {
77
+ const result = await client.sendMessage({
78
+ message: {
79
+ kind: "message",
80
+ role: "user",
81
+ messageId: cds.utils.uuid(),
82
+ parts: [{ kind: "text", text: message }],
83
+ },
84
+ })
85
+ return formatToolResult(extractResult(result))
86
+ } catch (err) {
87
+ LOG.warn("Sub-agent tool error", { agent: agentCard.name, error: err.message })
88
+ return `Error communicating with ${agentCard.name}: ${err.message}`
89
+ }
90
+ },
91
+ {
92
+ name: toolName(agentCard.name),
93
+ description: toolDescription(agentCard),
94
+ schema: z.object({
95
+ message: z.string().describe("The request to send to this agent"),
96
+ }),
97
+ },
98
+ )
99
+ }
100
+
101
+ export async function buildSubAgentToolLocally(serviceName) {
102
+ const srv = cds.services[serviceName]
103
+ if (!srv) throw new Error(`[sub-agents] No local service "${serviceName}" found.`)
104
+
105
+ const executor = LangGraphExecutor.for(srv)
106
+
107
+ const { generateAgentCard } = await import("../../lib/protocol/agent-card.js")
108
+ const agentCard = generateAgentCard(srv)
109
+ LOG.info(`Wired local sub-agent "${agentCard.name}" (${serviceName})`)
110
+
111
+ const { RequestContext, DefaultExecutionEventBus } = await import("@a2a-js/sdk/server")
112
+
113
+ return tool(
114
+ async ({ message }) => {
115
+ const taskId = cds.utils.uuid()
116
+ const contextId = cds.utils.uuid()
117
+
118
+ // Drive the local executor directly (no HTTP). Collect published events
119
+ // into a synthetic task so we can reuse extractResult for text + files.
120
+ const eventBus = new DefaultExecutionEventBus()
121
+
122
+ // The terminal status message and the "response" text artifact carry the
123
+ // final answer; file-* artifacts carry downstream FileParts. Accumulate
124
+ // the latest of each so partial/streamed updates are superseded.
125
+ let statusText = null
126
+ let responseText = null
127
+ let failed = null
128
+ const files = []
129
+
130
+ eventBus.on("event", (event) => {
131
+ if (event.kind === "status-update") {
132
+ const state = event.status?.state
133
+ const text = event.status?.message?.parts
134
+ ?.filter((p) => p.kind === "text")
135
+ .map((p) => p.text)
136
+ .join("\n")
137
+ if (text) statusText = text
138
+ if (state === "failed") failed = text || "Sub-agent execution failed."
139
+ } else if (event.kind === "artifact-update") {
140
+ const parts = event.artifact?.parts || []
141
+ if (event.artifact?.artifactId === "response") {
142
+ // append:false replaces; append:true accumulates streamed tokens.
143
+ const text = parts
144
+ .filter((p) => p.kind === "text")
145
+ .map((p) => p.text)
146
+ .join("")
147
+ if (text) responseText = event.append ? (responseText || "") + text : text
148
+ }
149
+ for (const p of parts) if (p.kind === "file") files.push(p)
150
+ }
151
+ })
152
+
153
+ const done = new Promise((resolve) => eventBus.once("finished", resolve))
154
+
155
+ const requestContext = new RequestContext(
156
+ {
157
+ kind: "message",
158
+ role: "user",
159
+ messageId: cds.utils.uuid(),
160
+ taskId,
161
+ contextId,
162
+ parts: [{ kind: "text", text: message }],
163
+ },
164
+ taskId,
165
+ contextId,
166
+ )
167
+
168
+ try {
169
+ // Run the sub-agent detached from the calling agent, in its own root
170
+ // context and transaction (cds.spawn creates + commits a fresh tx). A
171
+ // shared transaction breaks the stream.
172
+ await new Promise((resolve, reject) => {
173
+ cds
174
+ .spawn({ user: cds.context?.user, tenant: cds.context?.tenant }, async () => {
175
+ await executor.execute(requestContext, eventBus)
176
+ await done
177
+ })
178
+ .on("succeeded", resolve)
179
+ .on("failed", reject)
180
+ })
181
+ } catch (err) {
182
+ LOG.warn("Local sub-agent tool error", { agent: agentCard.name, error: err.message })
183
+ return `Error running ${agentCard.name}: ${err.message}`
184
+ }
185
+
186
+ if (failed) return `Error running ${agentCard.name}: ${failed}`
187
+
188
+ const text = responseText || statusText || "No response."
189
+ return formatToolResult({ text, files })
190
+ },
191
+ {
192
+ name: toolName(agentCard.name),
193
+ description: toolDescription(agentCard),
194
+ schema: z.object({
195
+ message: z.string().describe("The request to send to this agent"),
196
+ }),
197
+ },
198
+ )
199
+ }
200
+
201
+ export async function buildSubAgentToolFromConnection(serviceName) {
202
+ let endpoints = cds.service.endpoints4({
203
+ name: serviceName,
204
+ definition: cds.model.services[serviceName],
205
+ })
206
+ endpoints = Object.fromEntries(endpoints.map((o) => [o.kind, o.path]))
207
+ const { credentials, kind } = cds.requires[serviceName]
208
+
209
+ const destinationName = typeof credentials === "string" ? credentials : credentials?.name
210
+ const localUrl =
211
+ typeof credentials === "string"
212
+ ? null
213
+ : kind === undefined
214
+ ? credentials?.url?.replace("undefined", endpoints?.agent)
215
+ : kind !== "agent" && endpoints?.[kind]
216
+ ? credentials?.url.replace(endpoints[kind], endpoints.agent)
217
+ : credentials?.url
218
+
219
+ if (!localUrl && !destinationName) {
220
+ throw new Error(
221
+ `[sub-agents] Could not resolve URL or destination name for service "${serviceName}". Check cds.requires config.`,
222
+ )
223
+ }
224
+
225
+ const resolveHeaders = destinationName
226
+ ? async () => {
227
+ try {
228
+ const { getDestination, buildHeadersForDestination, retrieveJwt } =
229
+ await import("@sap-cloud-sdk/connectivity")
230
+ const jwt = retrieveJwt(cds.context?.http?.req)
231
+ const resolvedDest = await getDestination({ destinationName, jwt })
232
+ const rawHeaders = await buildHeadersForDestination(resolvedDest)
233
+ return Object.fromEntries(
234
+ Object.entries(rawHeaders).map(([k, v]) => [
235
+ k.replace(/(^|-)(.)/g, (_, sep, c) => sep + c.toUpperCase()),
236
+ v,
237
+ ]),
238
+ )
239
+ } catch (err) {
240
+ LOG.warn(
241
+ `Could not resolve destination "${destinationName}" via Cloud SDK, falling back: ${err.message}`,
242
+ )
243
+ const token = typeof credentials === "object" ? credentials?.authTokens?.[0]?.value : null
244
+ return token ? { Authorization: `Bearer ${token}` } : {}
245
+ }
246
+ }
247
+ : async () => {
248
+ const token = credentials?.authTokens?.[0]?.value
249
+ return token ? { Authorization: `Bearer ${token}` } : {}
250
+ }
251
+
252
+ // Resolve URL once at build time; headers are resolved fresh on every request.
253
+ let agentBaseUrl = localUrl
254
+ if (destinationName) {
255
+ try {
256
+ const { getDestination, retrieveJwt } = await import("@sap-cloud-sdk/connectivity")
257
+ const jwt = retrieveJwt(cds.context?.http?.req)
258
+ const resolvedDest = await getDestination({ destinationName, jwt })
259
+ if (resolvedDest?.url) agentBaseUrl = resolvedDest.url
260
+ } catch (err) {
261
+ LOG.warn(
262
+ `Could not resolve destination URL for "${destinationName}", using localUrl: ${err.message}`,
263
+ )
264
+ }
265
+ }
266
+
267
+ if (!agentBaseUrl) {
268
+ throw new Error(
269
+ `[sub-agents] Could not resolve URL for service "${serviceName}" after destination lookup.`,
270
+ )
271
+ }
272
+
273
+ const base = agentBaseUrl.replace(/\/$/, "")
274
+ LOG.info(`Connecting to sub-agent at ${base}`)
275
+
276
+ // revisit: a2a agents may be tenant specific, card per tenant?
277
+ const initialHeaders = await resolveHeaders()
278
+ const cardUrl = `${base}/.well-known/agent-card.json`
279
+ const cardRes = await fetch(cardUrl, {
280
+ headers: { Accept: "application/json", ...initialHeaders },
281
+ })
282
+ if (!cardRes.ok) {
283
+ throw new Error(
284
+ `[sub-agents] Agent card fetch failed for "${serviceName}" at ${cardUrl}: HTTP ${cardRes.status}`,
285
+ )
286
+ }
287
+ const agentCard = await cardRes.json()
288
+ LOG.info(`Connected to sub-agent "${agentCard.name}" (${serviceName})`)
289
+
290
+ const { ClientFactory, ClientFactoryOptions, JsonRpcTransportFactory, RestTransportFactory } =
291
+ await import("@a2a-js/sdk/client")
292
+
293
+ const customFetch = async (url, init = {}) => {
294
+ const headers = await resolveHeaders()
295
+ return fetch(url, { ...init, headers: { ...headers, ...(init.headers || {}) } })
296
+ }
297
+ const factory = new ClientFactory(
298
+ ClientFactoryOptions.createFrom(ClientFactoryOptions.default, {
299
+ transports: [
300
+ new JsonRpcTransportFactory({ fetchImpl: customFetch }),
301
+ new RestTransportFactory({ fetchImpl: customFetch }),
302
+ ],
303
+ }),
304
+ )
305
+ const client = await factory.createFromAgentCard(agentCard)
306
+
307
+ return createA2ATool(client, agentCard)
308
+ }
309
+
310
+ export function buildSubAgentTool(serviceName) {
311
+ return cds.requires[serviceName]?.credentials
312
+ ? buildSubAgentToolFromConnection(serviceName)
313
+ : buildSubAgentToolLocally(serviceName)
314
+ }
@@ -0,0 +1,25 @@
1
+ import cds from "@sap/cds"
2
+ import { getDescription } from "../../lib/utils/utils.js"
3
+
4
+ export function buildSystemPrompt(srv) {
5
+ const serviceDesc = getDescription(srv.definition) || `Service ${srv.name}`
6
+
7
+ return [
8
+ `You are an AI assistant for the "${srv.name}" service.`,
9
+ serviceDesc,
10
+ "",
11
+ "Always use the provided tools to answer questions - do not make up data.",
12
+ "Use the `describe` tool to get information about the service's entities and actions if needed.",
13
+ "Use the `query` tool to read data from entities.",
14
+ cds.env.agents?.per_action_tool !== false
15
+ ? "Call action and function tools directly by name."
16
+ : "Use the `call` tool to invoke actions and functions.",
17
+ ...(cds.env.agents?.fileIO?.enabled
18
+ ? [
19
+ "When the user's message contains '[Uploaded files: ...]', use the `read_file` tool to read each listed file before answering.",
20
+ "Use `emit_file_part` to return files in your response.",
21
+ ]
22
+ : []),
23
+ "Be concise and helpful.",
24
+ ].join("\n")
25
+ }