local-agent
Version:
A CLI agentic system for orchestrating tools and memory with per-folder scoping
483 lines (421 loc) • 20.3 kB
text/typescript
/**
* @fileoverview
* Provides the interactive session loop and supporting utilities for the local agent.
* Handles user prompts, conversation context, multi-step task management, and dynamic model/tool selection.
* Designed for extensibility and clarity for external developers.
*/
import * as readline from "readline";
import { createAISDKTools } from "@agentic/ai-sdk";
import { generateText } from "ai";
import { marked } from "marked";
import TerminalRenderer from "marked-terminal";
// Import new simplified systems
import { ProviderFactory, ProviderError } from "./providers";
import { TaskManager } from "./tasks";
// Debug logging utility
const isDebug = process.argv.includes('--debug');
function debugLog(...args: any[]) {
if (isDebug) console.log(...args);
}
import { handleError } from "./errors";
import { ConversationMessage } from "./types";
import { logToolUsed, logAgentResponse, logAgentError, logUserPrompt } from "./memory";
import { YELLOW, RESET } from "./initialization";
/**
* Starts and manages the interactive agent session in the terminal.
* Handles user prompts, conversation history, multi-step task logic, and tool/model selection.
* Provides a rich, markdown-enabled interface for agent interaction.
*
* @param {any} config - The agent configuration object.
* @param {Record<string, any>} loadedTools - Loaded MCP tool instances.
* @param {string} sessionFile - Path to the session memory log file.
* @param {string} agentName - The display name of the agent.
*/
export function runInteractiveSession(config: any, loadedTools: Record<string, any>, sessionFile: string, agentName: string, keys: Record<string, string> = {}) {
const BLUE = "\x1b[34m";
const RESET = "\x1b[0m";
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: `${BLUE}$> ${RESET}`
});
// Configure marked to use TerminalRenderer for markdown output
marked.setOptions({
renderer: new TerminalRenderer()
});
// Conversation history and task management
const conversationHistory: ConversationMessage[] = [];
const taskManager = new TaskManager();
// Enhanced system prompt for multi-step task handling
const enhancedSystemPrompt = `${config.system}
IMPORTANT INSTRUCTIONS FOR MULTI-STEP TASKS:
- You are capable of handling complex, multi-step tasks that require multiple tool invocations
- When a user requests a complex task (like setting up a project, etc.), break it down into logical steps
- After completing each step, analyze if the overall task is complete or if more steps are needed
- If more steps are needed, continue working on the task WITHOUT waiting for user input
- DO NOT STOP after creating just one file or folder - continue until the task is truly complete
- For development tasks, ensure you create ALL necessary files, folders, and configurations
- When creating projects, include package.json, proper file structure, source files, and basic functionality
TASK CONTINUATION LOGIC - BE AGGRESSIVE:
- If you just created a folder for a project, IMMEDIATELY continue by creating the necessary files
- If you created some files but the project is incomplete, CONTINUE creating remaining files
- If you set up basic structure, CONTINUE with configuration and dependencies
- Create multiple files in sequence without stopping
- Only consider a task complete when it's fully functional, all files exist, and ready to use
- Use phrases like "Now I will", "Next I need to", "Let me now" to indicate continuation
COMPLETION CRITERIA:
- For projects: All source files, configuration files, and documentation must be created
- The project should be immediately runnable/usable after completion
- Include instructions on how to run/test the created project
- Only stop when you would tell the user "you can now run this" or "everything is ready"
TOOL USAGE GUIDELINES:
- Use filesystem tools to create directories and write files manually
- DO NOT try to run shell commands like 'npx create-react-app' - you don't have shell access
- Instead, manually create all necessary files for projects (package.json, source files, etc.)
- When creating React apps, manually create: package.json, src/App.js, src/index.js, public/index.html
- When creating Node.js projects, manually create: package.json, server.js, routes/, etc.
- If a tool fails with an error, acknowledge the error and try a different approach
- Never repeat the same tool call that just failed
CONTEXT AWARENESS:
- Remember what you've done in previous steps of the current task
- Build upon previous actions rather than starting over
- Reference earlier work when explaining current actions
- If you get tool errors, explain them clearly and adapt your approach`;
console.log(`Type your prompt for ${agentName} (Ctrl+C to exit):`);
rl.prompt();
/**
* Builds a string representation of recent conversation history and current task context.
* Used to provide context to the language model for more coherent responses.
*
* @returns {string} The formatted conversation and task context.
*/
function buildConversationContext(): string {
if (conversationHistory.length === 0) return '';
const recentHistory = conversationHistory.slice(-10); // Keep last 10 messages
let context = '\n\nCONVERSATION HISTORY:\n';
recentHistory.forEach((msg, index) => {
const timeStr = msg.timestamp.toLocaleTimeString();
context += `[${timeStr}] ${msg.role.toUpperCase()}: ${msg.content}\n`;
if (msg.toolUsed) {
context += `[${timeStr}] TOOL_USED: ${msg.toolUsed}\n`;
}
});
// Get current task context from the task manager
if (taskManager.isTaskActive()) {
const taskContext = taskManager.getTaskContext();
context += `\nCURRENT TASK CONTEXT:\n`;
context += `Task: ${taskContext.taskDescription}\n`;
context += `Completed Steps: ${taskContext.completedSteps.join(', ')}\n`;
context += `Next Steps: ${taskContext.nextSteps.join(', ')}\n`;
}
return context;
}
/**
* Processes a user prompt, manages conversation flow, invokes tools/models, and handles multi-step logic.
* Updates conversation history and task context as needed.
*
* @param {string} prompt - The user's input prompt.
* @param {boolean} [isAutoContinuation=false] - Whether this is an automatic continuation of a multi-step task.
* @returns {Promise<void>}
*/
async function processUserInput(prompt: string, isAutoContinuation = false): Promise<void> {
const userTime = new Date();
// Log user prompt
if (!isAutoContinuation) {
logUserPrompt(sessionFile, prompt);
conversationHistory.push({
role: 'user',
content: prompt,
timestamp: userTime
});
// Detect if this is a new multi-step task
if (taskManager.isMultiStepTask(prompt)) {
taskManager.startTask(prompt);
}
}
// Add a space before the thinking/loading spinner
console.log("");
// Spinner animation for "Thinking..."
const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let spinnerIndex = 0;
let spinnerActive = true;
const spinnerInterval = setInterval(() => {
process.stdout.write(
`\r${spinnerFrames[spinnerIndex]} Thinking...`
);
spinnerIndex = (spinnerIndex + 1) % spinnerFrames.length;
}, 100);
try {
// Combine all loaded MCP tools into a single array for createAISDKTools
const mcpToolInstances = Object.values(loadedTools);
const allTools = mcpToolInstances.length === 1
? createAISDKTools(mcpToolInstances[0])
: createAISDKTools(...mcpToolInstances);
// Build the full prompt with conversation context
const contextualPrompt = prompt + buildConversationContext();
// Dynamically select provider and model using the factory
let model;
try {
// Get model from the factory (handles parsing, provider loading, and initialization)
model = await ProviderFactory.getModelFromString(config.model, keys);
} catch (err) {
spinnerActive = false;
clearInterval(spinnerInterval);
process.stdout.clearLine(0);
process.stdout.cursorTo(0);
// Specific error handling for provider errors
if (err instanceof ProviderError) {
handleError(err, 'model selection');
} else {
handleError(err, 'model initialization');
}
if (!isAutoContinuation) {
rl.prompt();
}
return;
}
// Add timeout for tool operations to prevent hanging
const generateTextWithTimeout = (timeoutMs: number = 120000) => {
return Promise.race([
generateText({
model,
tools: allTools,
temperature: config.temperature,
system: enhancedSystemPrompt,
prompt: contextualPrompt
}),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Tool operation timeout after 2 minutes')), timeoutMs)
)
]);
};
const result = await generateTextWithTimeout() as any; // Type assertion for now
spinnerActive = false;
clearInterval(spinnerInterval);
process.stdout.clearLine(0);
process.stdout.cursorTo(0);
let toolResultObj: any = null;
let toolName: string | undefined = undefined;
let assistantResponse = "";
let hasToolCalls = false;
// Debug: Log the full result structure to understand what we're getting
debugLog(`\n[DEBUG] Result structure:`, JSON.stringify({
text: result.text ? `"${result.text.substring(0, 100)}..."` : result.text,
toolCalls: result.toolCalls ? result.toolCalls.length : 0,
toolResults: result.toolResults ? result.toolResults.length : 0,
usage: result.usage
}, null, 2));
// Check for tool calls first (before results)
if (result.toolCalls && Array.isArray(result.toolCalls) && result.toolCalls.length > 0) {
hasToolCalls = true;
const toolCall = result.toolCalls[0]; // Get first tool call
toolName = toolCall.toolName;
debugLog(`\n[DEBUG] Tool call detected: ${toolName} with args:`, JSON.stringify(toolCall.args, null, 2));
}
// Check for tool results
if (result.toolResults && Array.isArray(result.toolResults) && result.toolResults.length > 0) {
toolResultObj = result.toolResults[0]; // Get first tool result
if (toolResultObj) {
toolName = toolResultObj.toolName || toolName || "unknown-tool";
debugLog(`\n[DEBUG] Tool result received for ${toolName}:`, JSON.stringify({
type: toolResultObj.type,
isError: toolResultObj.isError,
result: typeof toolResultObj.result === 'string' ?
toolResultObj.result.substring(0, 200) + '...' :
toolResultObj.result
}, null, 2));
}
}
// Process normal text response (when no tools are involved)
if (typeof result.text === "string" && result.text.trim() !== "" && !hasToolCalls) {
assistantResponse = result.text;
// Render markdown response with agent name in yellow
const agentPrefix = `${YELLOW}${agentName}>${RESET} `;
console.log(marked(`${agentPrefix}${result.text}\n`));
logAgentResponse(sessionFile, result.text);
}
if (toolResultObj && toolName) {
// Show yellow spinner with tool name until next LLM response
const toolSpinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let toolSpinnerIndex = 0;
const toolSpinnerInterval = setInterval(() => {
process.stdout.write(
`\r${YELLOW}Tool [${toolName}] is working... ${toolSpinnerFrames[toolSpinnerIndex]}${RESET}`
);
toolSpinnerIndex = (toolSpinnerIndex + 1) % toolSpinnerFrames.length;
}, 100);
// Record tool usage in task manager
taskManager.recordToolUse(toolName, toolResultObj);
logToolUsed(sessionFile, toolName, toolResultObj);
try {
// Build detailed tool result context
let toolResultContext = "";
if (toolResultObj.isError) {
// Handle tool errors explicitly
toolResultContext = `TOOL ERROR - ${toolName} failed with error:\n${JSON.stringify(toolResultObj.result, null, 2)}\n\nPlease acknowledge this error to the user, explain what went wrong, and if possible, try an alternative approach or fix the issue. Do not repeat the same action that failed.`;
console.log(`\n❌ [TOOL ERROR] ${toolName}:`, toolResultObj.result);
} else {
// Handle successful tool results
let resultData = toolResultObj.result;
// Make result more readable for the LLM
if (typeof resultData === 'object') {
resultData = JSON.stringify(resultData, null, 2);
} else if (typeof resultData === 'string' && resultData.length > 1000) {
// Truncate very long results
resultData = resultData.substring(0, 1000) + '\n... (truncated)';
}
toolResultContext = `TOOL SUCCESS - ${toolName} completed successfully with result:\n${resultData}\n\nPlease describe what happened to the user as if you performed the action yourself, in natural language. If this is part of a multi-step task and more work is needed to complete the overall goal, continue with the next logical step without waiting for user input.`;
console.log(`\n✅ [TOOL SUCCESS] ${toolName}`);
if (typeof toolResultObj.result === 'string' && toolResultObj.result.length < 200) {
console.log(`Result: ${toolResultObj.result}`);
}
}
const continuePrompt = toolResultContext;
// Use the same dynamic model selection for summary
let summaryModel;
try {
summaryModel = await ProviderFactory.getModelFromString(config.model, keys);
} catch (err) {
clearInterval(toolSpinnerInterval);
process.stdout.clearLine(0);
process.stdout.cursorTo(0);
handleError(err, 'summary model selection');
return;
}
const summaryResult = await generateText({
model: summaryModel,
temperature: config.temperature,
system: enhancedSystemPrompt,
prompt: continuePrompt + buildConversationContext()
});
clearInterval(toolSpinnerInterval);
process.stdout.clearLine(0);
process.stdout.cursorTo(0);
let summaryResponse = "";
if (typeof summaryResult.text === "string" && summaryResult.text.trim() !== "") {
summaryResponse = summaryResult.text;
} else {
summaryResponse = JSON.stringify(summaryResult, null, 2);
}
console.log("");
// Render markdown summary response with agent name in yellow
const agentPrefix = `${YELLOW}${agentName}>${RESET} `;
console.log(marked(`${agentPrefix}${summaryResponse}\n`));
logAgentResponse(sessionFile, summaryResponse);
assistantResponse = summaryResponse;
// Add to conversation history
conversationHistory.push({
role: 'assistant',
content: assistantResponse,
timestamp: new Date(),
toolUsed: toolName
});
// Process response through task manager
taskManager.processResponse(assistantResponse, toolName);
// Check task completion status
const completionStatus = taskManager.getCompletionStatus();
if (completionStatus.isComplete) {
if (completionStatus.reason) {
console.log(`\n✅ ${YELLOW}Multi-step task completed (${completionStatus.reason}). Steps taken: ${completionStatus.stepCount}${RESET}`);
} else {
console.log(`\n✅ ${YELLOW}Multi-step task completed. Steps taken: ${completionStatus.stepCount}${RESET}`);
}
taskManager.resetTask();
} else if (taskManager.shouldContinueAutomatically(assistantResponse, toolName)) {
console.log(`\n${YELLOW}Task continuation detected (step ${completionStatus.stepCount}). Press Enter to continue or modify the prompt:${RESET}`);
// Pre-populate the readline with continuation prompt
rl.write("continue");
rl.prompt();
return;
}
} catch (err) {
clearInterval(toolSpinnerInterval);
process.stdout.clearLine(0);
process.stdout.cursorTo(0);
handleError(err, 'tool summary generation');
}
} else if (assistantResponse) {
// Add to conversation history for non-tool responses
conversationHistory.push({
role: 'assistant',
content: assistantResponse,
timestamp: new Date()
});
// Process response through task manager
taskManager.processResponse(assistantResponse);
// Check task completion status
const completionStatus = taskManager.getCompletionStatus();
if (completionStatus.isComplete) {
if (completionStatus.reason) {
console.log(`\n✅ ${YELLOW}Multi-step task completed (${completionStatus.reason}). Steps taken: ${completionStatus.stepCount}${RESET}`);
} else {
console.log(`\n✅ ${YELLOW}Multi-step task completed. Steps taken: ${completionStatus.stepCount}${RESET}`);
}
taskManager.resetTask();
} else if (taskManager.shouldContinueAutomatically(assistantResponse)) {
console.log(`\n${YELLOW}Task continuation detected (step ${completionStatus.stepCount}). Press Enter to continue or modify the prompt:${RESET}`);
// Pre-populate the readline with continuation prompt
rl.write("continue");
rl.prompt();
return;
}
}
} catch (err) {
spinnerActive = false;
clearInterval(spinnerInterval);
process.stdout.clearLine(0);
process.stdout.cursorTo(0);
handleError(err, 'text generation');
}
// Only prompt for next input if we're not auto-continuing
if (!isAutoContinuation) {
rl.prompt();
}
}
rl.on("line", async (line) => {
const prompt = line.trim();
if (!prompt) {
rl.prompt();
return;
}
// Handle special commands
if (prompt === "/complete" || prompt === "/done") {
if (taskManager.isTaskActive()) {
taskManager.forceComplete("Manual completion by user");
console.log(`✅ ${YELLOW}Task manually marked as complete.${RESET}`);
} else {
console.log(`${YELLOW}No active task to complete.${RESET}`);
}
rl.prompt();
return;
}
if (prompt === "/status") {
if (taskManager.isTaskActive()) {
const context = taskManager.getTaskContext();
const status = taskManager.getCompletionStatus();
console.log(`\n📋 ${YELLOW}Current Task Status:${RESET}`);
console.log(`Task: ${context.taskDescription}`);
console.log(`Steps completed: ${status.stepCount}`);
console.log(`Completed: ${context.completedSteps.join(', ') || 'None'}`);
console.log(`Next steps: ${context.nextSteps.join(', ') || 'None'}`);
} else {
console.log(`${YELLOW}No active task.${RESET}`);
}
rl.prompt();
return;
}
if (prompt === "/help") {
console.log(`\n${YELLOW}Available commands:${RESET}`);
console.log(`/complete or /done - Manually mark current task as complete`);
console.log(`/status - Show current task status`);
console.log(`/help - Show this help message`);
rl.prompt();
return;
}
await processUserInput(prompt);
});
rl.on("close", () => {
console.log(`\nSession saved to ${sessionFile}`);
process.exit(0);
});
}