mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
This commit updates various modules to utilize the secure file system operations from the secureFs module instead of the native fs module. Key changes include: - Replaced fs imports with secureFs in multiple route handlers and services to enhance security and consistency in file operations. - Added centralized validation for working directories in the sdk-options module to ensure all AI model invocations are secure. These changes aim to improve the security and maintainability of file handling across the application.
582 lines
17 KiB
TypeScript
582 lines
17 KiB
TypeScript
/**
|
|
* Agent Service - Runs AI agents via provider architecture
|
|
* Manages conversation sessions and streams responses via WebSocket
|
|
*/
|
|
|
|
import path from 'path';
|
|
import * as secureFs from '../lib/secure-fs.js';
|
|
import type { EventEmitter } from '../lib/events.js';
|
|
import type { ExecuteOptions } from '@automaker/types';
|
|
import { readImageAsBase64, buildPromptWithImages, isAbortError } from '@automaker/utils';
|
|
import { ProviderFactory } from '../providers/provider-factory.js';
|
|
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
|
import { PathNotAllowedError } from '@automaker/platform';
|
|
|
|
interface Message {
|
|
id: string;
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
images?: Array<{
|
|
data: string;
|
|
mimeType: string;
|
|
filename: string;
|
|
}>;
|
|
timestamp: string;
|
|
isError?: boolean;
|
|
}
|
|
|
|
interface Session {
|
|
messages: Message[];
|
|
isRunning: boolean;
|
|
abortController: AbortController | null;
|
|
workingDirectory: string;
|
|
model?: string;
|
|
sdkSessionId?: string; // Claude SDK session ID for conversation continuity
|
|
}
|
|
|
|
interface SessionMetadata {
|
|
id: string;
|
|
name: string;
|
|
projectPath?: string;
|
|
workingDirectory: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
archived?: boolean;
|
|
tags?: string[];
|
|
model?: string;
|
|
sdkSessionId?: string; // Claude SDK session ID for conversation continuity
|
|
}
|
|
|
|
export class AgentService {
|
|
private sessions = new Map<string, Session>();
|
|
private stateDir: string;
|
|
private metadataFile: string;
|
|
private events: EventEmitter;
|
|
|
|
constructor(dataDir: string, events: EventEmitter) {
|
|
this.stateDir = path.join(dataDir, 'agent-sessions');
|
|
this.metadataFile = path.join(dataDir, 'sessions-metadata.json');
|
|
this.events = events;
|
|
}
|
|
|
|
async initialize(): Promise<void> {
|
|
await secureFs.mkdir(this.stateDir, { recursive: true });
|
|
}
|
|
|
|
/**
|
|
* Start or resume a conversation
|
|
*/
|
|
async startConversation({
|
|
sessionId,
|
|
workingDirectory,
|
|
}: {
|
|
sessionId: string;
|
|
workingDirectory?: string;
|
|
}) {
|
|
if (!this.sessions.has(sessionId)) {
|
|
const messages = await this.loadSession(sessionId);
|
|
const metadata = await this.loadMetadata();
|
|
const sessionMetadata = metadata[sessionId];
|
|
|
|
// Determine the effective working directory
|
|
const effectiveWorkingDirectory = workingDirectory || process.cwd();
|
|
const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory);
|
|
|
|
// Validate that the working directory is allowed using centralized validation
|
|
validateWorkingDirectory(resolvedWorkingDirectory);
|
|
|
|
this.sessions.set(sessionId, {
|
|
messages,
|
|
isRunning: false,
|
|
abortController: null,
|
|
workingDirectory: resolvedWorkingDirectory,
|
|
sdkSessionId: sessionMetadata?.sdkSessionId, // Load persisted SDK session ID
|
|
});
|
|
}
|
|
|
|
const session = this.sessions.get(sessionId)!;
|
|
return {
|
|
success: true,
|
|
messages: session.messages,
|
|
sessionId,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Send a message to the agent and stream responses
|
|
*/
|
|
async sendMessage({
|
|
sessionId,
|
|
message,
|
|
workingDirectory,
|
|
imagePaths,
|
|
model,
|
|
}: {
|
|
sessionId: string;
|
|
message: string;
|
|
workingDirectory?: string;
|
|
imagePaths?: string[];
|
|
model?: string;
|
|
}) {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) {
|
|
throw new Error(`Session ${sessionId} not found`);
|
|
}
|
|
|
|
if (session.isRunning) {
|
|
throw new Error('Agent is already processing a message');
|
|
}
|
|
|
|
// Update session model if provided
|
|
if (model) {
|
|
session.model = model;
|
|
await this.updateSession(sessionId, { model });
|
|
}
|
|
|
|
// Read images and convert to base64
|
|
const images: Message['images'] = [];
|
|
if (imagePaths && imagePaths.length > 0) {
|
|
for (const imagePath of imagePaths) {
|
|
try {
|
|
const imageData = await readImageAsBase64(imagePath);
|
|
images.push({
|
|
data: imageData.base64,
|
|
mimeType: imageData.mimeType,
|
|
filename: imageData.filename,
|
|
});
|
|
} catch (error) {
|
|
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add user message
|
|
const userMessage: Message = {
|
|
id: this.generateId(),
|
|
role: 'user',
|
|
content: message,
|
|
images: images.length > 0 ? images : undefined,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
|
|
// Build conversation history from existing messages BEFORE adding current message
|
|
const conversationHistory = session.messages.map((msg) => ({
|
|
role: msg.role,
|
|
content: msg.content,
|
|
}));
|
|
|
|
session.messages.push(userMessage);
|
|
session.isRunning = true;
|
|
session.abortController = new AbortController();
|
|
|
|
// Emit user message event
|
|
this.emitAgentEvent(sessionId, {
|
|
type: 'message',
|
|
message: userMessage,
|
|
});
|
|
|
|
await this.saveSession(sessionId, session.messages);
|
|
|
|
try {
|
|
// Build SDK options using centralized configuration
|
|
const sdkOptions = createChatOptions({
|
|
cwd: workingDirectory || session.workingDirectory,
|
|
model: model,
|
|
sessionModel: session.model,
|
|
systemPrompt: this.getSystemPrompt(),
|
|
abortController: session.abortController!,
|
|
});
|
|
|
|
// Extract model, maxTurns, and allowedTools from SDK options
|
|
const effectiveModel = sdkOptions.model!;
|
|
const maxTurns = sdkOptions.maxTurns;
|
|
const allowedTools = sdkOptions.allowedTools as string[] | undefined;
|
|
|
|
// Get provider for this model
|
|
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
|
|
|
console.log(
|
|
`[AgentService] Using provider "${provider.getName()}" for model "${effectiveModel}"`
|
|
);
|
|
|
|
// Build options for provider
|
|
const options: ExecuteOptions = {
|
|
prompt: '', // Will be set below based on images
|
|
model: effectiveModel,
|
|
cwd: workingDirectory || session.workingDirectory,
|
|
systemPrompt: this.getSystemPrompt(),
|
|
maxTurns: maxTurns,
|
|
allowedTools: allowedTools,
|
|
abortController: session.abortController!,
|
|
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
|
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
|
|
};
|
|
|
|
// Build prompt content with images
|
|
const { content: promptContent } = await buildPromptWithImages(
|
|
message,
|
|
imagePaths,
|
|
undefined, // no workDir for agent service
|
|
true // include image paths in text
|
|
);
|
|
|
|
// Set the prompt in options
|
|
options.prompt = promptContent;
|
|
|
|
// Execute via provider
|
|
const stream = provider.executeQuery(options);
|
|
|
|
let currentAssistantMessage: Message | null = null;
|
|
let responseText = '';
|
|
const toolUses: Array<{ name: string; input: unknown }> = [];
|
|
|
|
for await (const msg of stream) {
|
|
// Capture SDK session ID from any message and persist it
|
|
if (msg.session_id && !session.sdkSessionId) {
|
|
session.sdkSessionId = msg.session_id;
|
|
console.log(`[AgentService] Captured SDK session ID: ${msg.session_id}`);
|
|
// Persist the SDK session ID to ensure conversation continuity across server restarts
|
|
await this.updateSession(sessionId, { sdkSessionId: msg.session_id });
|
|
}
|
|
|
|
if (msg.type === 'assistant') {
|
|
if (msg.message?.content) {
|
|
for (const block of msg.message.content) {
|
|
if (block.type === 'text') {
|
|
responseText += block.text;
|
|
|
|
if (!currentAssistantMessage) {
|
|
currentAssistantMessage = {
|
|
id: this.generateId(),
|
|
role: 'assistant',
|
|
content: responseText,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
session.messages.push(currentAssistantMessage);
|
|
} else {
|
|
currentAssistantMessage.content = responseText;
|
|
}
|
|
|
|
this.emitAgentEvent(sessionId, {
|
|
type: 'stream',
|
|
messageId: currentAssistantMessage.id,
|
|
content: responseText,
|
|
isComplete: false,
|
|
});
|
|
} else if (block.type === 'tool_use') {
|
|
const toolUse = {
|
|
name: block.name || 'unknown',
|
|
input: block.input,
|
|
};
|
|
toolUses.push(toolUse);
|
|
|
|
this.emitAgentEvent(sessionId, {
|
|
type: 'tool_use',
|
|
tool: toolUse,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} else if (msg.type === 'result') {
|
|
if (msg.subtype === 'success' && msg.result) {
|
|
if (currentAssistantMessage) {
|
|
currentAssistantMessage.content = msg.result;
|
|
responseText = msg.result;
|
|
}
|
|
}
|
|
|
|
this.emitAgentEvent(sessionId, {
|
|
type: 'complete',
|
|
messageId: currentAssistantMessage?.id,
|
|
content: responseText,
|
|
toolUses,
|
|
});
|
|
}
|
|
}
|
|
|
|
await this.saveSession(sessionId, session.messages);
|
|
|
|
session.isRunning = false;
|
|
session.abortController = null;
|
|
|
|
return {
|
|
success: true,
|
|
message: currentAssistantMessage,
|
|
};
|
|
} catch (error) {
|
|
if (isAbortError(error)) {
|
|
session.isRunning = false;
|
|
session.abortController = null;
|
|
return { success: false, aborted: true };
|
|
}
|
|
|
|
console.error('[AgentService] Error:', error);
|
|
|
|
session.isRunning = false;
|
|
session.abortController = null;
|
|
|
|
const errorMessage: Message = {
|
|
id: this.generateId(),
|
|
role: 'assistant',
|
|
content: `Error: ${(error as Error).message}`,
|
|
timestamp: new Date().toISOString(),
|
|
isError: true,
|
|
};
|
|
|
|
session.messages.push(errorMessage);
|
|
await this.saveSession(sessionId, session.messages);
|
|
|
|
this.emitAgentEvent(sessionId, {
|
|
type: 'error',
|
|
error: (error as Error).message,
|
|
message: errorMessage,
|
|
});
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get conversation history
|
|
*/
|
|
getHistory(sessionId: string) {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) {
|
|
return { success: false, error: 'Session not found' };
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
messages: session.messages,
|
|
isRunning: session.isRunning,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Stop current agent execution
|
|
*/
|
|
async stopExecution(sessionId: string) {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) {
|
|
return { success: false, error: 'Session not found' };
|
|
}
|
|
|
|
if (session.abortController) {
|
|
session.abortController.abort();
|
|
session.isRunning = false;
|
|
session.abortController = null;
|
|
}
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
/**
|
|
* Clear conversation history
|
|
*/
|
|
async clearSession(sessionId: string) {
|
|
const session = this.sessions.get(sessionId);
|
|
if (session) {
|
|
session.messages = [];
|
|
session.isRunning = false;
|
|
await this.saveSession(sessionId, []);
|
|
}
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
// Session management
|
|
|
|
async loadSession(sessionId: string): Promise<Message[]> {
|
|
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
|
|
|
|
try {
|
|
const data = (await secureFs.readFile(sessionFile, 'utf-8')) as string;
|
|
return JSON.parse(data);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async saveSession(sessionId: string, messages: Message[]): Promise<void> {
|
|
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
|
|
|
|
try {
|
|
await secureFs.writeFile(sessionFile, JSON.stringify(messages, null, 2), 'utf-8');
|
|
await this.updateSessionTimestamp(sessionId);
|
|
} catch (error) {
|
|
console.error('[AgentService] Failed to save session:', error);
|
|
}
|
|
}
|
|
|
|
async loadMetadata(): Promise<Record<string, SessionMetadata>> {
|
|
try {
|
|
const data = (await secureFs.readFile(this.metadataFile, 'utf-8')) as string;
|
|
return JSON.parse(data);
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async saveMetadata(metadata: Record<string, SessionMetadata>): Promise<void> {
|
|
await secureFs.writeFile(this.metadataFile, JSON.stringify(metadata, null, 2), 'utf-8');
|
|
}
|
|
|
|
async updateSessionTimestamp(sessionId: string): Promise<void> {
|
|
const metadata = await this.loadMetadata();
|
|
if (metadata[sessionId]) {
|
|
metadata[sessionId].updatedAt = new Date().toISOString();
|
|
await this.saveMetadata(metadata);
|
|
}
|
|
}
|
|
|
|
async listSessions(includeArchived = false): Promise<SessionMetadata[]> {
|
|
const metadata = await this.loadMetadata();
|
|
let sessions = Object.values(metadata);
|
|
|
|
if (!includeArchived) {
|
|
sessions = sessions.filter((s) => !s.archived);
|
|
}
|
|
|
|
return sessions.sort(
|
|
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
|
);
|
|
}
|
|
|
|
async createSession(
|
|
name: string,
|
|
projectPath?: string,
|
|
workingDirectory?: string,
|
|
model?: string
|
|
): Promise<SessionMetadata> {
|
|
const sessionId = this.generateId();
|
|
const metadata = await this.loadMetadata();
|
|
|
|
// Determine the effective working directory
|
|
const effectiveWorkingDirectory = workingDirectory || projectPath || process.cwd();
|
|
const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory);
|
|
|
|
// Validate that the working directory is allowed using centralized validation
|
|
validateWorkingDirectory(resolvedWorkingDirectory);
|
|
|
|
// Validate that projectPath is allowed if provided
|
|
if (projectPath) {
|
|
validateWorkingDirectory(projectPath);
|
|
}
|
|
|
|
const session: SessionMetadata = {
|
|
id: sessionId,
|
|
name,
|
|
projectPath,
|
|
workingDirectory: resolvedWorkingDirectory,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
model,
|
|
};
|
|
|
|
metadata[sessionId] = session;
|
|
await this.saveMetadata(metadata);
|
|
|
|
return session;
|
|
}
|
|
|
|
async setSessionModel(sessionId: string, model: string): Promise<boolean> {
|
|
const session = this.sessions.get(sessionId);
|
|
if (session) {
|
|
session.model = model;
|
|
await this.updateSession(sessionId, { model });
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async updateSession(
|
|
sessionId: string,
|
|
updates: Partial<SessionMetadata>
|
|
): Promise<SessionMetadata | null> {
|
|
const metadata = await this.loadMetadata();
|
|
if (!metadata[sessionId]) return null;
|
|
|
|
metadata[sessionId] = {
|
|
...metadata[sessionId],
|
|
...updates,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
await this.saveMetadata(metadata);
|
|
return metadata[sessionId];
|
|
}
|
|
|
|
async archiveSession(sessionId: string): Promise<boolean> {
|
|
const result = await this.updateSession(sessionId, { archived: true });
|
|
return result !== null;
|
|
}
|
|
|
|
async unarchiveSession(sessionId: string): Promise<boolean> {
|
|
const result = await this.updateSession(sessionId, { archived: false });
|
|
return result !== null;
|
|
}
|
|
|
|
async deleteSession(sessionId: string): Promise<boolean> {
|
|
const metadata = await this.loadMetadata();
|
|
if (!metadata[sessionId]) return false;
|
|
|
|
delete metadata[sessionId];
|
|
await this.saveMetadata(metadata);
|
|
|
|
// Delete session file
|
|
try {
|
|
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
|
|
await secureFs.unlink(sessionFile);
|
|
} catch {
|
|
// File may not exist
|
|
}
|
|
|
|
// Clear from memory
|
|
this.sessions.delete(sessionId);
|
|
|
|
return true;
|
|
}
|
|
|
|
private emitAgentEvent(sessionId: string, data: Record<string, unknown>): void {
|
|
this.events.emit('agent:stream', { sessionId, ...data });
|
|
}
|
|
|
|
private getSystemPrompt(): string {
|
|
return `You are an AI assistant helping users build software. You are part of the Automaker application,
|
|
which is designed to help developers plan, design, and implement software projects autonomously.
|
|
|
|
**Feature Storage:**
|
|
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
|
|
Use the UpdateFeatureStatus tool to manage features, not direct file edits.
|
|
|
|
Your role is to:
|
|
- Help users define their project requirements and specifications
|
|
- Ask clarifying questions to better understand their needs
|
|
- Suggest technical approaches and architectures
|
|
- Guide them through the development process
|
|
- Be conversational and helpful
|
|
- Write, edit, and modify code files as requested
|
|
- Execute commands and tests
|
|
- Search and analyze the codebase
|
|
|
|
When discussing projects, help users think through:
|
|
- Core functionality and features
|
|
- Technical stack choices
|
|
- Data models and architecture
|
|
- User experience considerations
|
|
- Testing strategies
|
|
|
|
You have full access to the codebase and can:
|
|
- Read files to understand existing code
|
|
- Write new files
|
|
- Edit existing files
|
|
- Run bash commands
|
|
- Search for code patterns
|
|
- Execute tests and builds`;
|
|
}
|
|
|
|
private generateId(): string {
|
|
return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
}
|
|
}
|