From 6279f9c2a2bd58096982193da22eacc098d00f8a Mon Sep 17 00:00:00 2001 From: Cary Palmer Date: Mon, 14 Sep 2026 05:14:21 +0000 Subject: [PATCH 1/2] feat(mouth): Wave 7 P0 ToolLine, ask_person, silence stall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native OpenBot chat-loop parity: ToolLine mouth/hop feed cards, ask_person judgement exit, OpenRouter silence watchdog + sticky stopped banner. Patterns only — no CopilotKit / @ag-ui / generative UI. No release tag. --- AGENTS.md | 1 + docs/mouth-stream.md | 6 +- docs/wave7-p0-openbot-parity.md | 68 +++++++++ src/app.tsx | 90 ++++++++--- src/chrome/index.ts | 2 + src/chrome/tool-line.tsx | 193 ++++++++++++++++++++++++ src/domain.ts | 32 +++- src/runtime/mouth-stall.ts | 170 +++++++++++++++++++++ src/runtime/mouth-tool-line.ts | 76 ++++++++++ src/runtime/mouth.ts | 24 ++- src/runtime/working-set.ts | 2 +- src/session.ts | 85 ++++++++++- tests/session.test.ts | 12 +- tests/wave7-p0.test.ts | 256 ++++++++++++++++++++++++++++++++ 14 files changed, 984 insertions(+), 33 deletions(-) create mode 100644 docs/wave7-p0-openbot-parity.md create mode 100644 src/chrome/tool-line.tsx create mode 100644 src/runtime/mouth-stall.ts create mode 100644 src/runtime/mouth-tool-line.ts create mode 100644 tests/wave7-p0.test.ts diff --git a/AGENTS.md b/AGENTS.md index 763db3d..e6e60ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,7 @@ Invariants: - GPUI polish P0 (Wave 6) — toast stack + level policy (`src/chrome/toast*.tsx` / toast-store); EmptyState icon/CTA; focus chords Cmd+L / Cmd+B / Cmd+, / Cmd+J (HID + gpuix); `docs/wave6-gpui-polish.md`; no CopilotKit / gpui-kit; idle parks intact; package 0.15.0. - GPUI polish P1 (Wave 6) — Cmd+K command palette lite (sisters / rooms / settings sections / Jobs); visible steer-queue card (remove / send-now); shared `ListRow` density + hover end-slot; `Tip` microcopy on titlebar / rail / Jobs; opaque menuFill overlays; idle parks intact; package 0.15.0. - GPUI polish P2 (Wave 6) — optional Mac OS notify (background mouth complete / needs approval; Quiet + `AUTOMATON_DISABLE_NOTIFICATIONS`); pulse lease strides (MouthWait / Activity slow cadence on expensive surfaces); feed edge-fade spike; Shortcuts help from live `KEYMAP`; `docs/wave6-gpui-polish.md` + idle-cpu honesty; no CopilotKit / gpui-kit; no version cut in P2. +- Wave 7 P0 OpenBot parity — ToolLine mouth/hop feed cards; `ask_person` judgement exit; mouth silence watchdog + sticky stopped banner (`docs/wave7-p0-openbot-parity.md`); patterns only — no CopilotKit / `@ag-ui/*` / generative UI; package 0.15.0 (no tag in P0 PR). - Bezel light appearance (Wave 2 P2) — designed light (Settings → Window → Appearance; not invert of graphite); Brand-complete Settings (remaining CARD/FIELD via useChrome; update modal scrim); ToggleGroup, EmptyState, Sheet extracted. Still vendored `@gpuix/react` (Wave 3.2 is 0.6.2 + matched native). Package 0.8.0. ## Safety diff --git a/docs/mouth-stream.md b/docs/mouth-stream.md index 3005a5b..535ca9f 100644 --- a/docs/mouth-stream.md +++ b/docs/mouth-stream.md @@ -25,8 +25,10 @@ row lands only on the automaton that ran the tool. `detail` or any stream row. Same honesty as the action ledger and `docs/secrets.md`. -Paint is a quiet tertiary line in the feed (`decide · box_shell · shell`), -danger-tinted on `refuse`. No new motion libraries. +Paint is a ToolLine-class card (Wave 7 P0): one-line label + detail, +running pulse, refuse/fail tones, expandable phase disclosure. Consecutive +phases for the same tool+intent collapse at paint. No new motion libraries +(reuse pulse lease). See [Wave 7 P0](wave7-p0-openbot-parity.md). ## Action ledger initiator diff --git a/docs/wave7-p0-openbot-parity.md b/docs/wave7-p0-openbot-parity.md new file mode 100644 index 0000000..d03c3b6 --- /dev/null +++ b/docs/wave7-p0-openbot-parity.md @@ -0,0 +1,68 @@ +# Wave 7 P0 — OpenBot chat-loop parity (patterns only) + +**Status:** Implemented on branch `feat/wave-7-p0-toolline-ask-stall`. +**Policy:** Steal **patterns only** from CopilotKit/OpenBot. No `@ag-ui/*`, no +CopilotKit npm, no generative UI / sandboxed iframes. Mouths = OpenRouter; +Jobs = Puppetmaster. Native `@gpuix/react` only. Sisters stay separate. + +Do **not** cut a release tag in the P0 PR (v0.16.0 waits on coordinator). + +## P0a — ToolLine-class mouth / hop feed cards + +| OpenBot | Automaton | +| --- | --- | +| `ToolLine` one-line rhythm + disclosure | `src/chrome/tool-line.tsx` | +| Running shimmer | Pulse lease opacity (`motion` / Wave 6 pulse) | +| Handoff / escalation reuse ToolLine | Hop relays + `ask_person` stream rows | + +- Consecutive `mouth-stream` rows that share `tool` + `intent` collapse at + paint (`mouthStreamSuperseded`) so decide→act→done advances one card. +- Sent / from relays with envelope fields paint expandable Task / + Constraints / Expecting / Outcome (paths & policy only — never secrets, + stdout, or file bytes). +- Tones: running / refused / failed / done. + +## P0b — `ask_person` mouth exit + +JSON emit: + +```json +{ "type": "ask_person", "question": "…", "why": "…" } +``` + +- Ends the turn; parks a purpose=`ask` widget (`askPersonWidget` — + allowCustom Reply) with `why` as helpText. +- Speaks `Put to you.`; paints mouth-stream decide→act→done for + `ask_person`; durable `action_events` row via `buildActionEvent`. +- Prompt cue (`WIDGET_CUE`): prefer ask_person over hop when no sister can + settle it. Not gated on a hop grant. +- Answer resumes via existing `answerWidget` → `send` path. +- Not an on-call rota / Jobs GATE widget. Auto does not auto-approve when + unattended. + +## P0c — Mouth silence watchdog + sticky stopped banner + +| OpenBot | Automaton | +| --- | --- | +| `stall-guard` / `turn-watchdog` | `src/runtime/mouth-stall.ts` | +| Sticky stopped sentence | `Thread.stoppedReason` + `StoppedTurnBanner` | + +- Watches OpenRouter **response body** silence between chunks (injectable + clock; `AUTOMATON_MOUTH_STALL_MS`, default 45s; `0` disables). +- On stall: `MouthStallError` → `mouthFailSpeak` → + `OpenRouter went quiet mid-reply. Try again.` +- `failMouth` parks sticky `stoppedReason`; next `paintSend` / `send` + clears it. Auth / empty / rate-limit remain distinct sentences. +- Does **not** abort Puppetmaster Jobs. Consumer/UI pause is not Bot silence + (watch is on the OpenRouter body pump). + +## Out of scope (P1+) + +Interrupt history hygiene, hop `mayAddress` grants, hold cancelled-vs-refused, +computer activity epoch, proposed-sister consent — see +`artifacts/automaton-wave7-openbot-parity-audit.md`. + +## Tests + +`tests/wave7-p0.test.ts` — ToolLine fold, hop envelope, ask_person parse / +widget resume, injectable stall clock, sticky banner clear. diff --git a/src/app.tsx b/src/app.tsx index e7d8ff5..9395ed3 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -25,6 +25,7 @@ import { type Agent, type ChannelReplyRouting, type FeedItem, + parseMouthEmit, type GoalRun, type JobHandle, type MouthState, @@ -98,7 +99,17 @@ import { ensureLocalDashboard, isDashboardJobId, openDashboardUrl } from './runt import { githubUrlFromHomeRepo, readExplicitOriginRemote } from './runtime/cloud-origin' import { displayForMouth } from './runtime/computer' import { chatComputerOpenRouter, ensureComputerWorker, liveComputerSeams } from './runtime/computer-worker' -import { initiatorFromKickoff, mouthStreamLabel } from './runtime/mouth-stream' +import { buildActionEvent, initiatorFromKickoff, mouthStreamLabel } from './runtime/mouth-stream' +import { + hopRelayDetail, + hopRelayHasDisclosure, + hopRelayLabel, + mouthStreamSuperseded, + mouthStreamTone, + mouthStreamToolLabel, + peerDisplayName, + shouldPaintHopRelay, +} from './runtime/mouth-tool-line' import { sessionActivityForSister } from './runtime/session-activity' import { setHumanDriving } from './runtime/driving' import { quitAutomaton } from './runtime/quit' @@ -162,7 +173,7 @@ import { postOsNotify, } from './runtime/os-notify' import { ConfirmCard, QuestionCard, SecretRequestCard } from './cards' -import { ActivityZone, CommandPalette, Composer, EdgeFadeFrame, EmptyState, ListRow, MouthWaitBubble, Sheet, SteerQueueCard, Tip, Titlebar, ToastStack, activityVisible, groupBoxStyle, pushToast, tracesFromJob } from './chrome' +import { ActivityZone, CommandPalette, Composer, EdgeFadeFrame, EmptyState, ListRow, MouthWaitBubble, Sheet, SteerQueueCard, StoppedTurnBanner, Tip, Titlebar, ToastStack, ToolLine, activityVisible, groupBoxStyle, pushToast, tracesFromJob } from './chrome' import { connectorDisplayName } from './runtime/connectors' import { Settings } from './settings' import { motionTransition, pulseStrideFor } from './motion' @@ -505,6 +516,7 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { void ensureMouth(session, store, { onComplete: (agentId, spoken) => { let routing: ChannelReplyRouting | undefined + const emit = parseMouthEmit(spoken) setSession((current) => { if (current.threads[agentId]?.mouth === 'intro') markIntroPlayedAt(agentId) const taken = takePendingChannelReply(current, agentId) @@ -514,6 +526,19 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { persistIntroIfUserSpoke(next) return next }) + if (emit?.kind === 'ask_person') { + store.recordAction( + buildActionEvent({ + ownerAgentId: agentId, + tool: 'ask_person', + intent: 'ask', + decision: 'permit', + reason: 'ask_person', + path: 'person', + initiatorKind: 'person', + }), + ) + } try { const name = session.agents.find((a) => a.id === agentId)?.name ?? 'Sister' if ( @@ -1248,6 +1273,7 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { setSession((current) => sendSteerNow(current, current.activeAgentId, index)) } /> + {thread?.stoppedReason ? : null} agents: Agent[] }) { + const peerName = peerDisplayName(agents, item.peerId) + const label = hopRelayLabel(item.lane, peerName, item.failed) + const detail = hopRelayDetail(item) + const disclosure = hopRelayHasDisclosure(item) return (
- + + {disclosure ? ( + <> + {item.task?.trim() ?
Task: {item.task.trim()}
: null} + {item.constraints?.trim() ?
Constraints: {item.constraints.trim()}
: null} + {item.expecting?.trim() ?
Expecting: {item.expecting.trim()}
: null} + {item.lane === 'from' && item.text.trim() ?
Outcome: {item.text.trim()}
: null} + + ) : null} +
) }, sameFeedRowFingerprint) @@ -2106,25 +2150,29 @@ const FeedMouthStreamRow = React.memo(function FeedMouthStreamRow({ fingerprint: string item: Extract }) { - const T = useTokens() - const label = mouthStreamLabel(item.phase, item.tool, item.intent, item.detail, item.bytes) - const tone = item.phase === 'refuse' ? T.danger : T.tertiary + const tone = mouthStreamTone(item.phase) + const label = mouthStreamToolLabel(item.tool, item.intent) + const size = + typeof item.bytes === 'number' && Number.isFinite(item.bytes) + ? mouthStreamLabel('done', '_', '_', undefined, item.bytes).split(' · ').pop() + : undefined + const detailLine = [item.detail?.trim(), size].filter(Boolean).join(' · ') || undefined return (
-
-
{label}
-
+
+ {item.phase} · {item.tool} · {item.intent} + {item.detail?.trim() ? ` · ${item.detail.trim()}` : ''} + {size ? ` · ${size}` : ''} +
+
) }, sameFeedRowFingerprint) @@ -2494,7 +2542,7 @@ export const Feed = forwardRef { if (item.kind === 'relay') { - if (item.lane === 'from') return null + if (!shouldPaintHopRelay(item)) return null const peer = agents.find((agent) => agent.id === item.peerId) const fingerprint = feedRowFingerprint({ kind: 'relay', @@ -2502,6 +2550,9 @@ export const Feed = forwardRef } @@ -2544,6 +2595,7 @@ export const Feed = forwardRef + {expandable ? ( +
+ ▸ +
+ ) : null} + {running ? ( + +
{shown}
+ {detailText ? ( +
+ {detailText} +
+ ) : null} +
+ ) : ( + <> +
{shown}
+ {detailText ? ( +
+ {detailText} +
+ ) : null} + + )} + + ) + + return ( +
+ {expandable ? ( +
setOpen((value) => !value)} + > + {row} +
+ ) : ( + row + )} + {expandable && open ? ( +
+ {children} +
+ ) : null} +
+ ) +} + +/** Sticky composer banner until next Send — stalled / failed turn honesty. */ +export function StoppedTurnBanner({ + reason, + testId = 'stopped-turn-banner', +}: { + reason: string + testId?: string +}) { + const T = useTokens() + const text = reason.trim() || 'The mouth stopped without saying why.' + return ( +
+
{text}
+
+ ) +} diff --git a/src/domain.ts b/src/domain.ts index 743da04..31b24f2 100644 --- a/src/domain.ts +++ b/src/domain.ts @@ -187,8 +187,10 @@ export type FeedItem = lane: 'sent' | 'from' peerId: AgentId text: string - /** Original hop task, stamped on from-relays so assess still sees it after drop. */ + /** Hop task — stamped on sent + from relays for ToolLine disclosure. */ task?: string + /** Hop constraints — paths/policy only; never secrets. */ + constraints?: string /** Original hop expecting, stamped on from-relays until the head assesses. */ expecting?: string /** Sister failed or returned empty — assess is a notice, not copy. */ @@ -273,6 +275,11 @@ export type Thread = { * Not a Jobs/PM artifact. Honest: summarized, not verbatim transcript. */ compactSummary?: string + /** + * Sticky stopped-turn banner (Wave 7 P0c). Set on stall / failMouth; cleared + * on the next Send. Distinct from auth miss vs empty vs silence. + */ + stoppedReason?: string } export const STAFF_AGENT: Agent = { @@ -1735,6 +1742,18 @@ export function landWidgetForKind(kind: JobKind): QuestionWidget { } } +/** Mouth-native judgement exit — free-text reply via allowCustom. */ +export function askPersonWidget(question: string, why?: string): QuestionWidget { + const widget: QuestionWidget = { + prompt: question.trim() || 'Need your judgement.', + options: [{ label: 'Reply', value: 'reply', style: 'primary' }], + allowCustom: true, + } + const reason = why?.trim() + if (reason) widget.helpText = reason + return widget +} + export function hostApprovalWidget(prompt = 'Run this on your Mac?'): QuestionWidget { return { prompt, @@ -1887,6 +1906,7 @@ export type MouthEmit = | { kind: 'widget'; widget: QuestionWidget } | { kind: 'secret-request'; connectorId: string } | { kind: 'hop'; hop: Pick } + | { kind: 'ask_person'; question: string; why?: string } function stripJsonFence(text: string): string { const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i) @@ -1920,6 +1940,16 @@ export function parseMouthEmit(spoken: string): MouthEmit | null { }, } } + if (type === 'ask_person' || type === 'ask-person' || type === 'askPerson') { + const question = typeof parsed.question === 'string' ? parsed.question.trim() : '' + if (!question) return null + const why = typeof parsed.why === 'string' ? parsed.why.trim() : '' + return { + kind: 'ask_person', + question, + ...(why ? { why } : {}), + } + } if (type === 'widget' || type === 'question') { const optionsRaw = Array.isArray(parsed.options) ? parsed.options : [] const options: WidgetOption[] = optionsRaw diff --git a/src/runtime/mouth-stall.ts b/src/runtime/mouth-stall.ts new file mode 100644 index 0000000..b6197e1 --- /dev/null +++ b/src/runtime/mouth-stall.ts @@ -0,0 +1,170 @@ +/** + * Mouth silence watchdog (OpenBot stall-guard / turn-watchdog pattern). + * + * Silence ≠ wall-clock duration: long OpenRouter replies stay legal when bytes + * keep arriving. Wedged mid-response (no chunks for stallMs) fails the mouth + * with a distinct sentence. Jobs / Puppetmaster are not watched here. + * + * Injectable clock so tests can advance time without sleeping. stallMs ≤ 0 + * disables (prior behavior). AUTOMATON_MOUTH_STALL_MS overrides the default. + */ + +export type Clock = () => number + +export type StalledRead = { + silentForMs: number + chunks: number +} + +export type MouthStallWatch = { + noteChunk: () => void + isStalled: () => boolean + stalled: () => StalledRead | null + /** Silence elapsed since open / last chunk. */ + silentForMs: () => number +} + +export const MOUTH_STALL_ENV = 'AUTOMATON_MOUTH_STALL_MS' +/** Default silence before a wedged OpenRouter body is abandoned (ms). */ +export const DEFAULT_MOUTH_STALL_MS = 45_000 + +export const MOUTH_STALL_SPEAK = + 'OpenRouter went quiet mid-reply. Try again.' + +export function mouthStallMs(env: NodeJS.ProcessEnv = process.env): number { + const raw = env[MOUTH_STALL_ENV] + if (raw == null || raw.trim() === '') return DEFAULT_MOUTH_STALL_MS + const n = Number(raw) + if (!Number.isFinite(n)) return DEFAULT_MOUTH_STALL_MS + return Math.max(0, Math.floor(n)) +} + +export function createMouthStallWatch(options: { + stallMs: number + now?: Clock +}): MouthStallWatch | null { + const stallMs = options.stallMs + if (stallMs <= 0) return null + const now = options.now ?? Date.now + let lastChunkAt = now() + let chunks = 0 + return { + noteChunk() { + chunks += 1 + lastChunkAt = now() + }, + silentForMs() { + return Math.max(0, now() - lastChunkAt) + }, + isStalled() { + return now() - lastChunkAt >= stallMs + }, + stalled() { + const silentForMs = now() - lastChunkAt + if (silentForMs < stallMs) return null + return { silentForMs, chunks } + }, + } +} + +export class MouthStallError extends Error { + readonly silentForMs: number + readonly chunks: number + constructor(info: StalledRead) { + super('mouth stalled') + this.name = 'MouthStallError' + this.silentForMs = info.silentForMs + this.chunks = info.chunks + } +} + +export function isMouthStallError(error: unknown): boolean { + if (error instanceof MouthStallError) return true + return error instanceof Error && error.message === 'mouth stalled' +} + +/** + * Race a pending read against silence. `tick` advances the poll; tests pass a + * tick that resolves when they have moved the injectable clock past stallMs. + */ +export async function raceAgainstStall( + pending: Promise, + watch: MouthStallWatch, + options?: { + /** Called repeatedly until pending settles or stall; default real 10ms poll. */ + tick?: () => Promise + }, +): Promise { + const tick = + options?.tick ?? + (() => new Promise((resolve) => setTimeout(resolve, 10))) + let settled = false + let rejectStall: ((error: Error) => void) | null = null + const stallProbe = new Promise((_, reject) => { + rejectStall = reject + }) + const loop = (async () => { + while (!settled) { + if (watch.isStalled()) { + const info = watch.stalled() ?? { silentForMs: watch.silentForMs(), chunks: 0 } + rejectStall?.(new MouthStallError(info)) + return + } + await tick() + } + })() + try { + return await Promise.race([pending, stallProbe]) + } finally { + settled = true + await Promise.race([loop, Promise.resolve()]).catch(() => {}) + } +} + +/** + * Read a Response body while watching silence between chunks. + * Pass-through concatenate for JSON parse — does not hold chunks back from the + * caller beyond assembling the string chatOpenRouter already needed. + * stallMs ≤ 0 (or no body) → response.text(). + */ +export async function readResponseTextWithStall( + response: Response, + options: { + stallMs: number + now?: Clock + tick?: () => Promise + } = { stallMs: 0 }, +): Promise { + const stallMs = options.stallMs + const body = response.body + if (!body || stallMs <= 0) { + return response.text() + } + const watch = createMouthStallWatch({ stallMs, now: options.now }) + if (!watch) return response.text() + + const reader = body.getReader() + const decoder = new TextDecoder() + let text = '' + try { + for (;;) { + if (watch.isStalled()) { + throw new MouthStallError(watch.stalled() ?? { silentForMs: stallMs, chunks: 0 }) + } + const { done, value } = await raceAgainstStall(reader.read(), watch, { tick: options.tick }) + if (done) break + if (value) { + watch.noteChunk() + text += decoder.decode(value, { stream: true }) + } + } + text += decoder.decode() + return text + } finally { + try { + await reader.cancel() + } catch { + /* ignore */ + } + } +} diff --git a/src/runtime/mouth-tool-line.ts b/src/runtime/mouth-tool-line.ts new file mode 100644 index 0000000..21c99d9 --- /dev/null +++ b/src/runtime/mouth-tool-line.ts @@ -0,0 +1,76 @@ +import type { Agent, FeedItem, MouthStreamPhase } from '../domain' + +/** Whether a later consecutive mouth-stream row supersedes this one in the feed. */ +export function mouthStreamSuperseded( + items: readonly FeedItem[], + index: number, +): boolean { + const item = items[index] + if (!item || item.kind !== 'mouth-stream') return false + const next = items[index + 1] + if (!next || next.kind !== 'mouth-stream') return false + return ( + next.agentId === item.agentId && + next.tool === item.tool && + next.intent === item.intent + ) +} + +export type ToolLineTone = { + running: boolean + refused: boolean + failed: boolean +} + +export function mouthStreamTone(phase: MouthStreamPhase): ToolLineTone { + return { + running: phase === 'decide' || phase === 'act', + refused: phase === 'refuse', + failed: false, + } +} + +/** Human label for a mouth-stream tool row (OpenBot ToolLine rhythm). */ +export function mouthStreamToolLabel(tool: string, intent: string): string { + const t = tool.trim() + if (t === 'ask_person') return 'Asked you' + if (t.startsWith('mcp:')) return `MCP ${t.slice(4) || intent}` + if (t.startsWith('box_')) return intent.trim() || t.replace(/^box_/, '') + if (t.startsWith('host_')) return intent.trim() || t.replace(/^host_/, '') + return intent.trim() || t || 'Step' +} + +export function hopRelayLabel( + lane: 'sent' | 'from', + peerName: string, + failed?: boolean, +): string { + if (lane === 'sent') return `Sent to ${peerName}` + if (failed) return `From ${peerName}` + return `Message from ${peerName}` +} + +export function hopRelayDetail(item: Extract): string | undefined { + const task = item.task?.trim() + if (task) return task.length > 72 ? `${task.slice(0, 69)}…` : task + const text = item.text.trim() + if (!text) return undefined + return text.length > 72 ? `${text.slice(0, 69)}…` : text +} + +export function hopRelayHasDisclosure(item: Extract): boolean { + return Boolean(item.task?.trim() || item.expecting?.trim() || item.constraints?.trim() || item.text.trim()) +} + +export function peerDisplayName(agents: readonly Agent[], peerId: string): string { + return agents.find((agent) => agent.id === peerId)?.name ?? peerId +} + +/** + * Paint from-relays that carry hop envelope / failure; otherwise the feed still + * shows Message-from via the agent_note path on the sister reply bubble. + */ +export function shouldPaintHopRelay(item: Extract): boolean { + if (item.lane === 'sent') return true + return Boolean(item.task || item.expecting || item.constraints || item.failed) +} diff --git a/src/runtime/mouth.ts b/src/runtime/mouth.ts index 3d6005f..699c210 100644 --- a/src/runtime/mouth.ts +++ b/src/runtime/mouth.ts @@ -11,6 +11,12 @@ import { applyProviderReasoningControls, type ProviderMapContext } from './provi import type { StaffStore, TurnReceipt } from './store' import { runningTests } from './test-env' import { markCompactFail, runCompact, withCacheBreakpoint } from './compact' +import { + isMouthStallError, + MOUTH_STALL_SPEAK, + mouthStallMs, + readResponseTextWithStall, +} from './mouth-stall' import { buildWorkingSet, formatRecallSpoken, @@ -81,6 +87,7 @@ function isRateLimited(error: unknown): boolean { } export function mouthFailSpeak(error: unknown, authRejected = false): string { + if (isMouthStallError(error)) return MOUTH_STALL_SPEAK if (isRateLimited(error)) return 'OpenRouter rate limited. Try again.' if (error instanceof Error && error.message === 'empty mouth') return 'The model returned no text.' const status = openRouterStatus(error) @@ -458,6 +465,10 @@ async function speakIntro( export type ChatOpenRouterSeams = Pick & { map?: ProviderMapContext + /** Silence watchdog; 0 disables. Default from AUTOMATON_MOUTH_STALL_MS / 45s. */ + stallMs?: number + now?: () => number + stallTick?: () => Promise } export async function chatOpenRouter( @@ -487,7 +498,18 @@ export async function chatOpenRouter( if (!response.ok) { throw new Error(`openrouter ${response.status}`) } - const parsed: unknown = await response.json() + const stallMs = seams?.stallMs ?? mouthStallMs() + const raw = await readResponseTextWithStall(response, { + stallMs, + now: seams?.now, + tick: seams?.stallTick, + }) + let parsed: unknown + try { + parsed = JSON.parse(raw) as unknown + } catch { + throw new Error('empty mouth') + } const text = parsed && typeof parsed === 'object' && 'choices' in parsed ? ((parsed as { choices?: { message?: { content?: string } }[] }).choices?.[0]?.message diff --git a/src/runtime/working-set.ts b/src/runtime/working-set.ts index bd70f50..83af21b 100644 --- a/src/runtime/working-set.ts +++ b/src/runtime/working-set.ts @@ -168,7 +168,7 @@ function machineFact(projects?: MachineProject[]): string { } export const WIDGET_CUE = - 'To ask a multiple-choice question, reply with a JSON object {"type":"widget","prompt":"...","options":[{"label":"..."}]} (1-6 options). To collect a key, reply with {"type":"secret-request","connectorId":"openrouter"}. Never ask them to paste a key in chat. A widget or secret-request ends the turn.' + 'To ask a multiple-choice question, reply with a JSON object {"type":"widget","prompt":"...","options":[{"label":"..."}]} (1-6 options). To collect a key, reply with {"type":"secret-request","connectorId":"openrouter"}. Never ask them to paste a key in chat. When only a person can settle it (judgement, permission, a fact only they know), prefer {"type":"ask_person","question":"...","why":"..."} over hopping to another automaton or guessing — it ends the turn and waits for their reply. A widget, secret-request, or ask_person ends the turn.' export const INTRO_CUE = 'The user just opened your chat for the first time. Speak one or two sentences. Name yourself. Say what you do from your title and description. Do not ask how you can help. Do not list tools, jobs, or capabilities. Do not say "how can I help you."' diff --git a/src/session.ts b/src/session.ts index 92f8a6a..ad605f1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -51,6 +51,7 @@ import { normalizeWidget, parseDeskUrl, parseGithubIssue, + askPersonWidget, parseMouthEmit, formatSisterMandate, handedHopsSinceLastUser, @@ -643,6 +644,7 @@ export function paintSend( const active = session.activeAgentId if (!active || !session.threads[active]) return session if (!text && attachmentIds.length === 0) return session + session = clearStoppedReason(session, active) const row = thread(session, active) if (shouldQueueSteer(row.mouth, row.computerBusy === true)) { return enqueueSteer(session, text, attachmentIds) @@ -706,7 +708,9 @@ export function paintSend( /** Tests and non-Enter callers still book jobs in the same call. */ export function send(session: Session, raw: string, attachmentIds: string[] = []): Session { - return finishSend(paintSend(session, raw, attachmentIds)) + const focused = session.activeAgentId + const cleared = clearStoppedReason(session, focused) + return finishSend(paintSend(cleared, raw, attachmentIds)) } function paintDirect( @@ -857,8 +861,11 @@ function appendRelay( peerId: AgentId, text: string, focused: AgentId, - extra?: { task?: string; expecting?: string; failed?: boolean }, + extra?: { task?: string; constraints?: string; expecting?: string; failed?: boolean }, ): Session { + const task = extra?.task?.trim() || (lane === 'sent' ? text.trim() : '') || undefined + const constraints = extra?.constraints?.trim() || undefined + const expecting = extra?.expecting?.trim() || undefined return append( session, threadId, @@ -868,8 +875,9 @@ function appendRelay( lane, peerId, text, - ...(lane === 'from' && extra?.task ? { task: extra.task } : {}), - ...(lane === 'from' && extra?.expecting ? { expecting: extra.expecting } : {}), + ...(task ? { task } : {}), + ...(constraints ? { constraints } : {}), + ...(expecting ? { expecting } : {}), ...(lane === 'from' && extra?.failed ? { failed: true } : {}), }, focused, @@ -1294,6 +1302,11 @@ export function offerSisterHop(session: Session, hop: SisterHop): Session { const marker = { to: hop.to, depth: hop.depth } const mandate = formatSisterMandate(hop) let next = speak(session, hop.from, `Handed to ${name}.`, focused, undefined, marker) + next = appendRelay(next, hop.from, 'sent', hop.to, hop.task, focused, { + task: hop.task, + constraints: hop.constraints, + expecting: hop.expecting, + }) next = addPendingHop(next, hop.from, { to: hop.to, task: hop.task, @@ -1333,6 +1346,10 @@ export function completeMouth(session: Session, agentId: AgentId, spoken: string } return finishBatch(next, agentId) } + if (emit?.kind === 'ask_person') { + let next = emitAskPerson(session, agentId, emit.question, emit.why) + return finishBatch(next, agentId) + } if (emit?.kind === 'hop') { const hop: SisterHop = { from: agentId, @@ -1410,6 +1427,7 @@ function coordinatorReturn( const text = isFail ? hopFailureLine(name, envelope?.task) : cleaned let next = appendRelay(session, from, 'from', ownerId, text, focused, { task: envelope?.task, + constraints: envelope?.constraints, expecting: envelope?.expecting, failed: isFail, }) @@ -1441,23 +1459,27 @@ export function stopRun(session: Session, agentId: AgentId): Session { export function failMouth(session: Session, agentId: AgentId, spoken: string): Session { const row = thread(session, agentId) const last = row.items.at(-1) + const reason = sanitizeSpeak(spoken).trim() if (row.mouth === 'answer' && last?.kind === 'relay' && last.lane === 'from') { const name = session.agents.find((agent) => agent.id === last.peerId)?.name ?? 'They' let next = wakeMouth(session, agentId, 'idle') next = speak(next, agentId, returnBeat(name, last.text), session.activeAgentId) + next = setStoppedReason(next, agentId, reason) return finishBatch(next, agentId) } const from = inboundCoordinatorId(session, agentId) if (from && row.mouth !== 'idle') { const focused = session.activeAgentId - const line = sanitizeSpeak(spoken).trim() + const line = reason let next = session if (line) next = speak(next, agentId, line, focused) next = wakeMouth(next, agentId, 'idle') next = coordinatorReturn(next, from, agentId, line, focused, true) + next = setStoppedReason(next, agentId, reason) return finishBatch(next, agentId) } - return completeMouth(session, agentId, spoken) + let next = completeMouth(session, agentId, spoken) + return setStoppedReason(next, agentId, reason) } /** Persist mouth compact summary. Does not touch Jobs / PM artifacts. */ @@ -1925,6 +1947,57 @@ function dismissMoveOnWidgets(session: Session, agentId: AgentId): Session { return setThread(session, agentId, { items }) } +/** First-class judgement exit — parks ask widget + mouth-stream ToolLine arc. */ +export function emitAskPerson( + session: Session, + agentId: AgentId, + question: string, + why?: string, +): Session { + if (!session.threads[agentId]) return session + const focused = session.activeAgentId + const q = question.trim() + if (!q) { + let next = speak(session, agentId, 'Need a question for you.', focused) + next = wakeMouth(next, agentId, 'idle') + return next + } + let next = appendMouthStream(session, agentId, { + phase: 'decide', + tool: 'ask_person', + intent: 'ask', + detail: 'person', + }) + next = appendMouthStream(next, agentId, { + phase: 'act', + tool: 'ask_person', + intent: 'ask', + detail: 'person', + }) + next = appendMouthStream(next, agentId, { + phase: 'done', + tool: 'ask_person', + intent: 'ask', + detail: 'person', + }) + next = speak(next, agentId, 'Put to you.', focused) + next = emitWidgetItem(next, agentId, askPersonWidget(q, why), { purpose: 'ask' }) + return next +} + +export function setStoppedReason(session: Session, agentId: AgentId, reason: string): Session { + if (!session.threads[agentId]) return session + const text = reason.trim() + if (!text) return setThread(session, agentId, { stoppedReason: undefined }) + return setThread(session, agentId, { stoppedReason: text }) +} + +export function clearStoppedReason(session: Session, agentId: AgentId): Session { + if (!session.threads[agentId]) return session + if (!thread(session, agentId).stoppedReason) return session + return setThread(session, agentId, { stoppedReason: undefined }) +} + function emitWidgetItem( session: Session, agentId: AgentId, diff --git a/tests/session.test.ts b/tests/session.test.ts index 86b3e27..1b7846c 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -2162,9 +2162,15 @@ describe('typed sister hop', () => { 'staff', '{"type":"hop","to":"kernel","task":"Check the pin.","constraints":"No merge.","expecting":"A one-line status."}', ) - const staffLast = s.threads.staff.items.at(-1) - expect(staffLast?.kind === 'msg' && staffLast.text).toBe('Handed to Kernel.') - expect(staffLast?.kind === 'msg' && staffLast.sisterHop).toEqual({ to: 'kernel', depth: 0 }) + const handed = s.threads.staff.items.find( + (item) => item.kind === 'msg' && item.from === 'agent' && item.text === 'Handed to Kernel.', + ) + expect(handed?.kind === 'msg' && handed.text).toBe('Handed to Kernel.') + expect(handed?.kind === 'msg' && handed.sisterHop).toEqual({ to: 'kernel', depth: 0 }) + const sent = s.threads.staff.items.find((item) => item.kind === 'relay' && item.lane === 'sent') + expect(sent?.kind === 'relay' && sent.task).toBe('Check the pin.') + expect(sent?.kind === 'relay' && sent.constraints).toBe('No merge.') + expect(sent?.kind === 'relay' && sent.expecting).toBe('A one-line status.') const mandate = s.threads.kernel.items.find((item) => item.kind === 'msg' && item.from === 'user') expect(mandate?.kind === 'msg' && mandate.text).toBe( 'Task: Check the pin.\nConstraints: No merge.\nExpecting: A one-line status.', diff --git a/tests/wave7-p0.test.ts b/tests/wave7-p0.test.ts new file mode 100644 index 0000000..27d0d21 --- /dev/null +++ b/tests/wave7-p0.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { + askPersonWidget, + emptyThreads, + parseMouthEmit, + resetIdsForTests, + staffWithSisters, +} from '../src/domain' +import { + createMouthStallWatch, + isMouthStallError, + mouthStallMs, + MOUTH_STALL_SPEAK, + MouthStallError, + raceAgainstStall, + readResponseTextWithStall, +} from '../src/runtime/mouth-stall' +import { + hopRelayLabel, + mouthStreamSuperseded, + mouthStreamTone, + mouthStreamToolLabel, + shouldPaintHopRelay, +} from '../src/runtime/mouth-tool-line' +import { mouthFailSpeak } from '../src/runtime/mouth' +import { WIDGET_CUE } from '../src/runtime/working-set' +import { + answerWidget, + appendMouthStream, + clearStoppedReason, + completeMouth, + emitAskPerson, + failMouth, + offerSisterHop, + send, + setStoppedReason, + type Session, +} from '../src/session' + +const root = join(import.meta.dir, '..') + +function fresh(): Session { + resetIdsForTests() + const agents = staffWithSisters() + return { + agents, + activeAgentId: 'staff', + threads: emptyThreads(agents), + jobs: [], + pendingFanout: null, + } +} + +function withMouth(session: Session, agentId: string, mouth: 'answer' | 'idle'): Session { + const row = session.threads[agentId] + if (!row) return session + return { ...session, threads: { ...session.threads, [agentId]: { ...row, mouth } } } +} + +describe('wave 7 p0 ToolLine fold + hop cards', () => { + test('mouthStreamSuperseded collapses decide→act→done to latest phase', () => { + let session = fresh() + session = appendMouthStream(session, 'kernel', { phase: 'decide', tool: 'box_shell', intent: 'shell' }) + session = appendMouthStream(session, 'kernel', { phase: 'act', tool: 'box_shell', intent: 'shell' }) + session = appendMouthStream(session, 'kernel', { phase: 'done', tool: 'box_shell', intent: 'shell' }) + const items = session.threads.kernel!.items + expect(mouthStreamSuperseded(items, 0)).toBe(true) + expect(mouthStreamSuperseded(items, 1)).toBe(true) + expect(mouthStreamSuperseded(items, 2)).toBe(false) + expect(mouthStreamTone('act').running).toBe(true) + expect(mouthStreamTone('refuse').refused).toBe(true) + expect(mouthStreamToolLabel('ask_person', 'ask')).toBe('Asked you') + expect(mouthStreamToolLabel('box_shell', 'shell')).toBe('shell') + }) + + test('offerSisterHop stamps sent relay envelope for ToolLine disclosure', () => { + let session = fresh() + session = offerSisterHop(session, { + from: 'staff', + to: 'kernel', + task: 'Check the pin.', + constraints: 'No merge.', + expecting: 'A one-line status.', + depth: 0, + }) + const relays = (session.threads.staff?.items ?? []).filter((row) => row.kind === 'relay') + expect(relays.length).toBeGreaterThanOrEqual(1) + const sent = relays.find((row) => row.kind === 'relay' && row.lane === 'sent') + expect(sent?.kind).toBe('relay') + if (sent?.kind !== 'relay') throw new Error('expected sent relay') + expect(sent.task).toBe('Check the pin.') + expect(sent.constraints).toBe('No merge.') + expect(sent.expecting).toBe('A one-line status.') + expect(shouldPaintHopRelay(sent)).toBe(true) + expect(hopRelayLabel('sent', 'Kernel')).toBe('Sent to Kernel') + }) + + test('tool-line chrome is native (no CopilotKit)', () => { + const toolLine = readFileSync(join(root, 'src/chrome/tool-line.tsx'), 'utf8') + expect(toolLine).toContain('ToolLine') + expect(toolLine).not.toContain('@copilotkit') + expect(toolLine).not.toContain('@ag-ui') + const pkg = readFileSync(join(root, 'package.json'), 'utf8') + expect(pkg).not.toContain('@ag-ui') + expect(pkg).not.toContain('copilotkit') + }) +}) + +describe('wave 7 p0 ask_person', () => { + test('parseMouthEmit reads ask_person and refuses blank question', () => { + expect(parseMouthEmit('{"type":"ask_person","question":" "}')).toBeNull() + expect(parseMouthEmit('{"type":"ask_person","question":"Ship tonight?","why":"Only you can decide."}')).toEqual({ + kind: 'ask_person', + question: 'Ship tonight?', + why: 'Only you can decide.', + }) + const widget = askPersonWidget('Ship tonight?', 'Only you can decide.') + expect(widget.allowCustom).toBe(true) + expect(widget.helpText).toBe('Only you can decide.') + }) + + test('completeMouth ask_person parks widget + mouth-stream and resumes on answer', () => { + let session = fresh() + session = withMouth(session, 'staff', 'answer') + session = completeMouth( + session, + 'staff', + JSON.stringify({ type: 'ask_person', question: 'Merge dest?', why: 'Policy call.' }), + ) + const items = session.threads.staff!.items + const streams = items.filter((row) => row.kind === 'mouth-stream') + expect(streams.map((row) => (row.kind === 'mouth-stream' ? row.phase : null))).toEqual([ + 'decide', + 'act', + 'done', + ]) + expect(streams.every((row) => row.kind === 'mouth-stream' && row.tool === 'ask_person')).toBe(true) + const widget = items.find((row) => row.kind === 'widget' && row.status === 'open') + expect(widget?.kind).toBe('widget') + if (widget?.kind !== 'widget') throw new Error('expected widget') + expect(widget.purpose).toBe('ask') + expect(widget.widget.prompt).toBe('Merge dest?') + expect(widget.widget.helpText).toBe('Policy call.') + expect(session.threads.staff?.mouth).toBe('idle') + expect(WIDGET_CUE).toContain('ask_person') + + session = answerWidget(session, widget.id, { values: [], custom: 'Hold the merge.' }) + const lastUser = [...(session.threads.staff?.items ?? [])] + .reverse() + .find((row) => row.kind === 'msg' && row.from === 'user') + expect(lastUser?.kind === 'msg' && lastUser.text).toBe('Hold the merge.') + }) + + test('emitAskPerson refuses empty question', () => { + let session = fresh() + session = emitAskPerson(session, 'staff', ' ') + const spoken = session.threads.staff?.items.find((row) => row.kind === 'msg' && row.from === 'agent') + expect(spoken?.kind === 'msg' && spoken.text).toContain('Need a question') + }) +}) + +describe('wave 7 p0 mouth silence watchdog', () => { + test('injectable clock stalls after silence; 0 disables', () => { + expect(mouthStallMs({ AUTOMATON_MOUTH_STALL_MS: '0' })).toBe(0) + expect(createMouthStallWatch({ stallMs: 0 })).toBeNull() + let now = 1_000 + const watch = createMouthStallWatch({ stallMs: 100, now: () => now }) + expect(watch).not.toBeNull() + expect(watch!.isStalled()).toBe(false) + now = 1_099 + expect(watch!.isStalled()).toBe(false) + now = 1_100 + expect(watch!.isStalled()).toBe(true) + watch!.noteChunk() + now = 1_150 + expect(watch!.isStalled()).toBe(false) + now = 1_250 + expect(watch!.stalled()?.chunks).toBe(1) + }) + + test('raceAgainstStall rejects when clock advances past stallMs', async () => { + let now = 0 + const watch = createMouthStallWatch({ stallMs: 50, now: () => now })! + const hung = new Promise(() => {}) + const ticks: Array<() => void> = [] + const tick = () => + new Promise((resolve) => { + ticks.push(() => { + now += 25 + resolve() + }) + }) + const raced = raceAgainstStall(hung, watch, { tick }) + // Drive a few ticks past stall + for (let i = 0; i < 4; i++) { + await Promise.resolve() + const next = ticks.shift() + next?.() + } + await expect(raced).rejects.toBeInstanceOf(MouthStallError) + }) + + test('readResponseTextWithStall fails on silent body; mouthFailSpeak is distinct', async () => { + let now = 0 + const body = new ReadableStream({ + start() { + /* never enqueue — silence */ + }, + }) + const response = new Response(body, { status: 200 }) + const ticks: Array<() => void> = [] + const tick = () => + new Promise((resolve) => { + ticks.push(() => { + now += 30 + resolve() + }) + }) + const pending = readResponseTextWithStall(response, { + stallMs: 50, + now: () => now, + tick, + }).then( + (value) => ({ ok: true as const, value }), + (error) => ({ ok: false as const, error }), + ) + for (let i = 0; i < 8; i++) { + await Promise.resolve() + await Promise.resolve() + ticks.shift()?.() + const state = await Promise.race([pending, Promise.resolve(null)]) + if (state) { + expect(state.ok).toBe(false) + if (!state.ok) expect(isMouthStallError(state.error)).toBe(true) + expect(mouthFailSpeak(new MouthStallError({ silentForMs: 50, chunks: 0 }))).toBe(MOUTH_STALL_SPEAK) + expect(mouthFailSpeak(new Error('empty mouth'))).not.toBe(MOUTH_STALL_SPEAK) + return + } + } + throw new Error('expected stall rejection') + }) + + test('failMouth parks sticky stoppedReason; send clears it', () => { + let session = fresh() + session = withMouth(session, 'staff', 'answer') + session = failMouth(session, 'staff', MOUTH_STALL_SPEAK) + expect(session.threads.staff?.stoppedReason).toBe(MOUTH_STALL_SPEAK) + session = clearStoppedReason(session, 'staff') + expect(session.threads.staff?.stoppedReason).toBeUndefined() + session = setStoppedReason(session, 'staff', MOUTH_STALL_SPEAK) + session = send(session, 'try again') + expect(session.threads.staff?.stoppedReason).toBeUndefined() + }) +}) From 91a2c60c42554a5971c4af9978313d20a77e72f0 Mon Sep 17 00:00:00 2001 From: professorpalmer Date: Mon, 14 Sep 2026 00:16:41 -0500 Subject: [PATCH 2/2] fix(feed): keep relay-* testIds on ToolLine hop cards Staff shell expects relay-sent-; hop cards had switched to hop-card-. --- src/app.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.tsx b/src/app.tsx index 9395ed3..4313668 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -2073,7 +2073,7 @@ const FeedRelayRow = React.memo(function FeedRelayRow({ return (