@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.
- package/LICENSE +201 -0
- package/README.md +154 -0
- package/_i18n/messages.properties +31 -0
- package/cds-plugin.js +140 -0
- package/index.cds +116 -0
- package/index.js +0 -0
- package/lib/agents/markdown/backends/mime-utils.js +37 -0
- package/lib/agents/markdown/backends/outputs-backend.js +156 -0
- package/lib/agents/markdown/backends/readonly-backend.js +48 -0
- package/lib/agents/markdown/backends/uploads-backend.js +160 -0
- package/lib/agents/markdown/deep-agent.js +82 -0
- package/lib/agents/middleware/agent-actions.js +18 -0
- package/lib/agents/middleware/content-filter.js +191 -0
- package/lib/agents/middleware/hitl-edit-note-injector.js +18 -0
- package/lib/agents/middleware/hitl.js +20 -0
- package/lib/agents/middleware/index.js +19 -0
- package/lib/agents/middleware/patch-tool-calls.js +51 -0
- package/lib/agents/middleware/quota-enforcer.js +94 -0
- package/lib/agents/middleware/status-update.js +153 -0
- package/lib/agents/middleware/tool-selection.js +22 -0
- package/lib/agents/quota-enforcer-at-start.js +198 -0
- package/lib/agents/summarize-on-timeout.js +91 -0
- package/lib/compile.js +55 -0
- package/lib/index.cjs +1 -0
- package/lib/index.js +422 -0
- package/lib/models/aicore.js +441 -0
- package/lib/models/anthropic.js +77 -0
- package/lib/models/mock.js +88 -0
- package/lib/preview/chat.html +897 -0
- package/lib/preview/preview.js +46 -0
- package/lib/protocol/agent-card.js +297 -0
- package/lib/protocol/persistence/checkpoint-saver.js +320 -0
- package/lib/protocol/persistence/cleanup.js +84 -0
- package/lib/protocol/persistence/file-store.js +209 -0
- package/lib/protocol/persistence/push-notification-store.js +59 -0
- package/lib/protocol/persistence/task-store.js +47 -0
- package/lib/protocol/push-notification-sender.js +57 -0
- package/lib/sidecar.js +162 -0
- package/lib/telemetry/active-users.js +106 -0
- package/lib/telemetry/chat-tracing.js +364 -0
- package/lib/telemetry/metrics.js +85 -0
- package/lib/telemetry/mlflow.js +290 -0
- package/lib/telemetry/tool-tracing.js +164 -0
- package/lib/telemetry/tracing.js +150 -0
- package/lib/utils/inner-auth.js +33 -0
- package/lib/utils/markdown.js +199 -0
- package/lib/utils/message-handling.js +155 -0
- package/lib/utils/utils.js +168 -0
- package/package.json +225 -2
- package/srv/graph-cache.js +82 -0
- package/srv/handlers/graph-executor.js +1374 -0
- package/srv/handlers/index.js +178 -0
- package/srv/handlers/mcp-tools.js +161 -0
- package/srv/handlers/sub-agent-tools.js +316 -0
- package/srv/handlers/system-prompt.js +25 -0
- package/srv/handlers/tools.js +366 -0
- package/srv/langgraph-executor-srv.js +70 -0
- 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
|
+
}
|