@cap-js/agents 0.9.2 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/cds-plugin.js +2 -2
- package/lib/agents/middleware/content-filter.js +3 -2
- package/lib/agents/middleware/index.js +11 -0
- package/lib/agents/middleware/remote-mcp.js +62 -0
- package/lib/agents/middleware/tool-wrap.js +52 -0
- package/lib/compile.js +6 -2
- package/lib/eval/Judge.js +239 -0
- package/lib/eval/eval-describe.js +69 -0
- package/lib/eval/eval-run.js +147 -0
- package/lib/eval/index.js +6 -0
- package/lib/eval/metrics.js +53 -0
- package/lib/eval/span-collector.js +50 -0
- package/lib/index.js +2 -1
- package/lib/models/aicore.js +23 -6
- package/lib/preview/chat.html +368 -65
- package/lib/protocol/agent-card.js +6 -2
- package/lib/sidecar.js +1 -1
- package/lib/telemetry/chat-tracing.js +39 -8
- package/lib/telemetry/mlflow/credentials.js +79 -0
- package/lib/telemetry/mlflow/evaluation.js +38 -0
- package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +75 -0
- package/lib/telemetry/mlflow/exporter/MlflowExporter.js +115 -0
- package/lib/telemetry/mlflow/exporter/index.js +23 -0
- package/lib/telemetry/mlflow/index.js +16 -0
- package/lib/telemetry/mlflow/prompts.js +123 -0
- package/lib/telemetry/mlflow/tracing.js +264 -0
- package/lib/telemetry/tool-tracing.js +27 -54
- package/lib/telemetry/tracing.js +3 -1
- package/lib/utils/markdown.js +1 -11
- package/lib/utils/resilience.js +133 -0
- package/lib/utils/utils.js +17 -0
- package/package.json +23 -14
- package/srv/handlers/chat.js +278 -0
- package/srv/handlers/graph-executor.js +43 -37
- package/srv/handlers/index.js +3 -0
- package/srv/handlers/mcp-tools.js +7 -45
- package/srv/handlers/sub-agent-tools.js +8 -1
- package/srv/handlers/tools.js +9 -5
- package/index.js +0 -0
- package/lib/telemetry/mlflow.js +0 -290
- /package/{index.cds → srv/entities.cds} +0 -0
package/README.md
CHANGED
package/cds-plugin.js
CHANGED
|
@@ -4,7 +4,7 @@ const LOG = cds.log("agents")
|
|
|
4
4
|
import { patchLangChain } from "./lib/telemetry/tracing.js"
|
|
5
5
|
import cds_compile_to_a2a from "./lib/compile.js"
|
|
6
6
|
import registerDefaultAgentHandlers from "./srv/handlers/index.js"
|
|
7
|
-
import { slugified } from "./lib/utils/
|
|
7
|
+
import { slugified } from "./lib/utils/utils.js"
|
|
8
8
|
|
|
9
9
|
cds.compile.to.a2a = cds_compile_to_a2a
|
|
10
10
|
|
|
@@ -96,7 +96,7 @@ cds.on("served", async () => {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
if (cds.env.agents?.mlflow) {
|
|
99
|
-
const { setupMlflowExporter } = await import("./lib/telemetry/mlflow.js")
|
|
99
|
+
const { setupMlflowExporter } = await import("./lib/telemetry/mlflow/index.js")
|
|
100
100
|
setupMlflowExporter()
|
|
101
101
|
}
|
|
102
102
|
})
|
|
@@ -2,7 +2,7 @@ import cds from "@sap/cds"
|
|
|
2
2
|
import { createMiddleware } from "langchain"
|
|
3
3
|
import { AIMessage } from "@langchain/core/messages"
|
|
4
4
|
import { OrchestrationClient } from "@sap-ai-sdk/langchain"
|
|
5
|
-
import { timeout, retry } from "
|
|
5
|
+
import { timeout, retry } from "../../utils/resilience.js"
|
|
6
6
|
import { toSdkFilterFormat } from "../../../lib/models/aicore.js"
|
|
7
7
|
import { audit } from "../../utils/utils.js"
|
|
8
8
|
import { INSTRUMENTED } from "../../telemetry/tracing.js"
|
|
@@ -136,9 +136,10 @@ export async function contentFilterMiddleware(model) {
|
|
|
136
136
|
}
|
|
137
137
|
} else {
|
|
138
138
|
// Fail closed: unrecognized errors (timeout, 5xx, network) block the message
|
|
139
|
-
LOG.
|
|
139
|
+
LOG.error("Content filter unavailable — blocking input", {
|
|
140
140
|
error: err.message,
|
|
141
141
|
status,
|
|
142
|
+
data,
|
|
142
143
|
})
|
|
143
144
|
if (hasHumanMessage) {
|
|
144
145
|
return {
|
|
@@ -7,7 +7,17 @@ export default async function buildMiddleware(srv, options = {}) {
|
|
|
7
7
|
const { statusUpdateMiddleware } = await import("./status-update.js")
|
|
8
8
|
const { humanInTheLoopMiddleware } = await import("./hitl.js")
|
|
9
9
|
const { toolSelectionMiddleware } = await import("./tool-selection.js")
|
|
10
|
+
const { toolWrapMiddleware } = await import("./tool-wrap.js")
|
|
11
|
+
|
|
12
|
+
const hasDynamicMcp = tools?.some((t) => t._mcpDynamic) ?? false
|
|
13
|
+
let dynamicMcpMiddlewares = []
|
|
14
|
+
if (hasDynamicMcp) {
|
|
15
|
+
const { remoteMcpMiddleware } = await import("./remote-mcp.js")
|
|
16
|
+
dynamicMcpMiddlewares = [remoteMcpMiddleware()]
|
|
17
|
+
}
|
|
18
|
+
|
|
10
19
|
return [
|
|
20
|
+
...dynamicMcpMiddlewares,
|
|
11
21
|
...(await quotaEnforcerMiddleware()),
|
|
12
22
|
await contentFilterMiddleware(model),
|
|
13
23
|
await agentActionsMiddleware(),
|
|
@@ -15,5 +25,6 @@ export default async function buildMiddleware(srv, options = {}) {
|
|
|
15
25
|
await statusUpdateMiddleware(),
|
|
16
26
|
...(await humanInTheLoopMiddleware(srv, tools)),
|
|
17
27
|
toolSelectionMiddleware(),
|
|
28
|
+
toolWrapMiddleware(),
|
|
18
29
|
].filter(Boolean)
|
|
19
30
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
import { createMiddleware } from "langchain"
|
|
3
|
+
import { ToolMessage } from "@langchain/core/messages"
|
|
4
|
+
import { MultiServerMCPClient } from "@langchain/mcp-adapters"
|
|
5
|
+
import { toolName } from "../../utils/utils.js"
|
|
6
|
+
|
|
7
|
+
const LOG = cds.log("agents:mcp")
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Middleware that resolves remote MCP tools per-request using the current user's auth headers.
|
|
11
|
+
* Tools are cached on cds.context.__mcpDynamicTools keyed by mcpUrl for the lifetime of the
|
|
12
|
+
* request so multi-turn ReAct loops don't issue a new tools/list call on every model invocation.
|
|
13
|
+
*/
|
|
14
|
+
export function remoteMcpMiddleware() {
|
|
15
|
+
return createMiddleware({
|
|
16
|
+
name: "RemoteMcpMiddleware",
|
|
17
|
+
|
|
18
|
+
wrapModelCall: async (request, handler) => {
|
|
19
|
+
const cache = (cds.context.__mcpDynamicTools ??= {})
|
|
20
|
+
const placeholders = (request.tools ?? []).filter((t) => t._mcpDynamic)
|
|
21
|
+
await Promise.all(
|
|
22
|
+
placeholders.map(async ({ mcpUrl, serviceName, resolveHeaders }) => {
|
|
23
|
+
if (cache[mcpUrl]) return
|
|
24
|
+
const headers = await resolveHeaders()
|
|
25
|
+
const client = new MultiServerMCPClient({
|
|
26
|
+
mcpServers: { default: { transport: "http", url: mcpUrl, headers } },
|
|
27
|
+
})
|
|
28
|
+
const raw = await client.getTools()
|
|
29
|
+
for (const t of raw) t.name = toolName(`${serviceName}_${t.name}`)
|
|
30
|
+
cache[mcpUrl] = raw
|
|
31
|
+
LOG.debug(
|
|
32
|
+
`Got ${raw.length} MCP tools from ${mcpUrl}: ${raw.map((t) => t.name).join(", ")}`,
|
|
33
|
+
)
|
|
34
|
+
}),
|
|
35
|
+
)
|
|
36
|
+
const resolved = placeholders.flatMap(({ mcpUrl }) => cache[mcpUrl] ?? [])
|
|
37
|
+
const staticTools = (request.tools ?? []).filter((t) => !t._mcpDynamic)
|
|
38
|
+
return handler({ ...request, tools: [...staticTools, ...resolved] })
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
wrapToolCall: async (request, handler) => {
|
|
42
|
+
const allCached = Object.values(cds.context.__mcpDynamicTools ?? {}).flat()
|
|
43
|
+
const tool = allCached.find((t) => t.name === request.toolCall.name)
|
|
44
|
+
if (!tool) return handler(request)
|
|
45
|
+
try {
|
|
46
|
+
const output = await tool.invoke(request.toolCall.args)
|
|
47
|
+
return new ToolMessage({
|
|
48
|
+
name: request.toolCall.name,
|
|
49
|
+
content: typeof output === "string" ? output : JSON.stringify(output),
|
|
50
|
+
tool_call_id: request.toolCall.id,
|
|
51
|
+
})
|
|
52
|
+
} catch (err) {
|
|
53
|
+
LOG.warn(`MCP tool "${request.toolCall.name}" error: ${err.message}`)
|
|
54
|
+
return new ToolMessage({
|
|
55
|
+
name: request.toolCall.name,
|
|
56
|
+
content: `Error: ${err.message}`,
|
|
57
|
+
tool_call_id: request.toolCall.id,
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createMiddleware } from "langchain"
|
|
2
|
+
import { ToolMessage } from "@langchain/core/messages"
|
|
3
|
+
import { isGraphInterrupt } from "@langchain/langgraph"
|
|
4
|
+
import cds from "@sap/cds"
|
|
5
|
+
|
|
6
|
+
const LOG = cds.log("agents")
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Converts tool errors into error ToolMessages so the LLM can retry.
|
|
10
|
+
* Handles two paths: thrown errors (err.details appended when present)
|
|
11
|
+
* and tools returning artifact.isError=true (@cap-js/mcp action pattern).
|
|
12
|
+
*/
|
|
13
|
+
export function toolWrapMiddleware() {
|
|
14
|
+
return createMiddleware({
|
|
15
|
+
name: "ToolWrapMiddleware",
|
|
16
|
+
wrapToolCall: async (request, handler) => {
|
|
17
|
+
const { name, id, args } = request.toolCall
|
|
18
|
+
try {
|
|
19
|
+
LOG.debug("[tool]", name, args)
|
|
20
|
+
const result = await handler(request)
|
|
21
|
+
if (ToolMessage.isInstance(result) && result.artifact?.isError === true) {
|
|
22
|
+
result.status = "error"
|
|
23
|
+
}
|
|
24
|
+
if (
|
|
25
|
+
ToolMessage.isInstance(result) &&
|
|
26
|
+
result.status !== "error" &&
|
|
27
|
+
typeof result.content === "string" &&
|
|
28
|
+
result.content.startsWith("Error:")
|
|
29
|
+
) {
|
|
30
|
+
result.status = "error"
|
|
31
|
+
}
|
|
32
|
+
if (result?.status === "error") LOG.debug("[tool] error", name, result.content)
|
|
33
|
+
else LOG.debug("[tool] completed", name)
|
|
34
|
+
return result
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (isGraphInterrupt(err)) throw err
|
|
37
|
+
LOG.debug("[tool] error", name, err)
|
|
38
|
+
let content = `Error: ${err.message}`
|
|
39
|
+
if (Array.isArray(err.details) && err.details.length > 0) {
|
|
40
|
+
const lines = err.details.map((d) => `- ${d.message}`).join("\n")
|
|
41
|
+
content += `\n${lines}`
|
|
42
|
+
}
|
|
43
|
+
return new ToolMessage({
|
|
44
|
+
content,
|
|
45
|
+
tool_call_id: id ?? "",
|
|
46
|
+
name,
|
|
47
|
+
status: "error",
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
}
|
package/lib/compile.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import cds from "@sap/cds"
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
getDescription,
|
|
4
|
+
getFilteredEntities,
|
|
5
|
+
getFilteredActions,
|
|
6
|
+
slugified,
|
|
7
|
+
} from "./utils/utils.js"
|
|
3
8
|
import { buildAgentCard } from "./protocol/agent-card.js"
|
|
4
|
-
import { slugified } from "./utils/markdown.js"
|
|
5
9
|
|
|
6
10
|
const A2A_BASE_PATH = "/a2a"
|
|
7
11
|
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
import { recordEvaluation } from "./eval-run.js"
|
|
3
|
+
|
|
4
|
+
const LOG = cds.log("agents-judge")
|
|
5
|
+
const CRITERIA_SEPARATOR = "\n\n"
|
|
6
|
+
|
|
7
|
+
// ─── Base Judge ───────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export class Judge {
|
|
10
|
+
constructor(opts) {
|
|
11
|
+
_assertSingleConstructorArg(arguments)
|
|
12
|
+
const { criteria, assessmentName, continuous, invertedScala, type } = _judgeOptions(
|
|
13
|
+
opts,
|
|
14
|
+
"ANSWER_RELEVANCE_PROMPT",
|
|
15
|
+
arguments.length,
|
|
16
|
+
)
|
|
17
|
+
if (!criteria || typeof criteria !== "string") {
|
|
18
|
+
throw new TypeError("Judge: 'criteria' is required and must be a string")
|
|
19
|
+
}
|
|
20
|
+
if (type !== undefined && type !== "trajectory") {
|
|
21
|
+
throw new TypeError("Judge: 'type' must be 'trajectory' when provided")
|
|
22
|
+
}
|
|
23
|
+
this._criteria = criteria
|
|
24
|
+
this._type = type
|
|
25
|
+
const isInvertedScala = (criteria) => {
|
|
26
|
+
if (_promptKeyFromCriteria(criteria) === "TOXICITY_PROMPT") {
|
|
27
|
+
return true
|
|
28
|
+
}
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
this._invertedScala = invertedScala ?? isInvertedScala(criteria)
|
|
32
|
+
|
|
33
|
+
this._assessmentName =
|
|
34
|
+
assessmentName ?? _assessmentNameFromCriteria(this._criteria, this._defaultAssessmentName())
|
|
35
|
+
this._continuous = continuous ?? true
|
|
36
|
+
this._judgeImpl = null
|
|
37
|
+
this._sessionJudgeImpl = null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_defaultAssessmentName() {
|
|
41
|
+
return this._type === "trajectory" ? "trajectory" : "relevance"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async _ensureJudge(type) {
|
|
45
|
+
const judgeType = arguments.length === 0 ? this._type : type
|
|
46
|
+
if (judgeType === this._type && this._judgeImpl) return this._judgeImpl
|
|
47
|
+
if (judgeType !== this._type && this._sessionJudgeImpl) return this._sessionJudgeImpl
|
|
48
|
+
const judgeImpl = await _loadJudgeImpl(
|
|
49
|
+
this._criteria,
|
|
50
|
+
this._assessmentName,
|
|
51
|
+
this._continuous,
|
|
52
|
+
judgeType,
|
|
53
|
+
)
|
|
54
|
+
if (judgeType === this._type) this._judgeImpl = judgeImpl
|
|
55
|
+
else this._sessionJudgeImpl = judgeImpl
|
|
56
|
+
return judgeImpl
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Sibling with appended prompt/criteria. */
|
|
60
|
+
criteria(criteria) {
|
|
61
|
+
if (!criteria || typeof criteria !== "string") {
|
|
62
|
+
throw new TypeError("Judge.criteria: argument 'criteria' is required and must be a string")
|
|
63
|
+
}
|
|
64
|
+
const sibling = new this.constructor({
|
|
65
|
+
criteria: `${this._criteria}${CRITERIA_SEPARATOR}${criteria}`,
|
|
66
|
+
assessmentName: this._assessmentName,
|
|
67
|
+
continuous: this._continuous,
|
|
68
|
+
type: this._type,
|
|
69
|
+
})
|
|
70
|
+
return sibling
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @returns {Promise<{score, comment, pass}>} */
|
|
74
|
+
async evaluate(result) {
|
|
75
|
+
if (!result) throw new Error("evaluate: result is required")
|
|
76
|
+
if (Array.isArray(result)) return this._evaluateSession(result)
|
|
77
|
+
const judgeImpl = await this._ensureJudge()
|
|
78
|
+
const judgement = await judgeImpl(this._buildInput(result))
|
|
79
|
+
const { score, pass, comment } = this._judgementResult(judgement)
|
|
80
|
+
LOG.debug(`[${this._assessmentName}] score=${score} pass=${pass} — ${comment}`)
|
|
81
|
+
await recordEvaluation(result, {
|
|
82
|
+
pass,
|
|
83
|
+
score,
|
|
84
|
+
comment,
|
|
85
|
+
assessmentName: this._assessmentName,
|
|
86
|
+
})
|
|
87
|
+
return { score, comment, pass }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async _evaluateSession(results) {
|
|
91
|
+
if (results.length === 0) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
"Judge.evaluate: session assessment requires a non-empty array of chat() results",
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
const judgeImpl = await this._ensureJudge(undefined)
|
|
97
|
+
const judgement = await judgeImpl({ outputs: results.flatMap((r) => r.messages ?? []) })
|
|
98
|
+
const { score, pass, comment } = this._judgementResult(judgement)
|
|
99
|
+
LOG.info(`[${this._assessmentName}] pass=${pass} score=${score} — ${comment}`)
|
|
100
|
+
const first = results[0]
|
|
101
|
+
await recordEvaluation(first, {
|
|
102
|
+
score: pass,
|
|
103
|
+
comment,
|
|
104
|
+
sessionId: first.contextId,
|
|
105
|
+
assessmentName: this._assessmentName,
|
|
106
|
+
conversationLevel: true,
|
|
107
|
+
})
|
|
108
|
+
return { score, comment, pass }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_judgementResult(judgement) {
|
|
112
|
+
const raw = judgement?.score
|
|
113
|
+
const score = typeof raw === "boolean" ? raw : (raw ?? 0)
|
|
114
|
+
const pass = this._invertedScala
|
|
115
|
+
? typeof score === "boolean"
|
|
116
|
+
? !score
|
|
117
|
+
: score <= 0.5
|
|
118
|
+
: typeof score === "boolean"
|
|
119
|
+
? score
|
|
120
|
+
: score >= 0.5
|
|
121
|
+
const comment = judgement?.comment ?? ""
|
|
122
|
+
return { score, comment, pass }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_buildInput(result) {
|
|
126
|
+
if (this._type === "trajectory") {
|
|
127
|
+
return {
|
|
128
|
+
inputs: result.query ?? "",
|
|
129
|
+
outputs: result.messages ?? [],
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
inputs: result.query ?? "",
|
|
134
|
+
outputs: result.text,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── matchToolCall ───────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/** Deterministic tool call assertion. Contributes to success_rate rollup. */
|
|
142
|
+
export function matchToolCall(result, toolName, matcher) {
|
|
143
|
+
const match = (result?.toolCalls ?? []).find((c) => {
|
|
144
|
+
if (c.tool !== toolName) return false
|
|
145
|
+
if (matcher === undefined) return true
|
|
146
|
+
if (typeof matcher === "function") return !!matcher(c.args)
|
|
147
|
+
return _partialMatch(c.args, matcher)
|
|
148
|
+
})
|
|
149
|
+
const pass = !!match
|
|
150
|
+
recordEvaluation(result, { pass })
|
|
151
|
+
return pass
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function _partialMatch(actual, expected) {
|
|
155
|
+
if (!expected || typeof expected !== "object") return actual === expected
|
|
156
|
+
for (const [k, v] of Object.entries(expected)) {
|
|
157
|
+
if (actual?.[k] !== v) return false
|
|
158
|
+
}
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function _judgeOptions(opts, defaultCriteria = "ANSWER_RELEVANCE_PROMPT", argCount = 1) {
|
|
163
|
+
if (argCount === 0 || opts === undefined) return { criteria: defaultCriteria }
|
|
164
|
+
if (typeof opts === "string") return { criteria: opts }
|
|
165
|
+
if (opts && typeof opts === "object")
|
|
166
|
+
return { ...opts, criteria: opts.criteria ?? defaultCriteria }
|
|
167
|
+
throw new TypeError("Judge: constructor argument must be a string or an object")
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function _assertSingleConstructorArg(args) {
|
|
171
|
+
if (args.length > 1) throw new TypeError("Judge: constructor accepts a single argument")
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function _assessmentNameFromCriteria(criteria, fallback) {
|
|
175
|
+
const key = _promptKeyFromCriteria(criteria)
|
|
176
|
+
return key ? key.replace(/_PROMPT$/, "").toLowerCase() : fallback
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function _promptKeyFromCriteria(criteria) {
|
|
180
|
+
if (!criteria || typeof criteria !== "string") return null
|
|
181
|
+
const key = criteria.split(CRITERIA_SEPARATOR, 1)[0].trim()
|
|
182
|
+
return key.endsWith("_PROMPT") ? key : null
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const INPUT_OUTPUTS_PLACEHOLDER = `<input>
|
|
186
|
+
{inputs}
|
|
187
|
+
</input>
|
|
188
|
+
|
|
189
|
+
<output>
|
|
190
|
+
{outputs}
|
|
191
|
+
</output>`
|
|
192
|
+
|
|
193
|
+
function _resolvePrompt(openevals, criteria) {
|
|
194
|
+
if (Object.prototype.hasOwnProperty.call(openevals, criteria)) return openevals[criteria]
|
|
195
|
+
const key = _promptKeyFromCriteria(criteria)
|
|
196
|
+
if (!key || !Object.prototype.hasOwnProperty.call(openevals, key))
|
|
197
|
+
return `${criteria}${CRITERIA_SEPARATOR}${INPUT_OUTPUTS_PLACEHOLDER}`
|
|
198
|
+
const rest = criteria.slice(criteria.indexOf(key) + key.length).trimStart()
|
|
199
|
+
return rest ? `${openevals[key]}${CRITERIA_SEPARATOR}${rest}` : openevals[key]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
async function _loadOpenevals() {
|
|
205
|
+
const savedExpect = globalThis.expect
|
|
206
|
+
let openevals
|
|
207
|
+
try {
|
|
208
|
+
openevals = await import("openevals")
|
|
209
|
+
} catch (err) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
"openevals is required for Judge.evaluate(). Install it as a devDependency:\n npm install --save-dev openevals\n" +
|
|
212
|
+
`Original error: ${err.message}`,
|
|
213
|
+
{ cause: err },
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
if (savedExpect && globalThis.expect !== savedExpect) globalThis.expect = savedExpect
|
|
217
|
+
return openevals
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function _buildLlm() {
|
|
221
|
+
const name = "llm"
|
|
222
|
+
const { kind, impl, ...options } = cds.env.requires[name] ?? {}
|
|
223
|
+
const providerImpl = impl ?? cds.env.requires.kinds?.[kind]?.impl
|
|
224
|
+
if (!providerImpl) throw new Error("No service implementation found for " + name)
|
|
225
|
+
const { default: LLMProvider } = await import(providerImpl)
|
|
226
|
+
const llm = new LLMProvider(name, options)
|
|
227
|
+
llm[Symbol.for("@cap-js/agents:instrumented")] = true
|
|
228
|
+
return llm
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function _loadJudgeImpl(criteria, assessmentName, continuous, type) {
|
|
232
|
+
const openevals = await _loadOpenevals()
|
|
233
|
+
const llm = await _buildLlm()
|
|
234
|
+
const prompt = _resolvePrompt(openevals, criteria)
|
|
235
|
+
if (type === "trajectory") {
|
|
236
|
+
return openevals.createTrajectoryLLMAsJudge({ judge: llm, prompt, assessmentName })
|
|
237
|
+
}
|
|
238
|
+
return openevals.createLLMAsJudge({ judge: llm, prompt, continuous, assessmentName })
|
|
239
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { evalRun } from "../eval/eval-run.js"
|
|
2
|
+
|
|
3
|
+
const PATCHED = Symbol.for("@cap-js/agents:eval-describe-patched")
|
|
4
|
+
|
|
5
|
+
export function installEvalDescribe({
|
|
6
|
+
target = globalThis,
|
|
7
|
+
evalRun: registerEvalRun = evalRun,
|
|
8
|
+
} = {}) {
|
|
9
|
+
const original = target.describe
|
|
10
|
+
if (typeof original !== "function") return false
|
|
11
|
+
if (original[PATCHED]) return false
|
|
12
|
+
|
|
13
|
+
let depth = 0
|
|
14
|
+
|
|
15
|
+
const wrapSuite = (suite) => {
|
|
16
|
+
if (typeof suite !== "function") return suite
|
|
17
|
+
|
|
18
|
+
const wrapped = function evalDescribe(name, factory, ...args) {
|
|
19
|
+
if (typeof factory !== "function") return suite.call(this, name, factory, ...args)
|
|
20
|
+
|
|
21
|
+
return suite.call(
|
|
22
|
+
this,
|
|
23
|
+
name,
|
|
24
|
+
function evalSuite(...suiteArgs) {
|
|
25
|
+
depth += 1
|
|
26
|
+
const topLevel = depth === 1
|
|
27
|
+
try {
|
|
28
|
+
if (topLevel && typeof name === "string") registerEvalRun({ name })
|
|
29
|
+
return factory.apply(this, suiteArgs)
|
|
30
|
+
} finally {
|
|
31
|
+
depth -= 1
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
...args,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
copySuiteProperties(suite, wrapped, wrapSuite)
|
|
39
|
+
return wrapped
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
target.describe = wrapSuite(original)
|
|
43
|
+
target.describe[PATCHED] = true
|
|
44
|
+
target.describe._original = original
|
|
45
|
+
return true
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function copySuiteProperties(source, target, wrapSuite) {
|
|
49
|
+
for (const key of Reflect.ownKeys(source)) {
|
|
50
|
+
if (["length", "name", "prototype"].includes(key)) continue
|
|
51
|
+
const descriptor = Object.getOwnPropertyDescriptor(source, key)
|
|
52
|
+
if (!descriptor) continue
|
|
53
|
+
if (typeof descriptor.value === "function") {
|
|
54
|
+
if (["each", "skipIf", "runIf"].includes(key)) {
|
|
55
|
+
descriptor.value = function describeFactory(...args) {
|
|
56
|
+
return wrapSuite(source[key].apply(this, args))
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
descriptor.value = wrapSuite(descriptor.value)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
Object.defineProperty(target, key, descriptor)
|
|
64
|
+
} catch {
|
|
65
|
+
// Vitest may expose non-configurable helper properties. The base describe
|
|
66
|
+
// still works, so ignore properties that cannot be mirrored.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/* global beforeAll, afterEach, afterAll */
|
|
2
|
+
import cds from "@sap/cds"
|
|
3
|
+
import {
|
|
4
|
+
postMlflowAssessment,
|
|
5
|
+
createEvalRun,
|
|
6
|
+
closeEvalRun,
|
|
7
|
+
logMlflowMetrics,
|
|
8
|
+
} from "../telemetry/mlflow/evaluation.js"
|
|
9
|
+
import { flushMlflowTraces } from "../telemetry/mlflow/tracing.js"
|
|
10
|
+
|
|
11
|
+
export function getActiveRunState() {
|
|
12
|
+
return cds._activeEvalRun ?? null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function evalRun(opts = {}) {
|
|
16
|
+
if (typeof beforeAll !== "function" || typeof afterAll !== "function") return
|
|
17
|
+
if (!cds.env.agents?.mlflow) return
|
|
18
|
+
|
|
19
|
+
let state = null
|
|
20
|
+
|
|
21
|
+
function _makeState(runId, mlflowRunId) {
|
|
22
|
+
return {
|
|
23
|
+
runId,
|
|
24
|
+
mlflowRunId,
|
|
25
|
+
validationsByTask: new Map(),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
const runId = cds.utils.uuid()
|
|
31
|
+
const mlflowRunId = await createEvalRun(opts).catch(() => null)
|
|
32
|
+
state = _makeState(runId, mlflowRunId)
|
|
33
|
+
cds._activeEvalRun = state
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
if (typeof afterEach === "function") {
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
if (state) await _flushValidations(state)
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
afterAll(async () => {
|
|
43
|
+
if (state) await _flushValidations(state)
|
|
44
|
+
await flushMlflowTraces()
|
|
45
|
+
await closeEvalRun(state?.mlflowRunId).catch(() => {})
|
|
46
|
+
if (cds._activeEvalRun === state) cds._activeEvalRun = null
|
|
47
|
+
state = null
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function _flushValidations(state) {
|
|
52
|
+
if (!state?.validationsByTask.size) return
|
|
53
|
+
|
|
54
|
+
const tasks = []
|
|
55
|
+
for (const [, entry] of state.validationsByTask) {
|
|
56
|
+
const { passes, traceId } = entry
|
|
57
|
+
if (!passes.length) continue
|
|
58
|
+
|
|
59
|
+
const success_rate = passes.every(Boolean) ? 1 : 0
|
|
60
|
+
const output_correctness = passes.filter(Boolean).length / passes.length
|
|
61
|
+
const codeOpts = { sourceType: "CODE" }
|
|
62
|
+
|
|
63
|
+
if (state.mlflowRunId) {
|
|
64
|
+
tasks.push(
|
|
65
|
+
logMlflowMetrics(state.mlflowRunId, { success_rate, output_correctness }).catch(() => {}),
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (traceId) {
|
|
70
|
+
tasks.push(
|
|
71
|
+
flushMlflowTraces().then(() =>
|
|
72
|
+
Promise.all([
|
|
73
|
+
postMlflowAssessment(traceId, success_rate, "", "success_rate", null, codeOpts).catch(
|
|
74
|
+
() => {},
|
|
75
|
+
),
|
|
76
|
+
postMlflowAssessment(
|
|
77
|
+
traceId,
|
|
78
|
+
output_correctness,
|
|
79
|
+
"",
|
|
80
|
+
"output_correctness",
|
|
81
|
+
null,
|
|
82
|
+
codeOpts,
|
|
83
|
+
).catch(() => {}),
|
|
84
|
+
]),
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await Promise.all(tasks)
|
|
91
|
+
state.validationsByTask.clear()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function recordEvaluation(result, assessment = {}) {
|
|
95
|
+
if (!cds.env.agents?.mlflow) return
|
|
96
|
+
const { pass, score, comment, ...config } = assessment
|
|
97
|
+
// Conversation level evaluations shall not be included in the per task roll-up
|
|
98
|
+
if (pass !== undefined && !config.conversationLevel) _addValidation(result, pass)
|
|
99
|
+
if (score === undefined) return
|
|
100
|
+
return _postAssessmentScore(result, score, comment, config)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function _addValidation(result, pass) {
|
|
104
|
+
const state = result?._evalState ?? cds._activeEvalRun
|
|
105
|
+
if (!state || !result?.taskId) return
|
|
106
|
+
const key = result.taskId
|
|
107
|
+
if (!state.validationsByTask.has(key)) {
|
|
108
|
+
state.validationsByTask.set(key, { passes: [], traceId: result.traceId })
|
|
109
|
+
}
|
|
110
|
+
state.validationsByTask.get(key).passes.push(pass)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function _postAssessmentScore(result, score, comment, config) {
|
|
114
|
+
const traceId = config.traceId ?? result?.traceId
|
|
115
|
+
if (!traceId) return
|
|
116
|
+
|
|
117
|
+
const opts = { sourceType: config.sourceType }
|
|
118
|
+
await flushMlflowTraces()
|
|
119
|
+
|
|
120
|
+
// conversationLevel: session assessment — post with session metadata only
|
|
121
|
+
if (config.conversationLevel) {
|
|
122
|
+
await postMlflowAssessment(
|
|
123
|
+
traceId,
|
|
124
|
+
score,
|
|
125
|
+
comment ?? "",
|
|
126
|
+
config.assessmentName,
|
|
127
|
+
config.model ?? null,
|
|
128
|
+
{ ...opts, metadata: { "mlflow.trace.session": config.sessionId ?? "" } },
|
|
129
|
+
)
|
|
130
|
+
} else {
|
|
131
|
+
// Single-turn: post to this trace only, no session metadata
|
|
132
|
+
await postMlflowAssessment(
|
|
133
|
+
traceId,
|
|
134
|
+
score,
|
|
135
|
+
comment ?? "",
|
|
136
|
+
config.assessmentName,
|
|
137
|
+
config.model ?? null,
|
|
138
|
+
opts,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function logMlflowMetricsForResult(result, state = null) {
|
|
144
|
+
state = state ?? cds._activeEvalRun
|
|
145
|
+
if (!state?.mlflowRunId) return
|
|
146
|
+
await logMlflowMetrics(state.mlflowRunId, result.metrics).catch(() => {})
|
|
147
|
+
}
|