@cap-js/agents 0.9.1 → 0.9.3
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/README.md +2 -1
- package/cds-plugin.js +2 -2
- package/lib/agents/markdown/backends/outputs-backend.js +16 -12
- package/lib/agents/markdown/backends/readonly-backend.js +48 -0
- package/lib/agents/markdown/backends/uploads-backend.js +30 -13
- package/lib/agents/markdown/deep-agent.js +15 -26
- package/lib/agents/middleware/content-filter.js +3 -2
- package/lib/agents/middleware/index.js +11 -0
- package/lib/agents/middleware/remote-mcp.js +62 -0
- package/lib/agents/middleware/tool-wrap.js +52 -0
- package/lib/compile.js +6 -2
- package/lib/eval/Judge.js +239 -0
- package/lib/eval/eval-describe.js +69 -0
- package/lib/eval/eval-run.js +147 -0
- package/lib/eval/index.js +6 -0
- package/lib/eval/metrics.js +53 -0
- package/lib/eval/span-collector.js +50 -0
- package/lib/index.js +4 -3
- package/lib/models/aicore.js +23 -6
- package/lib/preview/chat.html +394 -69
- package/lib/protocol/agent-card.js +6 -2
- package/lib/protocol/persistence/checkpoint-saver.js +4 -1
- package/lib/protocol/persistence/cleanup.js +84 -0
- package/lib/sidecar.js +1 -1
- package/lib/telemetry/chat-tracing.js +61 -8
- package/lib/telemetry/mlflow/credentials.js +79 -0
- package/lib/telemetry/mlflow/evaluation.js +38 -0
- package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +75 -0
- package/lib/telemetry/mlflow/exporter/MlflowExporter.js +115 -0
- package/lib/telemetry/mlflow/exporter/index.js +23 -0
- package/lib/telemetry/mlflow/index.js +16 -0
- package/lib/telemetry/mlflow/prompts.js +123 -0
- package/lib/telemetry/mlflow/tracing.js +264 -0
- package/lib/telemetry/tool-tracing.js +27 -54
- package/lib/telemetry/tracing.js +3 -1
- package/lib/utils/markdown.js +1 -11
- package/lib/utils/resilience.js +133 -0
- package/lib/utils/utils.js +17 -0
- package/package.json +24 -15
- package/{index.cds → srv/entities.cds} +11 -0
- package/srv/handlers/chat.js +278 -0
- package/srv/handlers/graph-executor.js +48 -37
- package/srv/handlers/index.js +8 -0
- package/srv/handlers/mcp-tools.js +11 -47
- package/srv/handlers/sub-agent-tools.js +12 -3
- package/srv/handlers/tools.js +9 -5
- package/index.js +0 -0
- package/lib/telemetry/mlflow.js +0 -290
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ Both approaches start from a CDS service annotated with `@agent` (as shown in [R
|
|
|
54
54
|
|
|
55
55
|
### Agentify Existing CAP Services
|
|
56
56
|
|
|
57
|
-
With `@agent` alone, the plugin auto-generates tools from the service's entities and actions, creates a ReAct agent loop, and serves it as a remote agent — no code required.
|
|
57
|
+
With `@agent` alone, the plugin auto-generates tools from the service's entities and actions, creates a [ReAct agent](https://arxiv.org/abs/2210.03629) loop, and serves it as a remote agent — no code required.
|
|
58
58
|
|
|
59
59
|
```cds
|
|
60
60
|
@agent
|
|
@@ -135,6 +135,7 @@ The following capabilities are experimental and documented separately. Their pub
|
|
|
135
135
|
- [Configuration](.docs/configuration.md) — using multiple models, global and per-service settings, file I/O, and push notifications
|
|
136
136
|
- [Quota Enforcement](.docs/quota.md) — configurable rate limits and resource quotas
|
|
137
137
|
- [Audit Logging](.docs/audit-logging.md) — immutable audit trail of agent decisions and tool usage
|
|
138
|
+
- [Data Privacy](.docs/data-privacy.md) — deletion of message history
|
|
138
139
|
- [Telemetry](.docs/telemetry.md) — OpenTelemetry metrics, tracing, and MLflow export
|
|
139
140
|
- [Content Filter](.docs/content-filter.md) — SAP AI Core content filtering and prompt injection shielding
|
|
140
141
|
|
package/cds-plugin.js
CHANGED
|
@@ -4,7 +4,7 @@ const LOG = cds.log("agents")
|
|
|
4
4
|
import { patchLangChain } from "./lib/telemetry/tracing.js"
|
|
5
5
|
import cds_compile_to_a2a from "./lib/compile.js"
|
|
6
6
|
import registerDefaultAgentHandlers from "./srv/handlers/index.js"
|
|
7
|
-
import { slugified } from "./lib/utils/
|
|
7
|
+
import { slugified } from "./lib/utils/utils.js"
|
|
8
8
|
|
|
9
9
|
cds.compile.to.a2a = cds_compile_to_a2a
|
|
10
10
|
|
|
@@ -96,7 +96,7 @@ cds.on("served", async () => {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
if (cds.env.agents?.mlflow) {
|
|
99
|
-
const { setupMlflowExporter } = await import("./lib/telemetry/mlflow.js")
|
|
99
|
+
const { setupMlflowExporter } = await import("./lib/telemetry/mlflow/index.js")
|
|
100
100
|
setupMlflowExporter()
|
|
101
101
|
}
|
|
102
102
|
})
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { getConfig } from "@langchain/langgraph"
|
|
12
13
|
import { isTextMime, globToRegex } from "./mime-utils.js"
|
|
13
14
|
|
|
14
15
|
function inferMimeType(name) {
|
|
@@ -38,11 +39,14 @@ function inferMimeType(name) {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
export class OutputsBackend {
|
|
41
|
-
constructor(
|
|
42
|
-
this.taskId = taskId
|
|
42
|
+
constructor(fileStore) {
|
|
43
43
|
this.fileStore = fileStore
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
_taskId() {
|
|
47
|
+
return getConfig()?.configurable?._taskId || ""
|
|
48
|
+
}
|
|
49
|
+
|
|
46
50
|
// CompositeBackend strips the route prefix (/outputs/) before delegating,
|
|
47
51
|
// so paths arrive here as either "/name" (stripped) or "/outputs/name" (direct).
|
|
48
52
|
// _name() handles both forms.
|
|
@@ -54,19 +58,19 @@ export class OutputsBackend {
|
|
|
54
58
|
const name = this._name(filePath)
|
|
55
59
|
const mimeType = options.mimeType || inferMimeType(name)
|
|
56
60
|
const buf = typeof content === "string" ? Buffer.from(content, "utf-8") : Buffer.from(content)
|
|
57
|
-
await this.fileStore.saveOutputFile(this.
|
|
61
|
+
await this.fileStore.saveOutputFile(this._taskId(), name, mimeType, buf)
|
|
58
62
|
return { success: true }
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
async exists(filePath) {
|
|
62
66
|
const name = this._name(filePath)
|
|
63
|
-
const files = await this.fileStore.listOutputFilesMeta(this.
|
|
67
|
+
const files = await this.fileStore.listOutputFilesMeta(this._taskId())
|
|
64
68
|
return files.some((f) => f.name === name)
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
async stat(filePath) {
|
|
68
72
|
const name = this._name(filePath)
|
|
69
|
-
const files = await this.fileStore.listOutputFilesMeta(this.
|
|
73
|
+
const files = await this.fileStore.listOutputFilesMeta(this._taskId())
|
|
70
74
|
const file = files.find((f) => f.name === name)
|
|
71
75
|
if (!file) return null
|
|
72
76
|
return { name: file.name, mimeType: file.mimeType, size: file.size }
|
|
@@ -75,12 +79,12 @@ export class OutputsBackend {
|
|
|
75
79
|
// CompositeBackend re-prepends the route prefix to paths returned here,
|
|
76
80
|
// so return bare "/<name>" paths (without /outputs/) to avoid double-prefix.
|
|
77
81
|
async list(_dirPath) {
|
|
78
|
-
const files = await this.fileStore.listOutputFilesMeta(this.
|
|
82
|
+
const files = await this.fileStore.listOutputFilesMeta(this._taskId())
|
|
79
83
|
return files.map((f) => `/${f.name}`)
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
async ls(_path) {
|
|
83
|
-
const files = await this.fileStore.listOutputFilesMeta(this.
|
|
87
|
+
const files = await this.fileStore.listOutputFilesMeta(this._taskId())
|
|
84
88
|
return {
|
|
85
89
|
files: files.map((f) => ({
|
|
86
90
|
path: `/${f.name}`,
|
|
@@ -93,7 +97,7 @@ export class OutputsBackend {
|
|
|
93
97
|
|
|
94
98
|
async glob(pattern, _path) {
|
|
95
99
|
const re = globToRegex(pattern)
|
|
96
|
-
const files = await this.fileStore.listOutputFilesMeta(this.
|
|
100
|
+
const files = await this.fileStore.listOutputFilesMeta(this._taskId())
|
|
97
101
|
return {
|
|
98
102
|
files: files
|
|
99
103
|
.filter((f) => re.test(f.name))
|
|
@@ -103,7 +107,7 @@ export class OutputsBackend {
|
|
|
103
107
|
|
|
104
108
|
async grep(pattern, _path, glob) {
|
|
105
109
|
const reGlob = glob ? globToRegex(glob) : null
|
|
106
|
-
const files = await this.fileStore.listOutputFiles(this.
|
|
110
|
+
const files = await this.fileStore.listOutputFiles(this._taskId())
|
|
107
111
|
const matches = []
|
|
108
112
|
for (const f of files) {
|
|
109
113
|
if (reGlob && !reGlob.test(f.name)) continue
|
|
@@ -120,9 +124,9 @@ export class OutputsBackend {
|
|
|
120
124
|
|
|
121
125
|
async read(filePath, offset = 0, limit = 100) {
|
|
122
126
|
const name = this._name(filePath)
|
|
123
|
-
const file = await this.fileStore.getOutputFile(this.
|
|
127
|
+
const file = await this.fileStore.getOutputFile(this._taskId(), name)
|
|
124
128
|
if (!file) {
|
|
125
|
-
const files = await this.fileStore.listOutputFilesMeta(this.
|
|
129
|
+
const files = await this.fileStore.listOutputFilesMeta(this._taskId())
|
|
126
130
|
const available = files.map((f) => `/outputs/${f.name}`).join(", ") || "none"
|
|
127
131
|
return { error: `File not found: ${filePath}. Available: ${available}` }
|
|
128
132
|
}
|
|
@@ -141,7 +145,7 @@ export class OutputsBackend {
|
|
|
141
145
|
// for the rationale. Currently unused by our shipped code paths; do not delete.
|
|
142
146
|
async readRaw(filePath) {
|
|
143
147
|
const name = this._name(filePath)
|
|
144
|
-
const file = await this.fileStore.getOutputFile(this.
|
|
148
|
+
const file = await this.fileStore.getOutputFile(this._taskId(), name)
|
|
145
149
|
if (!file) return { error: `File not found: ${filePath}` }
|
|
146
150
|
return { bytes: file.bytes, mimeType: file.mimeType, size: file.size }
|
|
147
151
|
}
|
|
@@ -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
|
+
}
|
|
@@ -9,13 +9,22 @@
|
|
|
9
9
|
*
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { getConfig } from "@langchain/langgraph"
|
|
12
13
|
import { isTextMime, globToRegex } from "./mime-utils.js"
|
|
13
14
|
|
|
14
15
|
export class UploadsBackend {
|
|
15
|
-
constructor(
|
|
16
|
-
this.contextId = contextId
|
|
16
|
+
constructor(fileStore) {
|
|
17
17
|
this.fileStore = fileStore
|
|
18
|
-
|
|
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 }
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
// CompositeBackend strips the route prefix (/uploads/) before delegating,
|
|
@@ -26,12 +35,13 @@ export class UploadsBackend {
|
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
async read(filePath, offset = 0, limit = 100) {
|
|
38
|
+
const { contextId, userId } = this._resolveContext()
|
|
29
39
|
const name = this._name(filePath)
|
|
30
|
-
const file = await this.fileStore.getInputFile(
|
|
40
|
+
const file = await this.fileStore.getInputFile(contextId, name, userId)
|
|
31
41
|
|
|
32
42
|
if (!file) {
|
|
33
43
|
const available =
|
|
34
|
-
(await this.fileStore.listInputFiles(
|
|
44
|
+
(await this.fileStore.listInputFiles(contextId, userId))
|
|
35
45
|
.map((f) => `/uploads/${f.name}`)
|
|
36
46
|
.join(", ") || "none"
|
|
37
47
|
return { error: `File not found: ${filePath}. Available: ${available}` }
|
|
@@ -58,8 +68,9 @@ export class UploadsBackend {
|
|
|
58
68
|
// throws `TypeError: backend.readRaw is not a function`. Currently unused
|
|
59
69
|
// by our shipped code paths — do not delete as "dead".
|
|
60
70
|
async readRaw(filePath) {
|
|
71
|
+
const { contextId, userId } = this._resolveContext()
|
|
61
72
|
const name = this._name(filePath)
|
|
62
|
-
const file = await this.fileStore.getInputFile(
|
|
73
|
+
const file = await this.fileStore.getInputFile(contextId, name, userId)
|
|
63
74
|
if (!file) return { error: `File not found: ${filePath}` }
|
|
64
75
|
return { bytes: file.bytes, mimeType: file.mimeType, size: file.size }
|
|
65
76
|
}
|
|
@@ -67,12 +78,14 @@ export class UploadsBackend {
|
|
|
67
78
|
// CompositeBackend re-prepends the route prefix to paths returned here,
|
|
68
79
|
// so return bare "/<name>" paths (without /uploads/) to avoid double-prefix.
|
|
69
80
|
async list(_dirPath) {
|
|
70
|
-
const
|
|
81
|
+
const { contextId, userId } = this._resolveContext()
|
|
82
|
+
const files = await this.fileStore.listInputFiles(contextId, userId)
|
|
71
83
|
return files.map((f) => `/${f.name}`)
|
|
72
84
|
}
|
|
73
85
|
|
|
74
86
|
async ls(_path) {
|
|
75
|
-
const
|
|
87
|
+
const { contextId, userId } = this._resolveContext()
|
|
88
|
+
const files = await this.fileStore.listInputFiles(contextId, userId)
|
|
76
89
|
return {
|
|
77
90
|
files: files.map((f) => ({
|
|
78
91
|
path: `/${f.name}`,
|
|
@@ -84,8 +97,9 @@ export class UploadsBackend {
|
|
|
84
97
|
}
|
|
85
98
|
|
|
86
99
|
async glob(pattern, _path) {
|
|
100
|
+
const { contextId, userId } = this._resolveContext()
|
|
87
101
|
const re = globToRegex(pattern)
|
|
88
|
-
const files = await this.fileStore.listInputFiles(
|
|
102
|
+
const files = await this.fileStore.listInputFiles(contextId, userId)
|
|
89
103
|
return {
|
|
90
104
|
files: files
|
|
91
105
|
.filter((f) => re.test(f.name))
|
|
@@ -94,13 +108,14 @@ export class UploadsBackend {
|
|
|
94
108
|
}
|
|
95
109
|
|
|
96
110
|
async grep(pattern, _path, glob) {
|
|
111
|
+
const { contextId, userId } = this._resolveContext()
|
|
97
112
|
const reGlob = glob ? globToRegex(glob) : null
|
|
98
|
-
const files = await this.fileStore.listInputFiles(
|
|
113
|
+
const files = await this.fileStore.listInputFiles(contextId, userId)
|
|
99
114
|
const candidates = files.filter(
|
|
100
115
|
(f) => (!reGlob || reGlob.test(f.name)) && isTextMime(f.mimeType),
|
|
101
116
|
)
|
|
102
117
|
const fetched = await Promise.all(
|
|
103
|
-
candidates.map((f) => this.fileStore.getInputFile(
|
|
118
|
+
candidates.map((f) => this.fileStore.getInputFile(contextId, f.name, userId)),
|
|
104
119
|
)
|
|
105
120
|
const matches = []
|
|
106
121
|
for (let i = 0; i < candidates.length; i++) {
|
|
@@ -121,14 +136,16 @@ export class UploadsBackend {
|
|
|
121
136
|
}
|
|
122
137
|
|
|
123
138
|
async exists(filePath) {
|
|
139
|
+
const { contextId, userId } = this._resolveContext()
|
|
124
140
|
const name = this._name(filePath)
|
|
125
|
-
const file = await this.fileStore.getInputFile(
|
|
141
|
+
const file = await this.fileStore.getInputFile(contextId, name, userId)
|
|
126
142
|
return !!file
|
|
127
143
|
}
|
|
128
144
|
|
|
129
145
|
async stat(filePath) {
|
|
146
|
+
const { contextId, userId } = this._resolveContext()
|
|
130
147
|
const name = this._name(filePath)
|
|
131
|
-
const file = await this.fileStore.getInputFile(
|
|
148
|
+
const file = await this.fileStore.getInputFile(contextId, name, userId)
|
|
132
149
|
if (!file) return null
|
|
133
150
|
return { name: file.name, mimeType: file.mimeType, size: file.size }
|
|
134
151
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import cds from "@sap/cds"
|
|
2
|
+
import { ReadonlyBackend } from "./backends/readonly-backend.js"
|
|
2
3
|
const { fs, path } = cds.utils
|
|
3
4
|
|
|
4
5
|
const LOG = cds.log("agents")
|
|
@@ -22,7 +23,6 @@ export async function createAutoDeepAgent(srv, agentDir) {
|
|
|
22
23
|
createDeepAgent,
|
|
23
24
|
StateBackend,
|
|
24
25
|
CompositeBackend,
|
|
25
|
-
FilesystemBackend,
|
|
26
26
|
createMemoryMiddleware,
|
|
27
27
|
createSkillsMiddleware,
|
|
28
28
|
} = await import("deepagents")
|
|
@@ -33,37 +33,20 @@ export async function createAutoDeepAgent(srv, agentDir) {
|
|
|
33
33
|
tools: tools.length,
|
|
34
34
|
})
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
const additionalBackends = {}
|
|
37
|
+
|
|
37
38
|
if (cds.env.agents?.fileIO?.enabled) {
|
|
38
39
|
const { CdsFileStore } = await import("../../protocol/persistence/file-store.js")
|
|
39
40
|
const { UploadsBackend } = await import("./backends/uploads-backend.js")
|
|
40
41
|
const { OutputsBackend } = await import("./backends/outputs-backend.js")
|
|
41
42
|
const fileStore = new CdsFileStore()
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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()
|
|
43
|
+
Object.assign(additionalBackends, {
|
|
44
|
+
"/uploads/": new UploadsBackend(fileStore),
|
|
45
|
+
"/outputs/": new OutputsBackend(fileStore),
|
|
46
|
+
})
|
|
64
47
|
}
|
|
65
48
|
|
|
66
|
-
const agentFsBackend = new
|
|
49
|
+
const agentFsBackend = new ReadonlyBackend({ rootDir: agentDir, virtualMode: true })
|
|
67
50
|
const agentDirMiddleware = []
|
|
68
51
|
if (fs.existsSync(path.join(agentDir, "AGENTS.md"))) {
|
|
69
52
|
agentDirMiddleware.push(
|
|
@@ -74,14 +57,20 @@ export async function createAutoDeepAgent(srv, agentDir) {
|
|
|
74
57
|
)
|
|
75
58
|
}
|
|
76
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
|
+
})
|
|
77
64
|
agentDirMiddleware.push(
|
|
78
65
|
createSkillsMiddleware({
|
|
79
66
|
backend: agentFsBackend,
|
|
80
|
-
sources: ["./skills
|
|
67
|
+
sources: ["./skills"],
|
|
81
68
|
}),
|
|
82
69
|
)
|
|
83
70
|
}
|
|
84
71
|
|
|
72
|
+
const backend = new CompositeBackend(new StateBackend(), additionalBackends)
|
|
73
|
+
|
|
85
74
|
return createDeepAgent({
|
|
86
75
|
model,
|
|
87
76
|
tools,
|
|
@@ -2,7 +2,7 @@ import cds from "@sap/cds"
|
|
|
2
2
|
import { createMiddleware } from "langchain"
|
|
3
3
|
import { AIMessage } from "@langchain/core/messages"
|
|
4
4
|
import { OrchestrationClient } from "@sap-ai-sdk/langchain"
|
|
5
|
-
import { timeout, retry } from "
|
|
5
|
+
import { timeout, retry } from "../../utils/resilience.js"
|
|
6
6
|
import { toSdkFilterFormat } from "../../../lib/models/aicore.js"
|
|
7
7
|
import { audit } from "../../utils/utils.js"
|
|
8
8
|
import { INSTRUMENTED } from "../../telemetry/tracing.js"
|
|
@@ -136,9 +136,10 @@ export async function contentFilterMiddleware(model) {
|
|
|
136
136
|
}
|
|
137
137
|
} else {
|
|
138
138
|
// Fail closed: unrecognized errors (timeout, 5xx, network) block the message
|
|
139
|
-
LOG.
|
|
139
|
+
LOG.error("Content filter unavailable — blocking input", {
|
|
140
140
|
error: err.message,
|
|
141
141
|
status,
|
|
142
|
+
data,
|
|
142
143
|
})
|
|
143
144
|
if (hasHumanMessage) {
|
|
144
145
|
return {
|
|
@@ -7,7 +7,17 @@ export default async function buildMiddleware(srv, options = {}) {
|
|
|
7
7
|
const { statusUpdateMiddleware } = await import("./status-update.js")
|
|
8
8
|
const { humanInTheLoopMiddleware } = await import("./hitl.js")
|
|
9
9
|
const { toolSelectionMiddleware } = await import("./tool-selection.js")
|
|
10
|
+
const { toolWrapMiddleware } = await import("./tool-wrap.js")
|
|
11
|
+
|
|
12
|
+
const hasDynamicMcp = tools?.some((t) => t._mcpDynamic) ?? false
|
|
13
|
+
let dynamicMcpMiddlewares = []
|
|
14
|
+
if (hasDynamicMcp) {
|
|
15
|
+
const { remoteMcpMiddleware } = await import("./remote-mcp.js")
|
|
16
|
+
dynamicMcpMiddlewares = [remoteMcpMiddleware()]
|
|
17
|
+
}
|
|
18
|
+
|
|
10
19
|
return [
|
|
20
|
+
...dynamicMcpMiddlewares,
|
|
11
21
|
...(await quotaEnforcerMiddleware()),
|
|
12
22
|
await contentFilterMiddleware(model),
|
|
13
23
|
await agentActionsMiddleware(),
|
|
@@ -15,5 +25,6 @@ export default async function buildMiddleware(srv, options = {}) {
|
|
|
15
25
|
await statusUpdateMiddleware(),
|
|
16
26
|
...(await humanInTheLoopMiddleware(srv, tools)),
|
|
17
27
|
toolSelectionMiddleware(),
|
|
28
|
+
toolWrapMiddleware(),
|
|
18
29
|
].filter(Boolean)
|
|
19
30
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
import { createMiddleware } from "langchain"
|
|
3
|
+
import { ToolMessage } from "@langchain/core/messages"
|
|
4
|
+
import { MultiServerMCPClient } from "@langchain/mcp-adapters"
|
|
5
|
+
import { toolName } from "../../utils/utils.js"
|
|
6
|
+
|
|
7
|
+
const LOG = cds.log("agents:mcp")
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Middleware that resolves remote MCP tools per-request using the current user's auth headers.
|
|
11
|
+
* Tools are cached on cds.context.__mcpDynamicTools keyed by mcpUrl for the lifetime of the
|
|
12
|
+
* request so multi-turn ReAct loops don't issue a new tools/list call on every model invocation.
|
|
13
|
+
*/
|
|
14
|
+
export function remoteMcpMiddleware() {
|
|
15
|
+
return createMiddleware({
|
|
16
|
+
name: "RemoteMcpMiddleware",
|
|
17
|
+
|
|
18
|
+
wrapModelCall: async (request, handler) => {
|
|
19
|
+
const cache = (cds.context.__mcpDynamicTools ??= {})
|
|
20
|
+
const placeholders = (request.tools ?? []).filter((t) => t._mcpDynamic)
|
|
21
|
+
await Promise.all(
|
|
22
|
+
placeholders.map(async ({ mcpUrl, serviceName, resolveHeaders }) => {
|
|
23
|
+
if (cache[mcpUrl]) return
|
|
24
|
+
const headers = await resolveHeaders()
|
|
25
|
+
const client = new MultiServerMCPClient({
|
|
26
|
+
mcpServers: { default: { transport: "http", url: mcpUrl, headers } },
|
|
27
|
+
})
|
|
28
|
+
const raw = await client.getTools()
|
|
29
|
+
for (const t of raw) t.name = toolName(`${serviceName}_${t.name}`)
|
|
30
|
+
cache[mcpUrl] = raw
|
|
31
|
+
LOG.debug(
|
|
32
|
+
`Got ${raw.length} MCP tools from ${mcpUrl}: ${raw.map((t) => t.name).join(", ")}`,
|
|
33
|
+
)
|
|
34
|
+
}),
|
|
35
|
+
)
|
|
36
|
+
const resolved = placeholders.flatMap(({ mcpUrl }) => cache[mcpUrl] ?? [])
|
|
37
|
+
const staticTools = (request.tools ?? []).filter((t) => !t._mcpDynamic)
|
|
38
|
+
return handler({ ...request, tools: [...staticTools, ...resolved] })
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
wrapToolCall: async (request, handler) => {
|
|
42
|
+
const allCached = Object.values(cds.context.__mcpDynamicTools ?? {}).flat()
|
|
43
|
+
const tool = allCached.find((t) => t.name === request.toolCall.name)
|
|
44
|
+
if (!tool) return handler(request)
|
|
45
|
+
try {
|
|
46
|
+
const output = await tool.invoke(request.toolCall.args)
|
|
47
|
+
return new ToolMessage({
|
|
48
|
+
name: request.toolCall.name,
|
|
49
|
+
content: typeof output === "string" ? output : JSON.stringify(output),
|
|
50
|
+
tool_call_id: request.toolCall.id,
|
|
51
|
+
})
|
|
52
|
+
} catch (err) {
|
|
53
|
+
LOG.warn(`MCP tool "${request.toolCall.name}" error: ${err.message}`)
|
|
54
|
+
return new ToolMessage({
|
|
55
|
+
name: request.toolCall.name,
|
|
56
|
+
content: `Error: ${err.message}`,
|
|
57
|
+
tool_call_id: request.toolCall.id,
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createMiddleware } from "langchain"
|
|
2
|
+
import { ToolMessage } from "@langchain/core/messages"
|
|
3
|
+
import { isGraphInterrupt } from "@langchain/langgraph"
|
|
4
|
+
import cds from "@sap/cds"
|
|
5
|
+
|
|
6
|
+
const LOG = cds.log("agents")
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Converts tool errors into error ToolMessages so the LLM can retry.
|
|
10
|
+
* Handles two paths: thrown errors (err.details appended when present)
|
|
11
|
+
* and tools returning artifact.isError=true (@cap-js/mcp action pattern).
|
|
12
|
+
*/
|
|
13
|
+
export function toolWrapMiddleware() {
|
|
14
|
+
return createMiddleware({
|
|
15
|
+
name: "ToolWrapMiddleware",
|
|
16
|
+
wrapToolCall: async (request, handler) => {
|
|
17
|
+
const { name, id, args } = request.toolCall
|
|
18
|
+
try {
|
|
19
|
+
LOG.debug("[tool]", name, args)
|
|
20
|
+
const result = await handler(request)
|
|
21
|
+
if (ToolMessage.isInstance(result) && result.artifact?.isError === true) {
|
|
22
|
+
result.status = "error"
|
|
23
|
+
}
|
|
24
|
+
if (
|
|
25
|
+
ToolMessage.isInstance(result) &&
|
|
26
|
+
result.status !== "error" &&
|
|
27
|
+
typeof result.content === "string" &&
|
|
28
|
+
result.content.startsWith("Error:")
|
|
29
|
+
) {
|
|
30
|
+
result.status = "error"
|
|
31
|
+
}
|
|
32
|
+
if (result?.status === "error") LOG.debug("[tool] error", name, result.content)
|
|
33
|
+
else LOG.debug("[tool] completed", name)
|
|
34
|
+
return result
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (isGraphInterrupt(err)) throw err
|
|
37
|
+
LOG.debug("[tool] error", name, err)
|
|
38
|
+
let content = `Error: ${err.message}`
|
|
39
|
+
if (Array.isArray(err.details) && err.details.length > 0) {
|
|
40
|
+
const lines = err.details.map((d) => `- ${d.message}`).join("\n")
|
|
41
|
+
content += `\n${lines}`
|
|
42
|
+
}
|
|
43
|
+
return new ToolMessage({
|
|
44
|
+
content,
|
|
45
|
+
tool_call_id: id ?? "",
|
|
46
|
+
name,
|
|
47
|
+
status: "error",
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
}
|
package/lib/compile.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import cds from "@sap/cds"
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
getDescription,
|
|
4
|
+
getFilteredEntities,
|
|
5
|
+
getFilteredActions,
|
|
6
|
+
slugified,
|
|
7
|
+
} from "./utils/utils.js"
|
|
3
8
|
import { buildAgentCard } from "./protocol/agent-card.js"
|
|
4
|
-
import { slugified } from "./utils/markdown.js"
|
|
5
9
|
|
|
6
10
|
const A2A_BASE_PATH = "/a2a"
|
|
7
11
|
|