diff --git a/CHANGELOG.md b/CHANGELOG.md index 94edd1a1..1229b63d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,30 @@ new version heading in the same commit. ## [Unreleased] +## [0.447.0] - 2026-09-17 +### Added +- **Focus check — a nudge when a session goes down a rabbit hole.** The progress verdicts could only see a + run's *shape* (`circling` = the same action repeated, `stuck` = silence), and a rabbit hole has neither: + it is busy, varied work on the wrong thing, so it read as `forward`. Now, once a run has taken ~20 + actions (then every ~20 more, at most every 8 min), Haiku compares its recent work against the ask — + the original request as amended by every later human message, each paired with the agent proposal it + answered, so a bare "go" / "merge both" counts as approval. The judge runs out of band (the gate never + waits on it), and a `drifted` first vote is confirmed by two more (majority of 3, confidence ≥ 0.8) + before anything happens. A confirmed drift shows as a new **`drifting`** verdict on the session strip + (below `blocked`/`circling`, above `stuck`) and parks an advisory note on the agent's next tool call + (allow + `additionalContext`) suggesting it record the side-finding with `task_create` and return. If + the next check still says drifted, an unattended run's owner gets ONE Inbox card per streak; + interactive runs are never carded. Never blocks or stops anything. Backend: the workspace Anthropic key + (pinned to Haiku) when set, else a pooled `claude -p --model claude-haiku-4-5`. Modes in Settings → + Governance (`nudge` default · `observe` · `off`), per-agent opt-out `driftCheck: false`, process kill + switch `AOS_DRIFT=0`. Audited `drift.judged` (verdict, votes, outcome) / `drift.judge_failed` / + `drift.nudged` / `drift.escalated`. Tuned on a replay of 30 long live sessions (90 checkpoints): 2 + genuine rabbit holes confirmed (one from the held-out half), 0 false positives at the shipped floor — + the replay also caught harness injections (``, skill bodies) being read as human + messages, now filtered. Pinned by `scripts/drift-nudge-test.cjs`. + **For users:** when an agent wanders off what you asked for, its session now says **drifting** and the agent gets a gentle check-in to note the tangent and get back on track. Unattended runs that keep wandering send you one Inbox card. + **For admins:** the focus check can be set to Nudge, Observe only or Off in [Settings → Governance](#/settings/governance). + ## [0.446.1] - 2026-09-17 ### Fixed - **The pause test raced the launcher and blocked the deploy.** `session-pause-test.cjs`'s HTTP section diff --git a/docs/drift-focus-check.md b/docs/drift-focus-check.md new file mode 100644 index 00000000..601ce85c --- /dev/null +++ b/docs/drift-focus-check.md @@ -0,0 +1,77 @@ +# Focus check (drift nudge) + +Code: `src/edge/drift.ts` · wiring: `TerminalManager.driftObserve` / `runDriftJudge` in `src/terminal.ts` · +test: `scripts/drift-nudge-test.cjs`. + +## Why + +The progress verdicts (`src/state/session-progress.ts`) judge a run's shape: `circling` (the same action +repeated) and `stuck` (silence). A rabbit hole has neither: busy, varied, plausible work on the wrong +thing reads as `forward`. Catching it means comparing what the run is doing against what it was asked, +which takes a model. + +## How + +1. **Trigger (deterministic, free).** Count allowed top-level tool calls per session. First check at 20; + then every 20 more, at most once per 8 min. One judgement in flight per session; three consecutive + backend failures stop judging that session. +2. **Judge (Haiku, out of band).** Input: the original request, every later *human* message paired with + the agent message it replied to (head + tail, since proposals sit at the end), and the transcript tail. + Harness injections in the user role (``, skill bodies, command plumbing) are not + human messages and are filtered (`isHumanText`). The model first states the `current_ask`, then a + verdict: `on_track` / `supporting` / `drifted`. +3. **Confirmation.** A `drifted` first vote (confidence ≥ 0.8) triggers two more votes in parallel; the run + counts as drifting only on a majority. The CLI lane has no temperature control and single votes + flipped on identical input in the replay. +4. **Delivery.** + - `detected` (first confirmed drift): an advisory note rides out on the agent's NEXT allowed tool call + via `additionalContext`. The copy is branded, says what it saw, allows "this is needed, carry on", + and suggests `task_create` for the side-finding. + - `escalated` (still drifting at the next check): one more note, plus ONE `sessionOwner` Inbox card, only + for an unattended, unclaimed run. + - `persisting`: record only. `cleared`: the streak resets. + - The strip shows `drifting` for 30 min after a drifted judgement unless a newer one clears it. + Precedence: `blocked` > `circling` > `drifting` > `stuck` > `forward`. + +Never blocks, denies or stops anything. + +## Controls + +| Control | Where | +|---|---| +| Mode `nudge` (default) · `observe` · `off` | Settings → Governance (`settings: drift_nudge_mode`, owner/admin) | +| Per-agent opt-out | `driftCheck: false` in `agent.json` (for exploratory agents) | +| Process kill switch | `AOS_DRIFT=0` | +| Judge model | `AOS_DRIFT_MODEL` (default `claude-haiku-4-5`) | + +Backend: the workspace Anthropic key when set (pinned to Haiku whatever the Q&A model is), else an +OpenAI-compatible router LLM, else a pooled `claude -p --model `, the summarizer's lane +(`runClaudePrompt`). + +## Audit + +`drift.judged` {verdict, confidence, tangent, reason, outcome, mode, via, votes, ask} · +`drift.judge_failed` {via, reason} · `drift.nudged` · `drift.escalated`. All four are NOISE for the +activity trail. + +## Calibration (2026-09-17) + +Replay over 30 long instapods sessions (≥145 gated actions each), 3 checkpoints per session, using the +production prompt and the CLI lane. + +| Iteration | Change | Drifted / 42 (tuning set) | +|---|---|---| +| v1 | first prompt | 8, mostly false positives | +| v2 | filter harness injections; pair human replies with the proposal they answered | 3 | +| v3 | `current_ask` first; the fallout of the agent's own work counts as `supporting` | 2 | +| v4 | keep the tail of the approved proposal | 3 (single votes flicker run to run) | +| v5 | 2-of-3 confirmation, across 90 checkpoints (tuning + held-out) | 3 confirmed: 2 genuine, 1 FP at 0.75 | + +The floor was then raised to 0.8, which removes that FP. The two genuine hits: +- An agent asked only questions about tooling shipped an instrumentation PR. +- An agent told "the PR is done, this needs a founder decision, not more engineering" re-implemented the + feature from scratch. This one came from the held-out set, 3/3 votes. + +Cost: 102 model calls for 90 checks. CLI latency p50 ~14 s, which is irrelevant out of band. + +Two hits is a small sample. Re-derive the floor from `drift.judged` rows after a tenant runs in `observe`. diff --git a/package-lock.json b/package-lock.json index fecf137a..60023731 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.446.1", + "version": "0.447.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.446.1", + "version": "0.447.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 62379607..0ff1a510 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.446.1", + "version": "0.447.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", @@ -27,7 +27,7 @@ "check-deps": "bash scripts/install-deps.sh --check", "dev": "ts-node src/cli.ts serve", "demo:dev": "ts-node src/demo.ts", - "test:governance": "node scripts/version-sync-test.cjs && node scripts/governance-conformance.cjs && node scripts/tier-a-policy-test.cjs && node scripts/policy-baseline-test.cjs && node scripts/heredoc-intent-test.cjs && node scripts/capability-registry-test.cjs && node scripts/composio-envelope-test.cjs && node scripts/composio-identity-test.cjs && node scripts/idle-reaper-test.cjs && node scripts/dm-continuity-test.cjs && node scripts/telegram-dm-lane-test.cjs && node scripts/cli-link-origin-test.cjs && node scripts/alert-staleness-test.cjs && node scripts/run-as-identity-test.cjs && node scripts/email-identity-guard-test.cjs && node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-usage-refresh-test.cjs && node scripts/keychain-credential-test.cjs && node scripts/credential-preflight-test.cjs && node scripts/runtime-login-test.cjs && node scripts/rotate-on-reload-test.cjs && node scripts/headless-resumable-test.cjs && node scripts/session-revive-gates-test.cjs && node scripts/session-pause-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs && node scripts/output-style-test.cjs && node scripts/session-cost-test.cjs && node scripts/chain-model-test.cjs && node scripts/task-workers-test.cjs && node scripts/tuning-patch-test.cjs && node scripts/task-runs-test.cjs && node scripts/task-pr-links-test.cjs && node scripts/task-draft-delete-test.cjs && node scripts/task-discussion-delivery-test.cjs && node scripts/task-resume-test.cjs && node scripts/task-unblock-test.cjs && node scripts/audience-session-access-test.cjs && node scripts/warm-chat-test.cjs && node scripts/poke-warm-caller-test.cjs && node scripts/wakeup-queue-test.cjs && node scripts/stranded-human-stop-test.cjs && node scripts/inject-submit-test.cjs && node scripts/blocked-routing-test.cjs && node scripts/self-dispatch-guard-test.cjs && node scripts/task-proposals-test.cjs && node scripts/npm-boundary-test.cjs && node scripts/agent-edit-guard-test.cjs && node scripts/per-agent-context-test.cjs && node scripts/goal-update-guard-test.cjs && node scripts/insights-signal-test.cjs && node scripts/outcome-derivation-test.cjs && node scripts/episode-quality-test.cjs && node scripts/memory-upkeep-test.cjs && node scripts/automem-health-test.cjs && node scripts/memory-store-switch-test.cjs && node scripts/memory-preload-test.cjs && node scripts/turn-lifecycle-test.cjs && node scripts/resume-seed-test.cjs && node scripts/outcome-vocabulary-test.cjs && node scripts/skill-presets-test.cjs && node scripts/skill-edit-proposal-test.cjs && node scripts/notify-hook-route-test.cjs && node scripts/review-notify-test.cjs && node scripts/turn-idle-background-guard-test.cjs && node scripts/waiting-brief-test.cjs && node scripts/runtime-death-alert-test.cjs && node scripts/github-per-member-test.cjs && node scripts/github-multi-org-test.cjs && node scripts/card-measurement-test.cjs && node scripts/scheduler-admission-test.cjs && node scripts/tick-liveness-test.cjs && node scripts/audit-mirror-test.cjs && node scripts/request-metrics-test.cjs && node scripts/tool-usage-test.cjs && node scripts/sessions-list-perf-test.cjs && node scripts/summarizer-degradation-test.cjs && node scripts/agent-history-scope-test.cjs && node scripts/webhook-ingress-test.cjs && node scripts/slack-content-filter-test.cjs && node scripts/slack-ingress-test.cjs && node scripts/discord-ingress-test.cjs && node scripts/chat-attachments-test.cjs && node scripts/clickup-task-bridge-test.cjs && node scripts/agentric-commands-test.cjs && node scripts/whats-new-test.cjs && node scripts/opencode-gate-test.cjs && node scripts/protected-path-guard-test.cjs && node scripts/attach-grace-test.cjs && node scripts/attach-file-liveness-test.cjs && node scripts/feed-smoke.cjs && node scripts/activity-classify-test.cjs && node scripts/goal-room-test.cjs && node scripts/secret-rotation-test.cjs && node scripts/update-watch-test.cjs && node scripts/runtime-update-watch-test.cjs && node scripts/setup-wizard-test.cjs && node scripts/md-pdf-test.cjs && node scripts/proposal-surfacing-test.cjs && node scripts/process-janitor-test.cjs && node scripts/detached-work-steer-test.cjs && node scripts/statusline-install-test.cjs && node scripts/docs-create-agent-test.cjs && node scripts/agent-stats-rollup-test.cjs && node scripts/task-discussion-rollup-test.cjs && node scripts/session-insights-stamp-test.cjs && node scripts/loop-stall-attribution-test.cjs && node scripts/session-progress-test.cjs && node scripts/goal-metric-review-test.cjs && node scripts/capability-gap-test.cjs && node scripts/workflow-proposal-test.cjs && node scripts/automation-edit-proposal-test.cjs", + "test:governance": "node scripts/version-sync-test.cjs && node scripts/governance-conformance.cjs && node scripts/tier-a-policy-test.cjs && node scripts/policy-baseline-test.cjs && node scripts/heredoc-intent-test.cjs && node scripts/capability-registry-test.cjs && node scripts/composio-envelope-test.cjs && node scripts/composio-identity-test.cjs && node scripts/idle-reaper-test.cjs && node scripts/dm-continuity-test.cjs && node scripts/telegram-dm-lane-test.cjs && node scripts/cli-link-origin-test.cjs && node scripts/alert-staleness-test.cjs && node scripts/run-as-identity-test.cjs && node scripts/email-identity-guard-test.cjs && node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-usage-refresh-test.cjs && node scripts/keychain-credential-test.cjs && node scripts/credential-preflight-test.cjs && node scripts/runtime-login-test.cjs && node scripts/rotate-on-reload-test.cjs && node scripts/headless-resumable-test.cjs && node scripts/session-revive-gates-test.cjs && node scripts/session-pause-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs && node scripts/output-style-test.cjs && node scripts/session-cost-test.cjs && node scripts/chain-model-test.cjs && node scripts/task-workers-test.cjs && node scripts/tuning-patch-test.cjs && node scripts/task-runs-test.cjs && node scripts/task-pr-links-test.cjs && node scripts/task-draft-delete-test.cjs && node scripts/task-discussion-delivery-test.cjs && node scripts/task-resume-test.cjs && node scripts/task-unblock-test.cjs && node scripts/audience-session-access-test.cjs && node scripts/warm-chat-test.cjs && node scripts/poke-warm-caller-test.cjs && node scripts/wakeup-queue-test.cjs && node scripts/stranded-human-stop-test.cjs && node scripts/inject-submit-test.cjs && node scripts/blocked-routing-test.cjs && node scripts/self-dispatch-guard-test.cjs && node scripts/task-proposals-test.cjs && node scripts/npm-boundary-test.cjs && node scripts/agent-edit-guard-test.cjs && node scripts/per-agent-context-test.cjs && node scripts/goal-update-guard-test.cjs && node scripts/insights-signal-test.cjs && node scripts/outcome-derivation-test.cjs && node scripts/episode-quality-test.cjs && node scripts/memory-upkeep-test.cjs && node scripts/automem-health-test.cjs && node scripts/memory-store-switch-test.cjs && node scripts/memory-preload-test.cjs && node scripts/turn-lifecycle-test.cjs && node scripts/resume-seed-test.cjs && node scripts/outcome-vocabulary-test.cjs && node scripts/skill-presets-test.cjs && node scripts/skill-edit-proposal-test.cjs && node scripts/notify-hook-route-test.cjs && node scripts/review-notify-test.cjs && node scripts/turn-idle-background-guard-test.cjs && node scripts/waiting-brief-test.cjs && node scripts/runtime-death-alert-test.cjs && node scripts/github-per-member-test.cjs && node scripts/github-multi-org-test.cjs && node scripts/card-measurement-test.cjs && node scripts/scheduler-admission-test.cjs && node scripts/tick-liveness-test.cjs && node scripts/audit-mirror-test.cjs && node scripts/request-metrics-test.cjs && node scripts/tool-usage-test.cjs && node scripts/sessions-list-perf-test.cjs && node scripts/summarizer-degradation-test.cjs && node scripts/agent-history-scope-test.cjs && node scripts/webhook-ingress-test.cjs && node scripts/slack-content-filter-test.cjs && node scripts/slack-ingress-test.cjs && node scripts/discord-ingress-test.cjs && node scripts/chat-attachments-test.cjs && node scripts/clickup-task-bridge-test.cjs && node scripts/agentric-commands-test.cjs && node scripts/whats-new-test.cjs && node scripts/opencode-gate-test.cjs && node scripts/protected-path-guard-test.cjs && node scripts/attach-grace-test.cjs && node scripts/attach-file-liveness-test.cjs && node scripts/feed-smoke.cjs && node scripts/activity-classify-test.cjs && node scripts/goal-room-test.cjs && node scripts/secret-rotation-test.cjs && node scripts/update-watch-test.cjs && node scripts/runtime-update-watch-test.cjs && node scripts/setup-wizard-test.cjs && node scripts/md-pdf-test.cjs && node scripts/proposal-surfacing-test.cjs && node scripts/process-janitor-test.cjs && node scripts/detached-work-steer-test.cjs && node scripts/statusline-install-test.cjs && node scripts/docs-create-agent-test.cjs && node scripts/agent-stats-rollup-test.cjs && node scripts/task-discussion-rollup-test.cjs && node scripts/session-insights-stamp-test.cjs && node scripts/loop-stall-attribution-test.cjs && node scripts/session-progress-test.cjs && node scripts/drift-nudge-test.cjs && node scripts/goal-metric-review-test.cjs && node scripts/capability-gap-test.cjs && node scripts/workflow-proposal-test.cjs && node scripts/automation-edit-proposal-test.cjs", "test:alert-staleness": "node scripts/alert-staleness-test.cjs", "test:deps": "node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-login-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs", "test:dm-continuity": "node scripts/dm-continuity-test.cjs", diff --git a/scripts/drift-nudge-test.cjs b/scripts/drift-nudge-test.cjs new file mode 100644 index 00000000..1e621e60 --- /dev/null +++ b/scripts/drift-nudge-test.cjs @@ -0,0 +1,337 @@ +#!/usr/bin/env node +/* DRIFT FOCUS CHECK — the rabbit-hole nudge (src/edge/drift.ts). + * + * What has to hold, in the order it matters: + * + * 1. It never costs the gate anything. The judge runs out of band; a tool call is never delayed, and a + * broken/garbled/absent judge can only ever mean "no nudge" — never a false "drifted". + * 2. It is quiet by construction: nothing before a run has done real work, one judgement per interval, + * a low-confidence "drifted" is ignored, one human card per drifting streak (and none for a run a + * human is already watching). + * 3. The note is advisory copy, not an order — coercive framing is flagged by the model as injection. + * 4. The progress strip says `drifting`, below the more certain `circling`/`blocked`, and stops saying + * it once the run comes back or the verdict ages out. + * + * Run: node scripts/drift-nudge-test.cjs (needs `npm run build` first) */ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const http = require('http'); + +const ROOT = path.resolve(__dirname, '..'); +const HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'aos-drift-test-')); +process.env.AGENT_OS_HOME = HOME; +process.env.AGENT_OS_TENANT = 'testco'; +process.env.AOS_NO_TTYD = '1'; +delete process.env.AGENT_OS_SECRET_KEY; +delete process.env.AOS_DRIFT; + +let pass = 0, fail = 0; +const assert = (c, name, d) => c ? (pass++, console.log(` \x1b[32m✓\x1b[0m ${name}`)) : (fail++, console.log(` \x1b[31m✗ ${name}\x1b[0m${d ? ' — ' + d : ''}`)); +const eq = (a, b, name) => assert(a === b, name, `got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`); + +const D = require(path.join(ROOT, 'dist/edge/drift.js')); +const { deriveProgress } = require(path.join(ROOT, 'dist/state/session-progress.js')); +const { classifyActivity } = require(path.join(ROOT, 'dist/state/session-activity.js')); + +const NOW = 1_700_000_000_000; +const MIN = 60_000; +const J = (verdict, confidence = 0.9, tangent = 'the CI cache config', reason = 'none of it touches the webhook') => ({ verdict, confidence, tangent, reason }); + +console.log('\n\x1b[1mparseDriftJudgement — a garbled judge never reads as drifted\x1b[0m'); +{ + const p = D.parseDriftJudgement; + eq(p('{"verdict":"drifted","confidence":0.82,"tangent":"CI cache","reason":"unrelated"}')?.verdict, 'drifted', 'plain JSON'); + eq(p('```json\n{"verdict":"supporting","confidence":0.6,"tangent":"x","reason":"y"}\n```')?.verdict, 'supporting', 'fenced JSON'); + eq(p('Sure! {"verdict":"on_track","confidence":1,"tangent":"","reason":""} hope that helps')?.verdict, 'on_track', 'prose around the object'); + eq(p('{"verdict":"maybe","confidence":0.9}'), null, 'unknown verdict → null'); + eq(p('drifted'), null, 'bare word → null'); + eq(p('{not json}'), null, 'broken JSON → null'); + eq(p(null), null, 'no reply → null'); + eq(p('{"verdict":"drifted","confidence":7}').confidence, 1, 'confidence clamped to 1'); + eq(p('{"verdict":"drifted","confidence":"high"}').confidence, 0, 'non-numeric confidence → 0 (so it can never clear the floor)'); +} + +console.log('\n\x1b[1mbuildDriftInput — what the judge sees\x1b[0m'); +{ + const convo = (turns) => ({ found: true, turns }); + eq(D.buildDriftInput('', convo([{ kind: 'assistant', text: 'hi', ts: 1 }])), null, 'no ask → nothing to judge'); + eq(D.buildDriftInput('fix it', { found: false, turns: [] }), null, 'no transcript → nothing to judge'); + const turns = [ + { kind: 'user', text: 'fix the refund webhook', ts: 1 }, + { kind: 'activity', tool: 'Bash', label: 'Ran a command', detail: 'npm test', status: 'error', ts: 2 }, + { kind: 'user', text: 'actually, look at the CI cache first', ts: 3 }, + { kind: 'assistant', text: 'Looking at CI.', ts: 4 }, + ]; + const input = D.buildDriftInput('fix the refund webhook', convo(turns)); + assert(input.startsWith('ORIGINAL REQUEST:\nfix the refund webhook'), 'leads with the original request'); + assert(/LATER HUMAN MESSAGES[\s\S]*look at the CI cache first/.test(input), 'a human redirection is surfaced as its own section (the latest instruction IS the ask)'); + assert(!/LATER HUMAN MESSAGES[\s\S]*- fix the refund webhook/.test(input), 'the first human turn (the ask itself) is not repeated as a redirection'); + assert(input.includes('· Ran a command — npm test [failed]'), 'tool activity rendered with status'); + const many = Array.from({ length: 400 }, (_, i) => ({ kind: 'assistant', text: `step ${i} ` + 'x'.repeat(280), ts: i })); + const big = D.buildDriftInput('ask', convo(many)); + assert(big.length < 13_000, 'bounded input on a long run', `len ${big.length}`); + assert(big.includes('step 399'), 'keeps the TAIL — the rabbit hole is at the end'); +} + +console.log('\n\x1b[1mDriftMonitor — trigger cadence\x1b[0m'); +{ + const m = new D.DriftMonitor(); + let fired = 0; + for (let i = 1; i < D.DRIFT_FIRST_CHECK_ACTIONS; i++) if (m.observe('s', NOW)) fired++; + eq(fired, 0, `no judgement before ${D.DRIFT_FIRST_CHECK_ACTIONS} actions`); + assert(m.observe('s', NOW), `judgement due at action ${D.DRIFT_FIRST_CHECK_ACTIONS}`); + let during = 0; + for (let i = 0; i < 50; i++) if (m.observe('s', NOW + 20 * MIN)) during++; + eq(during, 0, 'at most one judgement in flight per session'); + m.record('s', J('on_track')); + eq(m.observe('s', NOW + MIN), false, 'enough actions but inside the min interval → not yet'); + assert(m.observe('s', NOW + D.DRIFT_MIN_INTERVAL_MS + MIN), 'actions + interval both satisfied → due'); + m.abort('s'); + const m2 = new D.DriftMonitor(); + for (let i = 0; i < D.DRIFT_FIRST_CHECK_ACTIONS - 1; i++) m2.observe('x', NOW); + let t = NOW; + let judged = 0; + for (let round = 0; round < 10; round++) { + for (let i = 0; i < D.DRIFT_EVERY_ACTIONS; i++) if (m2.observe('x', t)) { judged++; m2.abort('x'); } + t += D.DRIFT_MIN_INTERVAL_MS; + } + eq(judged, D.DRIFT_MAX_FAILURES, `a failing backend is given up on after ${D.DRIFT_MAX_FAILURES} failures`); + const m3 = new D.DriftMonitor(); + let t3 = NOW; + for (let i = 0; i < D.DRIFT_FIRST_CHECK_ACTIONS - 1; i++) m3.observe('y', t3); + let clean = 0; + for (let round = 0; round < 10; round++) { + for (let k = 0; k < D.DRIFT_EVERY_ACTIONS; k++) if (m3.observe('y', t3)) { clean++; m3.abort('y', false); } + t3 += D.DRIFT_MIN_INTERVAL_MS; + } + eq(clean, 10, 'a non-failure abort (nothing to read yet) never counts toward giving up'); +} + +console.log('\n\x1b[1mDriftMonitor — streak outcomes\x1b[0m'); +{ + const m = new D.DriftMonitor(); + eq(m.record('s', J('on_track')), 'none', 'on_track, no streak → none'); + eq(m.record('s', J('drifted', 0.5)), 'none', `a drifted verdict below ${D.DRIFT_MIN_CONFIDENCE} confidence is not acted on`); + eq(m.record('s', J('drifted'), 'NOTE-1'), 'detected', 'first confident drift → detected (nudge)'); + eq(m.takeNote('s'), 'NOTE-1', 'the note is parked for the next tool call'); + eq(m.takeNote('s'), undefined, '… and delivered once'); + eq(m.record('s', J('drifted'), 'NOTE-2'), 'escalated', 'still drifted after the nudge → escalated (human told)'); + eq(m.takeNote('s'), 'NOTE-2', 'escalation also re-nudges the agent'); + eq(m.record('s', J('drifted'), 'NOTE-3'), 'persisting', 'still drifted after escalation → persisting (no second card)'); + eq(m.takeNote('s'), undefined, 'persisting parks no further note (no nagging)'); + eq(m.record('s', J('supporting')), 'cleared', 'back to supporting work → cleared'); + eq(m.record('s', J('drifted'), 'NOTE-4'), 'detected', 'a NEW streak starts over at detected'); + eq(m.record('s', J('on_track')), 'cleared', 'clearing drops the streak …'); + eq(m.takeNote('s'), undefined, '… and any undelivered note with it'); + const obs = new D.DriftMonitor(); + obs.record('o', J('drifted'), undefined); + eq(obs.takeNote('o'), undefined, 'observe mode (no note passed) never parks a nudge'); + m.forget('s'); + eq(m.record('s', J('drifted'), 'N'), 'detected', 'forget() resets the session'); +} + +console.log('\n\x1b[1mCopy — advisory, not coercive\x1b[0m'); +{ + const note = D.driftNote('Fix the refund webhook\nmore detail below', J('drifted')); + assert(note.startsWith('Agentric focus check:'), 'branded'); + assert(note.includes('“Fix the refund webhook”'), 'quotes the ask gist (first line only)'); + assert(note.includes('the CI cache config'), 'names what the work is about'); + assert(/carry on/.test(note) && /can be wrong/.test(note), 'explicitly allows "this is needed, carry on"'); + assert(note.includes('task_create'), 'offers the constructive move: park the tangent as a task'); + assert(!/\b(?:MUST|DO NOT|NEVER|REQUIRED|immediately)\b/.test(note), 'no coercive framing'); + const body = D.driftEscalationBody('Fix it', J('drifted')); + assert(/Asked: “Fix it”/.test(body) && /Now working on: the CI cache config/.test(body), 'escalation card says asked vs now'); + eq(D.askGist('\n\n Title line \nrest'), 'Title line', 'gist = first non-empty line'); +} + +console.log('\n\x1b[1mProgress strip — `drifting`\x1b[0m'); +{ + const base = { now: NOW, claims: [], lastActivityTs: NOW - MIN, loopTs: null, awaiting: null }; + const drift = (agoMin, drifting = true) => ({ ts: NOW - agoMin * MIN, drifting, tangent: 'the CI cache config' }); + const v = (o) => deriveProgress({ ...base, ...o }); + eq(v({ drift: drift(2) }).verdict, 'drifting', 'a recent drifted judgement → drifting'); + assert(/CI cache/.test(v({ drift: drift(2) }).reason), 'reason names the tangent'); + eq(v({ drift: drift(2, false) }).verdict, 'forward', 'a newer clearing judgement → not drifting'); + eq(v({ drift: drift(45) }).verdict, 'forward', 'a drifted judgement older than the window ages out'); + eq(v({ drift: drift(2), awaiting: 'approval' }).verdict, 'blocked', 'blocked outranks drifting'); + eq(v({ drift: drift(2), loopTs: NOW - MIN, loopCount: 6 }).verdict, 'circling', 'circling (the more certain diagnosis) outranks drifting'); + eq(v({ drift: drift(2), lastActivityTs: NOW - 20 * MIN }).verdict, 'drifting', 'drifting outranks stuck'); + eq(v({}).verdict, 'forward', 'no judgement at all → unchanged behaviour'); +} + +console.log('\n\x1b[1mActivity trail — drift bookkeeping is not agent activity\x1b[0m'); +for (const t of ['drift.judged', 'drift.judge_failed', 'drift.nudged', 'drift.escalated']) { + eq(classifyActivity(t, {}), null, `${t} is noise (never "activity", never keeps a quiet run looking busy)`); +} + +// ─── End-to-end through the real gate ─────────────────────────────────────────────────────────────── +(async () => { + console.log('\n\x1b[1mEnd-to-end — TerminalManager.gate + a stub Anthropic API\x1b[0m'); + let reply = '{"verdict":"drifted","confidence":0.9,"tangent":"the CI cache config","reason":"nothing here touches the webhook"}'; + // Optional per-request override queue, consumed before `reply` — lets a test script a split vote. + const queue = []; + const requests = []; + const stub = http.createServer((req, res) => { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + requests.push({ url: req.url, key: req.headers['x-api-key'], body: JSON.parse(body) }); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ content: [{ type: 'text', text: queue.length ? queue.shift() : reply }] })); + }); + }); + await new Promise((r) => stub.listen(0, '127.0.0.1', r)); + process.env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${stub.address().port}`; + + const { loadAgentOS } = require(path.join(ROOT, 'dist/kernel.js')); + const { TerminalManager } = require(path.join(ROOT, 'dist/terminal.js')); + const aos = loadAgentOS(); + const tm = new TerminalManager(aos, 'http://127.0.0.1:0', path.join(HOME, 'tmux.sock'), 'https://aos.example.com'); + aos.settings.setAnthropicKey('sk-test-drift'); + aos.settings.setAnthropicModel('claude-opus-5'); // the judge must pin Haiku regardless of the Q&A model + for (const id of ['builder', 'explorer']) { + const dir = path.join(HOME, `agent-${id}`); + fs.mkdirSync(dir, { recursive: true }); + aos.agents.set(id, { id, name: id, runtime: 'claude-code', dir, ...(id === 'explorer' ? { driftCheck: false } : {}) }); + } + const owner = aos.team.invite({ email: 'owner@example.com', role: 'owner' }).member.id; + let seq = 0; + const mkRun = (agent, headless) => { + const id = `ses_drift_${++seq}`; + const t = Date.now(); + aos.db.prepare(`INSERT INTO term_sessions (id,agent,title,task,tmux,status,spawned_by,run_as,headless,created_at,updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`).run(id, agent, 'refund fix', 'Fix the refund webhook so Stripe retries stop failing', `t${seq}`, 'running', owner, owner, headless ? 1 : 0, t, t); + return id; + }; + tm.sessionConversation = () => ({ found: true, turns: [ + { kind: 'user', text: 'Fix the refund webhook so Stripe retries stop failing', ts: 1 }, + { kind: 'activity', tool: 'Bash', label: 'Ran a command', detail: 'cat .github/workflows/ci.yml', status: 'ok', ts: 2 }, + { kind: 'assistant', text: 'The CI cache key looks wrong, let me rework the whole cache layer.', ts: 3 }, + ] }); + const settle = () => new Promise((r) => setTimeout(r, 150)); + let cmdSeq = 0; + const bash = (sid, agent, n) => { + const out = []; + // Distinct, digit-free commands: the loop detector folds digit runs together, and its own note must + // not be mistaken for (or mask) the drift note under test. + const word = () => String(++cmdSeq).replace(/\d/g, (d) => 'abcdefghij'[d]); + for (let i = 0; i < n; i++) out.push(tm.gate(sid, agent, 'shell.exec', { tool: 'Bash', input: { command: `ls ${word()}` } }, 'look around')); + return out; + }; + const audits = (sid, type) => aos.db.prepare('SELECT data FROM audit_events WHERE run_id = ? AND type = ? ORDER BY ts, id').all(sid, type).map((r) => JSON.parse(r.data)); + const cards = (sid) => aos.db.prepare("SELECT title, body FROM messages WHERE session_id = ? AND title LIKE 'Drifting%'").all(sid); + + // Unattended run, nudge mode (the default). + const s1 = mkRun('builder', true); + const t0 = Date.now(); + const first = bash(s1, 'builder', D.DRIFT_FIRST_CHECK_ACTIONS); + assert(Date.now() - t0 < 1000, 'the gate never waits on the judge', `${Date.now() - t0}ms for ${first.length} calls`); + assert(first.every((g) => g.decision === 'allow' && !g.note), 'no note on the calls that triggered the judgement'); + await settle(); + eq(audits(s1, 'drift.judged').length, 1, 'exactly one judgement after the first-check threshold'); + eq(requests.length, 1 + D.DRIFT_CONFIRM_VOTES, 'a drifted first vote is confirmed by extra votes'); + eq(audits(s1, 'drift.judged')[0]?.votes?.join(','), 'drifted,drifted,drifted', 'votes recorded on the audit row'); + eq(requests[0]?.body.model, 'claude-haiku-4-5', 'the judge pins Haiku even when the workspace Q&A model is Opus'); + eq(requests[0]?.key, 'sk-test-drift', 'uses the workspace Anthropic key'); + assert(/Fix the refund webhook/.test(requests[0]?.body.messages?.[0]?.content || ''), 'the ask reaches the judge'); + const [next] = bash(s1, 'builder', 1); + eq(next.decision, 'allow', 'a drift note never changes the decision'); + assert(next.note && next.note.startsWith('Agentric focus check:'), 'the NEXT tool call carries the note'); + eq(bash(s1, 'builder', 1)[0].note, undefined, 'and only that one'); + eq(audits(s1, 'drift.judged')[0]?.outcome, 'detected', 'drift.judged audited with outcome detected'); + eq(audits(s1, 'drift.nudged').length, 1, 'drift.nudged audited once'); + eq(cards(s1).length, 0, 'no human card on the first drift — the agent gets a chance first'); + + // Second check, still drifted → escalate once. Move the clock past the interval. + const realNow = Date.now; + let skew = 0; + Date.now = () => realNow() + skew; + skew += D.DRIFT_MIN_INTERVAL_MS + MIN; + bash(s1, 'builder', D.DRIFT_EVERY_ACTIONS); + await settle(); + eq(audits(s1, 'drift.judged').length, 2, 'second judgement after interval + actions'); + eq(audits(s1, 'drift.judged')[1]?.outcome, 'escalated', 'still drifted → escalated'); + eq(cards(s1).length, 1, 'one Inbox card for the unattended run\'s owner'); + assert(/Now working on: the CI cache config/.test(cards(s1)[0]?.body || ''), 'the card says asked vs now'); + skew += D.DRIFT_MIN_INTERVAL_MS + MIN; + bash(s1, 'builder', D.DRIFT_EVERY_ACTIONS + 1); + await settle(); + eq(audits(s1, 'drift.judged')[2]?.outcome, 'persisting', 'third drifted verdict → persisting'); + eq(cards(s1).length, 1, 'still only one card per streak'); + + // Came back to the ask. + reply = '{"verdict":"on_track","confidence":0.95,"tangent":"refund webhook handler","reason":"editing the handler"}'; + skew += D.DRIFT_MIN_INTERVAL_MS + MIN; + bash(s1, 'builder', D.DRIFT_EVERY_ACTIONS + 1); + await settle(); + eq(audits(s1, 'drift.judged')[3]?.outcome, 'cleared', 'on_track after a streak → cleared'); + + // A single noisy drifted vote that the confirmation votes overrule: no nudge. + reply = '{"verdict":"drifted","confidence":0.9,"tangent":"the CI cache config","reason":"x"}'; + const s7 = mkRun('builder', true); + const on = '{"verdict":"on_track","confidence":0.9,"tangent":"refund handler","reason":"y"}'; + queue.push(reply, on, on); + const reqBefore = requests.length; + bash(s7, 'builder', D.DRIFT_FIRST_CHECK_ACTIONS); await settle(); + eq(requests.length - reqBefore, 3, 'drifted first vote → two confirmation votes'); + eq(audits(s7, 'drift.judged')[0]?.outcome, 'none', '1 of 3 drifted is not a majority → no drift'); + eq(audits(s7, 'drift.judged')[0]?.verdict, 'on_track', 'the dissenting vote is recorded as the verdict'); + eq(bash(s7, 'builder', 1)[0].note, undefined, '… and no nudge'); + queue.push(on); + const s8 = mkRun('builder', true); + const reqBefore8 = requests.length; + bash(s8, 'builder', D.DRIFT_FIRST_CHECK_ACTIONS); await settle(); + eq(requests.length - reqBefore8, 1, 'a non-drifted first vote costs exactly one call'); + queue.push(reply, on, reply); + const s9 = mkRun('builder', true); + bash(s9, 'builder', D.DRIFT_FIRST_CHECK_ACTIONS); await settle(); + eq(audits(s9, 'drift.judged')[0]?.outcome, 'detected', '2 of 3 drifted → confirmed'); + + // Interactive run: nudged, but never carded (a human is already on it). + reply = '{"verdict":"drifted","confidence":0.9,"tangent":"the CI cache config","reason":"x"}'; + const s2 = mkRun('builder', false); + bash(s2, 'builder', D.DRIFT_FIRST_CHECK_ACTIONS); await settle(); + assert(bash(s2, 'builder', 1)[0].note, 'interactive run is nudged too'); + skew += D.DRIFT_MIN_INTERVAL_MS + MIN; + bash(s2, 'builder', D.DRIFT_EVERY_ACTIONS); await settle(); + eq(audits(s2, 'drift.judged').pop()?.outcome, 'escalated', 'interactive run escalates in the audit/strip'); + eq(cards(s2).length, 0, '… but posts no Inbox card'); + + // Per-agent opt-out. + const before = requests.length; + const s3 = mkRun('explorer', true); + bash(s3, 'explorer', D.DRIFT_FIRST_CHECK_ACTIONS * 3); await settle(); + eq(requests.length, before, 'driftCheck:false agent is never judged'); + + // Observe mode: judged + recorded, never nudged, never carded. + aos.settings.setDriftMode('observe'); + const s4 = mkRun('builder', true); + bash(s4, 'builder', D.DRIFT_FIRST_CHECK_ACTIONS); await settle(); + eq(audits(s4, 'drift.judged')[0]?.mode, 'observe', 'observe mode still judges'); + eq(bash(s4, 'builder', 1)[0].note, undefined, 'observe mode never nudges'); + skew += D.DRIFT_MIN_INTERVAL_MS + MIN; + bash(s4, 'builder', D.DRIFT_EVERY_ACTIONS); await settle(); + eq(cards(s4).length, 0, 'observe mode never cards'); + + // Off. + aos.settings.setDriftMode('off'); + const before2 = requests.length; + const s5 = mkRun('builder', true); + bash(s5, 'builder', D.DRIFT_FIRST_CHECK_ACTIONS * 2); await settle(); + eq(requests.length, before2, 'off mode makes no model calls'); + + // A garbled judge: failure audited, no nudge. + aos.settings.setDriftMode('nudge'); + reply = 'I think it is probably fine?'; + const s6 = mkRun('builder', true); + bash(s6, 'builder', D.DRIFT_FIRST_CHECK_ACTIONS); await settle(); + eq(audits(s6, 'drift.judge_failed')[0]?.reason, 'unparseable', 'an unparseable reply is audited as a failure'); + eq(bash(s6, 'builder', 1)[0].note, undefined, '… and never becomes a nudge'); + + Date.now = realNow; + stub.close(); + try { fs.rmSync(HOME, { recursive: true, force: true }); } catch {} + console.log(`\n${fail ? '\x1b[31m' : '\x1b[32m'}${pass} passed, ${fail} failed\x1b[0m`); + process.exit(fail ? 1 : 0); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/src/edge/drift.ts b/src/edge/drift.ts new file mode 100644 index 00000000..934c78cc --- /dev/null +++ b/src/edge/drift.ts @@ -0,0 +1,369 @@ +/** + * DRIFT — "is this run still doing what it was asked to do?" + * + * The reliability monitor (`reliability.ts`) and the progress verdict (`session-progress.ts`) both judge + * the SHAPE of a run: the same action repeated (`circling`), nothing happening (`stuck`). A rabbit hole + * has neither shape. It is busy, varied, apparently productive work on the wrong thing — a side-bug, a + * refactor nobody asked for, an investigation three levels below what the ask needed — and it reads as + * `forward` on every shape signal we have. Catching it means comparing CONTENT against the ask, which is + * a language judgement, so it takes a model. + * + * Three parts, each deliberately separate: + * + * 1. **A cheap deterministic trigger** ({@link DriftMonitor.observe}). The judge only runs once a run has + * done enough to wander — {@link DRIFT_FIRST_CHECK_ACTIONS} top-level allowed actions, then every + * {@link DRIFT_EVERY_ACTIONS} more, never more often than {@link DRIFT_MIN_INTERVAL_MS}. A short run + * never pays for a judgement. + * 2. **A Haiku judge** ({@link judgeDrift}), run OUT OF BAND — never awaited inside the gate, which is on + * every tool call's hot path. It sees the ask, any later human redirections, and the recent tail of + * the transcript. The agent never grades itself: an agent in a rabbit hole believes it is progressing + * (same rule as the progress verdict). + * 3. **Advisory delivery.** A `drifted` verdict parks a note that rides out on the run's NEXT allowed + * tool call as an `instruct` (allow + `additionalContext` — the one channel verified to reach the + * model mid-turn). The copy is branded, explains what was observed, and offers the constructive + * move — park the tangent as a task and return — rather than an order; coercive wording is flagged by + * the model as prompt injection (docs/decision-brief-layer-plan.md §8a). If a later check still says + * `drifted`, the run is escalated ONCE to a human. Drift never stops a run: the judge can be wrong, and + * hard stops stay on budget/approvals/`stopSession`. + * + * State is in-memory per session, like the loop detector: a restart forgets it, and a run that is still + * drifting simply re-forms the verdict at the next check. + */ +import { chatComplete, LlmConfig } from './llm'; +import { runClaudePrompt, classify, SummaryFailure } from './summarize'; +import type { Conversation } from './conversation'; + +/** `off` — no judging. `observe` — judge + record the verdict (progress strip, audit) but never nudge the + * agent or post a card: the bake-in mode. `nudge` — the full loop. */ +export type DriftMode = 'off' | 'observe' | 'nudge'; +export const DRIFT_MODES: readonly DriftMode[] = ['off', 'observe', 'nudge']; + +export type DriftVerdict = 'on_track' | 'supporting' | 'drifted'; + +export interface DriftJudgement { + verdict: DriftVerdict; + /** The judge's own 0..1 confidence. NOT calibrated (Haiku's self-reported number) — used only as a + * floor that throws out the judge's own shrugs, never as a probability. */ + confidence: number; + /** A few words on what the recent work is actually about. */ + tangent: string; + /** One sentence of evidence. */ + reason: string; +} + +/** No judgement before a run has taken this many top-level allowed actions. */ +export const DRIFT_FIRST_CHECK_ACTIONS = 20; +/** Then one judgement per this many further actions … */ +export const DRIFT_EVERY_ACTIONS = 20; +/** … and never more often than this. */ +export const DRIFT_MIN_INTERVAL_MS = 8 * 60_000; +/** A `drifted` vote below this self-reported confidence is recorded but not acted on. 0.8, not 0.7: on the + * live replay (2026-09-17, 90 checkpoints across 30 long instapods sessions) the two genuine rabbit holes + * confirmed at 0.90 and 0.95, and the one confirmed false positive at 0.75. Two hits is a small sample — + * re-derive this from `drift.judged` audit rows once a tenant has run in `observe` for a while. */ +export const DRIFT_MIN_CONFIDENCE = 0.8; +/** Consecutive judge failures after which a session stops being judged (no key, CLI missing, quota). */ +export const DRIFT_MAX_FAILURES = 3; +/** A `drifted` verdict this recent still shows as `drifting` on the progress strip. Longer than two check + * intervals so the strip doesn't flicker back to `forward` between checks of a run that is still off. */ +export const DRIFT_WINDOW_MS = 30 * 60_000; + +/** What recording a judgement means for the caller. */ +export type DriftOutcome = + /** Not drifting (and wasn't). */ + | 'none' + /** First drifted verdict of a streak — nudge the agent. */ + | 'detected' + /** Still drifted after a nudge — tell a human (once per streak). */ + | 'escalated' + /** Still drifted, human already told — record only. */ + | 'persisting' + /** A drifted streak ended: the run came back to the ask (or a human redirected it). */ + | 'cleared'; + +interface SessionDriftState { + actions: number; + lastCheckActions: number; + lastCheckAt: number; + inflight: boolean; + failures: number; + streak: number; + escalated: boolean; + pendingNote?: string; +} + +export class DriftMonitor { + private readonly sessions = new Map(); + + private state(sessionId: string): SessionDriftState { + let s = this.sessions.get(sessionId); + if (!s) { + s = { actions: 0, lastCheckActions: 0, lastCheckAt: 0, inflight: false, failures: 0, streak: 0, escalated: false }; + this.sessions.set(sessionId, s); + } + return s; + } + + /** Count one allowed top-level action. Returns true when a judgement is due NOW — the caller then runs + * the judge out of band and reports back via {@link record} or {@link abort}. At most one judgement is + * in flight per session. */ + observe(sessionId: string, now: number): boolean { + const s = this.state(sessionId); + s.actions++; + if (s.inflight || s.failures >= DRIFT_MAX_FAILURES) return false; + const due = s.lastCheckAt === 0 + ? s.actions >= DRIFT_FIRST_CHECK_ACTIONS + : s.actions - s.lastCheckActions >= DRIFT_EVERY_ACTIONS && now - s.lastCheckAt >= DRIFT_MIN_INTERVAL_MS; + if (!due) return false; + s.inflight = true; + s.lastCheckAt = now; + s.lastCheckActions = s.actions; + return true; + } + + /** The judge could not run (or had nothing to read). Frees the slot; the interval still applies, so a + * failing backend is retried at the normal cadence, and given up on after {@link DRIFT_MAX_FAILURES}. */ + abort(sessionId: string, failed = true): void { + const s = this.state(sessionId); + s.inflight = false; + if (failed) s.failures++; + } + + /** Fold a judgement into the session's streak. `note` is the advisory text to park for the next tool + * call when the outcome warrants a nudge (pass undefined in `observe` mode). */ + record(sessionId: string, j: DriftJudgement, note?: string): DriftOutcome { + const s = this.state(sessionId); + s.inflight = false; + s.failures = 0; + const drifted = j.verdict === 'drifted' && j.confidence >= DRIFT_MIN_CONFIDENCE; + if (!drifted) { + if (s.streak === 0) return 'none'; + s.streak = 0; + s.escalated = false; + s.pendingNote = undefined; + return 'cleared'; + } + s.streak++; + if (s.streak === 1) { + s.pendingNote = note; + return 'detected'; + } + if (!s.escalated) { + s.escalated = true; + s.pendingNote = note; // one more nudge alongside the human being told + return 'escalated'; + } + return 'persisting'; + } + + /** Take (and clear) the parked note, if any. */ + takeNote(sessionId: string): string | undefined { + const s = this.sessions.get(sessionId); + const note = s?.pendingNote; + if (s) s.pendingNote = undefined; + return note; + } + + forget(sessionId: string): void { + this.sessions.delete(sessionId); + } +} + +// ─── The judge ────────────────────────────────────────────────────────────────────────────────────── + +/** Char budgets for the judge's input. The tail matters most — that's where a rabbit hole lives. */ +const ASK_CAP = 2500; +const RECENT_CAP = 9000; +const RECENT_TURNS = 60; + +export const DRIFT_SYSTEM = [ + "You check whether an autonomous agent's recent work still serves the task it was given.", + 'You get the ORIGINAL REQUEST, any LATER HUMAN MESSAGES (each shown with the agent message it replied to), and the RECENT WORK: the agent\'s own messages plus a "·" log of the tools it used.', + '', + 'First work out the CURRENT ASK: the original request as amended by every later human message. A human who redirected the agent, approved a proposal, or asked for more has changed the ask — the original request may be long superseded.', + '', + 'Then classify the RECENT WORK against the CURRENT ASK:', + '- "on_track": it is directly doing the ask.', + '- "supporting": not literally named in the ask, but plausibly needed to get the ask done — finding a root cause, setting up or repairing the environment, unblocking a failing step, verifying the result, reading context. Give the benefit of the doubt here.', + '- "drifted": it is pursuing something the ask does not need — an unrelated bug or system, a refactor or cleanup nobody asked for, polishing far beyond the ask, or digging into a sub-problem well past what the ask requires.', + '', + 'Rules:', + '- Anything a human asked for or agreed to is NEVER drift. A short human reply ("go", "yes", "merge both", "build and ship") approves whatever the agent proposed just before it — that proposal is shown with the reply, and it becomes part of the ask.', + '- Handling fallout from the agent\'s own work (a deploy it shipped broke production, CI it triggered failed, a review found defects) is "supporting".', + '- Judge the overall direction of the recent work, not one step. A single unexplained command is not drift.', + '- Be conservative: only say "drifted" when a teammate who read the ask and the human messages would clearly say "that\'s not what you were asked to do". Many legitimate tasks take detours.', + 'Reply with ONLY a JSON object, no prose, no code fence:', + '{"current_ask":"<=20 words","verdict":"on_track|supporting|drifted","confidence":0.0-1.0,"tangent":"<=12 words: what the recent work is about","reason":"<=25 words: the evidence"}', +].join('\n'); + +const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n) + '…' : s); +/** Keep both ends of a long message. An agent's proposal — what a following "go" / "ship it" approves — + * is usually its LAST paragraph, so a head-only clip cut exactly the part the reply refers to. */ +const headTail = (s: string, head: number, tail: number) => (s.length > head + tail + 3 ? `${s.slice(0, head)} … ${s.slice(-tail)}` : s); + +/** + * Is this `user` turn something a PERSON typed? A transcript's user role also carries harness injections — + * background-task notifications, a skill's body when it loads, slash-command plumbing. Found on the live + * replay: counted as "human messages", they pushed the real redirections ("merge both", "build and ship") + * out of the judge's view, and it called human-directed work drift. + */ +export function isHumanText(text: string): boolean { + const t = text.trim(); + if (!t) return false; + return !/^(?:||| text.replace(/^\s*\[Image:[^\]]*\]\s*/, '').replace(/\s+/g, ' ').trim(); + +/** Build the judge's user message. Null when there's nothing to judge (no ask, no transcript). */ +export function buildDriftInput(ask: string, convo: Conversation): string | null { + const askText = (ask || '').trim(); + if (!askText || !convo.found || convo.turns.length === 0) return null; + // Real human turns after the ask (the first one IS the ask), each paired with the agent message it + // answered: a bare "go" only means something next to the proposal it approved. + const later: string[] = []; + let seenAsk = false; + let lastAgent = ''; + for (const t of convo.turns) { + if (t.kind === 'assistant') { lastAgent = t.text; continue; } + if (t.kind !== 'user' || !isHumanText(t.text)) continue; + if (!seenAsk) { seenAsk = true; continue; } + const words = humanWords(t.text) || '[sent an image]'; + later.push(lastAgent + ? `- replying to the agent's “${headTail(lastAgent.replace(/\s+/g, ' '), 160, 440)}”, the human said: ${clip(words, 400)}` + : `- the human said: ${clip(words, 400)}`); + } + const lines: string[] = []; + for (const t of convo.turns.slice(-RECENT_TURNS)) { + if (t.kind === 'user') { if (isHumanText(t.text)) lines.push(`HUMAN: ${clip(humanWords(t.text) || '[sent an image]', 300)}`); } + else if (t.kind === 'assistant') lines.push(`AGENT: ${clip(t.text.replace(/\s+/g, ' '), 300)}`); + else lines.push(`· ${t.label}${t.detail ? ` — ${clip(t.detail.replace(/\s+/g, ' '), 160)}` : ''}${t.status === 'error' ? ' [failed]' : ''}`); + } + let recent = lines.join('\n'); + if (recent.length > RECENT_CAP) recent = '…(earlier work truncated)\n' + recent.slice(recent.length - RECENT_CAP); + return [ + `ORIGINAL REQUEST:\n${clip(askText, ASK_CAP)}`, + later.length ? `LATER HUMAN MESSAGES (oldest first — these amend the request):\n${later.slice(-6).join('\n')}` : '', + `RECENT WORK (oldest first):\n${recent}`, + ].filter(Boolean).join('\n\n'); +} + +/** Parse the judge's reply. Tolerant of a code fence or stray prose around the object; null on anything + * that isn't a well-formed verdict (an unparseable judge must never read as "drifted"). */ +export function parseDriftJudgement(text: string | null | undefined): DriftJudgement | null { + if (!text) return null; + const m = text.match(/\{[\s\S]*\}/); + if (!m) return null; + let o: Record; + try { o = JSON.parse(m[0]); } catch { return null; } + const verdict = o.verdict; + if (verdict !== 'on_track' && verdict !== 'supporting' && verdict !== 'drifted') return null; + const c = typeof o.confidence === 'number' ? o.confidence : Number(o.confidence); + const confidence = Number.isFinite(c) ? Math.max(0, Math.min(1, c)) : 0; + const str = (v: unknown, n: number) => (typeof v === 'string' ? clip(v.trim().replace(/\s+/g, ' '), n) : ''); + return { verdict, confidence, tangent: str(o.tangent, 120), reason: str(o.reason, 240) }; +} + +/** Model for the judge. Haiku: the question is narrow and it runs on a timer across the whole fleet. */ +export const DRIFT_MODEL = (): string => process.env.AOS_DRIFT_MODEL || 'claude-haiku-4-5'; + +export interface DriftJudgeResult { + judgement: DriftJudgement | null; + /** Every vote cast, in order (`drifted`/`on_track`/`supporting`, or `fail`). One entry unless the first + * vote said drifted and confirmation votes ran. */ + votes?: string[]; + via: 'api' | 'cli'; + /** Why no judgement came back, when it didn't. */ + reason?: SummaryFailure | 'unparseable'; + account?: string; +} + +/** + * Run the judge. Backend order: the tenant's own LLM config when present (an Anthropic key → the Messages + * API, pinned to Haiku; an OpenAI-compatible endpoint → its configured model), else a throwaway + * `claude -p --model ` on the pooled runtime credentials — the same out-of-band lane the session + * summarizer uses, so a subscription-only box still gets a judge. Never throws. + */ +export async function judgeDrift( + input: string, + deps: { llm: LlmConfig | null; credentials?: Record; account?: string; timeoutMs?: number }, +): Promise { + if (deps.llm) { + const llm: LlmConfig = deps.llm.provider === 'anthropic' ? { ...deps.llm, model: DRIFT_MODEL() } : deps.llm; + const text = await chatComplete(llm, [{ role: 'system', content: DRIFT_SYSTEM }, { role: 'user', content: input }], { maxTokens: 200, temperature: 0, timeoutMs: deps.timeoutMs ?? 20_000 }); + const judgement = parseDriftJudgement(text); + return { judgement, via: 'api', ...(judgement ? {} : { reason: text ? 'unparseable' : 'error' }) }; + } + try { + const out = await runClaudePrompt(DRIFT_SYSTEM, input, { credentials: deps.credentials, model: DRIFT_MODEL(), timeoutMs: deps.timeoutMs ?? 60_000 }); + const judgement = parseDriftJudgement(out); + return { judgement, via: 'cli', account: deps.account, ...(judgement ? {} : { reason: out.trim() ? 'unparseable' : 'empty_output' }) }; + } catch (err) { + return { judgement: null, via: 'cli', account: deps.account, reason: classify(err) }; + } +} + +/** Extra votes cast when the first one says `drifted`. */ +export const DRIFT_CONFIRM_VOTES = 2; + +const isDrifted = (j: DriftJudgement | null | undefined) => !!j && j.verdict === 'drifted' && j.confidence >= DRIFT_MIN_CONFIDENCE; + +/** + * The judge as the monitor uses it: one vote, and — only when that vote says `drifted` — two more in + * parallel, acting on the majority. Measured on the live replay (2026-09-17): the same transcript cut + * judged twice flipped between `drifted` and `on_track` on 3 of 42 checkpoints (the CLI lane has no + * temperature control), while the one genuine rabbit hole held on every run. A nudge interrupts a real + * agent, so a single noisy vote is not enough; a confirmation costs two extra calls on the ~5% of + * checks that come back drifted, and nothing on the rest. + */ +export async function judgeDriftConfirmed( + input: string, + deps: Parameters[1], +): Promise { + const first = await judgeDrift(input, deps); + if (!isDrifted(first.judgement)) return { ...first, votes: [first.judgement?.verdict ?? 'fail'] }; + const more = await Promise.all(Array.from({ length: DRIFT_CONFIRM_VOTES }, () => judgeDrift(input, deps))); + const all = [first, ...more]; + const votes = all.map((r) => (r.judgement ? (isDrifted(r.judgement) ? 'drifted' : r.judgement.verdict) : 'fail')); + const drifted = all.filter((r) => isDrifted(r.judgement)); + if (drifted.length * 2 > all.length) { + const confidence = drifted.reduce((a, r) => a + r.judgement!.confidence, 0) / drifted.length; + return { ...first, votes, judgement: { ...first.judgement!, confidence } }; + } + // Not confirmed: report the first dissenting vote as the verdict (a failed vote can't overturn — if + // every confirmation failed, 1 of 3 is still not a majority, so the run is simply not called drifting). + const dissent = more.find((r) => r.judgement && !isDrifted(r.judgement))?.judgement; + return { ...first, votes, judgement: dissent ?? { ...first.judgement!, verdict: 'supporting', reason: `unconfirmed: ${first.judgement!.reason}` } }; +} + +// ─── Copy ─────────────────────────────────────────────────────────────────────────────────────────── + +/** The gist of an ask for a one-line quote: first non-empty line, clipped. */ +export function askGist(ask: string): string { + const first = (ask || '').split('\n').map((l) => l.trim()).find(Boolean) ?? ''; + return clip(first.replace(/\s+/g, ' '), 140); +} + +/** The advisory note injected into the agent. Framing is load-bearing (§8a): branded, says what was + * observed and why it's being mentioned, offers a constructive way out, and explicitly allows "this is + * needed, carry on". No imperatives, no tokens. */ +export function driftNote(ask: string, j: DriftJudgement): string { + const about = j.tangent ? `about ${j.tangent}` : 'about something other than the ask'; + return ( + `Agentric focus check: this run was asked to “${askGist(ask)}”. An independent look at your recent ` + + `steps suggests they're ${about}${j.reason ? ` (${j.reason.replace(/\.$/, '')})` : ''}. If that work is ` + + `genuinely needed for the ask, carry on — this is only a check-in, and the check can be wrong. If it's ` + + `a side-finding, a good move is to record it with \`task_create\` (a human can pick it up later) and ` + + `return to the ask, so the person waiting on this run gets what they asked for.` + ); +} + +/** The one-time human escalation, for the Inbox card. */ +export function driftEscalationBody(ask: string, j: DriftJudgement): string { + return [ + `Asked: “${askGist(ask)}”`, + `Now working on: ${j.tangent || 'something else'}${j.reason ? ` — ${j.reason}` : ''}`, + 'The agent was given a focus check and is still off the ask. Open the session to redirect it, or stop it if the tangent isn\'t worth the run.', + ].join('\n'); +} diff --git a/src/edge/summarize.ts b/src/edge/summarize.ts index 53f00741..6a60c4bb 100644 --- a/src/edge/summarize.ts +++ b/src/edge/summarize.ts @@ -100,6 +100,16 @@ const INSTRUCTION = [ /** Run a fresh, throwaway `claude -p`, piping the transcript on stdin. Resolves its stdout. */ function runClaude(instruction: string, transcript: string, credentials?: Record): Promise { + return runClaudePrompt(instruction, `TRANSCRIPT:\n${transcript}\n`, { credentials, model: process.env.AOS_SUMMARY_MODEL }); +} + +/** A throwaway, tool-less `claude -p ` with `stdin` piped in; resolves stdout. Shared by the + * summarizer and the drift judge (`drift.ts`) — the one place the out-of-band CLI call is shaped. */ +export function runClaudePrompt( + instruction: string, + stdin: string, + opts: { credentials?: Record; model?: string; timeoutMs?: number } = {}, +): Promise { return new Promise((resolve, reject) => { const home = os.homedir(); // Mirror claude-launch.sh: the CLI is often under ~/.local/bin and a hardened service ships a @@ -107,21 +117,20 @@ function runClaude(instruction: string, transcript: string, credentials?: Record // to pick up) — this is a pure text-in/text-out call with no tools. // Pool credentials LAST so they override whatever the service environment carries — that precedence // is the fix: the box default is what was exhausted. - const env = { ...process.env, PATH: `${path.join(home, '.local', 'bin')}:${process.env.PATH ?? ''}`, ...(credentials ?? {}) }; + const env = { ...process.env, PATH: `${path.join(home, '.local', 'bin')}:${process.env.PATH ?? ''}`, ...(opts.credentials ?? {}) }; const args = ['-p', instruction]; - const model = process.env.AOS_SUMMARY_MODEL; - if (model) args.push('--model', model); + if (opts.model) args.push('--model', opts.model); const child = execFile( 'claude', args, - { env, cwd: os.tmpdir(), timeout: 90_000, maxBuffer: 8 * 1024 * 1024 }, + { env, cwd: os.tmpdir(), timeout: opts.timeoutMs ?? 90_000, maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => { if (err) return reject(err); resolve(String(stdout || '')); }, ); child.stdin?.on('error', () => {}); // a claude that exits early would EPIPE the write — ignore - child.stdin?.end(`TRANSCRIPT:\n${transcript}\n`); + child.stdin?.end(stdin); }); } diff --git a/src/governance/settings.ts b/src/governance/settings.ts index 2c2e4d96..824d49c9 100644 --- a/src/governance/settings.ts +++ b/src/governance/settings.ts @@ -64,6 +64,7 @@ const LEARNED_APPLY_KEY = 'learned_guidance_apply'; // 'off' to stop injecting ( const RECOMMENDATIONS_KEY = 'learned_recommendations'; // { open: Recommendation[], dismissed: string[] } const GOVERNANCE_KEY = 'governance_thresholds'; // numeric caps the never-tier policy rules read (JSON GovernanceThresholds) const HOST_GOV_KEY = 'host_governance_enabled'; // master switch for Phase 2b host-egress governance ('1'|'0') +const DRIFT_MODE_KEY = 'drift_nudge_mode'; // off | observe | nudge — the rabbit-hole focus check (edge/drift.ts) const SEMANTIC_GUARD_KEY = 'semantic_guard_enabled'; // master switch for the prompt-injection semantic guard ('1'|'0') const FILE_WRITE_GUARD_KEY = 'file_write_guard'; const ENRICH_PATTERNS_KEY = 'enrich_patterns'; // operator regex→boolean-fact rules the enricher applies (JSON EnrichPattern[]) @@ -974,6 +975,20 @@ export class SettingsStore { return on; } + // ── drift focus check (edge/drift.ts) ── + // Default `nudge`: the note is advisory (the agent may ignore it) and the human escalation is one card + // per drifting streak, so the loop is safe to run everywhere. `observe` records verdicts without + // touching the run — the mode to bake a tenant in; `off` stops judging (and its model spend) entirely. + driftMode(): 'off' | 'observe' | 'nudge' { + const v = this.getRow(DRIFT_MODE_KEY)?.value; + return v === 'off' || v === 'observe' ? v : 'nudge'; + } + + setDriftMode(mode: 'off' | 'observe' | 'nudge', by?: string): 'off' | 'observe' | 'nudge' { + this.set(DRIFT_MODE_KEY, mode, by); + return mode; + } + // ── file-write guard, tier 2 (file-guard.ts) ── // The crown-jewel DENY tier is always on and needs no switch — nothing legitimate writes to `~/.ssh` // or the workspace DB. THIS toggle only controls the softer "ask before writing anywhere outside the diff --git a/src/server.ts b/src/server.ts index 943926ee..fbfa799a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5626,7 +5626,7 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: // ── governance thresholds (the numeric caps the never-tier policy rules read) ── if (method === 'GET' && p === '/api/settings/governance') { if (!isAdmin(me)) return sendJson(res, 403, { error: 'owner or admin required' }); - return sendJson(res, 200, { ...os.settings.governanceThresholds(), hostGovernanceEnabled: os.settings.hostGovernanceEnabled(), semanticGuardEnabled: os.settings.semanticGuardEnabled(), fileWriteGuardEnabled: os.settings.fileWriteGuardEnabled(), ...os.settings.governanceMeta() }); + return sendJson(res, 200, { ...os.settings.governanceThresholds(), hostGovernanceEnabled: os.settings.hostGovernanceEnabled(), semanticGuardEnabled: os.settings.semanticGuardEnabled(), fileWriteGuardEnabled: os.settings.fileWriteGuardEnabled(), driftMode: os.settings.driftMode(), ...os.settings.governanceMeta() }); } if (method === 'PUT' && p === '/api/settings/governance') { if (!isAdmin(me)) return sendJson(res, 403, { error: 'owner or admin required' }); @@ -5652,8 +5652,15 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: os.settings.setSemanticGuardEnabled(b.semanticGuardEnabled, me.email); os.audit.append({ ts: Date.now(), runId: '-', tenant: os.tenant, principal: me.email, type: 'settings.semantic_guard.updated', data: { enabled: b.semanticGuardEnabled } }); } + // Drift focus check mode. Advisory (it never gates an effect), so owner OR admin — whoever reaches this + // route. Present-only, and an unknown value is refused rather than silently read back as the default. + if (b.driftMode !== undefined) { + if (b.driftMode !== 'off' && b.driftMode !== 'observe' && b.driftMode !== 'nudge') return sendJson(res, 400, { error: 'driftMode must be off, observe or nudge' }); + os.settings.setDriftMode(b.driftMode, me.email); + os.audit.append({ ts: Date.now(), runId: '-', tenant: os.tenant, principal: me.email, type: 'settings.drift_mode.updated', data: { mode: b.driftMode } }); + } os.audit.append({ ts: Date.now(), runId: '-', tenant: os.tenant, principal: me.email, type: 'settings.governance.updated', data: { ...saved } }); - return sendJson(res, 200, { ok: true, ...saved, hostGovernanceEnabled: os.settings.hostGovernanceEnabled(), semanticGuardEnabled: os.settings.semanticGuardEnabled(), fileWriteGuardEnabled: os.settings.fileWriteGuardEnabled() }); + return sendJson(res, 200, { ok: true, ...saved, hostGovernanceEnabled: os.settings.hostGovernanceEnabled(), semanticGuardEnabled: os.settings.semanticGuardEnabled(), fileWriteGuardEnabled: os.settings.fileWriteGuardEnabled(), driftMode: os.settings.driftMode() }); } // ── custom governance patterns (operator regex → boolean fact the enricher sets, policy gates on) ── @@ -7704,6 +7711,15 @@ function sessionProgress(os: AgentOS, runId: string, now: number): SessionProgre if (lastActivityTs == null && classifyActivity(r.type, d)) lastActivityTs = r.ts; } if (!claims.length && lastActivityTs == null && loopTs == null) return null; + // Newest focus-check judgement, by its own indexed lookup: judgements are ~10 min apart, so the 120-row + // tail above can easily not reach back to one on a busy run. + const driftRow = os.db + .prepare("SELECT ts, data FROM audit_events WHERE tenant = ? AND run_id = ? AND type = 'drift.judged' ORDER BY ts DESC LIMIT 1") + .get<{ ts: number; data: string }>(os.tenant, runId); + const driftData = driftRow ? safeJson(driftRow.data) : null; + const drift = driftRow && driftData + ? { ts: driftRow.ts, drifting: ['detected', 'escalated', 'persisting'].includes(String(driftData.outcome)), tangent: typeof driftData.tangent === 'string' && driftData.tangent ? driftData.tangent : null } + : null; const pendingApproval = os.db .prepare("SELECT 1 FROM approvals WHERE tenant = ? AND run_id = ? AND status = 'pending' LIMIT 1") .get<{ 1: number }>(os.tenant, runId) != null; @@ -7717,6 +7733,7 @@ function sessionProgress(os: AgentOS, runId: string, now: number): SessionProgre loopTs, loopCount, awaiting: pendingApproval ? 'approval' : pendingQuestion ? 'question' : null, + drift, }); } /** Resolve the requested inbox scope from `?scope=`. Only owner/admin may see the `all` oversight view; diff --git a/src/state/session-activity.ts b/src/state/session-activity.ts index 47427aea..be83fc6e 100644 --- a/src/state/session-activity.ts +++ b/src/state/session-activity.ts @@ -51,6 +51,8 @@ const NOISE = new Set([ 'action.attempt', 'policy.decision', 'space.released', 'approval.notified', 'approval.auto_approved', // The human side of the two agent→human channels — a follow-up to the agent's primitive, not one itself. 'approval.resolved', 'question.answered', + // The focus check's own bookkeeping — an observation ABOUT the run, not something the agent did. + 'drift.judged', 'drift.judge_failed', 'drift.nudged', 'drift.escalated', ]); /** Category by type-prefix — the fallback for any audited effect not spelled out below. */ diff --git a/src/state/session-progress.ts b/src/state/session-progress.ts index 0c5e702d..489a3ddb 100644 --- a/src/state/session-progress.ts +++ b/src/state/session-progress.ts @@ -31,7 +31,7 @@ /** How a run is moving. `blocked` is deliberately NOT a failure state — a run waiting on a human's * approval is behaving correctly, and reporting it as `stuck` would cry wolf on the one signal that * has to stay trustworthy. It is the HUMAN who is holding that run up. */ -export type ProgressVerdict = 'forward' | 'stuck' | 'circling' | 'blocked'; +export type ProgressVerdict = 'forward' | 'stuck' | 'circling' | 'drifting' | 'blocked'; /** No activity in the audit stream for this long ⇒ `stuck`. Also the staleness bound on a claim. */ export const STALL_MS = 5 * 60_000; @@ -43,6 +43,9 @@ export const CIRCLE_WINDOW_MS = 10 * 60_000; /** Claims needed inside {@link CIRCLE_WINDOW_MS} before a stationary step is called circling. Three, not * two: two updates within one step is ordinary narration, not a loop. */ export const CIRCLE_MIN_CLAIMS = 3; +/** A drifting judgement this recent still counts (mirrors edge/drift.ts DRIFT_WINDOW_MS — not imported, + * so this pure module stays dependency-free for the test harness). */ +export const DRIFT_WINDOW_MS = 30 * 60_000; /** One agent-declared position, parsed from an `update` call. `step`/`total`/`subject` are all optional * — an agent that just posts prose still gets a server-derived verdict, only without a bar. */ @@ -67,6 +70,9 @@ export interface ProgressInputs { awaiting: 'approval' | 'question' | null; /** Repeat count carried by that newest loop event, for the reason line. */ loopCount?: number | null; + /** The newest drift focus-check judgement (edge/drift.ts), if any. Only a `drifted` outcome inside + * {@link DRIFT_WINDOW_MS} reads as `drifting`; a later clearing judgement supersedes it by being newer. */ + drift?: { ts: number; drifting: boolean; tangent: string | null } | null; } export interface SessionProgress { @@ -121,8 +127,11 @@ export function parseClaim(ts: number, note: string, args: Record STALL_MS) { verdict = 'stuck'; reason = `no activity for ${Math.round(quietFor / 60_000)} min`; diff --git a/src/terminal.ts b/src/terminal.ts index 2243c9a3..6cf1b20f 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -29,6 +29,8 @@ import { resolveCapability } from './capabilities/normalize'; import { unwrapComposioEnvelope } from './capabilities/composio-envelope'; import { briefFor } from './governance/briefer'; import { ReliabilityMonitor } from './edge/reliability'; +import { DriftMonitor, DriftJudgement, buildDriftInput, judgeDriftConfirmed, driftNote, driftEscalationBody, askGist } from './edge/drift'; +import { resolveLlm } from './edge/llm'; import { hostGovernanceDecision, stricterDecision } from './governance/host-match'; import { fileGovernanceDecision } from './governance/file-guard'; import { injectionDecision } from './governance/semantic-guard'; @@ -939,6 +941,10 @@ export class TerminalManager { * agent via an `instruct` (allow + advisory note). In-memory per session. Off when AOS_RELIABILITY=0. */ private readonly reliability = new ReliabilityMonitor(); private readonly reliabilityOn = process.env.AOS_RELIABILITY !== '0'; + /** The rabbit-hole focus check (edge/drift.ts). Mode lives in workspace settings; `AOS_DRIFT=0` is the + * process-level kill switch (tests, or a box that must make no out-of-band model calls). */ + private readonly drift = new DriftMonitor(); + private readonly driftOn = process.env.AOS_DRIFT !== '0'; /** Sessions whose runtime launch is SCHEDULED but whose pane doesn't exist yet (see * `launchAgentRuntime`). `reachable` counts them as live so the window between "row written" and * "tmux up" can't be read as "nothing is running" — which would let a second turn launch a @@ -5689,9 +5695,16 @@ export class TerminalManager { const data: Record = { capability, signature: brief.signature }; if (sig.kind === 'loop') data.count = sig.count; else data.reason = sig.reason; this.audit(sessionId, agent, sig.kind === 'loop' ? 'reliability.loop' : 'reliability.detached_work', data); + this.driftObserve(sessionId, agent); // still counts toward the focus check; its note waits a call return { decision: 'allow', note: sig.note }; } } + // Focus check (edge/drift.ts): count the action, maybe kick an out-of-band judgement, and deliver a + // parked drift note on this call if an earlier judgement left one. Never blocks, never denies. + if (!sub) { + const note = this.driftObserve(sessionId, agent); + if (note) return { decision: 'allow', note }; + } return { decision: 'allow' }; } if (decision.effect === 'deny') return { decision: 'deny', reason: decision.reason, capability }; @@ -6030,6 +6043,57 @@ export class TerminalManager { return 'deny'; // rejected, cancelled, or unknown } + /** + * Drift focus check, called for every allowed top-level action. Returns a parked note to deliver on THIS + * call (nudge mode only), and starts a judgement in the background when one is due — the gate never + * waits on a model. See edge/drift.ts for the trigger, the judge and the copy. + */ + private driftObserve(sessionId: string, agent: string): string | undefined { + if (!this.driftOn) return undefined; + const mode = this.os.settings.driftMode(); + if (mode === 'off' || this.os.agents.get(agent)?.driftCheck === false) return undefined; + if (this.drift.observe(sessionId, Date.now())) void this.runDriftJudge(sessionId, agent, mode); + if (mode !== 'nudge') return undefined; + const note = this.drift.takeNote(sessionId); + if (note) this.audit(sessionId, agent, 'drift.nudged', {}); + return note; + } + + /** One out-of-band judgement. Every outcome is audited (`drift.judged`, or `drift.judge_failed` with the + * classified reason) so the focus check's hit rate and failure rate are readable from the trail. */ + private async runDriftJudge(sessionId: string, agent: string, mode: 'observe' | 'nudge'): Promise { + try { + const row = this.db.prepare('SELECT task, headless, claimed_by, status FROM term_sessions WHERE id = ?') + .get<{ task: string; headless: number; claimed_by: string | null; status: string }>(sessionId); + if (!row || row.status !== 'running') { this.drift.abort(sessionId, false); return; } + const input = buildDriftInput(row.task, this.sessionConversation(sessionId)); + if (!input) { this.drift.abort(sessionId, false); return; } + const llm = resolveLlm(this.os); + const cred = llm ? null : this.outOfBandCredentialEnv(); + const out = await judgeDriftConfirmed(input, { llm, credentials: cred?.vars, account: cred?.account }); + if (!out.judgement) { + this.drift.abort(sessionId); + this.audit(sessionId, agent, 'drift.judge_failed', { via: out.via, reason: out.reason ?? null, account: out.account ?? null }); + return; + } + const j: DriftJudgement = out.judgement; + const outcome = this.drift.record(sessionId, j, mode === 'nudge' ? driftNote(row.task, j) : undefined); + this.audit(sessionId, agent, 'drift.judged', { verdict: j.verdict, confidence: j.confidence, tangent: j.tangent, reason: j.reason, outcome, mode, via: out.via, votes: out.votes, ask: askGist(row.task) }); + // One card per drifting streak, and only for a run nobody is watching: an interactive or taken-over + // session already has a human on it, who sees the strip — a card there is just noise. + if (outcome === 'escalated' && mode === 'nudge' && row.headless === 1 && !row.claimed_by) { + this.addMessage({ + type: 'update', sessionId, agent, title: `Drifting — ${agent}`, body: driftEscalationBody(row.task, j), + status: 'open', args: { drift: { tangent: j.tangent, reason: j.reason } }, + audienceKind: 'sessionOwner', audienceId: sessionId, + }); + this.audit(sessionId, agent, 'drift.escalated', { tangent: j.tangent }); + } + } catch { + this.drift.abort(sessionId); + } + } + private addMessage(m: Omit): string { const id = newId('message'); this.db @@ -8674,6 +8738,7 @@ export class TerminalManager { } this.audit(sessionId, s.agent, 'session.ended', {}); this.reliability.forget(sessionId); // drop the loop-detector streak state for this run + this.drift.forget(sessionId); } /** A stopped/ended session was reconnected and is live again — the ttyd attach wrapper resurrected diff --git a/src/types.ts b/src/types.ts index cdf51150..f2198bd9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1684,6 +1684,11 @@ export interface AgentManifest extends RuntimeTuning { * automation — this only closes the open `/agentname` router. Use for supervisor/ops personas (e.g. a * `ceo` triage agent) you don't want anyone spawning from a shared comment thread. */ chatReachable?: boolean; + /** Whether the drift focus check (`edge/drift.ts`) judges this agent's runs. Default `true`. Set `false` + * for an agent whose job IS open-ended exploration (an investigator, a researcher), where "not what the + * ask named" is the point rather than a rabbit hole. Advisory only either way — never a governance + * control. */ + driftCheck?: boolean; /** The agent's visual icon. Either a built-in library id (a lucide icon name like `"Bot"`) or a raw * custom `…` markup string the user uploaded. Undefined → the console falls back to a * default glyph. Purely cosmetic. Rendered in an `` so inline SVG can't execute scripts. */ diff --git a/web/src/App.tsx b/web/src/App.tsx index 0328770f..f84370d8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input' import { Separator } from '@/components/ui/separator' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' -import { api, isDraftTask, EFFORTS, PERMISSION_MODES, type PermissionMode, type StateResp, type HostMetrics, type RequestMetricsSnapshot, type AgentInfo, type Session, type Msg, type Member, type Role, type TeamResp, type AgentAccess, type MemberIdentity, type IdentityProvider, IDENTITY_PROVIDERS, type Automation, type Task, type TaskEvent, type TaskAttachment, type TaskChild, type TaskRun, type TaskPr, type TaskPrSummary, type TaskWorkers, type TaskTimelineEntry, type TaskDiscussionSummary, type TaskDiscussionDelivery, type TaskStatus, type AddTaskReq, type Goal, type GoalEvent, type GoalMetricStatus, type GoalReading, type GoalStatus, type GoalCounts, type GoalProgress, type AddGoalReq, type MemoryRecord, type MemoryHealth, type MemoryBackend, type MemorySettings, type MemorySettingsReq, type OllamaStatus, type KbPage, type KbRevision, type AgentRevision, type AgentStats, type AgentProposalTrust, type Recommendation, type DigestConfig, type DigestModel, type DreamingState, type Measurement, type Insights, type ImprovementTile, type MemoryCleanupPlan, type KbTidyPlan, type TaskReconcilePlan, type LibraryTidyPlan, type SessionTidyPlan, type StuckGoal, type TroubledAutomation, type PolicyDocument, type PolicyRule, type PolicyOutcome, type PolicyOp, type PolicyProposal, type PolicyRevision, type PolicyDrift, type AutomationProposal, type AgentUpdateProposal, type GoalUpdateProposal, type DirListing, type FileEntry, type FileContent, type Artifact, type AppInfo, type AppFile, type AppCapabilities, type SkillSummary, type SkillsResp, type CatalogSkill, type CatalogAgent, type SkillSource, type RemoteSkill, type SkillshHit, type SkillRequest, type SecretRequest, type IntegrationsResp, type SlackStatus, type DiscordStatus, type TelegramStatus, type AuditEvent, type Effort, type RuntimeTuning, type RuntimeTuningPatch, type OutputStylesResp, type OutputStyleAdoption, type Concurrency, type RuntimeAccount, type RuntimeAccountKind, type RuntimeAccountsResp, type RuntimePresence, type RuntimeLogin, type SecretMeta, type UpdateStatus, type UpdateApplyResult, type UpdateWatchConfig, type UpdateWatchMode, type ActivityEvent, type ActivitySummaryRow, type SystemMetrics, type DepsReport, type DepStatus, type DepsInstallResult, type ChatTurn, type ChatArtifactRef, type ChatKbRef, type ChatAppRef, type RouterPreviewResp, type RouterCard, type SessionChain, type ChainNode, type ChainPending, type SessionProgress, type WhatsNewEntry } from '@/lib/api' +import { api, isDraftTask, EFFORTS, PERMISSION_MODES, type PermissionMode, type StateResp, type HostMetrics, type RequestMetricsSnapshot, type AgentInfo, type Session, type Msg, type Member, type Role, type TeamResp, type AgentAccess, type MemberIdentity, type IdentityProvider, IDENTITY_PROVIDERS, type Automation, type Task, type TaskEvent, type TaskAttachment, type TaskChild, type TaskRun, type TaskPr, type TaskPrSummary, type TaskWorkers, type TaskTimelineEntry, type TaskDiscussionSummary, type TaskDiscussionDelivery, type TaskStatus, type AddTaskReq, type Goal, type GoalEvent, type GoalMetricStatus, type GoalReading, type GoalStatus, type GoalCounts, type GoalProgress, type AddGoalReq, type MemoryRecord, type MemoryHealth, type MemoryBackend, type MemorySettings, type MemorySettingsReq, type OllamaStatus, type KbPage, type KbRevision, type AgentRevision, type AgentStats, type AgentProposalTrust, type Recommendation, type DigestConfig, type DigestModel, type DreamingState, type Measurement, type Insights, type ImprovementTile, type MemoryCleanupPlan, type KbTidyPlan, type TaskReconcilePlan, type LibraryTidyPlan, type SessionTidyPlan, type StuckGoal, type TroubledAutomation, type PolicyDocument, type PolicyRule, type PolicyOutcome, type PolicyOp, type PolicyProposal, type PolicyRevision, type PolicyDrift, type AutomationProposal, type AgentUpdateProposal, type GoalUpdateProposal, type DirListing, type FileEntry, type FileContent, type Artifact, type AppInfo, type AppFile, type AppCapabilities, type SkillSummary, type SkillsResp, type CatalogSkill, type CatalogAgent, type SkillSource, type RemoteSkill, type SkillshHit, type SkillRequest, type SecretRequest, type IntegrationsResp, type SlackStatus, type DiscordStatus, type TelegramStatus, type AuditEvent, type Effort, type RuntimeTuning, type RuntimeTuningPatch, type OutputStylesResp, type OutputStyleAdoption, type Concurrency, type RuntimeAccount, type RuntimeAccountKind, type RuntimeAccountsResp, type RuntimePresence, type RuntimeLogin, type SecretMeta, type UpdateStatus, type UpdateApplyResult, type UpdateWatchConfig, type UpdateWatchMode, type ActivityEvent, type ActivitySummaryRow, type SystemMetrics, type DepsReport, type DepStatus, type DepsInstallResult, type ChatTurn, type ChatArtifactRef, type ChatKbRef, type ChatAppRef, type RouterPreviewResp, type RouterCard, type SessionChain, type ChainNode, type ChainPending, type SessionProgress, type WhatsNewEntry, type DriftMode } from '@/lib/api' import { type Branding, type PublicBranding, type NotificationPrefs, DEFAULT_NOTIFICATION_PREFS, type PromptShortcut, type SessionMetrics, type Brief, type AutoApproval, type FeedItem, type FeedResponse, type FeedFilter, type TaskRunState, type GoalChatState } from '@/lib/api' import { applyAccent, applyFavicon, faviconDataUri, readableOn } from '@/lib/branding' import { ENTITY_ID_SRC, entityHref, isEntityId } from '@/lib/entity-links' @@ -5261,6 +5261,7 @@ const PROGRESS_META: Record('nudge') + const [driftBusy, setDriftBusy] = useState(false) const canEdit = me.role === 'owner' || me.role === 'admin' const isOwner = me.role === 'owner' @@ -18146,7 +18149,7 @@ function GovernanceSettings({ me }: { me: Member }) { api.governance().then((r) => { if (r.error) return const v = { moneyCapUsd: r.moneyCapUsd, bulkDeleteCount: r.bulkDeleteCount } - setT(v); setSaved(v); setMeta({ updatedAt: r.updatedAt, updatedBy: r.updatedBy }); setHostGov(!!r.hostGovernanceEnabled); setSemGuard(!!r.semanticGuardEnabled); setFileGuard(!!r.fileWriteGuardEnabled) + setT(v); setSaved(v); setMeta({ updatedAt: r.updatedAt, updatedBy: r.updatedBy }); setHostGov(!!r.hostGovernanceEnabled); setSemGuard(!!r.semanticGuardEnabled); setFileGuard(!!r.fileWriteGuardEnabled); if (r.driftMode) setDriftMode(r.driftMode) }).catch(() => {}) }, []) @@ -18168,6 +18171,14 @@ function GovernanceSettings({ me }: { me: Member }) { setSemGuard(!!r.semanticGuardEnabled) } + const saveDriftMode = async (next: DriftMode) => { + setDriftBusy(true) + const r = await api.saveGovernance({ ...saved, driftMode: next }) + setDriftBusy(false) + if (r.error) return setHint('⚠ ' + r.error) + if (r.driftMode) setDriftMode(r.driftMode) + } + const toggleFileGuard = async () => { setFileBusy(true) const next = !fileGuard @@ -18273,6 +18284,32 @@ function GovernanceSettings({ me }: { me: Member }) { + + +
+ Focus check (rabbit-hole nudge) + +
+

