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 docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ by an exact pass/fail decision, and records its outcome; the judge is just
another actor whose verdicts land on the bus as ordinary `agent.decision`
messages, and denied actions never execute. AgentV supplies objective
evidence; judge rejection never invents feedback. Teacher feedback guides the
student, and judge-approved adversarial challenges require the teacher to
student, and judge-approved adversarial challenges require the adversary to
improve the rubric.
Agent and skill lifecycle management is deliberately outside the harness.
Consumers may evolve agents independently and inject the resulting callbacks
Expand Down
6 changes: 2 additions & 4 deletions packages/harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ Every other role has a default, and all defaults follow one evidence
convention — feedback is the verdict:

- **judge** accepts any input and returns only `pass` or `fail`. Unset, a candidate passes when the teacher reports no feedback, a challenge stands when the adversary reports evidence, and actions are logged ungated.
- **adversary** receives only the artifact under test and its own prior messages, and reports `{ challenge, feedback }`. Unset, a passing candidate is accepted without adversarial review.
- **reviseRubric** tightens the rubric after a standing challenge. Unset, the challenge evidence is appended as new criteria.
- **adversary** is a config of its own: its required `challenge` callback receives only the artifact under test and its own prior messages, and reports `{ challenge, feedback }`; its optional `reviseRubric` callback tightens the rubric after a standing challenge — unset, the challenge evidence is appended as new criteria. With no adversary at all, a passing candidate is accepted without adversarial review.
- **bus** defaults to an in-memory write-ahead bus, returned on the run result for auditing.

