Skip to content
Merged
83 changes: 81 additions & 2 deletions api/improve.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,17 @@ interface ApprovalSignIn {
}

// @public
export function approvePending(dir: string, id: string, approvedBy: string): Promise<ApplyResult>;
export type ApprovedPending = ApplyResult | {
canary: TrafficCanary;
};

// @public
export function approvePending(dir: string, id: string, approvedBy: string, options?: ApprovePendingOptions): Promise<ApprovedPending>;

// @public
export interface ApprovePendingOptions {
db?: SqlDatabase;
}

// @public
export interface AuditEntry {
Expand Down Expand Up @@ -424,6 +434,28 @@ export interface CanaryReplayResult {
tokens: number;
}

// @public
export interface CanarySettlement {
applied?: ApplyResult;
outcome: 'none' | 'pending' | 'applied' | 'queued' | 'retired';
queued?: PendingProposal;
reason: string;
verdict: CanaryVerdict;
}

// @public
export interface CanaryVerdict {
flagged: number;
newClasses: string[];
p0: number;
reason: string;
sessions: number;
state: 'none' | 'pending' | 'promote' | 'retire';
}

// @public
export function canaryVerdict(db: SqlDatabase, dir: string, settings: ImproveSettings, now?: number): CanaryVerdict;

// @public
export interface CapturedCase {
id: string;
Expand Down Expand Up @@ -1031,7 +1063,7 @@ export interface HealCycle {
from?: string;
guard?: GuardCorpusResult;
id: string;
outcome: 'idle' | 'paused' | 'busy' | 'budget' | 'cooldown' | Exclude<ImproveOutcome, 'proposal'> | 'canary-failed' | 'applied' | 'pending' | 'guard-rolled-back' | 'stale' | 'refused' | 'interrupted';
outcome: 'idle' | 'paused' | 'busy' | 'budget' | 'cooldown' | Exclude<ImproveOutcome, 'proposal'> | 'canary-failed' | 'canary-started' | 'applied' | 'pending' | 'guard-rolled-back' | 'stale' | 'refused' | 'interrupted';
pending?: string;
reason: string;
result?: ImproveResult;
Expand Down Expand Up @@ -1855,6 +1887,9 @@ export function readAgentHarness(dir: string): Harness;
// @public
export function readAudit(dir: string): AuditEntry[];

// @public
export function readCanary(dir: string): TrafficCanary | undefined;

// @public
export function readCorpus(dir: string): CorpusCase[];

Expand Down Expand Up @@ -1974,6 +2009,9 @@ export interface RollbackResult {
written: string[];
}

// @public
export function routesToCandidate(sessionId: string, share: number): boolean;

// @public
interface RunDoneEvent extends AgentEventBase<'run.done'> {
finishReason: ExecutionFinishReason;
Expand Down Expand Up @@ -2225,6 +2263,9 @@ type SessionTurnOptions = Partial<SessionTurnCheckpoint> & {
onAgentEvent?: (event: AgentEvent) => void;
};

// @public
export function settleCanary(db: SqlDatabase, dir: string, settings: ImproveSettings, now?: number): Promise<CanarySettlement>;

// @public
interface SimpleAgent<TObject = unknown> {
approvals: AgentApprovals;
Expand Down Expand Up @@ -2288,6 +2329,20 @@ interface StandardSchemaV1<Input = unknown, Output = Input> {
};
}

// @public
export function startCanary(db: SqlDatabase, dir: string, input: StartCanaryInput, settings: ImproveSettings, now?: number): Promise<TrafficCanary>;

// @public
export interface StartCanaryInput {
agent?: string;
approvedBy?: string;
base: Harness;
cycle?: string;
maxAlwaysOnChars?: number;
replay?: Pick<CanaryReplayResult, 'passed' | 'reason'>;
result: ImproveResult;
}

// @public
export interface StatusCount {
max: number;
Expand Down Expand Up @@ -2326,6 +2381,12 @@ interface StepUsage {
usage: Usage;
}

// @public
export function stopCanary(dir: string, by: {
actor: AuditEntry['actor'];
reason: string;
}): Promise<boolean>;

// @public
interface StreamChunk {
// (undocumented)
Expand Down Expand Up @@ -2539,6 +2600,23 @@ interface ToolStartEvent extends AgentEventBase<'tool.start'> {
toolName: string;
}

// @public
export interface TrafficCanary {
agent?: string;
approvedBy?: string;
base: Harness;
baseRevision: string;
candidate: Harness;
cycle?: string;
maxAlwaysOnChars?: number;
p0: number;
result: ImproveResult;
revision: string;
sessions: number;
share: number;
startedAt: string;
}

// @public
interface Usage {
cachedInputTokens?: number;
Expand Down Expand Up @@ -2585,6 +2663,7 @@ export interface WatchOptions extends Omit<HealOptions, 'trigger' | 'maxTokens'

// @public
export interface WatchTick {
canary?: CanarySettlement;
cycle?: HealCycle;
guard?: GuardCorpusResult;
monitor?: MonitorVerdict;
Expand Down
7 changes: 7 additions & 0 deletions api/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1538,6 +1538,7 @@ export interface CreateAgentBase<TOutput extends StandardSchemaV1 = StandardSche
fallbackModels?: readonly string[];
guardrails?: AgentGuardrails;
handoffs?: ReadonlyArray<SimpleAgent | Handoff>;
harnessCanary?: (ctx: RunConfigContext) => HarnessArm | undefined;
hooks?: readonly AgentHook[];
limits?: RunLimits;
maxHandoffs?: number;
Expand Down Expand Up @@ -3212,6 +3213,12 @@ export interface HandoffTarget extends SubagentSpec {
handoffs?: readonly ResolvedHandoff[];
}

// @public
export interface HarnessArm {
instructions: string;
skills: readonly Skill[];
}

// @public
interface HealActionEvent extends AgentEventBase<'heal.action'> {
action: 'hint' | 'deny' | 'stop';
Expand Down
7 changes: 7 additions & 0 deletions api/worker.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,7 @@ export interface CreateAgentBase<TOutput extends StandardSchemaV1 = StandardSche
fallbackModels?: readonly string[];
guardrails?: AgentGuardrails;
handoffs?: ReadonlyArray<SimpleAgent | Handoff>;
harnessCanary?: (ctx: RunConfigContext) => HarnessArm | undefined;
hooks?: readonly AgentHook[];
limits?: RunLimits;
maxHandoffs?: number;
Expand Down Expand Up @@ -1807,6 +1808,12 @@ interface HandoffOptions {
toolName?: string;
}

// @public
interface HarnessArm {
instructions: string;
skills: readonly Skill[];
}

// @public
interface HealActionEvent extends AgentEventBase<'heal.action'> {
action: 'hint' | 'deny' | 'stop';
Expand Down
16 changes: 12 additions & 4 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -507,8 +507,13 @@ npx lousho improve reject ./my-agent 3f9c0a1b2d4e --by ali --reason "too broad"
harness's hash, and a file whose content doesn't match its id is left out.
`--show <id>` prints that proposal's review card.
- `approve <dir> <id> --by <name> [--check <module>] [--model provider/model]
[--read-only tool]... [--no-guard]` applies the proposal through
`applyProposal()` as that person's approval and removes it from the queue. A
[--read-only tool]... [--no-guard] [--db path]` applies the proposal through
`applyProposal()` as that person's approval and removes it from the queue.
With `canary.traffic` set, it starts the
[traffic canary](./improve.md#live-traffic-canary-opt-in) instead and writes
nothing yet, reading the observe database at `--db` (default
`~/.lousho/observe.db`); the proposal is applied as that approval when the
canary passes, and the watcher guards it then. A
proposal whose agent directory changed since it was made is refused and
marked stale; run improve again. Any other refusal is recorded on the entry
and shown by `pending`. Right after the write it runs the corpus guard, as an
Expand Down Expand Up @@ -583,7 +588,10 @@ between 0 and 1, and `editors.proposers` is 1 to 4. `requireRecurring`
defaults to `false` in manual mode.
`"enabled": false` stops the loop for this agent. `budget.maxTokensPerCycle`,
`canary.traffic` (`share` up to 0.25, `sessions` up to 50) and `library.domain`
are unset unless you set them. `canary.traffic` is accepted and shown by
`status`, but no cycle uses it yet: the replay canary always runs.
are unset unless you set them. With `canary.traffic` set, a proposal that
passes the replay canary (in auto mode), or that a person approves (in manual
mode), starts the
[live traffic canary](./improve.md#live-traffic-canary-opt-in), which serves it
to real sessions, instead of being written.
`loadImproveSettings()` reads the file. See
[Settings](./improve.md#settings) for what each key does.
47 changes: 40 additions & 7 deletions docs/improve.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,30 @@ Editors that used a lesson cite its id (`lib-…`) in their notes, and `citedEnt

The session never sees a held-out case's id: each held-out case appears as `held-out case 1`, `held-out case 2` and so on. A reply that isn't exactly one `<experience>` block stores nothing. That covers several blocks, a repeated field, a line that isn't a field, and a block alongside `NO_ENTRY`. Nothing is stored either when the session ends early or goes over `maxTokens`.

### Live traffic canary (opt-in)

**This serves an unproven harness to real users.** It is off unless `improve.json` sets `canary.traffic` (`share` up to 0.25, `sessions` up to 50). Use it only for failures that `canaryReplay` can't replay, and run `canaryReplay` first when you can.

`startCanary` takes a proposal that passed the gate and writes `.lousho/canary.json`. While that file exists, an agent loaded with `loadAgentDir` serves the candidate to a stable share of new sessions (by a hash of the session id; a run without a session id always gets the base) and stamps those runs with the candidate's revision. The opt-in must be in `improve.json` when the agent loads. The session cap is kept in `.lousho/canary-sessions.txt`, so it holds across restarts and processes on one host.

`healOnce()` (`lousho improve`) starts it for you in auto mode. When `canary.traffic` is set, a proposal that passes the gate and the replay canary starts a traffic canary instead of being applied, and the cycle ends `canary-started`. In manual mode no candidate reaches users before a person approves it: the cycle queues the proposal as usual, and `lousho improve approve` (`approvePending(dir, id, by, { db })`) starts the traffic canary instead of applying. When that canary passes, it is applied in the approver's name without a second approval. While the canary runs, every cycle ends `busy`. `lousho improve --watch` settles the trial on each tick. A promote then counts toward `maxAppliesPerDay` and is followed by the corpus guard, and the watcher waits a day when the cap is spent. A pause ends the canary.

`settleCanary` reads the trial; call it yourself on a timer when you don't use the watcher. After `sessions` candidate sessions it promotes when the share of flagged sessions is 0 or at least 0.05 below the base's rate over its last 20 runs, and no failure class appeared that the base didn't have. It retires early once a promotion is ruled out, and when the directory changed, the loop is paused, the opt-in is removed or `monitor.giveUpDays` pass. A promote goes through `applyProposal`, which checks the proposal again. A canary started in auto mode is queued for a person instead when `improve.json` has since switched to manual. In manual mode, `startCanary` refuses without `approvedBy`, and a canary without an approver serves no one.

```ts
import { loadImproveSettings, settleCanary, startCanary, type Harness, type ImproveResult, type SqlDatabase } from 'lousho/improve';

declare const db: SqlDatabase;
declare const result: ImproveResult;
declare const base: Harness;

const settings = loadImproveSettings('./agents/support');
await startCanary(db, './agents/support', { result, base }, settings);
const settled = await settleCanary(db, './agents/support', settings);
```

Deleting `.lousho/canary.json`, `stopCanary`, the `.lousho/improve.stop` file, `LOUSHO_IMPROVE=off` or `"enabled": false` stops it on the next run. A session that already started on the candidate keeps the candidate's instructions in its history.

## The self-healing loop

The loop turns an agent directory's failed runs into an applied edit, checks
Expand Down Expand Up @@ -488,6 +512,8 @@ Everything the loop keeps lives in the agent directory:
| `.lousho/cache/` | Editor sessions the loop can replay |
| `.lousho/state.json` | The cooldown, the monitor's verdict and the watcher's counters |
| `.lousho/improve.stop` | The kill switch, when it exists |
| `.lousho/canary.json` | The running traffic canary, when there is one |
| `.lousho/canary-sessions.txt` | Hashed ids of the sessions the traffic canary served |

Only cycles that got past the trigger are saved, so an `idle`, `paused`, `busy`,
`budget` or `cooldown` result costs nothing and isn't counted against
Expand All @@ -507,6 +533,7 @@ Both modes run the same gate and canary. They differ in who writes.
| A stale proposal | Refused; the cycle ends `stale` | Refused at approve time and marked stale |
| Corpus guard after the write | Right away, in the cycle | Right after `approve`, which rolls back to the approved revision's parent on a regression; with `--no-guard`, at the watcher's next scheduled guard |
| Monitor and automatic rollback | Yes, in a watcher | Yes, in a watcher |
| Traffic canary (`canary.traffic`) | Starts right after the replay canary; a pass is applied | Starts only on `approve`; a pass is applied as that approval |

Set the mode with `"approval"` in `improve.json`, `LOUSHO_IMPROVE_APPROVAL`, or
`--approval` (flag first, then the variable, then the file). Automatic rollback
Expand Down Expand Up @@ -539,7 +566,8 @@ npx lousho improve ./my-agent --check ./check.mjs --watch --interval 15m
Every `--interval` (default 15 minutes) a tick:

1. Reads `improve.json` again and checks the kill switch. A paused tick is
audited once as `pause` and posts `improve.paused`, and does nothing else.
audited once as `pause`, posts `improve.paused`, ends any traffic canary,
and does nothing else.
2. Takes the one-watcher lease in `.lousho/watch`, which lasts two intervals
and is renewed while the tick runs. A tick that can't take it skips.
3. Runs [the monitor](#the-monitor) on the last apply.
Expand All @@ -549,8 +577,12 @@ Every `--interval` (default 15 minutes) a tick:
5. Every `guard.everyHours` (default 24), replays the corpus under the current
revision. On a regression it rolls back to the last good revision: the
newest one whose latest guard passed, else the first baseline.
6. Runs one cycle within the daily caps. A tick that sees the same failures as
6. Settles a running [traffic canary](#live-traffic-canary-opt-in). A promote
counts toward `budget.maxAppliesPerDay` and is followed by the corpus guard;
when the cap is spent, the canary waits for the next day.
7. Runs one cycle within the daily caps. A tick that sees the same failures as
the last cycle, and no new failed run or score regression, skips the cycle.
While a traffic canary runs, the cycle ends `busy`.

`--watch` needs an agent name to find the agent's runs: `--agent`, or the name
the agent sets. Editor and reflection calls are limited to `rateLimit.rpm` per
Expand Down Expand Up @@ -641,7 +673,7 @@ optional, and an unknown key or a bad value stops the loop with
| `trigger.minNewFailures` | 3 | New failed runs that start a watcher's cycle |
| `trigger.requireRecurring` | `true` in auto, `false` in manual | Only failures seen in two or more runs become cases |
| `canary.replayRuns` | 6 | Fresh runs per case in the replay canary |
| `canary.traffic` | unset | `{ share, sessions }` for the live traffic canary (`share` at most 0.25, `sessions` at most 50). Accepted and shown by `status`, but no cycle uses it yet |
| `canary.traffic` | unset | `{ share, sessions }` turns on the live traffic canary (`share` at most 0.25, `sessions` at most 50): a proposal that passes the replay canary is trialled on real sessions instead of being written. See [Live traffic canary](#live-traffic-canary-opt-in) |
| `monitor.minRuns` | 20 | Runs on each side before the monitor judges |
| `monitor.window` | 20 | Runs per look |
| `monitor.maxDrop` | 0.05 | The drop that counts as a regression |
Expand All @@ -668,9 +700,9 @@ database, and writes one line to stderr:

| Event | When |
| --- | --- |
| `improve.applied` | A cycle applied an edit and the corpus guard passed |
| `improve.pending` | A cycle queued a proposal; `details.card` is the proposal file's local path |
| `improve.canary` | A proposal passed the gate and failed the replay canary |
| `improve.applied` | A cycle applied an edit and the corpus guard passed, or a traffic canary was promoted and applied |
| `improve.pending` | A cycle or a promoted traffic canary queued a proposal; `details.card` is the proposal file's local path |
| `improve.canary` | A proposal failed the replay canary, a traffic canary started, or a traffic canary was retired |
| `improve.rolled_back` | The corpus guard or the monitor rolled an edit back |
| `improve.guard_failed` | The monitor or a scheduled guard saw a regression and didn't roll back, because `autoRollback` is off or the rollback was refused. Posted once per revision |
| `improve.paused` | The kill switch is on, or three outages in a row paused the loop |
Expand Down Expand Up @@ -725,7 +757,8 @@ npx lousho improve ./my-agent --check ./check.mjs --dry-run --since 7d --out pro

On an agent directory, `lousho improve` runs one self-healing cycle with
`healOnce()`. In auto mode it **applies** a proposal that passes the gate and
the canary, and in manual mode it queues it. It needs a module whose default
the canary, and in manual mode it queues it. With `canary.traffic` set, auto
mode starts the [traffic canary](#live-traffic-canary-opt-in) instead. It needs a module whose default
export is your `CaseCheck` (return `failure` to name the check a failed answer
missed). It reads cases only from your local observe database, simulates tools
other than loading skills unless `--read-only` names them, and prints the review
Expand Down
Loading
Loading