@cap-js/agents 0.9.1 → 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/README.md +2 -3
- package/index.cds +11 -0
- 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/index.js +2 -2
- package/lib/preview/chat.html +26 -4
- package/lib/protocol/persistence/checkpoint-saver.js +4 -1
- package/lib/protocol/persistence/cleanup.js +84 -0
- package/lib/telemetry/chat-tracing.js +22 -0
- package/package.json +2 -2
- package/srv/handlers/graph-executor.js +5 -0
- package/srv/handlers/index.js +5 -0
- package/srv/handlers/mcp-tools.js +4 -2
- package/srv/handlers/sub-agent-tools.js +4 -2
package/README.md
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
[](https://api.reuse.software/info/github.com/cap-js/agents)
|
|
2
|
-
|
|
3
1
|
# SAP Cloud Application Programming Model, agent development plugin for Node.js
|
|
4
2
|
|
|
5
3
|
## About this project
|
|
@@ -54,7 +52,7 @@ Both approaches start from a CDS service annotated with `@agent` (as shown in [R
|
|
|
54
52
|
|
|
55
53
|
### Agentify Existing CAP Services
|
|
56
54
|
|
|
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.
|
|
55
|
+
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
56
|
|
|
59
57
|
```cds
|
|
60
58
|
@agent
|
|
@@ -135,6 +133,7 @@ The following capabilities are experimental and documented separately. Their pub
|
|
|
135
133
|
- [Configuration](.docs/configuration.md) — using multiple models, global and per-service settings, file I/O, and push notifications
|
|
136
134
|
- [Quota Enforcement](.docs/quota.md) — configurable rate limits and resource quotas
|
|
137
135
|
- [Audit Logging](.docs/audit-logging.md) — immutable audit trail of agent decisions and tool usage
|
|
136
|
+
- [Data Privacy](.docs/data-privacy.md) — deletion of message history
|
|
138
137
|
- [Telemetry](.docs/telemetry.md) — OpenTelemetry metrics, tracing, and MLflow export
|
|
139
138
|
- [Content Filter](.docs/content-filter.md) — SAP AI Core content filtering and prompt injection shielding
|
|
140
139
|
|
package/index.cds
CHANGED
|
@@ -50,12 +50,23 @@ entity Tasks : managed {
|
|
|
50
50
|
* Files written by agent via /outputs/ path for this task.
|
|
51
51
|
*/
|
|
52
52
|
outputFiles : Composition of many Attachments;
|
|
53
|
+
|
|
54
|
+
/** LangGraph checkpoints created by this task. Cascade-deleted. */
|
|
55
|
+
checkpoints : Composition of many Checkpoints
|
|
56
|
+
on checkpoints.task_id = taskId;
|
|
57
|
+
|
|
58
|
+
/** LangGraph checkpoint writes tied to this task. Cascade-deleted. */
|
|
59
|
+
checkpointWrites : Composition of many CheckpointWrites
|
|
60
|
+
on checkpointWrites.task_id = taskId;
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
entity Checkpoints : managed {
|
|
56
64
|
key thread_id : String;
|
|
57
65
|
key checkpoint_ns : String default '';
|
|
58
66
|
key checkpoint_id : String;
|
|
67
|
+
task_id : String;
|
|
68
|
+
task : Association to one Tasks
|
|
69
|
+
on task.taskId = task_id;
|
|
59
70
|
parent_checkpoint_id : String;
|
|
60
71
|
parent : Association to one Checkpoints
|
|
61
72
|
on parent.checkpoint_id = parent_checkpoint_id;
|
|
@@ -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,
|
package/lib/index.js
CHANGED
|
@@ -122,7 +122,7 @@ export default function A2AProtocolAdapter(srv, options = {}) {
|
|
|
122
122
|
}
|
|
123
123
|
})
|
|
124
124
|
|
|
125
|
-
if (
|
|
125
|
+
if (cds.env?.server?.index) {
|
|
126
126
|
linkProviders.push((entity, endpoint) => {
|
|
127
127
|
if (entity || endpoint?.kind !== "agent") return undefined
|
|
128
128
|
return {
|
|
@@ -182,7 +182,7 @@ export default function A2AProtocolAdapter(srv, options = {}) {
|
|
|
182
182
|
res.json(card)
|
|
183
183
|
})
|
|
184
184
|
|
|
185
|
-
if (
|
|
185
|
+
if (cds.env?.server?.index) {
|
|
186
186
|
router.use("/preview", previewAuthChallenge(srv), preview(agentCard.name || srv.name))
|
|
187
187
|
}
|
|
188
188
|
|
package/lib/preview/chat.html
CHANGED
|
@@ -429,7 +429,29 @@
|
|
|
429
429
|
<script>
|
|
430
430
|
const AGENT_URL = window.location.pathname.replace(/\/preview\/?$/, "") + "/"
|
|
431
431
|
const AUTH_HEADER = document.querySelector('meta[name="a2a-auth"]')?.content || ""
|
|
432
|
-
|
|
432
|
+
|
|
433
|
+
const fetchCsrfToken = async () => {
|
|
434
|
+
if (document.csrfToken === undefined) {
|
|
435
|
+
const res = await fetch("/", {
|
|
436
|
+
method: "HEAD",
|
|
437
|
+
headers: { "x-csrf-token": "fetch", ...(await authHeaders({ includeCsrf: false })) },
|
|
438
|
+
})
|
|
439
|
+
if (!res.ok) {
|
|
440
|
+
throw new Error(`Could not fetch CSRF token ${res.status}: ${res.statusText}`)
|
|
441
|
+
}
|
|
442
|
+
document.csrfToken = res.headers.get("x-csrf-token")
|
|
443
|
+
}
|
|
444
|
+
return document.csrfToken
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const authHeaders = async ({ includeCsrf = true } = {}) => {
|
|
448
|
+
const headers = AUTH_HEADER ? { Authorization: AUTH_HEADER } : {}
|
|
449
|
+
if (!includeCsrf) return headers
|
|
450
|
+
const csrfToken = await fetchCsrfToken()
|
|
451
|
+
if (csrfToken) headers["x-csrf-token"] = csrfToken
|
|
452
|
+
return headers
|
|
453
|
+
}
|
|
454
|
+
|
|
433
455
|
document.getElementById("back").addEventListener("click", () => history.back())
|
|
434
456
|
const messages = document.getElementById("messages")
|
|
435
457
|
const typing = document.getElementById("typing")
|
|
@@ -579,7 +601,7 @@
|
|
|
579
601
|
try {
|
|
580
602
|
const resp = await fetch(AGENT_URL, {
|
|
581
603
|
method: "POST",
|
|
582
|
-
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
604
|
+
headers: { "Content-Type": "application/json", ...(await authHeaders()) },
|
|
583
605
|
signal: abort.signal,
|
|
584
606
|
body: JSON.stringify({
|
|
585
607
|
jsonrpc: "2.0",
|
|
@@ -677,7 +699,7 @@
|
|
|
677
699
|
try {
|
|
678
700
|
const resp = await fetch(AGENT_URL, {
|
|
679
701
|
method: "POST",
|
|
680
|
-
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
702
|
+
headers: { "Content-Type": "application/json", ...(await authHeaders()) },
|
|
681
703
|
signal: abort.signal,
|
|
682
704
|
body: JSON.stringify({
|
|
683
705
|
jsonrpc: "2.0",
|
|
@@ -856,7 +878,7 @@
|
|
|
856
878
|
if (taskId) {
|
|
857
879
|
fetch(AGENT_URL, {
|
|
858
880
|
method: "POST",
|
|
859
|
-
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
881
|
+
headers: { "Content-Type": "application/json", ...(await authHeaders()) },
|
|
860
882
|
body: JSON.stringify({
|
|
861
883
|
jsonrpc: "2.0",
|
|
862
884
|
method: "tasks/cancel",
|
|
@@ -151,6 +151,7 @@ export class CdsCheckpointSaver extends BaseCheckpointSaver {
|
|
|
151
151
|
const thread_id = config.configurable?.thread_id
|
|
152
152
|
const checkpoint_ns = config.configurable?.checkpoint_ns ?? ""
|
|
153
153
|
const parent_checkpoint_id = config.configurable?.checkpoint_id
|
|
154
|
+
const task_id = config.configurable?._taskId || cds.context?.["agent.task.id"] || null
|
|
154
155
|
|
|
155
156
|
if (!thread_id) {
|
|
156
157
|
throw new Error('Missing required "thread_id" in config.configurable')
|
|
@@ -165,12 +166,14 @@ export class CdsCheckpointSaver extends BaseCheckpointSaver {
|
|
|
165
166
|
checkpoint_ns,
|
|
166
167
|
checkpoint_id: checkpoint.id,
|
|
167
168
|
parent_checkpoint_id,
|
|
169
|
+
task_id,
|
|
168
170
|
checkpoint: toUtf8String(serializedCheckpoint),
|
|
169
171
|
metadata: toUtf8String(serializedMetadata),
|
|
170
172
|
createdBy: resolveUserId(),
|
|
171
173
|
})
|
|
172
174
|
|
|
173
|
-
LOG._trace &&
|
|
175
|
+
LOG._trace &&
|
|
176
|
+
LOG.debug("Checkpoint saved", { thread_id, checkpoint_id: checkpoint.id, task_id })
|
|
174
177
|
|
|
175
178
|
return {
|
|
176
179
|
configurable: {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
import { ms4 } from "../../utils/utils.js"
|
|
3
|
+
|
|
4
|
+
const LOG = cds.log("agent")
|
|
5
|
+
|
|
6
|
+
const TASKS = "cap.agent.Tasks"
|
|
7
|
+
|
|
8
|
+
// Throttle: last cleanup timestamp per tenant & service
|
|
9
|
+
const lastDeletionTriggerMap = new Map()
|
|
10
|
+
|
|
11
|
+
/** Test-only: reset throttle map. */
|
|
12
|
+
export function _resetCleanupThrottle() {
|
|
13
|
+
lastDeletionTriggerMap.clear()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// TTL from cds.env.agents.retention., `false`/0 → disabled.
|
|
17
|
+
function resolveTtlMs() {
|
|
18
|
+
const cfg = cds.env.agents?.retention
|
|
19
|
+
if (cfg === false || cfg === 0) return 0
|
|
20
|
+
const value = cfg === true || cfg
|
|
21
|
+
if (typeof value === "number") return value
|
|
22
|
+
return ms4(String(value))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MS_OF_A_DAY = ms4("1d")
|
|
26
|
+
|
|
27
|
+
export async function triggerCleanup(serviceName) {
|
|
28
|
+
const ttlMs = resolveTtlMs()
|
|
29
|
+
if (!ttlMs) {
|
|
30
|
+
LOG.debug(`cds.agents.retention is not configured. Skipping cleanup of old Tasks.`)
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
const tenant = cds.context?.tenant
|
|
34
|
+
if (!lastDeletionTriggerMap.has(tenant)) lastDeletionTriggerMap.set(tenant, new Map())
|
|
35
|
+
const serviceMap = lastDeletionTriggerMap.get(tenant)
|
|
36
|
+
const lastDeletionTriggered = serviceMap.get(serviceName)
|
|
37
|
+
if (lastDeletionTriggered > Date.now() - MS_OF_A_DAY) {
|
|
38
|
+
LOG.debug(
|
|
39
|
+
`Skip scheduling deletion of tasks for ${serviceName} because the last scheduled deletion was triggered within the last 24h.`,
|
|
40
|
+
)
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
const srv = cds.services[serviceName]
|
|
44
|
+
if (!srv) {
|
|
45
|
+
LOG.warn(`triggerCleanup: service "${serviceName}" not found in cds.services, skipping.`)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if (typeof srv.schedule !== "function") {
|
|
49
|
+
LOG.debug(`triggerCleanup: srv.schedule not available (CDS < 9). Skipping cleanup scheduling.`)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
serviceMap.set(serviceName, Date.now())
|
|
53
|
+
const MAX_TIMEOUT = 2_147_483_647
|
|
54
|
+
const delay = Math.min(ttlMs + MS_OF_A_DAY, MAX_TIMEOUT)
|
|
55
|
+
await srv.schedule("cleanupTasks", {}).after(delay)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* GC of expired tasks per service.
|
|
60
|
+
* Compositions cascade automatically: inputFiles, outputFiles, pushConfigs,
|
|
61
|
+
* checkpoints, checkpointWrites.
|
|
62
|
+
*/
|
|
63
|
+
export async function cleanupExpiredTasks(serviceName) {
|
|
64
|
+
const ttlMs = resolveTtlMs()
|
|
65
|
+
if (!ttlMs) {
|
|
66
|
+
LOG.debug(`cds.agents.retention is not configured. Skipping cleanup of old Tasks.`)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const tenant = cds.context?.tenant
|
|
71
|
+
const now = Date.now()
|
|
72
|
+
const cutoff = new Date(now - ttlMs).toISOString()
|
|
73
|
+
|
|
74
|
+
const taskResult = await DELETE.from(TASKS).where({
|
|
75
|
+
modifiedAt: { "<": cutoff },
|
|
76
|
+
agentService: serviceName,
|
|
77
|
+
})
|
|
78
|
+
// affectedRows since cds10
|
|
79
|
+
const deletedTasks = typeof taskResult === "number" ? taskResult : taskResult?.affectedRows || 0
|
|
80
|
+
|
|
81
|
+
if (deletedTasks > 0) {
|
|
82
|
+
LOG.debug("Cleanup", { tenant, deletedTasks, cutoff })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -71,6 +71,28 @@ export function _patchChatModelProto(proto) {
|
|
|
71
71
|
try {
|
|
72
72
|
const result = await original.call(this, input, opts)
|
|
73
73
|
const duration = Date.now() - t0
|
|
74
|
+
const ir = result.additional_kwargs?.intermediate_results
|
|
75
|
+
if (span && ir) {
|
|
76
|
+
if (ir.input_filtering) span.setAttribute("gen_ai.orchestration.input_filtering", true)
|
|
77
|
+
if (ir.output_filtering) span.setAttribute("gen_ai.orchestration.output_filtering", true)
|
|
78
|
+
if (ir.input_masking) span.setAttribute("gen_ai.orchestration.input_masking", true)
|
|
79
|
+
const appliedFilterAmount = (filtering) => {
|
|
80
|
+
let res = []
|
|
81
|
+
for (const entry of filtering?.data?.choices ?? []) {
|
|
82
|
+
Object.keys(entry).forEach((e) => {
|
|
83
|
+
if (e !== "index") {
|
|
84
|
+
res = res.concat(Object.keys(entry[e]).map((filter) => `${e}_${filter}`))
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
return res
|
|
89
|
+
}
|
|
90
|
+
let ic = appliedFilterAmount(ir.input_filtering)
|
|
91
|
+
let oc = appliedFilterAmount(ir.output_filtering)
|
|
92
|
+
if (ic.length) span.setAttribute("gen_ai.orchestration.input_filter_services", ic)
|
|
93
|
+
if (oc.length) span.setAttribute("gen_ai.orchestration.output_filter_services", oc)
|
|
94
|
+
}
|
|
95
|
+
|
|
74
96
|
/** @type {import('@langchain/core/messages').UsageMetadata} */
|
|
75
97
|
const usage = result.usage_metadata
|
|
76
98
|
const finishReason =
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cap-js/agents",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "CDS plugin for building agents",
|
|
5
5
|
"author": "SAP SE (https://www.sap.com)",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -87,6 +87,7 @@
|
|
|
87
87
|
"trace_langchain": true,
|
|
88
88
|
"mlflow": false,
|
|
89
89
|
"connect": "auto",
|
|
90
|
+
"retention": "30d",
|
|
90
91
|
"[development]": {
|
|
91
92
|
"pushNotifications": {
|
|
92
93
|
"allowedDomains": [
|
|
@@ -116,7 +117,6 @@
|
|
|
116
117
|
"max_tokens": 4096,
|
|
117
118
|
"temperature": 0
|
|
118
119
|
},
|
|
119
|
-
"contentFilter": true,
|
|
120
120
|
"fileIO": {
|
|
121
121
|
"enabled": false,
|
|
122
122
|
"maxInputFileSizeBytes": 2097152,
|
|
@@ -6,6 +6,7 @@ import { mlflowAttrs, mlflowTraceAttrs, setSpanAttrs } from "../../lib/telemetry
|
|
|
6
6
|
import { CdsFileStore } from "../../lib/protocol/persistence/file-store.js"
|
|
7
7
|
import { formatFileSize, sanitizeFilename } from "./tools.js"
|
|
8
8
|
import { convertUsageData } from "../../lib/telemetry/chat-tracing.js"
|
|
9
|
+
import { triggerCleanup } from "../../lib/protocol/persistence/cleanup.js"
|
|
9
10
|
|
|
10
11
|
const LOG = cds.log("agents")
|
|
11
12
|
|
|
@@ -589,6 +590,10 @@ class GraphExecutor {
|
|
|
589
590
|
audit("AgentTaskStarted", {
|
|
590
591
|
data: { taskId, contextId, service: serviceName, userMessage: requestContext.userMessage },
|
|
591
592
|
})
|
|
593
|
+
// Lazy scheduling task deletion
|
|
594
|
+
cds.spawn({}, async () => {
|
|
595
|
+
await triggerCleanup(serviceName)
|
|
596
|
+
})
|
|
592
597
|
}
|
|
593
598
|
|
|
594
599
|
// ── File I/O: persist incoming FileParts to cap.agent.Tasks.inputFiles ──
|
package/srv/handlers/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { generateTools, createReadFileTool } from "./tools.js"
|
|
|
3
3
|
import { buildSystemPrompt } from "./system-prompt.js"
|
|
4
4
|
import buildMiddleware from "../../lib/agents/middleware/index.js"
|
|
5
5
|
import { partsToText } from "../../lib/utils/message-handling.js"
|
|
6
|
+
import { cleanupExpiredTasks } from "../../lib/protocol/persistence/cleanup.js"
|
|
6
7
|
|
|
7
8
|
const LOG = cds.log("agents")
|
|
8
9
|
|
|
@@ -170,4 +171,8 @@ export default function registerDefaultAgentHandlers(srv) {
|
|
|
170
171
|
},
|
|
171
172
|
})
|
|
172
173
|
})
|
|
174
|
+
|
|
175
|
+
srv.on("cleanupTasks", async () => {
|
|
176
|
+
await cleanupExpiredTasks(srv.name)
|
|
177
|
+
})
|
|
173
178
|
}
|
|
@@ -94,7 +94,8 @@ export async function buildMcpToolsFromConnection(serviceName) {
|
|
|
94
94
|
const { credentials, kind } = cds.requires[serviceName]
|
|
95
95
|
|
|
96
96
|
// dest is either a string (BTP destination name) or an object { name, url, ... }
|
|
97
|
-
const destinationName =
|
|
97
|
+
const destinationName =
|
|
98
|
+
typeof credentials === "string" ? credentials : (credentials?.destination ?? credentials?.name)
|
|
98
99
|
const localUrl =
|
|
99
100
|
typeof credentials === "string"
|
|
100
101
|
? null
|
|
@@ -121,7 +122,8 @@ export async function buildMcpToolsFromConnection(serviceName) {
|
|
|
121
122
|
)
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
const
|
|
125
|
+
const path = typeof credentials === "object" ? credentials?.path : null
|
|
126
|
+
const mcpUrl = url.replace(/\/$/, "") + (path ? `/${path.replace(/^\//, "")}` : "")
|
|
125
127
|
LOG.info(`Connecting to MCP server at ${mcpUrl}`)
|
|
126
128
|
|
|
127
129
|
const resolveHeaders = async () => {
|
|
@@ -206,7 +206,8 @@ export async function buildSubAgentToolFromConnection(serviceName) {
|
|
|
206
206
|
endpoints = Object.fromEntries(endpoints.map((o) => [o.kind, o.path]))
|
|
207
207
|
const { credentials, kind } = cds.requires[serviceName]
|
|
208
208
|
|
|
209
|
-
const destinationName =
|
|
209
|
+
const destinationName =
|
|
210
|
+
typeof credentials === "string" ? credentials : (credentials?.destination ?? credentials?.name)
|
|
210
211
|
const localUrl =
|
|
211
212
|
typeof credentials === "string"
|
|
212
213
|
? null
|
|
@@ -270,7 +271,8 @@ export async function buildSubAgentToolFromConnection(serviceName) {
|
|
|
270
271
|
)
|
|
271
272
|
}
|
|
272
273
|
|
|
273
|
-
const
|
|
274
|
+
const path = typeof credentials === "object" ? credentials?.path : null
|
|
275
|
+
const base = agentBaseUrl.replace(/\/$/, "") + (path ? `/${path.replace(/^\//, "")}` : "")
|
|
274
276
|
LOG.info(`Connecting to sub-agent at ${base}`)
|
|
275
277
|
|
|
276
278
|
// revisit: a2a agents may be tenant specific, card per tenant?
|