Repository navigation
[feat] Add workflow scorers and recorded quality labels #1085
Copy link
Copy link
Closed
Description
Parent: #818
Goal
Produce comparable quality labels across drivers without coupling scoring to branch delivery or a specific runtime.
How it works
# scorers/review-quality.md
---
labels:
- { value: good, score: 1, description: Finds the real defects and nothing else }
- { value: noisy, score: 0.5, description: Finds them, plus nits }
- { value: missed, score: 0, description: Misses a real defect }
passingScore: 0.5
samplingRate: 25
model: strong
---
Judge the review against the diff. Answer with one label.# workflows/review-and-fix.yaml (one step)
- id: review
agent: reviewer
scorers: [review-quality] # or { names: [review-quality], batch: true }$ teamai workflow start review-and-fix --driver local # the run settles; scoring never holds it
$ teamai score --next --json '{"driver":{"kind":"local","machine":"m1","can":{"judge":{"isolation":"verified","tools":["claude"],"access":"read"}}}}'
{"data":{"lease":{"ref":"s5d1e07c2a9b4/a91c0d7e","subject":{"runId":"r3f9","step":"review","pass":1},
"evidence":"<dataHome>/scores/evidence/a91c0d7e/","answerSchema":{…},
"submit":"teamai score --lease s5d1e07c2a9b4/a91c0d7e --submit --stdin"}}}
$ teamai score --lease s5d1e07c2a9b4/a91c0d7e --json '{"driver":{…}}' # judge here, local profile
$ teamai score --lease s5d1e07c2a9b4/a91c0d7e --submit --stdin < answer.json # or any driver's own judge
$ teamai score --run r3f9 --step review --pass 1 --scorer review-quality [--queue] # on demandworkflow next ──► never hands out judge work
score --next ──► judge lease (any driver with can.judge) ──► evidence dir ──► submit ──► label store
scores/<member>/<YYYY-MM>.jsonl
Scope
scorers/<name>.mddefinitions (frontmatter plus prompt;README.mdignored, other files are errors) and thescorers:step key in its short and long forms.- Deterministic sampling of succeeded passes of the steps that list a scorer.
- Open scoring work is derived from the run, never stored in it: no end, break, loop, count,
failureor work-token total reaches it, and the run settles without waiting. teamai score --next | --lease | --submit | --run … --pass … --scorer … [--queue]. A judge lease settles only through--submit;workflow reportrejects it.- Evidence directory per attempt, redacted as a whole:
diff.patch,transcripts/<step>#<pass>.mdincluding subagents,repo/(tracked files at the judged commit, no symlinks). A read-only step's diff is its recordedread.base..read.commit. - Judge profiles per tool, their verification, and an isolation policy (team
scoring.isolation, scorerisolation, machinescoringIsolation; strictest wins; defaultbest-effort;teamai doctorshows it). - Labels go to one label store shared with [feat] Score a finished session on demand #1178, with a keyed outbox:
{ scorer, rubric: { sourceHash, teamRevision? }, label, score, passed, source: "workflow", subject: { runId, step, pass }, judge: { kind: "model", tool, model?, usage?, isolation, access, batched? } | { kind: "human" }, scoredAt }. Key(scorer, rubric.sourceHash, subject, judge.kind); the newestscoredAtwins. Reasoning stays local in<dataHome>/scores/reasoning/<labelVersion>.md, one file per verdict (labelVersionis the sha256 of the label), so a rescore never overwrites earlier reasoning. - Workflow Runs shows
{scorer, label, passed}per pass and a separate scoring total. - Unknown
scorers:names:failinteamai review --branch; a warning at pull, and the step runs without that scorer.
Out of scope
PR delivery, automatic prompt rewriting, automatic improvement PRs, arbitrary judge code, scorer access to secrets, and whole-run scoring (#1247).
Dependencies
- Blocked by: [feat] Run prompt and agent steps with the local driver #1077, [feat] Add DAG conditions and composable sub-workflows #1078, [feat] Add workflow workspaces, branches, and session continuation #1080, [feat] Publish privacy-preserving workflow run records and stats #1083, [feat] Define the team revision once for run records, labels and snapshots #1163, [feat] Close the #1080 gaps that #1175 leaves open #1246 (the recorded review range, F4)
- Builds on: feat(workflow): publish and resume workflow runs across machines (#1084) #1280 (publish and resume runs)
- Unblocks: [feat] Score a finished session on demand #1178, [feat] Run benchmark suites as workflows #1180, [feat] Score what a workflow run delivered #1247, [feat] Validate workflows, prompts and scorers in review --branch #1248
Acceptance criteria
- Sampling is deterministic for a run/step/pass/scorer tuple.
workflow nextnever returns judge work;teamai score --nextdoes, only to a driver whosecan.judgemeets the required isolation.- A run with a sampled scorer that fails or spends tokens has the same
stepCounts,iterations,failureand work token total as the same run withsamplingRate: 0. - A phrase only the judge writes appears in no file under the run directory and in no published record.
- A label outside the scorer's list is rejected and the lease stays open.
- Unavailable scorer models follow the documented policy without changing the judged result.
- Labels from every driver and from [feat] Score a finished session on demand #1178 share one type and one store.
- A hostile fixture transcript gets every forbidden judge action denied under each
verifiedprofile; a tool that does not honor its profile isbest-effort. - No PR or provider capability is required.
- A branch with a malformed scorer or an unknown
scorers:name failsteamai review --branchwith exit 1 and afailfinding.
Verification
Focused scorer parsing, sampling, leasing, label, failure, and redaction tests; build/typecheck/lint; one real-CLI E2E with a fixture scorer and a real judge. Bilingual docs/guide/scorers.md, with pointers from the workflow pages.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
SaulMoro commentedon Oct 13, 2026
The scorer reads the diff of the step it judges, which needs the branch or workspace from #1080. Maybe add #1080 to blocked-by?
hsuchifeng commentedon Oct 14, 2026
Yes. A scorer reads the diff of the step it judges, so this issue is also blocked by #1080.
SaulMoro commentedon Oct 14, 2026
Warp's scorers judge a whole agent run, with its transcript, tool calls and outputs, rather than one step. Some suggestions from comparing the two:
SubagentStop, asvotes-judge.tsalready does. Its input would be the task, the output, the transcript when the driver has one, and the diff when there is a workspace. Reports from feat(workflow): run prompt and agent steps with the local driver #1120 already carry the session id. A transcript reference next to it, read only by the driver that wrote it (a file path forlocal, a thread id for a T3 Code driver), would show the judge what the agent did and not only the diff.scorer.md:labelsas a list of{ value, score, description },samplingRatefrom 0 to 100 with 25 as the default, between 1 and 20 labels, and at least one label at or abovepassingScoreand one below it. Teams coming from Warp could then bring their scorers.model, and a prompt step withoutagentcould use the same key. It would take what an agent'smodeltakes, an alias (Proposal: model aliases so one agent works in every tool #830) or a model name.nextruns where the driver runs, so it could resolve the alias withresolveAgentModelfor the driver's tool and return{ model, effort }. With nomodel, the tool's default applies and the driver does not pick one.SaulMoro commentedon Oct 14, 2026
Notes that affect scorers:
needs. Declaration order no longer implies a dependency (needs: []declares another root), so "the edge a scorer judges" is aneedsedge.pending | leased | running | succeeded | failed | skipped;runningis aworkflowstep whose child steps are in progress. Onlysucceededagent steps are candidates; steps inside sub-workflows are addressed by path (ship.check.classify).stopped()isfailedtoday), and dependents of a stopped step are skipped. A failed scorer step counted like a normal step would fail the run or skip the judged step's dependents, which breaks "scorer failure does not rewrite the judged step's outcome".workflowstep would be the next lease; a materialized scorer step can follow the same pattern (insert after the judged step, lease throughnext).success | failed | released.skippedand named ends are decided by the engine, not reported.session: { from: <step> }uses in [feat] Add workflow workspaces, branches, and session continuation #1080, and always redacted (the redactionteamai session migrate --scrubapplies) before it reaches the scorer prompt, since secrets stay out of scope. Records would still keep only the scorer name and label. If this belongs in a follow-up instead, I can file it.AgentSession.findreturns the session redacted by default.AgentSession.transcriptrenders one text entry per message for a judge's prompt, with tool results kept apart from what the member and the agent wrote. A scorer that counts tool calls reads them fromsession.messages, which has the same shape for every tool. Tokens, interruptions and corrections are already in the dashboard events under the same session id.AgentSession.listlists sessions across tools.{ scorer, label, score, passed, source: 'workflow' | 'session', subject, agent?, tool, model?, modelAlias?, teamRevision, scoredAt }.subjectis{ runId, step }for a step. Workflow labels go in the run record ([feat] Publish privacy-preserving workflow run records and stats #1083).passedis stored because a later change topassingScorewould otherwise rewrite old pass rates.teamRevisionshows which version of the rubric gave each label. Prompts, diffs, transcripts and the judge's reasoning are never published; the reasoning stays in the run state. Sampling ishash(scorer, subject) mod 10000 < samplingRate × 100, so a rerun replaces the label instead of adding one.The engine rules that span files are in
docs/dev/workflow-engine.md, added by #1129.hsuchifeng commentedon Oct 15, 2026
A scorer here judges one succeeded agent step, not a whole run. Whole-run scoring stays in #1137.
The step diff and its redacted session are both inputs. The session is resolved like
session: { from: <step> }and redacted liketeamai session migrate --scrub. Prompts, diffs, transcripts, and reasoning stay local.Use Warp's
scorer.md: 1–20 labels of{ value, score, description },samplingRate0–100 (default 25), with at least one label at or abovepassingScoreand one below.modelis an alias or a model name; omit it to use the tool default.Lock the label record now:
{ scorer, label, score, passed, source: "workflow" | "session", subject, agent?, tool, model?, modelAlias?, teamRevision, scoredAt }
subjectis{ runId, step }. Sampling ishash(scorer, subject) mod 10000 < samplingRate × 100. A failed scorer does not change the judged step or the run.SaulMoro commentedon Oct 15, 2026
Because the label record carries
teamRevision, it now uses the definition in #1163:HEADat scoring time.HEADcan hold a newer scorer that failed validation, or an unpushed commit no teammate can resolve.teamRevisionout rather than guessing.Acceptance criteria to add:
git cat-file -e <teamRevision>succeeds for a published label, andgit show <teamRevision>:scorers/<name>.mdis the rubric that scored it.teamRevision.I also added #1080 as a native blocker. The body and the comment above already list it, but the link was missing.
SaulMoro commentedon Oct 16, 2026
Two things from #1148 that scorers run into:
passesandrun.archive. Earlier passes of a step move into itspasses, and the passes of children and items a loop ran again move torun.archive. Picking succeeded agent steps fromrun.stepsalone sees only the last pass of every loop.endor abreak,stopWhereskips every pending step under it and cancels open leases. A scorer step inserted after the judged step, in the same workflow, is skipped whenever the judged step itself ends that workflow, such as a step withend: done. The scorer step needs a place an end does not reach. (Resolved in a later comment: scorer work is kept apart fromrun.steps.)SaulMoro commentedon Oct 16, 2026
Three optional changes to the label, and two notes, on the judge's model and on where the code lives. They are not conditions for shipping this issue: its scope, criteria and blockers stay as they are. If it lands first, #1137's issues adapt to what it shipped and keep reading the labels it published.
agent,tool,modelandmodelAlias. In records, labels and stats,agentwill mean what a step ran as: the definition and how the tool applied it, the tool and its version, model and effort as requested and as reported, and the team revision delivered to the checkout ([feat] Record the agent each workflow step ran as #1176). The YAML keyagent:still selects a definition. A workflow label sits in the run record next to the pass it judges, so readers can take all of that from the pass once [feat] Record the agent each workflow step ran as #1176 records it. Until then, keeping the flat fields is fine.subjectwould be{ runId, step, pass }. Under [feat] Define the team revision once for run records, labels and snapshots #1163's ruleteamRevisionis often absent, for a scorer edited and not pushed, and then nothing says which rubric gave the label. The scorer file's hash next to it would, as [feat] Define the team revision once for run records, labels and snapshots #1163 does for runs withsourceHash:rubric: { sourceHash, teamRevision? }. The logical key becomes(scorer, rubric.sourceHash, subject): the same rubric replaces a label, an edited one adds a label, and two rubrics can be compared on the same steps.judge: { tool, model?, usage? }, withusage(inputTokens,outputTokens,costUsd) only as far as the tool reports it.#1137 adds
agentto session labels, which have no run record to read it from.The judge's model. #1177 adds a workflow
defaults.modelfor prompt steps. A scorer step does not take it: the judge runs on the scorer'smodel, or the tool's default, whatever workflow it is in.Where the code lives. #1137 scores sessions with the same scorer files. If the definition parser, sampling, the judge prompt with the parsing of its answer, the alias lookup for
model, and the label type sit outsidesrc/workflow/(saysrc/scorers/), session scoring reuses them as they are. Otherwise #1137 moves them later.2 remaining items
SaulMoro commentedon Oct 20, 2026
Notes for scorers from #1175 and #1180. Step labels (this issue) and session labels (#1137, #1178, #1179) stay as decided, and whole-run scoring is now #1247; these points only change how a step is judged and recorded:
session: { from: <step> }". The body calls it "an internal read-only agent step", and in feat(workflow): add workspace branches #1175 a read-only step runs in the member's checkout (workspace.ts:242), which cannot see a [feat] Run benchmark suites as workflows #1180 trial's diff in the trial's own clone. I'd lease a scorer step only to the driver that owns the judged pass's copy, by the owner check acontinuestep gets (workspace.ts:260-263), with the judged pass'schangeson the lease. That driver prepares the evidence where the commits and the session are, and the answer-only judge runs outside the copy, in an empty directory, as [feat] Score a finished session on demand #1178 describes. The scorer is not itself acontinuestep, since the local driver commits and pushes whatever acontinuestep leaves (worktree.ts:287). [feat] Run benchmark suites as workflows #1180's "its scorer steps continue its copy" would change to match.driver.canreads onlyhitlandworkspace(engine.ts:1112-1125). I'd lease scorer work only when that driver also declarescan.judge, with the tools it can run as a judge. Unknowncankeys are listed inignored, so older CLIs keep working. The profile list that makes a judge answer-only is in [feat] Score a finished session on demand #1178, which is blocked by this issue; this issue would own the list, and [feat] Score a finished session on demand #1178 would reuse it and move its criterion 6 here.engine.ts:717), and an idle local driver returnsbusy(local-driver.ts:850). With thecan.judgegate that is the normal case for a member whose tool has no profile. A scorer step that no driver can take settles without a label once nothing else in the run is pending, and the local driver settles it at once when it has no judge profile. A scorer that runs gets a timeout of its own (see [feat] Enforce workflow requirements and execution limits #1082).state.yaml. This issue defines no place for a workflow judge's answer. I'd write it to<dataHome>/scores/reasoning/<labelId>.md, the store [feat] Score a finished session on demand #1178 defines for sessions, and keep only the label andlabelIdin the run file. That needs the pass andrubric.sourceHashin the label key (point 2 of the 2026-10-16 11:08 UTC comment), and not only for the file: under the lockedsubject: { runId, step }, sampling and "a rerun replaces the label" treat every pass of a looped step as one subject, so labels collide in the record too. Criterion: after a scored run, a phrase that occurs only in the judge's answer appears in no file under the run directory and in no published record.steps[],stepCountsanditerationscount every step (records.ts:202-230),failureOftakes the first failed top-level step (records.ts:155-166), and the Workflow Runs page sums every step's tokens (dashboard/workflow-runs.ts:124-138). Scorer work kept apart fromrun.steps(later comment on this issue) stays out of all of these and of [feat] Enforce workflow requirements and execution limits #1082's budget, and keep their usage in a separate scoring total that counts every attempt, including one that fails and gives no label. A label may also carry its judge's usage (judge.usage, point 3 of the 2026-10-16 11:08 UTC comment), with cost ascost: { amount, unit }in the tool's own unit, replacingcostUsd; [feat] Score a finished session on demand #1178's session label, which is this label, takes the same shape, and [feat] Run benchmark suites as workflows #1180 would use it too. Criterion: a run whose sampled scorer fails or spends tokens has the samestepCounts,iterations,failureand work token total as the same run withsamplingRate: 0, and its tokens appear in the scoring total.tool/modelit carries: the judged pass's, read from that pass's report. The judge's own tool and model go injudgeif point 3 is adopted, and nowhere in the label otherwise. Without that, a reader cannot tell whethermodelis the judge's, and stats that sum labels per model ([feat] Publish privacy-preserving workflow run records and stats #1083) mix the two. Criterion: with a judge on another model,judge.modelnames the judge's model, and a reader takes the judged pass's model from that pass'sagent, never from the label.review-qualityscorer judges the reviewer, a read-only step. Its directneedsor the member's HEAD do not say what it reviewed: in Discussion: Workflow Runtime Integration #818'sreview-and-fix, the firstreviewhas no localneeds, and the change lives in the enclosing copy. With F4 in [feat] Close the #1080 gaps that #1175 leaves open #1246, a read-only step inside a copy runs in that copy and its pass records the commit it read. The scorer's evidence is that range, the pass'soutputsand its redacted session; with no recorded range, the evidence says so rather than guessing. Criterion: a scorer on thereviewstep (agentreviewer) of Discussion: Workflow Runtime Integration #818'sreview-and-fixreceives the implementer's diff and the reviewer'sverdict.{ scorer, label, passed }per pass to that page's allowlist (dashboard/workflow-runs.ts:30-69), never evidence or reasoning.The recorded review range is F4 in #1246, which this issue now waits for.
feat(review): fail review --branch on an invalid workflow
feat(review): fail review --branch on an invalid workflow
SaulMoro commentedon Oct 22, 2026
Subagent transcripts in a step's evidence. A step's session is resolved like
session: { from: <step> }, and today that leaves out the work its subagents did:<sessionId>/subagents/agent-<agentId>.jsonl, next to the session file, withisSidechain: trueon every record. The Claude adapter reads only the session file and also drops sidechain records (claude-code.ts:560).subagents/directory, and its adapter does not read it either ("not migrated in the first version",codebuddy.ts:10).A step that hands its edits to a subagent is then judged on a session that did nothing. I'd have
AgentSession.findread the subagent files for these tools, and haveAgentSession.transcriptgive their entries marked with the subagent's id, in the order they ran, so the judge sees who did what. Scoring a subagent as its own subject stays out, as in #1137. Session scoring (#1178) reuses this unchanged.Criterion: a fixture Claude session whose only edit is made by a subagent gives the step's scorer that subagent's tool call.
SaulMoro commentedon Oct 22, 2026
Proposal: a workflow lists a step's scorers, and a scorer file holds only its rubric.
agentsin the scorer file selects by definition, and an agent definition is only the role file a step hands the tool, not what TeamAI delivered to it. That misses work: a prompt step withoutagent:cannot be scored, sessions neededsessions: true(#1179), delivering steps neededselects(#1247), and one role used in two workflows for different goals gets the same scorer. The label already records the whole context the pass ran with (#1176,team.selectionandused.skillsin #1178), so grouping by definition keeps working fromagent.definition. Instead:workflowstep takesscorers: [<name>, ...]. On aworkflowstep the scorers judge everything under it. On afor_eachstep they judge each item. There is no workflow-level key: the delivering step already covers the run ([feat] Score what a workflow run delivered #1247). Pull reports a name with no valid scorer, andscorers:on a step that runs no agent.teamai review --branchreports the same through [feat] Validate workflows, prompts and scorers in review --branch #1248's validator.description,labels,passingScore,samplingRate,modeland the prompt body, plussessions([feat] Score finished sessions in the background #1179) andcheck([feat] Score what a workflow run delivered #1247). It dropsagents. A file that still hasagentsorincludeDescendants, as a ported Warp scorer would, orselects, from an earlier draft of [feat] Score what a workflow run delivered #1247, fails validation withscorers/<name>.md uses `<key>`. A workflow lists the scorers for a step in its `scorers:` key.{ runId, step, pass }. Under{ runId, step }, every pass of a looped step is one subject, and an outer loop that reruns a sub-workflow reuses the same path and continues its pass numbers (archiveUnder,firstPassinloop.ts), so labels of different rounds collide. Each pass also reviews or changes a different range. To judge a whole loop or fan-out, list the scorer on theworkflowstep that contains it. A prompt step's label readsagentfrom its pass. Aworkflowstep's label has noagent, since its passes usually mix definitions.run.steps, one entry per sampled(scorer, subject), leased throughnext. Inrun.stepsit would be skipped by a top-levelend(stopWherematches every id under the top scope's prefix'',scope.ts), moved byarchiveUnder, and counted bystopped()and the record totals. Entries are independent of each other and of the run's outcome. The end record is written once every entry has settled, labelled or not, as [feat] Run benchmark suites as workflows #1180 asks for trial scorers.local-driver.ts), so it runs them one after another.Body changes this implies (applied to the body):
scorers/*.mddefinitions with agents, labels, scores, passing score, sampling rate, model, and prompt." becomes "Add and validatescorers/*.mddefinitions with labels, scores, passing score, sampling rate, model, and prompt, and thescorers:step key that names them."scorers:names during pull."evaluates".SaulMoro commentedon Oct 22, 2026
Body updated after a design pass. What changed:
next/reportteamai score --nexthands out judge work;workflow nextnever doesengine.ts:890-897)scores/<member>/<YYYY-MM>.jsonl, shared with #1178; the run settles firstendedAtand raced the outboxrun.stepscan.judgecan.judge: { isolation, tools }Judge profiles, measured with a hostile fixture transcript:
--restricted --tools=Read,Grep,Glob, cwd = evidence)readverifiedexec, permission profile: read evidence only, network off,[agents] enabled = false)readverified--standalone, isolated config and db, project config off, evidence outside any checkout)readverified--restricted)readbest-effortuntil a run proves itAny driver judges the same way:
test(push): use .md scorer names in the placement samples
feat(namespaces): resolve inactive scorers namespaces like prompts
feat(push): support all namespaced resource types (#1234)
SaulMoro commentedon Oct 23, 2026
Implementation note from #1297: the team's scoring settings live next to the scorers, by namespace, like model aliases, env, hooks and MCP, instead of in
teamai.yaml:isolation: the strictest wins. Settingbatchdifferently is an error for those members until one file changes.isolationand the machine'sscoring.isolationinconfig.yamlstill apply; the strictest wins.batch: a step'sscorers.batch, else the workflow'sdefaults.scorers.batch, elsescoring.yaml.scorers/<dir>/that no role or project declares is shared, as forprompts/.