Add 4 major features to codetyper-cli:
- Hooks System: Lifecycle hooks (PreToolUse, PostToolUse, SessionStart,
SessionEnd, UserPromptSubmit, Stop) with exit code control flow
- Plugin System: Custom tools, commands, and hooks via plugin manifest
- Session Forking: Snapshots, rewind, fork, and switch between branches
- Vim Motions: Normal/Insert/Command/Visual modes with keyboard navigation
New files:
- src/types/{hooks,plugin,session-fork,vim}.ts
- src/constants/{hooks,plugin,session-fork,vim}.ts
- src/services/{hooks-service,plugin-loader,plugin-service,session-fork-service}.ts
- src/stores/vim-store.ts (vanilla)
- src/tui/hooks/{useVimMode,useVimStore,useTodoStore,useThemeStore}.ts
- src/tui/components/VimStatusLine.tsx
Modified:
- src/services/agent.ts (hook integration)
- src/tools/index.ts (plugin tool registration)
- src/stores/{todo-store,theme-store}.ts (converted to vanilla)
- TUI components (updated hook imports)
377 lines
9.2 KiB
TypeScript
377 lines
9.2 KiB
TypeScript
/**
|
|
* Agent system for autonomous task execution
|
|
*
|
|
* This module handles the core agent loop:
|
|
* 1. Send messages to LLM with tools
|
|
* 2. Process tool calls from response
|
|
* 3. Execute tools and collect results
|
|
* 4. Send results back to LLM
|
|
* 5. Repeat until LLM responds with text only
|
|
*/
|
|
|
|
import chalk from "chalk";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import type { Message } from "@/types/providers";
|
|
import type { AgentOptions } from "@interfaces/AgentOptions";
|
|
import type { AgentResult } from "@interfaces/AgentResult";
|
|
import type {
|
|
AgentMessage,
|
|
ToolCallMessage,
|
|
ToolResultMessage,
|
|
} from "@/types/agent";
|
|
import { chat as providerChat } from "@providers/index";
|
|
import { getTool, getToolsForApi, refreshMCPTools } from "@tools/index";
|
|
import type { ToolContext, ToolCall, ToolResult } from "@/types/tools";
|
|
import { initializePermissions } from "@services/permissions";
|
|
import {
|
|
loadHooks,
|
|
executePreToolUseHooks,
|
|
executePostToolUseHooks,
|
|
} from "@services/hooks-service";
|
|
import { MAX_ITERATIONS } from "@constants/agent";
|
|
import { usageStore } from "@stores/usage-store";
|
|
|
|
/**
|
|
* Agent state interface
|
|
*/
|
|
interface AgentState {
|
|
sessionId: string;
|
|
workingDir: string;
|
|
abort: AbortController;
|
|
options: AgentOptions;
|
|
}
|
|
|
|
/**
|
|
* Create a new agent state
|
|
*/
|
|
const createAgentState = (
|
|
workingDir: string,
|
|
options: AgentOptions,
|
|
): AgentState => ({
|
|
sessionId: uuidv4(),
|
|
workingDir,
|
|
abort: new AbortController(),
|
|
options,
|
|
});
|
|
|
|
/**
|
|
* Call the LLM with tools
|
|
*/
|
|
const callLLM = async (
|
|
state: AgentState,
|
|
messages: AgentMessage[],
|
|
): Promise<{
|
|
content: string | null;
|
|
toolCalls?: ToolCall[];
|
|
}> => {
|
|
const toolDefs = getToolsForApi();
|
|
|
|
// Convert messages to provider format
|
|
const providerMessages: unknown[] = messages.map((msg) => {
|
|
if ("tool_calls" in msg) {
|
|
return {
|
|
role: "assistant",
|
|
content: msg.content,
|
|
tool_calls: msg.tool_calls,
|
|
};
|
|
}
|
|
if ("tool_call_id" in msg) {
|
|
return {
|
|
role: "tool",
|
|
tool_call_id: msg.tool_call_id,
|
|
content: msg.content,
|
|
};
|
|
}
|
|
return msg;
|
|
});
|
|
|
|
// Call provider with tools
|
|
const response = await providerChat(
|
|
state.options.provider,
|
|
providerMessages as Message[],
|
|
{
|
|
model: state.options.model,
|
|
tools: toolDefs,
|
|
},
|
|
);
|
|
|
|
// Track usage if available
|
|
if (response.usage) {
|
|
usageStore.addUsage({
|
|
promptTokens: response.usage.promptTokens,
|
|
completionTokens: response.usage.completionTokens,
|
|
totalTokens: response.usage.totalTokens,
|
|
model: state.options.model,
|
|
});
|
|
}
|
|
|
|
// Parse tool calls from response
|
|
const toolCalls: ToolCall[] = [];
|
|
|
|
if (response.toolCalls) {
|
|
for (const tc of response.toolCalls) {
|
|
let args: Record<string, unknown>;
|
|
try {
|
|
args =
|
|
typeof tc.function.arguments === "string"
|
|
? JSON.parse(tc.function.arguments)
|
|
: tc.function.arguments;
|
|
} catch {
|
|
args = {};
|
|
}
|
|
|
|
toolCalls.push({
|
|
id: tc.id,
|
|
name: tc.function.name,
|
|
arguments: args,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
content: response.content,
|
|
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Execute a tool call with hook support
|
|
*/
|
|
const executeTool = async (
|
|
state: AgentState,
|
|
toolCall: ToolCall,
|
|
): Promise<ToolResult> => {
|
|
// Execute PreToolUse hooks
|
|
const hookResult = await executePreToolUseHooks(
|
|
state.sessionId,
|
|
toolCall.name,
|
|
toolCall.arguments,
|
|
state.workingDir,
|
|
);
|
|
|
|
// Handle hook results
|
|
if (hookResult.action === "block") {
|
|
return {
|
|
success: false,
|
|
title: "Blocked by hook",
|
|
output: "",
|
|
error: hookResult.message,
|
|
};
|
|
}
|
|
|
|
if (hookResult.action === "warn") {
|
|
state.options.onWarning?.(hookResult.message);
|
|
}
|
|
|
|
// Apply modified arguments if hook returned them
|
|
const effectiveArgs =
|
|
hookResult.action === "modify"
|
|
? { ...toolCall.arguments, ...hookResult.updatedInput }
|
|
: toolCall.arguments;
|
|
|
|
const tool = getTool(toolCall.name);
|
|
|
|
if (!tool) {
|
|
return {
|
|
success: false,
|
|
title: "Unknown tool",
|
|
output: "",
|
|
error: `Tool not found: ${toolCall.name}`,
|
|
};
|
|
}
|
|
|
|
const ctx: ToolContext = {
|
|
sessionId: state.sessionId,
|
|
messageId: uuidv4(),
|
|
workingDir: state.workingDir,
|
|
abort: state.abort,
|
|
autoApprove: state.options.autoApprove,
|
|
onMetadata: (metadata) => {
|
|
if (state.options.verbose && metadata.output) {
|
|
// Already printed by the tool
|
|
}
|
|
},
|
|
};
|
|
|
|
let result: ToolResult;
|
|
|
|
try {
|
|
// Validate arguments
|
|
const validatedArgs = tool.parameters.parse(effectiveArgs);
|
|
result = await tool.execute(validatedArgs, ctx);
|
|
} catch (error: unknown) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
result = {
|
|
success: false,
|
|
title: "Tool error",
|
|
output: "",
|
|
error: errorMessage,
|
|
};
|
|
}
|
|
|
|
// Execute PostToolUse hooks (fire-and-forget, don't block on result)
|
|
executePostToolUseHooks(
|
|
state.sessionId,
|
|
toolCall.name,
|
|
effectiveArgs,
|
|
result,
|
|
state.workingDir,
|
|
).catch(() => {
|
|
// Silently ignore post-hook errors
|
|
});
|
|
|
|
return result;
|
|
};
|
|
|
|
/**
|
|
* Run the agent with the given messages
|
|
*/
|
|
export const runAgentLoop = async (
|
|
state: AgentState,
|
|
messages: Message[],
|
|
): Promise<AgentResult> => {
|
|
const maxIterations = state.options.maxIterations ?? MAX_ITERATIONS;
|
|
const allToolCalls: { call: ToolCall; result: ToolResult }[] = [];
|
|
let iterations = 0;
|
|
let finalResponse = "";
|
|
|
|
// Initialize permissions
|
|
await initializePermissions();
|
|
|
|
// Load hooks
|
|
await loadHooks(state.workingDir);
|
|
|
|
// Refresh MCP tools if available
|
|
await refreshMCPTools();
|
|
|
|
// Convert messages to agent format
|
|
const agentMessages: AgentMessage[] = [...messages];
|
|
|
|
while (iterations < maxIterations) {
|
|
iterations++;
|
|
|
|
if (state.options.verbose) {
|
|
console.log(chalk.gray(`\n--- Iteration ${iterations} ---`));
|
|
}
|
|
|
|
try {
|
|
// Call LLM with tools
|
|
const response = await callLLM(state, agentMessages);
|
|
|
|
// Check if response has tool calls
|
|
if (response.toolCalls && response.toolCalls.length > 0) {
|
|
// Add assistant message with tool calls
|
|
const assistantMessage: ToolCallMessage = {
|
|
role: "assistant",
|
|
content: response.content || null,
|
|
tool_calls: response.toolCalls.map((tc) => ({
|
|
id: tc.id,
|
|
type: "function" as const,
|
|
function: {
|
|
name: tc.name,
|
|
arguments: JSON.stringify(tc.arguments),
|
|
},
|
|
})),
|
|
};
|
|
agentMessages.push(assistantMessage);
|
|
|
|
// If there's text content, emit it
|
|
if (response.content) {
|
|
state.options.onText?.(response.content);
|
|
}
|
|
|
|
// Execute each tool call
|
|
for (const toolCall of response.toolCalls) {
|
|
state.options.onToolCall?.(toolCall);
|
|
|
|
if (state.options.verbose) {
|
|
console.log(chalk.cyan(`\nTool: ${toolCall.name}`));
|
|
console.log(
|
|
chalk.gray(JSON.stringify(toolCall.arguments, null, 2)),
|
|
);
|
|
}
|
|
|
|
const result = await executeTool(state, toolCall);
|
|
allToolCalls.push({ call: toolCall, result });
|
|
|
|
state.options.onToolResult?.(toolCall.id, result);
|
|
|
|
// Add tool result message
|
|
const toolResultMessage: ToolResultMessage = {
|
|
role: "tool",
|
|
tool_call_id: toolCall.id,
|
|
content: result.error
|
|
? `Error: ${result.error}\n\n${result.output}`
|
|
: result.output,
|
|
};
|
|
agentMessages.push(toolResultMessage);
|
|
}
|
|
} else {
|
|
// No tool calls - this is the final response
|
|
finalResponse = response.content || "";
|
|
state.options.onText?.(finalResponse);
|
|
break;
|
|
}
|
|
} catch (error: unknown) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : String(error);
|
|
state.options.onError?.(`Agent error: ${errorMessage}`);
|
|
return {
|
|
success: false,
|
|
finalResponse: `Error: ${errorMessage}`,
|
|
iterations,
|
|
toolCalls: allToolCalls,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (iterations >= maxIterations) {
|
|
state.options.onWarning?.(`Reached max iterations (${maxIterations})`);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
finalResponse,
|
|
iterations,
|
|
toolCalls: allToolCalls,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Create and run an agent with a single prompt
|
|
*/
|
|
export const runAgent = async (
|
|
prompt: string,
|
|
systemPrompt: string,
|
|
options: AgentOptions,
|
|
): Promise<AgentResult> => {
|
|
const messages: Message[] = [
|
|
{ role: "system", content: systemPrompt },
|
|
{ role: "user", content: prompt },
|
|
];
|
|
|
|
const state = createAgentState(process.cwd(), options);
|
|
return runAgentLoop(state, messages);
|
|
};
|
|
|
|
/**
|
|
* Create an agent instance with stop capability
|
|
*/
|
|
export const createAgent = (
|
|
workingDir: string,
|
|
options: AgentOptions,
|
|
): {
|
|
run: (messages: Message[]) => Promise<AgentResult>;
|
|
stop: () => void;
|
|
} => {
|
|
const state = createAgentState(workingDir, options);
|
|
|
|
return {
|
|
run: (messages: Message[]) => runAgentLoop(state, messages),
|
|
stop: () => state.abort.abort(),
|
|
};
|
|
};
|
|
|
|
// Re-export types
|
|
export type { AgentOptions, AgentResult };
|