+ Once a run has taken ~20 actions, and about every 10 minutes after, a small model (Haiku) compares its recent work + against what it was asked to do. If the run looks off the ask, the session shows drifting and — in + Nudge — the agent gets a short check-in suggesting it file the side-finding as a task and return. If it + is still off at the next check, an unattended run's owner gets one Inbox card. Advisory only: nothing is ever blocked or + stopped. Observe only records the verdicts without touching the run. Uses the workspace Anthropic key if + set, otherwise the runtime login. An agent can opt out with driftCheck: false. +

+
+
+ ) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ea4bea8d..693cbe05 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1452,6 +1452,9 @@ export interface SecretMeta { } /** Numeric governance caps the never-tier policy rules read ($moneyCapUsd / $bulkDeleteCount). */ +/** The drift focus check's mode (Settings → Governance). */ +export type DriftMode = 'off' | 'observe' | 'nudge' + export interface GovernanceThresholds { moneyCapUsd: number bulkDeleteCount: number @@ -1871,7 +1874,7 @@ export interface SessionProgress { total: number | null pct: number | null delta: number | null - verdict: 'forward' | 'stuck' | 'circling' | 'blocked' + verdict: 'forward' | 'stuck' | 'circling' | 'drifting' | 'blocked' reason: string note: string | null ts: number | null @@ -2282,8 +2285,8 @@ export const api = { cancelRuntimeLogin: (id: string) => call<{ ok: boolean; error?: string }>('DELETE', `/api/runtime-accounts/login/${encodeURIComponent(id)}`), checkRuntimeAccount: (runtime: string, name: string) => call<{ ok: boolean; error?: string; account?: RuntimeAccount; check?: { ok: boolean | null; note: string } }>('POST', `/api/runtime-accounts/${encodeURIComponent(runtime)}/${encodeURIComponent(name)}/check`), - governance: () => call('GET', '/api/settings/governance'), - saveGovernance: (t: GovernanceThresholds & { hostGovernanceEnabled?: boolean; semanticGuardEnabled?: boolean; fileWriteGuardEnabled?: boolean }) => call<{ ok: boolean; error?: string; hostGovernanceEnabled?: boolean; semanticGuardEnabled?: boolean; fileWriteGuardEnabled?: boolean } & GovernanceThresholds>('PUT', '/api/settings/governance', t), + governance: () => call('GET', '/api/settings/governance'), + saveGovernance: (t: GovernanceThresholds & { hostGovernanceEnabled?: boolean; semanticGuardEnabled?: boolean; fileWriteGuardEnabled?: boolean; driftMode?: DriftMode }) => call<{ ok: boolean; error?: string; hostGovernanceEnabled?: boolean; semanticGuardEnabled?: boolean; fileWriteGuardEnabled?: boolean; driftMode?: DriftMode } & GovernanceThresholds>('PUT', '/api/settings/governance', t), // Per-tenant console branding (accent colour + favicon badge). branding: () => call('GET', '/api/settings/branding'),