@cap-js/agents 0.9.1 → 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/README.md CHANGED
@@ -1,5 +1,3 @@
1
- [![REUSE status](https://api.reuse.software/badge/github.com/cap-js/agents)](https://api.reuse.software/info/github.com/cap-js/agents)
2
-
3
1
  # SAP Cloud Application Programming Model, agent development plugin for Node.js
4
2
 
5
3
  ## About this project
@@ -54,7 +52,7 @@ Both approaches start from a CDS service annotated with `@agent` (as shown in [R
54
52
 
55
53
  ### Agentify Existing CAP Services
56
54
 
57
- With `@agent` alone, the plugin auto-generates tools from the service's entities and actions, creates a ReAct agent loop, and serves it as a remote agent — no code required.
55
+ With `@agent` alone, the plugin auto-generates tools from the service's entities and actions, creates a [ReAct agent](https://arxiv.org/abs/2210.03629) loop, and serves it as a remote agent — no code required.
58
56
 
59
57
  ```cds
60
58
  @agent
@@ -135,6 +133,7 @@ The following capabilities are experimental and documented separately. Their pub
135
133
  - [Configuration](.docs/configuration.md) — using multiple models, global and per-service settings, file I/O, and push notifications
136
134
  - [Quota Enforcement](.docs/quota.md) — configurable rate limits and resource quotas
137
135
  - [Audit Logging](.docs/audit-logging.md) — immutable audit trail of agent decisions and tool usage
136
+ - [Data Privacy](.docs/data-privacy.md) — deletion of message history
138
137
  - [Telemetry](.docs/telemetry.md) — OpenTelemetry metrics, tracing, and MLflow export
139
138
  - [Content Filter](.docs/content-filter.md) — SAP AI Core content filtering and prompt injection shielding
140
139
 
package/index.cds CHANGED
@@ -50,12 +50,23 @@ entity Tasks : managed {
50
50
  * Files written by agent via /outputs/ path for this task.
51
51
  */
52
52
  outputFiles : Composition of many Attachments;
53
+
54
+ /** LangGraph checkpoints created by this task. Cascade-deleted. */
55
+ checkpoints : Composition of many Checkpoints
56
+ on checkpoints.task_id = taskId;
57
+
58
+ /** LangGraph checkpoint writes tied to this task. Cascade-deleted. */
59
+ checkpointWrites : Composition of many CheckpointWrites
60
+ on checkpointWrites.task_id = taskId;
53
61
  }
54
62
 
55
63
  entity Checkpoints : managed {
56
64
  key thread_id : String;
57
65
  key checkpoint_ns : String default '';
58
66
  key checkpoint_id : String;
67
+ task_id : String;
68
+ task : Association to one Tasks
69
+ on task.taskId = task_id;
59
70
  parent_checkpoint_id : String;
60
71
  parent : Association to one Checkpoints
61
72
  on parent.checkpoint_id = parent_checkpoint_id;
@@ -9,6 +9,7 @@
9
9
  *
10
10
  */
11
11
 
12
+ import { getConfig } from "@langchain/langgraph"
12
13
  import { isTextMime, globToRegex } from "./mime-utils.js"
13
14
 
14
15
  function inferMimeType(name) {
@@ -38,11 +39,14 @@ function inferMimeType(name) {
38
39
  }
39
40
 
40
41
  export class OutputsBackend {
41
- constructor(taskId, fileStore) {
42
- this.taskId = taskId
42
+ constructor(fileStore) {
43
43
  this.fileStore = fileStore
44
44
  }
45
45
 
46
+ _taskId() {
47
+ return getConfig()?.configurable?._taskId || ""
48
+ }
49
+
46
50
  // CompositeBackend strips the route prefix (/outputs/) before delegating,
47
51
  // so paths arrive here as either "/name" (stripped) or "/outputs/name" (direct).
48
52
  // _name() handles both forms.
@@ -54,19 +58,19 @@ export class OutputsBackend {
54
58
  const name = this._name(filePath)
55
59
  const mimeType = options.mimeType || inferMimeType(name)
56
60
  const buf = typeof content === "string" ? Buffer.from(content, "utf-8") : Buffer.from(content)
57
- await this.fileStore.saveOutputFile(this.taskId, name, mimeType, buf)
61
+ await this.fileStore.saveOutputFile(this._taskId(), name, mimeType, buf)
58
62
  return { success: true }
59
63
  }
60
64
 
61
65
  async exists(filePath) {
62
66
  const name = this._name(filePath)
63
- const files = await this.fileStore.listOutputFilesMeta(this.taskId)
67
+ const files = await this.fileStore.listOutputFilesMeta(this._taskId())
64
68
  return files.some((f) => f.name === name)
65
69
  }
66
70
 
67
71
  async stat(filePath) {
68
72
  const name = this._name(filePath)
69
- const files = await this.fileStore.listOutputFilesMeta(this.taskId)
73
+ const files = await this.fileStore.listOutputFilesMeta(this._taskId())
70
74
  const file = files.find((f) => f.name === name)
71
75
  if (!file) return null
72
76
  return { name: file.name, mimeType: file.mimeType, size: file.size }
@@ -75,12 +79,12 @@ export class OutputsBackend {
75
79
  // CompositeBackend re-prepends the route prefix to paths returned here,
76
80
  // so return bare "/<name>" paths (without /outputs/) to avoid double-prefix.
77
81
  async list(_dirPath) {
78
- const files = await this.fileStore.listOutputFilesMeta(this.taskId)
82
+ const files = await this.fileStore.listOutputFilesMeta(this._taskId())
79
83
  return files.map((f) => `/${f.name}`)
80
84
  }
81
85
 
82
86
  async ls(_path) {
83
- const files = await this.fileStore.listOutputFilesMeta(this.taskId)
87
+ const files = await this.fileStore.listOutputFilesMeta(this._taskId())
84
88
  return {
85
89
  files: files.map((f) => ({
86
90
  path: `/${f.name}`,
@@ -93,7 +97,7 @@ export class OutputsBackend {
93
97
 
94
98
  async glob(pattern, _path) {
95
99
  const re = globToRegex(pattern)
96
- const files = await this.fileStore.listOutputFilesMeta(this.taskId)
100
+ const files = await this.fileStore.listOutputFilesMeta(this._taskId())
97
101
  return {
98
102
  files: files
99
103
  .filter((f) => re.test(f.name))
@@ -103,7 +107,7 @@ export class OutputsBackend {
103
107
 
104
108
  async grep(pattern, _path, glob) {
105
109
  const reGlob = glob ? globToRegex(glob) : null
106
- const files = await this.fileStore.listOutputFiles(this.taskId)
110
+ const files = await this.fileStore.listOutputFiles(this._taskId())
107
111
  const matches = []
108
112
  for (const f of files) {
109
113
  if (reGlob && !reGlob.test(f.name)) continue
@@ -120,9 +124,9 @@ export class OutputsBackend {
120
124
 
121
125
  async read(filePath, offset = 0, limit = 100) {
122
126
  const name = this._name(filePath)
123
- const file = await this.fileStore.getOutputFile(this.taskId, name)
127
+ const file = await this.fileStore.getOutputFile(this._taskId(), name)
124
128
  if (!file) {
125
- const files = await this.fileStore.listOutputFilesMeta(this.taskId)
129
+ const files = await this.fileStore.listOutputFilesMeta(this._taskId())
126
130
  const available = files.map((f) => `/outputs/${f.name}`).join(", ") || "none"
127
131
  return { error: `File not found: ${filePath}. Available: ${available}` }
128
132
  }
@@ -141,7 +145,7 @@ export class OutputsBackend {
141
145
  // for the rationale. Currently unused by our shipped code paths; do not delete.
142
146
  async readRaw(filePath) {
143
147
  const name = this._name(filePath)
144
- const file = await this.fileStore.getOutputFile(this.taskId, name)
148
+ const file = await this.fileStore.getOutputFile(this._taskId(), name)
145
149
  if (!file) return { error: `File not found: ${filePath}` }
146
150
  return { bytes: file.bytes, mimeType: file.mimeType, size: file.size }
147
151
  }
@@ -0,0 +1,48 @@
1
+ import { FilesystemBackend } from "deepagents"
2
+
3
+ export class ReadonlyBackend {
4
+ constructor(options) {
5
+ // explicitly keep this internal (no class inheritance) so we do not expose write operations
6
+ this._fsBackend = new FilesystemBackend(options)
7
+ }
8
+
9
+ ls(path) {
10
+ return this._fsBackend.ls(path)
11
+ }
12
+
13
+ read(filePath, offset, limit) {
14
+ return this._fsBackend.read(filePath, offset, limit)
15
+ }
16
+
17
+ readRaw(filePath) {
18
+ return this._fsBackend.readRaw(filePath)
19
+ }
20
+
21
+ grep(pattern, path, glob, maxCount) {
22
+ return this._fsBackend.grep(pattern, path, glob, maxCount)
23
+ }
24
+
25
+ glob(pattern, path) {
26
+ return this._fsBackend.glob(pattern, path)
27
+ }
28
+
29
+ downloadFiles(paths) {
30
+ return this._fsBackend.downloadFiles(paths)
31
+ }
32
+
33
+ write() {
34
+ return { error: "read-only" }
35
+ }
36
+
37
+ edit() {
38
+ return { error: "read-only" }
39
+ }
40
+
41
+ delete() {
42
+ return { error: "read-only" }
43
+ }
44
+
45
+ uploadFiles() {
46
+ return { error: "read-only" }
47
+ }
48
+ }
@@ -9,13 +9,22 @@
9
9
  *
10
10
  */
11
11
 
12
+ import { getConfig } from "@langchain/langgraph"
12
13
  import { isTextMime, globToRegex } from "./mime-utils.js"
13
14
 
14
15
  export class UploadsBackend {
15
- constructor(contextId, fileStore, userId) {
16
- this.contextId = contextId
16
+ constructor(fileStore) {
17
17
  this.fileStore = fileStore
18
- this.userId = userId
18
+ }
19
+
20
+ _resolveContext() {
21
+ const config = getConfig()
22
+ const rawThreadId = config?.configurable?.thread_id || ""
23
+ const contextId = rawThreadId.includes(":")
24
+ ? rawThreadId.split(":").slice(1).join(":")
25
+ : rawThreadId
26
+ const userId = config?.configurable?._userId
27
+ return { contextId, userId }
19
28
  }
20
29
 
21
30
  // CompositeBackend strips the route prefix (/uploads/) before delegating,
@@ -26,12 +35,13 @@ export class UploadsBackend {
26
35
  }
27
36
 
28
37
  async read(filePath, offset = 0, limit = 100) {
38
+ const { contextId, userId } = this._resolveContext()
29
39
  const name = this._name(filePath)
30
- const file = await this.fileStore.getInputFile(this.contextId, name, this.userId)
40
+ const file = await this.fileStore.getInputFile(contextId, name, userId)
31
41
 
32
42
  if (!file) {
33
43
  const available =
34
- (await this.fileStore.listInputFiles(this.contextId, this.userId))
44
+ (await this.fileStore.listInputFiles(contextId, userId))
35
45
  .map((f) => `/uploads/${f.name}`)
36
46
  .join(", ") || "none"
37
47
  return { error: `File not found: ${filePath}. Available: ${available}` }
@@ -58,8 +68,9 @@ export class UploadsBackend {
58
68
  // throws `TypeError: backend.readRaw is not a function`. Currently unused
59
69
  // by our shipped code paths — do not delete as "dead".
60
70
  async readRaw(filePath) {
71
+ const { contextId, userId } = this._resolveContext()
61
72
  const name = this._name(filePath)
62
- const file = await this.fileStore.getInputFile(this.contextId, name, this.userId)
73
+ const file = await this.fileStore.getInputFile(contextId, name, userId)
63
74
  if (!file) return { error: `File not found: ${filePath}` }
64
75
  return { bytes: file.bytes, mimeType: file.mimeType, size: file.size }
65
76
  }
@@ -67,12 +78,14 @@ export class UploadsBackend {
67
78
  // CompositeBackend re-prepends the route prefix to paths returned here,
68
79
  // so return bare "/<name>" paths (without /uploads/) to avoid double-prefix.
69
80
  async list(_dirPath) {
70
- const files = await this.fileStore.listInputFiles(this.contextId, this.userId)
81
+ const { contextId, userId } = this._resolveContext()
82
+ const files = await this.fileStore.listInputFiles(contextId, userId)
71
83
  return files.map((f) => `/${f.name}`)
72
84
  }
73
85
 
74
86
  async ls(_path) {
75
- const files = await this.fileStore.listInputFiles(this.contextId, this.userId)
87
+ const { contextId, userId } = this._resolveContext()
88
+ const files = await this.fileStore.listInputFiles(contextId, userId)
76
89
  return {
77
90
  files: files.map((f) => ({
78
91
  path: `/${f.name}`,
@@ -84,8 +97,9 @@ export class UploadsBackend {
84
97
  }
85
98
 
86
99
  async glob(pattern, _path) {
100
+ const { contextId, userId } = this._resolveContext()
87
101
  const re = globToRegex(pattern)
88
- const files = await this.fileStore.listInputFiles(this.contextId, this.userId)
102
+ const files = await this.fileStore.listInputFiles(contextId, userId)
89
103
  return {
90
104
  files: files
91
105
  .filter((f) => re.test(f.name))
@@ -94,13 +108,14 @@ export class UploadsBackend {
94
108
  }
95
109
 
96
110
  async grep(pattern, _path, glob) {
111
+ const { contextId, userId } = this._resolveContext()
97
112
  const reGlob = glob ? globToRegex(glob) : null
98
- const files = await this.fileStore.listInputFiles(this.contextId, this.userId)
113
+ const files = await this.fileStore.listInputFiles(contextId, userId)
99
114
  const candidates = files.filter(
100
115
  (f) => (!reGlob || reGlob.test(f.name)) && isTextMime(f.mimeType),
101
116
  )
102
117
  const fetched = await Promise.all(
103
- candidates.map((f) => this.fileStore.getInputFile(this.contextId, f.name, this.userId)),
118
+ candidates.map((f) => this.fileStore.getInputFile(contextId, f.name, userId)),
104
119
  )
105
120
  const matches = []
106
121
  for (let i = 0; i < candidates.length; i++) {
@@ -121,14 +136,16 @@ export class UploadsBackend {
121
136
  }
122
137
 
123
138
  async exists(filePath) {
139
+ const { contextId, userId } = this._resolveContext()
124
140
  const name = this._name(filePath)
125
- const file = await this.fileStore.getInputFile(this.contextId, name, this.userId)
141
+ const file = await this.fileStore.getInputFile(contextId, name, userId)
126
142
  return !!file
127
143
  }
128
144
 
129
145
  async stat(filePath) {
146
+ const { contextId, userId } = this._resolveContext()
130
147
  const name = this._name(filePath)
131
- const file = await this.fileStore.getInputFile(this.contextId, name, this.userId)
148
+ const file = await this.fileStore.getInputFile(contextId, name, userId)
132
149
  if (!file) return null
133
150
  return { name: file.name, mimeType: file.mimeType, size: file.size }
134
151
  }
@@ -1,4 +1,5 @@
1
1
  import cds from "@sap/cds"
2
+ import { ReadonlyBackend } from "./backends/readonly-backend.js"
2
3
  const { fs, path } = cds.utils
3
4
 
4
5
  const LOG = cds.log("agents")
@@ -22,7 +23,6 @@ export async function createAutoDeepAgent(srv, agentDir) {
22
23
  createDeepAgent,
23
24
  StateBackend,
24
25
  CompositeBackend,
25
- FilesystemBackend,
26
26
  createMemoryMiddleware,
27
27
  createSkillsMiddleware,
28
28
  } = await import("deepagents")
@@ -33,37 +33,20 @@ export async function createAutoDeepAgent(srv, agentDir) {
33
33
  tools: tools.length,
34
34
  })
35
35
 
36
- let backend
36
+ const additionalBackends = {}
37
+
37
38
  if (cds.env.agents?.fileIO?.enabled) {
38
39
  const { CdsFileStore } = await import("../../protocol/persistence/file-store.js")
39
40
  const { UploadsBackend } = await import("./backends/uploads-backend.js")
40
41
  const { OutputsBackend } = await import("./backends/outputs-backend.js")
41
42
  const fileStore = new CdsFileStore()
42
- backend = (runtime) => {
43
- const rawThreadId = runtime?.configurable?.thread_id || ""
44
- const contextId = rawThreadId.includes(":")
45
- ? rawThreadId.split(":").slice(1).join(":")
46
- : rawThreadId
47
- const taskId = runtime?.configurable?._taskId || ""
48
- // GraphExecutor threads the request-entry user id as `_userId` so the
49
- // backend keeps user isolation even if cds.context drifts inside the
50
- // agent's tool callbacks. Falls back to cds.context for compatibility.
51
- const userId = runtime?.configurable?._userId
52
- return new CompositeBackend(
53
- new StateBackend(),
54
- {
55
- "/uploads/": new UploadsBackend(contextId, fileStore, userId),
56
- "/outputs/": new OutputsBackend(taskId, fileStore),
57
- },
58
- // Fallback to StateBackend for any other paths so deepagents'
59
- // built-in tools that read state still work.
60
- )
61
- }
62
- } else {
63
- backend = new StateBackend()
43
+ Object.assign(additionalBackends, {
44
+ "/uploads/": new UploadsBackend(fileStore),
45
+ "/outputs/": new OutputsBackend(fileStore),
46
+ })
64
47
  }
65
48
 
66
- const agentFsBackend = new FilesystemBackend({ rootDir: agentDir })
49
+ const agentFsBackend = new ReadonlyBackend({ rootDir: agentDir, virtualMode: true })
67
50
  const agentDirMiddleware = []
68
51
  if (fs.existsSync(path.join(agentDir, "AGENTS.md"))) {
69
52
  agentDirMiddleware.push(
@@ -74,14 +57,20 @@ export async function createAutoDeepAgent(srv, agentDir) {
74
57
  )
75
58
  }
76
59
  if (fs.existsSync(path.join(agentDir, "skills"))) {
60
+ additionalBackends["./skills/"] = additionalBackends["/skills/"] = new ReadonlyBackend({
61
+ rootDir: path.join(agentDir, "skills"),
62
+ virtualMode: true,
63
+ })
77
64
  agentDirMiddleware.push(
78
65
  createSkillsMiddleware({
79
66
  backend: agentFsBackend,
80
- sources: ["./skills/"],
67
+ sources: ["./skills"],
81
68
  }),
82
69
  )
83
70
  }
84
71
 
72
+ const backend = new CompositeBackend(new StateBackend(), additionalBackends)
73
+
85
74
  return createDeepAgent({
86
75
  model,
87
76
  tools,
package/lib/index.js CHANGED
@@ -122,7 +122,7 @@ export default function A2AProtocolAdapter(srv, options = {}) {
122
122
  }
123
123
  })
124
124
 
125
- if (!cds.env.profiles?.includes("production")) {
125
+ if (cds.env?.server?.index) {
126
126
  linkProviders.push((entity, endpoint) => {
127
127
  if (entity || endpoint?.kind !== "agent") return undefined
128
128
  return {
@@ -182,7 +182,7 @@ export default function A2AProtocolAdapter(srv, options = {}) {
182
182
  res.json(card)
183
183
  })
184
184
 
185
- if (!cds.env.profiles?.includes("production")) {
185
+ if (cds.env?.server?.index) {
186
186
  router.use("/preview", previewAuthChallenge(srv), preview(agentCard.name || srv.name))
187
187
  }
188
188
 
@@ -429,7 +429,29 @@
429
429
  <script>
430
430
  const AGENT_URL = window.location.pathname.replace(/\/preview\/?$/, "") + "/"
431
431
  const AUTH_HEADER = document.querySelector('meta[name="a2a-auth"]')?.content || ""
432
- const authHeaders = () => (AUTH_HEADER ? { Authorization: AUTH_HEADER } : {})
432
+
433
+ const fetchCsrfToken = async () => {
434
+ if (document.csrfToken === undefined) {
435
+ const res = await fetch("/", {
436
+ method: "HEAD",
437
+ headers: { "x-csrf-token": "fetch", ...(await authHeaders({ includeCsrf: false })) },
438
+ })
439
+ if (!res.ok) {
440
+ throw new Error(`Could not fetch CSRF token ${res.status}: ${res.statusText}`)
441
+ }
442
+ document.csrfToken = res.headers.get("x-csrf-token")
443
+ }
444
+ return document.csrfToken
445
+ }
446
+
447
+ const authHeaders = async ({ includeCsrf = true } = {}) => {
448
+ const headers = AUTH_HEADER ? { Authorization: AUTH_HEADER } : {}
449
+ if (!includeCsrf) return headers
450
+ const csrfToken = await fetchCsrfToken()
451
+ if (csrfToken) headers["x-csrf-token"] = csrfToken
452
+ return headers
453
+ }
454
+
433
455
  document.getElementById("back").addEventListener("click", () => history.back())
434
456
  const messages = document.getElementById("messages")
435
457
  const typing = document.getElementById("typing")
@@ -579,7 +601,7 @@
579
601
  try {
580
602
  const resp = await fetch(AGENT_URL, {
581
603
  method: "POST",
582
- headers: { "Content-Type": "application/json", ...authHeaders() },
604
+ headers: { "Content-Type": "application/json", ...(await authHeaders()) },
583
605
  signal: abort.signal,
584
606
  body: JSON.stringify({
585
607
  jsonrpc: "2.0",
@@ -677,7 +699,7 @@
677
699
  try {
678
700
  const resp = await fetch(AGENT_URL, {
679
701
  method: "POST",
680
- headers: { "Content-Type": "application/json", ...authHeaders() },
702
+ headers: { "Content-Type": "application/json", ...(await authHeaders()) },
681
703
  signal: abort.signal,
682
704
  body: JSON.stringify({
683
705
  jsonrpc: "2.0",
@@ -856,7 +878,7 @@
856
878
  if (taskId) {
857
879
  fetch(AGENT_URL, {
858
880
  method: "POST",
859
- headers: { "Content-Type": "application/json", ...authHeaders() },
881
+ headers: { "Content-Type": "application/json", ...(await authHeaders()) },
860
882
  body: JSON.stringify({
861
883
  jsonrpc: "2.0",
862
884
  method: "tasks/cancel",
@@ -151,6 +151,7 @@ export class CdsCheckpointSaver extends BaseCheckpointSaver {
151
151
  const thread_id = config.configurable?.thread_id
152
152
  const checkpoint_ns = config.configurable?.checkpoint_ns ?? ""
153
153
  const parent_checkpoint_id = config.configurable?.checkpoint_id
154
+ const task_id = config.configurable?._taskId || cds.context?.["agent.task.id"] || null
154
155
 
155
156
  if (!thread_id) {
156
157
  throw new Error('Missing required "thread_id" in config.configurable')
@@ -165,12 +166,14 @@ export class CdsCheckpointSaver extends BaseCheckpointSaver {
165
166
  checkpoint_ns,
166
167
  checkpoint_id: checkpoint.id,
167
168
  parent_checkpoint_id,
169
+ task_id,
168
170
  checkpoint: toUtf8String(serializedCheckpoint),
169
171
  metadata: toUtf8String(serializedMetadata),
170
172
  createdBy: resolveUserId(),
171
173
  })
172
174
 
173
- LOG._trace && LOG.debug("Checkpoint saved", { thread_id, checkpoint_id: checkpoint.id })
175
+ LOG._trace &&
176
+ LOG.debug("Checkpoint saved", { thread_id, checkpoint_id: checkpoint.id, task_id })
174
177
 
175
178
  return {
176
179
  configurable: {
@@ -0,0 +1,84 @@
1
+ import cds from "@sap/cds"
2
+ import { ms4 } from "../../utils/utils.js"
3
+
4
+ const LOG = cds.log("agent")
5
+
6
+ const TASKS = "cap.agent.Tasks"
7
+
8
+ // Throttle: last cleanup timestamp per tenant & service
9
+ const lastDeletionTriggerMap = new Map()
10
+
11
+ /** Test-only: reset throttle map. */
12
+ export function _resetCleanupThrottle() {
13
+ lastDeletionTriggerMap.clear()
14
+ }
15
+
16
+ // TTL from cds.env.agents.retention., `false`/0 → disabled.
17
+ function resolveTtlMs() {
18
+ const cfg = cds.env.agents?.retention
19
+ if (cfg === false || cfg === 0) return 0
20
+ const value = cfg === true || cfg
21
+ if (typeof value === "number") return value
22
+ return ms4(String(value))
23
+ }
24
+
25
+ const MS_OF_A_DAY = ms4("1d")
26
+
27
+ export async function triggerCleanup(serviceName) {
28
+ const ttlMs = resolveTtlMs()
29
+ if (!ttlMs) {
30
+ LOG.debug(`cds.agents.retention is not configured. Skipping cleanup of old Tasks.`)
31
+ return
32
+ }
33
+ const tenant = cds.context?.tenant
34
+ if (!lastDeletionTriggerMap.has(tenant)) lastDeletionTriggerMap.set(tenant, new Map())
35
+ const serviceMap = lastDeletionTriggerMap.get(tenant)
36
+ const lastDeletionTriggered = serviceMap.get(serviceName)
37
+ if (lastDeletionTriggered > Date.now() - MS_OF_A_DAY) {
38
+ LOG.debug(
39
+ `Skip scheduling deletion of tasks for ${serviceName} because the last scheduled deletion was triggered within the last 24h.`,
40
+ )
41
+ return
42
+ }
43
+ const srv = cds.services[serviceName]
44
+ if (!srv) {
45
+ LOG.warn(`triggerCleanup: service "${serviceName}" not found in cds.services, skipping.`)
46
+ return
47
+ }
48
+ if (typeof srv.schedule !== "function") {
49
+ LOG.debug(`triggerCleanup: srv.schedule not available (CDS < 9). Skipping cleanup scheduling.`)
50
+ return
51
+ }
52
+ serviceMap.set(serviceName, Date.now())
53
+ const MAX_TIMEOUT = 2_147_483_647
54
+ const delay = Math.min(ttlMs + MS_OF_A_DAY, MAX_TIMEOUT)
55
+ await srv.schedule("cleanupTasks", {}).after(delay)
56
+ }
57
+
58
+ /**
59
+ * GC of expired tasks per service.
60
+ * Compositions cascade automatically: inputFiles, outputFiles, pushConfigs,
61
+ * checkpoints, checkpointWrites.
62
+ */
63
+ export async function cleanupExpiredTasks(serviceName) {
64
+ const ttlMs = resolveTtlMs()
65
+ if (!ttlMs) {
66
+ LOG.debug(`cds.agents.retention is not configured. Skipping cleanup of old Tasks.`)
67
+ return
68
+ }
69
+
70
+ const tenant = cds.context?.tenant
71
+ const now = Date.now()
72
+ const cutoff = new Date(now - ttlMs).toISOString()
73
+
74
+ const taskResult = await DELETE.from(TASKS).where({
75
+ modifiedAt: { "<": cutoff },
76
+ agentService: serviceName,
77
+ })
78
+ // affectedRows since cds10
79
+ const deletedTasks = typeof taskResult === "number" ? taskResult : taskResult?.affectedRows || 0
80
+
81
+ if (deletedTasks > 0) {
82
+ LOG.debug("Cleanup", { tenant, deletedTasks, cutoff })
83
+ }
84
+ }
@@ -71,6 +71,28 @@ export function _patchChatModelProto(proto) {
71
71
  try {
72
72
  const result = await original.call(this, input, opts)
73
73
  const duration = Date.now() - t0
74
+ const ir = result.additional_kwargs?.intermediate_results
75
+ if (span && ir) {
76
+ if (ir.input_filtering) span.setAttribute("gen_ai.orchestration.input_filtering", true)
77
+ if (ir.output_filtering) span.setAttribute("gen_ai.orchestration.output_filtering", true)
78
+ if (ir.input_masking) span.setAttribute("gen_ai.orchestration.input_masking", true)
79
+ const appliedFilterAmount = (filtering) => {
80
+ let res = []
81
+ for (const entry of filtering?.data?.choices ?? []) {
82
+ Object.keys(entry).forEach((e) => {
83
+ if (e !== "index") {
84
+ res = res.concat(Object.keys(entry[e]).map((filter) => `${e}_${filter}`))
85
+ }
86
+ })
87
+ }
88
+ return res
89
+ }
90
+ let ic = appliedFilterAmount(ir.input_filtering)
91
+ let oc = appliedFilterAmount(ir.output_filtering)
92
+ if (ic.length) span.setAttribute("gen_ai.orchestration.input_filter_services", ic)
93
+ if (oc.length) span.setAttribute("gen_ai.orchestration.output_filter_services", oc)
94
+ }
95
+
74
96
  /** @type {import('@langchain/core/messages').UsageMetadata} */
75
97
  const usage = result.usage_metadata
76
98
  const finishReason =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/agents",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "CDS plugin for building agents",
5
5
  "author": "SAP SE (https://www.sap.com)",
6
6
  "license": "Apache-2.0",
@@ -87,6 +87,7 @@
87
87
  "trace_langchain": true,
88
88
  "mlflow": false,
89
89
  "connect": "auto",
90
+ "retention": "30d",
90
91
  "[development]": {
91
92
  "pushNotifications": {
92
93
  "allowedDomains": [
@@ -116,7 +117,6 @@
116
117
  "max_tokens": 4096,
117
118
  "temperature": 0
118
119
  },
119
- "contentFilter": true,
120
120
  "fileIO": {
121
121
  "enabled": false,
122
122
  "maxInputFileSizeBytes": 2097152,
@@ -6,6 +6,7 @@ import { mlflowAttrs, mlflowTraceAttrs, setSpanAttrs } from "../../lib/telemetry
6
6
  import { CdsFileStore } from "../../lib/protocol/persistence/file-store.js"
7
7
  import { formatFileSize, sanitizeFilename } from "./tools.js"
8
8
  import { convertUsageData } from "../../lib/telemetry/chat-tracing.js"
9
+ import { triggerCleanup } from "../../lib/protocol/persistence/cleanup.js"
9
10
 
10
11
  const LOG = cds.log("agents")
11
12
 
@@ -589,6 +590,10 @@ class GraphExecutor {
589
590
  audit("AgentTaskStarted", {
590
591
  data: { taskId, contextId, service: serviceName, userMessage: requestContext.userMessage },
591
592
  })
593
+ // Lazy scheduling task deletion
594
+ cds.spawn({}, async () => {
595
+ await triggerCleanup(serviceName)
596
+ })
592
597
  }
593
598
 
594
599
  // ── File I/O: persist incoming FileParts to cap.agent.Tasks.inputFiles ──
@@ -3,6 +3,7 @@ import { generateTools, createReadFileTool } from "./tools.js"
3
3
  import { buildSystemPrompt } from "./system-prompt.js"
4
4
  import buildMiddleware from "../../lib/agents/middleware/index.js"
5
5
  import { partsToText } from "../../lib/utils/message-handling.js"
6
+ import { cleanupExpiredTasks } from "../../lib/protocol/persistence/cleanup.js"
6
7
 
7
8
  const LOG = cds.log("agents")
8
9
 
@@ -170,4 +171,8 @@ export default function registerDefaultAgentHandlers(srv) {
170
171
  },
171
172
  })
172
173
  })
174
+
175
+ srv.on("cleanupTasks", async () => {
176
+ await cleanupExpiredTasks(srv.name)
177
+ })
173
178
  }
@@ -94,7 +94,8 @@ export async function buildMcpToolsFromConnection(serviceName) {
94
94
  const { credentials, kind } = cds.requires[serviceName]
95
95
 
96
96
  // dest is either a string (BTP destination name) or an object { name, url, ... }
97
- const destinationName = typeof credentials === "string" ? credentials : credentials?.name
97
+ const destinationName =
98
+ typeof credentials === "string" ? credentials : (credentials?.destination ?? credentials?.name)
98
99
  const localUrl =
99
100
  typeof credentials === "string"
100
101
  ? null
@@ -121,7 +122,8 @@ export async function buildMcpToolsFromConnection(serviceName) {
121
122
  )
122
123
  }
123
124
 
124
- const mcpUrl = url.replace(/\/$/, "")
125
+ const path = typeof credentials === "object" ? credentials?.path : null
126
+ const mcpUrl = url.replace(/\/$/, "") + (path ? `/${path.replace(/^\//, "")}` : "")
125
127
  LOG.info(`Connecting to MCP server at ${mcpUrl}`)
126
128
 
127
129
  const resolveHeaders = async () => {
@@ -206,7 +206,8 @@ export async function buildSubAgentToolFromConnection(serviceName) {
206
206
  endpoints = Object.fromEntries(endpoints.map((o) => [o.kind, o.path]))
207
207
  const { credentials, kind } = cds.requires[serviceName]
208
208
 
209
- const destinationName = typeof credentials === "string" ? credentials : credentials?.name
209
+ const destinationName =
210
+ typeof credentials === "string" ? credentials : (credentials?.destination ?? credentials?.name)
210
211
  const localUrl =
211
212
  typeof credentials === "string"
212
213
  ? null
@@ -270,7 +271,8 @@ export async function buildSubAgentToolFromConnection(serviceName) {
270
271
  )
271
272
  }
272
273
 
273
- const base = agentBaseUrl.replace(/\/$/, "")
274
+ const path = typeof credentials === "object" ? credentials?.path : null
275
+ const base = agentBaseUrl.replace(/\/$/, "") + (path ? `/${path.replace(/^\//, "")}` : "")
274
276
  LOG.info(`Connecting to sub-agent at ${base}`)
275
277
 
276
278
  // revisit: a2a agents may be tenant specific, card per tenant?