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
942 changes: 940 additions & 2 deletions api/improve.api.md

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,23 @@ npx lousho improve reject ./my-agent 3f9c0a1b2d4e --by ali --reason "too broad"
and moves the file to `.lousho/proposals/rejected/`. A rejected proposal is
never queued again.

`--watch` keeps the loop running on an agent directory:

```bash
npx lousho improve ./my-agent --check ./check.mjs --watch --interval 15m
npx lousho improve pause ./my-agent --reason "release freeze" --by ali
npx lousho improve resume ./my-agent --by ali
```

Each tick checks the kill switch (`.lousho/improve.stop`, `LOUSHO_IMPROVE=off`
or `"enabled": false`, audited once as `pause`), takes a lease so only one
watcher runs per directory, runs the post-apply monitor, replays the corpus
every `guard.everyHours`, and then runs one cycle within the daily caps. A tick
with the same failures as the last one and no new failed run is skipped.
Three outages in a row write the stop file. Editor calls are limited to
`rateLimit.rpm` a minute. Ctrl-C stops after the current step. `pause` writes
the stop file and `resume` removes it; both are audited.

### `improve.json`

Self-healing settings live in `improve.json` beside the agent. Every key is
Expand All @@ -510,6 +527,7 @@ optional, and an unknown key or a bad value is an error
"monitor": { "minRuns": 20, "window": 20, "maxDrop": 0.05, "autoRollback": true, "giveUpDays": 7 },
"guard": { "runs": 3, "everyHours": 24, "maxCases": 30 },
"cooldownHours": 24,
"rateLimit": { "rpm": 20 },
"library": { "enabled": true }
}
```
Expand Down
18 changes: 18 additions & 0 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5995,6 +5995,23 @@ npx lousho improve reject ./my-agent 3f9c0a1b2d4e --by ali --reason "too broad"
and moves the file to `.lousho/proposals/rejected/`. A rejected proposal is
never queued again.

`--watch` keeps the loop running on an agent directory:

```bash
npx lousho improve ./my-agent --check ./check.mjs --watch --interval 15m
npx lousho improve pause ./my-agent --reason "release freeze" --by ali
npx lousho improve resume ./my-agent --by ali
```

Each tick checks the kill switch (`.lousho/improve.stop`, `LOUSHO_IMPROVE=off`
or `"enabled": false`, audited once as `pause`), takes a lease so only one
watcher runs per directory, runs the post-apply monitor, replays the corpus
every `guard.everyHours`, and then runs one cycle within the daily caps. A tick
with the same failures as the last one and no new failed run is skipped.
Three outages in a row write the stop file. Editor calls are limited to
`rateLimit.rpm` a minute. Ctrl-C stops after the current step. `pause` writes
the stop file and `resume` removes it; both are audited.

### `improve.json`

Self-healing settings live in `improve.json` beside the agent. Every key is
Expand All @@ -6010,6 +6027,7 @@ optional, and an unknown key or a bad value is an error
"monitor": { "minRuns": 20, "window": 20, "maxDrop": 0.05, "autoRollback": true, "giveUpDays": 7 },
"guard": { "runs": 3, "everyHours": 24, "maxCases": 30 },
"cooldownHours": 24,
"rateLimit": { "rpm": 20 },
"library": { "enabled": true }
}
```
Expand Down
48 changes: 48 additions & 0 deletions src/cli/improve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,54 @@ describe('lousho improve, one self-healing cycle', () => {
expect(fs.existsSync(path.join(root, '.lousho'))).toBe(false);
});

