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
11 changes: 6 additions & 5 deletions src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ export { Scratchpad } from './scratchpad.js';

export { getCurrentDate, buildSystemPrompt, buildIterationPrompt, DEFAULT_SYSTEM_PROMPT } from './prompts.js';

export type {
AgentConfig,
export type {
AgentConfig,
Message,
AgentEvent,
ThinkingEvent,
Expand All @@ -14,13 +14,14 @@ export type {
ToolEndEvent,
ToolErrorEvent,
ToolLimitEvent,
AskUserEvent,
AnswerStartEvent,
DoneEvent,
} from './types.js';

export type {
ToolCallRecord,
ToolContext,
export type {
ToolCallRecord,
ToolContext,
ScratchpadEntry,
ToolLimitConfig,
ToolUsageStatus,
Expand Down
26 changes: 24 additions & 2 deletions src/agent/tool-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AIMessage } from '@langchain/core/messages';
import { StructuredToolInterface } from '@langchain/core/tools';
import { createProgressChannel } from '../utils/progress-channel.js';
import type {
AskUserEvent,
ToolEndEvent,
ToolErrorEvent,
ToolLimitEvent,
Expand All @@ -15,7 +16,8 @@ type ToolExecutionEvent =
| ToolProgressEvent
| ToolEndEvent
| ToolErrorEvent
| ToolLimitEvent;
| ToolLimitEvent
| AskUserEvent;

/**
* Executes tool calls and emits streaming tool lifecycle events.
Expand All @@ -24,7 +26,7 @@ export class AgentToolExecutor {
constructor(
private readonly toolMap: Map<string, StructuredToolInterface>,
private readonly signal?: AbortSignal
) {}
) { }

async *executeAll(
response: AIMessage,
Expand Down Expand Up @@ -65,6 +67,26 @@ export class AgentToolExecutor {

const toolStartTime = Date.now();

// Special handling for ask_user tool: yield event and wait for user response
if (toolName === 'ask_user') {
const question = (toolArgs.question as string) || 'Can you provide more details?';
let resolveAnswer!: (answer: string) => void;
const answerPromise = new Promise<string>((resolve) => {
resolveAnswer = resolve;
});

yield { type: 'ask_user', question, resolve: resolveAnswer } as AskUserEvent;

const userAnswer = await answerPromise;
const duration = Date.now() - toolStartTime;
const result = `User answered: ${userAnswer}`;

yield { type: 'tool_end', tool: toolName, args: toolArgs, result, duration };
ctx.scratchpad.recordToolCall(toolName, question);
ctx.scratchpad.addToolResult(toolName, toolArgs, result);
return;
}

try {
const tool = this.toolMap.get(toolName);
if (!tool) {
Expand Down
12 changes: 12 additions & 0 deletions src/agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,17 @@ export interface ContextClearedEvent {
keptCount: number;
}

/**
* Agent is asking the user a clarifying question
*/
export interface AskUserEvent {
type: 'ask_user';
/** The question to display to the user */
question: string;
/** Callback to provide the user's answer — resolves the tool's promise */
resolve: (answer: string) => void;
}

/**
* Final answer generation started
*/
Expand Down Expand Up @@ -132,6 +143,7 @@ export type AgentEvent =
| ToolEndEvent
| ToolErrorEvent
| ToolLimitEvent
| AskUserEvent
| ContextClearedEvent
| AnswerStartEvent
| DoneEvent;
73 changes: 40 additions & 33 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ config({ quiet: true });

export function CLI() {
const { exit } = useApp();

// Ref to hold setError - avoids TDZ issue since useModelSelection needs to call
// setError but useAgentRunner (which provides setError) depends on useModelSelection's outputs
const setErrorRef = useRef<((error: string | null) => void) | null>(null);

// Model selection state and handlers
const {
selectionState,
Expand All @@ -44,7 +44,7 @@ export function CLI() {
handleApiKeySubmit,
isInSelectionFlow,
} = useModelSelection((errorMsg) => setErrorRef.current?.(errorMsg));

// Agent execution state and handlers
const {
history,
Expand All @@ -54,11 +54,12 @@ export function CLI() {
runQuery,
cancelExecution,
setError,
submitAskUserResponse,
} = useAgentRunner({ model, modelProvider: provider, maxIterations: 10 }, inMemoryChatHistoryRef);

// Assign setError to ref so useModelSelection's callback can access it
setErrorRef.current = setError;

// Input history for up/down arrow navigation
const {
historyValue,
Expand All @@ -68,7 +69,7 @@ export function CLI() {
updateAgentResponse,
resetNavigation,
} = useInputHistory();

// Handle history navigation from Input component
const handleHistoryNavigate = useCallback((direction: 'up' | 'down') => {
if (direction === 'up') {
Expand All @@ -77,7 +78,7 @@ export function CLI() {
navigateDown();
}
}, [navigateUp, navigateDown]);

// Handle user input submission
const handleSubmit = useCallback(async (query: string) => {
// Handle exit
Expand All @@ -86,27 +87,33 @@ export function CLI() {
exit();
return;
}

// Handle model selection command
if (query === '/model') {
startSelection();
return;
}


// If agent is waiting for user answer, route input there
if (workingState.status === 'ask_user') {
submitAskUserResponse(query);
return;
}

// Ignore if not idle (processing or in selection flow)
if (isInSelectionFlow() || workingState.status !== 'idle') return;

// Save user message to history immediately and reset navigation
await saveMessage(query);
resetNavigation();

// Run query and save agent response when complete
const result = await runQuery(query);
if (result?.answer) {
await updateAgentResponse(result.answer);
}
}, [exit, startSelection, isInSelectionFlow, workingState.status, runQuery, saveMessage, updateAgentResponse, resetNavigation]);
}, [exit, startSelection, isInSelectionFlow, workingState.status, submitAskUserResponse, runQuery, saveMessage, updateAgentResponse, resetNavigation]);

// Handle keyboard shortcuts
useInput((input, key) => {
// Escape key - cancel selection flows or running agent
Expand All @@ -120,7 +127,7 @@ export function CLI() {
return;
}
}

// Ctrl+C - cancel or exit
if (key.ctrl && input === 'c') {
if (isInSelectionFlow()) {
Expand All @@ -133,18 +140,18 @@ export function CLI() {
}
}
});

// Render selection screens
const { appState, pendingProvider, pendingModels } = selectionState;

if (appState === 'provider_select') {
return (
<Box flexDirection="column">
<ProviderSelector provider={provider} onSelect={handleProviderSelect} />
</Box>
);
}

if (appState === 'model_select' && pendingProvider) {
return (
<Box flexDirection="column">
Expand All @@ -157,7 +164,7 @@ export function CLI() {
</Box>
);
}

if (appState === 'model_input' && pendingProvider) {
return (
<Box flexDirection="column">
Expand All @@ -169,60 +176,60 @@ export function CLI() {
</Box>
);
}

if (appState === 'api_key_confirm' && pendingProvider) {
return (
<Box flexDirection="column">
<ApiKeyConfirm
providerName={getProviderDisplayName(pendingProvider)}
onConfirm={handleApiKeyConfirm}
<ApiKeyConfirm
providerName={getProviderDisplayName(pendingProvider)}
onConfirm={handleApiKeyConfirm}
/>
</Box>
);
}

if (appState === 'api_key_input' && pendingProvider) {
const apiKeyName = getApiKeyNameForProvider(pendingProvider) || '';
return (
<Box flexDirection="column">
<ApiKeyInput
<ApiKeyInput
providerName={getProviderDisplayName(pendingProvider)}
apiKeyName={apiKeyName}
onSubmit={handleApiKeySubmit}
onSubmit={handleApiKeySubmit}
/>
</Box>
);
}

// Main chat interface
return (
<Box flexDirection="column">
<Intro provider={provider} model={model} />

{/* All history items (queries, events, answers) */}
{history.map(item => (
<HistoryItemView key={item.id} item={item} />
))}

{/* Error display */}
{error && (
<Box marginBottom={1}>
<Text color="red">Error: {error}</Text>
</Box>
)}

{/* Working indicator - only show when processing */}
{isProcessing && <WorkingIndicator state={workingState} />}

{/* Input */}
<Box marginTop={1}>
<Input
onSubmit={handleSubmit}
<Input
onSubmit={handleSubmit}
historyValue={historyValue}
onHistoryNavigate={handleHistoryNavigate}
/>
</Box>

{/* Debug Panel - set show={false} to hide */}
<DebugPanel maxLines={8} show={true} />
</Box>
Expand Down
Loading