@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,46 @@
1
+ import express from "express"
2
+ import { readFileSync } from "node:fs"
3
+ import { fileURLToPath } from "node:url"
4
+ import { join, dirname } from "node:path"
5
+ import { createRequire } from "node:module"
6
+
7
+ const _dir = dirname(fileURLToPath(import.meta.url))
8
+ const _chatTemplate = readFileSync(join(_dir, "chat.html"), "utf-8")
9
+ const _require = createRequire(import.meta.url)
10
+ const _markedPath = join(dirname(_require.resolve("marked/package.json")), "lib", "marked.umd.js")
11
+ const _markedJs = readFileSync(_markedPath, "utf-8")
12
+
13
+ export default function preview(agentName) {
14
+ const router = express.Router()
15
+
16
+ const safeName = agentName
17
+ .replace(/&/g, "&")
18
+ .replace(/</g, "&lt;")
19
+ .replace(/>/g, "&gt;")
20
+ .replace(/"/g, "&quot;")
21
+ .replace(/'/g, "&#39;")
22
+
23
+ // Serve chat preview UI
24
+ router.get("/", (req, res) => {
25
+ if (!req.originalUrl.split("?")[0].endsWith("/")) {
26
+ const [pathPart, query = ""] = req.originalUrl.split("?")
27
+ return res.redirect(301, pathPart + "/" + (query ? "?" + query : ""))
28
+ }
29
+ // Echo the browser's Authorization header into the page
30
+ const authHeader = (req.headers.authorization || "").replace(/"/g, "&quot;")
31
+ const html = _chatTemplate
32
+ .replace(/\{\{agentName\}\}/g, safeName)
33
+ .replace(/\{\{authHeader\}\}/g, authHeader)
34
+ res.setHeader("Content-Type", "text/html; charset=utf-8")
35
+ res.send(html)
36
+ })
37
+
38
+ // Serve bundled marked.js (avoids non-SAP CDN dependency)
39
+ router.get("/marked.min.js", (_req, res) => {
40
+ res.setHeader("Content-Type", "application/javascript; charset=utf-8")
41
+ res.setHeader("Cache-Control", "public, max-age=86400")
42
+ res.send(_markedJs)
43
+ })
44
+
45
+ return router
46
+ }
@@ -0,0 +1,297 @@
1
+ import cds from "@sap/cds"
2
+ import { createRequire } from "node:module"
3
+ const { path } = cds.utils
4
+ import { getDescription, getFilteredEntities, getFilteredActions } from "../utils/utils.js"
5
+ import {
6
+ scanSkills,
7
+ parseAgentMetadata,
8
+ parseAgentCardMd,
9
+ parseAgentCardFile,
10
+ slugified,
11
+ } from "../utils/markdown.js"
12
+
13
+ const require = createRequire(import.meta.url)
14
+
15
+ const LOG = cds.log("agents")
16
+
17
+ /**
18
+ * Check if push notifications are enabled via cds.agents.pushNotifications config.
19
+ * Defaults to true. Set to `false` to disable.
20
+ */
21
+ function isPushNotificationsEnabled() {
22
+ const cfg = cds.env.agents?.pushNotifications
23
+ if (cfg === false) return false
24
+ return true
25
+ }
26
+
27
+ const FILE_IO_MIME_TYPES = [
28
+ "text/csv",
29
+ "application/json",
30
+ "text/plain",
31
+ "application/pdf",
32
+ "image/png",
33
+ "image/jpeg",
34
+ ]
35
+
36
+ /**
37
+ * Merge fileIO MIME types into an agent card when cds.env.agents.fileIO.enabled is true.
38
+ * defaultInputModes/defaultOutputModes are sufficient to advertise file transfer
39
+ * capabilities to A2A clients — no capability extension is added.
40
+ */
41
+ function applyFileIOCapability(card) {
42
+ const cfg = cds.env.agents?.fileIO
43
+ if (!cfg?.enabled) return card
44
+ const inputMimes = cfg.defaultInputModes ?? FILE_IO_MIME_TYPES
45
+ const outputMimes = cfg.defaultOutputModes ?? FILE_IO_MIME_TYPES
46
+ const inputModes = [...new Set([...(card.defaultInputModes ?? []), ...inputMimes])]
47
+ const outputModes = [...new Set([...(card.defaultOutputModes ?? []), ...outputMimes])]
48
+ return { ...card, defaultInputModes: inputModes, defaultOutputModes: outputModes }
49
+ }
50
+
51
+ /**
52
+ * Lines starting with "Example:" (case-insensitive) are parsed as skill examples.
53
+ */
54
+ function getDocExamples(def) {
55
+ if (!def.doc) return []
56
+ return def.doc
57
+ .split("\n")
58
+ .filter((line) => /^\s*example\s*:/i.test(line))
59
+ .map((line) => line.replace(/^\s*example\s*:\s*/i, "").trim())
60
+ .filter(Boolean)
61
+ }
62
+
63
+ /**
64
+ * Generate skills from entities and actions (agentify mode).
65
+ */
66
+ function generateSkills(entities, actions) {
67
+ const skills = []
68
+ const entityNames = Object.keys(entities)
69
+
70
+ if (entityNames.length > 0) {
71
+ const entityDescriptions = entityNames
72
+ .map((name) => {
73
+ const desc = getDescription(entities[name])
74
+ return desc ? `${name}: ${desc}` : name
75
+ })
76
+ .join(" \n ")
77
+
78
+ skills.push({
79
+ id: "query",
80
+ name: "Data Query",
81
+ description: `Query data from the service. Available entities: \n ${entityDescriptions}`,
82
+ examples: [`Show me all ${entityNames[0]}`],
83
+ tags: ["query", "data", "read", ...entityNames.slice(0, 3).map((n) => n.toLowerCase())],
84
+ })
85
+ }
86
+
87
+ for (const [name, action] of Object.entries(actions)) {
88
+ const description =
89
+ getDescription(action) || `${action.kind === "function" ? "Get" : "Execute"} ${name}`
90
+
91
+ const docExamples = getDocExamples(action)
92
+ const examples = docExamples.length > 0 ? docExamples : [description]
93
+
94
+ const tags = [name.toLowerCase(), action.kind]
95
+ if (action.kind === "action") tags.push("hitl")
96
+
97
+ skills.push({ id: name, name, description, examples, tags })
98
+ }
99
+
100
+ return skills
101
+ }
102
+
103
+ /**
104
+ * Build agent card from explicit params (used by compile.js for compile-time generation).
105
+ */
106
+ function buildAgentCard({ name, description, entities, actions, url, streaming = false }) {
107
+ const skills = generateSkills(entities, actions)
108
+
109
+ let version = "0.0.1"
110
+ try {
111
+ version = require(path.join(cds.root, "package.json")).version || version
112
+ } catch {
113
+ /* fallback */
114
+ }
115
+
116
+ return applyFileIOCapability({
117
+ name,
118
+ description,
119
+ url,
120
+ version,
121
+ protocolVersion: "0.3.0",
122
+ supportedInterfaces: [{ url, protocolBinding: "JSONRPC", protocolVersion: "0.3.0" }],
123
+ capabilities: { streaming, pushNotifications: isPushNotificationsEnabled() },
124
+ defaultInputModes: ["text/plain"],
125
+ defaultOutputModes: ["text/plain"],
126
+ skills,
127
+ })
128
+ }
129
+
130
+ /**
131
+ * Build agent card from CDS model (agentify mode, runtime).
132
+ */
133
+ function buildAgentCardFromModel(srv, servicePath, streaming = false) {
134
+ return buildAgentCard({
135
+ name: srv.name,
136
+ description: getDescription(srv.definition) || `Agent for ${srv.name}`,
137
+ entities: getFilteredEntities(srv),
138
+ actions: getFilteredActions(srv),
139
+ url: servicePath,
140
+ streaming,
141
+ })
142
+ }
143
+
144
+ /**
145
+ * Build agent card from AGENT_CARD.md (explicit card file).
146
+ */
147
+ function buildAgentCardFromCardFile(cardMeta, agentMeta, srv, servicePath, streaming = false) {
148
+ let version = cardMeta.version || agentMeta?.version
149
+ try {
150
+ version = version || require(path.join(cds.root, "package.json")).version
151
+ } catch {
152
+ /* fallback */
153
+ }
154
+ version = version || "1.0.0"
155
+
156
+ const skills = (cardMeta.skills || []).map((s) => ({
157
+ id: s.id || s.name,
158
+ name: s.name || s.id,
159
+ description: s.description || "",
160
+ tags: Array.isArray(s.tags) ? s.tags : [],
161
+ examples: Array.isArray(s.examples) ? s.examples : [],
162
+ }))
163
+
164
+ return {
165
+ name: cardMeta.name || agentMeta?.name || srv.name,
166
+ description: (
167
+ cardMeta.description ||
168
+ agentMeta?.description ||
169
+ getDescription(srv.definition) ||
170
+ `Agent for ${srv.name}`
171
+ ).trim(),
172
+ url: servicePath,
173
+ version,
174
+ protocolVersion: cardMeta.metadata?.protocolVersion || "0.3.0",
175
+ supportedInterfaces: [
176
+ { url: servicePath, protocolBinding: "JSONRPC", protocolVersion: "0.3.0" },
177
+ ],
178
+ capabilities: cardMeta.metadata?.capabilities || {
179
+ streaming,
180
+ pushNotifications: isPushNotificationsEnabled(),
181
+ },
182
+ defaultInputModes: cardMeta.defaultInputModes || ["text/plain"],
183
+ defaultOutputModes: cardMeta.defaultOutputModes || ["text/plain"],
184
+ skills,
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Build agent card from skills/ directory scan.
190
+ */
191
+ function buildAgentCardFromSkills(agentDir, agentMeta, srv, servicePath, streaming = false) {
192
+ const skills = scanSkills(path.join(agentDir, "skills"))
193
+
194
+ let version = agentMeta?.version
195
+ try {
196
+ version = version || require(path.join(cds.root, "package.json")).version
197
+ } catch {
198
+ /* fallback */
199
+ }
200
+ version = version || "1.0.0"
201
+
202
+ return {
203
+ name: agentMeta?.name || srv.name,
204
+ description: (
205
+ agentMeta?.description ||
206
+ getDescription(srv.definition) ||
207
+ `Agent for ${srv.name}`
208
+ ).trim(),
209
+ url: servicePath,
210
+ version,
211
+ protocolVersion: "0.3.0",
212
+ supportedInterfaces: [
213
+ { url: servicePath, protocolBinding: "JSONRPC", protocolVersion: "0.3.0" },
214
+ ],
215
+ capabilities: { streaming, pushNotifications: isPushNotificationsEnabled() },
216
+ defaultInputModes: ["text/plain"],
217
+ defaultOutputModes: ["text/plain"],
218
+ skills,
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Generate the A2A agent card for a service.
224
+ *
225
+ * Resolution chain (inputs supplied via the `resolved` parameter):
226
+ * 1. `resolved.agentCardPath` — explicit path to agent card markdown
227
+ * (sourced from `@agent.card` annotation by the caller)
228
+ * 2. `<resolved.agentDir>/AGENT_CARD.md` — convention within agent directory
229
+ * 3. `<resolved.agentDir>/skills/` — auto-generated from SKILL.md frontmatter
230
+ * 4. CDS model — entities + actions (agentify fallback)
231
+ *
232
+ * @param {object} srv CDS ApplicationService
233
+ * @param {object} [options] adapter options (e.g. `path`)
234
+ * @param {object} [resolved] convention-resolved paths
235
+ * @param {string} [resolved.agentDir] absolute path to agent directory or undefined
236
+ * @param {string} [resolved.agentCardPath] absolute path to explicit card file or undefined
237
+ */
238
+ function generateAgentCard(srv, options = {}, resolved = {}) {
239
+ const servicePath = options.path || `/a2a/${slugified(srv.name)}`
240
+ const { agentDir, agentCardPath } = resolved
241
+ const streaming = true
242
+
243
+ // Priority 1: Explicit agentCardPath (from @agent.card annotation)
244
+ if (agentCardPath) {
245
+ const cardMeta = parseAgentCardFile(agentCardPath)
246
+ if (cardMeta) {
247
+ const card = buildAgentCardFromCardFile(cardMeta, null, srv, servicePath, streaming)
248
+ LOG._trace &&
249
+ LOG.debug("Generated agent card from @agent.card annotation", {
250
+ service: srv.name,
251
+ path: agentCardPath,
252
+ skills: card.skills.length,
253
+ })
254
+ return applyFileIOCapability(card)
255
+ }
256
+ LOG.warn("Agent card file not found or invalid YAML, falling back", {
257
+ service: srv.name,
258
+ path: agentCardPath,
259
+ })
260
+ }
261
+
262
+ if (agentDir) {
263
+ const agentMeta = parseAgentMetadata(agentDir)
264
+
265
+ // Priority 2: AGENT_CARD.md in agentDir (convention)
266
+ const cardMeta = parseAgentCardMd(agentDir)
267
+ if (cardMeta) {
268
+ const card = buildAgentCardFromCardFile(cardMeta, agentMeta, srv, servicePath, streaming)
269
+ LOG._trace &&
270
+ LOG.debug("Generated agent card from AGENT_CARD.md", {
271
+ service: srv.name,
272
+ skills: card.skills.length,
273
+ })
274
+ return applyFileIOCapability(card)
275
+ }
276
+
277
+ // Priority 3: Scan skills/ directory
278
+ const card = buildAgentCardFromSkills(agentDir, agentMeta, srv, servicePath, streaming)
279
+ LOG._trace &&
280
+ LOG.debug("Generated agent card from skills/", {
281
+ service: srv.name,
282
+ skills: card.skills.length,
283
+ })
284
+ return applyFileIOCapability(card)
285
+ }
286
+
287
+ // Priority 4: CDS model (agentify mode)
288
+ const card = buildAgentCardFromModel(srv, servicePath, streaming)
289
+ LOG._trace &&
290
+ LOG.debug("Generated agent card from CDS model", {
291
+ service: srv.name,
292
+ skills: card.skills.length,
293
+ })
294
+ return applyFileIOCapability(card)
295
+ }
296
+
297
+ export { generateAgentCard, buildAgentCard }
@@ -0,0 +1,317 @@
1
+ /* eslint-disable no-await-in-loop */
2
+ import cds from "@sap/cds"
3
+ import {
4
+ BaseCheckpointSaver,
5
+ WRITES_IDX_MAP,
6
+ copyCheckpoint,
7
+ getCheckpointId,
8
+ } from "@langchain/langgraph-checkpoint"
9
+
10
+ const LOG = cds.log("agents")
11
+
12
+ const toUtf8String = (bytes) => Buffer.from(bytes).toString("utf-8")
13
+
14
+ const CHECKPOINTS = "cap.agent.Checkpoints"
15
+ const WRITES = "cap.agent.CheckpointWrites"
16
+
17
+ const ASSOC_TYPES = new Set(["cds.Association", "cds.Composition"])
18
+
19
+ function scalarColumnsOf(entityName) {
20
+ return Object.entries(cds.model.definitions[entityName].elements)
21
+ .filter(([, e]) => !ASSOC_TYPES.has(e.type))
22
+ .map(([name]) => name)
23
+ }
24
+
25
+ const resolveUserId = () => {
26
+ const id = cds.context?.user?.id
27
+ if (!id)
28
+ LOG.warn("cds.context missing or has no user — checkpoint query falls back to 'anonymous'")
29
+ return id ?? "anonymous"
30
+ }
31
+
32
+ /**
33
+ * CDS entity-backed LangGraph checkpoint saver.
34
+ *
35
+ * Persists graph state to cap.agent.Checkpoints and cap.agent.CheckpointWrites,
36
+ * enabling multi-turn conversations.
37
+ *
38
+ * Implements BaseCheckpointSaver from @langchain/langgraph-checkpoint.
39
+ * Uses inherited JsonPlusSerializer (this.serde) to handle LangChain-specific
40
+ * types (messages, tool calls, etc.).
41
+ */
42
+ export class CdsCheckpointSaver extends BaseCheckpointSaver {
43
+ constructor(serde) {
44
+ super(serde)
45
+ }
46
+
47
+ /**
48
+ * Find the checkpoint namespace holding the most recent checkpoint for a
49
+ * thread. LangGraph writes the root graph state under checkpoint_ns=""; but a
50
+ * graph whose final node runs in a nested namespace (e.g. a custom StateGraph
51
+ * with a "tools" node) may only leave state under that nested ns. Callers that
52
+ * recover final state from the checkpoint can use this to locate the right ns
53
+ * when the default "" lookup misses. Returns undefined if the thread has none.
54
+ */
55
+ async latestNamespace(thread_id) {
56
+ if (!thread_id) return undefined
57
+ const row = await SELECT.one
58
+ .from(CHECKPOINTS)
59
+ .columns("checkpoint_ns")
60
+ .where({ thread_id, createdBy: resolveUserId() })
61
+ .orderBy("checkpoint_id desc")
62
+ return row?.checkpoint_ns
63
+ }
64
+
65
+ /**
66
+ * Load a checkpoint for a thread.
67
+ * If checkpoint_id in config -> load that exact checkpoint.
68
+ * Otherwise -> load latest (checkpoint_id is UUID6, time-ordered, so desc = latest).
69
+ * Also loads associated CheckpointWrites (LangGraph expects them in the tuple).
70
+ */
71
+ async getTuple(config) {
72
+ const thread_id = config.configurable?.thread_id
73
+ if (!thread_id) return undefined
74
+ const checkpoint_ns = config.configurable?.checkpoint_ns ?? ""
75
+ const checkpoint_id = getCheckpointId(config)
76
+
77
+ let row
78
+ if (checkpoint_id) {
79
+ row = await SELECT.one
80
+ .from(CHECKPOINTS)
81
+ .columns(...scalarColumnsOf(CHECKPOINTS))
82
+ .where({ thread_id, checkpoint_ns, checkpoint_id, createdBy: resolveUserId() })
83
+ } else {
84
+ row = await SELECT.one
85
+ .from(CHECKPOINTS)
86
+ .columns(...scalarColumnsOf(CHECKPOINTS))
87
+ .where({ thread_id, checkpoint_ns, createdBy: resolveUserId() })
88
+ .orderBy("checkpoint_id desc")
89
+ }
90
+
91
+ if (!row) return undefined
92
+
93
+ if (!row.checkpoint) {
94
+ LOG.warn("Checkpoint row exists but LOB column is missing — treating as miss", {
95
+ thread_id,
96
+ checkpoint_id: row.checkpoint_id,
97
+ })
98
+ return undefined
99
+ }
100
+
101
+ const actualCheckpointId = row.checkpoint_id
102
+
103
+ const persistAll = cds.env.agents?.persistAllCheckpointWrites === true
104
+ const writesFilter = { thread_id, checkpoint_ns, checkpoint_id: actualCheckpointId }
105
+ if (!persistAll) writesFilter.idx = { "<": 0 }
106
+ const writeRows = await SELECT.from(WRITES)
107
+ .columns(...scalarColumnsOf(WRITES))
108
+ .where(writesFilter)
109
+
110
+ // Deserialize via inherited JsonPlusSerializer (handles LangChain message types etc.)
111
+ const checkpoint = await this.serde.loadsTyped("json", row.checkpoint)
112
+ const metadata = await this.serde.loadsTyped("json", row.metadata)
113
+ const pendingWrites = await Promise.all(
114
+ writeRows.map(async (w) => [
115
+ w.task_id,
116
+ w.channel,
117
+ await this.serde.loadsTyped("json", w.value),
118
+ ]),
119
+ )
120
+
121
+ const tuple = {
122
+ config: {
123
+ configurable: { thread_id, checkpoint_ns, checkpoint_id: actualCheckpointId },
124
+ },
125
+ checkpoint,
126
+ metadata,
127
+ pendingWrites,
128
+ }
129
+
130
+ // Link to parent checkpoint for time-travel / history chain
131
+ if (row.parent_checkpoint_id) {
132
+ tuple.parentConfig = {
133
+ configurable: {
134
+ thread_id,
135
+ checkpoint_ns,
136
+ checkpoint_id: row.parent_checkpoint_id,
137
+ },
138
+ }
139
+ }
140
+
141
+ LOG._trace && LOG.debug("Checkpoint loaded", { thread_id, checkpoint_id: actualCheckpointId })
142
+ return tuple
143
+ }
144
+
145
+ /**
146
+ * Save a new checkpoint after a graph step completes.
147
+ * config.configurable.checkpoint_id = the PARENT checkpoint (what was loaded before this step).
148
+ * checkpoint.id = the NEW checkpoint being stored.
149
+ */
150
+ async put(config, checkpoint, metadata) {
151
+ const thread_id = config.configurable?.thread_id
152
+ const checkpoint_ns = config.configurable?.checkpoint_ns ?? ""
153
+ const parent_checkpoint_id = config.configurable?.checkpoint_id
154
+
155
+ if (!thread_id) {
156
+ throw new Error('Missing required "thread_id" in config.configurable')
157
+ }
158
+
159
+ const prepared = copyCheckpoint(checkpoint)
160
+ const [, serializedCheckpoint] = await this.serde.dumpsTyped(prepared)
161
+ const [, serializedMetadata] = await this.serde.dumpsTyped(metadata)
162
+
163
+ await UPSERT.into(CHECKPOINTS).entries({
164
+ thread_id,
165
+ checkpoint_ns,
166
+ checkpoint_id: checkpoint.id,
167
+ parent_checkpoint_id,
168
+ checkpoint: toUtf8String(serializedCheckpoint),
169
+ metadata: toUtf8String(serializedMetadata),
170
+ createdBy: resolveUserId(),
171
+ })
172
+
173
+ LOG._trace && LOG.debug("Checkpoint saved", { thread_id, checkpoint_id: checkpoint.id })
174
+
175
+ return {
176
+ configurable: {
177
+ thread_id,
178
+ checkpoint_ns,
179
+ checkpoint_id: checkpoint.id,
180
+ },
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Save intermediate node outputs during a graph step.
186
+ * Called by LangGraph after each node finishes, before the next checkpoint is created.
187
+ * Special channels (__interrupt__, __error__) get negative indices via WRITES_IDX_MAP.
188
+ *
189
+ * By default only special (negative-index) writes are persisted to reduce DB load.
190
+ * Regular node outputs are already captured in the checkpoint blob via put().
191
+ * Enable cds.env.agents.persistAllCheckpointWrites for custom graphs with parallel branches.
192
+ */
193
+ async putWrites(config, writes, taskId) {
194
+ const thread_id = config.configurable?.thread_id
195
+ const checkpoint_ns = config.configurable?.checkpoint_ns ?? ""
196
+ const checkpoint_id = config.configurable?.checkpoint_id
197
+
198
+ if (!thread_id || !checkpoint_id) {
199
+ throw new Error('Missing required "thread_id" or "checkpoint_id" in config.configurable')
200
+ }
201
+
202
+ const persistAll = cds.env.agents?.persistAllCheckpointWrites === true
203
+ const entries = []
204
+
205
+ for (let i = 0; i < writes.length; i++) {
206
+ const [channel, value] = writes[i]
207
+ const idx = WRITES_IDX_MAP[channel] ?? i
208
+
209
+ // Skip positive-index (regular node output) writes unless full persistence enabled
210
+ if (!persistAll && idx >= 0) continue
211
+
212
+ // Dedup: skip positive-index writes that already exist
213
+ if (idx >= 0) {
214
+ const existing = await SELECT.one
215
+ .from(WRITES)
216
+ .columns("task_id")
217
+ .where({ thread_id, checkpoint_ns, checkpoint_id, task_id: taskId, idx })
218
+ if (existing) continue
219
+ }
220
+
221
+ const [, serializedValue] = await this.serde.dumpsTyped(value)
222
+
223
+ entries.push({
224
+ thread_id,
225
+ checkpoint_ns,
226
+ checkpoint_id,
227
+ task_id: taskId,
228
+ idx,
229
+ channel,
230
+ value: toUtf8String(serializedValue),
231
+ createdBy: resolveUserId(),
232
+ })
233
+ }
234
+
235
+ if (entries.length > 0) {
236
+ await UPSERT.into(WRITES).entries(entries)
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Stream checkpoints for a thread, newest first.
242
+ */
243
+ async *list(config, options) {
244
+ const { thread_id, checkpoint_ns = "" } = config?.configurable ?? {}
245
+ if (!thread_id) return
246
+
247
+ const userLimit = options?.limit
248
+ const BATCH_SIZE = 100
249
+ let cursorBefore = options?.before?.configurable?.checkpoint_id
250
+ let yielded = 0
251
+
252
+ while (true) {
253
+ const remaining = userLimit ? userLimit - yielded : Infinity
254
+ if (remaining <= 0) return
255
+ const batchSize = Math.min(BATCH_SIZE, remaining)
256
+
257
+ const rows = await SELECT.from(CHECKPOINTS)
258
+ .columns(...scalarColumnsOf(CHECKPOINTS))
259
+ .where({
260
+ thread_id,
261
+ checkpoint_ns,
262
+ createdBy: resolveUserId(),
263
+ ...(cursorBefore ? { checkpoint_id: { "<": cursorBefore } } : {}),
264
+ })
265
+ .orderBy("checkpoint_id desc")
266
+ .limit(batchSize)
267
+
268
+ if (rows.length === 0) return
269
+
270
+ for (const row of rows) {
271
+ if (!row.checkpoint) {
272
+ LOG.warn("Checkpoint row has missing LOB — skipping", {
273
+ thread_id,
274
+ checkpoint_id: row.checkpoint_id,
275
+ })
276
+ continue
277
+ }
278
+ const [checkpoint, metadata] = await Promise.all([
279
+ this.serde.loadsTyped("json", row.checkpoint),
280
+ this.serde.loadsTyped("json", row.metadata),
281
+ ])
282
+ yield {
283
+ config: {
284
+ configurable: { thread_id, checkpoint_ns, checkpoint_id: row.checkpoint_id },
285
+ },
286
+ checkpoint,
287
+ metadata,
288
+ parentConfig: row.parent_checkpoint_id
289
+ ? {
290
+ configurable: { thread_id, checkpoint_ns, checkpoint_id: row.parent_checkpoint_id },
291
+ }
292
+ : undefined,
293
+ }
294
+ yielded++
295
+ }
296
+
297
+ // Short batch -> no more rows beyond it.
298
+ if (rows.length < batchSize) return
299
+
300
+ // Advance cursor: oldest row of this batch (rows are checkpoint_id desc).
301
+ cursorBefore = rows[rows.length - 1].checkpoint_id
302
+ }
303
+ }
304
+
305
+ async deleteThread(threadId) {
306
+ const checkpointIds = await SELECT.from(CHECKPOINTS)
307
+ .columns("checkpoint_id", "checkpoint_ns")
308
+ .where({ thread_id: threadId, createdBy: cds.context.user.id })
309
+
310
+ if (checkpointIds.length > 0) {
311
+ const ids = checkpointIds.map(({ checkpoint_id }) => checkpoint_id)
312
+ await DELETE.from(WRITES).where({ thread_id: threadId, checkpoint_id: { in: ids } })
313
+ }
314
+
315
+ await DELETE.from(CHECKPOINTS).where({ thread_id: threadId, createdBy: cds.context.user.id })
316
+ }
317
+ }