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
2 changes: 1 addition & 1 deletion .claude/skills/agent-eval/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -976,7 +976,7 @@ Do you have ≥5 well-defined transformations to test (e.g. directive enum)?
**Recipe by tier:**

- **Prompt-tier (string surface)** — start with `gepaDriver()`. Escalate to a custom `Mutator` + `evolutionaryDriver` only when you have a known directive enum that beats LLM-discovery.
- **Code-tier (`CodeSurface`)** — use agent-runtime's `improvementDriver` with a `reflectiveGenerator` or `agenticGenerator`. The substrate's drivers don't reason about code diffs.
- **Code-tier (`CodeSurface`)** — use agent-runtime's `improvementDriver` with a `reflectiveGenerator` or `agenticGenerator`, then finalize through `gitWorktreeAdapter` and call `verifyCodeSurface` before execution. The substrate's drivers don't reason about code diffs.
- **Knowledge-tier** — out of scope here; agent-knowledge owns this.

### Surface menu — what string to pick
Expand Down
2 changes: 2 additions & 0 deletions .evolve/skill-runs.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@
{"skill":"critical-audit","ts":"2026-06-21T12:57:05Z","project":"agent-eval","target":"PR #264 review comment 4762012574","verdict":"APPROVE_AFTER_FIXES","score":"8/10","findings":"1 medium, 9 low; 8 fixed, 1 not_actionable, 1 deferred","files":[".gitignore","CHANGELOG.md","src/agent-profile.ts","src/agent-profile.test.ts","src/contract/define-agent-eval.ts","src/contract/index.ts","tests/contract-define-agent-eval.test.ts"]}
{"skill":"critical-audit","ts":"2026-07-09T21:54:37Z","project":"agent-eval","target":"clustered paired binary inference for repository-disjoint coding benchmarks","verdict":"APPROVE_AFTER_FIXES","score":"8/10","findings":"3 medium found and resolved: estimand mismatch, one-cluster interval, insufficient bootstrap draws","files":["src/clustered-paired-binary.ts","src/statistics.ts","src/index.ts","tests/clustered-paired-binary.test.ts",".claude/skills/agent-eval/SKILL.md"]}
{"skill":"critical-audit","ts":"2026-07-09T22:35:41Z","project":"agent-eval","target":"clustered paired binary inference at abe1d89a11fe1b40b75d0e4d96ccf68dfa2403b1","verdict":"APPROVE","score":"8/10","findings":"1 low accepted risk: independent-package parity vector remains before paper use","files":["src/clustered-paired-binary.ts","src/statistics.ts","src/index.ts","tests/clustered-paired-binary.test.ts",".claude/skills/agent-eval/SKILL.md"]}
{"skill":"agent-eval","ts":"2026-07-09T22:27:32Z","project":"agent-eval","target":"immutable CodeSurface identity","outcome":"implemented content-addressed Git identity and fail-loud verification","branch":"feat/immutable-code-surface","model_runs":0}
{"skill":"harden","ts":"2026-07-09T22:27:32Z","project":"agent-eval","target":"CodeSurface Git trust boundary","outcome":"fixed inherited Git environment repository-redirection bypass; added real-Git adversarial coverage","branch":"feat/immutable-code-surface","model_runs":0}
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,27 @@ All notable changes to `@tangle-network/agent-eval` and its sibling `agent-eval-

---

## [0.113.0] — 2026-07-10 — immutable code candidates

### Changed

- `CodeSurface` is now a finalized, content-addressed code candidate.
`gitWorktreeAdapter.finalize()` records exact base/candidate commits, the final tree, and the SHA-256 + byte length of the raw binary Git patch; `surfaceHash` and `surfaceContentHash` no longer use filesystem paths.
Binary-patch generation runs against an isolated bare repository with fixed diff options, config, attributes, compression, and locale, so ambient repository, global, system, or environment settings cannot change the digest for identical trees.
- `LoopProvenanceRecord.schema` is now `tangle.loop-provenance.v2`, distinguishing records that use content-addressed code-surface hashes from v1 records whose code hashes included mutable paths.
- `resolveWorktreePath()` now verifies the candidate before returning its checkout.
Dirty or ignored files, moved refs, missing objects, wrong trees, raw byte or executable-mode mismatches, external symlinks, submodules, and patch-byte mismatches fail instead of being evaluated under stale identity.
Raw file hashing bypasses Git clean/smudge filters so repository configuration cannot hide different executable bytes.
Use `verifyCodeSurface()` when a verification receipt is needed directly.

### Breaking

- `resolveWorktreePath()` no longer returns a best-effort, unchecked locator: it verifies the finalized candidate and throws on any identity mismatch.
There is intentionally no lenient fallback on the evaluation path.
- Path-only `CodeSurface` objects are invalid.
Every field in the finalized identity is required.
Downstream callers must migrate to adapter-finalized candidates when adopting 0.113.0.

## [0.112.0] — 2026-07-10 — complete agent-surface findings

### Added
Expand Down
2 changes: 1 addition & 1 deletion clients/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "agent-eval-rpc"
version = "0.112.0"
version = "0.113.0"
description = "Python RPC client for @tangle-network/agent-eval — judge content against rubrics over HTTP or stdio RPC. Eval logic runs in the Node runtime; this package is a thin wire client."
readme = "README.md"
requires-python = ">=3.10"
Expand Down
2 changes: 1 addition & 1 deletion clients/python/src/agent_eval_rpc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
try:
__version__ = version("agent-eval-rpc")
except PackageNotFoundError:
__version__ = "0.112.0"
__version__ = "0.113.0"

__all__ = [
"Client",
Expand Down
2 changes: 1 addition & 1 deletion clients/python/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion docs/design/loop-taxonomy.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,13 @@ imports agent-eval and implements it.

`MutableSurface` is the thing the proposer changes. It has tiers, least → most
invasive. `MutableSurface = string | CodeSurface` spans all of them: `string`
for tiers 1–2, `CodeSurface{ worktreeRef }` for tier 4.
for tiers 1–2, and a finalized `CodeSurface` for tier 4. A code surface's
worktree path is only its locator; exact base/candidate commits, final tree,
and binary-patch digest are its portable identity. Call `verifyCodeSurface`
before executing the checkout so a moved ref or post-finalization mutation
fails before measurement. Verification hashes raw files and executable modes
without Git filters and rejects external symlinks or submodules whose bytes are
not represented by the candidate tree.

| Tier | Surface | Generator that changes it | Blast radius |
|---|---|---|---|
Expand Down
2 changes: 1 addition & 1 deletion docs/improvement-glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Grounded to `agent-eval/src/campaign/types.ts` unless noted.

| Term | Plain sentence | If you know DSPy/GEPA |
|---|---|---|
| **surface** | The one thing being changed this run — a prompt string, a JSON config string, or a code/worktree ref (`MutableSurface = string \| CodeSurface`, `types.ts:158`). | The optimized artifact: a signature/predictor's instruction text, or the module config. |
| **surface** | The one thing being changed this run — a prompt string, a JSON config string, or a finalized code candidate (`MutableSurface = string \| CodeSurface`, `types.ts`). | The optimized artifact: a signature/predictor's instruction text, or the module config. |
| **proposer** | The strategy that, given the current surface + what failed, proposes the next batch of candidate surfaces to measure — it does **not** run the agent or score anything (`SurfaceProposer.propose`, `types.ts:286`). | The optimizer / teleprompter (MIPRO, BootstrapFewShot, GEPA's reflective proposer). |
| **candidate** | One proposed surface plus its human `label` and `rationale`, ready to be measured (`ProposedCandidate`, `types.ts:166`). | One trial instruction/program the optimizer wants to evaluate. |
| **generation** | One round of *propose → measure → rank → promote*; the loop runs up to `maxGenerations` of them (`GenerationRecord`, `types.ts:549`). | One GEPA iteration / optimization step. |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tangle-network/agent-eval",
"version": "0.112.0",
"version": "0.113.0",
"description": "Evaluate and improve AI agents from runs, traces, judges, and feedback. Compare candidates, cluster failures, measure lift, and gate releases.",
"homepage": "https://github.com/tangle-network/agent-eval#readme",
"repository": {
Expand Down
13 changes: 11 additions & 2 deletions scripts/verify-package-exports.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,17 @@ try {
throw new Error('missing belief-state export analyzeBeliefPolicy')
}
const campaign = await import('@tangle-network/agent-eval/campaign')
if (!('compositeProposer' in campaign)) {
throw new Error('missing campaign export compositeProposer')
for (const name of [
'compositeProposer',
'gitWorktreeAdapter',
'verifyCodeSurface',
'resolveWorktreePath',
'assertCodeSurfaceIdentity',
'codeSurfaceIdentityMaterial',
'surfaceHash',
'surfaceContentHash',
]) {
if (!(name in campaign)) throw new Error('missing campaign export ' + name)
}
`,
],
Expand Down
6 changes: 3 additions & 3 deletions src/campaign/analyst-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ describe('buildAnalystSurfaceDispatch — runs the analyst with the surface as a

it('fails loud if handed a code-tier surface (analyst prompt is prompt-tier)', async () => {
const dispatch = buildAnalystSurfaceDispatch({ analystOptions: { ai: {} as never } })
await expect(dispatch({ kind: 'code', worktreeRef: 'wt/abc' }, scenario, ctx)).rejects.toThrow(
/prompt-tier/,
)
await expect(
dispatch({ kind: 'code', worktreeRef: 'wt/abc' } as never, scenario, ctx),
).rejects.toThrow(/prompt-tier/)
})
})

