@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,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MLflow span attribute helpers for @cap-js/agents.
|
|
3
|
+
*
|
|
4
|
+
* Adds mlflow.* attributes to existing OTel spans so the MLflow OTLP
|
|
5
|
+
* ingestion endpoint can assemble them into proper MLflow traces.
|
|
6
|
+
*
|
|
7
|
+
* All functions return {} when cds.env.agents.mlflow is falsy (zero overhead).
|
|
8
|
+
*
|
|
9
|
+
* The exporter itself is configured via setupMlflowExporter() as a
|
|
10
|
+
* RoutingSpanProcessor that lazily creates per-experiment-ID exporters —
|
|
11
|
+
* existing exporters (Dynatrace, Cloud Logging, Grafana, etc.) continue
|
|
12
|
+
* to work unchanged.
|
|
13
|
+
*/
|
|
14
|
+
import cds from "@sap/cds"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the MLflow experiment ID for the current request context.
|
|
18
|
+
*
|
|
19
|
+
* Resolution order:
|
|
20
|
+
* 1. @Core.SchemaVersion annotation on the service (feature-toggleable via cds.context.model)
|
|
21
|
+
* 2. cds.env.requires["mlflow"].credentials.MLFLOW_EXPERIMENT_ID
|
|
22
|
+
* 3. process.env.MLFLOW_EXPERIMENT_ID
|
|
23
|
+
*
|
|
24
|
+
* MLflow requires experiment IDs to be numeric (int64). Throws if annotation
|
|
25
|
+
* value is not a valid integer string.
|
|
26
|
+
*/
|
|
27
|
+
function resolveExperimentId() {
|
|
28
|
+
// Read from @Core.SchemaVersion on the active service (feature-toggle-aware)
|
|
29
|
+
const serviceName = cds.context?.["agent.service"]
|
|
30
|
+
if (serviceName) {
|
|
31
|
+
const definition =
|
|
32
|
+
cds.context?.model?.definitions?.[serviceName] || cds.services?.[serviceName]?.definition
|
|
33
|
+
const annotated = definition?.["@Core.SchemaVersion"]
|
|
34
|
+
if (annotated) {
|
|
35
|
+
const id = String(annotated)
|
|
36
|
+
if (!/^\d+$/.test(id)) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`@Core.SchemaVersion on "${serviceName}" must be a numeric string (MLflow experiment ID requires int64). Got: "${id}"`,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
return id
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Fallback: credentials / env
|
|
46
|
+
const creds = cds.env.requires?.mlflow?.credentials || cds.env.requires?.mlflow?.credentials
|
|
47
|
+
return creds?.MLFLOW_EXPERIMENT_ID || process.env.MLFLOW_EXPERIMENT_ID
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build mlflow.* span attributes for a given span type.
|
|
52
|
+
* @param {"LLM"|"AGENT"|"TOOL"|"CHAIN"|"RETRIEVER"} spanType
|
|
53
|
+
* @param {{ inputs?: any, outputs?: any, model?: string, provider?: string, functionName?: string, tokenUsage?: object }} [opts]
|
|
54
|
+
* @returns {Record<string, string>} Attributes to set on span, or {} when disabled
|
|
55
|
+
*/
|
|
56
|
+
export function mlflowAttrs(spanType, opts = {}) {
|
|
57
|
+
if (!cds.env.agents?.mlflow) return {}
|
|
58
|
+
const experimentId = resolveExperimentId()
|
|
59
|
+
const attrs = {}
|
|
60
|
+
if (experimentId) attrs["mlflow.experimentId"] = String(experimentId)
|
|
61
|
+
attrs["mlflow.spanType"] = spanType
|
|
62
|
+
if (opts.model) attrs["mlflow.llm.model"] = String(opts.model)
|
|
63
|
+
if (opts.provider) attrs["mlflow.llm.provider"] = String(opts.provider)
|
|
64
|
+
if (opts.functionName) attrs["mlflow.spanFunctionName"] = String(opts.functionName)
|
|
65
|
+
if (opts.inputs !== undefined) attrs["mlflow.spanInputs"] = JSON.stringify(opts.inputs)
|
|
66
|
+
if (opts.outputs !== undefined) attrs["mlflow.spanOutputs"] = JSON.stringify(opts.outputs)
|
|
67
|
+
if (opts.tokenUsage) {
|
|
68
|
+
attrs["mlflow.chat.tokenUsage"] = JSON.stringify({
|
|
69
|
+
input_tokens: opts.tokenUsage.input_tokens,
|
|
70
|
+
output_tokens: opts.tokenUsage.output_tokens,
|
|
71
|
+
total_tokens: opts.tokenUsage.total_tokens,
|
|
72
|
+
cache_creation_input_tokens: opts.tokenUsage.cache_creation_input_tokens,
|
|
73
|
+
cache_read_input_tokens: opts.tokenUsage.cache_read_input_tokens,
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
return attrs
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build mlflow trace-level tag attributes (set on root/workflow span).
|
|
81
|
+
* Uses mlflow.traceTag.* prefix so MLflow server extracts them as trace tags.
|
|
82
|
+
* Also sets OTel semconv user.id and session.id which MLflow reads natively.
|
|
83
|
+
* @returns {Record<string, string>} Attributes to set on root span, or {} when disabled
|
|
84
|
+
*/
|
|
85
|
+
export function mlflowTraceAttrs() {
|
|
86
|
+
if (!cds.env.agents?.mlflow) return {}
|
|
87
|
+
const session = String(cds.context?.["agent.context.id"] || "")
|
|
88
|
+
const user = String(cds.context?.user?.id || "")
|
|
89
|
+
const tenant = String(cds.context?.tenant || "")
|
|
90
|
+
return {
|
|
91
|
+
// OTel semconv keys MLflow reads natively for user/session display
|
|
92
|
+
"session.id": session,
|
|
93
|
+
"user.id": user,
|
|
94
|
+
// mlflow.traceTag.* prefix for custom trace tags
|
|
95
|
+
"mlflow.traceTag.tenant": tenant,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Spread attributes object onto an OTel span (null-safe).
|
|
101
|
+
* Coerces all values to strings to prevent [object Object] in downstream systems.
|
|
102
|
+
* @param {import("@opentelemetry/api").Span | null} span
|
|
103
|
+
* @param {Record<string, string>} attrs
|
|
104
|
+
*/
|
|
105
|
+
export function setSpanAttrs(span, attrs) {
|
|
106
|
+
if (!cds.env.agents?.mlflow) return
|
|
107
|
+
if (!span) return
|
|
108
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
109
|
+
if (v !== undefined && v !== null) span.setAttribute(k, v)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* SpanProcessor that routes spans to per-experiment-ID BatchSpanProcessors.
|
|
115
|
+
* Lazily creates an OTLPTraceExporter for each unique mlflow.experimentId
|
|
116
|
+
* seen on spans, so different services (or feature-toggled @Core.SchemaVersion
|
|
117
|
+
* values) export to the correct MLflow experiment.
|
|
118
|
+
*
|
|
119
|
+
* Supports both static headers (PAT token) and async header factories (OAuth).
|
|
120
|
+
*/
|
|
121
|
+
export class RoutingSpanProcessor {
|
|
122
|
+
constructor({ url, headersConfig, ucTableName, BatchSpanProcessor, OTLPTraceExporter }) {
|
|
123
|
+
this._url = url
|
|
124
|
+
this._headersConfig = headersConfig
|
|
125
|
+
this._ucTableName = ucTableName
|
|
126
|
+
this._BatchSpanProcessor = BatchSpanProcessor
|
|
127
|
+
this._OTLPTraceExporter = OTLPTraceExporter
|
|
128
|
+
this._processors = new Map()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
onStart() {}
|
|
132
|
+
|
|
133
|
+
onEnd(span) {
|
|
134
|
+
const expId = span.attributes?.["mlflow.experimentId"]
|
|
135
|
+
if (!expId) return
|
|
136
|
+
this._getOrCreate(expId).onEnd(span)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_getOrCreate(experimentId) {
|
|
140
|
+
if (this._processors.has(experimentId)) return this._processors.get(experimentId)
|
|
141
|
+
const ucHeader = this._ucTableName
|
|
142
|
+
? { "X-Databricks-UC-Table-Name": this._ucTableName }
|
|
143
|
+
: undefined
|
|
144
|
+
// Build headers — async factory or static object
|
|
145
|
+
const headers =
|
|
146
|
+
typeof this._headersConfig === "function"
|
|
147
|
+
? async () => ({
|
|
148
|
+
...(await this._headersConfig()),
|
|
149
|
+
"x-mlflow-experiment-id": experimentId,
|
|
150
|
+
...ucHeader,
|
|
151
|
+
})
|
|
152
|
+
: {
|
|
153
|
+
...this._headersConfig,
|
|
154
|
+
"x-mlflow-experiment-id": experimentId,
|
|
155
|
+
...ucHeader,
|
|
156
|
+
}
|
|
157
|
+
const exporter = new this._OTLPTraceExporter({ url: this._url, headers })
|
|
158
|
+
const proc = new this._BatchSpanProcessor(exporter)
|
|
159
|
+
this._processors.set(experimentId, proc)
|
|
160
|
+
return proc
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async forceFlush() {
|
|
164
|
+
await Promise.all([...this._processors.values()].map((p) => p.forceFlush()))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async shutdown() {
|
|
168
|
+
await Promise.all([...this._processors.values()].map((p) => p.shutdown()))
|
|
169
|
+
this._processors.clear()
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Register MLflow OTLP RoutingSpanProcessor. No-op when credentials missing.
|
|
175
|
+
* Reads from cds.env.requires["mlflow"].credentials
|
|
176
|
+
*
|
|
177
|
+
* Auth: OAuth (clientid+clientsecret+url) takes precedence over static MLFLOW_TOKEN.
|
|
178
|
+
* UC: set UC_CATALOG + UC_SCHEMA + UC_TABLE_PREFIX for Unity Catalog trace storage.
|
|
179
|
+
*/
|
|
180
|
+
export async function setupMlflowExporter() {
|
|
181
|
+
if (!cds.env.agents?.mlflow) return
|
|
182
|
+
|
|
183
|
+
const LOG = cds.log("agents")
|
|
184
|
+
try {
|
|
185
|
+
const creds = cds.env.requires?.mlflow?.credentials || {}
|
|
186
|
+
const host = creds.MLFLOW_HOST
|
|
187
|
+
const token = creds.MLFLOW_TOKEN
|
|
188
|
+
const mlflowEndpoint =
|
|
189
|
+
creds.MLFLOW_OTLP_ENDPOINT || (host && `${host.replace(/\/$/, "")}/v1/traces`)
|
|
190
|
+
|
|
191
|
+
// Resolve authentication mode
|
|
192
|
+
let headersConfig
|
|
193
|
+
if (creds.clientid && creds.clientsecret) {
|
|
194
|
+
const tokenUrl = `${creds.url ?? host.replace(/\/$/, "")}/oidc/v1/token`
|
|
195
|
+
let cached = null
|
|
196
|
+
let expiresAt = 0
|
|
197
|
+
headersConfig = async function fetchToken() {
|
|
198
|
+
// Return cached token if still valid (60s buffer before expiry)
|
|
199
|
+
if (cached && Date.now() < expiresAt) return cached
|
|
200
|
+
const res = await fetch(tokenUrl, {
|
|
201
|
+
method: "POST",
|
|
202
|
+
headers: {
|
|
203
|
+
Authorization:
|
|
204
|
+
"Basic " + Buffer.from(`${creds.clientid}:${creds.clientsecret}`).toString("base64"),
|
|
205
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
206
|
+
},
|
|
207
|
+
body: `grant_type=client_credentials&scope=all-apis`,
|
|
208
|
+
signal: AbortSignal.timeout(10_000),
|
|
209
|
+
})
|
|
210
|
+
if (!res.ok) {
|
|
211
|
+
const body = await res.text().catch(() => "")
|
|
212
|
+
const msg = `MLFlow oauth export failed: HTTP ${res.status} ${res.statusText}`
|
|
213
|
+
throw cds.error({ message: msg, response: body })
|
|
214
|
+
}
|
|
215
|
+
const { access_token, expires_in } = await res.json()
|
|
216
|
+
cached = { Authorization: `Bearer ${access_token}` }
|
|
217
|
+
expiresAt = Date.now() + (expires_in - 60) * 1000
|
|
218
|
+
return cached
|
|
219
|
+
}
|
|
220
|
+
LOG.debug("MLflow: using OAuth client credentials authentication")
|
|
221
|
+
} else if (token) {
|
|
222
|
+
headersConfig = { Authorization: `Bearer ${token}` }
|
|
223
|
+
} else {
|
|
224
|
+
headersConfig = {}
|
|
225
|
+
LOG.debug("MLflow: no auth credentials — assuming unauthenticated MLflow server")
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!mlflowEndpoint) {
|
|
229
|
+
LOG.warn(
|
|
230
|
+
"MLflow: no endpoint configured (MLFLOW_HOST or MLFLOW_OTLP_ENDPOINT) — export disabled",
|
|
231
|
+
)
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const ucCatalog = creds.UC_CATALOG
|
|
236
|
+
const ucSchema = creds.UC_SCHEMA
|
|
237
|
+
const ucTablePrefix = creds.UC_TABLE_PREFIX
|
|
238
|
+
const ucTableName =
|
|
239
|
+
ucCatalog && ucSchema && ucTablePrefix
|
|
240
|
+
? `${ucCatalog}.${ucSchema}.${ucTablePrefix}_otel_spans`
|
|
241
|
+
: undefined
|
|
242
|
+
|
|
243
|
+
const { trace } = await import("@opentelemetry/api")
|
|
244
|
+
const provider = trace.getTracerProvider()
|
|
245
|
+
const delegate = provider.getDelegate?.() || provider
|
|
246
|
+
|
|
247
|
+
let BatchSpanProcessor, OTLPTraceExporter
|
|
248
|
+
try {
|
|
249
|
+
const { createRequire } = await import("node:module")
|
|
250
|
+
const req = createRequire(process.cwd() + "/")
|
|
251
|
+
;({ BatchSpanProcessor } = req("@opentelemetry/sdk-trace-base"))
|
|
252
|
+
;({ OTLPTraceExporter } = req("@opentelemetry/exporter-trace-otlp-proto"))
|
|
253
|
+
} catch (err) {
|
|
254
|
+
LOG.warn(
|
|
255
|
+
"MLflow: @opentelemetry/exporter-trace-otlp-proto not resolvable from the application. " +
|
|
256
|
+
"Install a version matching your @cap-js/telemetry major " +
|
|
257
|
+
"(v1 → ^0.57, v2 → ^0.221). Export disabled.",
|
|
258
|
+
{ error: err.message },
|
|
259
|
+
)
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const routing = new RoutingSpanProcessor({
|
|
264
|
+
url: mlflowEndpoint,
|
|
265
|
+
headersConfig,
|
|
266
|
+
ucTableName,
|
|
267
|
+
BatchSpanProcessor,
|
|
268
|
+
OTLPTraceExporter,
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
if (delegate.addSpanProcessor) {
|
|
272
|
+
// OTEL SDK v1
|
|
273
|
+
delegate.addSpanProcessor(routing)
|
|
274
|
+
} else if (delegate._activeSpanProcessor?._spanProcessors) {
|
|
275
|
+
// OTEL SDK v2: no addSpanProcessor — push into MultiSpanProcessor
|
|
276
|
+
delegate._activeSpanProcessor._spanProcessors.push(routing)
|
|
277
|
+
} else {
|
|
278
|
+
LOG.warn(
|
|
279
|
+
"MLflow: no TracerProvider with addSpanProcessor — ensure @cap-js/telemetry is loaded",
|
|
280
|
+
)
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
LOG.info("MLflow: routing span processor added (per-experiment export)", {
|
|
284
|
+
endpoint: mlflowEndpoint,
|
|
285
|
+
...(ucTableName && { ucTableName }),
|
|
286
|
+
})
|
|
287
|
+
} catch (err) {
|
|
288
|
+
LOG.error("MLflow: failed to configure OTLP export", { error: err.message })
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
import * as metrics from "./metrics.js"
|
|
3
|
+
import { mlflowAttrs, setSpanAttrs } from "./mlflow.js"
|
|
4
|
+
import { audit } from "../utils/utils.js"
|
|
5
|
+
|
|
6
|
+
const LOG = cds.log("agents")
|
|
7
|
+
|
|
8
|
+
/** Symbol marking our own instrumented classes — patch skips these */
|
|
9
|
+
const INSTRUMENTED = Symbol.for("@cap-js/agents:instrumented")
|
|
10
|
+
|
|
11
|
+
/** Symbol marking a prototype as already patched */
|
|
12
|
+
const PATCHED = Symbol.for("@cap-js/agents:patched")
|
|
13
|
+
|
|
14
|
+
export async function patchTools() {
|
|
15
|
+
try {
|
|
16
|
+
const mod = await import("@langchain/core/tools")
|
|
17
|
+
if (mod.StructuredTool?.prototype) _patchToolsProto(mod.StructuredTool.prototype)
|
|
18
|
+
|
|
19
|
+
// Also patch CJS prototype (different from ESM in dual-package modules)
|
|
20
|
+
try {
|
|
21
|
+
const { createRequire } = await import("node:module")
|
|
22
|
+
const req = createRequire(import.meta.url)
|
|
23
|
+
const cjs = req("@langchain/core/tools")
|
|
24
|
+
if (cjs.StructuredTool?.prototype) _patchToolsProto(cjs.StructuredTool.prototype)
|
|
25
|
+
} catch {
|
|
26
|
+
/* CJS not available */
|
|
27
|
+
}
|
|
28
|
+
} catch (err) {
|
|
29
|
+
LOG.error("Failed to patch StructuredTool for tracing", { error: err.message })
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function _patchToolsProto(proto) {
|
|
34
|
+
if (proto[PATCHED]) return
|
|
35
|
+
const original = proto.invoke
|
|
36
|
+
if (typeof original !== "function") {
|
|
37
|
+
LOG.warn("StructuredTool.invoke not found — tracing patch skipped")
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
proto.invoke = async function (args, config) {
|
|
42
|
+
if (this[INSTRUMENTED]) return original.call(this, args, config)
|
|
43
|
+
|
|
44
|
+
const tracer = metrics.getTracer()
|
|
45
|
+
const toolName = this.name || this.constructor.name
|
|
46
|
+
|
|
47
|
+
const invoke = async (span) => {
|
|
48
|
+
if (span) {
|
|
49
|
+
span.setAttribute("gen_ai.operation.name", "execute_tool")
|
|
50
|
+
span.setAttribute("gen_ai.provider.name", "langchain")
|
|
51
|
+
span.setAttribute("gen_ai.tool.call.id", toolName)
|
|
52
|
+
const inputs = args?.args ?? args
|
|
53
|
+
setSpanAttrs(span, mlflowAttrs("TOOL", { inputs, functionName: toolName }))
|
|
54
|
+
if (LOG._debug) span.setAttribute("gen_ai.tool.call.arguments", JSON.stringify(inputs))
|
|
55
|
+
}
|
|
56
|
+
const t0 = Date.now()
|
|
57
|
+
try {
|
|
58
|
+
const result = await original.call(this, args, config)
|
|
59
|
+
const duration = Date.now() - t0
|
|
60
|
+
|
|
61
|
+
const semanticError = result?.artifact?.isError === true
|
|
62
|
+
const outcome = semanticError ? "error" : "success"
|
|
63
|
+
const outputs = result?.content ?? result
|
|
64
|
+
|
|
65
|
+
if (span) {
|
|
66
|
+
span.setAttribute("gen_ai.tool.call.outcome", outcome)
|
|
67
|
+
if (semanticError) {
|
|
68
|
+
const msg = typeof outputs === "string" ? outputs : "tool returned isError"
|
|
69
|
+
span.setAttribute("error.type", "Error")
|
|
70
|
+
span.setStatus({ code: 2, message: msg })
|
|
71
|
+
span.addEvent("exception", {
|
|
72
|
+
"exception.type": "Error",
|
|
73
|
+
"exception.message": msg,
|
|
74
|
+
})
|
|
75
|
+
setSpanAttrs(span, mlflowAttrs("TOOL", { outputs: { error: msg } }))
|
|
76
|
+
} else {
|
|
77
|
+
span.setStatus({ code: 1 })
|
|
78
|
+
setSpanAttrs(span, mlflowAttrs("TOOL", { outputs }))
|
|
79
|
+
}
|
|
80
|
+
if (LOG._debug) {
|
|
81
|
+
span.setAttribute("gen_ai.tool.call.result", outputs)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
metrics.toolInvocations.add(1, {
|
|
86
|
+
"sap.tenantId": cds.context?.tenant || "anonymous",
|
|
87
|
+
"agent.service": config?.configurable?._service || cds.context?.["agent.service"],
|
|
88
|
+
tool: toolName,
|
|
89
|
+
outcome,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// Audit: record tool invocation
|
|
93
|
+
const taskId = config?.configurable?._taskId || cds.context?.["agent.task.id"]
|
|
94
|
+
if (taskId) {
|
|
95
|
+
const resultStr = typeof outputs === "string" ? outputs : JSON.stringify(outputs)
|
|
96
|
+
audit("ToolInvocation", {
|
|
97
|
+
data: {
|
|
98
|
+
taskId,
|
|
99
|
+
service: config?.configurable?._service || cds.context?.["agent.service"],
|
|
100
|
+
tool: toolName,
|
|
101
|
+
args,
|
|
102
|
+
outcome,
|
|
103
|
+
...(semanticError
|
|
104
|
+
? { error: resultStr?.slice(0, 2000) }
|
|
105
|
+
: { result: resultStr?.slice(0, 2000) }),
|
|
106
|
+
duration,
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return result
|
|
112
|
+
} catch (err) {
|
|
113
|
+
const duration = Date.now() - t0
|
|
114
|
+
if (span) {
|
|
115
|
+
span.setAttribute("gen_ai.tool.call.outcome", "error")
|
|
116
|
+
span.setAttribute("error.type", err.constructor?.name || "Error")
|
|
117
|
+
span.setStatus({ code: 2, message: err.message })
|
|
118
|
+
span.recordException(err)
|
|
119
|
+
setSpanAttrs(span, mlflowAttrs("TOOL", { outputs: { error: err.message } }))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
metrics.toolInvocations.add(1, {
|
|
123
|
+
"sap.tenantId": cds.context?.tenant || "anonymous",
|
|
124
|
+
"agent.service": config?.configurable?._service || cds.context?.["agent.service"],
|
|
125
|
+
tool: toolName,
|
|
126
|
+
outcome: "error",
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// Audit: record failed tool invocation
|
|
130
|
+
const taskId = config?.configurable?._taskId || cds.context?.["agent.task.id"]
|
|
131
|
+
if (taskId) {
|
|
132
|
+
audit("ToolInvocation", {
|
|
133
|
+
data: {
|
|
134
|
+
taskId,
|
|
135
|
+
service: config?.configurable?._service || cds.context?.["agent.service"],
|
|
136
|
+
tool: toolName,
|
|
137
|
+
args,
|
|
138
|
+
outcome: "error",
|
|
139
|
+
error: err.message,
|
|
140
|
+
duration,
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Swallow error: return as string so LLM can see it and retry.
|
|
146
|
+
// Deep agents use ToolNode with handleToolErrors (default: true) which
|
|
147
|
+
// already wraps errors as ToolMessages. Custom graphs that invoke tools
|
|
148
|
+
// directly also benefit from error swallowing — the graph continues and
|
|
149
|
+
// the LLM can retry with a corrected query.
|
|
150
|
+
return `Error: ${err.message}`
|
|
151
|
+
} finally {
|
|
152
|
+
if (span) span.end()
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (tracer) {
|
|
157
|
+
return tracer.startActiveSpan(`execute_tool DynamicStructuredTool ${toolName}`, (span) =>
|
|
158
|
+
invoke(span),
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
return invoke(null)
|
|
162
|
+
}
|
|
163
|
+
proto[PATCHED] = true
|
|
164
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
import * as metrics from "./metrics.js"
|
|
3
|
+
import { mlflowAttrs, setSpanAttrs } from "./mlflow.js"
|
|
4
|
+
|
|
5
|
+
const LOG = cds.log("agents")
|
|
6
|
+
|
|
7
|
+
// ─── Patching ────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
/** Symbol marking our own instrumented classes — patch skips these */
|
|
10
|
+
export const INSTRUMENTED = Symbol.for("@cap-js/agents:instrumented")
|
|
11
|
+
|
|
12
|
+
/** Symbol marking a prototype as already patched */
|
|
13
|
+
const PATCHED = Symbol.for("@cap-js/agents:patched")
|
|
14
|
+
|
|
15
|
+
export async function patchLangChain() {
|
|
16
|
+
await patchChatModel()
|
|
17
|
+
await patchTools()
|
|
18
|
+
await patchRunnableLambda()
|
|
19
|
+
await patchRunnableSequence()
|
|
20
|
+
LOG.debug("LangChain tracing patches applied")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ─── BaseChatModel (extracted to chat-tracing.js) ────────────────────────────
|
|
24
|
+
|
|
25
|
+
import { patchChatModel } from "./chat-tracing.js"
|
|
26
|
+
|
|
27
|
+
// ─── StructuredTool (extracted to tool-tracing.js) ───────────────────────────
|
|
28
|
+
|
|
29
|
+
import { patchTools } from "./tool-tracing.js"
|
|
30
|
+
|
|
31
|
+
// ─── RunnableLambda ───────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
async function patchRunnableLambda() {
|
|
34
|
+
try {
|
|
35
|
+
const mod = await import("@langchain/core/runnables")
|
|
36
|
+
if (mod.RunnableLambda?.prototype) _patchRunnableLambdaProto(mod.RunnableLambda.prototype)
|
|
37
|
+
|
|
38
|
+
// Also patch CJS prototype
|
|
39
|
+
try {
|
|
40
|
+
const { createRequire } = await import("node:module")
|
|
41
|
+
const req = createRequire(import.meta.url)
|
|
42
|
+
const cjs = req("@langchain/core/runnables")
|
|
43
|
+
if (cjs.RunnableLambda?.prototype) _patchRunnableLambdaProto(cjs.RunnableLambda.prototype)
|
|
44
|
+
} catch {
|
|
45
|
+
/* CJS not available */
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
LOG.error("Failed to patch RunnableLambda for tracing", { error: err.message })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function _patchRunnableLambdaProto(proto) {
|
|
53
|
+
if (proto[PATCHED]) return
|
|
54
|
+
const original = proto.invoke
|
|
55
|
+
if (typeof original !== "function") {
|
|
56
|
+
LOG.warn("RunnableLambda.invoke not found — tracing patch skipped")
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
proto.invoke = async function (input, config) {
|
|
61
|
+
const tracer = metrics.getTracer()
|
|
62
|
+
if (!tracer) return original.call(this, input, config)
|
|
63
|
+
|
|
64
|
+
const name = this.name || config?.runName || this.func?.name || "anonymous"
|
|
65
|
+
return tracer.startActiveSpan(`task RunnableLambda ${name}`, async (span) => {
|
|
66
|
+
span.setAttribute("gen_ai.operation.name", "invoke_workflow")
|
|
67
|
+
span.setAttribute("gen_ai.provider.name", "langchain")
|
|
68
|
+
setSpanAttrs(span, mlflowAttrs("CHAIN", { functionName: name }))
|
|
69
|
+
if (LOG._debug) span.setAttribute("gen_ai.input.messages", JSON.stringify(input))
|
|
70
|
+
try {
|
|
71
|
+
const result = await original.call(this, input, config)
|
|
72
|
+
if (LOG._debug) {
|
|
73
|
+
span.setAttribute(
|
|
74
|
+
"gen_ai.output.messages",
|
|
75
|
+
typeof result === "string" ? result : JSON.stringify(result),
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
return result
|
|
79
|
+
} catch (err) {
|
|
80
|
+
span.setAttribute("error.type", err.constructor?.name || "Error")
|
|
81
|
+
span.setStatus({ code: 2, message: err.message })
|
|
82
|
+
throw err
|
|
83
|
+
} finally {
|
|
84
|
+
span.end()
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
proto[PATCHED] = true
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── RunnableSequence ─────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
async function patchRunnableSequence() {
|
|
94
|
+
try {
|
|
95
|
+
const mod = await import("@langchain/core/runnables")
|
|
96
|
+
if (mod.RunnableSequence?.prototype) _patchRunnableSequenceProto(mod.RunnableSequence.prototype)
|
|
97
|
+
|
|
98
|
+
// Also patch CJS prototype
|
|
99
|
+
try {
|
|
100
|
+
const { createRequire } = await import("node:module")
|
|
101
|
+
const req = createRequire(import.meta.url)
|
|
102
|
+
const cjs = req("@langchain/core/runnables")
|
|
103
|
+
if (cjs.RunnableSequence?.prototype)
|
|
104
|
+
_patchRunnableSequenceProto(cjs.RunnableSequence.prototype)
|
|
105
|
+
} catch {
|
|
106
|
+
/* CJS not available */
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
LOG.error("Failed to patch RunnableSequence for tracing", { error: err.message })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function _patchRunnableSequenceProto(proto) {
|
|
114
|
+
if (proto[PATCHED]) return
|
|
115
|
+
const original = proto.invoke
|
|
116
|
+
if (typeof original !== "function") {
|
|
117
|
+
LOG.warn("RunnableSequence.invoke not found — tracing patch skipped")
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
proto.invoke = async function (input, config) {
|
|
122
|
+
const tracer = metrics.getTracer()
|
|
123
|
+
if (!tracer) return original.call(this, input, config)
|
|
124
|
+
|
|
125
|
+
const name = this.name || config?.runName || this.first?.name || "RunnableSequence"
|
|
126
|
+
return tracer.startActiveSpan(`workflow RunnableSequence ${name}`, async (span) => {
|
|
127
|
+
span.setAttribute("gen_ai.operation.name", "invoke_workflow")
|
|
128
|
+
span.setAttribute("gen_ai.provider.name", "langchain")
|
|
129
|
+
setSpanAttrs(span, mlflowAttrs("CHAIN", { functionName: name }))
|
|
130
|
+
if (LOG._debug) span.setAttribute("gen_ai.input.messages", JSON.stringify(input))
|
|
131
|
+
try {
|
|
132
|
+
const result = await original.call(this, input, config)
|
|
133
|
+
if (LOG._debug) {
|
|
134
|
+
span.setAttribute(
|
|
135
|
+
"gen_ai.output.messages",
|
|
136
|
+
typeof result === "string" ? result : JSON.stringify(result),
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
return result
|
|
140
|
+
} catch (err) {
|
|
141
|
+
span.setAttribute("error.type", err.constructor?.name || "Error")
|
|
142
|
+
span.setStatus({ code: 2, message: err.message })
|
|
143
|
+
throw err
|
|
144
|
+
} finally {
|
|
145
|
+
span.end()
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
proto[PATCHED] = true
|
|
150
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dev-only preview auth challenge.
|
|
5
|
+
*
|
|
6
|
+
* When a CDS service has NO service-level `@requires`/`@restrict` but SOME
|
|
7
|
+
* entity or action inside is gated, an anonymous user has no access
|
|
8
|
+
*/
|
|
9
|
+
export const previewAuthChallenge = (srv) => (req, res, next) => {
|
|
10
|
+
// Auth kind — only mocked participates. `dummy` is intentionally excluded
|
|
11
|
+
// (grants every role to every user — nothing to challenge). Production auth
|
|
12
|
+
// strategies (`xsuaa`, `jwt`, `ias`, ...) are untouched.
|
|
13
|
+
const auth = cds.env.requires?.auth
|
|
14
|
+
const kind = typeof auth === "string" ? auth : auth?.kind
|
|
15
|
+
if (kind !== "mocked") return next()
|
|
16
|
+
|
|
17
|
+
// Service shape — service itself must be open (else the outer 401 gate in
|
|
18
|
+
// `lib/index.js` already challenges), but something inside must be gated.
|
|
19
|
+
const def = srv?.definition
|
|
20
|
+
if (!def || def["@requires"] || def["@restrict"]) return next()
|
|
21
|
+
const hasInnerAuth =
|
|
22
|
+
Object.values(srv.entities || {}).some((e) => e["@requires"] || e["@restrict"]) ||
|
|
23
|
+
Array.from(srv.actions || []).some((a) => a["@requires"] || a["@restrict"])
|
|
24
|
+
if (!hasInnerAuth) return next()
|
|
25
|
+
|
|
26
|
+
// Caller identity — anonymous callers get the challenge.
|
|
27
|
+
const user = cds.context?.user || req?.user
|
|
28
|
+
const anonymous = !user || user._is_anonymous || user.id === "anonymous"
|
|
29
|
+
if (!anonymous) return next()
|
|
30
|
+
|
|
31
|
+
res.setHeader("WWW-Authenticate", `Basic realm="cap-agents:${srv.name}"`)
|
|
32
|
+
res.status(401).end()
|
|
33
|
+
}
|