it('--watch runs cycles until stopped, and refuses a dry run', async () => {
const db = openDb(dbPath);
ingestBatch(db, stamped('s3'));
db.close();
let stopNow: () => void = () => undefined;
const stop = new Promise<void>((resolve) => {
stopNow = resolve;
});
const lines: string[] = [];
const stdout = new Writable({
write(chunk, _encoding, callback) {
lines.push(String(chunk));
if (String(chunk).startsWith('lousho improve: ')) stopNow();
callback();
},
});
const code = await runImprove([agentDir, '--check', checkPath, '--db', dbPath, '--watch', '--interval', '1h', '--agent', 'support'], {
stdout,
stderr: sink().stream,
overrides: { provider: mockModel([fixedWhenForbidden], { onExhausted: 'repeat-last' }) },
editor: editorFor(FIX),
stop,
});
expect(code).toBe(0);
expect(lines.join('')).toMatch(/lousho improve: applied: Applied/);
expect(instructions()).toBe(FIX);
const dry = await run([agentDir, '--check', checkPath, '--db', dbPath, '--watch', '--dry-run'], { editor: editorFor(FIX) });
expect(dry).toMatchObject({ code: 2, stderr: expect.stringMatching(/--watch needs an agent directory/) });
expect(() => parseImproveArgs([agentDir, '--check', checkPath, '--interval', 'soon'])).toThrow(/--interval must be a positive duration/);
expect(parseImproveArgs([agentDir, '--check', checkPath, '--interval', '30s']).intervalMs).toBe(30_000);
});

it('pause writes the kill switch once and resume removes it, both audited', async () => {
const paused = await run(['pause', agentDir, '--reason', 'maintenance', '--by', 'ana']);
expect(paused).toMatchObject({ code: 0, stdout: expect.stringMatching(/Paused self-healing/) });
expect(fs.readFileSync(path.join(agentDir, '.lousho', 'improve.stop'), 'utf8')).toBe('maintenance\n');
expect((await run(['pause', agentDir])).stdout).toMatch(/already paused/);
const cycle = await run([agentDir, '--check', checkPath, '--db', dbPath], { editor: editorFor(FIX) });
expect(cycle.stdout).toMatch(/lousho improve: paused: Paused: \.lousho\/improve\.stop exists/);
expect(instructions()).toBe('You answer refund questions.');
expect((await run(['resume', agentDir, '--by', 'ana'])).stdout).toMatch(/Resumed self-healing/);
expect(fs.existsSync(path.join(agentDir, '.lousho', 'improve.stop'))).toBe(false);
expect(readAudit(agentDir).map((entry) => [entry.action, entry.actor])).toEqual([
['pause', 'person:ana'],
['resume', 'person:ana'],
]);
});

