Status: Implemented
Author: Mohamed Ameen
Date: 2026-05-09
Last Updated: 2026-05-09
Reviewers: --
Package: src/orchestrator/prm.py
Entry Point: N/A (library + executor integration; no CLI command)
Version Introduced: v0.15.0 (rule-based detectors); v0.20.0 A1 (LLM augmentation, PRMConfig)
The PRM (Process Reward Model) scores a task's trajectory — the chronological sequence of agent dispatches — and detects failure-mode patterns that a single-step reward signal would miss. When a pattern fires, the PRM emits a CourseCorrection markdown block that the executor splices into the next agent's prompt, nudging the agent off a stuck path before the budget is exhausted.
It is the in-loop, per-dispatch counterpart to the tournament-scoped PlateauDetector. Where the plateau detector watches outcome quality across passes, the PRM watches process patterns across dispatches within a single execute task.
In scope:
TrajectoryEventandTrajectoryStore— append-only, FIFO-evicted per-task event log.- Five rule-based detectors (
detect_repetition_loop,detect_ping_pong,detect_expansion_drift,detect_stuck_on_test,detect_context_thrash). LLMTrajectoryClassifier— opt-in LLM-augmentation that emits the samePatternobjects from a Haiku-class model.merge_patterns— rules-primary deduplication policy.CourseCorrection— formatting + fingerprint cap (one correction per fingerprint per task).- Wiring into
orchestrator.execute_phase.delegate. - Configuration via
PRMConfig(strategy,ml_threshold,ml_min_events).
Out of scope:
- Trajectory persistence beyond a single executor session (the store is in-memory only).
- Reward learning (no policy update; the PRM only emits suggestions).
- Per-tournament-pass scoring (that is the tournament Borda/veto aggregator's job).
The PRM lives between the executor's pre-invocation lessons-injection step and the post-invocation result-recording step. Its place in the pipeline:
flowchart TB
A["delegate(task, role)"] --> B[inject lessons block]
B --> C{trajectory_store.analyze(task_id)}
C -->|patterns empty| D[run agent]
C -->|patterns matched| C2{cfg.prm.strategy == "rules+ml"?}
C2 -->|yes + classifier wired| C3[LLMTrajectoryClassifier.classify]
C3 --> C4[merge_patterns]
C2 -->|no| C5[use rule patterns]
C4 --> E
C5 --> E[CourseCorrection.from_pattern top]
E --> F{has_emitted fingerprint?}
F -->|yes| D
F -->|no| G[splice CC block + ledger op + knowledge event]
G --> D
D --> H[record TrajectoryEvent]
H --> I[return AgentResult]
- FR-1: Record one
TrajectoryEventper delegate dispatch, capturing role, action, target files, success flag, duration, and timestamp. - FR-2: Run five rule-based detectors against the trailing event window of each task and return matched
Patternobjects ranked by severity. - FR-3: Allow opt-in LLM augmentation that emits additional patterns at confidence ≥
cfg.prm.ml_threshold. - FR-4: Cap the per-task event log at 50 entries; evict FIFO when full.
- FR-5: Emit at most one
CourseCorrectionper(taxonomy, pattern)fingerprint per task. - FR-6: When a correction is emitted, append a
course_correction_emittedledger op AND record acourse_correctionTournamentEventin the swarm-tier knowledge store. - FR-7: Gracefully degrade on any error (LLM failure, ledger failure, knowledge-store failure) — never block dispatch.
- Crash-safety: The trajectory store is in-memory only by design — a crash mid-run loses trajectory data. This mirrors the rest of v0.15.0's stuck-state design (the persistent signal is the
course_correction_emittedledger op). - Subprocess isolation: The PRM runs entirely in the orchestrator process; no subprocess.
- Asyncio concurrency:
LLMTrajectoryClassifier.classifyis async (it awaits the completer). The rule-based detectors are pure sync. The store is NOT thread-safe — consumers must serialize via the per-task worker (which the executor already does). - Pydantic v2 strict validation:
PRMConfigusesConfigDict(extra="forbid")and validatesml_threshold ∈ [0,1]andml_min_events ≥ 1. - LLM cost efficiency: Default
strategy="rules"makes zero LLM calls."rules+ml"adds one Haiku-class call per dispatch when the trajectory has ≥ml_min_eventsevents; cold-start guard skips early dispatches. - Deterministic reproducibility: Rule-based detectors are deterministic (pure functions of the event list). LLM detectors are not deterministic; merge policy ensures rule-only output is stable when ML is off.
- Maintainability: Patterns + taxonomy + default suggestions are colocated in
prm.pylookup tables (_PATTERN_SEVERITY,_PATTERN_TAXONOMY,_PATTERN_DEFAULT_SUGGESTION). Adding a pattern is a 3-table edit + new detector function.
- Must not introduce a numpy / scikit-learn dependency.
- Must not block executor dispatch on classifier failure.
- Must work with the existing
TrajectoryStore(no schema migration of existing events when ML is opted in).
flowchart TB
subgraph orchestrator
EX[execute_phase.delegate]
TR[TrajectoryStore]
CL[LLMTrajectoryClassifier?]
end
subgraph PRM
DET[5 rule detectors]
MRG[merge_patterns]
CC[CourseCorrection]
end
subgraph state
LD[plan_manager.ledger_append<br/>course_correction_emitted]
KN[knowledge.record_tournament_event<br/>course_correction]
end
EX -->|analyze task_id| TR
TR --> DET
DET --> MRG
EX -.optional.-> CL
CL --> MRG
MRG --> CC
CC --> EX
EX --> LD
EX --> KN
EX --> TR
| File | Element | Responsibility |
|---|---|---|
src/orchestrator/prm.py |
TrajectoryEvent |
Frozen dataclass — one dispatch event |
src/orchestrator/prm.py |
TrajectoryStore |
Per-task in-memory log + analyze() orchestration |
src/orchestrator/prm.py |
Pattern |
Detected pattern with derived taxonomy + severity |
src/orchestrator/prm.py |
CourseCorrection |
Markdown block + fingerprint for emission cap |
src/orchestrator/prm.py |
detect_* functions |
Five rule-based detectors |
src/orchestrator/prm.py |
LLMTrajectoryClassifier |
Opt-in LLM augmentation |
src/orchestrator/prm.py |
merge_patterns |
Rules-primary dedup |
src/config/schema.py |
PRMConfig |
Strategy + thresholds (extra="forbid") |
src/orchestrator/__init__.py |
Orchestrator._trajectory_store |
Lazy slot wired in __init__ |
src/orchestrator/execute_phase.py (lines 2460-2554, 2694-2719) |
delegate integration |
Analyze → emit → record |
@dataclass(frozen=True)
class TrajectoryEvent:
timestamp: float # epoch seconds — used by detect_context_thrash
role: str # "developer", "test_engineer", ...
action: str # "edit", "test", "review", or envelope.action
target_files: tuple[str, ...]
success: bool
duration_s: float
@dataclass
class Pattern:
name: str # one of PatternName
@property
def taxonomy(self) -> str: # derived via _PATTERN_TAXONOMY
@property
def severity(self) -> int: # derived via _PATTERN_SEVERITY
@dataclass
class CourseCorrection:
taxonomy: str
pattern: str
suggestion: str
@classmethod
def from_pattern(cls, p: Pattern) -> "CourseCorrection": ...
def format_for_prompt(self) -> str: ... # markdown block
def fingerprint(self) -> str: # f"{taxonomy}:{pattern}"Five canonical patterns. Severity governs which CourseCorrection is emitted when multiple patterns fire simultaneously (analyze() pre-sorts descending).
| Pattern | Severity | Taxonomy | Threshold | What it catches |
|---|---|---|---|---|
stuck_on_test |
5 | coordination_error |
3 consecutive failed test_engineer events at tail |
Test engineer hammering a failure without varying |
expansion_drift |
4 | specification_error |
3 events at tail with strictly growing target_files set + no success |
Scope creep without progress |
context_thrash |
3 | coordination_error |
5 events at tail with no shared files between consecutive pairs | Rapid topic-switching producing shallow changes |
ping_pong |
2 | reasoning_error |
4 events at tail alternating between exactly two distinct target_files sets |
Bouncing between two files without committing |
repetition_loop |
1 | reasoning_error |
3 consecutive events at tail with identical (role, action, target_files) |
Same edit on same files repeatedly |
Detector source: src/orchestrator/prm.py lines 286-367. Each detector is a pure function returning Pattern \| None.
class PRMConfig(BaseModel):
"""v0.20.0 A1: configuration for the trajectory PRM."""
model_config = ConfigDict(extra="forbid")
strategy: Literal["rules", "rules+ml"] = "rules"
ml_threshold: float = Field(default=0.7, ge=0.0, le=1.0)
ml_min_events: int = Field(default=3, ge=1)| Field | Type | Default | Meaning |
|---|---|---|---|
strategy |
Literal["rules","rules+ml"] |
"rules" |
Rules-only (byte-identical to v0.19.0) or rules-primary + LLM-secondary |
ml_threshold |
float ∈ [0,1] |
0.7 |
Confidence cutoff below which the LLM classifier discards a pattern |
ml_min_events |
int ≥ 1 |
3 |
Cold-start guard — skip the LLM call when the task has fewer events than this |
_build_classify_prompt() (line 406) renders a Haiku-friendly prompt. Expected response:
{"patterns": [{"name": "stuck_on_test", "confidence": 0.85}]}_parse_classify_response() (line 434) is defensive: tries strict JSON first (locating the first {...} block to skip prose), falls back to regex extraction of paired (name, confidence) so a slightly malformed response still yields useful patterns. Any name not in _PATTERN_TAXONOMY is dropped; any confidence below ml_threshold is dropped.
| Decision | Rationale | Alternatives Considered |
|---|---|---|
| Rule-based detectors as primary, LLM as secondary | Rules are precise, deterministic, and free; LLM adds recall on novel patterns the rules don't encode. Inverting the priority would burn tokens on every dispatch. | LLM-only (too expensive + non-deterministic); rules-only (misses novel modes — the v0.19.0 baseline) |
| In-memory store, FIFO eviction at 50 events | Trajectory data is only useful within one execute task; persistence buys nothing but disk cost. The 50-event cap bounds memory on long-running tasks while keeping the trailing window fresh. | Persistent ledger (overkill — course_correction_emitted already carries the audit signal); unbounded log (memory bomb on huge tasks) |
| Cap one CourseCorrection per fingerprint per task | A pattern that already fired and was acted on shouldn't re-fire and re-prompt every dispatch. Fingerprint = taxonomy:pattern ensures genuinely different patterns can each fire once. |
No cap (annoying + token waste); cap by pattern name only (would suppress same-pattern-different-taxonomy cases — but no such case exists today) |
LLMTrajectoryClassifier swallows all exceptions |
The PRM is advisory; an LLM hiccup must never block the executor. Returning [] falls through to rule-only patterns, which is the correct degraded behavior. |
Surface the error (would force the executor to handle it inline); retry (latency cost without clear benefit) |
merge_patterns is rules-first dedup |
Rule patterns are canonical (no confidence to attach); when both lists carry the same pattern, the rule version wins for stable reporting. | LLM-first (less stable across runs); union without dedup (inflates the pattern list) |
PRMConfig.strategy as a Literal (not boolean) |
Future strategies (e.g. "ml-only", "rules+ml-shadow") can be added without a schema break. |
bool ml_enabled (less extensible) |
- Recall vs cost:
"rules+ml"adds one LLM call per dispatch with a sufficiently long trajectory. On a task with 30 dispatches that exceedsml_min_eventsafter dispatch 3, that's ~27 extra calls per task. Setml_threshold=0.85+to suppress low-confidence noise; raiseml_min_eventsto delay the first call. - Determinism vs adaptability: Rule-based detectors are deterministic and reviewable; LLM patterns are not.
"rules"is the safe default;"rules+ml"is opt-in. - In-memory loss: A crash mid-run loses the trajectory. This is acceptable because (a) the executor restarts the task on resume from the last completed dispatch, and (b) the persistent signal —
course_correction_emittedops in the ledger — is preserved.
TrajectoryStore.analyze(task_id):
- Snapshot events via
events_for(task_id). - Run all five detectors in declaration order; collect non-
NonePatternobjects. - Sort descending by
severity. - Return list (empty if no detectors fired).
merge_patterns(rules_patterns, ml_patterns):
- Iterate
rules_patterns, add tooutandseensets. - Iterate
ml_patterns, skip any inseen, otherwise add. - Sort descending by
severity. - Return
out.
Pre-dispatch (lines 2460-2554):
- Resolve
trajectory_store = getattr(orch, "trajectory_store", None). Skip if missing. - Call
trajectory_store.analyze(envelope.task_id). Catch any exception, logexecute_phase.prm_analyze_failed, fall through withpatterns=[]. - Read
cfg.prm.strategy. If"rules+ml"andorch.llm_trajectory_classifieris wired:- Snapshot
events_for(task_id). await ml_clf.classify(events).- On exception, log
execute_phase.prm_ml_classify_failed; fall back toml_patterns=[]. - If
ml_patternsnon-empty,patterns = merge_patterns(patterns, ml_patterns).
- Snapshot
- If
patternsnon-empty, taketop = patterns[0], buildcc = CourseCorrection.from_pattern(top), computefingerprint = cc.fingerprint(). - If not
trajectory_store.has_emitted(task_id, fingerprint):- Append
cc.format_for_prompt()to the prompt parts. trajectory_store.mark_emitted(task_id, fingerprint).- Append
course_correction_emittedledger op (payload:task_id, taxonomy, pattern, suggestion[:500]). Catch + log on failure. - Record
course_correctionTournamentEvent(family"prm", hypothesis includes pattern name + task id, evidence is the suggestion). Catch + log on failure.
- Append
Post-dispatch (lines 2694-2719):
- Build
TrajectoryEvent(timestamp=_t0, role, action, target_files, success, duration_s). trajectory_store.record(envelope.task_id, event). Catch + log on failure.
The PRM does not write its own files. The two persistence points — course_correction_emitted ledger op and course_correction knowledge event — go through plan_manager.ledger_append (CAS-protected JSONL append) and knowledge.record_tournament_event (atomic JSONL append) respectively.
Every external call (analyze, classify, ledger_append, record_tournament_event, record) is wrapped in a try/except that logs a structured warning and continues. The PRM is advisory — its failures must never block the executor.
- Internal:
state.knowledge.TournamentEvent,state.plan_manager.PlanManager.ledger_append,config.schema.PRMConfig - External: stdlib only (
json,re,dataclasses,collections.deque,typing). No numpy / scikit-learn / pydantic dependency inprm.pyitself.
AutodevConfig.prm is the single configuration point. Default PRMConfig() is safe (rules-only, no LLM calls). Operators flip on LLM augmentation by editing .autodev/config.json:
{
"prm": {
"strategy": "rules+ml",
"ml_threshold": 0.75,
"ml_min_events": 5
}
}Wiring the classifier: when strategy == "rules+ml", the orchestrator must additionally attach an llm_trajectory_classifier instance to itself (the getattr(orch, "llm_trajectory_classifier", None) call site is the contract). Today this wiring lives outside the orchestrator constructor — the integration test or runtime entrypoint constructs LLMTrajectoryClassifier(completer=..., threshold=cfg.prm.ml_threshold, min_events=cfg.prm.ml_min_events) and assigns it.
state.plan_manager.PlanManager.ledger_appendfor thecourse_correction_emittedop.state.knowledge.KnowledgeStore.record_tournament_eventfor thecourse_correctionevent.- An
LLMCompletercallable (typed asCallable[[str], Awaitable[str]]) when"rules+ml"is enabled.
None directly. The LLMCompleter callable is provider-agnostic — production wires it to a Haiku-class adapter via the platform adapter; tests stub it with a deterministic function.
| Op | Payload | When |
|---|---|---|
course_correction_emitted |
{task_id, taxonomy, pattern, suggestion[:500]} |
After a CourseCorrection is spliced into a prompt for the first time per fingerprint per task. Defined in state/ledger.py line 99 + plan_manager.py line 991. |
| Event | Family | Confidence | When |
|---|---|---|---|
course_correction |
prm |
0.6 (per _BASE_CONFIDENCE in state/knowledge.py line 133) |
Same trigger as course_correction_emitted. Recorded via record_tournament_event. |
orchestrator.execute_phase.delegate— the only consumer.orchestrator.Orchestrator.__init__(line 76) — owns theTrajectoryStoreinstance.
When "rules+ml" is enabled and the classifier is wired to a real adapter, one Haiku-class LLM call per dispatch (subject to the cold-start guard).
- Each
detect_*function: positive cases, threshold-just-below cases, negative cases (no overlap, success interspersed). merge_patterns: dedup, ordering, severity sorting.LLMTrajectoryClassifier.classify: cold-start (returns[]), happy path (parses pattern), malformed JSON (regex fallback), thrown completer (returns[])._parse_classify_response: strict JSON, trailing prose, regex fallback, unknown pattern names dropped, sub-threshold confidences dropped.CourseCorrection.from_pattern+format_for_prompt+fingerprint: round-trip and stability.PRMConfiground-trip via Pydanticmodel_dump_json/model_validate_json.
- Run
delegatewith a mock orchestrator carrying a stubtrajectory_storethat pre-loaded events triggeringrepetition_loop. Assert the## COURSE CORRECTIONblock appears in the prompt and the ledger op is recorded. - Run twice — assert the second call does NOT re-emit (fingerprint cap).
- Run with
cfg.prm.strategy="rules+ml"and a stub classifier returning a novel pattern. Assertmerge_patternsordering. - Run with a classifier that raises. Assert no exception escapes
delegateand the rule-only pattern still emits.
- Hypothesis strategy generating random
TrajectoryEventlists. Assertanalyze()is total (never raises) and that returned patterns are sorted by severity descending.
- The
suggestiontext injected into prompts is hardcoded in_PATTERN_DEFAULT_SUGGESTION(no user input). The LLM-classifier path only consumes pattern names (matched against the canonical taxonomy table), not suggestion text — so a malicious LLM response cannot inject prompt content. ledger_appendandrecord_tournament_eventtruncate the suggestion to 500 chars defensively even though the source is already bounded.
- Rule detectors operate on at most 50 events (the FIFO cap) and are O(n) per detector; total per-
analyze()cost is O(5n) ≈ O(n). Negligible. - LLM classifier latency is ~300-800ms for a Haiku-class call on a typical 20-event trajectory summary. The cold-start guard (
ml_min_events) defers the first call until the trajectory is genuinely long enough to yield signal. - The fingerprint cap means at most one classifier round-trip pays off per task per pattern type — subsequent dispatches with the same pattern still call
classifybut the result is suppressed at the emit step. Future enhancement: short-circuitclassifywhen all known patterns are already emitted.
None. The PRM has no CLI command. Configuration is via .autodev/config.json only.
| Event | When | Key fields |
|---|---|---|
execute_phase.prm_analyze_failed |
analyze() raised |
task_id, err |
execute_phase.prm_ml_classify_failed |
LLM classifier raised | task_id, err |
execute_phase.prm_record_failed |
record() raised |
task_id, err |
execute_phase.ledger_append_failed |
Ledger op append failed | op, err |
execute_phase.knowledge_record_failed |
Knowledge event record failed | event, err |
course_correction_emittedledger ops in.autodev/ledger.jsonl— every emitted correction is replayable.course_correctionevents in the swarm-tier knowledge store — feed back into the next session's ranked-lessons block.
autodev status does not currently display PRM state. Future enhancement: surface "course corrections emitted this run" as a status counter.
| Operation | LLM Calls | Notes |
|---|---|---|
Default (strategy="rules") |
0 | Pure rule-based, no extra calls |
strategy="rules+ml", per dispatch with len(events) ≥ ml_min_events |
1 | Haiku-class call, ~150-400 input tokens (event summary), ≤100 output tokens |
| Per task (worst case, 30 dispatches, all qualifying) | ~27 | Subtract the first ml_min_events cold-start dispatches |
| Per plan with 10 tasks | ~270 | Multiply per-task cost by task count |
Mitigations: raise ml_threshold (admit fewer patterns), raise ml_min_events (delay the first call), keep strategy="rules" for routine work and only enable on high-stakes refactors.
- Short-circuit when all patterns already emitted: Skip
classifyonce every fingerprint in_PATTERN_TAXONOMYhas been emitted for a task. - Persistent trajectory across resume: Currently in-memory; persisting would let resumed tasks consult earlier-pre-crash patterns.
- Pattern-confidence weighting in
merge_patterns: Today rules win on dedup with no confidence attached. Could attach a synthetic confidence (1.0 for rules) and pick the higher-confidence variant. - Telemetry counters: Aggregate per-session counts of (analyzed, fired-by-rule, fired-by-ml, suppressed-by-fingerprint, suggested-and-followed) to evaluate PRM efficacy.
- Should
LLMTrajectoryClassifierget a per-call cache keyed on the event-list digest to dedupe rapid back-to-back classifications within one dispatch round? - Should the
course_correctionknowledge event respectKnowledgeConfig.denylist_roles? Today it is recorded under family"prm"with no role attribution. - Should the fingerprint also include
task_phaseto allow re-emission across phase boundaries for the same pattern?
- ADR-002: Append-only JSONL ledger with CAS — governs
course_correction_emittedop shape.
src/orchestrator/prm.py— full modulesrc/orchestrator/execute_phase.pylines 2460-2554 (analyze + emit) and 2694-2719 (record)src/orchestrator/__init__.pylines 73-76, 163-165 (TrajectoryStore wiring)src/config/schema.pylines 361-386 (PRMConfig)src/state/knowledge.pylines 117-133 (course_correctionevent type + base confidence)src/state/ledger.pyline 99,src/state/plan_manager.pyline 991 (course_correction_emittedop registration)config_system_design.md—PRMConfigfield documentationplateau_detection_design.md— sister detector for tournament-pass plateaus
| Date | Author | Changes |
|---|---|---|
| 2026-05-09 | Mohamed Ameen | Initial draft documenting v0.15.0 rule-based PRM + v0.20.0 A1 LLM augmentation. |