@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,152 @@
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 { isTextMime, globToRegex } from "./mime-utils.js"
13
+
14
+ function inferMimeType(name) {
15
+ const ext = name?.split(".").pop()?.toLowerCase()
16
+ const map = {
17
+ csv: "text/csv",
18
+ txt: "text/plain",
19
+ md: "text/markdown",
20
+ html: "text/html",
21
+ htm: "text/html",
22
+ json: "application/json",
23
+ xml: "application/xml",
24
+ yaml: "application/yaml",
25
+ yml: "application/yaml",
26
+ pdf: "application/pdf",
27
+ png: "image/png",
28
+ jpg: "image/jpeg",
29
+ jpeg: "image/jpeg",
30
+ gif: "image/gif",
31
+ svg: "image/svg+xml",
32
+ js: "application/javascript",
33
+ ts: "text/typescript",
34
+ py: "text/x-python",
35
+ sql: "application/sql",
36
+ }
37
+ return map[ext] || "application/octet-stream"
38
+ }
39
+
40
+ export class OutputsBackend {
41
+ constructor(taskId, fileStore) {
42
+ this.taskId = taskId
43
+ this.fileStore = fileStore
44
+ }
45
+
46
+ // CompositeBackend strips the route prefix (/outputs/) before delegating,
47
+ // so paths arrive here as either "/name" (stripped) or "/outputs/name" (direct).
48
+ // _name() handles both forms.
49
+ _name(filePath) {
50
+ return filePath.replace(/^\/outputs\//, "").replace(/^\//, "")
51
+ }
52
+
53
+ async write(filePath, content, options = {}) {
54
+ const name = this._name(filePath)
55
+ const mimeType = options.mimeType || inferMimeType(name)
56
+ const buf = typeof content === "string" ? Buffer.from(content, "utf-8") : Buffer.from(content)
57
+ await this.fileStore.saveOutputFile(this.taskId, name, mimeType, buf)
58
+ return { success: true }
59
+ }
60
+
61
+ async exists(filePath) {
62
+ const name = this._name(filePath)
63
+ const files = await this.fileStore.listOutputFilesMeta(this.taskId)
64
+ return files.some((f) => f.name === name)
65
+ }
66
+
67
+ async stat(filePath) {
68
+ const name = this._name(filePath)
69
+ const files = await this.fileStore.listOutputFilesMeta(this.taskId)
70
+ const file = files.find((f) => f.name === name)
71
+ if (!file) return null
72
+ return { name: file.name, mimeType: file.mimeType, size: file.size }
73
+ }
74
+
75
+ // CompositeBackend re-prepends the route prefix to paths returned here,
76
+ // so return bare "/<name>" paths (without /outputs/) to avoid double-prefix.
77
+ async list(_dirPath) {
78
+ const files = await this.fileStore.listOutputFilesMeta(this.taskId)
79
+ return files.map((f) => `/${f.name}`)
80
+ }
81
+
82
+ async ls(_path) {
83
+ const files = await this.fileStore.listOutputFilesMeta(this.taskId)
84
+ return {
85
+ files: files.map((f) => ({
86
+ path: `/${f.name}`,
87
+ is_dir: false,
88
+ size: f.size,
89
+ modified_at: "",
90
+ })),
91
+ }
92
+ }
93
+
94
+ async glob(pattern, _path) {
95
+ const re = globToRegex(pattern)
96
+ const files = await this.fileStore.listOutputFilesMeta(this.taskId)
97
+ return {
98
+ files: files
99
+ .filter((f) => re.test(f.name))
100
+ .map((f) => ({ path: `/${f.name}`, is_dir: false, size: f.size, modified_at: "" })),
101
+ }
102
+ }
103
+
104
+ async grep(pattern, _path, glob) {
105
+ const reGlob = glob ? globToRegex(glob) : null
106
+ const files = await this.fileStore.listOutputFiles(this.taskId)
107
+ const matches = []
108
+ for (const f of files) {
109
+ if (reGlob && !reGlob.test(f.name)) continue
110
+ if (!isTextMime(f.mimeType)) continue
111
+ const lines = (f.bytes ? Buffer.from(f.bytes).toString("utf-8") : "").split("\n")
112
+ for (let i = 0; i < lines.length; i++) {
113
+ if (lines[i].includes(pattern)) {
114
+ matches.push({ path: `/${f.name}`, line: i + 1, text: lines[i] })
115
+ }
116
+ }
117
+ }
118
+ return { matches }
119
+ }
120
+
121
+ async read(filePath, offset = 0, limit = 100) {
122
+ const name = this._name(filePath)
123
+ const file = await this.fileStore.getOutputFile(this.taskId, name)
124
+ if (!file) {
125
+ const files = await this.fileStore.listOutputFilesMeta(this.taskId)
126
+ const available = files.map((f) => `/outputs/${f.name}`).join(", ") || "none"
127
+ return { error: `File not found: ${filePath}. Available: ${available}` }
128
+ }
129
+ if (!isTextMime(file.mimeType)) {
130
+ return { error: `"${name}" is a binary file (${file.mimeType}) — cannot be read as text.` }
131
+ }
132
+ const text = file.bytes ? Buffer.from(file.bytes).toString("utf-8") : ""
133
+ const lines = text.split("\n")
134
+ return {
135
+ content: lines.slice(offset, offset + limit).join("\n"),
136
+ mimeType: file.mimeType,
137
+ }
138
+ }
139
+
140
+ // Defensive stub for deepagents' BackendProtocolV2 — see UploadsBackend.readRaw
141
+ // for the rationale. Currently unused by our shipped code paths; do not delete.
142
+ async readRaw(filePath) {
143
+ const name = this._name(filePath)
144
+ const file = await this.fileStore.getOutputFile(this.taskId, name)
145
+ if (!file) return { error: `File not found: ${filePath}` }
146
+ return { bytes: file.bytes, mimeType: file.mimeType, size: file.size }
147
+ }
148
+
149
+ edit() {
150
+ return { error: "use write_file to overwrite an output file" }
151
+ }
152
+ }
@@ -0,0 +1,143 @@
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 { isTextMime, globToRegex } from "./mime-utils.js"
13
+
14
+ export class UploadsBackend {
15
+ constructor(contextId, fileStore, userId) {
16
+ this.contextId = contextId
17
+ this.fileStore = fileStore
18
+ this.userId = userId
19
+ }
20
+
21
+ // CompositeBackend strips the route prefix (/uploads/) before delegating,
22
+ // so paths arrive here as either "/name" (stripped) or "/uploads/name" (direct).
23
+ // normalise() handles both forms.
24
+ _name(filePath) {
25
+ return filePath.replace(/^\/uploads\//, "").replace(/^\//, "")
26
+ }
27
+
28
+ async read(filePath, offset = 0, limit = 100) {
29
+ const name = this._name(filePath)
30
+ const file = await this.fileStore.getInputFile(this.contextId, name, this.userId)
31
+
32
+ if (!file) {
33
+ const available =
34
+ (await this.fileStore.listInputFiles(this.contextId, this.userId))
35
+ .map((f) => `/uploads/${f.name}`)
36
+ .join(", ") || "none"
37
+ return { error: `File not found: ${filePath}. Available: ${available}` }
38
+ }
39
+
40
+ if (file.mimeType?.startsWith("image/")) {
41
+ return { error: `"${name}" is an image. Use an image analysis tool.` }
42
+ }
43
+
44
+ if (!isTextMime(file.mimeType)) {
45
+ return { error: `"${name}" is a binary file (${file.mimeType}) — cannot be read as text.` }
46
+ }
47
+
48
+ const lines = (file.bytes ? file.bytes.toString("utf-8") : "").split("\n")
49
+ return {
50
+ content: lines.slice(offset, offset + limit).join("\n"),
51
+ mimeType: file.mimeType,
52
+ }
53
+ }
54
+
55
+ // Defensive stub for deepagents' BackendProtocolV2: callers that bypass the
56
+ // text-only `read()` path (binary handling, future deepagents versions) hit
57
+ // this method. Without it, `compositeBackend.readRaw('/uploads/<name>')`
58
+ // throws `TypeError: backend.readRaw is not a function`. Currently unused
59
+ // by our shipped code paths — do not delete as "dead".
60
+ async readRaw(filePath) {
61
+ const name = this._name(filePath)
62
+ const file = await this.fileStore.getInputFile(this.contextId, name, this.userId)
63
+ if (!file) return { error: `File not found: ${filePath}` }
64
+ return { bytes: file.bytes, mimeType: file.mimeType, size: file.size }
65
+ }
66
+
67
+ // CompositeBackend re-prepends the route prefix to paths returned here,
68
+ // so return bare "/<name>" paths (without /uploads/) to avoid double-prefix.
69
+ async list(_dirPath) {
70
+ const files = await this.fileStore.listInputFiles(this.contextId, this.userId)
71
+ return files.map((f) => `/${f.name}`)
72
+ }
73
+
74
+ async ls(_path) {
75
+ const files = await this.fileStore.listInputFiles(this.contextId, this.userId)
76
+ return {
77
+ files: files.map((f) => ({
78
+ path: `/${f.name}`,
79
+ is_dir: false,
80
+ size: f.size,
81
+ modified_at: "",
82
+ })),
83
+ }
84
+ }
85
+
86
+ async glob(pattern, _path) {
87
+ const re = globToRegex(pattern)
88
+ const files = await this.fileStore.listInputFiles(this.contextId, this.userId)
89
+ return {
90
+ files: files
91
+ .filter((f) => re.test(f.name))
92
+ .map((f) => ({ path: `/${f.name}`, is_dir: false, size: f.size, modified_at: "" })),
93
+ }
94
+ }
95
+
96
+ async grep(pattern, _path, glob) {
97
+ const reGlob = glob ? globToRegex(glob) : null
98
+ const files = await this.fileStore.listInputFiles(this.contextId, this.userId)
99
+ const candidates = files.filter(
100
+ (f) => (!reGlob || reGlob.test(f.name)) && isTextMime(f.mimeType),
101
+ )
102
+ const fetched = await Promise.all(
103
+ candidates.map((f) => this.fileStore.getInputFile(this.contextId, f.name, this.userId)),
104
+ )
105
+ const matches = []
106
+ for (let i = 0; i < candidates.length; i++) {
107
+ const file = fetched[i]
108
+ if (!file) continue
109
+ const lines = (file.bytes ? file.bytes.toString("utf-8") : "").split("\n")
110
+ for (let j = 0; j < lines.length; j++) {
111
+ if (lines[j].includes(pattern)) {
112
+ matches.push({
113
+ path: `/${candidates[i].name}`,
114
+ line: j + 1,
115
+ text: lines[j],
116
+ })
117
+ }
118
+ }
119
+ }
120
+ return { matches }
121
+ }
122
+
123
+ async exists(filePath) {
124
+ const name = this._name(filePath)
125
+ const file = await this.fileStore.getInputFile(this.contextId, name, this.userId)
126
+ return !!file
127
+ }
128
+
129
+ async stat(filePath) {
130
+ const name = this._name(filePath)
131
+ const file = await this.fileStore.getInputFile(this.contextId, name, this.userId)
132
+ if (!file) return null
133
+ return { name: file.name, mimeType: file.mimeType, size: file.size }
134
+ }
135
+
136
+ write() {
137
+ return { error: "read-only" }
138
+ }
139
+
140
+ edit() {
141
+ return { error: "read-only — uploads cannot be edited" }
142
+ }
143
+ }
@@ -0,0 +1,93 @@
1
+ import cds from "@sap/cds"
2
+ const { fs, path } = cds.utils
3
+
4
+ const LOG = cds.log("agents")
5
+
6
+ /**
7
+ * Auto-build a `deepagents` graph from the convention-resolved agent directory.
8
+ */
9
+ export async function createAutoDeepAgent(srv, agentDir) {
10
+ if (!agentDir) {
11
+ throw new Error(
12
+ `createAutoDeepAgent: agentDir is required (service: ${srv?.name ?? "<unknown>"})`,
13
+ )
14
+ }
15
+
16
+ const tools = await srv.send("buildTools")
17
+ const model = await srv.send("buildModel", { deepAgent: true, tools })
18
+ const middleware = await srv.send("buildMiddleware", { model, tools })
19
+ const systemPrompt = await srv.send("buildSystemPrompt")
20
+
21
+ const {
22
+ createDeepAgent,
23
+ StateBackend,
24
+ CompositeBackend,
25
+ FilesystemBackend,
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
+ let backend
37
+ if (cds.env.agents?.fileIO?.enabled) {
38
+ const { CdsFileStore } = await import("../../protocol/persistence/file-store.js")
39
+ const { UploadsBackend } = await import("./backends/uploads-backend.js")
40
+ const { OutputsBackend } = await import("./backends/outputs-backend.js")
41
+ const fileStore = new CdsFileStore()
42
+ backend = (runtime) => {
43
+ const rawThreadId = runtime?.configurable?.thread_id || ""
44
+ const contextId = rawThreadId.includes(":")
45
+ ? rawThreadId.split(":").slice(1).join(":")
46
+ : rawThreadId
47
+ const taskId = runtime?.configurable?._taskId || ""
48
+ // GraphExecutor threads the request-entry user id as `_userId` so the
49
+ // backend keeps user isolation even if cds.context drifts inside the
50
+ // agent's tool callbacks. Falls back to cds.context for compatibility.
51
+ const userId = runtime?.configurable?._userId
52
+ return new CompositeBackend(
53
+ new StateBackend(),
54
+ {
55
+ "/uploads/": new UploadsBackend(contextId, fileStore, userId),
56
+ "/outputs/": new OutputsBackend(taskId, fileStore),
57
+ },
58
+ // Fallback to StateBackend for any other paths so deepagents'
59
+ // built-in tools that read state still work.
60
+ )
61
+ }
62
+ } else {
63
+ backend = new StateBackend()
64
+ }
65
+
66
+ const agentFsBackend = new FilesystemBackend({ rootDir: agentDir })
67
+ const agentDirMiddleware = []
68
+ if (fs.existsSync(path.join(agentDir, "AGENTS.md"))) {
69
+ agentDirMiddleware.push(
70
+ createMemoryMiddleware({
71
+ backend: agentFsBackend,
72
+ sources: ["./AGENTS.md"],
73
+ }),
74
+ )
75
+ }
76
+ if (fs.existsSync(path.join(agentDir, "skills"))) {
77
+ agentDirMiddleware.push(
78
+ createSkillsMiddleware({
79
+ backend: agentFsBackend,
80
+ sources: ["./skills/"],
81
+ }),
82
+ )
83
+ }
84
+
85
+ return createDeepAgent({
86
+ model,
87
+ tools,
88
+ systemPrompt,
89
+ backend,
90
+ middleware: [...agentDirMiddleware, ...middleware],
91
+ // checkpointer auto-injected by GraphExecutor (CdsCheckpointSaver)
92
+ })
93
+ }
@@ -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
+ }
@@ -0,0 +1,18 @@
1
+ import { createMiddleware } from "langchain"
2
+ import { HumanMessage } from "@langchain/core/messages"
3
+ import { z } from "zod"
4
+
5
+ // Injects a HumanMessage for a pending _hitlEditNote before the next model turn.
6
+ export function hitlEditNoteInjectorMiddleware() {
7
+ return createMiddleware({
8
+ name: "hitlEditNoteInjectorMiddleware",
9
+ stateSchema: z.object({ _hitlEditNote: z.string().optional() }),
10
+ beforeModel: async (state) => {
11
+ if (!state._hitlEditNote) return
12
+ return {
13
+ messages: [new HumanMessage(state._hitlEditNote)],
14
+ _hitlEditNote: undefined,
15
+ }
16
+ },
17
+ })
18
+ }
@@ -0,0 +1,20 @@
1
+ import { humanInTheLoopMiddleware as hitl } from "langchain"
2
+ import { hitlEditNoteInjectorMiddleware } from "./hitl-edit-note-injector.js"
3
+
4
+ function buildHitlInterruptMap(srv, tools = []) {
5
+ return tools.reduce((interruptOn, tool) => {
6
+ if (
7
+ srv.actions[tool.name]?.["@agent.hitl"] ??
8
+ srv.actions[tool.name]?.["@Common.IsActionCritical"]
9
+ ) {
10
+ interruptOn[tool.name] = { allowedDecisions: ["approve", "reject", "edit"] }
11
+ }
12
+ return interruptOn
13
+ }, {})
14
+ }
15
+
16
+ export async function humanInTheLoopMiddleware(srv, tools) {
17
+ const interruptOn = buildHitlInterruptMap(srv, tools)
18
+ if (!Object.keys(interruptOn).length) return []
19
+ return [hitl({ interruptOn }), hitlEditNoteInjectorMiddleware()]
20
+ }
@@ -0,0 +1,19 @@
1
+ export default async function buildMiddleware(srv, options = {}) {
2
+ const { tools, model } = options
3
+ const { quotaEnforcerMiddleware } = await import("./quota-enforcer.js")
4
+ const { contentFilterMiddleware } = await import("./content-filter.js")
5
+ const { agentActionsMiddleware } = await import("./agent-actions.js")
6
+ const { patchToolCallsMiddleware } = await import("./patch-tool-calls.js")
7
+ const { statusUpdateMiddleware } = await import("./status-update.js")
8
+ const { humanInTheLoopMiddleware } = await import("./hitl.js")
9
+ const { toolSelectionMiddleware } = await import("./tool-selection.js")
10
+ return [
11
+ ...(await quotaEnforcerMiddleware()),
12
+ await contentFilterMiddleware(model),
13
+ await agentActionsMiddleware(),
14
+ patchToolCallsMiddleware(),
15
+ await statusUpdateMiddleware(),
16
+ ...(await humanInTheLoopMiddleware(srv, tools)),
17
+ toolSelectionMiddleware(),
18
+ ].filter(Boolean)
19
+ }