@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
package/lib/index.js ADDED
@@ -0,0 +1,422 @@
1
+ import cds from "@sap/cds"
2
+ import express from "express"
3
+ import { AsyncLocalStorage } from "node:async_hooks"
4
+
5
+ import { generateAgentCard } from "./protocol/agent-card.js"
6
+ import { resolveAgentDir, resolveAgentCardPath } from "./utils/markdown.js"
7
+ import { CdsTaskStore } from "./protocol/persistence/task-store.js"
8
+ import { CdsPushNotificationStore } from "./protocol/persistence/push-notification-store.js"
9
+ import { CdsPushNotificationSender } from "./protocol/push-notification-sender.js"
10
+ import preview from "./preview/preview.js"
11
+ import { previewAuthChallenge } from "./utils/inner-auth.js"
12
+ import { short, audit } from "./utils/utils.js"
13
+ import { partsToText } from "./utils/message-handling.js"
14
+ import * as metrics from "./telemetry/metrics.js"
15
+
16
+ const LOG = cds.log("agents")
17
+
18
+ // SSE wire-format helpers, mirroring @a2a-js/sdk's sse_utils so the adapter
19
+ // streams message/stream responses identically to the SDK's reference
20
+ // Express handler. Inlined (not imported) because the SDK does not export
21
+ // these from its public entry points.
22
+ const SSE_HEADERS = {
23
+ "Content-Type": "text/event-stream",
24
+ "Cache-Control": "no-cache",
25
+ Connection: "keep-alive",
26
+ "X-Accel-Buffering": "no", // disable nginx buffering
27
+ }
28
+ function formatSSEEvent(event) {
29
+ return `data: ${JSON.stringify(event)}\n\n`
30
+ }
31
+ function formatSSEErrorEvent(error) {
32
+ return `event: error\ndata: ${JSON.stringify(error)}\n\n`
33
+ }
34
+
35
+ // Carries the incoming Authorization header across the async boundary created
36
+ // by the A2A SDK's fire-and-forget executor pattern.
37
+ export const authContext = new AsyncLocalStorage()
38
+
39
+ export default function A2AProtocolAdapter(srv, options = {}) {
40
+ // Skipping if this is not a service
41
+ if (srv.definition?.kind !== "service") return null
42
+
43
+ if (!(srv instanceof cds.ApplicationService) && !options.isSidecar) {
44
+ LOG.debug("Skipping service", { service: srv.name })
45
+ return null
46
+ }
47
+
48
+ const router = express.Router()
49
+
50
+ // Enforce @requires / @restrict — mirrors HttpAdapter.authorize (cds/lib/srv/protocols/http.js)
51
+ // Uses cds.context.model (not srv.definition) to support feature-toggled annotations.
52
+ // All checks computed per-request (env may change after boot; feature toggles vary per tenant).
53
+ router.use((req, res, next) => {
54
+ const def = cds.context?.model?.definitions?.[srv.name] || srv.definition
55
+ const requires = def?.["@requires"]
56
+ const restrict = def?.["@restrict"]
57
+
58
+ let declaredRoles
59
+ if (requires != null) {
60
+ // @requires present — normalize to array
61
+ declaredRoles = Array.isArray(requires) ? requires : [requires]
62
+ } else if (restrict != null) {
63
+ // If no clause carries `to`, fall through to the env fallback below
64
+ // matches CDS core: dev = public, prod = authenticated-user
65
+ declaredRoles = restrict
66
+ .map((r) => r.to)
67
+ .flat()
68
+ .filter(Boolean)
69
+ }
70
+
71
+ const roles = declaredRoles?.length
72
+ ? declaredRoles
73
+ : process.env.NODE_ENV === "production" &&
74
+ cds.env.requires?.auth?.restrict_all_services !== false && ["authenticated-user"]
75
+
76
+ if (!roles) return next()
77
+
78
+ const user = cds.context?.user
79
+ if (user && roles.some((role) => user.has(role))) return next()
80
+
81
+ const anonymous = !user || user._is_anonymous
82
+ const status = anonymous ? 401 : 403
83
+ const code = anonymous ? -32001 : -32003
84
+ const msgKey = anonymous ? "UNAUTHORIZED" : "FORBIDDEN"
85
+
86
+ if (req.method === "POST") {
87
+ const body = JSON.stringify({
88
+ jsonrpc: "2.0",
89
+ id: req.body?.id ?? null,
90
+ error: { code, message: cds.i18n.messages.at(msgKey) },
91
+ })
92
+ res.writeHead(status, { "Content-Type": "application/json" })
93
+ return res.end(body)
94
+ }
95
+ if (status === 401) {
96
+ res.writeHead(401, { "WWW-Authenticate": 'Basic realm="Users"' })
97
+ } else {
98
+ res.writeHead(status)
99
+ }
100
+ return res.end()
101
+ })
102
+
103
+ const resolved = {
104
+ agentDir: resolveAgentDir(srv),
105
+ agentCardPath: resolveAgentCardPath(srv),
106
+ }
107
+
108
+ const agentCard = generateAgentCard(srv, options, resolved)
109
+
110
+ // If service is behind a proxy, @Core.Links with rel='via' provides the proxy URL
111
+ const viaLink = srv.definition["@Core.Links"]?.find((l) => l.rel === "via")
112
+ const proxyUrl = viaLink?.href
113
+
114
+ // Add "Agent Card" and "Preview" links to the CDS index page
115
+ const linkProviders = (srv.$linkProviders ??= [])
116
+ linkProviders.push((entity, endpoint) => {
117
+ if (entity || endpoint?.kind !== "agent") return undefined
118
+ return {
119
+ href: `${endpoint.path}/.well-known/agent-card.json`,
120
+ name: "Agent Card",
121
+ title: "A2A Agent Card",
122
+ }
123
+ })
124
+
125
+ if (!cds.env.profiles?.includes("production")) {
126
+ linkProviders.push((entity, endpoint) => {
127
+ if (entity || endpoint?.kind !== "agent") return undefined
128
+ return {
129
+ href: `${endpoint.path}/preview`,
130
+ name: "Preview",
131
+ title: "Preview in chat UI",
132
+ }
133
+ })
134
+ }
135
+
136
+ // Lazy-load SDK, connect to executor, and create request handler.
137
+ // Executor resolved via CDS service pattern — apps customize via
138
+ // buildModel/buildTools/buildSystemPrompt/buildGraph event handlers.
139
+ let _requestHandler = null
140
+ let _executor = null
141
+ async function getRequestHandler() {
142
+ if (_requestHandler) return _requestHandler
143
+
144
+ const { DefaultRequestHandler } = await import("@a2a-js/sdk/server")
145
+ const { LangGraphExecutor } = await import("../srv/langgraph-executor-srv.js")
146
+
147
+ const executor = LangGraphExecutor.for(srv)
148
+ _executor = executor
149
+ const pushEnabled = cds.env.agents?.pushNotifications !== false
150
+ const pushStore = pushEnabled ? new CdsPushNotificationStore() : undefined
151
+ const pushSender = pushStore ? new CdsPushNotificationSender(pushStore) : undefined
152
+ _requestHandler = new DefaultRequestHandler(
153
+ agentCard,
154
+ new CdsTaskStore(),
155
+ executor,
156
+ undefined, // eventBusManager — use SDK default
157
+ pushStore,
158
+ pushSender,
159
+ )
160
+ return _requestHandler
161
+ }
162
+
163
+ router.get("/.well-known/agent-card.json", (req, res) => {
164
+ const url = proxyUrl || `${req.protocol}://${req.get("host")}${req.baseUrl}`
165
+ // Regenerate agent card when feature toggles are active (annotations may differ)
166
+ let card
167
+ if (cds.context?.features && Object.keys(cds.context.features).length > 0) {
168
+ const featureResolved = {
169
+ agentDir: resolveAgentDir(srv),
170
+ agentCardPath: resolveAgentCardPath(srv),
171
+ }
172
+ card = { ...generateAgentCard(srv, options, featureResolved), url }
173
+ } else {
174
+ card = { ...agentCard, url }
175
+ }
176
+ if (card.supportedInterfaces) {
177
+ card.supportedInterfaces = card.supportedInterfaces.map((iface) => ({
178
+ ...iface,
179
+ url,
180
+ }))
181
+ }
182
+ res.json(card)
183
+ })
184
+
185
+ if (!cds.env.profiles?.includes("production")) {
186
+ router.use("/preview", previewAuthChallenge(srv), preview(agentCard.name || srv.name))
187
+ }
188
+
189
+ const inputCap = cds.env.agents?.fileIO?.maxInputFileSizeBytes || 0
190
+ const envelopeLimit = Math.max(5 * 1024 * 1024, inputCap * 2 + 1024 * 1024)
191
+ router.post("/", express.json({ limit: envelopeLimit }), async (req, res) => {
192
+ const t0 = Date.now()
193
+ const method = req.body?.method
194
+ const taskId = req.body?.params?.message?.taskId || req.body?.params?.id || ""
195
+ const contextId = req.body?.params?.message?.contextId || ""
196
+ const requestAttrs = { ...metrics.attrs(srv), "agent.method": method || "unknown" }
197
+
198
+ metrics.requestsTotal.add(1, requestAttrs)
199
+
200
+ const userText =
201
+ method === "message/send" || method === "message/stream"
202
+ ? partsToText(req.body?.params?.message?.parts)
203
+ : undefined
204
+
205
+ // A2A correlation: set task/context IDs on active OTel span + rename.
206
+ const span = metrics.getActiveSpan()
207
+ if (span) {
208
+ span.updateName(`POST /a2a/${srv.name}/`)
209
+ if (cds.context) {
210
+ cds.context["_mlflow.rootSpan"] = span
211
+ }
212
+ }
213
+
214
+ if (method === "message/send" || method === "message/stream") {
215
+ const text = userText
216
+
217
+ const maxLen = cds.env.agents?.pool?.maxIncomingMessageLength
218
+ if (maxLen > 0 && text?.length > maxLen) {
219
+ LOG.warn("message too long", {
220
+ conversation: short(contextId),
221
+ service: srv.name,
222
+ })
223
+ metrics.errorsTotal.add(1, { ...requestAttrs, "agent.error.code": 400 })
224
+
225
+ // Audit: security event for quota breach
226
+ audit("IncomingMessageExceedingLength", {
227
+ data: {
228
+ service: srv.name,
229
+ user: cds.context?.user?.id,
230
+ message: text?.length > 5000 ? text.slice(0, 5000) + "…" : text,
231
+ forwardedIp: req.headers?.["x-forwarded-for"],
232
+ },
233
+ ip: req.ip,
234
+ })
235
+
236
+ if (!res.headersSent) {
237
+ res.status(400).json({
238
+ jsonrpc: "2.0",
239
+ // REVISIT: i18n
240
+ error: { code: -32029, message: cds.i18n.messages.at("MESSAGE_TOO_LONG", [maxLen]) },
241
+ id: req.body?.id || null,
242
+ })
243
+ }
244
+ return
245
+ }
246
+
247
+ const truncated = text?.length > 80 ? text.slice(0, 80) + "..." : text
248
+ LOG.info("request", {
249
+ conversation: short(contextId),
250
+ service: srv.name,
251
+ method,
252
+ text: truncated,
253
+ })
254
+ } else {
255
+ LOG.debug("request", { conversation: short(contextId), service: srv.name, method })
256
+ }
257
+
258
+ try {
259
+ // Quota enforcement — return 429 before SDK processes the request
260
+ // Skip for resume messages (have taskId — already past quota gate)
261
+ if ((method === "message/send" || method === "message/stream") && !taskId) {
262
+ const { default: quotaEnforcerAtStart } =
263
+ await import("./agents/quota-enforcer-at-start.js")
264
+ const quotaResult = await quotaEnforcerAtStart()
265
+ if (quotaResult) {
266
+ LOG.warn("quota exceeded", {
267
+ conversation: short(contextId),
268
+ service: srv.name,
269
+ reason: quotaResult.message,
270
+ })
271
+ metrics.errorsTotal.add(1, { ...requestAttrs, "agent.error.code": 429 })
272
+
273
+ // Audit: security event for quota breach
274
+ audit("QuotaExceeded", {
275
+ data: {
276
+ service: srv.name,
277
+ user: cds.context?.user?.id,
278
+ reason: quotaResult.message,
279
+ forwardedIp: req.headers?.["x-forwarded-for"],
280
+ },
281
+ ip: req.ip,
282
+ })
283
+
284
+ if (!res.headersSent) {
285
+ res
286
+ .status(429)
287
+ .set("Retry-After", String(quotaResult.retryAfter))
288
+ .json({
289
+ jsonrpc: "2.0",
290
+ error: { code: -32029, message: quotaResult.message },
291
+ id: req.body?.id || null,
292
+ })
293
+ }
294
+ return
295
+ }
296
+ // Flag to toggle that the task must be inserted on save and not updated
297
+ cds.context["agent.new.task"] = true
298
+ }
299
+
300
+ const requestHandler = await getRequestHandler()
301
+ const { JsonRpcTransportHandler } = await import("@a2a-js/sdk/server")
302
+ const transport = new JsonRpcTransportHandler(requestHandler)
303
+
304
+ // Abort execution on client disconnect (covers both stream and unary).
305
+ // For new tasks, taskId is empty at registration time — use a mutable
306
+ // ref that gets populated from the result or first SSE event.
307
+ let resolvedTaskId = taskId
308
+ const abortOnClose = () => {
309
+ // Skip when the response ended normally; only fire on real disconnect.
310
+ if (res.writableEnded) return
311
+ if (resolvedTaskId && _executor?.abort) {
312
+ _executor.abort(resolvedTaskId)
313
+ }
314
+ }
315
+ res.on("close", abortOnClose)
316
+
317
+ const handleRequest = () => transport.handle(req.body)
318
+ // In sidecar mode the executor fires asynchronously, severing the CDS http
319
+ // context. Run inside authContext so the auth header is forwarded to outbound
320
+ // calls on the remote Java service.
321
+ const result = await (options.isSidecar
322
+ ? authContext.run({ authHeader: req.headers?.authorization }, handleRequest)
323
+ : handleRequest())
324
+
325
+ // For unary responses, capture taskId from result (covers new message/send)
326
+ if (!resolvedTaskId && result?.result?.id) {
327
+ resolvedTaskId = result.result.id
328
+ }
329
+
330
+ // JsonRpcTransportHandler.handle() returns an AsyncGenerator for
331
+ // method=message/stream and a plain JSON-RPC envelope for unary methods
332
+ // (message/send, tasks/get, etc.). Passing the AsyncGenerator straight
333
+ // to res.json() serialises it as `{}` — clients waiting for SSE then
334
+ // see an empty body and time out.
335
+ //
336
+ // Mirror the SDK's reference Express handler (jsonRpcHandler in
337
+ // @a2a-js/sdk/dist/server/express/index.cjs lines ~358-401): detect
338
+ // the AsyncGenerator via Symbol.asyncIterator and stream events as
339
+ // text/event-stream. Non-stream results continue through res.json.
340
+ if (typeof result?.[Symbol.asyncIterator] === "function") {
341
+ Object.entries(SSE_HEADERS).forEach(([k, v]) => res.setHeader(k, v))
342
+ res.flushHeaders()
343
+ // On SSE, also release the async generator on disconnect. The
344
+ // abortOnClose above already handles executor.abort(); this listener
345
+ // adds generator cleanup without duplicating the abort logic.
346
+ res.on("close", () => {
347
+ if (!res.writableEnded) result.return?.()
348
+ })
349
+ try {
350
+ for await (const event of result) {
351
+ // Capture taskId from first event for new tasks
352
+ if (!resolvedTaskId) {
353
+ resolvedTaskId = event?.result?.id || event?.result?.taskId || ""
354
+ }
355
+ res.write(formatSSEEvent(event))
356
+ }
357
+ } catch (streamError) {
358
+ const errMsg = String(streamError?.message ?? streamError) || "Streaming error."
359
+ LOG.error("SSE stream failed", { conversation: short(contextId), error: errMsg })
360
+ metrics.errorsTotal.add(1, { ...requestAttrs, "agent.error.code": -32603 })
361
+ // Headers are always flushed before this catch block is reachable,
362
+ // so the only viable recovery is an SSE error frame.
363
+ if (!res.writableEnded) {
364
+ res.write(
365
+ formatSSEErrorEvent({
366
+ jsonrpc: "2.0",
367
+ id: req.body?.id || null,
368
+ error: { code: -32603, message: errMsg },
369
+ }),
370
+ )
371
+ }
372
+ } finally {
373
+ if (!res.writableEnded) res.end()
374
+ }
375
+ } else {
376
+ res.json(result)
377
+ }
378
+ } catch (err) {
379
+ LOG.error("request failed", { conversation: short(contextId), method, error: err.message })
380
+ LOG.debug("request failed stack", { conversation: short(contextId), stack: err.stack })
381
+
382
+ metrics.errorsTotal.add(1, { ...requestAttrs, "agent.error.code": -32603 })
383
+
384
+ // In production, don't reveal internal error details to clients (CDS pattern)
385
+ const PROD = process.env.NODE_ENV === "production" || process.env.CDS_ENV === "prod"
386
+ const message =
387
+ PROD && err.$sanitize !== false
388
+ ? cds.i18n.messages.at(500) || "Internal Server Error"
389
+ : "Internal error: " + err.message
390
+
391
+ if (!res.headersSent) {
392
+ res.status(500).json({
393
+ jsonrpc: "2.0",
394
+ error: { code: -32603, message },
395
+ id: req.body?.id || null,
396
+ })
397
+ }
398
+ } finally {
399
+ // Duration only meaningful for non-streaming requests;
400
+ // for message/stream the response is delivered asynchronously after this point
401
+ if (method !== "message/stream") {
402
+ metrics.requestDuration.record(Date.now() - t0, requestAttrs)
403
+ }
404
+ }
405
+ })
406
+
407
+ router.all("/", (_req, res) => {
408
+ res
409
+ .set("Allow", "POST")
410
+ .status(405)
411
+ .json({
412
+ jsonrpc: "2.0",
413
+ error: { code: -32000, message: "Method not allowed." },
414
+ id: null,
415
+ })
416
+ })
417
+
418
+ LOG.debug("Adapter initialized", { service: srv.name })
419
+
420
+ router.router = router
421
+ return router
422
+ }