@cap-js/agents 0.0.0 → 0.9.2

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 (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +154 -0
  3. package/_i18n/messages.properties +31 -0
  4. package/cds-plugin.js +140 -0
  5. package/index.cds +116 -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 +156 -0
  9. package/lib/agents/markdown/backends/readonly-backend.js +48 -0
  10. package/lib/agents/markdown/backends/uploads-backend.js +160 -0
  11. package/lib/agents/markdown/deep-agent.js +82 -0
  12. package/lib/agents/middleware/agent-actions.js +18 -0
  13. package/lib/agents/middleware/content-filter.js +191 -0
  14. package/lib/agents/middleware/hitl-edit-note-injector.js +18 -0
  15. package/lib/agents/middleware/hitl.js +20 -0
  16. package/lib/agents/middleware/index.js +19 -0
  17. package/lib/agents/middleware/patch-tool-calls.js +51 -0
  18. package/lib/agents/middleware/quota-enforcer.js +94 -0
  19. package/lib/agents/middleware/status-update.js +153 -0
  20. package/lib/agents/middleware/tool-selection.js +22 -0
  21. package/lib/agents/quota-enforcer-at-start.js +198 -0
  22. package/lib/agents/summarize-on-timeout.js +91 -0
  23. package/lib/compile.js +55 -0
  24. package/lib/index.cjs +1 -0
  25. package/lib/index.js +422 -0
  26. package/lib/models/aicore.js +441 -0
  27. package/lib/models/anthropic.js +77 -0
  28. package/lib/models/mock.js +88 -0
  29. package/lib/preview/chat.html +897 -0
  30. package/lib/preview/preview.js +46 -0
  31. package/lib/protocol/agent-card.js +297 -0
  32. package/lib/protocol/persistence/checkpoint-saver.js +320 -0
  33. package/lib/protocol/persistence/cleanup.js +84 -0
  34. package/lib/protocol/persistence/file-store.js +209 -0
  35. package/lib/protocol/persistence/push-notification-store.js +59 -0
  36. package/lib/protocol/persistence/task-store.js +47 -0
  37. package/lib/protocol/push-notification-sender.js +57 -0
  38. package/lib/sidecar.js +162 -0
  39. package/lib/telemetry/active-users.js +106 -0
  40. package/lib/telemetry/chat-tracing.js +364 -0
  41. package/lib/telemetry/metrics.js +85 -0
  42. package/lib/telemetry/mlflow.js +290 -0
  43. package/lib/telemetry/tool-tracing.js +164 -0
  44. package/lib/telemetry/tracing.js +150 -0
  45. package/lib/utils/inner-auth.js +33 -0
  46. package/lib/utils/markdown.js +199 -0
  47. package/lib/utils/message-handling.js +155 -0
  48. package/lib/utils/utils.js +168 -0
  49. package/package.json +225 -2
  50. package/srv/graph-cache.js +82 -0
  51. package/srv/handlers/graph-executor.js +1374 -0
  52. package/srv/handlers/index.js +178 -0
  53. package/srv/handlers/mcp-tools.js +161 -0
  54. package/srv/handlers/sub-agent-tools.js +316 -0
  55. package/srv/handlers/system-prompt.js +25 -0
  56. package/srv/handlers/tools.js +366 -0
  57. package/srv/langgraph-executor-srv.js +70 -0
  58. package/srv/push-notification-srv.js +149 -0
@@ -0,0 +1,84 @@
1
+ import cds from "@sap/cds"
2
+ import { ms4 } from "../../utils/utils.js"
3
+
4
+ const LOG = cds.log("agent")
5
+
6
+ const TASKS = "cap.agent.Tasks"
7
+
8
+ // Throttle: last cleanup timestamp per tenant & service
9
+ const lastDeletionTriggerMap = new Map()
10
+
11
+ /** Test-only: reset throttle map. */
12
+ export function _resetCleanupThrottle() {
13
+ lastDeletionTriggerMap.clear()
14
+ }
15
+
16
+ // TTL from cds.env.agents.retention., `false`/0 → disabled.
17
+ function resolveTtlMs() {
18
+ const cfg = cds.env.agents?.retention
19
+ if (cfg === false || cfg === 0) return 0
20
+ const value = cfg === true || cfg
21
+ if (typeof value === "number") return value
22
+ return ms4(String(value))
23
+ }
24
+
25
+ const MS_OF_A_DAY = ms4("1d")
26
+
27
+ export async function triggerCleanup(serviceName) {
28
+ const ttlMs = resolveTtlMs()
29
+ if (!ttlMs) {
30
+ LOG.debug(`cds.agents.retention is not configured. Skipping cleanup of old Tasks.`)
31
+ return
32
+ }
33
+ const tenant = cds.context?.tenant
34
+ if (!lastDeletionTriggerMap.has(tenant)) lastDeletionTriggerMap.set(tenant, new Map())
35
+ const serviceMap = lastDeletionTriggerMap.get(tenant)
36
+ const lastDeletionTriggered = serviceMap.get(serviceName)
37
+ if (lastDeletionTriggered > Date.now() - MS_OF_A_DAY) {
38
+ LOG.debug(
39
+ `Skip scheduling deletion of tasks for ${serviceName} because the last scheduled deletion was triggered within the last 24h.`,
40
+ )
41
+ return
42
+ }
43
+ const srv = cds.services[serviceName]
44
+ if (!srv) {
45
+ LOG.warn(`triggerCleanup: service "${serviceName}" not found in cds.services, skipping.`)
46
+ return
47
+ }
48
+ if (typeof srv.schedule !== "function") {
49
+ LOG.debug(`triggerCleanup: srv.schedule not available (CDS < 9). Skipping cleanup scheduling.`)
50
+ return
51
+ }
52
+ serviceMap.set(serviceName, Date.now())
53
+ const MAX_TIMEOUT = 2_147_483_647
54
+ const delay = Math.min(ttlMs + MS_OF_A_DAY, MAX_TIMEOUT)
55
+ await srv.schedule("cleanupTasks", {}).after(delay)
56
+ }
57
+
58
+ /**
59
+ * GC of expired tasks per service.
60
+ * Compositions cascade automatically: inputFiles, outputFiles, pushConfigs,
61
+ * checkpoints, checkpointWrites.
62
+ */
63
+ export async function cleanupExpiredTasks(serviceName) {
64
+ const ttlMs = resolveTtlMs()
65
+ if (!ttlMs) {
66
+ LOG.debug(`cds.agents.retention is not configured. Skipping cleanup of old Tasks.`)
67
+ return
68
+ }
69
+
70
+ const tenant = cds.context?.tenant
71
+ const now = Date.now()
72
+ const cutoff = new Date(now - ttlMs).toISOString()
73
+
74
+ const taskResult = await DELETE.from(TASKS).where({
75
+ modifiedAt: { "<": cutoff },
76
+ agentService: serviceName,
77
+ })
78
+ // affectedRows since cds10
79
+ const deletedTasks = typeof taskResult === "number" ? taskResult : taskResult?.affectedRows || 0
80
+
81
+ if (deletedTasks > 0) {
82
+ LOG.debug("Cleanup", { tenant, deletedTasks, cutoff })
83
+ }
84
+ }
@@ -0,0 +1,209 @@
1
+ import { Readable } from "node:stream"
2
+ import cds from "@sap/cds"
3
+
4
+ const LOG = cds.log("agents")
5
+
6
+ /**
7
+ * Resolve the user id used to scope input-file reads.
8
+ */
9
+ function resolveUserId(explicit) {
10
+ if (explicit) return explicit
11
+ return cds.context.user.id
12
+ }
13
+
14
+ /**
15
+ * Consume a Readable stream into a Buffer.
16
+ */
17
+ async function toBuffer(value) {
18
+ if (Buffer.isBuffer(value)) return value
19
+ if (value && typeof value.pipe === "function") {
20
+ return new Promise((resolve, reject) => {
21
+ const chunks = []
22
+ value.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
23
+ value.on("end", () => resolve(Buffer.concat(chunks)))
24
+ value.on("error", reject)
25
+ })
26
+ }
27
+ if (value == null) return Buffer.alloc(0)
28
+ return Buffer.from(value)
29
+ }
30
+
31
+ function inputFilesEntity() {
32
+ return cds.model.definitions["cap.agent.Tasks.inputFiles"]
33
+ }
34
+
35
+ function outputFilesEntity() {
36
+ return cds.model.definitions["cap.agent.Tasks.outputFiles"]
37
+ }
38
+
39
+ /**
40
+ * CDS-backed store for A2A file I/O using @cap-js/attachments composition children.
41
+ */
42
+ export class CdsFileStore {
43
+ async saveInputFile(taskId, name, mimeType, bytesBuffer) {
44
+ const InputFiles = inputFilesEntity()
45
+ LOG.debug("Files: save input", { taskId, name, bytes: bytesBuffer.length })
46
+ await INSERT.into(InputFiles).entries({
47
+ ID: cds.utils.uuid(),
48
+ up__taskId: taskId,
49
+ filename: name,
50
+ mimeType,
51
+ content: Readable.from([bytesBuffer]),
52
+ })
53
+ }
54
+
55
+ async getInputFile(contextId, name, userId) {
56
+ const InputFiles = inputFilesEntity()
57
+ const row = await SELECT.one
58
+ .from(InputFiles)
59
+ .columns("ID", "filename", "mimeType", "content")
60
+ .where({ "up_.contextId": contextId, "up_.createdBy": resolveUserId(userId), filename: name })
61
+ .orderBy([
62
+ { ref: ["up_", "createdAt"], sort: "desc" },
63
+ { ref: ["createdAt"], sort: "desc" },
64
+ ])
65
+
66
+ if (!row) {
67
+ LOG.debug("Files: get input miss", { contextId, name })
68
+ return null
69
+ }
70
+
71
+ const bytes = await toBuffer(row.content)
72
+ LOG.debug("Files: get input hit", { contextId, name, size: bytes.length })
73
+ return {
74
+ name: row.filename,
75
+ mimeType: row.mimeType,
76
+ bytes,
77
+ size: bytes.length,
78
+ }
79
+ }
80
+
81
+ async listInputFiles(contextId, userId) {
82
+ const InputFiles = inputFilesEntity()
83
+ const rows = await SELECT.from(InputFiles)
84
+ .columns(
85
+ "ID",
86
+ "filename",
87
+ "mimeType",
88
+ "createdAt",
89
+ { ref: ["up_", "createdAt"], as: "taskCreatedAt" },
90
+ { func: "length", args: [{ ref: ["content"] }], as: "size" },
91
+ )
92
+ .where({ "up_.contextId": contextId, "up_.createdBy": resolveUserId(userId) })
93
+ .orderBy([
94
+ { ref: ["up_", "createdAt"], sort: "desc" },
95
+ { ref: ["createdAt"], sort: "desc" },
96
+ ])
97
+
98
+ // JS dedupe by filename keeping latest (first in desc order)
99
+ const seen = new Map()
100
+ for (const row of rows) {
101
+ if (!seen.has(row.filename)) seen.set(row.filename, row)
102
+ }
103
+ return [...seen.values()].map((r) => ({
104
+ name: r.filename,
105
+ mimeType: r.mimeType,
106
+ size: r.size ?? 0,
107
+ }))
108
+ }
109
+
110
+ /**
111
+ * Delete all input files for a context.
112
+ */
113
+ async deleteInputFiles(contextId, opts = {}) {
114
+ const InputFiles = inputFilesEntity()
115
+ const where = { "up_.contextId": contextId }
116
+ if (!opts.allUsers) where["up_.createdBy"] = resolveUserId(opts.userId)
117
+ await DELETE.from(InputFiles).where(where)
118
+ LOG.debug("Files: deleted input files", { contextId, allUsers: !!opts.allUsers })
119
+ }
120
+
121
+ async saveOutputFile(taskId, name, mimeType, bytesBuffer) {
122
+ const OutputFiles = outputFilesEntity()
123
+ LOG.debug("Files: save output", { taskId, name, bytes: bytesBuffer.length })
124
+ const existing = await SELECT.one
125
+ .from(OutputFiles)
126
+ .columns("ID")
127
+ .where({ up__taskId: taskId, filename: name })
128
+ if (existing) {
129
+ await UPDATE(OutputFiles)
130
+ .set({ mimeType, content: Readable.from([bytesBuffer]) })
131
+ .where({ ID: existing.ID })
132
+ } else {
133
+ await INSERT.into(OutputFiles).entries({
134
+ ID: cds.utils.uuid(),
135
+ up__taskId: taskId,
136
+ filename: name,
137
+ mimeType,
138
+ content: Readable.from([bytesBuffer]),
139
+ })
140
+ }
141
+ }
142
+
143
+ async getOutputFile(taskId, name) {
144
+ const OutputFiles = outputFilesEntity()
145
+ const row = await SELECT.one
146
+ .from(OutputFiles)
147
+ .columns("ID", "filename", "mimeType", "content")
148
+ .where({ up__taskId: taskId, filename: name })
149
+ .orderBy([{ ref: ["createdAt"], sort: "desc" }])
150
+
151
+ if (!row) {
152
+ LOG.debug("Files: get output miss", { taskId, name })
153
+ return null
154
+ }
155
+
156
+ const bytes = await toBuffer(row.content)
157
+ return {
158
+ name: row.filename,
159
+ mimeType: row.mimeType,
160
+ bytes,
161
+ size: bytes.length,
162
+ }
163
+ }
164
+
165
+ async listOutputFiles(taskId) {
166
+ const OutputFiles = outputFilesEntity()
167
+ const rows = await SELECT.from(OutputFiles)
168
+ .columns("ID", "filename", "mimeType", "content")
169
+ .where({ up__taskId: taskId })
170
+
171
+ return Promise.all(
172
+ rows.map(async (row) => {
173
+ const bytes = await toBuffer(row.content)
174
+ return {
175
+ name: row.filename,
176
+ mimeType: row.mimeType,
177
+ bytes,
178
+ size: bytes.length,
179
+ }
180
+ }),
181
+ )
182
+ }
183
+
184
+ /**
185
+ * List output file metadata for a task without fetching content bytes.
186
+ */
187
+ async listOutputFilesMeta(taskId) {
188
+ const OutputFiles = outputFilesEntity()
189
+ const rows = await SELECT.from(OutputFiles)
190
+ .columns("ID", "filename", "mimeType", {
191
+ func: "length",
192
+ args: [{ ref: ["content"] }],
193
+ as: "size",
194
+ })
195
+ .where({ up__taskId: taskId })
196
+
197
+ return rows.map((r) => ({
198
+ name: r.filename,
199
+ mimeType: r.mimeType,
200
+ size: r.size ?? 0,
201
+ }))
202
+ }
203
+
204
+ async deleteOutputFiles(taskId) {
205
+ const OutputFiles = outputFilesEntity()
206
+ await DELETE.from(OutputFiles).where({ up__taskId: taskId })
207
+ LOG.debug("Files: deleted output files", { taskId })
208
+ }
209
+ }
@@ -0,0 +1,59 @@
1
+ import cds from "@sap/cds"
2
+ import { isAllowedDomain } from "../../utils/utils.js"
3
+
4
+ const LOG = cds.log("agents")
5
+
6
+ const CONFIGS = "cap.agent.PushNotificationConfigs"
7
+
8
+ /**
9
+ * CDS entity-backed store for A2A push notification configs.
10
+ *
11
+ * Implements the PushNotificationStore interface from @a2a-js/sdk/server:
12
+ * save(taskId: string, config: PushNotificationConfig): Promise<void>
13
+ * load(taskId: string): Promise<PushNotificationConfig[]>
14
+ * delete(taskId: string, configId?: string): Promise<void>
15
+ *
16
+ * REVISIT: Currently only stores URL (no auth). When authenticated push callbacks needed:
17
+ * - Persist authentication.schemes in DB (non-secret metadata)
18
+ * - Store token/credentials in SAP Credential Store via @sap-cloud-sdk
19
+ * - Reconstruct full PushNotificationConfig on load()
20
+ * - See index.cds REVISIT comment for full plan
21
+ */
22
+ export class CdsPushNotificationStore {
23
+ async save(taskId, config) {
24
+ const configId = config.id || taskId
25
+
26
+ // Validate callback URL against configured domain allowlist
27
+ const allowedDomains = cds.env.agents?.pushNotifications?.allowedDomains
28
+ if (!isAllowedDomain(config.url, allowedDomains)) {
29
+ const domains = Array.isArray(allowedDomains)
30
+ ? allowedDomains.join(", ")
31
+ : "(none configured)"
32
+ throw new Error(cds.i18n.messages.at("PUSH_URL_NOT_ALLOWED", [config.url, domains]))
33
+ }
34
+
35
+ const existing = await SELECT.one
36
+ .from(CONFIGS)
37
+ .where({ taskId, configId, createdBy: cds.context.user.id })
38
+ if (!existing) {
39
+ await INSERT.into(CONFIGS).entries({ taskId, configId, url: config.url })
40
+ } else {
41
+ await UPDATE.entity(CONFIGS)
42
+ .set({ url: config.url })
43
+ .where({ taskId, configId, createdBy: cds.context.user.id })
44
+ }
45
+ LOG.debug("Push notification config saved", { taskId, configId })
46
+ }
47
+
48
+ async load(taskId) {
49
+ const rows = await SELECT.from(CONFIGS).where({ taskId, createdBy: cds.context.user.id })
50
+ return rows.map((r) => ({ id: r.configId, url: r.url }))
51
+ }
52
+
53
+ async delete(taskId, configId) {
54
+ const where = { taskId, createdBy: cds.context.user.id }
55
+ if (configId) where.configId = configId
56
+ await DELETE.from(CONFIGS).where(where)
57
+ LOG.debug("Push notification config deleted", { taskId, configId: configId || "all" })
58
+ }
59
+ }
@@ -0,0 +1,47 @@
1
+ import cds from "@sap/cds"
2
+
3
+ const LOG = cds.log("agents")
4
+
5
+ const TASKS = "cap.agent.Tasks"
6
+
7
+ /**
8
+ * CDS entity-backed store for A2A tasks.
9
+ *
10
+ * Implements the TaskStore interface from @a2a-js/sdk/server:
11
+ * save(task: Task): Promise<void>
12
+ * load(taskId: string): Promise<Task | undefined>
13
+ */
14
+ export class CdsTaskStore {
15
+ async save(task) {
16
+ if (cds.context["agent.new.task"]) {
17
+ await INSERT.into(TASKS).entries({
18
+ taskId: task.id,
19
+ contextId: task.contextId,
20
+ state: task.status?.state,
21
+ data: JSON.stringify(task),
22
+ })
23
+ delete cds.context["agent.new.task"]
24
+ } else {
25
+ await UPDATE.entity(TASKS)
26
+ .where({ taskId: task.id, createdBy: cds.context.user.id })
27
+ .set({
28
+ contextId: task.contextId,
29
+ state: task.status?.state,
30
+ data: JSON.stringify(task),
31
+ })
32
+ }
33
+
34
+ LOG._trace && LOG.debug("Task saved", { taskId: task.id, state: task.status?.state })
35
+ }
36
+
37
+ async load(taskId) {
38
+ const row = await SELECT.one.from(TASKS).where({ taskId, createdBy: cds.context.user.id })
39
+ if (!row) {
40
+ LOG._trace && LOG.debug("Task not found", { taskId })
41
+ return undefined
42
+ }
43
+
44
+ LOG._trace && LOG.debug("Task loaded", { taskId, state: row.state })
45
+ return JSON.parse(row.data)
46
+ }
47
+ }
@@ -0,0 +1,57 @@
1
+ import cds from "@sap/cds"
2
+
3
+ const LOG = cds.log("agents")
4
+
5
+ /**
6
+ * CDS-based push notification sender with:
7
+ * - IAS App-to-App client_credentials token exchange (when identity binding present)
8
+ * - CAP outbox for reliable at-least-once delivery with retry
9
+ * - Fallback to plain HTTP POST (dev mode, no identity binding)
10
+ *
11
+ * Implements PushNotificationSender interface from @a2a-js/sdk/server:
12
+ * send(task: Task): Promise<void>
13
+ *
14
+ * Token exchange uses @sap-cloud-sdk/connectivity getIasToken() which handles:
15
+ * - mTLS or client_secret auth (based on identity binding credentials)
16
+ * - Per-tenant tokens (appTid)
17
+ * - Built-in LRU token cache
18
+ */
19
+ export class CdsPushNotificationSender {
20
+ constructor(pushNotificationStore) {
21
+ this._store = pushNotificationStore
22
+ this._outboxed = null
23
+ }
24
+
25
+ async send(task) {
26
+ if (!task?.id) return
27
+
28
+ const configs = await this._store.load(task.id)
29
+ if (!configs?.length) return
30
+
31
+ const outboxed = await this._getOutboxed()
32
+
33
+ // Emit one event per push config — independent retry per target
34
+ for (const config of configs) {
35
+ try {
36
+ // eslint-disable-next-line no-await-in-loop
37
+ await outboxed.emit("pushNotification", {
38
+ task,
39
+ url: config.url,
40
+ })
41
+ } catch (err) {
42
+ LOG.error("Failed to enqueue push notification", {
43
+ taskId: task.id,
44
+ url: config.url,
45
+ error: err.message,
46
+ })
47
+ }
48
+ }
49
+ }
50
+
51
+ async _getOutboxed() {
52
+ if (this._outboxed) return this._outboxed
53
+ const srv = await cds.connect.to("agent-push-notifications")
54
+ this._outboxed = cds.outboxed(srv)
55
+ return this._outboxed
56
+ }
57
+ }
package/lib/sidecar.js ADDED
@@ -0,0 +1,162 @@
1
+ import cds from "@sap/cds"
2
+ import A2AProtocolAdapter, { authContext } from "./index.js"
3
+ import { slugified } from "./utils/markdown.js"
4
+ import registerDefaultAgentHandlers from "../srv/handlers/index.js"
5
+
6
+ const LOG = cds.log("a2a")
7
+
8
+ /**
9
+ * Create an A2A agent router for a CDS service (local or remote).
10
+ *
11
+ * This is the programmatic API for sidecar mode. While the protocol adapter
12
+ * factory is called automatically for local @agent services during cds.serve(),
13
+ * remote services obtained via cds.connect.to() need this explicit API.
14
+ *
15
+ * The returned Express router can be mounted on any Express app.
16
+ *
17
+ * @param {cds.Service} srv - A CDS service instance (local or remote)
18
+ * @param {object} [options] - Options passed to A2AProtocolAdapter
19
+ * @param {string} [options.path] - Override the default mount path
20
+ * @returns {express.Router|null} Express router or null if service is incompatible
21
+ *
22
+ * @example
23
+ * const { createA2AAgent } = require('@cap-js/a2a/lib/sidecar')
24
+ * const remoteSrv = await cds.connect.to('CatalogService')
25
+ * const router = createA2AAgent(remoteSrv)
26
+ * app.use('/a2a/catalog', router)
27
+ */
28
+ function createA2AAgent(srv, options = {}) {
29
+ return A2AProtocolAdapter(srv, { ...options, isSidecar: true })
30
+ }
31
+
32
+ /**
33
+ * Bootstrap A2A sidecar mode.
34
+ *
35
+ * Auto-detects all @agent-annotated services from the loaded model,
36
+ * connects to them, and mounts A2A agent routers on the Express app.
37
+ *
38
+ * Activated via the "a2a-sidecar" CDS profile, which loads A2ASidecarBootstrap
39
+ * whose init() calls this function.
40
+ */
41
+ async function bootstrapSidecar() {
42
+ const app = cds.app
43
+
44
+ if (!app) {
45
+ LOG.error("Cannot bootstrap agent sidecar: no Express app available")
46
+ return
47
+ }
48
+
49
+ const requires = cds.env.requires || {}
50
+ const isNode = cds.env.profiles?.includes("node")
51
+
52
+ const agentServices = Object.entries(cds.model?.definitions || {})
53
+ .filter(([, def]) => def.kind === "service" && def["@agent"])
54
+ .map(([cdsName]) => {
55
+ // Use slugified name for Node apps (/hcql/catalog), CDS name for Java apps (/hcql/CatalogService)
56
+ const name = isNode ? slugified(cdsName) : cdsName
57
+ return { name, cdsName, config: requires[name] || { kind: "hcql" } }
58
+ })
59
+
60
+ if (agentServices.length === 0) {
61
+ LOG.info("No remote @agent services found for A2A sidecar")
62
+ return
63
+ }
64
+
65
+ LOG.info(`Bootstrapping agent sidecar for ${isNode ? "node" : "java"} app`, {
66
+ services: agentServices.map((s) => s.cdsName),
67
+ })
68
+
69
+ // Connect to each remote service and mount A2A routers
70
+ await Promise.all(
71
+ agentServices.map(async ({ name, cdsName, config }) => {
72
+ try {
73
+ // Connect to the service
74
+ const srv = await cds.connect.to(name)
75
+
76
+ // When connected via slugified name (Node apps), restore the CDS name and model
77
+ // so tools and agent card generation work against the correct definition.
78
+ if (isNode) {
79
+ srv.name = cdsName
80
+ srv.model = cds.model
81
+ delete srv.definition
82
+ delete srv.entities
83
+ delete srv.actions
84
+ delete srv.operations
85
+ }
86
+
87
+ // Register default agent handlers (buildGraph, buildTools, buildModel, etc.)
88
+ // normally done by the cds "serving" hook, which only fires for ApplicationServices.
89
+ registerDefaultAgentHandlers(srv)
90
+
91
+ // Attach "before":
92
+ // Forward the incoming Authorization header to all outbound calls on this service.
93
+ // We use our own AsyncLocalStorage (authContext) because the A2A SDK fires the
94
+ // executor asynchronously, which severs the CDS http context (cds.context.http).
95
+ srv.before("*", (req) => {
96
+ const authHeader = authContext.getStore()?.authHeader
97
+ if (authHeader) req.headers = { ...req.headers, authorization: authHeader }
98
+ })
99
+
100
+ // Apply the HCQL actions patch only for Java main apps.
101
+ // Java's HCQL adapter requires the envelope format {"event":"<action>","args":[...]},
102
+ // but Node.js HCQL accepts the standard CDS remote format directly.
103
+ // Enable via cds.env.agent._javaHcqlCompat (set automatically by --profile java).
104
+ if ((!config.kind || config.kind === "hcql") && !isNode && cds.env.agent?._javaHcqlCompat) {
105
+ _patchHcqlActions(srv)
106
+ }
107
+
108
+ // Add service with its sluggified name
109
+ const path = config.a2aPath || `/a2a/${slugified(cdsName)}`
110
+ const router = createA2AAgent(srv, { path })
111
+
112
+ if (router) {
113
+ app.use(path, cds.middlewares.before, router)
114
+ LOG.info("Mounted agent", { service: cdsName, path })
115
+ } else {
116
+ LOG.warn("Could not create agent for service", { service: cdsName })
117
+ }
118
+ } catch (err) {
119
+ LOG.error("Failed to create agent for remote service", {
120
+ service: cdsName,
121
+ error: err.message,
122
+ })
123
+ }
124
+ }),
125
+ )
126
+ }
127
+
128
+ export { createA2AAgent, bootstrapSidecar }
129
+
130
+ // _patchHcqlActions patches actions and functions to use the HCQL envelope format
131
+ // {"event":"<action>","args":[{...}]} posted to the service root.
132
+ // Necessary because @sap/cds/libx/_runtime/remote/Service.js (v9.9.1) sends:
133
+ // - actions as REST-style POST /<action> with data as body
134
+ // - functions as OData-style GET /<function>(param=@param)?@param=value
135
+ // The Java HCQL adapter rejects both formats.
136
+ // This patch may be removed once the Java HCQL accepts the format sent by Service.js.
137
+ //
138
+ // No recursion risk: srv.on(opName, ...) only fires for the action event (e.g. "submitOrder"),
139
+ // while srv.send({ method: "POST", path: "/" }) dispatches as "POST /" — a different event key.
140
+ function _patchHcqlActions(srv) {
141
+ const model = srv.model || cds.model
142
+ const actions = Object.entries(model.definitions)
143
+ .filter(
144
+ ([n, def]) =>
145
+ n.startsWith(srv.name + ".") && (def.kind === "action" || def.kind === "function"),
146
+ )
147
+ .map(([n]) => n.slice(srv.name.length + 1))
148
+
149
+ srv.prepend(() => {
150
+ for (const opName of actions) {
151
+ srv.on(opName, async (req) => {
152
+ const result = await srv.send({
153
+ method: "POST",
154
+ path: "/",
155
+ headers: req.headers,
156
+ data: { event: opName, args: [req.data] },
157
+ })
158
+ req.reply(result)
159
+ })
160
+ }
161
+ })
162
+ }