@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
|
@@ -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, "<")
|
|
19
|
+
.replace(/>/g, ">")
|
|
20
|
+
.replace(/"/g, """)
|
|
21
|
+
.replace(/'/g, "'")
|
|
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, """)
|
|
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
|
+
}
|