it('an agent whose instructions are not in instructions.md is forced to dry run', async () => {
fs.mkdirSync(path.join(agentDir, 'instructions'));
fs.writeFileSync(path.join(agentDir, 'instructions', 'claude.md'), 'You answer refund questions for Claude.');
Expand Down
103 changes: 99 additions & 4 deletions src/cli/improve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { scoreCase } from '../improve/cases';
import { agentInputOf } from '../improve/recoveries';
import type { HealCycle } from '../improve/cycles';
import { healOnce } from '../improve/heal';
import { watchAgent } from '../improve/watch';
import { writtenHash } from '../improve/harnessWriter';
import { readAgentHarness } from '../improve/revisions';
import { loadImproveSettings, type ImproveSettings } from '../improve/settings';
Expand All @@ -39,7 +40,7 @@ import { isImproveAdmin, runImproveAdmin } from './improveAdmin';
import { dbPathOf, resolveAgent, sinceMs } from './observe';

const USAGE =
'Usage: lousho improve <agent-dir|agent.ts> --check <module> [--model provider/model] [--editor provider/model] [--db path] [--since <N>d|<N>h|<N>m] [--agent X] [--protect traceId]... [--read-only tool]... [--max-tokens n] [--out file] [--with-recoveries] [--approval auto|manual] [--dry-run]\n lousho improve revisions|rollback|audit|pending|approve|reject <agent-dir> ...';
'Usage: lousho improve <agent-dir|agent.ts> --check <module> [--model provider/model] [--editor provider/model] [--db path] [--since <N>d|<N>h|<N>m] [--agent X] [--protect traceId]... [--read-only tool]... [--max-tokens n] [--out file] [--with-recoveries] [--approval auto|manual] [--dry-run] [--watch [--interval 15m]]\n lousho improve revisions|rollback|audit|pending|approve|reject|pause|resume <agent-dir> ...';

const HELP = `${USAGE}

Expand Down Expand Up @@ -86,6 +87,12 @@ Options:
keeps an edit that quotes a failed check, flagged on the card
--dry-run print the review card and change nothing, from every failed run
whatever its revision, as before self-healing
--watch keep running: every --interval (default 15m) check the kill
switch, take the one-watcher lease, run the post-apply monitor,
the corpus guard every guard.everyHours, then a cycle within
the daily caps. --agent names the agent whose runs the monitor
judges (default: the agent's name). Ctrl-C stops after the step
--interval <N>m the tick interval for --watch, as <N>s, <N>m or <N>h
-h, --help show this help

Revisions, rollback, the audit log and pending proposals (run one with --help for its flags):
Expand All @@ -95,6 +102,8 @@ Revisions, rollback, the audit log and pending proposals (run one with --help fo
lousho improve pending <agent-dir> [--json]
lousho improve approve <agent-dir> <id> --by <name>
lousho improve reject <agent-dir> <id> --by <name> [--reason text]
lousho improve pause <agent-dir> [--reason text] [--by <name>] write the kill switch file
lousho improve resume <agent-dir> [--by <name>] remove it

See docs/improve.md.`;

Expand All @@ -116,6 +125,8 @@ const SPEC: CommandSpec = {
'with-recoveries': { type: 'boolean' },
approval: { type: 'string' },
'dry-run': { type: 'boolean' },
watch: { type: 'boolean' },
interval: { type: 'string' },
},
};

Expand All @@ -137,6 +148,10 @@ export interface ImproveArgs {
approval?: ImproveSettings['approval'];
/** `--dry-run`: print the card and write nothing. */
dryRun?: boolean;
/** `--watch`: keep running cycles on a timer. */
watch?: boolean;
/** `--interval`, in milliseconds. */
intervalMs?: number;
help?: boolean;
}

Expand All @@ -147,6 +162,8 @@ export interface ImproveIo {
overrides?: CreateAgentConfig;
/** The editors' provider, instead of `--editor` (tests pass a mock). */
editor?: LLMProvider;
/** Resolves when `--watch` should stop, instead of SIGINT or SIGTERM (tests pass one). */
stop?: Promise<unknown>;
}

const listValue = (value: unknown): string[] => (Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []);
Expand All @@ -158,6 +175,16 @@ function maxTokensOf(text: string | undefined): number | undefined {
return tokens;
}

const INTERVAL_UNIT_MS: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000 };

function intervalOf(text: string | undefined): Pick<ImproveArgs, 'intervalMs'> {
if (text === undefined) return {};
const match = /^(\d+)([smh])$/.exec(text);
const count = Number(match?.[1] ?? 0);
if (match === null || count <= 0) throw usageError(SPEC, `--interval must be a positive duration like '15m', '30s' or '1h', not '${text}'.`);
return { intervalMs: count * INTERVAL_UNIT_MS[match[2]] };
}

function approvalOf(text: string | undefined): Pick<ImproveArgs, 'approval'> {
if (text === undefined) return {};
if (text !== 'auto' && text !== 'manual') throw usageError(SPEC, `--approval must be 'auto' or 'manual', not '${text}'.`);
Expand Down Expand Up @@ -187,6 +214,8 @@ export function parseImproveArgs(args: string[]): ImproveArgs {
maxTokens: maxTokensOf(stringValue(values['max-tokens'])),
withRecoveries: values['with-recoveries'] === true,
dryRun: values['dry-run'] === true,
watch: values.watch === true,
...intervalOf(stringValue(values.interval)),
...approvalOf(stringValue(values.approval)),
};
}
Expand Down Expand Up @@ -421,11 +450,15 @@ function reportCycle(cycle: HealCycle, args: ImproveArgs, io: ImproveIo): void {
}
}

