@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.
Files changed (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +154 -0
  3. package/_i18n/messages.properties +31 -0
  4. package/cds-plugin.js +140 -0
  5. package/index.cds +116 -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 +156 -0
  9. package/lib/agents/markdown/backends/readonly-backend.js +48 -0
  10. package/lib/agents/markdown/backends/uploads-backend.js +160 -0
  11. package/lib/agents/markdown/deep-agent.js +82 -0
  12. package/lib/agents/middleware/agent-actions.js +18 -0
  13. package/lib/agents/middleware/content-filter.js +191 -0
  14. package/lib/agents/middleware/hitl-edit-note-injector.js +18 -0
  15. package/lib/agents/middleware/hitl.js +20 -0
  16. package/lib/agents/middleware/index.js +19 -0
  17. package/lib/agents/middleware/patch-tool-calls.js +51 -0
  18. package/lib/agents/middleware/quota-enforcer.js +94 -0
  19. package/lib/agents/middleware/status-update.js +153 -0
  20. package/lib/agents/middleware/tool-selection.js +22 -0
  21. package/lib/agents/quota-enforcer-at-start.js +198 -0
  22. package/lib/agents/summarize-on-timeout.js +91 -0
  23. package/lib/compile.js +55 -0
  24. package/lib/index.cjs +1 -0
  25. package/lib/index.js +422 -0
  26. package/lib/models/aicore.js +441 -0
  27. package/lib/models/anthropic.js +77 -0
  28. package/lib/models/mock.js +88 -0
  29. package/lib/preview/chat.html +897 -0
  30. package/lib/preview/preview.js +46 -0
  31. package/lib/protocol/agent-card.js +297 -0
  32. package/lib/protocol/persistence/checkpoint-saver.js +320 -0
  33. package/lib/protocol/persistence/cleanup.js +84 -0
  34. package/lib/protocol/persistence/file-store.js +209 -0
  35. package/lib/protocol/persistence/push-notification-store.js +59 -0
  36. package/lib/protocol/persistence/task-store.js +47 -0
  37. package/lib/protocol/push-notification-sender.js +57 -0
  38. package/lib/sidecar.js +162 -0
  39. package/lib/telemetry/active-users.js +106 -0
  40. package/lib/telemetry/chat-tracing.js +364 -0
  41. package/lib/telemetry/metrics.js +85 -0
  42. package/lib/telemetry/mlflow.js +290 -0
  43. package/lib/telemetry/tool-tracing.js +164 -0
  44. package/lib/telemetry/tracing.js +150 -0
  45. package/lib/utils/inner-auth.js +33 -0
  46. package/lib/utils/markdown.js +199 -0
  47. package/lib/utils/message-handling.js +155 -0
  48. package/lib/utils/utils.js +168 -0
  49. package/package.json +225 -2
  50. package/srv/graph-cache.js +82 -0
  51. package/srv/handlers/graph-executor.js +1374 -0
  52. package/srv/handlers/index.js +178 -0
  53. package/srv/handlers/mcp-tools.js +161 -0
  54. package/srv/handlers/sub-agent-tools.js +316 -0
  55. package/srv/handlers/system-prompt.js +25 -0
  56. package/srv/handlers/tools.js +366 -0
  57. package/srv/langgraph-executor-srv.js +70 -0
  58. 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
+ }