@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
@@ -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
+ }
@@ -0,0 +1,106 @@
1
+ import cds from "@sap/cds"
2
+ import * as metrics from "./metrics.js"
3
+ import { ms4 } from "../utils/utils.js"
4
+
5
+ const LOG = cds.log("agents")
6
+ const TASKS = "cap.agent.Tasks"
7
+
8
+ /** Cached active users data — updated by computeActiveUsers(), reported by gauge callback */
9
+ let _activeUsersData = []
10
+
11
+ /**
12
+ * Compute active users from Tasks table (last 24h rolling window).
13
+ * Groups by agentService, counts distinct createdBy.
14
+ *
15
+ * In multi-tenant deployments, iterates all subscribed tenants via
16
+ * cds.xt.DeploymentService and queries each tenant's HDI container separately.
17
+ * In single-tenant mode, queries the current DB directly.
18
+ */
19
+ export async function computeActiveUsers() {
20
+ _activeUsersData = []
21
+
22
+ let tenants
23
+ try {
24
+ const ds = await cds.connect.to("cds.xt.DeploymentService")
25
+ tenants = await ds.getTenants()
26
+ } catch {
27
+ // Not multi-tenant (dev/SQLite) — run in current context
28
+ tenants = null
29
+ }
30
+
31
+ if (!tenants) {
32
+ await _computeForTenant(cds.context?.tenant || "anonymous")
33
+ } else {
34
+ for (const tenant of tenants) {
35
+ // eslint-disable-next-line no-await-in-loop
36
+ await cds.spawn({ tenant, user: cds.User.privileged }, () => _computeForTenant(tenant))
37
+ }
38
+ }
39
+
40
+ LOG.debug("active_users computed", {
41
+ tenants: tenants?.length || 1,
42
+ services: _activeUsersData.length,
43
+ })
44
+ }
45
+
46
+ async function _computeForTenant(tenant) {
47
+ const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
48
+ const results = await SELECT.from(TASKS)
49
+ .columns("agentService", "count(distinct createdBy) as userCount")
50
+ .where({ createdAt: { ">=": since } })
51
+ .groupBy("agentService")
52
+
53
+ for (const r of results) {
54
+ _activeUsersData.push({
55
+ agentService: r.agentService,
56
+ userCount: r.userCount,
57
+ tenant,
58
+ })
59
+ }
60
+ }
61
+
62
+ /** ObservableGauge callback — reports cached values on each OTel collection */
63
+ export function observeActiveUsers(result) {
64
+ for (const { agentService, userCount, tenant } of _activeUsersData) {
65
+ result.observe(userCount, {
66
+ "sap.tenantId": tenant,
67
+ "agent.service": agentService,
68
+ })
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Setup the active_users ObservableGauge + schedule periodic computation.
74
+ * When cds.env.agents.activeUsersInterval is 0, only the gauge is registered (no automatic scheduling).
75
+ * Apps can still trigger computation manually via executor.emit("computeActiveUsers").
76
+ */
77
+ export function setupActiveUsersMetric() {
78
+ const interval = cds.env.agents?.activeUsersInterval
79
+
80
+ // Always register the gauge
81
+ metrics.createActiveUsersGauge(observeActiveUsers)
82
+
83
+ // Disabled scheduling when interval is 0 or "0"
84
+ if (interval === 0 || interval === "0") {
85
+ LOG.debug("active_users automatic scheduling disabled (activeUsersInterval = 0)")
86
+ return
87
+ }
88
+
89
+ // Schedule periodic computation
90
+ const every = interval || "24h"
91
+ const ms = ms4(every)
92
+ const spawned = cds.spawn({ every: ms }, async () => {
93
+ try {
94
+ await computeActiveUsers()
95
+ } catch (err) {
96
+ LOG.error("active_users computation failed", { error: err.message })
97
+ }
98
+ })
99
+
100
+ // Clean up timer on shutdown to prevent post-teardown errors in test environments
101
+ if (spawned?.timer) {
102
+ cds.on("shutdown", () => clearInterval(spawned.timer))
103
+ }
104
+
105
+ LOG.debug("active_users metric scheduled", { every })
106
+ }