async function healDir(args: ImproveArgs, io: ImproveIo, dir: string, config: CreateAgentConfig): Promise<number> {
async function cycleInputs(args: ImproveArgs, io: ImproveIo, dir: string, config: CreateAgentConfig) {
const settings = loadImproveSettings(dir, args.approval ? { approval: args.approval } : {});
const editor = editorOf(args, config, io);
const check = await loadCheck(args.check);
const db = openObserveDb(args);
return { settings, editor, check, db: openObserveDb(args) };
}

async function healDir(args: ImproveArgs, io: ImproveIo, dir: string, config: CreateAgentConfig): Promise<number> {
const { settings, editor, check, db } = await cycleInputs(args, io, dir, config);
const controller = new AbortController();
const interrupt = () => controller.abort();
process.once('SIGINT', interrupt);
Expand Down Expand Up @@ -459,15 +492,77 @@ async function healDir(args: ImproveArgs, io: ImproveIo, dir: string, config: Cr
}
}

function stopRequested(io: ImproveIo): { stopped: Promise<unknown>; dispose(): void } {
if (io.stop) return { stopped: io.stop, dispose: () => undefined };
let done: () => void = () => undefined;
const stopped = new Promise<void>((resolve) => {
done = resolve;
});
process.once('SIGINT', done);
process.once('SIGTERM', done);
return {
stopped,
dispose: () => {
process.off('SIGINT', done);
process.off('SIGTERM', done);
},
};
}

async function watchDir(args: ImproveArgs, io: ImproveIo, dir: string, config: CreateAgentConfig): Promise<number> {
const { settings, editor, check, db } = await cycleInputs(args, io, dir, config);
const name = args.agent ?? config.name;
if (name === undefined) throw usageError(SPEC, "--watch needs --agent: the agent doesn't name itself, so the monitor can't find its runs.");
const stop = stopRequested(io);
try {
const agent = resolveAgent(SPEC, db, name);
io.stderr.write(`lousho improve: watching ${dir} in ${settings.approval} mode every ${(args.intervalMs ?? 900_000) / 1000}s; 'lousho improve pause ${dir}' or Ctrl-C stops it.\n`);
await withoutObserve(async () => {
const watcher = watchAgent({
dir,
db,
check,
runCase: runCaseWith(config, args, io, new Map()),
editor,
settings,
reloadSettings: () => loadImproveSettings(dir, args.approval ? { approval: args.approval } : {}),
agent,
protect: args.protect,
withRecoveries: args.withRecoveries === true,
...(args.since !== undefined && { since: args.since }),
...(args.intervalMs !== undefined && { intervalMs: args.intervalMs }),
onCycle: (cycle) => io.stdout.write(`lousho improve: ${cycle.outcome}: ${cycle.reason}\n`),
onError: (error) => io.stderr.write(`lousho improve: a watch tick failed: ${error instanceof Error ? error.message : String(error)}\n`),
});
await stop.stopped;
await watcher.stop();
});
return 0;
} finally {
stop.dispose();
db.close();
}
}

function assertWatchArgs(args: ImproveArgs, target: DevTarget, forced: string | undefined): void {
if (!args.watch) {
if (args.intervalMs !== undefined) throw usageError(SPEC, '--interval only applies with --watch.');
return;
}
if (args.maxTokens !== undefined || args.out !== undefined) throw usageError(SPEC, '--watch takes its token budget from improve.json and writes no --out file; drop --max-tokens and --out.');
if (args.dryRun || forced || target.kind !== 'dir') throw usageError(SPEC, `--watch needs an agent directory whose instructions live in instructions.md, and can't be a dry run${forced ? `: ${forced}` : ''}.`);
}

async function improve(args: ImproveArgs, io: ImproveIo): Promise<number> {
const target = detectTarget(args.path, 'improve');
const config = await loadConfig(target);
if (args.out !== undefined) assertOutUsable(args.out, target, config);
const harness = baseHarness(config);
const forced = args.dryRun ? undefined : dryRunReason(target, harness);
assertWatchArgs(args, target, forced);
if (forced) io.stderr.write(`lousho improve: this is a dry run, because ${forced}. Nothing will be applied.\n`);
if (args.dryRun || forced || target.kind !== 'dir') return proposeOnly(args, io, target, config, harness);
return healDir(args, io, target.path, config);
return args.watch ? watchDir(args, io, target.path, config) : healDir(args, io, target.path, config);
}

/** Runs `lousho improve` with the arguments after `improve`; resolves with the exit code. */
Expand Down
3 changes: 2 additions & 1 deletion src/cli/improveAdmin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { readAudit, verifyAudit, type AuditEntry } from '../improve/audit';
import { approvePending, isPendingId, listPending, readPendingProposal, rejectPending } from '../improve/pending';
import { currentRevision, listRevisions, type HarnessRevision } from '../improve/revisions';
import { parseCommand, stringValue, usageError, type CommandSpec } from './args';
import { pause, resume } from './improvePause';

interface AdminIo {
stdout: Writable;
Expand Down Expand Up @@ -186,7 +187,7 @@ async function reject(args: readonly string[], io: AdminIo): Promise<number> {
return 0;
}

const COMMANDS: Record<string, (args: readonly string[], io: AdminIo) => number | Promise<number>> = { revisions, rollback, audit, pending, approve, reject };
const COMMANDS: Record<string, (args: readonly string[], io: AdminIo) => number | Promise<number>> = { revisions, rollback, audit, pending, approve, reject, pause, resume };

/** Whether the arguments after `improve` name an admin subcommand rather than an agent path. */
export function isImproveAdmin(args: readonly string[]): boolean {
Expand Down
46 changes: 46 additions & 0 deletions src/cli/improvePause.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import * as path from 'node:path';
import type { Writable } from 'node:stream';
import { pauseImprove, resumeImprove } from '../improve/pause';
import { parseCommand, stringValue, usageError, type CommandSpec, type ParsedCommand } from './args';

interface PauseIo {
stdout: Writable;
stderr: Writable;
}

const PAUSE: CommandSpec = {
command: 'improve pause',
usage: 'Usage: lousho improve pause <agent-dir> [--reason text] [--by <name>]',
positionals: 1,
options: { reason: { type: 'string' }, by: { type: 'string' } },
};
const RESUME: CommandSpec = { command: 'improve resume', usage: 'Usage: lousho improve resume <agent-dir> [--by <name>]', positionals: 1, options: { by: { type: 'string' } } };

function dirOf(spec: CommandSpec, args: readonly string[], io: PauseIo): { dir: string; values: ParsedCommand['values'] } | undefined {
const { values, positionals, help } = parseCommand(spec, [...args]);
if (help) {
io.stdout.write(`${spec.usage}\n`);
return undefined;
}
if (positionals.length !== 1) throw usageError(spec, 'an agent directory is required.');
return { dir: path.resolve(positionals[0]), values };
}

export async function pause(args: readonly string[], io: PauseIo): Promise<number> {
const parsed = dirOf(PAUSE, args, io);
if (!parsed) return 0;
const by = stringValue(parsed.values.by);
const reason = stringValue(parsed.values.reason);
const done = await pauseImprove(parsed.dir, { ...(by !== undefined && { by }), ...(reason !== undefined && { reason }) });
io.stdout.write(done ? `Paused self-healing in ${parsed.dir}: wrote .lousho/improve.stop. A running watcher stops at its next tick.\n` : `Self-healing in ${parsed.dir} was already paused.\n`);
return 0;
}

export async function resume(args: readonly string[], io: PauseIo): Promise<number> {
const parsed = dirOf(RESUME, args, io);
if (!parsed) return 0;
const by = stringValue(parsed.values.by);
const done = await resumeImprove(parsed.dir, by !== undefined ? { by } : {});
io.stdout.write(done ? `Resumed self-healing in ${parsed.dir}: removed .lousho/improve.stop.\n` : `Self-healing in ${parsed.dir} was not paused by the stop file.\n`);
return 0;
}
Loading
Loading