> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pandaprobe.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Repair agent

> The package-owned agent that reads the notice, diagnoses the failure, and writes the rule

When the trajectory gate fires, something has to read the notice, look at the trace, and decide what was learned. In v0.9 that something is a **separate, package-owned repair agent** — its own model call, its own session, its own trace, its own prompt. Your task agent is never asked to do it.

<Note>
  The repair agent needs a model. `Harness.create()` raises `ValueError` unless you set `repair_model` (or `HARNESS_REPAIR_MODEL`), because no billable default is ever chosen for you. `observe_only=True` is the one non-mutating exception.
</Note>

```python theme={null}
from pandaprobe_harness import Harness, HarnessConfig

harness = Harness.create(
    HarnessConfig(
        repair_model="openai/gpt-...",   # any LiteLLM identifier
        repair_timeout_s=60,
        repair_max_turns=6,
        domain_policy="Supervisor-credential authentication is authorized.",
    )
)
```

## Why a second agent

The obvious design is to let the task agent heal itself: it already has the context, so hand it the mailbox and some tools. A measured AppWorld run showed what that actually costs. Given ten administrative tools and a standing instruction to check its mailbox, the agent spent its turns operating the harness instead of doing the task — and **nine of the thirteen rules it wrote were about gaming its own diagnostic protocol** rather than about the work.

The failure is structural, not a prompting mistake. Diagnosis and execution are different jobs that want different context, different tools, and different success criteria. Splitting them means the task agent's whole surface is the task, and the repair agent's whole surface is one failure.

## The ownership line

| The developer owns                                     | PandaProbe owns                                                   |
| ------------------------------------------------------ | ----------------------------------------------------------------- |
| The task agent, its model, framework, and prompts      | Task instrumentation and evaluation                               |
| Domain tools and the execution loop                    | Trajectory detection and diagnostic notices                       |
| The environment                                        | The repair agent loop, prompt, and capabilities                   |
| *(optional)* a replay function and an outcome verifier | Workspace administration, validation, and read-only rule delivery |

## One episode, end to end

