Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/lib/agent/agent-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,13 @@ export type AgentConfig = {
orchestrator?: import('@lib/agent/runner/sequence/orchestrator/queue-tools').OrchestratorToolsContext;
};

export type AgentNudge = { id: number; message: string };

export type AgentNudgeSource = (
afterId?: number,
signal?: AbortSignal,
) => Promise<AgentNudge | null>;

/**
* Stop hook return type: either allow stop or block with a reason.
*/
Expand Down Expand Up @@ -779,6 +786,13 @@ export async function runAgent(
* aborted` events (e.g. the orchestrator's task type and id).
*/
analyticsProperties?: Record<string, unknown>;
/**
* Optional live user guidance channel. TUI implementations resolve
* whenever the operator asks the agent to move on; non-interactive hosts
* omit it. Kept on the execution call, not initializeAgent(), because it is
* an interactive transport concern rather than agent bootstrap config.
*/
waitForAgentNudge?: AgentNudgeSource;
},
middleware?: {
onMessage(message: any): void;
Expand Down Expand Up @@ -833,6 +847,30 @@ export async function runAgent(
message: { role: 'user', content: prompt },
parent_tool_use_id: null,
};
let lastNudgeId = 0;
while (config?.waitForAgentNudge) {
const nudgeAbort = new AbortController();
const nextNudge = config.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;
};

Expand Down
4 changes: 4 additions & 0 deletions src/lib/agent/runner/harness/anthropic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const anthropicBackend: AgentHarness = {
askBridge,
middleware,
model,
waitForAgentNudge,
} = inputs;
const {
skillsBaseUrl,
Expand Down Expand Up @@ -93,6 +94,7 @@ export const anthropicBackend: AgentHarness = {
additionalFeatureQueue: config.additionalFeatureQueue ?? [],
abortCases: config.abortCases,
emitStepEvents: config.trackStepProgress ?? false,
waitForAgentNudge,
},
middleware,
);
Expand All @@ -115,6 +117,7 @@ export const anthropicBackend: AgentHarness = {
additionalFeatureQueue,
requestRemark,
analyticsProperties,
waitForAgentNudge,
} = inputs;
const options = sessionToOptions(session);

Expand Down Expand Up @@ -149,6 +152,7 @@ export const anthropicBackend: AgentHarness = {
additionalFeatureQueue,
requestRemark,
analyticsProperties,
waitForAgentNudge,
},
);
},
Expand Down
34 changes: 32 additions & 2 deletions src/lib/agent/runner/harness/pi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,8 +439,38 @@ export const piBackend: AgentHarness = {

try {
// Non-streaming: resolves when the agent run completes. Throws if no
// model/api key, or on a transport error.
await agentSession.prompt(prompt);
// model/api key, or on a transport error. While it runs, forward user
// nudges through pi's steering queue so the shared TUI control has the
// same behavior as the Anthropic prompt stream.
const promptRun = agentSession.prompt(prompt);
const nudgeAbort = new AbortController();
const nudgeLoop = inputs.waitForAgentNudge
? (async () => {
let lastNudgeId = 0;
while (inputs.waitForAgentNudge) {
const next = await inputs.waitForAgentNudge(
lastNudgeId,
nudgeAbort.signal,
);
if (!next) return;
lastNudgeId = next.id;
logToFile(`[pi] user nudge sent: ${next.message}`);
try {
await agentSession.steer(next.message);
} catch (err) {
logToFile(`[pi] user nudge ignored: ${String(err)}`);
}
}
})()
: undefined;
try {
await promptRun;
} finally {
nudgeAbort.abort();
await nudgeLoop?.catch((err) => {
logToFile(`[pi] nudge loop stopped: ${String(err)}`);
});
}
} finally {
unsubscribe();
mcpCleanup?.();
Expand Down
9 changes: 8 additions & 1 deletion src/lib/agent/runner/harness/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ import type { Harness } from '@lib/constants';
import type { ProgramConfig } from '@lib/programs/program-step';
import type { SpinnerHandle } from '@ui';
import type { WizardAskBridge } from '@lib/wizard-ask-bridge';
import type { AgentErrorType } from '@lib/agent/agent-interface';
import type {
AgentErrorType,
AgentNudgeSource,
} from '@lib/agent/agent-interface';
import type { OrchestratorToolsContext } from '@lib/agent/runner/sequence/orchestrator/queue-tools';
import type {
ProgramRun,
Expand Down Expand Up @@ -56,6 +59,8 @@ export interface BackendRunInputs {
middleware?: RunMiddleware;
/** Gateway model id resolved from the (runner, model) pair. */
model: string;
/** Optional live user guidance source for harnesses that support mid-run input. */
waitForAgentNudge?: AgentNudgeSource;
}

/** What a runner reports back: an error classification, or nothing on success. */
Expand Down Expand Up @@ -91,6 +96,8 @@ export interface TaskRunInputs {
requestRemark: boolean;
/** Per-call analytics properties merged into `agent completed` / `agent aborted` events. */
analyticsProperties: Record<string, unknown>;
/** Optional live user guidance source for harnesses that support mid-run input. */
waitForAgentNudge?: AgentNudgeSource;
}

/** A drop-in agent runner: consumes a fully-assembled run, returns a result. */
Expand Down
2 changes: 2 additions & 0 deletions src/lib/agent/runner/sequence/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ export async function runLinearProgram(
askBridge,
middleware,
model: pick.model,
waitForAgentNudge: (afterId, signal) =>
getUI().waitForAgentNudge(afterId, signal),
});

// 9. Error handling (full set from both runners)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,8 @@ export async function runOrchestrator(
additionalFeatureQueue: [],
requestRemark: false,
analyticsProperties: { task_type: 'seed', harness: seedPick.harness },
waitForAgentNudge: (afterId, signal) =>
getUI().waitForAgentNudge(afterId, signal),
});
if (seedResult.error) {
logToFile(
Expand Down Expand Up @@ -376,6 +378,8 @@ export async function runOrchestrator(
task_id: task.id,
harness: taskPick.harness,
},
waitForAgentNudge: (afterId, signal) =>
getUI().waitForAgentNudge(afterId, signal),
});
} finally {
renderQueue();
Expand Down
10 changes: 10 additions & 0 deletions src/ui/logging-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down
41 changes: 41 additions & 0 deletions src/ui/tui/__tests__/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
7 changes: 7 additions & 0 deletions src/ui/tui/ink-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 4 additions & 2 deletions src/ui/tui/primitives/TabContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@ interface TabContainerProps {
expandableStatus?: boolean;
/** Store reference — required when expandableStatus is true so status state is shared. */
store?: WizardStore;
extraBindings?: KeyBinding[];
}

export const TabContainer = ({
tabs,
statusMessage,
expandableStatus = false,
store,
extraBindings = [],
}: TabContainerProps) => {
const [activeTab, setActiveTab] = useState(0);
// Fallback to local state when no store is provided
Expand Down Expand Up @@ -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);

Expand Down
14 changes: 14 additions & 0 deletions src/ui/tui/screens/RunScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<KeyBinding[]>(
() => [
{
match: 'n',
label: 'n',
action: 'nudge agent',
priority: 20,
handler: () => store.nudgeAgent(),
},
],
[store],
);

const leftPane = store.learnCardComplete ? (
<TipsCard store={store} tips={programTips} />
Expand Down Expand Up @@ -145,6 +158,7 @@ export const RunScreen = ({ store }: RunScreenProps) => {
tabs={tabs}
statusMessage={statuses}
expandableStatus
extraBindings={nudgeBindings}
store={store}
/>
);
Expand Down
16 changes: 15 additions & 1 deletion src/ui/tui/screens/audit/AuditRunScreen.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -63,6 +64,18 @@ export const AuditRunScreen = ({ store }: AuditRunScreenProps) => {
notebookUrl={store.session.notebookUrl}
/>
);
const nudgeBindings = useMemo<KeyBinding[]>(
() => [
{
match: 'n',
label: 'n',
action: 'nudge agent',
priority: 20,
handler: () => store.nudgeAgent(),
},
],
[store],
);

// Narrow terminals: drop the area pane.
const statusComponent =
Expand Down Expand Up @@ -94,6 +107,7 @@ export const AuditRunScreen = ({ store }: AuditRunScreenProps) => {
tabs={tabs}
statusMessage={statuses}
expandableStatus
extraBindings={nudgeBindings}
store={store}
/>
);
Expand Down
Loading