From 09be8e6f78d5feff0f00eea71c379c6642688b80 Mon Sep 17 00:00:00 2001 From: BakedSoups Date: Fri, 3 Jul 2026 12:27:29 -0700 Subject: [PATCH] Add agent nudge control --- src/lib/agent/agent-interface.ts | 37 ++++++++++++++++ .../agent/runner/harness/anthropic/index.ts | 4 ++ src/ui/logging-ui.ts | 10 +++++ src/ui/tui/__tests__/store.test.ts | 41 +++++++++++++++++ src/ui/tui/ink-ui.ts | 7 +++ src/ui/tui/primitives/TabContainer.tsx | 6 ++- src/ui/tui/screens/RunScreen.tsx | 14 ++++++ src/ui/tui/screens/audit/AuditRunScreen.tsx | 16 ++++++- src/ui/tui/store.ts | 44 +++++++++++++++++++ src/ui/wizard-ui.ts | 8 ++++ 10 files changed, 184 insertions(+), 3 deletions(-) diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index 43412b30..6ae7cbb2 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -207,6 +207,14 @@ export type AgentConfig = { getPendingQuestion?: () => | import('@lib/wizard-session').PendingQuestion | null; + /** + * Optional live user guidance channel. TUI implementations resolve whenever + * the operator asks the agent to move on; non-interactive hosts omit it. + */ + waitForAgentNudge?: ( + afterId?: number, + signal?: AbortSignal, + ) => Promise<{ id: number; message: string } | null>; /** * Orchestrator queue context. Present only when the `wizard-orchestrator` * flag routes the run here; threaded into wizard-tools so the orchestrator @@ -299,6 +307,10 @@ type AgentRunConfig = { getPendingQuestion?: () => | import('@lib/wizard-session').PendingQuestion | null; + waitForAgentNudge?: ( + afterId?: number, + signal?: AbortSignal, + ) => Promise<{ id: number; message: string } | null>; }; /** @@ -712,6 +724,7 @@ export async function initializeAgent( allowedTools: config.allowedTools, disallowedTools: config.disallowedTools, getPendingQuestion: config.getPendingQuestion, + waitForAgentNudge: config.waitForAgentNudge, }; logToFile('Agent config:', { @@ -833,6 +846,30 @@ export async function runAgent( message: { role: 'user', content: prompt }, parent_tool_use_id: null, }; + let lastNudgeId = 0; + while (agentConfig.waitForAgentNudge) { + const nudgeAbort = new AbortController(); + const nextNudge = agentConfig.waitForAgentNudge( + lastNudgeId, + nudgeAbort.signal, + ); + const next = await Promise.race([ + resultReceived.then(() => { + nudgeAbort.abort(); + return null; + }), + nextNudge, + ]); + if (!next) return; + lastNudgeId = next.id; + logToFile(`[agent] user nudge sent: ${next.message}`); + yield { + type: 'user', + session_id: '', + message: { role: 'user', content: next.message }, + parent_tool_use_id: null, + }; + } await resultReceived; }; diff --git a/src/lib/agent/runner/harness/anthropic/index.ts b/src/lib/agent/runner/harness/anthropic/index.ts index 01dcd101..190cde9a 100644 --- a/src/lib/agent/runner/harness/anthropic/index.ts +++ b/src/lib/agent/runner/harness/anthropic/index.ts @@ -71,6 +71,8 @@ export const anthropicBackend: AgentHarness = { allowedTools: programConfig.allowedTools, disallowedTools: programConfig.disallowedTools, getPendingQuestion: () => session.pendingQuestion, + waitForAgentNudge: (afterId, signal) => + getUI().waitForAgentNudge(afterId, signal), modelOverride: model, }, sessionToOptions(session), @@ -133,6 +135,8 @@ export const anthropicBackend: AgentHarness = { wizardMetadata: boot.wizardMetadata, integrationLabel: programConfig.id, orchestrator, + waitForAgentNudge: (afterId, signal) => + getUI().waitForAgentNudge(afterId, signal), }, options, ); diff --git a/src/ui/logging-ui.ts b/src/ui/logging-ui.ts index b3206f64..b94ad45c 100644 --- a/src/ui/logging-ui.ts +++ b/src/ui/logging-ui.ts @@ -93,6 +93,16 @@ export class LoggingUI implements WizardUI { console.log(`◇ ${message}`); } + waitForAgentNudge( + _afterId?: number, + signal?: AbortSignal, + ): Promise<{ id: number; message: string } | null> { + if (signal?.aborted) return Promise.resolve(null); + return new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(null), { once: true }); + }); + } + setDetectedFramework(label: string): void { console.log(`✔ Framework: ${label}`); } diff --git a/src/ui/tui/__tests__/store.test.ts b/src/ui/tui/__tests__/store.test.ts index a146f0ef..0760b645 100644 --- a/src/ui/tui/__tests__/store.test.ts +++ b/src/ui/tui/__tests__/store.test.ts @@ -794,6 +794,47 @@ describe('WizardStore', () => { }); }); + describe('agent nudges', () => { + it('records a nudge with a monotonic id and status message', () => { + const store = createStore(); + + store.nudgeAgent('Move to the next task.'); + + expect(store.agentNudge).toEqual({ + id: 1, + message: 'Move to the next task.', + }); + expect(store.statusMessages).toEqual(['Sent a nudge to the agent.']); + expect(wizardCaptureMock).toHaveBeenCalledWith( + 'agent nudged', + expect.objectContaining({ program_id: Program.PostHogIntegration }), + ); + }); + + it('waits for the next nudge after the given id', async () => { + const store = createStore(); + store.nudgeAgent('First nudge'); + + const next = store.waitForAgentNudge(1); + store.nudgeAgent('Second nudge'); + + await expect(next).resolves.toEqual({ + id: 2, + message: 'Second nudge', + }); + }); + + it('resolves null when the wait is aborted', async () => { + const store = createStore(); + const controller = new AbortController(); + + const next = store.waitForAgentNudge(0, controller.signal); + controller.abort(); + + await expect(next).resolves.toBeNull(); + }); + }); + describe('tasks', () => { it('setTasks replaces the task list', () => { const store = createStore(); diff --git a/src/ui/tui/ink-ui.ts b/src/ui/tui/ink-ui.ts index 598d66cc..96545743 100644 --- a/src/ui/tui/ink-ui.ts +++ b/src/ui/tui/ink-ui.ts @@ -223,6 +223,13 @@ export class InkUI implements WizardUI { this.store.pushStatus(message); } + waitForAgentNudge( + afterId?: number, + signal?: AbortSignal, + ): Promise<{ id: number; message: string } | null> { + return this.store.waitForAgentNudge(afterId, signal); + } + syncTodos( todos: Array<{ content: string; status: string; activeForm?: string }>, ): void { diff --git a/src/ui/tui/primitives/TabContainer.tsx b/src/ui/tui/primitives/TabContainer.tsx index 1b5a0ed6..9d113b7b 100644 --- a/src/ui/tui/primitives/TabContainer.tsx +++ b/src/ui/tui/primitives/TabContainer.tsx @@ -33,6 +33,7 @@ interface TabContainerProps { expandableStatus?: boolean; /** Store reference — required when expandableStatus is true so status state is shared. */ store?: WizardStore; + extraBindings?: KeyBinding[]; } export const TabContainer = ({ @@ -40,6 +41,7 @@ export const TabContainer = ({ statusMessage, expandableStatus = false, store, + extraBindings = [], }: TabContainerProps) => { const [activeTab, setActiveTab] = useState(0); // Fallback to local state when no store is provided @@ -78,8 +80,8 @@ export const TabContainer = ({ }, }); } - return b; - }, [tabs.length, expandableStatus, store]); + return [...b, ...extraBindings]; + }, [tabs.length, expandableStatus, store, extraBindings]); useKeyBindings('tab-container', bindings); diff --git a/src/ui/tui/screens/RunScreen.tsx b/src/ui/tui/screens/RunScreen.tsx index da78ce37..6f7e5dec 100644 --- a/src/ui/tui/screens/RunScreen.tsx +++ b/src/ui/tui/screens/RunScreen.tsx @@ -18,6 +18,7 @@ import { EventPlanViewer, HNViewer, } from '@ui/tui/primitives/index'; +import type { KeyBinding } from '@ui/tui/hooks/useKeyBindings'; import type { ProgressItem } from '@ui/tui/primitives/index'; import { ADDITIONAL_FEATURE_LABELS } from '@lib/wizard-session'; import { LearnCard } from '@ui/tui/components/LearnCard'; @@ -98,6 +99,18 @@ export const RunScreen = ({ store }: RunScreenProps) => { // Program-supplied tips for the right pane; undefined falls back to // DEFAULT_TIPS inside TipsCard, so non-self-driving programs are unaffected. const programTips = getProgramConfig(activeProgram).getTips?.(store); + const nudgeBindings = useMemo( + () => [ + { + match: 'n', + label: 'n', + action: 'nudge agent', + priority: 20, + handler: () => store.nudgeAgent(), + }, + ], + [store], + ); const leftPane = store.learnCardComplete ? ( @@ -145,6 +158,7 @@ export const RunScreen = ({ store }: RunScreenProps) => { tabs={tabs} statusMessage={statuses} expandableStatus + extraBindings={nudgeBindings} store={store} /> ); diff --git a/src/ui/tui/screens/audit/AuditRunScreen.tsx b/src/ui/tui/screens/audit/AuditRunScreen.tsx index 1f5e22b7..436e5fde 100644 --- a/src/ui/tui/screens/audit/AuditRunScreen.tsx +++ b/src/ui/tui/screens/audit/AuditRunScreen.tsx @@ -1,4 +1,4 @@ -import { useSyncExternalStore } from 'react'; +import { useMemo, useSyncExternalStore } from 'react'; import { join } from 'node:path'; import { Box } from 'ink'; import type { WizardStore } from '@ui/tui/store'; @@ -8,6 +8,7 @@ import { LogViewer, HNViewer, } from '@ui/tui/primitives/index'; +import type { KeyBinding } from '@ui/tui/hooks/useKeyBindings'; import { useStdoutDimensions } from '@ui/tui/hooks/useStdoutDimensions'; import { useFileWatcher } from '@ui/tui/hooks/file-watcher'; import { AuditChecksViewer } from './AuditChecksViewer/AuditChecksViewer.js'; @@ -63,6 +64,18 @@ export const AuditRunScreen = ({ store }: AuditRunScreenProps) => { notebookUrl={store.session.notebookUrl} /> ); + const nudgeBindings = useMemo( + () => [ + { + match: 'n', + label: 'n', + action: 'nudge agent', + priority: 20, + handler: () => store.nudgeAgent(), + }, + ], + [store], + ); // Narrow terminals: drop the area pane. const statusComponent = @@ -94,6 +107,7 @@ export const AuditRunScreen = ({ store }: AuditRunScreenProps) => { tabs={tabs} statusMessage={statuses} expandableStatus + extraBindings={nudgeBindings} store={store} /> ); diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index 181818ab..2aed3335 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -191,6 +191,7 @@ export class WizardStore { private $currentStage = atom<{ stage: string; startedAt: number } | null>( null, ); + private $agentNudge = atom<{ id: number; message: string } | null>(null); private $tokenUsage = atom(EMPTY_TOKEN_USAGE); // Defaults on for local/dev/test runs (tsx, `pnpm try`, vitest) so // contributors see it without needing to know the shortcut; defaults off @@ -199,6 +200,7 @@ export class WizardStore { private $tokenHudVisible = atom(IS_DEV); private _onTasksChanged: (() => void) | null = null; + private _nudgeSeq = 0; /** Last screen seen — used to detect screen transitions for analytics. */ private _lastScreen: ScreenName | null = null; @@ -390,6 +392,10 @@ export class WizardStore { return this.$currentStage.get(); } + get agentNudge(): { id: number; message: string } | null { + return this.$agentNudge.get(); + } + /** No-op when the stage hasn't changed, so `startedAt` survives across * re-renders and tab switches and measures real stage time. */ setCurrentStage(stage: string): void { @@ -910,6 +916,44 @@ export class WizardStore { this.emitChange(); } + nudgeAgent( + message = 'Please finish the current line of work and move on if you are stuck.', + ): void { + this._nudgeSeq += 1; + this.$agentNudge.set({ id: this._nudgeSeq, message }); + this.emitChange(); + this.pushStatus('Sent a nudge to the agent.'); + analytics.wizardCapture('agent nudged', { + program_id: this.router.activeProgram, + ...sessionProperties(this.session), + }); + } + + waitForAgentNudge( + afterId = 0, + signal?: AbortSignal, + ): Promise<{ id: number; message: string } | null> { + const current = this.$agentNudge.get(); + if (current && current.id > afterId) return Promise.resolve(current); + if (signal?.aborted) return Promise.resolve(null); + return new Promise((resolve) => { + let unsub: (() => void) | null = null; + const onAbort = () => { + unsub?.(); + resolve(null); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + unsub = this.subscribe(() => { + const next = this.$agentNudge.get(); + if (next && next.id > afterId) { + unsub?.(); + signal?.removeEventListener('abort', onAbort); + resolve(next); + } + }); + }); + } + get tokenUsage(): TokenUsageSnapshot { return this.$tokenUsage.get(); } diff --git a/src/ui/wizard-ui.ts b/src/ui/wizard-ui.ts index 155a41f5..6903887f 100644 --- a/src/ui/wizard-ui.ts +++ b/src/ui/wizard-ui.ts @@ -107,6 +107,14 @@ export interface WizardUI { note(message: string): void; pushStatus(message: string): void; + /** User-triggered guidance for an active agent run. TUI-only callers emit it + * into the live prompt stream; non-interactive implementations may never + * resolve. */ + waitForAgentNudge( + afterId?: number, + signal?: AbortSignal, + ): Promise<{ id: number; message: string } | null>; + // ── Spinner ─────────────────────────────────────────────────────── spinner(): SpinnerHandle;