The harness does not create, configure, or select agents — no models, prompts,
Expand Down Expand Up @@ -63,8 +62,7 @@ const result = await harness.run({
student: myStudent,
teacher: myTeacher,
judge: myJudge,
adversary: myAdversary,
reviseRubric: myRubricRevision,
adversary: { challenge: myAdversary, reviseRubric: myRubricRevision },
});
```

Expand Down
131 changes: 68 additions & 63 deletions packages/harness/src/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,40 @@ export interface RubricRevision<TFeedback> {
readonly feedback: readonly TFeedback[];
}

/** The challenge turn is deliberately narrow: the adversary sees the task and
* only its own prior messages, never the teacher's assessment or the rubric. */
export interface AdversaryTurn {
readonly task: unknown;
readonly context: readonly AgentBusEntry[];
readonly signal?: AbortSignal;
}

export interface RubricRevisionTurn<TCandidate, TAssessment> {
readonly task: unknown;
readonly candidate: TCandidate;
readonly assessment: TAssessment;
readonly rubric: string;
readonly context: readonly AgentBusEntry[];
readonly signal?: AbortSignal;
}

/** The adversary role in full. Challenging accepted candidates and tightening
* the rubric when a challenge stands are one responsibility, so both callbacks
* live here; only the challenge is required. */
export interface AdversaryConfig<TCandidate, TAssessment, TFeedback, TChallenge> {
/** Challenges a candidate the judge accepted. */
readonly challenge: (
candidate: TCandidate,
turn: AdversaryTurn,
) => AdversaryResult<TChallenge, TFeedback> | Promise<AdversaryResult<TChallenge, TFeedback>>;
/** Revises the rubric after a standing challenge; when unset the challenge
* evidence is appended to the rubric as new criteria. */
readonly reviseRubric?: (
challenge: AdversaryResult<TChallenge, TFeedback>,
turn: RubricRevisionTurn<TCandidate, TAssessment>,
) => RubricRevision<TFeedback> | Promise<RubricRevision<TFeedback>>;
}

/** Every request carries the evidence the default verdicts weigh, so a
* configured judge can apply the same convention even when its bus context is
* windowed or redacted. */
Expand Down Expand Up @@ -93,25 +127,10 @@ export interface HarnessInput<TCandidate, TAssessment, TFeedback, TChallenge> {
readonly judge?: (
request: JudgeRequest<TCandidate, TAssessment, TFeedback, TChallenge>,
) => JudgeDecision | Promise<JudgeDecision>;
/** Challenges candidates the judge accepted. When unset, a passing
* candidate is accepted without adversarial review. */
readonly adversary?: (
candidate: TCandidate,
turn: Readonly<{ task: unknown; context: readonly AgentBusEntry[]; signal?: AbortSignal }>,
) => AdversaryResult<TChallenge, TFeedback> | Promise<AdversaryResult<TChallenge, TFeedback>>;
/** Revises the rubric after a standing challenge; when unset the challenge
* evidence is appended to the rubric as new criteria. */
readonly reviseRubric?: (
challenge: AdversaryResult<TChallenge, TFeedback>,
turn: Readonly<{
task: unknown;
candidate: TCandidate;
assessment: TAssessment;
rubric: string;
context: readonly AgentBusEntry[];
signal?: AbortSignal;
}>,
) => RubricRevision<TFeedback> | Promise<RubricRevision<TFeedback>>;
/** Challenges candidates the judge accepted, and optionally revises the
* rubric when a challenge stands. When unset, a passing candidate is
* accepted without adversarial review. */
readonly adversary?: AdversaryConfig<TCandidate, TAssessment, TFeedback, TChallenge>;
/** Shapes bus history into actor context; full history when unset. */
readonly contextProvider?: ContextProvider;
readonly signal?: AbortSignal;
Expand Down Expand Up @@ -142,12 +161,16 @@ export function defineTrainingHarness<TCandidate, TAssessment, TFeedback>(
const bus = input.bus ?? new WriteAheadAgentBus();
const provide = input.contextProvider ?? fullHistory;
const judge = input.judge;
const revise = input.reviseRubric ?? appendCriteria;
let rubric: string = rubricText.parse(input.rubric);
const rounds: HarnessRound<TCandidate, TAssessment, TChallenge>[] = [];
let feedback: readonly TFeedback[] = [];
let previousCandidate: string | undefined;

// Factories for the shapes every turn rebuilds: the optional abort
// signal and provider-shaped context.
const signal = input.signal === undefined ? {} : { signal: input.signal };
const contextOf = async (actor?: string) => provide(await bus.read(actor));

// Every actor invocation is written ahead; a configured judge also
// gates it, and every verdict — the judge's or the evidence
// convention's — lands on the bus as an ordinary judge message.
Expand All @@ -173,12 +196,7 @@ export function defineTrainingHarness<TCandidate, TAssessment, TFeedback>(
for (let round = 1; round <= maxRounds; round += 1) {
input.signal?.throwIfAborted();
const turn: StudentTurn<TFeedback> = Object.freeze({
round,
task: input.task,
rubric,
feedback,
context: await provide(await bus.read()),
...(input.signal === undefined ? {} : { signal: input.signal }),
round, task: input.task, rubric, feedback, context: await contextOf(), ...signal,
});
const candidate = await dispatch(actors.student, kinds.propose, { round, task: input.task, rubric, feedback },
() => input.student(turn));
Expand All @@ -190,67 +208,54 @@ export function defineTrainingHarness<TCandidate, TAssessment, TFeedback>(
const assessment = await dispatch(actors.teacher, kinds.assess, { round, candidateId },
() => input.teacher(candidate, turn));
input.signal?.throwIfAborted();
const candidateDecision = await decide({ candidateId }, {
subject: "candidate",
task: input.task,
candidate,
assessment: assessment.assessment,
feedback: assessment.feedback,
rubric,
context: [],
}, () => assessment.feedback.length === 0 ? "pass" : "fail");
// The evidence both verdict requests carry, and one factory for
// every shape a recorded round takes.
const evidence = { task: input.task, candidate, rubric, context: [] } as const;
const candidateDecision = await decide({ candidateId },
{ subject: "candidate", ...evidence, assessment: assessment.assessment, feedback: assessment.feedback },
() => assessment.feedback.length === 0 ? "pass" : "fail");
const record = (challenged?: Readonly<{ challenge: TChallenge; decision: JudgeDecision }>) =>
rounds.push(Object.freeze({
round, candidate, assessment: assessment.assessment, judgeDecision: candidateDecision,
...(challenged === undefined ? {} : { adversary: Object.freeze(challenged) }), rubric,
}));

if (candidateDecision === "fail") {
rounds.push(Object.freeze({ round, candidate, assessment: assessment.assessment, judgeDecision: candidateDecision, rubric }));
record();
feedback = Object.freeze([...assessment.feedback]);
continue;
}

const adversary = input.adversary;
if (adversary === undefined) {
rounds.push(Object.freeze({ round, candidate, assessment: assessment.assessment, judgeDecision: candidateDecision, rubric }));
record();
return result("accepted");
}

const challenge = await dispatch(actors.adversary, kinds.challenge, { candidateId }, async () =>
adversary(candidate, {
task: input.task,
context: await provide(await bus.read(actors.adversary)),
...(input.signal === undefined ? {} : { signal: input.signal }),
}));
adversary.challenge(candidate, { task: input.task, context: await contextOf(actors.adversary), ...signal }));
input.signal?.throwIfAborted();
const challengeDecision = await decide({ candidateId }, {
subject: "adversary",
task: input.task,
candidate,
challenge: challenge.challenge,
feedback: challenge.feedback,
rubric,
context: [],
}, () => challenge.feedback.length > 0 ? "pass" : "fail");
const challengeDecision = await decide({ candidateId },
{ subject: "adversary", ...evidence, challenge: challenge.challenge, feedback: challenge.feedback },
() => challenge.feedback.length > 0 ? "pass" : "fail");

if (challengeDecision === "fail") {
rounds.push(Object.freeze({ round, candidate, assessment: assessment.assessment, judgeDecision: candidateDecision,
adversary: Object.freeze({ challenge: challenge.challenge, decision: challengeDecision }), rubric }));
record({ challenge: challenge.challenge, decision: challengeDecision });
return result("accepted");
}

const revision = await dispatch(actors.teacher, kinds.reviseRubric, { round, candidateId }, async () =>
const revise = adversary.reviseRubric ?? appendCriteria;
const revision = await dispatch(actors.adversary, kinds.reviseRubric, { round, candidateId }, async () =>
revise(challenge, {
task: input.task,
candidate,
assessment: assessment.assessment,
rubric,
context: await provide(await bus.read()),
...(input.signal === undefined ? {} : { signal: input.signal }),
task: input.task, candidate, assessment: assessment.assessment, rubric,
context: await contextOf(), ...signal,
}));
input.signal?.throwIfAborted();
const revised: string = rubricText.parse(revision.rubric);
if (revised === rubric) throw new Error("teacher must improve the rubric after an approved adversarial challenge");
if (revised === rubric) throw new Error("adversary must improve the rubric after a standing challenge");
rubric = revised;
feedback = Object.freeze([...revision.feedback]);
rounds.push(Object.freeze({ round, candidate, assessment: assessment.assessment, judgeDecision: candidateDecision,
adversary: Object.freeze({ challenge: challenge.challenge, decision: challengeDecision }), rubric }));
record({ challenge: challenge.challenge, decision: challengeDecision });
}

return result("exhausted");
Expand Down
3 changes: 3 additions & 0 deletions packages/harness/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@ export type { AbsolutePath, AgentBusEntry, AgentMessage } from "./schema.js";

export { defaultMaxRounds, defineTrainingHarness } from "./harness.js";
export type {
AdversaryConfig,
AdversaryResult,
AdversaryTurn,
ContextProvider,
HarnessInput,
HarnessRound,
HarnessRun,
HarnessSettings,
JudgeRequest,
RubricRevision,
RubricRevisionTurn,
StudentTurn,
TeacherResult,
TrainingHarness,
Expand Down
27 changes: 15 additions & 12 deletions packages/harness/test/harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe("training harness", () => {
rubric: "Initial rubric",
student: ({ round }) => `candidate-${round}`,
teacher: () => ({ assessment: "evidence", feedback: [] }),
adversary: () => ({ challenge: "challenge", feedback: evidence.shift() ?? [] }),
adversary: { challenge: () => ({ challenge: "challenge", feedback: evidence.shift() ?? [] }) },
});

// Round one's challenge stood and tightened the rubric by default;
Expand All @@ -57,7 +57,7 @@ describe("training harness", () => {
it("uses one callback loop for teacher feedback, judge decisions, and adversarial review", async () => {
const callbacks = await loopCallbacks(["fail", "pass", "fail"]);
const student = vi.fn(({ round }) => `candidate-${round}`);
const adversary = vi.fn(() => ({ challenge: "counterexample", feedback: [] }));
const challenge = vi.fn(() => ({ challenge: "counterexample", feedback: [] }));
const harness = defineTrainingHarness<string, string, string>({ maxRounds: 2 });

const result = await harness.run({
Expand All @@ -66,14 +66,13 @@ describe("training harness", () => {
rubric: "Candidate must be correct",
student,
teacher: () => ({ assessment: "evidence", feedback: ["teacher-only feedback"] }),
adversary,
reviseRubric: () => ({ rubric: "unused", feedback: [] }),
adversary: { challenge, reviseRubric: () => ({ rubric: "unused", feedback: [] }) },
});

expect(result.outcome).toBe("accepted");
expect(student.mock.calls[1]?.[0].feedback).toEqual(["teacher-only feedback"]);
expect(student.mock.calls[1]?.[0].context.length).toBeGreaterThan(0);
expect(adversary).toHaveBeenCalledOnce();
expect(challenge).toHaveBeenCalledOnce();
expect(result.final.adversary).toEqual({ challenge: "counterexample", decision: "fail" });
// The judge is just another actor: its verdicts are ordinary messages.
const judgeEntries = await callbacks.bus.read("judge");
Expand All @@ -96,7 +95,7 @@ describe("training harness", () => {
controller.abort();
return { assessment: null, feedback: [] };
},
adversary: () => ({ challenge: "challenge", feedback: [] }),
adversary: { challenge: () => ({ challenge: "challenge", feedback: [] }) },
})).rejects.toThrow();
});

Expand All @@ -112,11 +111,13 @@ describe("training harness", () => {
rubric: "Check tests",
student: () => "candidate",
teacher,
adversary: (_candidate, turn) => {
expect(JSON.stringify(turn)).not.toMatch(/teacher|rubric|student/i);
return { challenge: "edge-case failure", feedback: ["handle edge case"] };
adversary: {
challenge: (_candidate, turn) => {
expect(JSON.stringify(turn)).not.toMatch(/teacher|rubric|student/i);
return { challenge: "edge-case failure", feedback: ["handle edge case"] };
},
reviseRubric,
},
reviseRubric,
});

expect(result.outcome).toBe("exhausted");
Expand Down Expand Up @@ -196,8 +197,10 @@ describe("training harness", () => {
return `candidate-${round}`;
},
teacher: () => ({ assessment: "evidence", feedback: [] }),
adversary: () => ({ challenge: "challenge", feedback: [] }),
reviseRubric: () => ({ rubric: "revised", feedback: [] }),
adversary: {
challenge: () => ({ challenge: "challenge", feedback: [] }),
reviseRubric: () => ({ rubric: "revised", feedback: [] }),
},
});

// By round two the bus holds many entries; the provider windowed them to one.
Expand Down
26 changes: 14 additions & 12 deletions src/providers/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,23 +52,19 @@ export function createHarnessLoop(options: HarnessLoopOptions = {}): TrainingLoo
...(options.judge === undefined ? {} : { judge: options.judge }),
task: { trainable: input.trainableId, objective: input.objective },
rubric: input.rubric,
...(input.signal === undefined ? {} : { signal: input.signal }),
...maybeSignal(input.signal),
// The governed harness explores one candidate per round; fan-out stays 1.
student: ({ round, feedback, signal }) =>
input.propose({ round, slot: 1, feedback, ...(signal === undefined ? {} : { signal }) }),
input.propose({ round, slot: 1, feedback, ...maybeSignal(signal) }),
teacher: async (candidate, { round, signal }) => {
const review = await input.review(candidate, {
label: `candidate-${round}`,
...(signal === undefined ? {} : { signal }),
});
const review = await input.review(candidate, { label: `candidate-${round}`, ...maybeSignal(signal) });
return { assessment: review, feedback: review.decision.failures };
},
adversary: async (candidate, { signal }) => {
const challenge = await input.review(candidate, {
label: `adversary-${candidate.id}`,
...(signal === undefined ? {} : { signal }),
});
return { challenge, feedback: challenge.decision.failures };
adversary: {
challenge: async (candidate, { signal }) => {
const challenge = await input.review(candidate, { label: `adversary-${candidate.id}`, ...maybeSignal(signal) });
return { challenge, feedback: challenge.decision.failures };
},
},
});
return {
Expand All @@ -77,3 +73,9 @@ export function createHarnessLoop(options: HarnessLoopOptions = {}): TrainingLoo
};
};
}

/** Spreads an abort signal only when one exists, so optional-property types
* never receive an explicit `undefined`. */
function maybeSignal(signal: AbortSignal | undefined): { signal: AbortSignal } | Record<never, never> {
return signal === undefined ? {} : { signal };
}
Loading