<Steps>
  <Step title="Notices are grouped">
    Pending notices from the same session and turn are coalesced into one **episode** when their trace or signature evidence overlaps — one underlying failure, one diagnosis. The episode keeps every notice id, and one resolution acknowledges the whole group atomically.
  </Step>

  <Step title="The assignment is built">
    The episode becomes a `RepairAssignment`: the notices with their alerting metrics, thresholds and judge `reason` strings, the flagged trace ids, the dump path, any host [`RuleScopeHint`](#scope-selection) metadata and `task_summary`, and your `domain_policy`. All of it is sanitized and bounded, and the prompt declares it untrusted data rather than instructions.
  </Step>

  <Step title="The repair agent runs">
    A bounded tool loop over PandaProbe's official LiteLLM wrapper, in a fresh async context under its own session id. It reads the notice, inspects a trace the notice named, searches existing rules for prior coverage, and then either writes one candidate or resolves without one.
  </Step>

  <Step title="It resolves exactly once">
    An episode ends in one `RepairStatus`: `candidate_added`, `duplicate` (an active rule already covers it), `already_covered` (a candidate is already testing it), `no_proposal`, `unactionable`, `timed_out`, `failed`, or `cancelled`. At most one candidate per episode, always.
  </Step>

  <Step title="Settlement returns the outcome">
    `settle()` waits for evaluation, notice persistence, and one bounded repair attempt, then hands you a `RepairResult`. Timeout, cancellation, or failure acknowledges nothing — the notice stays pending and recoverable, and the developer task never fails.
  </Step>
</Steps>

```python theme={null}
settlement = await harness.settle(session_id)

if settlement.repair is not None:
    r = settlement.repair
    r.status                # "candidate_added" | "duplicate" | "no_proposal" | ...
    r.candidate_rule_ids    # the candidate this episode created, if any
    r.selected_scope        # where it filed the rule
    r.scope_rationale       # one sentence on why that scope
    r.considered_rule_ids   # what it looked at before writing
    r.turns, r.tool_calls   # model rounds and workspace calls
    r.usage                 # normalized tokens/cost when the provider reports them
    r.error_category        # set only on timeout/failure
```

## The repair capability set

The repair agent's tools are package-internal and scoped to **its own episode**. It cannot read a notice it was not assigned, or inspect a trace that notice does not name.

| Operation                                                          | Bound                                                                           |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------- |
| `harness_notice_read`                                              | Only notice ids in the assigned episode.                                        |
| `harness_trace_inspect`                                            | Only trace ids the assigned notice flagged.                                     |
| `harness_rules_read` / `_search` / `_list` / `harness_rule_status` | The same four reads the task agent has.                                         |
| `harness_rule_add`                                                 | At most one candidate per episode; rejected outright after the first.           |
| `harness_notice_ack`                                               | Only after a candidate was created in this episode.                             |
| `harness_notice_resolve`                                           | `duplicate` and `already_covered` must name a live rule of the matching status. |

<Warning>
  The repair agent **cannot promote or retire a rule** — that capability does not exist for either agent. `harness_rule_add` creates a provisional candidate; only [validation](/harness/closed-loop/rule-validation) decides its fate. An agent that could approve its own work would be a self-approving loop, which is the thing the closed loop exists to prevent.
</Warning>

## Novelty comes first

Before a proposal is accepted, the store checks it against live rules in the target scope — normalized exact text, failure signatures, bounded tags, and deterministic lexical overlap. A covered proposal resolves as `duplicate` or `already_covered` instead of adding near-identical guidance. The prompt reinforces it: minor wording changes, a narrower example, or another occurrence of the same workflow do not justify a new rule.

A rule's `metric` is also validated against the evaluator's metric registry at this boundary. That field is not a label — validation matches it against `breach:<metric>`-style signatures to decide which sessions and replay cases count as evidence. A name that matches nothing reads as "never breached", which can invert the rule's own verdict, so an unknown or composite value is rejected for the model to correct.

## Scope selection

The repair agent also decides **where** the rule is filed, as part of the same call — no extra model round for a filename.

* **`global`** is the default: broadly reusable rules, not tied to one task, workflow, application, tool, or domain.
* **A concise contextual name** — an application, workflow, or domain drawn from the evidence — is preferred whenever the rule genuinely belongs to that context. The catalog is open; a new name simply creates `rules/<scope>.md`.
* **`scoped`** is the fallback: the rule is specific, but no meaningful stable name could be determined.

Hosts may pass bounded `RuleScopeHint` metadata and a short `task_summary` on the turn payload. Both *inform* the decision; neither dictates it.

```python theme={null}
harness.on_turn_end({
    "session_id": session_id,
    "turn_index": n,
    "end_state": end_state(),
    "task_summary": "Refund order 1017 and confirm the customer was notified.",
    "rule_scope_hints": [
        RuleScopeHint(
            key="payments",
            description="Payment authorization and transaction workflows.",
        ).to_json()
    ],
})
```

<Note>
  `task_summary` exists because a task id is opaque. Told only `3ab5b8b_2`, a model has nothing to name a scope after; told what the task asked for, it can tell a Spotify library task from a Venmo payment. It is sanitized, length-bounded, and framed as untrusted data like every other externally-authored string.
</Note>

The harness enforces only what a model cannot be trusted to guarantee: a path-shaped or unusable name is **rejected rather than slugified** (silently turning `../../etc/passwd` into `etc-passwd` would file a rule under a name nobody chose); a generic host or integration label is refused, because it names where the agent ran rather than what failed; and with no expressed choice the default applies — silence never resolves to `scoped`.

## Trace isolation

Repair activity must never be scored as task activity, or the harness would be grading itself.

* Repair completions run under a distinct SDK session, `repair-<task-session-id>-<episode-id>`, with repair-role metadata. Exact-session task-trace discovery excludes them.
* With `trace_repair_agent=true`, each run exports **one** trace named `pandaprobe`: a `harness` CHAIN span containing repeated `repair-agent` and `tools` AGENT spans, each `repair-agent` holding the wrapper's `litellm-chat` LLM span and each `tools` span holding one TOOL child per workspace call.
* With it disabled (the default), the SDK context is non-exporting, so the wrapper cannot create an accidental standalone trace. Task tracing is identical either way.

## Cost and bounds

Repair is the loop's second model consumer, and it is bounded on four axes: `repair_timeout_s` (60 s), `repair_max_turns` (6), `repair_max_tokens` (4096), and one episode per settled turn at most. Notice coalescing bounds it further — three related notices on one turn produce one episode, not three.

The six-turn default accommodates providers that emit a single tool call per round, letting one episode complete read → inspect → search → add → acknowledge without any provider-specific orchestration. `repair_reasoning_effort` defaults to `"none"` because current OpenAI reasoning models require it to use function tools on the wrapped chat-completions path; it is forwarded only when LiteLLM reports support.

## Observing repair

```python theme={null}
harness.journal.recent(types=("repair_started", "repair_completed", "repair_failed"))
```

Structured journal events cover the lifecycle: `repair_started`, `repair_model_turn`, `repair_tool_call`, `repair_candidate_added`, `repair_notice_resolved`, and one terminal event per episode (`repair_completed`, `repair_duplicate`, `repair_already_covered`, `repair_no_proposal`, `repair_unactionable`, `repair_timed_out`, `repair_failed`). Each carries the episode id, grouped notice ids, recommended and selected scope, considered rule ids, and suppression reason — never prompts, credentials, provider responses, or unbounded diagnostic payloads.
