Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .evolve/skill-runs.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,7 @@
{"skill":"/agent-eval","ts":"2026-07-30T11:07:02Z","project":"agent-eval-analyst-proof-20260730","target":"crash-safe resumable public analyst benchmark command","operatorPrompt":"","durationMin":null,"verdict":"PASS","dispatchedTo":"/verify","operatorOverride":null,"transcriptPath":null,"traceDir":null}
{"skill":"/verify","ts":"2026-07-30T11:07:07Z","project":"agent-eval-analyst-proof-20260730","target":"benchmark command recovery, artifacts, and AgentRx metrics","operatorPrompt":"","durationMin":null,"verdict":"PASS","dispatchedTo":"/stop","operatorOverride":null,"transcriptPath":null,"traceDir":null}
{"skill":"/semgrep","ts":"2026-07-30T15:47:18Z","project":"agent-eval-analyst-proof-20260730","target":"agent-eval public analyst benchmark, 664 files, 492 rules","operatorPrompt":"","durationMin":null,"verdict":"PASS","dispatchedTo":"/stop","operatorOverride":null,"transcriptPath":null,"traceDir":null}
{"skill":"/review-to-green","ts":"2026-07-30T19:04:25Z","project":"agent-eval-exact-analyst-run-20260730","target":"exact AnalystRegistry audit findings","operatorPrompt":"","durationMin":null,"verdict":"DONE","dispatchedTo":"/ship","operatorOverride":null,"transcriptPath":null,"traceDir":null}
{"skill":"/review-to-green","ts":"2026-07-30T20:07:14Z","project":"agent-eval-exact-analyst-run-20260730","target":"exact AnalystRegistry six audit findings","operatorPrompt":"","durationMin":null,"verdict":"PASS","dispatchedTo":"/simplify","operatorOverride":null,"transcriptPath":null,"traceDir":null}
{"skill":"/simplify","ts":"2026-07-30T20:07:14Z","project":"agent-eval-exact-analyst-run-20260730","target":"exact AnalystRegistry execution policy","operatorPrompt":"","durationMin":null,"verdict":"PASS","dispatchedTo":"/verify","operatorOverride":null,"transcriptPath":null,"traceDir":null}
{"skill":"/report","ts":"2026-07-30T20:07:31Z","project":"agent-eval-exact-analyst-run-20260730","target":"exact AnalystRegistry replacement status","operatorPrompt":"","durationMin":null,"verdict":"PASS","dispatchedTo":"/stop","operatorOverride":null,"transcriptPath":null,"traceDir":null}
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ All notable changes to `@tangle-network/agent-eval` and its sibling `agent-eval-

### Added

- `AnalystRegistry.runExact()` requires ordered analyst ids and an explicit value for every run-policy field.
It never inherits registry insertion order or the constructor's default budget.
Disabled budgets, timeouts, cancellation, cost attribution, tags, and prior findings use explicit `null` values.
Exact runs bind analyst, ledger, hook, chat, and policy identities with non-secret configuration digests.
They use the registry's shared serial execution path and persist canonical equal or weighted allocations instead of adding a second scheduler or allocator.
Exact receipts explicitly distinguish complete execution from a failed ordered prefix.
`ExactAnalystRunExecutionError.result` is always a canonical immutable failed receipt with valid completed work and accounting.
- `defineTraceAnalyst()` accepts canonical `executionConfig` and returns an exact-capable custom analyst when it is supplied, while preserving its existing minimal form.
- **Breaking:** `AnalystBenchmarkCase` now requires `clusterId` and `labelState`.
A case must identify its independent source unit and state whether labels prove an issue, prove no issue, or leave the outcome unknown.
The benchmark no longer guesses either field from an empty issue list.
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,38 @@ Use `analyzeRuns()` for `RunRecord[]`.
For traces, run a registry of built-in or custom analysts, measure it on labeled issues and exact span locations, then turn only reviewed findings into eval data.
For a public quality check, convert CodeTraceBench with `traces import-codetracebench`, then run `agent-eval analyst-benchmark` against the pinned labels and a real model.

Use `AnalystRegistry.runExact()` when the caller, rather than registry defaults, must own every execution choice.
The ordered `analystIds` array is the execution order, and `null` explicitly disables optional budget, timeout, cancellation, cost, tag, or prior-finding channels.
Exact runs are serial; callers that need recursive or concurrent scheduling compose them through their runtime rather than adding a second scheduler here.

```ts
const result = await registry.runExact('analysis-1', inputs, {
analystIds: ['failure-mode', 'improvement'],
budget: { kind: 'equal', totalUsd: 2 },
totalTimeoutMs: 30_000,
signal: null,
costLedger: null,
costLedgerIdentity: null,
costPhase: null,
tags: null,
priorFindings: null,
chainFindings: true,
missingInputMode: 'abort',
applyRegistryHooks: false,
useRegistryChat: false,
})
```

Custom analysts passed to `runExact()` declare canonical `executionConfig`.
The same `defineTraceAnalyst()` helper returns an exact-capable analyst when that field is present.
Built-in analysts already declare it.
Trace analysts selected by `runExact()` also require `aiIdentity`, using the same non-secret `id`, `version`, and canonical `config` shape as cost ledgers, registry hooks, and registry chat clients.
Exact lifecycle hooks receive frozen snapshots for observation; they cannot rewrite the planned context.
Persisted results store configuration digests, not raw configuration.
The persisted plan records the exact equal or weighted allocation for every routed analyst, and archival validates summaries against that same plan.
Every exact receipt says whether it is `complete` or `failed`; a complete receipt must cover the full plan, while a failed receipt may contain only the executed prefix.
Any failure after an exact run starts rejects with `ExactAnalystRunExecutionError`; its immutable failed receipt preserves valid completed summaries, findings, usage, and cost.

See [concepts](./docs/concepts.md), [customer paths](./docs/customer-journeys.md), and [trace analysis](./docs/trace-analysis.md).

## Entry Points
Expand Down
37 changes: 37 additions & 0 deletions scripts/verify-package-exports.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ try {
import {
CostLedger,
CONTROL_INTEGRITY_ANALYST as ROOT_CONTROL_INTEGRITY_ANALYST,
AnalystRegistry as RootAnalystRegistry,
analyzeSupervisorRunIntegrity as ROOT_ANALYZE_SUPERVISOR_RUN_INTEGRITY,
analystFindingDigest,
analystRunDigest,
Expand All @@ -167,6 +168,8 @@ try {
type ChatClient,
type CostLedgerHandle as RootCostLedgerHandle,
type ExecutorConfig,
type ExactExecutionComponentIdentity as RootExactExecutionComponentIdentity,
type ExactRegistryRunOpts as RootExactRegistryRunOpts,
type JudgeFn,
type LlmJudgeOptions as RootLlmJudgeOptions,
type LlmClientOptions,
Expand All @@ -180,6 +183,7 @@ try {
import {
CONTROL_INTEGRITY_ANALYST,
ControlIntegrityAnalyst,
AnalystRegistry as AnalystSubpathRegistry,
RawAnalystFindingSchema,
agentRxBenchmarkCase,
agentRxPredictionsToFindings,
Expand All @@ -188,6 +192,8 @@ try {
emitControlIntegrityFindings,
traceStoreEvidenceResolver,
type AnalystBenchmarkCase,
type ExactExecutionComponentIdentity as AnalystExactExecutionComponentIdentity,
type ExactRegistryRunOpts as AnalystExactRegistryRunOpts,
type RawAnalystFinding,
} from '@tangle-network/agent-eval/analyst'
import {
Expand Down Expand Up @@ -360,6 +366,31 @@ try {
const removedDatasetRecordInput: Parameters<typeof buildRlDataset>[0] = removedRecordInputs
const canonicalChat = null as unknown as ChatClient
const canonicalJudge = null as unknown as JudgeFn
const exactComponentIdentity: RootExactExecutionComponentIdentity = {
id: 'package-check',
version: '1.0.0',
config: { mode: 'compile-check' },
}
const analystExactComponentIdentity: AnalystExactExecutionComponentIdentity =
exactComponentIdentity
const exactRunOptions: RootExactRegistryRunOpts = {
analystIds: ['package-check'],
budget: null,
totalTimeoutMs: null,
signal: null,
costLedger: null,
costLedgerIdentity: null,
costPhase: null,
tags: null,
priorFindings: null,
chainFindings: false,
missingInputMode: 'abort',
applyRegistryHooks: false,
useRegistryChat: false,
}
const analystExactRunOptions: AnalystExactRegistryRunOpts = exactRunOptions
const rootExactRegistry = new RootAnalystRegistry()
const analystExactRegistry = new AnalystSubpathRegistry()
const controlIntegrityAnalyst: ControlIntegrityAnalyst = CONTROL_INTEGRITY_ANALYST
const controlIntegrityInput = undefined as SupervisorRunSources | SupervisorRunTree | undefined
const controlIntegrityReport = undefined as SupervisorRunIntegrityReport | undefined
Expand Down Expand Up @@ -548,6 +579,12 @@ try {
removedDatasetRecordInput,
canonicalChat,
canonicalJudge,
exactComponentIdentity,
analystExactComponentIdentity,
exactRunOptions,
analystExactRunOptions,
rootExactRegistry,
analystExactRegistry,
controlIntegrityAnalyst,
controlIntegrityInput,
controlIntegrityReport,
Expand Down
96 changes: 96 additions & 0 deletions src/analyst/analyst.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ describe('AnalystHooks', () => {

it('does not reclassify a successful analyst when its after hook fails', async () => {
let onErrorCalls = 0
let onCompleteCalls = 0
const reg = new AnalystRegistry({
hooks: {
onAfterAnalyze: () => {
Expand All @@ -371,13 +372,17 @@ describe('AnalystHooks', () => {
onErrorCalls += 1
return []
},
onComplete: () => {
onCompleteCalls += 1
},
},
})
reg.register(ok('a'))
const record = { id: 'r' } as unknown as AnalystRunInputs['runRecord']

await expect(reg.run('run-1', { runRecord: record })).rejects.toThrow('telemetry failed')
expect(onErrorCalls).toBe(0)
expect(onCompleteCalls).toBe(0)
})

it('onError can convert a thrown analyst into findings', async () => {
Expand Down Expand Up @@ -860,6 +865,97 @@ describe('AnalystRegistry.runStream', () => {
'run-completed',
])
})

it('does not admit the next analyst after a stream consumer stops', async () => {
const started: string[] = []
const analyst = (id: string): Analyst => ({
id,
description: id,
inputKind: 'custom',
cost: { kind: 'deterministic' },
version: '1',
async analyze() {
started.push(id)
return []
},
})
const registry = new AnalystRegistry()
registry.register(analyst('a'))
registry.register(analyst('b'))

for await (const event of registry.runStream('consumer-stop', {
custom: { a: 1, b: 1 },
})) {
if (event.type === 'analyst-completed') break
}

expect(started).toEqual(['a'])
})
})

describe('legacy lifecycle context', () => {
it('preserves a hook-selected signal and deadline for the analyst', async () => {
const selected = new AbortController()
let observedSignal: AbortSignal | undefined
let observedDeadline: number | undefined
const registry = new AnalystRegistry({
hooks: {
onBeforeAnalyze({ ctx }) {
ctx.signal = selected.signal
ctx.deadlineMs = 123
},
},
})
registry.register({
id: 'a',
description: 'a',
inputKind: 'custom',
cost: { kind: 'deterministic' },
version: '1',
async analyze(_input, ctx) {
observedSignal = ctx.signal
observedDeadline = ctx.deadlineMs
return []
},
})

await registry.run('hook-context', { custom: { a: 1 } })

expect(observedSignal).toBe(selected.signal)
expect(observedDeadline).toBe(123)
})

it('keeps the run timeout authoritative when a hook selects another signal', async () => {
const selected = new AbortController()
let completed = false
let observedSignal: AbortSignal | undefined
const registry = new AnalystRegistry({
hooks: {
onBeforeAnalyze({ ctx }) {
ctx.signal = selected.signal
},
},
})
registry.register({
id: 'a',
description: 'a',
inputKind: 'custom',
cost: { kind: 'deterministic' },
version: '1',
async analyze(_input, ctx) {
observedSignal = ctx.signal
await new Promise((resolve) => setTimeout(resolve, 40))
completed = true
return []
},
})

const result = await registry.run('hook-timeout', { custom: { a: 1 } }, { timeoutMs: 5 })

expect(observedSignal).toBe(selected.signal)
expect(result.per_analyst[0]?.status).toBe('failed')
expect(completed).toBe(false)
})
})

describe('RegistryRunOpts.priorFindings forwarding', () => {
Expand Down
10 changes: 8 additions & 2 deletions src/analyst/behavioral-analyst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
type SuboptimalCode,
} from '../trace-analyst/behavioral-metrics'
import type { TraceAnalysisStore } from '../trace-analyst/store'
import { type Analyst, type AnalystFinding, makeFinding } from './types'
import type { ExactCapableAnalyst } from './exact-types'
import { type AnalystFinding, makeFinding } from './types'

const RECOMMENDED_ACTION: Record<SuboptimalCode, string> = {
'monotonic-input-growth':
Expand Down Expand Up @@ -134,7 +135,7 @@ export function deriveEfficiencyFindings(
/** The deterministic behavioral/efficiency analyst (no LLM, any-model). */
export function behavioralAnalyst(
options: BehavioralAnalystOptions = {},
): Analyst<TraceAnalysisStore> {
): ExactCapableAnalyst<TraceAnalysisStore> {
const maxTraces = positiveInteger(options.maxTraces ?? DEFAULT_MAX_TRACES, 'maxTraces')
const maxEvidenceRefsPerFinding = positiveInteger(
options.maxEvidenceRefsPerFinding ?? DEFAULT_MAX_EVIDENCE_REFS,
Expand All @@ -147,6 +148,11 @@ export function behavioralAnalyst(
inputKind: 'trace-store',
cost: { kind: 'deterministic' },
version: '2.0.0',
executionConfig: {
kind: 'behavioral-efficiency',
max_traces: maxTraces,
max_evidence_refs_per_finding: maxEvidenceRefsPerFinding,
},
async analyze(store, context) {
const analyzedTraceIds = await listTraceIds(store, maxTraces, context.signal)
const findingsById = new Map<
Expand Down
10 changes: 10 additions & 0 deletions src/analyst/default-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,4 +356,14 @@ describe('buildDefaultAnalystRegistry', () => {
/maxEvidenceRefsPerFinding/,
)
})

it('binds effective deterministic limits for exact-run provenance', () => {
expect(
behavioralAnalyst({ maxTraces: 25, maxEvidenceRefsPerFinding: 3 }).executionConfig,
).toEqual({
kind: 'behavioral-efficiency',
max_traces: 25,
max_evidence_refs_per_finding: 3,
})
})
})
11 changes: 10 additions & 1 deletion src/analyst/default-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@

import type { AxAIService } from '@ax-llm/ax'
import { type BehavioralAnalystOptions, behavioralAnalyst } from './behavioral-analyst'
import type { ExactExecutionComponentIdentity } from './exact-types'
import { createTraceAnalystKind, type TraceAnalystKindSpec } from './kind-factory'
import { DEFAULT_TRACE_ANALYST_KINDS } from './kinds'
import { AnalystRegistry, type AnalystRegistryOptions } from './registry'

export interface DefaultAnalystRegistryOptions {
/** Ax service for the agentic RLM kinds. Omit → only the deterministic analyst. */
ai?: AxAIService
/** Required when agentic kinds will be selected by `runExact`. */
aiIdentity?: ExactExecutionComponentIdentity
/** Required unless `ai` was created by `createAnalystAi`. */
model?: string
/** Which agentic kinds to register when `ai` is present. Default = the shipped suite. */
Expand All @@ -40,7 +43,13 @@ export function buildDefaultAnalystRegistry(
if (opts.ai) {
const kinds = opts.kinds ?? DEFAULT_TRACE_ANALYST_KINDS
for (const spec of kinds) {
registry.register(createTraceAnalystKind(spec, { ai: opts.ai, model: opts.model }))
registry.register(
createTraceAnalystKind(spec, {
ai: opts.ai,
model: opts.model,
aiIdentity: opts.aiIdentity,
}),
)
}
}
return registry
Expand Down
37 changes: 37 additions & 0 deletions src/analyst/define.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { defineTraceAnalyst } from './define'
import type { ExactCapableAnalyst } from './exact-types'

describe('defineTraceAnalyst', () => {
it('fills the fixed trace-store fields and preserves the declared cost', () => {
Expand Down Expand Up @@ -46,4 +47,40 @@ describe('defineTraceAnalyst', () => {
defineTraceAnalyst(missingCost)
}).toThrow(/cost must be declared/)
})

it('creates an exact-capable analyst when execution configuration is declared', () => {
const analyst: ExactCapableAnalyst = defineTraceAnalyst({
id: 'tool-fidelity',
description: 'Compare native and normalized tool calls.',
version: '2.0.0',
cost: { kind: 'deterministic' },
executionConfig: {
kind: 'tool-fidelity',
schemaVersion: '1',
},
async analyze() {
return []
},
})

expect(analyst.executionConfig).toEqual({
kind: 'tool-fidelity',
schemaVersion: '1',
})
})

it('rejects a non-object exact execution configuration', () => {
expect(() =>
defineTraceAnalyst({
id: 'tool-fidelity',
description: 'Compare native and normalized tool calls.',
cost: { kind: 'deterministic' },
// @ts-expect-error JavaScript callers can supply a non-object value.
executionConfig: [],
async analyze() {
return []
},
}),
).toThrow(/executionConfig must be an object/)
})
})
Loading