@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.
- package/LICENSE +201 -0
- package/README.md +155 -0
- package/_i18n/messages.properties +31 -0
- package/cds-plugin.js +140 -0
- package/index.cds +105 -0
- package/index.js +0 -0
- package/lib/agents/markdown/backends/mime-utils.js +37 -0
- package/lib/agents/markdown/backends/outputs-backend.js +152 -0
- package/lib/agents/markdown/backends/uploads-backend.js +143 -0
- package/lib/agents/markdown/deep-agent.js +93 -0
- package/lib/agents/middleware/agent-actions.js +18 -0
- package/lib/agents/middleware/content-filter.js +191 -0
- package/lib/agents/middleware/hitl-edit-note-injector.js +18 -0
- package/lib/agents/middleware/hitl.js +20 -0
- package/lib/agents/middleware/index.js +19 -0
- package/lib/agents/middleware/patch-tool-calls.js +51 -0
- package/lib/agents/middleware/quota-enforcer.js +94 -0
- package/lib/agents/middleware/status-update.js +153 -0
- package/lib/agents/middleware/tool-selection.js +22 -0
- package/lib/agents/quota-enforcer-at-start.js +198 -0
- package/lib/agents/summarize-on-timeout.js +91 -0
- package/lib/compile.js +55 -0
- package/lib/index.cjs +1 -0
- package/lib/index.js +422 -0
- package/lib/models/aicore.js +441 -0
- package/lib/models/anthropic.js +77 -0
- package/lib/models/mock.js +88 -0
- package/lib/preview/chat.html +875 -0
- package/lib/preview/preview.js +46 -0
- package/lib/protocol/agent-card.js +297 -0
- package/lib/protocol/persistence/checkpoint-saver.js +317 -0
- package/lib/protocol/persistence/file-store.js +209 -0
- package/lib/protocol/persistence/push-notification-store.js +59 -0
- package/lib/protocol/persistence/task-store.js +47 -0
- package/lib/protocol/push-notification-sender.js +57 -0
- package/lib/sidecar.js +162 -0
- package/lib/telemetry/active-users.js +106 -0
- package/lib/telemetry/chat-tracing.js +342 -0
- package/lib/telemetry/metrics.js +85 -0
- package/lib/telemetry/mlflow.js +290 -0
- package/lib/telemetry/tool-tracing.js +164 -0
- package/lib/telemetry/tracing.js +150 -0
- package/lib/utils/inner-auth.js +33 -0
- package/lib/utils/markdown.js +199 -0
- package/lib/utils/message-handling.js +155 -0
- package/lib/utils/utils.js +168 -0
- package/package.json +225 -2
- package/srv/graph-cache.js +82 -0
- package/srv/handlers/graph-executor.js +1369 -0
- package/srv/handlers/index.js +173 -0
- package/srv/handlers/mcp-tools.js +159 -0
- package/srv/handlers/sub-agent-tools.js +314 -0
- package/srv/handlers/system-prompt.js +25 -0
- package/srv/handlers/tools.js +366 -0
- package/srv/langgraph-executor-srv.js +70 -0
- package/srv/push-notification-srv.js +149 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
const { path, fs } = cds.utils
|
|
3
|
+
const LOG = cds.log("agents")
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mirrors CAP's internal slug rules used for service path generation.
|
|
7
|
+
*/
|
|
8
|
+
export const slugified = (name) =>
|
|
9
|
+
/[^.]+$/
|
|
10
|
+
.exec(name)[0]
|
|
11
|
+
.replace(/Service$/, "")
|
|
12
|
+
.replace(/_/g, "-")
|
|
13
|
+
.replace(/([a-z0-9])([A-Z])/g, (_m, c, C) => c + "-" + C)
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Absolute filesystem directory of the `.cds` source file that defines `srv`.
|
|
18
|
+
*/
|
|
19
|
+
export function serviceSourceDir(srv) {
|
|
20
|
+
const file = srv?.definition?.$location?.file
|
|
21
|
+
if (!file) return undefined
|
|
22
|
+
// $location.file is relative to cds.root
|
|
23
|
+
return path.dirname(path.join(cds.root, file))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get the effective service definition (feature-toggled if available, else base).
|
|
28
|
+
*/
|
|
29
|
+
function effectiveDefinition(srv) {
|
|
30
|
+
return cds.context?.model?.definitions?.[srv.name] || srv?.definition
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve a path from a `@agent.directory` / `@agent.card` annotation against the
|
|
35
|
+
* `.cds` source file's directory. Absolute paths are returned as-is.
|
|
36
|
+
*/
|
|
37
|
+
function resolveAnnotatedPath(srv, annotationValue) {
|
|
38
|
+
if (!annotationValue || typeof annotationValue !== "string") return undefined
|
|
39
|
+
if (path.isAbsolute(annotationValue)) return annotationValue
|
|
40
|
+
const srcDir = serviceSourceDir(srv)
|
|
41
|
+
if (!srcDir) return undefined
|
|
42
|
+
return path.resolve(srcDir, annotationValue)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the agent directory for a service.
|
|
47
|
+
*
|
|
48
|
+
* Resolution order (all paths relative to the `.cds` source file dir):
|
|
49
|
+
* 1. `@agent.directory` annotation (relative to `.cds` source file dir, or absolute)
|
|
50
|
+
* 2. `<srcDir>` itself — when the service `.cds` file lives inside its own dedicated
|
|
51
|
+
* folder and `AGENTS.md` is co-located there (e.g. `srv/travel-agent/service.cds`
|
|
52
|
+
* + `srv/travel-agent/AGENTS.md`).
|
|
53
|
+
* 3. `<srcDir>/<slug-with-agent-suffix>/` — sibling directory.
|
|
54
|
+
* The slug gets `-agent` appended if it does not already end in `-agent`,
|
|
55
|
+
* e.g. `TravelService` → `./travel-agent/`, `ProductAgentService` → `./product-agent/`.
|
|
56
|
+
* 4. `<srcDir>/agents/<base>/` — collected under an `agents/` parent.
|
|
57
|
+
* The base is the slug with any trailing `-agent` stripped,
|
|
58
|
+
* e.g. `TravelService` → `./agents/travel/`, `ProductAgentService` → `./agents/product/`.
|
|
59
|
+
* 5. `undefined`
|
|
60
|
+
*/
|
|
61
|
+
export function resolveAgentDir(srv) {
|
|
62
|
+
const def = effectiveDefinition(srv)
|
|
63
|
+
const annotated = resolveAnnotatedPath(srv, def?.["@agent.directory"])
|
|
64
|
+
// 1. `@agent.directory`
|
|
65
|
+
if (annotated) {
|
|
66
|
+
if (fs.existsSync(annotated)) return annotated
|
|
67
|
+
LOG.warn("@agent.directory path not found, falling back to convention", {
|
|
68
|
+
service: srv?.name,
|
|
69
|
+
annotation: def?.["@agent.directory"],
|
|
70
|
+
resolved: annotated,
|
|
71
|
+
})
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
const srcDir = serviceSourceDir(srv)
|
|
75
|
+
if (!srcDir) return undefined
|
|
76
|
+
|
|
77
|
+
// 2. <srcDir> itself — AGENTS.md co-located with the .cds file
|
|
78
|
+
if (fs.existsSync(path.join(srcDir, "AGENTS.md"))) return srcDir
|
|
79
|
+
|
|
80
|
+
const slug = slugified(srv.name)
|
|
81
|
+
const agentSlug = slug.endsWith("-agent") ? slug : `${slug}-agent`
|
|
82
|
+
const base = slug.endsWith("-agent") ? slug.slice(0, -"-agent".length) : slug
|
|
83
|
+
|
|
84
|
+
// 3. <srcDir>/<agentSlug>/
|
|
85
|
+
const sibling = path.join(srcDir, agentSlug)
|
|
86
|
+
if (fs.existsSync(sibling)) return sibling
|
|
87
|
+
|
|
88
|
+
// 4. <srcDir>/agents/<base>/
|
|
89
|
+
if (base) {
|
|
90
|
+
const grouped = path.join(srcDir, "agents", base)
|
|
91
|
+
if (fs.existsSync(grouped)) return grouped
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve an explicit agent-card markdown file via `@agent.card` annotation.
|
|
99
|
+
* Returns `undefined` when no annotation is set; falls through to the
|
|
100
|
+
* agent-dir-based resolution chain (`AGENT_CARD.md` → `skills/`) handled by
|
|
101
|
+
* the card builder.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveAgentCardPath(srv) {
|
|
104
|
+
const def = effectiveDefinition(srv)
|
|
105
|
+
return resolveAnnotatedPath(srv, def?.["@agent.card"])
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `<dir>/AGENTS.md` exists ⇒ this is a deep-agent directory.
|
|
110
|
+
*/
|
|
111
|
+
export function isDeepAgentDir(dir) {
|
|
112
|
+
if (!dir) return false
|
|
113
|
+
return fs.existsSync(path.join(dir, "AGENTS.md"))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Parse YAML frontmatter from a markdown file.
|
|
118
|
+
*/
|
|
119
|
+
export function parseFrontmatter(filePath) {
|
|
120
|
+
let content
|
|
121
|
+
try {
|
|
122
|
+
content = fs.readFileSync(filePath, "utf8")
|
|
123
|
+
} catch {
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)
|
|
128
|
+
if (!match) return null
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const { yaml } = cds.utils
|
|
132
|
+
return yaml.parse(match[1])
|
|
133
|
+
} catch {
|
|
134
|
+
// Fallback: return null if YAML parsing fails
|
|
135
|
+
return null
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Scan a skills directory and return skill metadata for the agent card.
|
|
141
|
+
* Reads SKILL.md frontmatter per agentskills.io spec.
|
|
142
|
+
* Respects metadata.private: true to exclude internal skills.
|
|
143
|
+
*
|
|
144
|
+
* @returns {Array<{id, name, description, tags, examples}>}
|
|
145
|
+
*/
|
|
146
|
+
export function scanSkills(skillsDir) {
|
|
147
|
+
if (!fs.existsSync(skillsDir)) return []
|
|
148
|
+
|
|
149
|
+
let entries
|
|
150
|
+
try {
|
|
151
|
+
entries = fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
152
|
+
} catch {
|
|
153
|
+
return []
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const skills = []
|
|
157
|
+
for (const entry of entries) {
|
|
158
|
+
if (!entry.isDirectory()) continue
|
|
159
|
+
const skillFile = path.join(skillsDir, entry.name, "SKILL.md")
|
|
160
|
+
const meta = parseFrontmatter(skillFile)
|
|
161
|
+
if (!meta || !meta.name) continue
|
|
162
|
+
|
|
163
|
+
// metadata.private: true → internal skill, not in agent card
|
|
164
|
+
const isPrivate = meta.metadata?.private === "true" || meta.metadata?.private === true
|
|
165
|
+
if (isPrivate) continue
|
|
166
|
+
|
|
167
|
+
skills.push({
|
|
168
|
+
id: meta.name,
|
|
169
|
+
name: meta.name.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
170
|
+
description: (meta.description || "").trim(),
|
|
171
|
+
tags: meta.metadata?.tags || [],
|
|
172
|
+
examples: meta.metadata?.examples || [],
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Alphabetical sort for deterministic output across filesystems
|
|
177
|
+
return skills.sort((a, b) => a.id.localeCompare(b.id))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Parse AGENTS.md frontmatter for agent-level metadata.
|
|
182
|
+
*/
|
|
183
|
+
export function parseAgentMetadata(agentDir) {
|
|
184
|
+
return parseFrontmatter(path.join(agentDir, "AGENTS.md"))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Parse AGENT_CARD.md for explicit agent card definition (convention within agentDir).
|
|
189
|
+
*/
|
|
190
|
+
export function parseAgentCardMd(agentDir) {
|
|
191
|
+
return parseFrontmatter(path.join(agentDir, "AGENT_CARD.md"))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Parse an agent card markdown file at an explicit absolute path.
|
|
196
|
+
*/
|
|
197
|
+
export function parseAgentCardFile(absolutePath) {
|
|
198
|
+
return parseFrontmatter(absolutePath)
|
|
199
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A2A message part utilities — handle both wire formats:
|
|
3
|
+
*
|
|
4
|
+
* v0.3.x (kind-based): {kind:"text",text} | {kind:"file",file:{bytes|uri,mimeType}} | {kind:"data",data}
|
|
5
|
+
* v1.0 (oneOf): {content:{$case:"text"|"raw"|"url"|"data", value}, mediaType, filename}
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extract plain text from A2A parts. Data parts → JSON.stringify. File parts skipped.
|
|
10
|
+
* @param {Array} parts
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
export function partsToText(parts = []) {
|
|
14
|
+
return (
|
|
15
|
+
parts
|
|
16
|
+
.map((p) => {
|
|
17
|
+
if (p.content?.$case === "text") return p.content.value || ""
|
|
18
|
+
if (p.content?.$case === "data") return JSON.stringify(p.content.value)
|
|
19
|
+
// REVISIT: remove when @a2a-js/sdk pinned to ^1.x (SDK normalizes to content.$case)
|
|
20
|
+
if (p.kind === "text" || (!p.kind && p.text)) return p.text || ""
|
|
21
|
+
if (p.kind === "data" && p.data !== undefined) return JSON.stringify(p.data)
|
|
22
|
+
return null
|
|
23
|
+
})
|
|
24
|
+
.filter((v) => v !== null)
|
|
25
|
+
.join(" ") || ""
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Return the raw `data` object of the FIRST DataPart in `parts`, or undefined if none.
|
|
31
|
+
* Handles both wire shapes (v0.3 kind-based, v1.0 oneOf); returned as-is (no clone/validation).
|
|
32
|
+
* @param {Array} parts
|
|
33
|
+
* @returns {object | undefined}
|
|
34
|
+
*/
|
|
35
|
+
export function firstDataPart(parts = []) {
|
|
36
|
+
for (const p of parts) {
|
|
37
|
+
// Nullish payloads fall through to the text parser (spec: `data` is always an object).
|
|
38
|
+
if (p.content?.$case === "data" && p.content.value != null) return p.content.value
|
|
39
|
+
// REVISIT: remove when @a2a-js/sdk pinned to ^1.x (SDK normalizes to content.$case)
|
|
40
|
+
if (p.kind === "data" && p.data != null) return p.data
|
|
41
|
+
}
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const IMAGE_MIME_RE = /^image\//
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Map A2A parts to LangChain-JS / OpenAI content value for MLflow span inputs.
|
|
49
|
+
*
|
|
50
|
+
* Single text part → plain string. Otherwise array of content blocks:
|
|
51
|
+
* text/data → {type:"text", text}
|
|
52
|
+
* image/* → {type:"image_url", image_url:{url:"data:<mime>;base64,..."|"<uri>"}}
|
|
53
|
+
* other files → {type:"text", text:"[file: <name> (<mime>)]"}
|
|
54
|
+
*
|
|
55
|
+
* @param {Array} parts
|
|
56
|
+
* @returns {string | Array}
|
|
57
|
+
*/
|
|
58
|
+
export function partsToMessageContent(parts = []) {
|
|
59
|
+
const blocks = []
|
|
60
|
+
for (const p of parts) {
|
|
61
|
+
// v1.0 oneOf
|
|
62
|
+
if (p.content?.$case) {
|
|
63
|
+
switch (p.content.$case) {
|
|
64
|
+
case "text":
|
|
65
|
+
blocks.push({ type: "text", text: p.content.value || "" })
|
|
66
|
+
break
|
|
67
|
+
case "url": {
|
|
68
|
+
const mime = p.mediaType || ""
|
|
69
|
+
if (IMAGE_MIME_RE.test(mime)) {
|
|
70
|
+
blocks.push({ type: "image_url", image_url: { url: p.content.value } })
|
|
71
|
+
} else {
|
|
72
|
+
const label = p.filename
|
|
73
|
+
? `[file: ${p.filename} (${mime || "unknown"})]`
|
|
74
|
+
: p.content.value
|
|
75
|
+
blocks.push({ type: "text", text: label })
|
|
76
|
+
}
|
|
77
|
+
break
|
|
78
|
+
}
|
|
79
|
+
case "raw": {
|
|
80
|
+
const mime = p.mediaType || "application/octet-stream"
|
|
81
|
+
const b64 = Buffer.isBuffer(p.content.value)
|
|
82
|
+
? p.content.value.toString("base64")
|
|
83
|
+
: p.content.value
|
|
84
|
+
if (IMAGE_MIME_RE.test(mime)) {
|
|
85
|
+
blocks.push({ type: "image_url", image_url: { url: `data:${mime};base64,${b64}` } })
|
|
86
|
+
} else {
|
|
87
|
+
blocks.push({ type: "text", text: `[file: ${p.filename || "attachment"} (${mime})]` })
|
|
88
|
+
}
|
|
89
|
+
break
|
|
90
|
+
}
|
|
91
|
+
case "data":
|
|
92
|
+
blocks.push({ type: "text", text: JSON.stringify(p.content.value, null, 2) })
|
|
93
|
+
break
|
|
94
|
+
default:
|
|
95
|
+
break
|
|
96
|
+
}
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
// REVISIT: remove when @a2a-js/sdk pinned to ^1.x (SDK normalizes to content.$case)
|
|
100
|
+
// v0.3.x kind-based
|
|
101
|
+
if (p.kind === "text" || (!p.kind && p.text)) {
|
|
102
|
+
blocks.push({ type: "text", text: p.text || "" })
|
|
103
|
+
} else if (p.kind === "file") {
|
|
104
|
+
const f = p.file || p
|
|
105
|
+
const mime = f.mimeType || "application/octet-stream"
|
|
106
|
+
if (f.bytes) {
|
|
107
|
+
if (IMAGE_MIME_RE.test(mime)) {
|
|
108
|
+
blocks.push({ type: "image_url", image_url: { url: `data:${mime};base64,${f.bytes}` } })
|
|
109
|
+
} else {
|
|
110
|
+
blocks.push({ type: "text", text: `[file: ${f.name || "attachment"} (${mime})]` })
|
|
111
|
+
}
|
|
112
|
+
} else if (f.uri) {
|
|
113
|
+
if (IMAGE_MIME_RE.test(mime)) {
|
|
114
|
+
blocks.push({ type: "image_url", image_url: { url: f.uri } })
|
|
115
|
+
} else {
|
|
116
|
+
blocks.push({ type: "text", text: `[file: ${f.name || f.uri} (${mime})]` })
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} else if (p.kind === "data" && p.data !== undefined) {
|
|
120
|
+
blocks.push({ type: "text", text: JSON.stringify(p.data, null, 2) })
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (blocks.length === 0) return ""
|
|
124
|
+
if (blocks.length === 1 && blocks[0].type === "text") return blocks[0].text
|
|
125
|
+
return blocks
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Build OpenAI-format chat messages from A2A conversation history + current message.
|
|
130
|
+
*
|
|
131
|
+
* A2A roles → OpenAI roles:
|
|
132
|
+
* "user" | "ROLE_USER" | 1 → "user"
|
|
133
|
+
* "agent" | "ROLE_AGENT" | 2 → "assistant"
|
|
134
|
+
*
|
|
135
|
+
* Uses history when available (SDK appends current message to it before execute()),
|
|
136
|
+
* falls back to current message only for new tasks.
|
|
137
|
+
*
|
|
138
|
+
* @param {object} requestContext
|
|
139
|
+
* @returns {Array<{role:string, content:string|Array}>}
|
|
140
|
+
*/
|
|
141
|
+
export function buildChatMessages(requestContext) {
|
|
142
|
+
const history = requestContext.task?.history || []
|
|
143
|
+
const current = requestContext.userMessage
|
|
144
|
+
const a2aMessages = history.length > 0 ? history : [current]
|
|
145
|
+
|
|
146
|
+
function toOaiRole(role) {
|
|
147
|
+
if (role === "agent" || role === "ROLE_AGENT" || role === 2) return "assistant"
|
|
148
|
+
return "user"
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return a2aMessages.filter(Boolean).map((msg) => ({
|
|
152
|
+
role: toOaiRole(msg.role),
|
|
153
|
+
content: partsToMessageContent(msg.parts || []),
|
|
154
|
+
}))
|
|
155
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
import { domainToASCII } from "node:url"
|
|
3
|
+
|
|
4
|
+
function resolveI18n(value, locale) {
|
|
5
|
+
if (!value) return undefined
|
|
6
|
+
const match = /{i18n>([^}]+)}/.exec(value)
|
|
7
|
+
if (match) {
|
|
8
|
+
return cds.i18n?.labels?.texts4?.(locale)?.[match[1]] || value
|
|
9
|
+
}
|
|
10
|
+
return value
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getDescription(obj, locale) {
|
|
14
|
+
locale = locale || cds.context?.locale || "en"
|
|
15
|
+
|
|
16
|
+
const title =
|
|
17
|
+
cds.i18n?.labels?.at(obj, locale) ||
|
|
18
|
+
resolveI18n(obj["@Common.Label"], locale) ||
|
|
19
|
+
resolveI18n(obj["@title"], locale)
|
|
20
|
+
|
|
21
|
+
const description =
|
|
22
|
+
resolveI18n(obj["@Core.Description"], locale) || resolveI18n(obj["@description"], locale)
|
|
23
|
+
|
|
24
|
+
const longDescription = resolveI18n(obj["@Core.LongDescription"], locale)
|
|
25
|
+
|
|
26
|
+
const parts = [title, description].filter(Boolean)
|
|
27
|
+
if (parts.length === 0 && !longDescription) return obj.doc || undefined
|
|
28
|
+
|
|
29
|
+
let result = parts.join("\n")
|
|
30
|
+
|
|
31
|
+
if (longDescription) {
|
|
32
|
+
result = result ? `${result}\n\n${longDescription}` : longDescription
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return result || undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getFilteredEntities(srv) {
|
|
39
|
+
return Object.fromEntries(
|
|
40
|
+
Object.entries(srv.entities || {})
|
|
41
|
+
.filter(
|
|
42
|
+
([name, entity]) =>
|
|
43
|
+
!(entity["@cds.autoexposed"] && !entity["@cds.autoexpose"]) &&
|
|
44
|
+
!name.endsWith("DraftAdministrativeData") &&
|
|
45
|
+
!name.endsWith(".texts") &&
|
|
46
|
+
!entity["@cds.api.ignore"],
|
|
47
|
+
)
|
|
48
|
+
.sort(([a], [b]) => a.localeCompare(b)),
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getFilteredActions(srv) {
|
|
53
|
+
const actions = {}
|
|
54
|
+
for (const op of srv.actions || []) {
|
|
55
|
+
if ((op.kind === "action" || op.kind === "function") && !op["@cds.api.ignore"]) {
|
|
56
|
+
const localName = op.name.split(".").pop()
|
|
57
|
+
actions[localName] = op
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return actions
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Sanitize an agent name into a valid LangChain/LLM tool name.
|
|
65
|
+
*/
|
|
66
|
+
export function toolName(rawName) {
|
|
67
|
+
return rawName
|
|
68
|
+
.replace(/[^a-zA-Z0-9_-]/g, "_")
|
|
69
|
+
.toLowerCase()
|
|
70
|
+
.replace(/^[^a-z]/, (c) => `_${c}`) // ensure first char is a letter (some LLM APIs reject otherwise)
|
|
71
|
+
.slice(0, 60)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Shorten a UUID/ID to first 8 characters for log readability.
|
|
76
|
+
*/
|
|
77
|
+
export function short(id) {
|
|
78
|
+
return id?.slice(0, 8) || "-"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Emit an audit log event (fire-and-forget).
|
|
83
|
+
* All events are mapped to SecurityEvent for compatibility with SAP Audit Log Service.
|
|
84
|
+
* The original event name is preserved in data.data.event for forensic analysis.
|
|
85
|
+
* Includes cds.context.id as correlationId for cross-referencing with auto-emitted
|
|
86
|
+
* DPP events (e.g., SensitiveDataRead triggered by tool entity access).
|
|
87
|
+
* Never blocks execution. Logs warning on failure.
|
|
88
|
+
*/
|
|
89
|
+
export function audit(event, data) {
|
|
90
|
+
if (!cds.requires["audit-log"]) return
|
|
91
|
+
cds.connect
|
|
92
|
+
.to("audit-log")
|
|
93
|
+
.then((a) =>
|
|
94
|
+
a.log("SecurityEvent", {
|
|
95
|
+
data: { event, correlationId: cds.context?.id, ...data.data },
|
|
96
|
+
ip: data.ip,
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
.catch((err) => {
|
|
100
|
+
const LOG = cds.log("agents|audit")
|
|
101
|
+
LOG.warn("audit emit failed", { event, error: err.message })
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const ALLOWED_PROTOCOLS = ["http:", "https:"]
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Validates that a URL belongs to one of the allowed domains.
|
|
109
|
+
* Handles subdomains, punycode normalization, protocol validation.
|
|
110
|
+
* Returns true if URL's hostname matches or is a subdomain of any allowed domain.
|
|
111
|
+
* Returns false for invalid URLs, non-HTTP(S) protocols, or domain mismatch.
|
|
112
|
+
*/
|
|
113
|
+
export function isAllowedDomain(untrustedUrl, allowedDomains) {
|
|
114
|
+
if (!untrustedUrl || !allowedDomains?.length) return false
|
|
115
|
+
|
|
116
|
+
let hostname
|
|
117
|
+
try {
|
|
118
|
+
const parsed = new URL(untrustedUrl)
|
|
119
|
+
if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) return false
|
|
120
|
+
hostname = parsed.hostname
|
|
121
|
+
if (!hostname) return false
|
|
122
|
+
} catch {
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const domain of allowedDomains) {
|
|
127
|
+
const normalized = domainToASCII(domain)
|
|
128
|
+
if (!normalized) continue
|
|
129
|
+
if (hostname === normalized) return true
|
|
130
|
+
if (hostname.endsWith("." + normalized)) return true
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const ms4 =
|
|
137
|
+
cds.utils.ms4 ||
|
|
138
|
+
((val) => {
|
|
139
|
+
if (typeof val === "number") return val
|
|
140
|
+
const m = /^(\d+)\s*(ms|s|sec|seconds?|m|min|minutes?|h|hrs|hours?|d|days?|w|weeks?)?$/i.exec(
|
|
141
|
+
String(val),
|
|
142
|
+
)
|
|
143
|
+
if (!m) return Number(val) || 0
|
|
144
|
+
const n = Number(m[1])
|
|
145
|
+
const unit = (m[2] || "ms").toLowerCase()
|
|
146
|
+
const factors = {
|
|
147
|
+
ms: 1,
|
|
148
|
+
s: 1000,
|
|
149
|
+
sec: 1000,
|
|
150
|
+
second: 1000,
|
|
151
|
+
seconds: 1000,
|
|
152
|
+
m: 60000,
|
|
153
|
+
min: 60000,
|
|
154
|
+
minute: 60000,
|
|
155
|
+
minutes: 60000,
|
|
156
|
+
h: 3600000,
|
|
157
|
+
hrs: 3600000,
|
|
158
|
+
hour: 3600000,
|
|
159
|
+
hours: 3600000,
|
|
160
|
+
d: 86400000,
|
|
161
|
+
day: 86400000,
|
|
162
|
+
days: 86400000,
|
|
163
|
+
w: 604800000,
|
|
164
|
+
week: 604800000,
|
|
165
|
+
weeks: 604800000,
|
|
166
|
+
}
|
|
167
|
+
return n * (factors[unit] || 1)
|
|
168
|
+
})
|