@cap-js/agents 0.0.0 → 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/LICENSE +201 -0
- package/README.md +154 -0
- package/_i18n/messages.properties +31 -0
- package/cds-plugin.js +140 -0
- package/index.cds +116 -0
- package/index.js +0 -0
- package/lib/agents/markdown/backends/mime-utils.js +37 -0
- package/lib/agents/markdown/backends/outputs-backend.js +156 -0
- package/lib/agents/markdown/backends/readonly-backend.js +48 -0
- package/lib/agents/markdown/backends/uploads-backend.js +160 -0
- package/lib/agents/markdown/deep-agent.js +82 -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 +897 -0
- package/lib/preview/preview.js +46 -0
- package/lib/protocol/agent-card.js +297 -0
- package/lib/protocol/persistence/checkpoint-saver.js +320 -0
- package/lib/protocol/persistence/cleanup.js +84 -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 +364 -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 +1374 -0
- package/srv/handlers/index.js +178 -0
- package/srv/handlers/mcp-tools.js +161 -0
- package/srv/handlers/sub-agent-tools.js +316 -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,106 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
import * as metrics from "./metrics.js"
|
|
3
|
+
import { ms4 } from "../utils/utils.js"
|
|
4
|
+
|
|
5
|
+
const LOG = cds.log("agents")
|
|
6
|
+
const TASKS = "cap.agent.Tasks"
|
|
7
|
+
|
|
8
|
+
/** Cached active users data — updated by computeActiveUsers(), reported by gauge callback */
|
|
9
|
+
let _activeUsersData = []
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Compute active users from Tasks table (last 24h rolling window).
|
|
13
|
+
* Groups by agentService, counts distinct createdBy.
|
|
14
|
+
*
|
|
15
|
+
* In multi-tenant deployments, iterates all subscribed tenants via
|
|
16
|
+
* cds.xt.DeploymentService and queries each tenant's HDI container separately.
|
|
17
|
+
* In single-tenant mode, queries the current DB directly.
|
|
18
|
+
*/
|
|
19
|
+
export async function computeActiveUsers() {
|
|
20
|
+
_activeUsersData = []
|
|
21
|
+
|
|
22
|
+
let tenants
|
|
23
|
+
try {
|
|
24
|
+
const ds = await cds.connect.to("cds.xt.DeploymentService")
|
|
25
|
+
tenants = await ds.getTenants()
|
|
26
|
+
} catch {
|
|
27
|
+
// Not multi-tenant (dev/SQLite) — run in current context
|
|
28
|
+
tenants = null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!tenants) {
|
|
32
|
+
await _computeForTenant(cds.context?.tenant || "anonymous")
|
|
33
|
+
} else {
|
|
34
|
+
for (const tenant of tenants) {
|
|
35
|
+
// eslint-disable-next-line no-await-in-loop
|
|
36
|
+
await cds.spawn({ tenant, user: cds.User.privileged }, () => _computeForTenant(tenant))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
LOG.debug("active_users computed", {
|
|
41
|
+
tenants: tenants?.length || 1,
|
|
42
|
+
services: _activeUsersData.length,
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function _computeForTenant(tenant) {
|
|
47
|
+
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
|
|
48
|
+
const results = await SELECT.from(TASKS)
|
|
49
|
+
.columns("agentService", "count(distinct createdBy) as userCount")
|
|
50
|
+
.where({ createdAt: { ">=": since } })
|
|
51
|
+
.groupBy("agentService")
|
|
52
|
+
|
|
53
|
+
for (const r of results) {
|
|
54
|
+
_activeUsersData.push({
|
|
55
|
+
agentService: r.agentService,
|
|
56
|
+
userCount: r.userCount,
|
|
57
|
+
tenant,
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** ObservableGauge callback — reports cached values on each OTel collection */
|
|
63
|
+
export function observeActiveUsers(result) {
|
|
64
|
+
for (const { agentService, userCount, tenant } of _activeUsersData) {
|
|
65
|
+
result.observe(userCount, {
|
|
66
|
+
"sap.tenantId": tenant,
|
|
67
|
+
"agent.service": agentService,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Setup the active_users ObservableGauge + schedule periodic computation.
|
|
74
|
+
* When cds.env.agents.activeUsersInterval is 0, only the gauge is registered (no automatic scheduling).
|
|
75
|
+
* Apps can still trigger computation manually via executor.emit("computeActiveUsers").
|
|
76
|
+
*/
|
|
77
|
+
export function setupActiveUsersMetric() {
|
|
78
|
+
const interval = cds.env.agents?.activeUsersInterval
|
|
79
|
+
|
|
80
|
+
// Always register the gauge
|
|
81
|
+
metrics.createActiveUsersGauge(observeActiveUsers)
|
|
82
|
+
|
|
83
|
+
// Disabled scheduling when interval is 0 or "0"
|
|
84
|
+
if (interval === 0 || interval === "0") {
|
|
85
|
+
LOG.debug("active_users automatic scheduling disabled (activeUsersInterval = 0)")
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Schedule periodic computation
|
|
90
|
+
const every = interval || "24h"
|
|
91
|
+
const ms = ms4(every)
|
|
92
|
+
const spawned = cds.spawn({ every: ms }, async () => {
|
|
93
|
+
try {
|
|
94
|
+
await computeActiveUsers()
|
|
95
|
+
} catch (err) {
|
|
96
|
+
LOG.error("active_users computation failed", { error: err.message })
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// Clean up timer on shutdown to prevent post-teardown errors in test environments
|
|
101
|
+
if (spawned?.timer) {
|
|
102
|
+
cds.on("shutdown", () => clearInterval(spawned.timer))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
LOG.debug("active_users metric scheduled", { every })
|
|
106
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
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
|
+
const PATCHED = Symbol.for("@cap-js/agents:patched")
|
|
9
|
+
const SPAN_KIND_CLIENT = 3
|
|
10
|
+
|
|
11
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export async function patchChatModel() {
|
|
14
|
+
try {
|
|
15
|
+
const mod = await import("@langchain/core/language_models/chat_models")
|
|
16
|
+
if (mod.BaseChatModel?.prototype) _patchChatModelProto(mod.BaseChatModel.prototype)
|
|
17
|
+
|
|
18
|
+
// Also patch CJS prototype (different from ESM in dual-package modules)
|
|
19
|
+
try {
|
|
20
|
+
const { createRequire } = await import("node:module")
|
|
21
|
+
const req = createRequire(import.meta.url)
|
|
22
|
+
const cjs = req("@langchain/core/language_models/chat_models")
|
|
23
|
+
if (cjs.BaseChatModel?.prototype) _patchChatModelProto(cjs.BaseChatModel.prototype)
|
|
24
|
+
} catch {
|
|
25
|
+
/* CJS not available */
|
|
26
|
+
}
|
|
27
|
+
} catch (err) {
|
|
28
|
+
LOG.error("Failed to patch BaseChatModel for tracing", { error: err.message })
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Prototype patch ─────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export function _patchChatModelProto(proto) {
|
|
35
|
+
if (proto[PATCHED]) return
|
|
36
|
+
const original = proto.invoke
|
|
37
|
+
if (typeof original !== "function") {
|
|
38
|
+
LOG.warn("BaseChatModel.invoke not found — tracing patch skipped")
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
proto.invoke = async function (input, opts) {
|
|
43
|
+
// Skip instances explicitly opted out (e.g. content-filter probe model)
|
|
44
|
+
const INSTRUMENTED = Symbol.for("@cap-js/agents:instrumented")
|
|
45
|
+
if (this[INSTRUMENTED]) return original.call(this, input, opts)
|
|
46
|
+
|
|
47
|
+
const tracer = metrics.getTracer()
|
|
48
|
+
if (!tracer) return original.call(this, input, opts)
|
|
49
|
+
|
|
50
|
+
const model = this.options?.model || this.model || this.constructor.name
|
|
51
|
+
const provider = _detectProvider(this)
|
|
52
|
+
const node = opts?.runName || "agent"
|
|
53
|
+
const messages = Array.isArray(input) ? input : undefined
|
|
54
|
+
const cacheControl = _detectCacheControl(messages)
|
|
55
|
+
const streaming = this.streaming || this.orchestrationConfig?.streaming || false
|
|
56
|
+
const mAttrs = _metricAttrs(model, node)
|
|
57
|
+
|
|
58
|
+
const invoke = async (span) => {
|
|
59
|
+
if (span) {
|
|
60
|
+
setLLMSpanStartAttrs(span, {
|
|
61
|
+
model,
|
|
62
|
+
provider,
|
|
63
|
+
params: this.options?.params,
|
|
64
|
+
streaming,
|
|
65
|
+
cacheControl,
|
|
66
|
+
node,
|
|
67
|
+
messages,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
const t0 = Date.now()
|
|
71
|
+
try {
|
|
72
|
+
const result = await original.call(this, input, opts)
|
|
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
|
+
|
|
96
|
+
/** @type {import('@langchain/core/messages').UsageMetadata} */
|
|
97
|
+
const usage = result.usage_metadata
|
|
98
|
+
const finishReason =
|
|
99
|
+
result.response_metadata?.finish_reason ||
|
|
100
|
+
result.response_metadata?.stop_reason ||
|
|
101
|
+
(result.tool_calls?.length > 0 ? "tool_use" : "stop")
|
|
102
|
+
|
|
103
|
+
_handleSuccess(span, {
|
|
104
|
+
model,
|
|
105
|
+
provider,
|
|
106
|
+
mAttrs,
|
|
107
|
+
opts,
|
|
108
|
+
params: this.options?.params,
|
|
109
|
+
node,
|
|
110
|
+
tokenUsage: convertUsageData(usage),
|
|
111
|
+
outputContent: result.content ? extractText(result.content) : undefined,
|
|
112
|
+
response: {
|
|
113
|
+
finishReason,
|
|
114
|
+
model: result.response_metadata?.model_name || result.response_metadata?.model || model,
|
|
115
|
+
id: result.response_metadata?.id || result.id,
|
|
116
|
+
toolCalls: result.tool_calls?.map((tc) => ({ name: tc.name, args: tc.args })),
|
|
117
|
+
},
|
|
118
|
+
duration,
|
|
119
|
+
})
|
|
120
|
+
return result
|
|
121
|
+
} catch (err) {
|
|
122
|
+
_handleError(span, { err, mAttrs, model, node, messages })
|
|
123
|
+
throw err
|
|
124
|
+
} finally {
|
|
125
|
+
if (span) span.end()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return tracer.startActiveSpan(`chat ${model}`, { kind: SPAN_KIND_CLIENT }, (span) =>
|
|
130
|
+
invoke(span),
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
proto[PATCHED] = true
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Internal helpers ────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/** Detect LLM provider from model instance properties. */
|
|
139
|
+
function _detectProvider(instance) {
|
|
140
|
+
if (instance.orchestrationConfig) return "sap-ai-core"
|
|
141
|
+
return "langchain"
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function _metricAttrs(model, node) {
|
|
145
|
+
return {
|
|
146
|
+
"sap.tenantId": cds.context?.tenant || "anonymous",
|
|
147
|
+
"agent.service": cds.context?.["agent.service"],
|
|
148
|
+
model,
|
|
149
|
+
node,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Detect cache_control presence in messages (set by injectCacheControl in aicore). */
|
|
154
|
+
function _detectCacheControl(messages) {
|
|
155
|
+
if (!Array.isArray(messages)) return false
|
|
156
|
+
return messages.some((m) => {
|
|
157
|
+
if (!Array.isArray(m.content)) return false
|
|
158
|
+
return m.content.some((b) => b.cache_control)
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Handle successful LLM call: metrics, span end attrs, truncation warning, audit. */
|
|
163
|
+
function _handleSuccess(
|
|
164
|
+
span,
|
|
165
|
+
{ model, provider, mAttrs, opts, params, node, tokenUsage, outputContent, response, duration },
|
|
166
|
+
) {
|
|
167
|
+
metrics.llmInvocations.add(1, { ...mAttrs, outcome: "success" })
|
|
168
|
+
if (tokenUsage?.input_tokens) metrics.llmInputTokens.add(tokenUsage.input_tokens, mAttrs)
|
|
169
|
+
if (tokenUsage?.output_tokens) metrics.llmOutputTokens.add(tokenUsage.output_tokens, mAttrs)
|
|
170
|
+
|
|
171
|
+
if (span) {
|
|
172
|
+
setLLMSpanEndAttrs(span, { model, provider, tokenUsage, outputContent, response })
|
|
173
|
+
span.setStatus({ code: 1 })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (response?.finishReason === "length" || response?.finishReason === "max_tokens") {
|
|
177
|
+
LOG.warn("LLM response truncated: output_tokens reached max_tokens limit", {
|
|
178
|
+
model,
|
|
179
|
+
node,
|
|
180
|
+
max_tokens: params?.max_tokens,
|
|
181
|
+
output_tokens: tokenUsage?.output_tokens,
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const taskId = opts?.configurable?._taskId || cds.context?.["agent.task.id"]
|
|
186
|
+
if (taskId) {
|
|
187
|
+
audit("AgentDecision", {
|
|
188
|
+
data: {
|
|
189
|
+
taskId,
|
|
190
|
+
contextId:
|
|
191
|
+
opts?.configurable?.thread_id?.split(":")[1] || cds.context?.["agent.context.id"],
|
|
192
|
+
service: opts?.configurable?._service || cds.context?.["agent.service"],
|
|
193
|
+
model,
|
|
194
|
+
iteration: opts?.configurable?._iteration ?? cds.context?.["agent.iteration"],
|
|
195
|
+
toolCalls: response?.toolCalls,
|
|
196
|
+
tokenUsage,
|
|
197
|
+
duration,
|
|
198
|
+
},
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Handle LLM error: content-filter warnings, metrics, span error attrs. */
|
|
204
|
+
function _handleError(span, { err, mAttrs, model, node, messages }) {
|
|
205
|
+
const status = err.rootCause?.status
|
|
206
|
+
const data = err.rootCause?.response?.data
|
|
207
|
+
const headers = err.rootCause?.response?.headers
|
|
208
|
+
const isFilterModule = /Filtering Module/i.test(data?.error?.location || "")
|
|
209
|
+
const isExternalFailure = headers?.["ai-external-failure"] === "true"
|
|
210
|
+
|
|
211
|
+
if (isFilterModule && status === 503 && isExternalFailure) {
|
|
212
|
+
LOG.warn(
|
|
213
|
+
"Content filter service rejected the request (likely payload too large for prompt_shield). " +
|
|
214
|
+
"Disable content filtering via the buildContentFilter event handler — see README → Content Filter → Limitations.",
|
|
215
|
+
{ model, node, status, location: data?.error?.location, messageCount: messages?.length },
|
|
216
|
+
)
|
|
217
|
+
} else if (isFilterModule && status === 400) {
|
|
218
|
+
LOG.warn("Content filter blocked the request", {
|
|
219
|
+
model,
|
|
220
|
+
node,
|
|
221
|
+
status,
|
|
222
|
+
reason: data?.error?.message,
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
metrics.llmInvocations.add(1, { ...mAttrs, outcome: "error" })
|
|
227
|
+
if (span) {
|
|
228
|
+
span.setAttribute("error.type", err.constructor?.name || "Error")
|
|
229
|
+
span.setStatus({ code: 2, message: err.message })
|
|
230
|
+
span.recordException(err)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ─── Shared LLM span helpers ─────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/** Extract plain text from LangChain message content (string or content-block array). */
|
|
237
|
+
export function extractText(content) {
|
|
238
|
+
if (typeof content === "string") return content
|
|
239
|
+
if (Array.isArray(content)) {
|
|
240
|
+
return content
|
|
241
|
+
.filter((b) => b.type === "text")
|
|
242
|
+
.map((b) => b.text || "")
|
|
243
|
+
.join("")
|
|
244
|
+
}
|
|
245
|
+
return String(content ?? "")
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Map LangChain message role to OpenAI role string. */
|
|
249
|
+
export function toRole(msg) {
|
|
250
|
+
const t = msg._getType?.()
|
|
251
|
+
if (t === "human") return "user"
|
|
252
|
+
if (t === "ai") return "assistant"
|
|
253
|
+
return t || "user"
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Set common LLM span start attributes.
|
|
258
|
+
*/
|
|
259
|
+
export function setLLMSpanStartAttrs(
|
|
260
|
+
span,
|
|
261
|
+
{ model, provider, params, streaming, cacheControl, node, messages },
|
|
262
|
+
) {
|
|
263
|
+
span.setAttribute("gen_ai.operation.name", "chat")
|
|
264
|
+
span.setAttribute("gen_ai.provider.name", provider)
|
|
265
|
+
span.setAttribute("gen_ai.request.model", model)
|
|
266
|
+
span.setAttribute("mlflow.message.format", "langchain-js")
|
|
267
|
+
if (params?.temperature != null)
|
|
268
|
+
span.setAttribute("gen_ai.request.temperature", params.temperature)
|
|
269
|
+
if (params?.max_tokens != null) span.setAttribute("gen_ai.request.max_tokens", params.max_tokens)
|
|
270
|
+
if (cds.context?.["agent.context.id"])
|
|
271
|
+
span.setAttribute("gen_ai.conversation.id", cds.context["agent.context.id"])
|
|
272
|
+
if (streaming) span.setAttribute("gen_ai.request.stream", true)
|
|
273
|
+
if (node) span.setAttribute("agent.llm.node", node)
|
|
274
|
+
if (cacheControl) span.setAttribute("gen_ai.request.cache_control", true)
|
|
275
|
+
|
|
276
|
+
// MLflow inputs: chat messages for Chat tab
|
|
277
|
+
if ((cds.env.agents?.mlflow || LOG._debug) && messages) {
|
|
278
|
+
const mlMessages = messages.map((m) => ({ role: toRole(m), content: extractText(m.content) }))
|
|
279
|
+
setSpanAttrs(span, mlflowAttrs("LLM", { model, provider, inputs: { messages: mlMessages } }))
|
|
280
|
+
if (LOG._debug) {
|
|
281
|
+
span.setAttribute("gen_ai.input.messages", JSON.stringify(messages.map((m) => m.content)))
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Set gen_ai.response.*, gen_ai.usage.*, MLflow outputs on span end.
|
|
288
|
+
*/
|
|
289
|
+
export function setLLMSpanEndAttrs(span, { model, provider, tokenUsage, outputContent, response }) {
|
|
290
|
+
if (LOG._debug && outputContent) {
|
|
291
|
+
span.setAttribute("gen_ai.output.messages", outputContent)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// gen_ai.response.* attributes
|
|
295
|
+
if (response) {
|
|
296
|
+
if (response.finishReason)
|
|
297
|
+
span.setAttribute("gen_ai.response.finish_reasons", [response.finishReason])
|
|
298
|
+
if (response.model) span.setAttribute("gen_ai.response.model", response.model)
|
|
299
|
+
if (response.id) span.setAttribute("gen_ai.response.id", response.id)
|
|
300
|
+
if (response.finishReason === "length" || response.finishReason === "max_tokens") {
|
|
301
|
+
span.setAttribute("gen_ai.response.truncated", true)
|
|
302
|
+
}
|
|
303
|
+
if (response.toolCalls?.length > 0) {
|
|
304
|
+
span.setAttribute("gen_ai.response.tool_calls", JSON.stringify(response.toolCalls))
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (tokenUsage) {
|
|
309
|
+
setTokenUsage(span, tokenUsage)
|
|
310
|
+
if (cds.env.agents?.mlflow) {
|
|
311
|
+
const mlOpts = { model, provider, tokenUsage: { ...tokenUsage, reasoning_tokens: undefined } }
|
|
312
|
+
if (outputContent) {
|
|
313
|
+
mlOpts.outputs = {
|
|
314
|
+
choices: [{ message: { role: "assistant", content: outputContent } }],
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
setSpanAttrs(span, mlflowAttrs("LLM", mlOpts))
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// MLflow outputs without token usage
|
|
323
|
+
if (cds.env.agents?.mlflow && outputContent) {
|
|
324
|
+
setSpanAttrs(
|
|
325
|
+
span,
|
|
326
|
+
mlflowAttrs("LLM", {
|
|
327
|
+
model,
|
|
328
|
+
provider,
|
|
329
|
+
outputs: { choices: [{ message: { role: "assistant", content: outputContent } }] },
|
|
330
|
+
}),
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function setTokenUsage(span, tokenUsage) {
|
|
336
|
+
span.setAttribute("gen_ai.usage.input_tokens", tokenUsage.input_tokens)
|
|
337
|
+
span.setAttribute("gen_ai.usage.output_tokens", tokenUsage.output_tokens)
|
|
338
|
+
span.setAttribute("gen_ai.usage.total_tokens", tokenUsage.total_tokens)
|
|
339
|
+
|
|
340
|
+
if (tokenUsage.cache_read_input_tokens != null)
|
|
341
|
+
span.setAttribute("gen_ai.usage.cache_read.input_tokens", tokenUsage.cache_read_input_tokens)
|
|
342
|
+
if (tokenUsage.cache_creation_input_tokens != null)
|
|
343
|
+
span.setAttribute(
|
|
344
|
+
"gen_ai.usage.cache_creation.input_tokens",
|
|
345
|
+
tokenUsage.cache_creation_input_tokens,
|
|
346
|
+
)
|
|
347
|
+
if (tokenUsage.reasoning_tokens != null)
|
|
348
|
+
span.setAttribute("gen_ai.usage.reasoning.output_tokens", tokenUsage.reasoning_tokens)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Convert LangChain UsageMetadata to flat token usage format.
|
|
353
|
+
*/
|
|
354
|
+
export function convertUsageData(usage) {
|
|
355
|
+
if (!usage) return undefined
|
|
356
|
+
return {
|
|
357
|
+
input_tokens: usage.input_tokens,
|
|
358
|
+
output_tokens: usage.output_tokens,
|
|
359
|
+
total_tokens: usage.total_tokens,
|
|
360
|
+
cache_creation_input_tokens: usage.input_token_details?.cache_creation,
|
|
361
|
+
cache_read_input_tokens: usage.input_token_details?.cache_read,
|
|
362
|
+
reasoning_tokens: usage.output_token_details?.reasoning,
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
import { createRequire } from "node:module"
|
|
3
|
+
|
|
4
|
+
const NOOP_METER = (() => {
|
|
5
|
+
const noop = () => ({ add() {}, record() {} })
|
|
6
|
+
return { createCounter: noop, createHistogram: noop, createUpDownCounter: noop }
|
|
7
|
+
})()
|
|
8
|
+
|
|
9
|
+
let _otel
|
|
10
|
+
try {
|
|
11
|
+
// Synchronous require avoids top-level await which blocks module graph
|
|
12
|
+
// resolution and causes issues with CDS plugin loading order.
|
|
13
|
+
const require = createRequire(import.meta.url)
|
|
14
|
+
_otel = require("@opentelemetry/api")
|
|
15
|
+
} catch {
|
|
16
|
+
_otel = null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getMeter() {
|
|
20
|
+
return _otel ? _otel.metrics.getMeter("@cap-js/agents") : NOOP_METER
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const meter = getMeter()
|
|
24
|
+
|
|
25
|
+
export const requestDuration = meter.createHistogram("agent.request.duration", {
|
|
26
|
+
description: "End-to-end agent request duration",
|
|
27
|
+
unit: "ms",
|
|
28
|
+
})
|
|
29
|
+
export const requestsTotal = meter.createCounter("agent.requests.total", {
|
|
30
|
+
description: "Total inbound agent requests",
|
|
31
|
+
})
|
|
32
|
+
export const errorsTotal = meter.createCounter("agent.errors.total", {
|
|
33
|
+
description: "Agent requests resulting in error",
|
|
34
|
+
})
|
|
35
|
+
export const concurrentExecutions = meter.createUpDownCounter("agent.executions.concurrent", {
|
|
36
|
+
description: "Currently active workflow executions",
|
|
37
|
+
})
|
|
38
|
+
export const workflowsCompleted = meter.createCounter("agent.workflows.completed", {
|
|
39
|
+
description: "Number of completed agent workflows",
|
|
40
|
+
})
|
|
41
|
+
export const agentActions = meter.createCounter("agent_actions", {
|
|
42
|
+
description: "LLM invocations (agent node calls) per tenant",
|
|
43
|
+
})
|
|
44
|
+
export const llmInputTokens = meter.createCounter("agent.llm.input_tokens", {
|
|
45
|
+
description: "LLM input tokens consumed",
|
|
46
|
+
})
|
|
47
|
+
export const llmOutputTokens = meter.createCounter("agent.llm.output_tokens", {
|
|
48
|
+
description: "LLM output tokens generated",
|
|
49
|
+
})
|
|
50
|
+
export const llmInvocations = meter.createCounter("agent.llm.invocations", {
|
|
51
|
+
description: "LLM invocation count",
|
|
52
|
+
})
|
|
53
|
+
export const toolInvocations = meter.createCounter("agent.tool.invocations", {
|
|
54
|
+
description: "Tool invocation count",
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
/** Common attributes for all agent metrics */
|
|
58
|
+
export function attrs(srv) {
|
|
59
|
+
return {
|
|
60
|
+
"sap.tenantId": cds.context?.tenant || "anonymous",
|
|
61
|
+
"agent.service": typeof srv === "string" ? srv : srv.name,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get active OTel span. Returns null if OTel is unavailable or no span is active.
|
|
67
|
+
*/
|
|
68
|
+
export function getActiveSpan() {
|
|
69
|
+
return _otel?.trace.getActiveSpan() || null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Get OTel tracer for @cap-js/agents (or null if unavailable) */
|
|
73
|
+
export function getTracer() {
|
|
74
|
+
return _otel?.trace.getTracer("@cap-js/agents") || null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Create the active_users ObservableGauge with given observation callback */
|
|
78
|
+
export function createActiveUsersGauge(callback) {
|
|
79
|
+
if (!_otel) return null
|
|
80
|
+
const gauge = meter.createObservableGauge("active_users", {
|
|
81
|
+
description: "Active users per tenant and agent service (24h rolling window)",
|
|
82
|
+
})
|
|
83
|
+
gauge.addCallback(callback)
|
|
84
|
+
return gauge
|
|
85
|
+
}
|