@tangle-network/agent-bench 0.1.0
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 +15 -0
- package/package.json +35 -0
- package/src/adapters.ts +60 -0
- package/src/aec-gate.mts +217 -0
- package/src/atom-humaneval.mts +197 -0
- package/src/atom-mcp-e2e.mts +223 -0
- package/src/benchmarks/_harness.ts +206 -0
- package/src/benchmarks/aec-bench.test.mts +53 -0
- package/src/benchmarks/aec-bench.ts +319 -0
- package/src/benchmarks/appworld.test.mts +45 -0
- package/src/benchmarks/appworld.ts +426 -0
- package/src/benchmarks/cad-design.ts +429 -0
- package/src/benchmarks/cadbench.ts +135 -0
- package/src/benchmarks/cadgenbench.ts +121 -0
- package/src/benchmarks/commit0.test.mts +71 -0
- package/src/benchmarks/commit0.ts +266 -0
- package/src/benchmarks/enterpriseops-gym.test.mts +77 -0
- package/src/benchmarks/enterpriseops-gym.ts +343 -0
- package/src/benchmarks/finsearchcomp.ts +371 -0
- package/src/benchmarks/frames.ts +520 -0
- package/src/benchmarks/hotpotqa.ts +320 -0
- package/src/benchmarks/humaneval.ts +251 -0
- package/src/benchmarks/mind2web.ts +311 -0
- package/src/benchmarks/programbench.test.mts +60 -0
- package/src/benchmarks/programbench.ts +210 -0
- package/src/benchmarks/simpleqa.ts +390 -0
- package/src/benchmarks/swe-bench.ts +152 -0
- package/src/benchmarks/terminal-bench.ts +182 -0
- package/src/benchmarks/trata-hedge.ts +496 -0
- package/src/benchmarks/types.ts +58 -0
- package/src/browser/adapters/bad-design-audit.ts +153 -0
- package/src/browser/adapters/bad-design-audit.verify.ts +85 -0
- package/src/browser/adapters/bad.ts +165 -0
- package/src/browser/agent-adapter.ts +145 -0
- package/src/browser/process-adapter.ts +146 -0
- package/src/browser/run-to-spans.ts +105 -0
- package/src/browser/run-to-spans.verify.ts +89 -0
- package/src/browser/ui-reviewer.ts +200 -0
- package/src/browser/ui-reviewer.verify.ts +94 -0
- package/src/browser/verify.ts +96 -0
- package/src/clbench-codebase-gate.mts +314 -0
- package/src/clbench-context-gate.mts +305 -0
- package/src/cloud-loop.mts +138 -0
- package/src/coding-skills/minimal-diff.md +9 -0
- package/src/coding-skills/read-before-edit.md +9 -0
- package/src/coding-skills/reproduce-first.md +10 -0
- package/src/coding-skills/run-tests-after-edit.md +9 -0
- package/src/coding-skills/trace-the-failure.md +9 -0
- package/src/commit0-env-run.mts +59 -0
- package/src/commit0-env.ts +173 -0
- package/src/commit0-gate.mts +529 -0
- package/src/commit0-prereqs.sh +48 -0
- package/src/corpus-replay.mts +300 -0
- package/src/corpus-report.mts +504 -0
- package/src/corpus.test.mts +273 -0
- package/src/corpus.ts +325 -0
- package/src/decoder-live.mts +133 -0
- package/src/directives.ts +84 -0
- package/src/diverse-gate.mjs +112 -0
- package/src/egress-probe.mts +26 -0
- package/src/eops-skills/address-every-subtask.md +5 -0
- package/src/eops-skills/exact-tools-and-args.md +5 -0
- package/src/eops-skills/full-sequence-to-goal.md +5 -0
- package/src/eops-skills/ground-every-value.md +5 -0
- package/src/eops-skills/honor-the-policies.md +5 -0
- package/src/examples/README.md +58 -0
- package/src/examples/math-demo.mts +110 -0
- package/src/examples/strategy-demo.mts +119 -0
- package/src/fleet.mts +121 -0
- package/src/gate-cli.mts +101 -0
- package/src/gate.test.mts +129 -0
- package/src/gate.ts +460 -0
- package/src/generate-eval/certify.ts +178 -0
- package/src/generate-eval/schema.ts +78 -0
- package/src/humaneval-gate.mts +204 -0
- package/src/humaneval-repair-gate.mts +143 -0
- package/src/index.ts +19 -0
- package/src/mcp-mount-probe.mts +126 -0
- package/src/profile-coordinates.ts +134 -0
- package/src/profiles.ts +128 -0
- package/src/refine-loop.test.mts +106 -0
- package/src/refine-loop.ts +106 -0
- package/src/research-gate.mts +132 -0
- package/src/research-shot.ts +134 -0
- package/src/resolve-client.ts +58 -0
- package/src/router-executor.ts +51 -0
- package/src/run-pool.ts +48 -0
- package/src/runtime-hook-recorder.ts +137 -0
- package/src/sandbox-run.ts +125 -0
- package/src/search-bench/bridge.ts +124 -0
- package/src/search-bench/export.mts +0 -0
- package/src/search-bench/parametric-check.mts +63 -0
- package/src/search-bench/profiles.ts +98 -0
- package/src/search-bench/run.mts +287 -0
- package/src/search-bench/tasks-fresh.ts +688 -0
- package/src/search-bench/tasks.ts +129 -0
- package/src/search-tool.ts +95 -0
- package/src/selector.test.mts +189 -0
- package/src/selector.ts +366 -0
- package/src/skill-sandbox-smoke.mts +100 -0
- package/src/stats.mts +90 -0
- package/src/terminal-compare.ts +519 -0
- package/src/trajectory-assemble.mjs +130 -0
- package/src/trata-gate.mts +243 -0
- package/src/trata-gepa.mts +434 -0
- package/src/worker-blender.ts +230 -0
- package/src/worker-browser.ts +102 -0
- package/src/worker-build123d.ts +143 -0
- package/src/worker-cad.ts +451 -0
- package/src/worker.ts +136 -0
- package/src/workspace-loop.mts +133 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One research rollout as a reusable primitive: 2-step RAG — (1) provider-pinned web
|
|
3
|
+
* search via the router's proven `/v1/search?provider=<id>` + `web_fetch` of the top-K
|
|
4
|
+
* result pages, (2) answer with that evidence via `routerChatWithUsage` (no tools on the
|
|
5
|
+
* answer call → `content` always present, so a search arm differs from the parametric
|
|
6
|
+
* control ONLY by the evidence). Pure router HTTP (bearer `TANGLE_API_KEY`).
|
|
7
|
+
*
|
|
8
|
+
* Shared by the off-sandbox RAG leaderboard (`research-gate.mts`) and the router-backed
|
|
9
|
+
* loop executor (`router-executor.ts`), so both score the identical retrieve→answer body
|
|
10
|
+
* — the only difference is who drives the rounds (a flat best-of-k pool vs the real
|
|
11
|
+
* `runLoop` kernel with analyst steering).
|
|
12
|
+
*/
|
|
13
|
+
import { routerChatWithUsage } from '@tangle-network/agent-runtime/loops'
|
|
14
|
+
|
|
15
|
+
export interface ShotCfg {
|
|
16
|
+
model: string
|
|
17
|
+
/** search provider id: 'default'/'off'/'none' = no search (parametric control); else a router provider. */
|
|
18
|
+
search: string
|
|
19
|
+
maxResults: number
|
|
20
|
+
/** how many top search URLs to web_fetch full page content for (0 = snippets only). */
|
|
21
|
+
fetchTopK: number
|
|
22
|
+
temperature: number
|
|
23
|
+
routerBaseUrl: string
|
|
24
|
+
routerKey: string
|
|
25
|
+
timeoutMs: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Shot {
|
|
29
|
+
taskId: string
|
|
30
|
+
attempt: number
|
|
31
|
+
answer: string
|
|
32
|
+
ok: boolean
|
|
33
|
+
detail?: string
|
|
34
|
+
wallMs: number
|
|
35
|
+
/** count of search hits retrieved (0 ⇒ no search happened / it failed). */
|
|
36
|
+
searches: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Fetch a URL's extracted page text via the router web_fetch MCP tool. Returns '' on any failure. */
|
|
40
|
+
export async function fetchPage(url: string, cfg: ShotCfg): Promise<string> {
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(`${cfg.routerBaseUrl}/search/mcp?provider=${encodeURIComponent(cfg.search)}`, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.routerKey}` },
|
|
45
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'web_fetch', arguments: { url } } }),
|
|
46
|
+
...(cfg.timeoutMs ? { signal: AbortSignal.timeout(Math.min(cfg.timeoutMs, 60_000)) } : {}),
|
|
47
|
+
})
|
|
48
|
+
if (!res.ok) return ''
|
|
49
|
+
const body = (await res.json()) as { result?: { content?: Array<{ text?: string }> } }
|
|
50
|
+
const text = body.result?.content?.[0]?.text ?? ''
|
|
51
|
+
// The tool returns a JSON string {url,title,content}; pull `content` if parseable, else the raw text.
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(text) as { content?: string }
|
|
54
|
+
return (parsed.content ?? text).slice(0, 2500)
|
|
55
|
+
} catch {
|
|
56
|
+
return text.slice(0, 2500)
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
return ''
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* One research rollout, 2-step RAG against `prompt` (the task question, possibly with a
|
|
65
|
+
* steer appended): (1) provider-pinned web search + web_fetch of the top-K pages, (2)
|
|
66
|
+
* answer with that evidence. The search query is the clean question (first non-empty
|
|
67
|
+
* line) — appended worker-contract / steer boilerplate pollutes the query and returns 0
|
|
68
|
+
* hits — while the ANSWER sees the full `prompt` so a steer round can act on it. The
|
|
69
|
+
* COMMIT prompt stops the model deferring ("may I search?"), which otherwise scores 0.
|
|
70
|
+
* Fault-isolated: a flaky call → a NO-ANSWER `Shot`, never a throw.
|
|
71
|
+
*/
|
|
72
|
+
export async function runResearchShot(prompt: string, taskId: string, attempt: number, cfg: ShotCfg): Promise<Shot> {
|
|
73
|
+
const startedAt = Date.now()
|
|
74
|
+
const useSearch = cfg.search !== 'default' && cfg.search !== 'off' && cfg.search !== 'none'
|
|
75
|
+
let searches = 0
|
|
76
|
+
try {
|
|
77
|
+
// 1) Provider-pinned web search (proven /v1/search). The control arm skips this.
|
|
78
|
+
let context = ''
|
|
79
|
+
if (useSearch) {
|
|
80
|
+
const query = (prompt.split('\n').find((l) => l.trim().length > 0) ?? prompt).slice(0, 300)
|
|
81
|
+
const sres = await fetch(`${cfg.routerBaseUrl}/search?provider=${encodeURIComponent(cfg.search)}`, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.routerKey}` },
|
|
84
|
+
body: JSON.stringify({ query, count: cfg.maxResults }),
|
|
85
|
+
...(cfg.timeoutMs ? { signal: AbortSignal.timeout(cfg.timeoutMs) } : {}),
|
|
86
|
+
})
|
|
87
|
+
if (!sres.ok) {
|
|
88
|
+
// Surface, never silently degrade to parametric — a failed search must be visible.
|
|
89
|
+
console.warn(` [search FAIL ${taskId}#${attempt}] HTTP ${sres.status}: ${(await sres.text()).slice(0, 140)}`)
|
|
90
|
+
} else {
|
|
91
|
+
const sb = (await sres.json()) as { data?: Array<{ title?: string; url?: string; snippet?: string }> }
|
|
92
|
+
const hits = sb.data ?? []
|
|
93
|
+
searches = hits.length
|
|
94
|
+
// Fetch the full page text of the top-K results (snippets rarely carry exact figures).
|
|
95
|
+
const fetched = await Promise.all(hits.slice(0, cfg.fetchTopK).map((h) => (h.url ? fetchPage(h.url, cfg) : Promise.resolve(''))))
|
|
96
|
+
context = hits
|
|
97
|
+
.map((h, i) => `[${i + 1}] ${h.title ?? ''}\n${h.snippet ?? ''}\n${h.url ?? ''}${fetched[i] ? `\nPAGE CONTENT:\n${fetched[i]}` : ''}`)
|
|
98
|
+
.join('\n\n')
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 2) Answer — no tools (content always present), COMMIT (no deferral), via the shared router primitive.
|
|
103
|
+
const commit =
|
|
104
|
+
'You have no further tools and cannot ask questions or request more research. ' +
|
|
105
|
+
'Output a SINGLE, FINAL answer to the question, leading with the value in the exact units and precision requested ' +
|
|
106
|
+
'(e.g. "Answer: -47.9 billion USD"). ' +
|
|
107
|
+
(useSearch
|
|
108
|
+
? 'Use the WEB SEARCH RESULTS below (snippets + fetched page content) as your primary evidence; cite the source. '
|
|
109
|
+
: 'Answer from your own knowledge. ') +
|
|
110
|
+
'If you are not fully certain, still COMMIT to your single best estimate — never refuse, defer, or reply with a question.'
|
|
111
|
+
const userContent = useSearch && context ? `${prompt}\n\n=== WEB SEARCH RESULTS (provider: ${cfg.search}) ===\n${context}` : prompt
|
|
112
|
+
const { content } = await routerChatWithUsage(
|
|
113
|
+
{ routerBaseUrl: cfg.routerBaseUrl, routerKey: cfg.routerKey, model: cfg.model },
|
|
114
|
+
[
|
|
115
|
+
{ role: 'system', content: commit },
|
|
116
|
+
{ role: 'user', content: userContent },
|
|
117
|
+
],
|
|
118
|
+
{ temperature: cfg.temperature, ...(cfg.timeoutMs ? { signal: AbortSignal.timeout(cfg.timeoutMs) } : {}) },
|
|
119
|
+
)
|
|
120
|
+
const answer = content.trim()
|
|
121
|
+
const ok = answer.length > 0
|
|
122
|
+
return { taskId, attempt, answer, ok, searches, wallMs: Date.now() - startedAt, ...(ok ? {} : { detail: `empty answer (searches=${searches})` }) }
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return {
|
|
125
|
+
taskId,
|
|
126
|
+
attempt,
|
|
127
|
+
answer: '',
|
|
128
|
+
ok: false,
|
|
129
|
+
searches,
|
|
130
|
+
wallMs: Date.now() - startedAt,
|
|
131
|
+
detail: `rollout error: ${(err instanceof Error ? err.message : String(err)).slice(0, 200)}`,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE harness → worker-client mapping for the bench layer. Every bench entrypoint
|
|
3
|
+
* (rsi.ts, run.ts, …) derives the `SandboxClient` the kernel's `runLoop` drives from a single
|
|
4
|
+
* selector instead of hand-rolling the branch:
|
|
5
|
+
*
|
|
6
|
+
* - `router` → OFF-BOX: a router chat-completion as the leaf executor, presented
|
|
7
|
+
* as a SandboxClient (no sandbox dependency). For deployable-checker
|
|
8
|
+
* domains whose worker is a completion, or where box egress is blocked.
|
|
9
|
+
* - `router` + searchProvider → OFF-BOX `router-tools`: the same off-box loop with a live
|
|
10
|
+
* `web_search` tool (the capability axis research benches need).
|
|
11
|
+
* - anything else (`sandbox`/a BackendType) → IN-BOX: a real `Sandbox`. The in-box backend
|
|
12
|
+
* TYPE (opencode/codex/…) is set separately on the `AgentRunSpec`;
|
|
13
|
+
* this only decides off-box-vs-in-box transport for `runLoop`.
|
|
14
|
+
*
|
|
15
|
+
* Centralizing it here means a new entrypoint gets the full off-box/in-box matrix for free and
|
|
16
|
+
* the mapping can't drift between callers.
|
|
17
|
+
*/
|
|
18
|
+
import { createExecutor, inlineSandboxClient, type SandboxClient } from '@tangle-network/agent-runtime/loops'
|
|
19
|
+
import { Sandbox } from '@tangle-network/sandbox'
|
|
20
|
+
import { makeSearchExecutor, webSearchTool } from './search-tool'
|
|
21
|
+
|
|
22
|
+
export interface ResolveBenchClientOptions {
|
|
23
|
+
/** The selector (the `BACKEND` env value): `router` = off-box; anything else = in-box Sandbox. */
|
|
24
|
+
backend: string
|
|
25
|
+
routerBaseUrl: string
|
|
26
|
+
routerKey: string
|
|
27
|
+
model: string
|
|
28
|
+
/** When set on the `router` backend, the off-box worker becomes a `router-tools` agentic loop
|
|
29
|
+
* with a live `web_search` tool backed by this provider (`you`/`exa`/…). */
|
|
30
|
+
searchProvider?: string
|
|
31
|
+
sandboxBaseUrl?: string
|
|
32
|
+
/** In-box sandbox timeout (ms). */
|
|
33
|
+
timeoutMs?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resolveBenchClient(opts: ResolveBenchClientOptions): SandboxClient {
|
|
37
|
+
const { backend, routerBaseUrl, routerKey, model, searchProvider } = opts
|
|
38
|
+
if (backend === 'router') {
|
|
39
|
+
if (searchProvider) {
|
|
40
|
+
return inlineSandboxClient(
|
|
41
|
+
createExecutor({
|
|
42
|
+
backend: 'router-tools',
|
|
43
|
+
routerBaseUrl,
|
|
44
|
+
routerKey,
|
|
45
|
+
model,
|
|
46
|
+
tools: [webSearchTool],
|
|
47
|
+
executeToolCall: makeSearchExecutor({ routerBaseUrl, routerKey, provider: searchProvider }),
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
return inlineSandboxClient(createExecutor({ backend: 'router', routerBaseUrl, routerKey, model }))
|
|
52
|
+
}
|
|
53
|
+
return new Sandbox({
|
|
54
|
+
baseUrl: opts.sandboxBaseUrl ?? 'https://sandbox.tangle.tools',
|
|
55
|
+
apiKey: routerKey,
|
|
56
|
+
timeoutMs: opts.timeoutMs ?? 1_200_000,
|
|
57
|
+
} as never)
|
|
58
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router-backed research executor — the "router" cost-dial backend (experiment.ts:
|
|
3
|
+
* "backend = the injected SandboxClient (router / bridge / sandbox)"). Each run is
|
|
4
|
+
* ONE research shot (router web-search + answer, off-sandbox); its answer is the
|
|
5
|
+
* terminal `finalText` the kernel's `answerOutput` parses.
|
|
6
|
+
*
|
|
7
|
+
* Why off-sandbox: research is retrieval, not in-box code execution — it never
|
|
8
|
+
* needed a box, and a real sandbox box reaches only the router (egress allowlist,
|
|
9
|
+
* ops-board #976), so it cannot web-search natively anyway. Driving the loop with
|
|
10
|
+
* this executor instead of a box gives the REAL `runLoop` kernel full `rounds` +
|
|
11
|
+
* analyst steering (the depth regime), search working, no sandbox dependency.
|
|
12
|
+
*
|
|
13
|
+
* This is a BYO `Executor` over `runResearchShot`; `inlineSandboxClient` supplies
|
|
14
|
+
* the one shared box-shell so this file owns only the research-shot specifics
|
|
15
|
+
* (no hand-rolled create/streamPrompt/delete). A fresh executor per round ⇒ no
|
|
16
|
+
* fork/live session; statefulness is the across-round steer, not a box.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
type ExecutorFactory,
|
|
20
|
+
type ExecutorResult,
|
|
21
|
+
inlineSandboxClient,
|
|
22
|
+
type SandboxClient,
|
|
23
|
+
} from '@tangle-network/agent-runtime/loops'
|
|
24
|
+
import { runResearchShot, type ShotCfg } from './research-shot'
|
|
25
|
+
|
|
26
|
+
export function routerSandboxClient(cfg: ShotCfg): SandboxClient {
|
|
27
|
+
let seq = 0
|
|
28
|
+
const factory: ExecutorFactory<unknown> = () => {
|
|
29
|
+
const id = `router-research-${seq++}`
|
|
30
|
+
let artifact: ExecutorResult<unknown> | undefined
|
|
31
|
+
return {
|
|
32
|
+
runtime: 'router',
|
|
33
|
+
async execute(task): Promise<ExecutorResult<unknown>> {
|
|
34
|
+
const started = Date.now()
|
|
35
|
+
const shot = await runResearchShot(String(task), id, 0, cfg)
|
|
36
|
+
artifact = {
|
|
37
|
+
outRef: `router-research:${id}`,
|
|
38
|
+
out: { content: shot.answer },
|
|
39
|
+
spent: { iterations: 1, tokens: { input: 0, output: 0 }, usd: 0, ms: Date.now() - started },
|
|
40
|
+
}
|
|
41
|
+
return artifact
|
|
42
|
+
},
|
|
43
|
+
teardown: () => Promise.resolve({ destroyed: true }),
|
|
44
|
+
resultArtifact() {
|
|
45
|
+
if (!artifact) throw new Error('routerSandboxClient: resultArtifact() read before execute()')
|
|
46
|
+
return artifact
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return inlineSandboxClient(factory)
|
|
51
|
+
}
|
package/src/run-pool.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded-concurrency pool — the ONE pool every batch runner uses.
|
|
3
|
+
*
|
|
4
|
+
* Every batch-* command, the finsearch loop, and terminal-compare hand-rolled the
|
|
5
|
+
* same shape: a shared `next` cursor, `Math.min(concurrency, items.length)` drain
|
|
6
|
+
* workers, and `Promise.all` over them. That boilerplate lives here once.
|
|
7
|
+
* Aggregation and console output stay at each call site — the pool owns scheduling,
|
|
8
|
+
* nothing else. Item-agnostic on purpose: a pool shouldn't know about BenchTask
|
|
9
|
+
* (terminal-compare pools over task-id strings), so it's generic in the item type.
|
|
10
|
+
*
|
|
11
|
+
* A worker that throws yields `{ ok:false, error }` for THAT item; the batch never
|
|
12
|
+
* aborts because one item failed. Results are returned in item order (indexed by
|
|
13
|
+
* the item's position), not completion order.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface PoolOutcome<T, R> {
|
|
17
|
+
index: number
|
|
18
|
+
item: T
|
|
19
|
+
ok: boolean
|
|
20
|
+
value?: R
|
|
21
|
+
error?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function runPool<T, R>(
|
|
25
|
+
items: T[],
|
|
26
|
+
concurrency: number,
|
|
27
|
+
worker: (item: T, index: number) => Promise<R>,
|
|
28
|
+
opts?: { onResult?: (o: PoolOutcome<T, R>) => void },
|
|
29
|
+
): Promise<PoolOutcome<T, R>[]> {
|
|
30
|
+
const results: PoolOutcome<T, R>[] = new Array(items.length)
|
|
31
|
+
let next = 0
|
|
32
|
+
const drain = async (): Promise<void> => {
|
|
33
|
+
while (next < items.length) {
|
|
34
|
+
const index = next++
|
|
35
|
+
const item = items[index] as T
|
|
36
|
+
let outcome: PoolOutcome<T, R>
|
|
37
|
+
try {
|
|
38
|
+
outcome = { index, item, ok: true, value: await worker(item, index) }
|
|
39
|
+
} catch (err) {
|
|
40
|
+
outcome = { index, item, ok: false, error: err instanceof Error ? err.message : String(err) }
|
|
41
|
+
}
|
|
42
|
+
results[index] = outcome
|
|
43
|
+
opts?.onResult?.(outcome)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, drain))
|
|
47
|
+
return results
|
|
48
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
export type BenchRuntimeHookPhase = 'before' | 'after' | 'error' | 'event'
|
|
2
|
+
|
|
3
|
+
export interface BenchRuntimeHookEvent<Payload = unknown> {
|
|
4
|
+
id: string
|
|
5
|
+
runId: string
|
|
6
|
+
scenarioId?: string
|
|
7
|
+
target: string
|
|
8
|
+
phase: BenchRuntimeHookPhase
|
|
9
|
+
timestamp: number
|
|
10
|
+
stepIndex?: number
|
|
11
|
+
parentId?: string
|
|
12
|
+
payload?: Payload
|
|
13
|
+
metadata?: Record<string, unknown>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BenchRuntimeDecisionEvidenceRef {
|
|
17
|
+
source: string
|
|
18
|
+
id: string
|
|
19
|
+
detail?: string
|
|
20
|
+
metadata?: Record<string, unknown>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BenchRuntimeDecisionPoint {
|
|
24
|
+
id: string
|
|
25
|
+
runId: string
|
|
26
|
+
scenarioId?: string
|
|
27
|
+
stepIndex: number
|
|
28
|
+
kind: string
|
|
29
|
+
candidateActions: string[]
|
|
30
|
+
context?: string
|
|
31
|
+
evidence: BenchRuntimeDecisionEvidenceRef[]
|
|
32
|
+
metadata?: Record<string, unknown>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface BenchRuntimeHooks {
|
|
36
|
+
onEvent?: (
|
|
37
|
+
event: BenchRuntimeHookEvent,
|
|
38
|
+
context: { signal?: AbortSignal },
|
|
39
|
+
) => void | Promise<void>
|
|
40
|
+
onDecisionPoint?: (
|
|
41
|
+
point: BenchRuntimeDecisionPoint,
|
|
42
|
+
context: { signal?: AbortSignal },
|
|
43
|
+
) => void | Promise<void>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RuntimeHookRecorder {
|
|
47
|
+
readonly events: BenchRuntimeHookEvent[]
|
|
48
|
+
readonly decisionPoints: BenchRuntimeDecisionPoint[]
|
|
49
|
+
readonly hooks: BenchRuntimeHooks
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const MAX_STRING_LENGTH = 12_000
|
|
53
|
+
const MAX_CONTEXT_LENGTH = 20_000
|
|
54
|
+
const MAX_EVIDENCE_DETAIL_LENGTH = 2_000
|
|
55
|
+
const MAX_CANDIDATE_ACTIONS = 50
|
|
56
|
+
const MAX_EVIDENCE_REFS = 50
|
|
57
|
+
const MAX_METADATA_DEPTH = 4
|
|
58
|
+
const MAX_METADATA_KEYS = 100
|
|
59
|
+
const SENSITIVE_KEY_RE = /(?:authorization|api[_-]?key|token|secret|password|cookie|credential|bearer)/i
|
|
60
|
+
const SENSITIVE_VALUE_RES = [
|
|
61
|
+
/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi,
|
|
62
|
+
/\b(?:sk|gh[pousr])_[A-Za-z0-9_]{20,}\b/g,
|
|
63
|
+
/\b(?:sk|ghp|gho|ghu|ghs|ghr)-[A-Za-z0-9_-]{20,}\b/g,
|
|
64
|
+
]
|
|
65
|
+
const SENSITIVE_ASSIGNMENT_RE =
|
|
66
|
+
/\b(api[_-]?key|token|secret|password|cookie)\s*[:=]\s*["']?[^"'\s,;}]+/gi
|
|
67
|
+
|
|
68
|
+
function sanitizeString(value: string, maxLength: number): string {
|
|
69
|
+
let sanitized = value
|
|
70
|
+
for (const pattern of SENSITIVE_VALUE_RES) {
|
|
71
|
+
sanitized = sanitized.replace(pattern, '[REDACTED]')
|
|
72
|
+
}
|
|
73
|
+
sanitized = sanitized.replace(SENSITIVE_ASSIGNMENT_RE, (_match, key: string) => `${key}=[REDACTED]`)
|
|
74
|
+
if (sanitized.length <= maxLength) return sanitized
|
|
75
|
+
return sanitized.slice(0, maxLength)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sanitizeMetadata(value: unknown, depth = 0): unknown {
|
|
79
|
+
if (value == null) return value
|
|
80
|
+
if (typeof value === 'string') return sanitizeString(value, MAX_STRING_LENGTH)
|
|
81
|
+
if (typeof value === 'number' || typeof value === 'boolean') return value
|
|
82
|
+
if (Array.isArray(value)) {
|
|
83
|
+
if (depth >= MAX_METADATA_DEPTH) return '[MaxDepth]'
|
|
84
|
+
return value.slice(0, MAX_METADATA_KEYS).map((item) => sanitizeMetadata(item, depth + 1))
|
|
85
|
+
}
|
|
86
|
+
if (typeof value !== 'object') return undefined
|
|
87
|
+
if (depth >= MAX_METADATA_DEPTH) return '[MaxDepth]'
|
|
88
|
+
|
|
89
|
+
const sanitized: Record<string, unknown> = {}
|
|
90
|
+
for (const [key, nested] of Object.entries(value).slice(0, MAX_METADATA_KEYS)) {
|
|
91
|
+
sanitized[key] = SENSITIVE_KEY_RE.test(key) ? '[REDACTED]' : sanitizeMetadata(nested, depth + 1)
|
|
92
|
+
}
|
|
93
|
+
return sanitized
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sanitizeMetadataRecord(metadata: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
|
|
97
|
+
if (!metadata) return undefined
|
|
98
|
+
const sanitized = sanitizeMetadata(metadata)
|
|
99
|
+
if (!sanitized || typeof sanitized !== 'object' || Array.isArray(sanitized)) return undefined
|
|
100
|
+
return sanitized as Record<string, unknown>
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function snapshotDecisionPoint(point: BenchRuntimeDecisionPoint): BenchRuntimeDecisionPoint {
|
|
104
|
+
return {
|
|
105
|
+
id: point.id,
|
|
106
|
+
runId: point.runId,
|
|
107
|
+
scenarioId: point.scenarioId,
|
|
108
|
+
stepIndex: point.stepIndex,
|
|
109
|
+
kind: point.kind,
|
|
110
|
+
candidateActions: point.candidateActions.slice(0, MAX_CANDIDATE_ACTIONS).map((action) => sanitizeString(action, MAX_STRING_LENGTH)),
|
|
111
|
+
context: typeof point.context === 'string' ? sanitizeString(point.context, MAX_CONTEXT_LENGTH) : undefined,
|
|
112
|
+
evidence: point.evidence.slice(0, MAX_EVIDENCE_REFS).map((ref) => ({
|
|
113
|
+
source: sanitizeString(ref.source, MAX_STRING_LENGTH),
|
|
114
|
+
id: sanitizeString(ref.id, MAX_STRING_LENGTH),
|
|
115
|
+
detail: typeof ref.detail === 'string' ? sanitizeString(ref.detail, MAX_EVIDENCE_DETAIL_LENGTH) : undefined,
|
|
116
|
+
metadata: sanitizeMetadataRecord(ref.metadata),
|
|
117
|
+
})),
|
|
118
|
+
metadata: sanitizeMetadataRecord(point.metadata),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createRuntimeHookRecorder(): RuntimeHookRecorder {
|
|
123
|
+
const events: BenchRuntimeHookEvent[] = []
|
|
124
|
+
const decisionPoints: BenchRuntimeDecisionPoint[] = []
|
|
125
|
+
return {
|
|
126
|
+
events,
|
|
127
|
+
decisionPoints,
|
|
128
|
+
hooks: {
|
|
129
|
+
onEvent: (event) => {
|
|
130
|
+
events.push(event)
|
|
131
|
+
},
|
|
132
|
+
onDecisionPoint: (point) => {
|
|
133
|
+
decisionPoints.push(snapshotDecisionPoint(point))
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared sandbox-rollout helpers for the bench harnesses.
|
|
3
|
+
*
|
|
4
|
+
* The worker plumbing every sandbox-backed bench needs, independent of how the
|
|
5
|
+
* loop is driven: build the standard `AgentRunSpec` (`sandboxAgentRun`), parse
|
|
6
|
+
* the agent's final answer from the event stream (`answerOutput`), name the
|
|
7
|
+
* cost-dial backend (`WorkerBackendType`), and run a single-model "review the
|
|
8
|
+
* prior attempt" analyst (`llmAnalyst`/`AnalystFn`). These are pure profile /
|
|
9
|
+
* backend / parsing plumbing — no experiment shell, no topology arms.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
type AgentProfile,
|
|
14
|
+
type AgentRunSpec,
|
|
15
|
+
type OutputAdapter,
|
|
16
|
+
routerChatWithUsage,
|
|
17
|
+
} from '@tangle-network/agent-runtime/loops'
|
|
18
|
+
// `BackendType` is the sandbox SDK's harness union — its canonical home. agent-runtime consumes
|
|
19
|
+
// it from there too; it is not re-exported from the loops barrel.
|
|
20
|
+
import type { BackendType } from '@tangle-network/sandbox'
|
|
21
|
+
|
|
22
|
+
/** Parse the agent's final answer from the event stream (harness-agnostic).
|
|
23
|
+
* The default deliverable; a benchmark whose artifact is a file overrides via
|
|
24
|
+
* its own `OutputAdapter` that reads from the run. */
|
|
25
|
+
export const answerOutput: OutputAdapter<string> = {
|
|
26
|
+
parse(events) {
|
|
27
|
+
let answer = ''
|
|
28
|
+
for (const ev of events) {
|
|
29
|
+
const d = (ev as { data?: Record<string, unknown> })?.data
|
|
30
|
+
const t = d?.finalText ?? d?.text ?? d?.result
|
|
31
|
+
if (typeof t === 'string' && t.length > 0) answer = t
|
|
32
|
+
}
|
|
33
|
+
return answer
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What an analyst sees of each prior attempt: its output, its verdict, and its
|
|
38
|
+
* raw trace events. The events are the trace an analyst reads. */
|
|
39
|
+
export type SteerHistory = ReadonlyArray<{
|
|
40
|
+
output?: string
|
|
41
|
+
verdict?: { valid?: boolean; score?: number; notes?: string }
|
|
42
|
+
events?: readonly unknown[]
|
|
43
|
+
}>
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The investigation: read the prior attempt's trace, return targeted feedback for
|
|
47
|
+
* the next one. It observes BEHAVIOR (output, trace), never the judge's verdict —
|
|
48
|
+
* the selector != judge firewall.
|
|
49
|
+
*/
|
|
50
|
+
export type AnalystFn = (history: SteerHistory, task?: string) => Promise<string>
|
|
51
|
+
|
|
52
|
+
/** Simple analyst: ONE model call reads the public task plus a bounded view of the
|
|
53
|
+
* last attempt (its output + a tail of its trace events) and returns a concrete
|
|
54
|
+
* correction. Selector != judge firewall: it NEVER reads the held-out judge's
|
|
55
|
+
* verdict or failure detail — that would be a non-deployable oracle gradient
|
|
56
|
+
* toward the reference answer. A deployable steerer must locate the fault from the
|
|
57
|
+
* task and the agent's own behavior alone. */
|
|
58
|
+
export const llmAnalyst = (cfg: { routerBaseUrl: string; routerKey: string; model: string }): AnalystFn =>
|
|
59
|
+
async (history, task) => {
|
|
60
|
+
const last = history.at(-1)
|
|
61
|
+
const traceTail = (last?.events ?? [])
|
|
62
|
+
.slice(-12)
|
|
63
|
+
.map((e) => (typeof e === 'string' ? e : JSON.stringify(e)))
|
|
64
|
+
.join('\n')
|
|
65
|
+
.slice(-2000)
|
|
66
|
+
const { content } = await routerChatWithUsage(cfg, [
|
|
67
|
+
{
|
|
68
|
+
role: 'system',
|
|
69
|
+
content:
|
|
70
|
+
"You review an AI agent's previous attempt at a task. From the task, the attempt's output, and its execution trace ALONE, judge whether it correctly and completely solved the task. If you find a specific fault — a wrong value, a guessed API signature, a missing step, a misread requirement — name it and give the concrete correction in 1-3 sentences. Reply exactly 'no change needed' if the attempt looks correct and complete.",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
role: 'user',
|
|
74
|
+
content: `Task:\n${task ?? '(task unavailable)'}\n\nPrevious answer:\n${last?.output ?? '(none)'}\n\nTrace tail:\n${traceTail}`,
|
|
75
|
+
},
|
|
76
|
+
])
|
|
77
|
+
return content
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Cost-dial backend = the SDK's canonical `BackendType` (single source of truth; no local
|
|
81
|
+
* literal copy that drifts from the harness set). `hermes` = the inference-router agent (the
|
|
82
|
+
* cheap "router llm-call" dial); the rest are agent CLIs. The ONLY knob that changes which
|
|
83
|
+
* agent runs — no per-backend worker. */
|
|
84
|
+
export type WorkerBackendType = BackendType
|
|
85
|
+
|
|
86
|
+
/** Build the standard sandbox `AgentRunSpec` for a benchmark — the worker the
|
|
87
|
+
* kernel injects. `backendType` is the cost dial. Model auth is the BOX'S OWN
|
|
88
|
+
* provisioned credential: `backend.model` pins provider/model/baseUrl only, and
|
|
89
|
+
* the platform generates the in-box provider config keyed to
|
|
90
|
+
* `{env:OPENCODE_MODEL_API_KEY}`. Never pass an external router key into the
|
|
91
|
+
* box — the egress proxy rejects foreign credentials (403, empty output). */
|
|
92
|
+
export function sandboxAgentRun(opts: {
|
|
93
|
+
model: string
|
|
94
|
+
routerBaseUrl: string
|
|
95
|
+
backendType?: WorkerBackendType
|
|
96
|
+
/** In-box model provider. Default `openai` (registered models like gpt-4.1).
|
|
97
|
+
* Cheap router models (deepseek/kimi/glm) are not in opencode's `openai`
|
|
98
|
+
* registry and 404 in-box — pass `openai-compat` (generic passthrough). */
|
|
99
|
+
provider?: string
|
|
100
|
+
name?: string
|
|
101
|
+
taskToPrompt?: (task: string) => string
|
|
102
|
+
/** Extra box-level env (e.g. `TANGLE_SEARCH_DEFAULT_PROVIDER` to pin the in-box
|
|
103
|
+
* agent's web-search provider, provider keys like EXA_API_KEY). Allowlisted
|
|
104
|
+
* keys only reach the spawned CLI. Must NOT carry router/model credentials. */
|
|
105
|
+
env?: Record<string, string>
|
|
106
|
+
/** The developer's AgentProfile — the one knob for "which agent" (prompt / model /
|
|
107
|
+
* tools / mcp). Spread through verbatim; the backend cost-dial is tagged into
|
|
108
|
+
* metadata. Omitted ⇒ a minimal worker profile. */
|
|
109
|
+
profile?: AgentProfile
|
|
110
|
+
}): AgentRunSpec<string> {
|
|
111
|
+
const backendType = opts.backendType ?? 'opencode'
|
|
112
|
+
const name = opts.profile?.name ?? opts.name ?? `${backendType}-worker`
|
|
113
|
+
return {
|
|
114
|
+
profile: { ...opts.profile, name, metadata: { ...opts.profile?.metadata, backendType } },
|
|
115
|
+
name,
|
|
116
|
+
taskToPrompt: opts.taskToPrompt ?? ((t) => t),
|
|
117
|
+
sandboxOverrides: {
|
|
118
|
+
...(opts.env ? { env: opts.env } : {}),
|
|
119
|
+
backend: {
|
|
120
|
+
type: backendType,
|
|
121
|
+
model: { provider: opts.provider ?? 'openai', model: opts.model, baseUrl: opts.routerBaseUrl },
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli-bridge cell executor — the unblocked path to the head-to-head numbers
|
|
3
|
+
* while the sandbox sidecar image is pending #1810.
|
|
4
|
+
*
|
|
5
|
+
* Same arms, same deterministic oracle, same export. The only difference from
|
|
6
|
+
* the sandbox path is HOW the harness runs: a single OpenAI-compatible chat call
|
|
7
|
+
* to the local cli-bridge (`/v1/chat/completions`) with an `agent_profile` that
|
|
8
|
+
* (a) disables native web tools via `metadata.disallowedTools` and (b) adds the
|
|
9
|
+
* provider search MCP via `mcp` — both PROVEN to work on the bridge. Native arm
|
|
10
|
+
* leaves the harness untouched.
|
|
11
|
+
*
|
|
12
|
+
* The bridge model id IS the harness selector (e.g. `claude-code/sonnet`,
|
|
13
|
+
* `opencode/zai-coding-plan/glm-5.1`), so `harness` here is just the label.
|
|
14
|
+
*/
|
|
15
|
+
import { createExecutor } from '@tangle-network/agent-runtime/loops'
|
|
16
|
+
import type { SearchArm } from './profiles'
|
|
17
|
+
import { armLabel } from './profiles'
|
|
18
|
+
import type { SearchCellResult } from './run.mts'
|
|
19
|
+
import { type SearchTask, scoreTask, taskToPrompt } from './tasks'
|
|
20
|
+
|
|
21
|
+
const nativeWebDisallowed = ['WebSearch', 'WebFetch', 'web_search', 'web_fetch', 'websearch', 'webfetch', 'fetch']
|
|
22
|
+
|
|
23
|
+
/** Build the cli-bridge `agent_profile` for one arm (bridge dialect: disable via
|
|
24
|
+
* `metadata.disallowedTools`, search MCP via `mcp.<name>.transport:'http'`). */
|
|
25
|
+
function bridgeProfile(arm: SearchArm, routerSearchMcp: string, tangleApiKey: string, label: string): Record<string, unknown> {
|
|
26
|
+
if (arm === 'native') return { name: `search-bench-${label}` }
|
|
27
|
+
const base = { name: `search-bench-${label}`, metadata: { disallowedTools: nativeWebDisallowed } }
|
|
28
|
+
if (arm === 'off') return base
|
|
29
|
+
return {
|
|
30
|
+
...base,
|
|
31
|
+
mcp: {
|
|
32
|
+
tangle_search: {
|
|
33
|
+
transport: 'http',
|
|
34
|
+
url: `${routerSearchMcp}?provider=${encodeURIComponent(arm.provider)}`,
|
|
35
|
+
headers: { Authorization: `Bearer ${tangleApiKey}` },
|
|
36
|
+
enabled: true,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const urlRe = /https?:\/\/[^\s)\]}"'<>]+/gi
|
|
43
|
+
function citationsOf(answer: string): string[] {
|
|
44
|
+
return [...new Set((answer.match(urlRe) ?? []).map((u) => u.replace(/[.,;]+$/, '')))]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface BridgeCfg {
|
|
48
|
+
bridgeUrl: string
|
|
49
|
+
bridgeBearer: string
|
|
50
|
+
tangleApiKey: string
|
|
51
|
+
/** Router search-MCP endpoint, e.g. https://router.tangle.tools/v1/search/mcp */
|
|
52
|
+
routerSearchMcp: string
|
|
53
|
+
/** harness label → cli-bridge model id (the harness selector). */
|
|
54
|
+
bridgeModels: Record<string, string>
|
|
55
|
+
timeoutMs?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function runBridgeCell(
|
|
59
|
+
cfg: BridgeCfg,
|
|
60
|
+
task: SearchTask,
|
|
61
|
+
harness: string,
|
|
62
|
+
arm: SearchArm,
|
|
63
|
+
): Promise<SearchCellResult> {
|
|
64
|
+
const startedAt = Date.now()
|
|
65
|
+
const armId = armLabel(arm)
|
|
66
|
+
const base = {
|
|
67
|
+
taskId: task.id,
|
|
68
|
+
domain: task.domain,
|
|
69
|
+
harness,
|
|
70
|
+
arm: armId,
|
|
71
|
+
model: cfg.bridgeModels[harness] ?? harness,
|
|
72
|
+
ts: new Date(startedAt).toISOString(),
|
|
73
|
+
}
|
|
74
|
+
const controller = new AbortController()
|
|
75
|
+
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs ?? 300_000)
|
|
76
|
+
try {
|
|
77
|
+
// One harness turn through the unified bridge executor — same backend the
|
|
78
|
+
// loop path uses; this cell scorer just adds oracle scoring + citations.
|
|
79
|
+
const exec = createExecutor({
|
|
80
|
+
backend: 'bridge',
|
|
81
|
+
bridgeUrl: cfg.bridgeUrl,
|
|
82
|
+
bridgeBearer: cfg.bridgeBearer,
|
|
83
|
+
model: cfg.bridgeModels[harness] ?? harness,
|
|
84
|
+
agentProfile: bridgeProfile(arm, cfg.routerSearchMcp, cfg.tangleApiKey, `${harness}-${armId}`),
|
|
85
|
+
timeoutMs: cfg.timeoutMs ?? 300_000,
|
|
86
|
+
})({ profile: { name: `${harness}-${armId}` }, harness: null }, { signal: controller.signal, seams: {} })
|
|
87
|
+
// bridgeExecutor is one-shot (async execute resolves an ExecutorResult).
|
|
88
|
+
const artifact = (await exec.execute(taskToPrompt(task), controller.signal)) as {
|
|
89
|
+
out: unknown
|
|
90
|
+
spent: { tokens: { input: number; output: number }; usd: number }
|
|
91
|
+
}
|
|
92
|
+
const out = artifact.out as { content?: string; toolCalls?: string[] }
|
|
93
|
+
const answer = out.content ?? ''
|
|
94
|
+
const names = out.toolCalls ?? []
|
|
95
|
+
const { score, reasons } = scoreTask(task, answer)
|
|
96
|
+
return {
|
|
97
|
+
...base,
|
|
98
|
+
score,
|
|
99
|
+
reasons,
|
|
100
|
+
...(artifact.spent.usd ? { costUsd: artifact.spent.usd } : {}),
|
|
101
|
+
...(artifact.spent.tokens.input ? { tokensIn: artifact.spent.tokens.input } : {}),
|
|
102
|
+
...(artifact.spent.tokens.output ? { tokensOut: artifact.spent.tokens.output } : {}),
|
|
103
|
+
wallMs: Date.now() - startedAt,
|
|
104
|
+
toolCalls: names.length,
|
|
105
|
+
toolNames: [...new Set(names)],
|
|
106
|
+
citations: citationsOf(answer),
|
|
107
|
+
answer,
|
|
108
|
+
}
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return {
|
|
111
|
+
...base,
|
|
112
|
+
score: null,
|
|
113
|
+
reasons: [],
|
|
114
|
+
wallMs: Date.now() - startedAt,
|
|
115
|
+
toolCalls: 0,
|
|
116
|
+
toolNames: [],
|
|
117
|
+
citations: [],
|
|
118
|
+
answer: '',
|
|
119
|
+
infraError: err instanceof Error ? err.message : String(err),
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
clearTimeout(timer)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
Binary file
|