Expand Down
7 changes: 7 additions & 0 deletions src/campaign/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,11 @@ export {
type SkillPatchRejection,
} from './skill-patch'
export { type CampaignStorage, fsCampaignStorage, inMemoryCampaignStorage } from './storage'
// ── Code-surface content identity ────────────────────────────────────
export {
assertCodeSurfaceIdentity,
codeSurfaceIdentityMaterial,
} from './surface-identity'
export { isTransientTransportFailure, type TransientFailureOptions } from './transient-failure'
export type {
CampaignAggregates,
Expand Down Expand Up @@ -331,9 +336,11 @@ export type {
export { isProposedCandidate, labelTrustRank } from './types'
// ── Worktree adapter (VCS-pluggable; code-tier surfaces) ─────────────
export {
type CodeSurfaceVerification,
type GitWorktreeAdapterOptions,
gitWorktreeAdapter,
resolveWorktreePath,
verifyCodeSurface,
type Worktree,
type WorktreeAdapter,
WorktreeAdapterError,
Expand Down
19 changes: 2 additions & 17 deletions src/campaign/presets/run-optimization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@
* re-score + release gate + optional PR.
*/

import { createHash } from 'node:crypto'
import { type Objective, paretoFrontier } from '../../pareto'
import { type RunCampaignOptions, runCampaign } from '../run-campaign'
import { campaignBreakdown, campaignMeanComposite } from '../score-utils'
import { surfaceHash } from '../surface-identity'
import {
type CampaignResult,
type GenerationRecord,
Expand Down Expand Up @@ -355,19 +355,4 @@ function computeParetoFrontier(scored: ParetoParent[]): ParetoParent[] {
return paretoFrontier(scored, objectives).frontier
}

/**
* Short (16-char) sha256 fingerprint of a `MutableSurface`: hashes text content for prompt surfaces, or the worktree + base ref pair for code surfaces.
*/
export function surfaceHash(surface: MutableSurface): string {
// Prompt/tool surfaces (string) hash by content; code surfaces hash by the
// worktree + base ref pair (the content lives in git, not in the string).
const material =
typeof surface === 'string'
? surface
: JSON.stringify({
kind: surface.kind,
worktreeRef: surface.worktreeRef,
baseRef: surface.baseRef ?? null,
})
return createHash('sha256').update(material).digest('hex').slice(0, 16)
}
export { surfaceHash } from '../surface-identity'
36 changes: 35 additions & 1 deletion src/campaign/proposers/composite.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import type { ProposeContext, SurfaceProposer } from '../types'
import type { CodeSurface, ProposeContext, SurfaceProposer } from '../types'
import { compositeProposer } from './composite'

function ctxOf(populationSize: number): ProposeContext {
Expand Down Expand Up @@ -32,6 +32,23 @@ function stub(
}
}

function codeSurface(worktreeRef: string): CodeSurface {
return {
kind: 'code',
worktreeRef,
baseRef: 'main',
baseCommit: '1'.repeat(40),
baseTree: '2'.repeat(40),
candidateCommit: '3'.repeat(40),
candidateTree: '4'.repeat(40),
patch: {
format: 'git-diff-binary',
sha256: `sha256:${'5'.repeat(64)}`,
byteLength: 1,
},
}
}

describe('compositeProposer (N proposers, one generation pool)', () => {
it('splits the population budget and merges candidates with member provenance', async () => {
const composite = compositeProposer({
Expand Down Expand Up @@ -69,6 +86,23 @@ describe('compositeProposer (N proposers, one generation pool)', () => {
expect(sameLabel.startsWith('a:')).toBe(true)
})

it('dedupes content-identical code surfaces across different worktree paths', async () => {
const proposer = (kind: string, surface: CodeSurface): SurfaceProposer => ({
kind,
propose: async () => [{ surface, label: 'v', rationale: 'r' }],
})
const first = codeSurface('/tmp/candidate-a')
const sameBytesElsewhere = codeSurface('/tmp/candidate-b')
const composite = compositeProposer({
proposers: [proposer('a', first), proposer('b', sameBytesElsewhere)],
})

const pool = await composite.propose(ctxOf(2))

expect(pool).toHaveLength(1)
expect((pool[0] as { surface: CodeSurface }).surface.worktreeRef).toBe(first.worktreeRef)
})

it('isolates a failing member; throws only when ALL members fail', async () => {
const oneDown = compositeProposer({
proposers: [stub('boom', [], { fail: true }), stub('ok', ['x1', 'x2'])],
Expand Down
3 changes: 2 additions & 1 deletion src/campaign/proposers/composite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
* component attribution — see the experiment-optimal research brief.
*/

import { surfaceContentHash } from '../surface-identity'
import type {
GenerationRecord,
MutableSurface,
Expand Down Expand Up @@ -99,7 +100,7 @@ export function compositeProposer<TFindings = unknown>(
: (proposal as MutableSurface)
const label = isCandidate ? (proposal as ProposedCandidate).label : 'candidate'
const rationale = isCandidate ? (proposal as ProposedCandidate).rationale : ''
const key = typeof surface === 'string' ? surface : JSON.stringify(surface)
const key = surfaceContentHash(surface)
if (seen.has(key)) continue
seen.add(key)
pool.push({ surface, label: `${member.kind}:${label}`, rationale })
Expand Down
6 changes: 4 additions & 2 deletions src/campaign/proposers/policy-edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
policyEditsFromFindings,
} from '../../analyst/policy-edit'
import type { AnalystFinding } from '../../analyst/types'
import { assertCodeSurfaceIdentity, surfaceContentHash } from '../surface-identity'
import type { MutableSurface, ProposeContext, ProposedCandidate, SurfaceProposer } from '../types'

export interface PolicyEditProposerOptions {
Expand Down Expand Up @@ -108,13 +109,14 @@ function coerceCandidateSurface(surface: unknown): MutableSurface {
if (surface && typeof surface === 'object') {
const obj = surface as Record<string, unknown>
if (obj.kind === 'code' && typeof obj.worktreeRef === 'string') {
return surface as MutableSurface
assertCodeSurfaceIdentity(surface)
return surface
}
return JSON.stringify(surface, null, 2)
}
throw new Error('policyEditProposer: policy edit produced an unsupported surface')
}

function sameSurface(a: MutableSurface, b: MutableSurface): boolean {
return JSON.stringify(a) === JSON.stringify(b)
return surfaceContentHash(a) === surfaceContentHash(b)
}
22 changes: 4 additions & 18 deletions src/campaign/provenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,24 +37,10 @@ import type {
import { summarizeBackendIntegrity } from '../integrity/backend-integrity'
import type { RunRecord } from '../run-record'
import type { CampaignStorage } from './storage'
import { surfaceContentHash } from './surface-identity'
import type { CampaignResult, GateDecision, GateResult, MutableSurface, Scenario } from './types'

/** Stable sha256 (full hex) of a surface's effective text. Code surfaces hash
* their worktree+base identity since the content lives in git. Distinct from
* `surfaceHash` (16-char content fingerprint used as a loop identity key);
* this is the byte-identical-verifiable content hash the provenance record +
* `RunRecord.promptHash` carry. */
export function surfaceContentHash(surface: MutableSurface): string {
const material =
typeof surface === 'string'
? surface
: JSON.stringify({
kind: surface.kind,
worktreeRef: surface.worktreeRef,
baseRef: surface.baseRef ?? null,
})
return `sha256:${createHash('sha256').update(material).digest('hex')}`
}
export { surfaceContentHash } from './surface-identity'

export interface LoopProvenanceCandidate {
/** Generation index this candidate was proposed in. */
Expand Down Expand Up @@ -92,7 +78,7 @@ export interface LoopProvenanceBackend {
* the bare hosted event) + backend provenance.
*/
export interface LoopProvenanceRecord {
schema: 'tangle.loop-provenance.v1'
schema: 'tangle.loop-provenance.v2'
runId: string
runDir: string
timestamp: string
Expand Down Expand Up @@ -200,7 +186,7 @@ export function buildLoopProvenanceRecord<TArtifact, TScenario extends Scenario>
const winnerHoldoutComposite = meanHoldoutComposite(args.winnerOnHoldout)

const record: LoopProvenanceRecord = {
schema: 'tangle.loop-provenance.v1',
schema: 'tangle.loop-provenance.v2',
runId: args.runId,
runDir: args.runDir,
timestamp: args.timestamp,
Expand Down
Loading
Loading