@cap-js/agents 0.9.2 → 0.9.4

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.
Files changed (49) hide show
  1. package/README.md +2 -0
  2. package/cds-plugin.js +2 -2
  3. package/lib/agents/middleware/content-filter.js +3 -2
  4. package/lib/agents/middleware/hitl-decision-note-injector.js +18 -0
  5. package/lib/agents/middleware/hitl.js +2 -2
  6. package/lib/agents/middleware/index.js +11 -0
  7. package/lib/agents/middleware/quota-enforcer.js +9 -0
  8. package/lib/agents/middleware/remote-mcp.js +62 -0
  9. package/lib/agents/middleware/tool-wrap.js +52 -0
  10. package/lib/compile.js +6 -2
  11. package/lib/eval/Judge.js +239 -0
  12. package/lib/eval/eval-describe.js +69 -0
  13. package/lib/eval/eval-run.js +147 -0
  14. package/lib/eval/index.js +6 -0
  15. package/lib/eval/metrics.js +53 -0
  16. package/lib/eval/span-collector.js +50 -0
  17. package/lib/index.js +2 -1
  18. package/lib/models/aicore.js +23 -6
  19. package/lib/models/anthropic.js +27 -12
  20. package/lib/preview/chat.html +373 -66
  21. package/lib/protocol/agent-card.js +6 -2
  22. package/lib/sidecar.js +1 -1
  23. package/lib/telemetry/chat-tracing.js +39 -8
  24. package/lib/telemetry/mlflow/credentials.js +79 -0
  25. package/lib/telemetry/mlflow/evaluation.js +38 -0
  26. package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +75 -0
  27. package/lib/telemetry/mlflow/exporter/MlflowExporter.js +115 -0
  28. package/lib/telemetry/mlflow/exporter/index.js +23 -0
  29. package/lib/telemetry/mlflow/index.js +16 -0
  30. package/lib/telemetry/mlflow/prompts.js +123 -0
  31. package/lib/telemetry/mlflow/tracing.js +264 -0
  32. package/lib/telemetry/tool-tracing.js +27 -54
  33. package/lib/telemetry/tracing.js +3 -1
  34. package/lib/utils/markdown.js +1 -11
  35. package/lib/utils/message-handling.js +14 -0
  36. package/lib/utils/resilience.js +133 -0
  37. package/lib/utils/utils.js +17 -0
  38. package/package.json +26 -14
  39. package/srv/handlers/chat.js +278 -0
  40. package/srv/handlers/graph-executor/hitl.js +263 -0
  41. package/srv/handlers/graph-executor.js +100 -286
  42. package/srv/handlers/index.js +5 -1
  43. package/srv/handlers/mcp-tools.js +7 -45
  44. package/srv/handlers/sub-agent-tools.js +30 -12
  45. package/srv/handlers/tools.js +39 -5
  46. package/index.js +0 -0
  47. package/lib/agents/middleware/hitl-edit-note-injector.js +0 -18
  48. package/lib/telemetry/mlflow.js +0 -290
  49. /package/{index.cds → srv/entities.cds} +0 -0
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ [![REUSE status](https://api.reuse.software/badge/github.com/cap-js/agents)](https://api.reuse.software/info/github.com/cap-js/agents)
2
+
1
3
  # SAP Cloud Application Programming Model, agent development plugin for Node.js
2
4
 
3
5
  ## About this project
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/markdown.js"
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 "@sap-cloud-sdk/resilience"
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.warn("Content filter unavailable — blocking input", {
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 {
@@ -0,0 +1,18 @@
1
+ import { createMiddleware } from "langchain"
2
+ import { HumanMessage } from "@langchain/core/messages"
3
+ import { z } from "zod"
4
+
5
+ // Injects user HITL decisions before the model continues after a rejection or edit.
6
+ export function hitlDecisionNoteInjectorMiddleware() {
7
+ return createMiddleware({
8
+ name: "hitlDecisionNoteInjectorMiddleware",
9
+ stateSchema: z.object({ _hitlDecisionNote: z.string().optional() }),
10
+ beforeModel: async (state) => {
11
+ if (!state._hitlDecisionNote) return
12
+ return {
13
+ messages: [new HumanMessage(state._hitlDecisionNote)],
14
+ _hitlDecisionNote: undefined,
15
+ }
16
+ },
17
+ })
18
+ }
@@ -1,5 +1,5 @@
1
1
  import { humanInTheLoopMiddleware as hitl } from "langchain"
2
- import { hitlEditNoteInjectorMiddleware } from "./hitl-edit-note-injector.js"
2
+ import { hitlDecisionNoteInjectorMiddleware } from "./hitl-decision-note-injector.js"
3
3
 
4
4
  function buildHitlInterruptMap(srv, tools = []) {
5
5
  return tools.reduce((interruptOn, tool) => {
@@ -16,5 +16,5 @@ function buildHitlInterruptMap(srv, tools = []) {
16
16
  export async function humanInTheLoopMiddleware(srv, tools) {
17
17
  const interruptOn = buildHitlInterruptMap(srv, tools)
18
18
  if (!Object.keys(interruptOn).length) return []
19
- return [hitl({ interruptOn }), hitlEditNoteInjectorMiddleware()]
19
+ return [hitl({ interruptOn }), hitlDecisionNoteInjectorMiddleware()]
20
20
  }
@@ -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
  }
@@ -89,6 +89,15 @@ export async function quotaEnforcerMiddleware() {
89
89
  }
90
90
  },
91
91
  },
92
+ afterAgent: {
93
+ hook: () => {
94
+ return {
95
+ runModelCallCount: 0,
96
+ runTokenCount: 0,
97
+ runToolCallCount: 0,
98
+ }
99
+ },
100
+ },
92
101
  }),
93
102
  ]
94
103
  }
@@ -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 { getDescription, getFilteredEntities, getFilteredActions } from "./utils/utils.js"
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
+ }