@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.
- package/LICENSE +201 -0
- package/README.md +155 -0
- package/_i18n/messages.properties +31 -0
- package/cds-plugin.js +140 -0
- package/index.cds +105 -0
- package/index.js +0 -0
- package/lib/agents/markdown/backends/mime-utils.js +37 -0
- package/lib/agents/markdown/backends/outputs-backend.js +152 -0
- package/lib/agents/markdown/backends/uploads-backend.js +143 -0
- package/lib/agents/markdown/deep-agent.js +93 -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 +875 -0
- package/lib/preview/preview.js +46 -0
- package/lib/protocol/agent-card.js +297 -0
- package/lib/protocol/persistence/checkpoint-saver.js +317 -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 +342 -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 +1369 -0
- package/srv/handlers/index.js +173 -0
- package/srv/handlers/mcp-tools.js +159 -0
- package/srv/handlers/sub-agent-tools.js +314 -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
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
|
+
}
|