@cap-js/agents 0.0.0 → 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/LICENSE +201 -0
- package/README.md +154 -0
- package/_i18n/messages.properties +31 -0
- package/cds-plugin.js +140 -0
- package/index.cds +116 -0
- package/index.js +0 -0
- package/lib/agents/markdown/backends/mime-utils.js +37 -0
- package/lib/agents/markdown/backends/outputs-backend.js +156 -0
- package/lib/agents/markdown/backends/readonly-backend.js +48 -0
- package/lib/agents/markdown/backends/uploads-backend.js +160 -0
- package/lib/agents/markdown/deep-agent.js +82 -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 +897 -0
- package/lib/preview/preview.js +46 -0
- package/lib/protocol/agent-card.js +297 -0
- package/lib/protocol/persistence/checkpoint-saver.js +320 -0
- package/lib/protocol/persistence/cleanup.js +84 -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 +364 -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 +1374 -0
- package/srv/handlers/index.js +178 -0
- package/srv/handlers/mcp-tools.js +161 -0
- package/srv/handlers/sub-agent-tools.js +316 -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,37 @@
|
|
|
1
|
+
export function isTextMime(mimeType) {
|
|
2
|
+
if (!mimeType) return false
|
|
3
|
+
if (mimeType.startsWith("text/")) return true
|
|
4
|
+
return [
|
|
5
|
+
"application/json",
|
|
6
|
+
"application/xml",
|
|
7
|
+
"application/csv",
|
|
8
|
+
"application/x-ndjson",
|
|
9
|
+
"application/ld+json",
|
|
10
|
+
"application/graphql",
|
|
11
|
+
"application/yaml",
|
|
12
|
+
"application/x-yaml",
|
|
13
|
+
"application/toml",
|
|
14
|
+
"application/javascript",
|
|
15
|
+
"application/ecmascript",
|
|
16
|
+
"application/sql",
|
|
17
|
+
].includes(mimeType)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function globToRegex(pattern) {
|
|
21
|
+
let re = "^"
|
|
22
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
23
|
+
const c = pattern[i]
|
|
24
|
+
if (c === "*") re += ".*"
|
|
25
|
+
else if (c === "?") re += "."
|
|
26
|
+
else if (c === "[") {
|
|
27
|
+
const close = pattern.indexOf("]", i)
|
|
28
|
+
if (close === -1) re += "\\["
|
|
29
|
+
else {
|
|
30
|
+
re += pattern.slice(i, close + 1)
|
|
31
|
+
i = close
|
|
32
|
+
}
|
|
33
|
+
} else if (".+^${}()|\\".includes(c)) re += "\\" + c
|
|
34
|
+
else re += c
|
|
35
|
+
}
|
|
36
|
+
return new RegExp(re + "$")
|
|
37
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OutputsBackend — deepagents BackendProtocolV2 implementation for /outputs/ route.
|
|
3
|
+
*
|
|
4
|
+
* Creative reuse of deepagents' existing write_file tool: instead of adding
|
|
5
|
+
* an emit_file_part tool to the deep agent's tool set, the executor routes
|
|
6
|
+
* /outputs/ writes (which the agent makes via the standard write_file tool)
|
|
7
|
+
* to cap.agent.Tasks.outputFiles in CDS. After invocation, the executor reads all output
|
|
8
|
+
* files for the task from cap.agent.Tasks.outputFiles and emits them as A2A FileParts.
|
|
9
|
+
*
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getConfig } from "@langchain/langgraph"
|
|
13
|
+
import { isTextMime, globToRegex } from "./mime-utils.js"
|
|
14
|
+
|
|
15
|
+
function inferMimeType(name) {
|
|
16
|
+
const ext = name?.split(".").pop()?.toLowerCase()
|
|
17
|
+
const map = {
|
|
18
|
+
csv: "text/csv",
|
|
19
|
+
txt: "text/plain",
|
|
20
|
+
md: "text/markdown",
|
|
21
|
+
html: "text/html",
|
|
22
|
+
htm: "text/html",
|
|
23
|
+
json: "application/json",
|
|
24
|
+
xml: "application/xml",
|
|
25
|
+
yaml: "application/yaml",
|
|
26
|
+
yml: "application/yaml",
|
|
27
|
+
pdf: "application/pdf",
|
|
28
|
+
png: "image/png",
|
|
29
|
+
jpg: "image/jpeg",
|
|
30
|
+
jpeg: "image/jpeg",
|
|
31
|
+
gif: "image/gif",
|
|
32
|
+
svg: "image/svg+xml",
|
|
33
|
+
js: "application/javascript",
|
|
34
|
+
ts: "text/typescript",
|
|
35
|
+
py: "text/x-python",
|
|
36
|
+
sql: "application/sql",
|
|
37
|
+
}
|
|
38
|
+
return map[ext] || "application/octet-stream"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class OutputsBackend {
|
|
42
|
+
constructor(fileStore) {
|
|
43
|
+
this.fileStore = fileStore
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_taskId() {
|
|
47
|
+
return getConfig()?.configurable?._taskId || ""
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// CompositeBackend strips the route prefix (/outputs/) before delegating,
|
|
51
|
+
// so paths arrive here as either "/name" (stripped) or "/outputs/name" (direct).
|
|
52
|
+
// _name() handles both forms.
|
|
53
|
+
_name(filePath) {
|
|
54
|
+
return filePath.replace(/^\/outputs\//, "").replace(/^\//, "")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async write(filePath, content, options = {}) {
|
|
58
|
+
const name = this._name(filePath)
|
|
59
|
+
const mimeType = options.mimeType || inferMimeType(name)
|
|
60
|
+
const buf = typeof content === "string" ? Buffer.from(content, "utf-8") : Buffer.from(content)
|
|
61
|
+
await this.fileStore.saveOutputFile(this._taskId(), name, mimeType, buf)
|
|
62
|
+
return { success: true }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async exists(filePath) {
|
|
66
|
+
const name = this._name(filePath)
|
|
67
|
+
const files = await this.fileStore.listOutputFilesMeta(this._taskId())
|
|
68
|
+
return files.some((f) => f.name === name)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async stat(filePath) {
|
|
72
|
+
const name = this._name(filePath)
|
|
73
|
+
const files = await this.fileStore.listOutputFilesMeta(this._taskId())
|
|
74
|
+
const file = files.find((f) => f.name === name)
|
|
75
|
+
if (!file) return null
|
|
76
|
+
return { name: file.name, mimeType: file.mimeType, size: file.size }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// CompositeBackend re-prepends the route prefix to paths returned here,
|
|
80
|
+
// so return bare "/<name>" paths (without /outputs/) to avoid double-prefix.
|
|
81
|
+
async list(_dirPath) {
|
|
82
|
+
const files = await this.fileStore.listOutputFilesMeta(this._taskId())
|
|
83
|
+
return files.map((f) => `/${f.name}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async ls(_path) {
|
|
87
|
+
const files = await this.fileStore.listOutputFilesMeta(this._taskId())
|
|
88
|
+
return {
|
|
89
|
+
files: files.map((f) => ({
|
|
90
|
+
path: `/${f.name}`,
|
|
91
|
+
is_dir: false,
|
|
92
|
+
size: f.size,
|
|
93
|
+
modified_at: "",
|
|
94
|
+
})),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async glob(pattern, _path) {
|
|
99
|
+
const re = globToRegex(pattern)
|
|
100
|
+
const files = await this.fileStore.listOutputFilesMeta(this._taskId())
|
|
101
|
+
return {
|
|
102
|
+
files: files
|
|
103
|
+
.filter((f) => re.test(f.name))
|
|
104
|
+
.map((f) => ({ path: `/${f.name}`, is_dir: false, size: f.size, modified_at: "" })),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async grep(pattern, _path, glob) {
|
|
109
|
+
const reGlob = glob ? globToRegex(glob) : null
|
|
110
|
+
const files = await this.fileStore.listOutputFiles(this._taskId())
|
|
111
|
+
const matches = []
|
|
112
|
+
for (const f of files) {
|
|
113
|
+
if (reGlob && !reGlob.test(f.name)) continue
|
|
114
|
+
if (!isTextMime(f.mimeType)) continue
|
|
115
|
+
const lines = (f.bytes ? Buffer.from(f.bytes).toString("utf-8") : "").split("\n")
|
|
116
|
+
for (let i = 0; i < lines.length; i++) {
|
|
117
|
+
if (lines[i].includes(pattern)) {
|
|
118
|
+
matches.push({ path: `/${f.name}`, line: i + 1, text: lines[i] })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { matches }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async read(filePath, offset = 0, limit = 100) {
|
|
126
|
+
const name = this._name(filePath)
|
|
127
|
+
const file = await this.fileStore.getOutputFile(this._taskId(), name)
|
|
128
|
+
if (!file) {
|
|
129
|
+
const files = await this.fileStore.listOutputFilesMeta(this._taskId())
|
|
130
|
+
const available = files.map((f) => `/outputs/${f.name}`).join(", ") || "none"
|
|
131
|
+
return { error: `File not found: ${filePath}. Available: ${available}` }
|
|
132
|
+
}
|
|
133
|
+
if (!isTextMime(file.mimeType)) {
|
|
134
|
+
return { error: `"${name}" is a binary file (${file.mimeType}) — cannot be read as text.` }
|
|
135
|
+
}
|
|
136
|
+
const text = file.bytes ? Buffer.from(file.bytes).toString("utf-8") : ""
|
|
137
|
+
const lines = text.split("\n")
|
|
138
|
+
return {
|
|
139
|
+
content: lines.slice(offset, offset + limit).join("\n"),
|
|
140
|
+
mimeType: file.mimeType,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Defensive stub for deepagents' BackendProtocolV2 — see UploadsBackend.readRaw
|
|
145
|
+
// for the rationale. Currently unused by our shipped code paths; do not delete.
|
|
146
|
+
async readRaw(filePath) {
|
|
147
|
+
const name = this._name(filePath)
|
|
148
|
+
const file = await this.fileStore.getOutputFile(this._taskId(), name)
|
|
149
|
+
if (!file) return { error: `File not found: ${filePath}` }
|
|
150
|
+
return { bytes: file.bytes, mimeType: file.mimeType, size: file.size }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
edit() {
|
|
154
|
+
return { error: "use write_file to overwrite an output file" }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UploadsBackend — deepagents BackendProtocolV2 implementation for /uploads/ route.
|
|
3
|
+
*
|
|
4
|
+
* Bridges deepagents' built-in read_file tool (which routes /uploads/* paths
|
|
5
|
+
* through whatever backend is registered at '/uploads/' in CompositeBackend)
|
|
6
|
+
* to cap.agent.Tasks.inputFiles via cds.ql. Without this backend, deepagents has no way
|
|
7
|
+
* to honour /uploads/ paths at all — FilesystemMiddleware wires read_file
|
|
8
|
+
* unconditionally to the backend.
|
|
9
|
+
*
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getConfig } from "@langchain/langgraph"
|
|
13
|
+
import { isTextMime, globToRegex } from "./mime-utils.js"
|
|
14
|
+
|
|
15
|
+
export class UploadsBackend {
|
|
16
|
+
constructor(fileStore) {
|
|
17
|
+
this.fileStore = fileStore
|
|
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 }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// CompositeBackend strips the route prefix (/uploads/) before delegating,
|
|
31
|
+
// so paths arrive here as either "/name" (stripped) or "/uploads/name" (direct).
|
|
32
|
+
// normalise() handles both forms.
|
|
33
|
+
_name(filePath) {
|
|
34
|
+
return filePath.replace(/^\/uploads\//, "").replace(/^\//, "")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async read(filePath, offset = 0, limit = 100) {
|
|
38
|
+
const { contextId, userId } = this._resolveContext()
|
|
39
|
+
const name = this._name(filePath)
|
|
40
|
+
const file = await this.fileStore.getInputFile(contextId, name, userId)
|
|
41
|
+
|
|
42
|
+
if (!file) {
|
|
43
|
+
const available =
|
|
44
|
+
(await this.fileStore.listInputFiles(contextId, userId))
|
|
45
|
+
.map((f) => `/uploads/${f.name}`)
|
|
46
|
+
.join(", ") || "none"
|
|
47
|
+
return { error: `File not found: ${filePath}. Available: ${available}` }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (file.mimeType?.startsWith("image/")) {
|
|
51
|
+
return { error: `"${name}" is an image. Use an image analysis tool.` }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!isTextMime(file.mimeType)) {
|
|
55
|
+
return { error: `"${name}" is a binary file (${file.mimeType}) — cannot be read as text.` }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const lines = (file.bytes ? file.bytes.toString("utf-8") : "").split("\n")
|
|
59
|
+
return {
|
|
60
|
+
content: lines.slice(offset, offset + limit).join("\n"),
|
|
61
|
+
mimeType: file.mimeType,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Defensive stub for deepagents' BackendProtocolV2: callers that bypass the
|
|
66
|
+
// text-only `read()` path (binary handling, future deepagents versions) hit
|
|
67
|
+
// this method. Without it, `compositeBackend.readRaw('/uploads/<name>')`
|
|
68
|
+
// throws `TypeError: backend.readRaw is not a function`. Currently unused
|
|
69
|
+
// by our shipped code paths — do not delete as "dead".
|
|
70
|
+
async readRaw(filePath) {
|
|
71
|
+
const { contextId, userId } = this._resolveContext()
|
|
72
|
+
const name = this._name(filePath)
|
|
73
|
+
const file = await this.fileStore.getInputFile(contextId, name, userId)
|
|
74
|
+
if (!file) return { error: `File not found: ${filePath}` }
|
|
75
|
+
return { bytes: file.bytes, mimeType: file.mimeType, size: file.size }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// CompositeBackend re-prepends the route prefix to paths returned here,
|
|
79
|
+
// so return bare "/<name>" paths (without /uploads/) to avoid double-prefix.
|
|
80
|
+
async list(_dirPath) {
|
|
81
|
+
const { contextId, userId } = this._resolveContext()
|
|
82
|
+
const files = await this.fileStore.listInputFiles(contextId, userId)
|
|
83
|
+
return files.map((f) => `/${f.name}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async ls(_path) {
|
|
87
|
+
const { contextId, userId } = this._resolveContext()
|
|
88
|
+
const files = await this.fileStore.listInputFiles(contextId, userId)
|
|
89
|
+
return {
|
|
90
|
+
files: files.map((f) => ({
|
|
91
|
+
path: `/${f.name}`,
|
|
92
|
+
is_dir: false,
|
|
93
|
+
size: f.size,
|
|
94
|
+
modified_at: "",
|
|
95
|
+
})),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async glob(pattern, _path) {
|
|
100
|
+
const { contextId, userId } = this._resolveContext()
|
|
101
|
+
const re = globToRegex(pattern)
|
|
102
|
+
const files = await this.fileStore.listInputFiles(contextId, userId)
|
|
103
|
+
return {
|
|
104
|
+
files: files
|
|
105
|
+
.filter((f) => re.test(f.name))
|
|
106
|
+
.map((f) => ({ path: `/${f.name}`, is_dir: false, size: f.size, modified_at: "" })),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async grep(pattern, _path, glob) {
|
|
111
|
+
const { contextId, userId } = this._resolveContext()
|
|
112
|
+
const reGlob = glob ? globToRegex(glob) : null
|
|
113
|
+
const files = await this.fileStore.listInputFiles(contextId, userId)
|
|
114
|
+
const candidates = files.filter(
|
|
115
|
+
(f) => (!reGlob || reGlob.test(f.name)) && isTextMime(f.mimeType),
|
|
116
|
+
)
|
|
117
|
+
const fetched = await Promise.all(
|
|
118
|
+
candidates.map((f) => this.fileStore.getInputFile(contextId, f.name, userId)),
|
|
119
|
+
)
|
|
120
|
+
const matches = []
|
|
121
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
122
|
+
const file = fetched[i]
|
|
123
|
+
if (!file) continue
|
|
124
|
+
const lines = (file.bytes ? file.bytes.toString("utf-8") : "").split("\n")
|
|
125
|
+
for (let j = 0; j < lines.length; j++) {
|
|
126
|
+
if (lines[j].includes(pattern)) {
|
|
127
|
+
matches.push({
|
|
128
|
+
path: `/${candidates[i].name}`,
|
|
129
|
+
line: j + 1,
|
|
130
|
+
text: lines[j],
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { matches }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async exists(filePath) {
|
|
139
|
+
const { contextId, userId } = this._resolveContext()
|
|
140
|
+
const name = this._name(filePath)
|
|
141
|
+
const file = await this.fileStore.getInputFile(contextId, name, userId)
|
|
142
|
+
return !!file
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async stat(filePath) {
|
|
146
|
+
const { contextId, userId } = this._resolveContext()
|
|
147
|
+
const name = this._name(filePath)
|
|
148
|
+
const file = await this.fileStore.getInputFile(contextId, name, userId)
|
|
149
|
+
if (!file) return null
|
|
150
|
+
return { name: file.name, mimeType: file.mimeType, size: file.size }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
write() {
|
|
154
|
+
return { error: "read-only" }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
edit() {
|
|
158
|
+
return { error: "read-only — uploads cannot be edited" }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
import { ReadonlyBackend } from "./backends/readonly-backend.js"
|
|
3
|
+
const { fs, path } = cds.utils
|
|
4
|
+
|
|
5
|
+
const LOG = cds.log("agents")
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Auto-build a `deepagents` graph from the convention-resolved agent directory.
|
|
9
|
+
*/
|
|
10
|
+
export async function createAutoDeepAgent(srv, agentDir) {
|
|
11
|
+
if (!agentDir) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`createAutoDeepAgent: agentDir is required (service: ${srv?.name ?? "<unknown>"})`,
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const tools = await srv.send("buildTools")
|
|
18
|
+
const model = await srv.send("buildModel", { deepAgent: true, tools })
|
|
19
|
+
const middleware = await srv.send("buildMiddleware", { model, tools })
|
|
20
|
+
const systemPrompt = await srv.send("buildSystemPrompt")
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
createDeepAgent,
|
|
24
|
+
StateBackend,
|
|
25
|
+
CompositeBackend,
|
|
26
|
+
createMemoryMiddleware,
|
|
27
|
+
createSkillsMiddleware,
|
|
28
|
+
} = await import("deepagents")
|
|
29
|
+
|
|
30
|
+
LOG.debug("Auto-building deep agent", {
|
|
31
|
+
service: srv?.name,
|
|
32
|
+
agentDir,
|
|
33
|
+
tools: tools.length,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const additionalBackends = {}
|
|
37
|
+
|
|
38
|
+
if (cds.env.agents?.fileIO?.enabled) {
|
|
39
|
+
const { CdsFileStore } = await import("../../protocol/persistence/file-store.js")
|
|
40
|
+
const { UploadsBackend } = await import("./backends/uploads-backend.js")
|
|
41
|
+
const { OutputsBackend } = await import("./backends/outputs-backend.js")
|
|
42
|
+
const fileStore = new CdsFileStore()
|
|
43
|
+
Object.assign(additionalBackends, {
|
|
44
|
+
"/uploads/": new UploadsBackend(fileStore),
|
|
45
|
+
"/outputs/": new OutputsBackend(fileStore),
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const agentFsBackend = new ReadonlyBackend({ rootDir: agentDir, virtualMode: true })
|
|
50
|
+
const agentDirMiddleware = []
|
|
51
|
+
if (fs.existsSync(path.join(agentDir, "AGENTS.md"))) {
|
|
52
|
+
agentDirMiddleware.push(
|
|
53
|
+
createMemoryMiddleware({
|
|
54
|
+
backend: agentFsBackend,
|
|
55
|
+
sources: ["./AGENTS.md"],
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
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
|
+
})
|
|
64
|
+
agentDirMiddleware.push(
|
|
65
|
+
createSkillsMiddleware({
|
|
66
|
+
backend: agentFsBackend,
|
|
67
|
+
sources: ["./skills"],
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const backend = new CompositeBackend(new StateBackend(), additionalBackends)
|
|
73
|
+
|
|
74
|
+
return createDeepAgent({
|
|
75
|
+
model,
|
|
76
|
+
tools,
|
|
77
|
+
systemPrompt,
|
|
78
|
+
backend,
|
|
79
|
+
middleware: [...agentDirMiddleware, ...middleware],
|
|
80
|
+
// checkpointer auto-injected by GraphExecutor (CdsCheckpointSaver)
|
|
81
|
+
})
|
|
82
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
import { createMiddleware } from "langchain"
|
|
3
|
+
import * as metrics from "../../telemetry/metrics.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Middleware that increments the `agent_actions` metric on every LLM invocation.
|
|
7
|
+
*/
|
|
8
|
+
export async function agentActionsMiddleware() {
|
|
9
|
+
return createMiddleware({
|
|
10
|
+
name: "agentActionsMiddleware",
|
|
11
|
+
afterModel: {
|
|
12
|
+
hook: () => {
|
|
13
|
+
metrics.agentActions.add(1, { "sap.tenantId": cds.context?.tenant || "anonymous" })
|
|
14
|
+
return {}
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
})
|
|
18
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
import { createMiddleware } from "langchain"
|
|
3
|
+
import { AIMessage } from "@langchain/core/messages"
|
|
4
|
+
import { OrchestrationClient } from "@sap-ai-sdk/langchain"
|
|
5
|
+
import { timeout, retry } from "@sap-cloud-sdk/resilience"
|
|
6
|
+
import { toSdkFilterFormat } from "../../../lib/models/aicore.js"
|
|
7
|
+
import { audit } from "../../utils/utils.js"
|
|
8
|
+
import { INSTRUMENTED } from "../../telemetry/tracing.js"
|
|
9
|
+
|
|
10
|
+
const LOG = cds.log("agents")
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extract HTTP status and response data from a wrapped AI SDK error wrapped
|
|
14
|
+
* within langchain middleware wraps.
|
|
15
|
+
*/
|
|
16
|
+
function extractErrorDetails(err) {
|
|
17
|
+
let current = err
|
|
18
|
+
while (current) {
|
|
19
|
+
// Direct properties (e.g. MiddlewareError that has them)
|
|
20
|
+
if (current.rootCause?.status != null) {
|
|
21
|
+
return { status: current.rootCause.status, data: current.rootCause.response?.data }
|
|
22
|
+
}
|
|
23
|
+
// Check if current error itself is an axios-like response
|
|
24
|
+
if (current.status != null && current.response?.data) {
|
|
25
|
+
return { status: current.status, data: current.response.data }
|
|
26
|
+
}
|
|
27
|
+
current = current.cause
|
|
28
|
+
}
|
|
29
|
+
return { status: undefined, data: undefined }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Content filter middleware - workaround for aicore limitations on input filtering.
|
|
34
|
+
* Checks latest user input / tool result against input filters via a
|
|
35
|
+
* cheap model call (max_tokens:1) before the real model processes full context.
|
|
36
|
+
*/
|
|
37
|
+
export async function contentFilterMiddleware(model) {
|
|
38
|
+
const { contentFilter } = model?.options ?? {}
|
|
39
|
+
if (!contentFilter?.input) return
|
|
40
|
+
|
|
41
|
+
const inputFiltering = toSdkFilterFormat({ input: contentFilter.input })
|
|
42
|
+
|
|
43
|
+
const inputFilterModel = new OrchestrationClient(
|
|
44
|
+
{
|
|
45
|
+
promptTemplating: { model: { name: "gpt-4o-mini", params: { max_tokens: 1 } } },
|
|
46
|
+
filtering: inputFiltering,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
disableStreaming: true,
|
|
50
|
+
customRequestConfig: {
|
|
51
|
+
middleware: [timeout(10_000), retry(2)],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
model?.deploymentConfig,
|
|
55
|
+
model?.destination,
|
|
56
|
+
)
|
|
57
|
+
// Opt this internal probe out of generic LangChain tracing: it runs before the
|
|
58
|
+
// real model (BeforeModelNode) and would otherwise emit a "chat OrchestrationClient"
|
|
59
|
+
// span that shadows the real model's chat span in same-trace span lookups.
|
|
60
|
+
inputFilterModel[INSTRUMENTED] = true
|
|
61
|
+
|
|
62
|
+
return createMiddleware({
|
|
63
|
+
name: "ContentFilterMiddleware",
|
|
64
|
+
beforeModel: {
|
|
65
|
+
canJumpTo: ["end"],
|
|
66
|
+
hook: async (state) => {
|
|
67
|
+
const messages = state.messages || []
|
|
68
|
+
|
|
69
|
+
// Sanitize poisoned tool messages that triggered a prior content filter block.
|
|
70
|
+
// Detected by: tool message immediately followed by AI "malicious result" recovery.
|
|
71
|
+
for (let i = 0; i < messages.length - 1; i++) {
|
|
72
|
+
const type = messages[i]._getType?.() || messages[i].type
|
|
73
|
+
const nextType = messages[i + 1]?._getType?.() || messages[i + 1]?.type
|
|
74
|
+
if (type === "tool" && nextType === "ai") {
|
|
75
|
+
const nextContent = messages[i + 1]?.content || ""
|
|
76
|
+
if (/malicious result/i.test(nextContent) && messages[i].content) {
|
|
77
|
+
messages[i].content = ""
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Extract messages after the last AI message (new input for this model call)
|
|
83
|
+
const toFilter = []
|
|
84
|
+
let hasHumanMessage = false
|
|
85
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
86
|
+
const type = messages[i]._getType?.() || messages[i].type
|
|
87
|
+
if (type === "human") {
|
|
88
|
+
toFilter.unshift(messages[i])
|
|
89
|
+
hasHumanMessage = true
|
|
90
|
+
} else if (type === "tool") {
|
|
91
|
+
toFilter.unshift(messages[i])
|
|
92
|
+
} else {
|
|
93
|
+
// Stop at first AI/system message — anything before is already processed
|
|
94
|
+
break
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (toFilter.length === 0) return
|
|
99
|
+
|
|
100
|
+
// Convert tool messages to human messages for the filter model
|
|
101
|
+
// (OrchestrationClient requires human messages to trigger input filter)
|
|
102
|
+
const { HumanMessage } = await import("@langchain/core/messages")
|
|
103
|
+
const filterMessages = toFilter.map((m) => {
|
|
104
|
+
const type = m._getType?.() || m.type
|
|
105
|
+
if (type === "tool") {
|
|
106
|
+
const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content)
|
|
107
|
+
return new HumanMessage(content)
|
|
108
|
+
}
|
|
109
|
+
return m
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
await inputFilterModel.invoke(filterMessages)
|
|
114
|
+
} catch (err) {
|
|
115
|
+
const { status, data } = extractErrorDetails(err)
|
|
116
|
+
const isFilterBlock =
|
|
117
|
+
/Filtering Module/i.test(data?.error?.location || "") && status === 400
|
|
118
|
+
|
|
119
|
+
if (isFilterBlock) {
|
|
120
|
+
LOG.warn("Content filter blocked input", { reason: data?.error?.message })
|
|
121
|
+
audit("ContentFilterBlocked", {
|
|
122
|
+
data: {
|
|
123
|
+
service: cds.context?.["agent.service"],
|
|
124
|
+
user: cds.context?.user?.id,
|
|
125
|
+
taskId: cds.context?.["agent.task.id"],
|
|
126
|
+
reason: data?.error?.message,
|
|
127
|
+
source: hasHumanMessage ? "user" : "tool",
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
// Only terminate for human messages; tool results continue (let model handle)
|
|
131
|
+
if (hasHumanMessage) {
|
|
132
|
+
return {
|
|
133
|
+
jumpTo: "end",
|
|
134
|
+
messages: [new AIMessage(cds.i18n.messages.at("CONTENT_FILTER_BLOCKED"))],
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
// Fail closed: unrecognized errors (timeout, 5xx, network) block the message
|
|
139
|
+
LOG.warn("Content filter unavailable — blocking input", {
|
|
140
|
+
error: err.message,
|
|
141
|
+
status,
|
|
142
|
+
})
|
|
143
|
+
if (hasHumanMessage) {
|
|
144
|
+
return {
|
|
145
|
+
jumpTo: "end",
|
|
146
|
+
messages: [new AIMessage(cds.i18n.messages.at("CONTENT_FILTER_UNAVAILABLE"))],
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
wrapModelCall: async (request, handler) => {
|
|
154
|
+
try {
|
|
155
|
+
return await handler(request)
|
|
156
|
+
} catch (err) {
|
|
157
|
+
const { status, data } = extractErrorDetails(err)
|
|
158
|
+
const isFilterBlock =
|
|
159
|
+
/Filtering Module/i.test(data?.error?.location || "") && status === 400
|
|
160
|
+
|
|
161
|
+
if (isFilterBlock) {
|
|
162
|
+
const lastMsg = request.state?.messages?.at(-1)
|
|
163
|
+
const isTool = lastMsg?._getType?.() === "tool" || lastMsg?.type === "tool"
|
|
164
|
+
const reason = data?.error?.message || "Content filter blocked"
|
|
165
|
+
|
|
166
|
+
LOG.warn("Content filter blocked model response", {
|
|
167
|
+
reason,
|
|
168
|
+
source: isTool ? "tool" : "model",
|
|
169
|
+
})
|
|
170
|
+
audit("ContentFilterBlocked", {
|
|
171
|
+
data: {
|
|
172
|
+
service: cds.context?.["agent.service"],
|
|
173
|
+
user: cds.context?.user?.id,
|
|
174
|
+
taskId: cds.context?.["agent.task.id"],
|
|
175
|
+
reason,
|
|
176
|
+
source: isTool ? "tool" : "model",
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// Return graceful AI message so the agent can recover
|
|
181
|
+
const message = isTool
|
|
182
|
+
? cds.i18n.messages.at("CONTENT_FILTER_TOOL_BLOCKED")
|
|
183
|
+
: reason.split("-").at(-1)?.trim() || cds.i18n.messages.at("CONTENT_FILTER_BLOCKED")
|
|
184
|
+
|
|
185
|
+
return new AIMessage(message)
|
|
186
|
+
}
|
|
187
|
+
throw err
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
})
|
|
191
|
+
}
|