@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,366 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
import { DynamicStructuredTool, tool } from "@langchain/core/tools"
|
|
3
|
+
import z from "zod"
|
|
4
|
+
import {
|
|
5
|
+
createGenericReadToolDefinition,
|
|
6
|
+
createDescribeToolDefinition,
|
|
7
|
+
createCallActionToolDefinition,
|
|
8
|
+
createPerActionToolDefinition,
|
|
9
|
+
executeGenericReadTool,
|
|
10
|
+
executeDescribe,
|
|
11
|
+
executeCallActionTool,
|
|
12
|
+
executePerActionTool,
|
|
13
|
+
} from "@cap-js/mcp/lib/tools.js"
|
|
14
|
+
import { getFilteredEntities, getFilteredActions } from "../../lib/utils/utils.js"
|
|
15
|
+
import { isTextMime } from "../../lib/agents/markdown/backends/mime-utils.js"
|
|
16
|
+
import { checkAuthorization } from "@cap-js/mcp/lib/auth.js"
|
|
17
|
+
|
|
18
|
+
const LOG = cds.log("agents")
|
|
19
|
+
|
|
20
|
+
const unwrap = (result) => {
|
|
21
|
+
const text = result.content?.[0]?.text ?? ""
|
|
22
|
+
return [text, { isError: result.isError === true }]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// we need the checkAuthorization result many times in the same cds context
|
|
26
|
+
function cachedAuth(srv) {
|
|
27
|
+
if (!cds.context) return checkAuthorization(srv)
|
|
28
|
+
const cache = (cds.context.__agentAuth ??= {})
|
|
29
|
+
return (cache[srv.name] ??= checkAuthorization(srv))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class GenericReadTool extends DynamicStructuredTool {
|
|
33
|
+
constructor(srv, entities) {
|
|
34
|
+
const def = createGenericReadToolDefinition(Object.keys(entities), srv.name, "")
|
|
35
|
+
super({
|
|
36
|
+
name: def.name,
|
|
37
|
+
description: def.description,
|
|
38
|
+
schema: def.inputSchema,
|
|
39
|
+
responseFormat: "content_and_artifact",
|
|
40
|
+
func: async (args) => {
|
|
41
|
+
return unwrap(await executeGenericReadTool(srv, entities, args, { log: LOG }))
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
this.srv = srv
|
|
45
|
+
this._allEntities = entities
|
|
46
|
+
delete this.description
|
|
47
|
+
delete this.schema
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get description() {
|
|
51
|
+
const { entities, error } = cachedAuth(this.srv)
|
|
52
|
+
if (error) return "Not available due to " + error?.reason
|
|
53
|
+
return createGenericReadToolDefinition(Object.keys(entities), this.srv.name, "").description
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get schema() {
|
|
57
|
+
const { entities, error } = cachedAuth(this.srv)
|
|
58
|
+
if (error) return z.undefined
|
|
59
|
+
return createGenericReadToolDefinition(Object.keys(entities), this.srv.name, "").inputSchema
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
isAllowed() {
|
|
63
|
+
const { entities, error } = cachedAuth(this.srv)
|
|
64
|
+
return !error && Object.keys(entities).length > 0
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class DescribeTool extends DynamicStructuredTool {
|
|
69
|
+
constructor(srv, entities, actions) {
|
|
70
|
+
const def = createDescribeToolDefinition(
|
|
71
|
+
Object.keys(entities),
|
|
72
|
+
Object.keys(actions),
|
|
73
|
+
srv.name,
|
|
74
|
+
"",
|
|
75
|
+
)
|
|
76
|
+
super({
|
|
77
|
+
name: def.name,
|
|
78
|
+
description: def.description,
|
|
79
|
+
schema: def.inputSchema,
|
|
80
|
+
responseFormat: "content_and_artifact",
|
|
81
|
+
func: async (args) => {
|
|
82
|
+
return unwrap(await executeDescribe(srv, entities, actions, args, { log: LOG }))
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
this.srv = srv
|
|
86
|
+
this._allEntities = entities
|
|
87
|
+
this._allActions = actions
|
|
88
|
+
delete this.description
|
|
89
|
+
delete this.schema
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
get description() {
|
|
93
|
+
const { entities, actions, error } = cachedAuth(this.srv)
|
|
94
|
+
if (error) return "Not available due to " + error?.reason
|
|
95
|
+
return createDescribeToolDefinition(
|
|
96
|
+
Object.keys(entities),
|
|
97
|
+
Object.keys(actions),
|
|
98
|
+
this.srv.name,
|
|
99
|
+
"",
|
|
100
|
+
).description
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
get schema() {
|
|
104
|
+
const { entities, actions, error } = cachedAuth(this.srv)
|
|
105
|
+
if (error) return z.undefined
|
|
106
|
+
return createDescribeToolDefinition(
|
|
107
|
+
Object.keys(entities),
|
|
108
|
+
Object.keys(actions),
|
|
109
|
+
this.srv.name,
|
|
110
|
+
"",
|
|
111
|
+
).inputSchema
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
isAllowed() {
|
|
115
|
+
const { entities, actions, error } = cachedAuth(this.srv)
|
|
116
|
+
return !error && (Object.keys(entities).length > 0 || Object.keys(actions).length > 0)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
class PerActionTool extends DynamicStructuredTool {
|
|
121
|
+
constructor(srv, actionName, action) {
|
|
122
|
+
const def = createPerActionToolDefinition(actionName, action, srv.name, srv.model, "")
|
|
123
|
+
super({
|
|
124
|
+
name: def.name,
|
|
125
|
+
description: def.description,
|
|
126
|
+
schema: def.inputSchema,
|
|
127
|
+
responseFormat: "content_and_artifact",
|
|
128
|
+
func: async (args) => {
|
|
129
|
+
return unwrap(await executePerActionTool(srv, actionName, action, args, { log: LOG }))
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
this.srv = srv
|
|
133
|
+
this.actionName = actionName
|
|
134
|
+
this.action = action
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
isAllowed() {
|
|
138
|
+
const { actions, error } = cachedAuth(this.srv)
|
|
139
|
+
return !error && Object.prototype.hasOwnProperty.call(actions, this.actionName)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
class CallActionTool extends DynamicStructuredTool {
|
|
144
|
+
constructor(srv, actions) {
|
|
145
|
+
const def = createCallActionToolDefinition(Object.keys(actions), srv.name, "")
|
|
146
|
+
super({
|
|
147
|
+
name: def.name,
|
|
148
|
+
description: def.description,
|
|
149
|
+
schema: def.inputSchema,
|
|
150
|
+
responseFormat: "content_and_artifact",
|
|
151
|
+
func: async (args) => {
|
|
152
|
+
return unwrap(await executeCallActionTool(srv, actions, args, { log: LOG }))
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
this.srv = srv
|
|
156
|
+
this._allActions = actions
|
|
157
|
+
delete this.description
|
|
158
|
+
delete this.schema
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
get description() {
|
|
162
|
+
const { actions, error } = cachedAuth(this.srv)
|
|
163
|
+
if (error) return "Not available due to " + error?.reason
|
|
164
|
+
return createCallActionToolDefinition(Object.keys(actions), this.srv.name, "").description
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
get schema() {
|
|
168
|
+
const { actions, error } = cachedAuth(this.srv)
|
|
169
|
+
if (error) return z.undefined
|
|
170
|
+
return createCallActionToolDefinition(Object.keys(actions), this.srv.name, "").inputSchema
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
isAllowed() {
|
|
174
|
+
const { actions, error } = cachedAuth(this.srv)
|
|
175
|
+
return !error && Object.keys(actions).length > 0
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Reuses tool definitions and execution logic from @cap-js/mcp.
|
|
181
|
+
*
|
|
182
|
+
* @param {object} srv - CDS ApplicationService
|
|
183
|
+
*/
|
|
184
|
+
export function generateTools(srv) {
|
|
185
|
+
const entities = getFilteredEntities(srv)
|
|
186
|
+
const actions = getFilteredActions(srv)
|
|
187
|
+
|
|
188
|
+
const tools = []
|
|
189
|
+
|
|
190
|
+
// Query tool — one tool for reading all entities
|
|
191
|
+
const entityNames = Object.keys(entities)
|
|
192
|
+
if (entityNames.length > 0) {
|
|
193
|
+
tools.push(new GenericReadTool(srv, entities))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Describe tool — introspect service model
|
|
197
|
+
const actionNames = Object.keys(actions)
|
|
198
|
+
if (entityNames.length > 0 || actionNames.length > 0) {
|
|
199
|
+
tools.push(new DescribeTool(srv, entities, actions))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Action/function tools — per-action (default) or combined call action
|
|
203
|
+
const usePerActionTools = cds.env.agents?.per_action_tool !== false
|
|
204
|
+
if (actionNames.length > 0) {
|
|
205
|
+
if (usePerActionTools) {
|
|
206
|
+
for (const [name, action] of Object.entries(actions)) {
|
|
207
|
+
tools.push(new PerActionTool(srv, name, action))
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
tools.push(new CallActionTool(srv, actions))
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// File tools — only when fileIO is enabled
|
|
215
|
+
// emit_file_part: stateless protocol emitter; safe to tools.push once at startup.
|
|
216
|
+
// read_file: per-request (needs contextId) — created on-demand via createReadFileTool().
|
|
217
|
+
if (cds.env.agents?.fileIO?.enabled) {
|
|
218
|
+
tools.push(createEmitFilePartTool())
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return tools
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── File tools ────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Render a byte count as a human-readable string ("123 B" / "12.3 KB" / "1.2 MB").
|
|
228
|
+
* Exported so callers (e.g. GraphExecutor's file manifest) format sizes identically.
|
|
229
|
+
*/
|
|
230
|
+
export function formatFileSize(bytes) {
|
|
231
|
+
if (bytes < 1024) return `${bytes} B`
|
|
232
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
233
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Sanitize a user-supplied filename so it is safe to use as a stable key,
|
|
238
|
+
* a /uploads/<name> path component, and an artifactId fragment.
|
|
239
|
+
*
|
|
240
|
+
* - Strips any directory components (path.basename) — defeats `../../etc/passwd`
|
|
241
|
+
* tricks that would otherwise produce confusing artifactIds and DB keys.
|
|
242
|
+
* - Replaces characters that are not letters, digits, dot, dash, underscore, or
|
|
243
|
+
* space with `_`. Leaves Unicode letters (CJK/etc.) intact.
|
|
244
|
+
* - Collapses leading dots so the name cannot start with `.` (avoids hidden-file
|
|
245
|
+
* surprises and `..` artifacts).
|
|
246
|
+
* - Falls back to "unnamed" for empty input.
|
|
247
|
+
*/
|
|
248
|
+
export function sanitizeFilename(raw) {
|
|
249
|
+
const name = String(raw ?? "").trim()
|
|
250
|
+
if (!name) return "unnamed"
|
|
251
|
+
// path.basename equivalent without pulling in node:path: drop everything up to
|
|
252
|
+
// the last forward- or back-slash. Also drop any pure ".."/"." segments.
|
|
253
|
+
const base = name.replace(/^.*[\\/]/, "")
|
|
254
|
+
const cleaned = base.replace(/[^\p{L}\p{N}._\-\s]/gu, "_").replace(/^\.+/, "")
|
|
255
|
+
return cleaned || "unnamed"
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Create a tool that emits a file as part of the A2A response.
|
|
260
|
+
* Pure protocol emitter — caller provides the bytes, no generation, no placeholders.
|
|
261
|
+
* The executor's toolResults collection loop parses `kind:'file'` JSON from this tool.
|
|
262
|
+
*/
|
|
263
|
+
export function createEmitFilePartTool() {
|
|
264
|
+
return tool(
|
|
265
|
+
async ({ filename, mimeType, content, encoding = "utf-8" }) => {
|
|
266
|
+
const bytes =
|
|
267
|
+
encoding === "base64" ? content : Buffer.from(content, "utf-8").toString("base64")
|
|
268
|
+
const decodedSize =
|
|
269
|
+
encoding === "base64"
|
|
270
|
+
? Buffer.byteLength(content, "base64")
|
|
271
|
+
: Buffer.byteLength(content, "utf-8")
|
|
272
|
+
LOG.info("emit_file_part", { filename, mimeType, encoding, bytes: decodedSize })
|
|
273
|
+
return JSON.stringify({ kind: "file", file: { name: filename, mimeType, bytes } })
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
name: "emit_file_part",
|
|
277
|
+
description:
|
|
278
|
+
'Emit a file as part of the A2A response. For text formats (CSV, JSON, plain text) provide raw text content. For binary formats provide base64-encoded content and set encoding to "base64". You are responsible for obtaining or generating the file content before calling this tool.',
|
|
279
|
+
schema: z.object({
|
|
280
|
+
filename: z.string().describe("Filename with extension, e.g. report.csv"),
|
|
281
|
+
mimeType: z.string().describe("MIME type, e.g. text/csv, application/json, image/png"),
|
|
282
|
+
content: z
|
|
283
|
+
.string()
|
|
284
|
+
.describe("File content: raw text for text formats, base64 string for binary formats"),
|
|
285
|
+
encoding: z.enum(["utf-8", "base64"]).optional().default("utf-8"),
|
|
286
|
+
}),
|
|
287
|
+
},
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Create a read_file tool scoped to the current conversation.
|
|
293
|
+
* For the default LangGraph path only — deepagents tools.pushs its own read_file
|
|
294
|
+
* tool via FilesystemMiddleware, which routes through UploadsBackend.
|
|
295
|
+
*
|
|
296
|
+
* When contextId is omitted, the tool resolves it from cds.context at invocation
|
|
297
|
+
* time (set by GraphExecutor before graph.invoke). This allows a single tool
|
|
298
|
+
* instance to be reused across requests in createAgent-based graphs.
|
|
299
|
+
*
|
|
300
|
+
* @param {import('../../lib/protocol/persistence/file-store.js').CdsFileStore} fileStore
|
|
301
|
+
* @param {string} [contextId] - If omitted, read from cds.context["agent.context.id"]
|
|
302
|
+
* @param {string} [userId] - If omitted, read from cds.context.user.id
|
|
303
|
+
*/
|
|
304
|
+
export function createReadFileTool(fileStore, contextId, userId) {
|
|
305
|
+
return tool(
|
|
306
|
+
async ({ path: filePath }) => {
|
|
307
|
+
try {
|
|
308
|
+
const resolvedContextId = contextId || cds.context?.["agent.context.id"]
|
|
309
|
+
const resolvedUserId = userId || cds.context?.user?.id
|
|
310
|
+
const name = filePath.replace(/^\/uploads\//, "")
|
|
311
|
+
if (!fileStore) {
|
|
312
|
+
return `read_file is not available in this context (no file store configured).`
|
|
313
|
+
}
|
|
314
|
+
const file = await fileStore.getInputFile(resolvedContextId, name, resolvedUserId)
|
|
315
|
+
if (!file) {
|
|
316
|
+
const available =
|
|
317
|
+
(await fileStore.listInputFiles(resolvedContextId, resolvedUserId))
|
|
318
|
+
.map((f) => `/uploads/${f.name}`)
|
|
319
|
+
.join(", ") || "none"
|
|
320
|
+
LOG.info("read_file (not found)", {
|
|
321
|
+
contextId: resolvedContextId,
|
|
322
|
+
path: filePath,
|
|
323
|
+
available,
|
|
324
|
+
})
|
|
325
|
+
return `File not found: ${filePath}. Available files: ${available}`
|
|
326
|
+
}
|
|
327
|
+
if (file.mimeType?.startsWith("image/")) {
|
|
328
|
+
LOG.info("read_file (image, not returned as text)", {
|
|
329
|
+
contextId: resolvedContextId,
|
|
330
|
+
path: filePath,
|
|
331
|
+
mimeType: file.mimeType,
|
|
332
|
+
size: file.size,
|
|
333
|
+
})
|
|
334
|
+
return `"${name}" is an image (${formatFileSize(file.size)}). Use an image analysis tool to process it.`
|
|
335
|
+
}
|
|
336
|
+
if (!isTextMime(file.mimeType)) {
|
|
337
|
+
LOG.info("read_file (binary, not returned as text)", {
|
|
338
|
+
contextId: resolvedContextId,
|
|
339
|
+
path: filePath,
|
|
340
|
+
mimeType: file.mimeType,
|
|
341
|
+
size: file.size,
|
|
342
|
+
})
|
|
343
|
+
return `"${name}" is a binary file (${file.mimeType}, ${formatFileSize(file.size)}). Cannot be read as text.`
|
|
344
|
+
}
|
|
345
|
+
LOG.info("read_file", {
|
|
346
|
+
contextId: resolvedContextId,
|
|
347
|
+
path: filePath,
|
|
348
|
+
mimeType: file.mimeType,
|
|
349
|
+
size: file.size,
|
|
350
|
+
})
|
|
351
|
+
return file.bytes.toString("utf-8")
|
|
352
|
+
} catch (err) {
|
|
353
|
+
LOG.error("read_file failed", { filePath, error: err.message })
|
|
354
|
+
return `Error reading file: ${err.message}`
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
name: "read_file",
|
|
359
|
+
description:
|
|
360
|
+
"Read the contents of an uploaded file. Use the /uploads/<filename> path from the file manifest. Returns file content for text-based formats.",
|
|
361
|
+
schema: z.object({
|
|
362
|
+
path: z.string().describe("File path, e.g. /uploads/report.csv"),
|
|
363
|
+
}),
|
|
364
|
+
},
|
|
365
|
+
)
|
|
366
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
import { GraphExecutor } from "./handlers/graph-executor.js"
|
|
3
|
+
import { GraphCache, hashFeatures } from "./graph-cache.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* LangGraph-based executor service for @agent annotated services.
|
|
7
|
+
*
|
|
8
|
+
* Dispatches `buildGraph` event on the target ApplicationService.
|
|
9
|
+
* Graphs cached per feature vector (cds.context.features) in a FIFO cache.
|
|
10
|
+
* Lazy init: graph built on first request (features not available at startup).
|
|
11
|
+
*/
|
|
12
|
+
export class LangGraphExecutor {
|
|
13
|
+
static _instance
|
|
14
|
+
_caches = new Map()
|
|
15
|
+
|
|
16
|
+
static for(srv) {
|
|
17
|
+
this._instance ??= new LangGraphExecutor()
|
|
18
|
+
return this._instance.for(srv)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for(srv) {
|
|
22
|
+
return {
|
|
23
|
+
execute: (requestContext, eventBus) => this._execute(srv, requestContext, eventBus),
|
|
24
|
+
cancelTask: async (taskId, eventBus) => {
|
|
25
|
+
const executor = await this._ensureExecutor(srv)
|
|
26
|
+
return executor.cancelTask(taskId, eventBus)
|
|
27
|
+
},
|
|
28
|
+
abort: async (taskId) => {
|
|
29
|
+
const executor = await this._ensureExecutor(srv)
|
|
30
|
+
return executor.abort(taskId)
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_getCache(srv) {
|
|
36
|
+
if (!this._caches.has(srv.name)) {
|
|
37
|
+
this._caches.set(srv.name, new GraphCache())
|
|
38
|
+
}
|
|
39
|
+
return this._caches.get(srv.name)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async _ensureExecutor(srv) {
|
|
43
|
+
const features = cds.context?.features || {}
|
|
44
|
+
const key = hashFeatures(features)
|
|
45
|
+
const cache = this._getCache(srv)
|
|
46
|
+
|
|
47
|
+
return cache.getOrBuild(key, () => this._buildExecutor(srv))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async _buildExecutor(srv) {
|
|
51
|
+
const result = await srv.send("buildGraph", {})
|
|
52
|
+
|
|
53
|
+
if (result && typeof result.invoke === "function") {
|
|
54
|
+
return new GraphExecutor(result, srv)
|
|
55
|
+
}
|
|
56
|
+
if (result && typeof result.execute === "function") {
|
|
57
|
+
return result
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
throw new Error(
|
|
61
|
+
`buildGraph handler for service "${srv.name}" must return a compiled LangGraph (with invoke()) ` +
|
|
62
|
+
`or a GraphExecutor (with execute()). Got: ${typeof result}`,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async _execute(srv, requestContext, eventBus) {
|
|
67
|
+
const executor = await this._ensureExecutor(srv)
|
|
68
|
+
return executor.execute(requestContext, eventBus)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
import { isAllowedDomain } from "../lib/utils/utils.js"
|
|
3
|
+
|
|
4
|
+
const LOG = cds.log("agents")
|
|
5
|
+
|
|
6
|
+
// Hop limit for callback redirects. Real webhooks rarely need more than a
|
|
7
|
+
// single hop (protocol upgrade or CDN edge routing); 3 leaves headroom
|
|
8
|
+
// without opening a redirect loop as an amplifier.
|
|
9
|
+
const MAX_REDIRECTS = 3
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* CDS service that handles push notification delivery.
|
|
13
|
+
* Registered as `agent-push-notifications` and wrapped in CAP outbox
|
|
14
|
+
* by CdsPushNotificationSender for at-least-once delivery with retry.
|
|
15
|
+
*/
|
|
16
|
+
export default class AgentPushNotificationService extends cds.Service {
|
|
17
|
+
async init() {
|
|
18
|
+
this.on("pushNotification", async (msg) => {
|
|
19
|
+
const { task, url } = msg.data
|
|
20
|
+
await pushNotification(url, task)
|
|
21
|
+
})
|
|
22
|
+
return super.init()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function pushNotification(url, task) {
|
|
27
|
+
const headers = { "Content-Type": "application/json" }
|
|
28
|
+
|
|
29
|
+
const iasResource = cds.env.agents?.pushNotifications?.ias?.resource
|
|
30
|
+
if (iasResource) {
|
|
31
|
+
const bearerToken = await fetchIasToken(iasResource)
|
|
32
|
+
if (bearerToken) {
|
|
33
|
+
headers["Authorization"] = `Bearer ${bearerToken}`
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const allowedDomains = cds.env.agents?.pushNotifications?.allowedDomains
|
|
38
|
+
const res = await fetchWithRedirects(
|
|
39
|
+
url,
|
|
40
|
+
{
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers,
|
|
43
|
+
body: JSON.stringify(task),
|
|
44
|
+
signal: AbortSignal.timeout(10_000),
|
|
45
|
+
},
|
|
46
|
+
allowedDomains,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
// Throw so outbox retries on transient failures (5xx, network)
|
|
51
|
+
const body = await res.text().catch(() => "")
|
|
52
|
+
const msg = `Push notification failed: HTTP ${res.status} ${res.statusText}`
|
|
53
|
+
if (res.status >= 500 || res.status === 429) {
|
|
54
|
+
throw cds.error({ message: msg, response: body }) // retryable
|
|
55
|
+
}
|
|
56
|
+
// 4xx = permanent failure, log and don't retry
|
|
57
|
+
LOG.error(msg, { taskId: task.id, url, response: body })
|
|
58
|
+
} else {
|
|
59
|
+
LOG.debug("Push notification delivered", { taskId: task.id, url, status: res.status })
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* POST to `url` with manual redirect handling. Every hop (including the
|
|
65
|
+
* initial URL) is re-validated against `allowedDomains` so a compromised or
|
|
66
|
+
* open-redirect endpoint on an allow-listed domain cannot exfiltrate the
|
|
67
|
+
* request body + Authorization header to an off-allowlist host.
|
|
68
|
+
*
|
|
69
|
+
* Policy:
|
|
70
|
+
* - Non-3xx responses are returned as-is.
|
|
71
|
+
* - 307 / 308 preserve method + body and follow if the new URL is allowlisted.
|
|
72
|
+
* - 301 / 302 / 303 would change POST semantics (spec allows GET downgrade)
|
|
73
|
+
* so they are refused - legitimate push webhooks never rely on that.
|
|
74
|
+
* - The redirect chain is capped at MAX_REDIRECTS. Absolute and relative
|
|
75
|
+
* Location values are both resolved against the previous URL.
|
|
76
|
+
*/
|
|
77
|
+
async function fetchWithRedirects(url, options, allowedDomains, maxRedirects = MAX_REDIRECTS) {
|
|
78
|
+
let currentUrl = url
|
|
79
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
80
|
+
if (!isAllowedDomain(currentUrl, allowedDomains)) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Push callback URL not on allowlist: ${currentUrl}` +
|
|
83
|
+
(hop > 0 ? ` (reached via ${hop} redirect${hop === 1 ? "" : "s"})` : ""),
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Never let fetch follow silently - we validate each hop explicitly.
|
|
88
|
+
// Sequential await is required
|
|
89
|
+
// eslint-disable-next-line no-await-in-loop
|
|
90
|
+
const res = await fetch(currentUrl, { ...options, redirect: "manual" })
|
|
91
|
+
|
|
92
|
+
if (res.status < 300 || res.status >= 400) {
|
|
93
|
+
return res
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 3xx: only 307 / 308 preserve method + body. 301 / 302 / 303 semantically
|
|
97
|
+
// downgrade POST to GET - a push callback that relies on that is misconfigured.
|
|
98
|
+
if (res.status !== 307 && res.status !== 308) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Push callback redirect ${res.status} would change method or drop body; refusing`,
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (hop === maxRedirects) {
|
|
105
|
+
throw new Error(`Push callback exceeded ${maxRedirects} redirects`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const location = res.headers.get("location")
|
|
109
|
+
if (!location) {
|
|
110
|
+
// 3xx without Location - nothing to follow, return as-is
|
|
111
|
+
return res
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Resolve relative Locations against the previous URL.
|
|
115
|
+
currentUrl = new URL(location, currentUrl).toString()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function fetchIasToken(resource) {
|
|
120
|
+
let getIasToken
|
|
121
|
+
try {
|
|
122
|
+
;({ getIasToken } = await import("@sap-cloud-sdk/connectivity"))
|
|
123
|
+
} catch {
|
|
124
|
+
LOG.debug("@sap-cloud-sdk/connectivity not available, skipping IAS token")
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const tenant = cds.context?.tenant
|
|
129
|
+
const options = {
|
|
130
|
+
resource: { name: resource },
|
|
131
|
+
...(tenant && { appTid: tenant }),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const result = await getIasToken(options)
|
|
136
|
+
return result?.access_token || result?.token || result
|
|
137
|
+
} catch (err) {
|
|
138
|
+
// No identity binding → expected in dev/test
|
|
139
|
+
if (
|
|
140
|
+
err.message?.includes("identity") ||
|
|
141
|
+
err.message?.includes("service binding") ||
|
|
142
|
+
err.message?.includes("not found")
|
|
143
|
+
) {
|
|
144
|
+
LOG.debug("No identity service binding, push without auth", { resource })
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
throw err
|
|
148
|
+
}
|
|
149
|
+
}
|