Spaces:
Running
Running
| import { InferenceClient } from "@huggingface/inference"; | |
| import { StateGraph, Annotation, START, END } from "@langchain/langgraph"; | |
| import { | |
| HumanMessage, | |
| AIMessage, | |
| BaseMessage, | |
| ToolMessage, | |
| } from "@langchain/core/messages"; | |
| import { observeConsoleTool } from "./tools"; | |
| import { mcpClientManager, setMCPWebSocketConnection } from "./mcp-client"; | |
| import { planTasksTool, updateTaskTool, viewTasksTool } from "./task-tracker"; | |
| import { documentationService } from "./documentation"; | |
| import { | |
| StreamingToolCallParser, | |
| extractToolCalls, | |
| } from "../utils/tool-call-parser"; | |
| import { virtualFileSystem } from "../services/virtual-fs"; | |
| import type { WebSocket } from "ws"; | |
| const AgentState = Annotation.Root({ | |
| messages: Annotation<BaseMessage[]>({ | |
| reducer: (x, y) => x.concat(y), | |
| }), | |
| hasToolCalls: Annotation<boolean>({ | |
| reducer: (_, y) => y, | |
| default: () => false, | |
| }), | |
| }); | |
| export class LangGraphAgent { | |
| private client: InferenceClient | null = null; | |
| private graph!: ReturnType<typeof StateGraph.prototype.compile>; | |
| private model: string = "Qwen/Qwen3-Next-80B-A3B-Instruct"; | |
| private documentation: string = ""; | |
| private ws: WebSocket | null = null; | |
| constructor() { | |
| this.setupGraph(); | |
| } | |
| async initialize(hfToken: string, ws?: WebSocket) { | |
| if (!hfToken) { | |
| throw new Error("Hugging Face authentication required"); | |
| } | |
| this.client = new InferenceClient(hfToken); | |
| if (ws) { | |
| this.ws = ws; | |
| setMCPWebSocketConnection(ws); | |
| } | |
| await mcpClientManager.initialize(); | |
| const docs = await documentationService.load(); | |
| this.documentation = docs || ""; | |
| } | |
| private setupGraph() { | |
| const graph = new StateGraph(AgentState); | |
| graph.addNode("agent", async (state, config) => { | |
| const systemPrompt = this.buildSystemPrompt(); | |
| const messages = this.formatMessages(state.messages, systemPrompt); | |
| let fullResponse = ""; | |
| const parser = new StreamingToolCallParser(); | |
| let currentSegmentId: string | null = null; | |
| const messageId = config?.metadata?.messageId; | |
| const abortSignal = config?.metadata?.abortSignal as | |
| | AbortSignal | |
| | undefined; | |
| for await (const token of this.streamModelResponse( | |
| messages, | |
| abortSignal, | |
| )) { | |
| fullResponse += token; | |
| config?.writer?.({ type: "token", content: token }); | |
| // Process token through the parser | |
| const parseResult = parser.process(token); | |
| // Only stream safe text (without tool calls) | |
| if (parseResult.safeText) { | |
| // Start a text segment if needed | |
| if (!currentSegmentId && parseResult.safeText.trim() && this.ws) { | |
| currentSegmentId = `seg_${Date.now()}_${Math.random()}`; | |
| this.ws.send( | |
| JSON.stringify({ | |
| type: "segment_start", | |
| payload: { | |
| segmentId: currentSegmentId, | |
| segmentType: "text", | |
| messageId, | |
| }, | |
| timestamp: Date.now(), | |
| }), | |
| ); | |
| } | |
| // Stream the safe text | |
| if (currentSegmentId && this.ws) { | |
| this.ws.send( | |
| JSON.stringify({ | |
| type: "segment_token", | |
| payload: { | |
| segmentId: currentSegmentId, | |
| token: parseResult.safeText, | |
| messageId, | |
| }, | |
| timestamp: Date.now(), | |
| }), | |
| ); | |
| } | |
| } | |
| // If we detected a tool call start, end the current text segment | |
| if (parseResult.pendingToolCall && currentSegmentId && this.ws) { | |
| this.ws.send( | |
| JSON.stringify({ | |
| type: "segment_end", | |
| payload: { | |
| segmentId: currentSegmentId, | |
| messageId, | |
| }, | |
| timestamp: Date.now(), | |
| }), | |
| ); | |
| currentSegmentId = null; | |
| } | |
| } | |
| // Finalize any remaining text segment | |
| if (currentSegmentId && this.ws) { | |
| // Process any remaining buffered content | |
| const remainingBuffer = parser.getBuffer(); | |
| if (remainingBuffer && !parser.isInToolCall()) { | |
| const finalResult = parser.process(""); | |
| if (finalResult.safeText) { | |
| this.ws.send( | |
| JSON.stringify({ | |
| type: "segment_token", | |
| payload: { | |
| segmentId: currentSegmentId, | |
| token: finalResult.safeText, | |
| messageId, | |
| }, | |
| timestamp: Date.now(), | |
| }), | |
| ); | |
| } | |
| } | |
| this.ws.send( | |
| JSON.stringify({ | |
| type: "segment_end", | |
| payload: { | |
| segmentId: currentSegmentId, | |
| messageId, | |
| }, | |
| timestamp: Date.now(), | |
| }), | |
| ); | |
| } | |
| const toolCalls = extractToolCalls(fullResponse); | |
| if (toolCalls.length > 0) { | |
| const toolResults = await this.executeToolsWithSegments( | |
| toolCalls, | |
| config?.metadata?.messageId as string | undefined, | |
| ); | |
| return { | |
| messages: [ | |
| new AIMessage({ | |
| content: fullResponse, | |
| additional_kwargs: { has_tool_calls: true }, | |
| }), | |
| ...toolResults, | |
| ], | |
| hasToolCalls: true, | |
| }; | |
| } | |
| if (state.messages.length > 0) { | |
| const lastUserMessage = state.messages[state.messages.length - 1]; | |
| if (lastUserMessage instanceof HumanMessage) { | |
| const needsTools = this.shouldUseTools( | |
| lastUserMessage.content as string, | |
| ); | |
| if (needsTools && !state.hasToolCalls) { | |
| const reminderMessage = new AIMessage( | |
| "I need to use tools to complete this task. Let me try again with the appropriate tool.", | |
| ); | |
| return { | |
| messages: [reminderMessage], | |
| hasToolCalls: false, | |
| }; | |
| } | |
| } | |
| } | |
| return { | |
| messages: [new AIMessage(fullResponse)], | |
| hasToolCalls: false, | |
| }; | |
| }); | |
| // @ts-expect-error - LangGraph type mismatch with START constant | |
| graph.addEdge(START, "agent"); | |
| // @ts-expect-error - LangGraph type mismatch with conditional edges | |
| graph.addConditionalEdges("agent", (state) => this.shouldContinue(state), { | |
| continue: "agent", | |
| end: END, | |
| }); | |
| this.graph = graph.compile(); | |
| } | |
| private shouldContinue(state: typeof AgentState.State): string { | |
| const lastMessage = state.messages[state.messages.length - 1]; | |
| if (lastMessage instanceof ToolMessage) { | |
| return "continue"; | |
| } | |
| if ( | |
| lastMessage instanceof AIMessage && | |
| lastMessage.additional_kwargs?.has_tool_calls | |
| ) { | |
| return "continue"; | |
| } | |
| return "end"; | |
| } | |
| private buildSystemPrompt(): string { | |
| return `## Role & Primary Guidance | |
| You are a VibeGame Engine Specialist operating in a single-file editor environment. You work with ONE game file that users can see and edit in real-time, similar to JSFiddle or CodePen. | |
| ## Live Coding Environment Context | |
| - **SINGLE EDITOR**: There is exactly ONE editor file containing the game's XML/HTML code | |
| - **"The code" ALWAYS refers to**: The content currently in the editor | |
| - **Live Preview**: Changes to the editor automatically reload the game | |
| - **User's View**: Users see the same editor you're modifying | |
| - **GAME is PRE-IMPORTED**: The GAME object is automatically available - NEVER write \`import * as GAME from 'vibegame'\` | |
| - **Auto-run enabled**: GAME.run() is called automatically - use \`GAME.withPlugin(MyPlugin)\` NOT \`GAME.withPlugin(MyPlugin).run()\` | |
| ## VibeGame Expert Knowledge | |
| ${this.documentation} | |
| ## MCP Tool Execution Protocol | |
| ### Format Requirements | |
| - MANDATORY format: <tool name="tool_name">{"param": "value"}</tool> | |
| - JSON arguments must be valid JSON (use double quotes for strings) | |
| - **CRITICAL**: Execute ONE tool at a time and wait for the result | |
| - The parser will handle unclosed tags gracefully for recovery | |
| - **NEVER** generate multiple tool calls in a single response | |
| - After each tool execution, analyze the result before deciding next action | |
| - ALWAYS check console after making changes | |
| - The game auto-reloads after editor changes | |
| ### Tool Execution Rules | |
| 1. Execute a single tool | |
| 2. Read and analyze the tool's response | |
| 3. Only then decide if another tool is needed | |
| 4. If multiple edits are needed, use plan_tasks first to organize them | |
| ### Available MCP Tools (All operate on the SINGLE editor file) | |
| #### Understanding the Editor Content | |
| - search_editor: Find text/patterns in the current game code | |
| Parameters: | |
| - query: string - Text or regex pattern to search for | |
| - mode: "text" | "regex" - Search mode (optional, default: "text") | |
| - contextLines: number - Lines of context (optional, default: 2, max: 5) | |
| Example: <tool name="search_editor">{"query": "dynamic-part", "mode": "text"}</tool> | |
| - read_editor_lines: Read specific lines from the current game code | |
| Parameters: | |
| - startLine: number - Starting line (1-indexed) | |
| - endLine: number - Ending line (optional, defaults to startLine) | |
| Example: <tool name="read_editor_lines">{"startLine": 10, "endLine": 20}</tool> | |
| - read_editor: Read the complete game code currently in the editor | |
| Parameters: none | |
| Example: <tool name="read_editor">{}</tool> | |
| Note: Use when user asks to "explain the code", "show the code", or needs full context | |
| #### Modifying the Game | |
| - edit_editor: Make targeted changes to the game code | |
| Parameters: | |
| - oldText: string - Exact text to find and replace (max ~20 lines). MUST include actual content, not just whitespace | |
| - newText: string - Replacement text | |
| Example: <tool name="edit_editor">{"oldText": "color='#ff0000'", "newText": "color='#00ff00'"}</tool> | |
| IMPORTANT: Always include meaningful content (tags, comments, or code) in oldText, never just spaces/newlines | |
| Note: Returns standardized response with game status and console output | |
| - write_editor: Replace all game code with new content | |
| Parameters: | |
| - content: string - Complete new game code | |
| Example: <tool name="write_editor">{"content": "<world>...</world>"}</tool> | |
| Note: Use ONLY for starting fresh or complete rewrites | |
| #### Task Management | |
| - plan_tasks: Create task list for complex operations | |
| Parameters: | |
| - tasks: string[] - Array of task descriptions | |
| Example: <tool name="plan_tasks">{"tasks": ["Add physics component", "Create gravity system", "Test gravity effect"]}</tool> | |
| - update_task: Update task status | |
| Parameters: | |
| - taskId: number - Task ID to update | |
| - status: "pending" | "in_progress" | "completed" | |
| Example: <tool name="update_task">{"taskId": 1, "status": "completed"}</tool> | |
| - view_tasks: View current task list | |
| Parameters: none | |
| Example: <tool name="view_tasks">{}</tool> | |
| #### Runtime Monitoring | |
| - observe_console: Check console messages and game state | |
| Parameters: none | |
| Example: <tool name="observe_console">{}</tool> | |
| Note: Returns recent console messages and game status | |
| ## Tool Response Format | |
| All editor modification tools return: | |
| - Success indicator (✅ or ❌) | |
| - Action description | |
| - Game status (Running/Error/Loading) | |
| - Console output from the game | |
| ## Common User Requests (Context Clarification) | |
| When users say: | |
| - "explain the code" → Read and explain the CURRENT editor content | |
| - "what's in the game?" → Describe the entities/elements in the editor | |
| - "fix the error" → Check console, then modify the editor content | |
| - "add a..." → Add new elements to the existing editor content | |
| - "change the..." → Modify existing elements in the editor | |
| ## Execution Patterns | |
| ### Code Writing Rules (Live Environment) | |
| - **NO IMPORTS**: GAME is pre-imported globally - NEVER write \`import * as GAME from 'vibegame'\` | |
| - **NO .run()**: Auto-run is enabled - write \`GAME.withPlugin(MyPlugin)\` not \`GAME.withPlugin(MyPlugin).run()\` | |
| - **Direct access**: Use GAME.defineComponent, GAME.Types, etc. directly | |
| - **Script tags**: When adding JavaScript, use \`<script>\` tags in the XML/HTML | |
| ### Standard Workflow (ONE TOOL AT A TIME) | |
| 1. Execute: <tool name="read_editor">{}</tool> (if needed) → WAIT for result | |
| 2. Execute: <tool name="search_editor">{"query": "target_element"}</tool> → WAIT for result | |
| 3. Execute: <tool name="read_editor_lines">{"startLine": X, "endLine": Y}</tool> → WAIT for result | |
| 4. Execute: <tool name="edit_editor">{"oldText": "...", "newText": "..."}</tool> → WAIT for result | |
| 5. Execute: <tool name="observe_console">{}</tool> → WAIT for result | |
| 6. Based on console output, decide if another edit is needed | |
| ### IMPORTANT: Sequential Tool Execution | |
| - NEVER chain multiple TOOL: commands in one response | |
| - Each tool call must be in its own separate message | |
| - Always analyze the tool result before proceeding | |
| - If you need to make multiple edits, use plan_tasks to organize them first | |
| ### Error Recovery | |
| When you see an error in tool results: | |
| 1. Read the error message carefully | |
| 2. Search for the problematic code | |
| 3. Make corrections with edit_editor | |
| 4. Verify fix with observe_console | |
| ### Complex Changes | |
| For changes requiring multiple edits: | |
| 1. <tool name="plan_tasks">{"tasks": [...]}</tool> | |
| 2. Update task status as you progress | |
| 3. Break edits into small chunks (<20 lines each) | |
| 4. Test after each significant change | |
| ## Critical Rules | |
| - REMEMBER: You work with ONE editor file - all references to "the code" mean this single file | |
| - ALWAYS check console output after edits | |
| - NEVER skip tool execution - implement directly | |
| - Use exact text matches for edit_editor | |
| - Keep individual edits small and focused | |
| - When unsure what user refers to, assume they mean the editor content | |
| Remember: You're in a live coding environment. Users see the same editor you're modifying. Tool responses include console output - read them carefully to understand game state.`; | |
| } | |
| private async *streamModelResponse( | |
| messages: Array<{ role: string; content: string }>, | |
| abortSignal?: AbortSignal, | |
| ): AsyncGenerator<string, string, unknown> { | |
| if (!this.client) { | |
| throw new Error("Agent not initialized"); | |
| } | |
| let fullContent = ""; | |
| const stream = this.client.chatCompletionStream({ | |
| model: this.model, | |
| messages, | |
| temperature: 0.3, | |
| max_tokens: 2048, | |
| }); | |
| for await (const chunk of stream) { | |
| if (abortSignal?.aborted) { | |
| throw new Error("AbortError"); | |
| } | |
| const token = chunk.choices[0]?.delta?.content || ""; | |
| if (token) { | |
| fullContent += token; | |
| yield token; | |
| } | |
| } | |
| return fullContent; | |
| } | |
| private shouldUseTools(content: string): boolean { | |
| const lowerContent = content.toLowerCase(); | |
| const actionKeywords = [ | |
| "find", | |
| "search", | |
| "look for", | |
| "where", | |
| "change", | |
| "modify", | |
| "update", | |
| "edit", | |
| "replace", | |
| "show", | |
| "display", | |
| "read", | |
| "what", | |
| "check", | |
| "create", | |
| "write", | |
| "add", | |
| "implement", | |
| "fix", | |
| "debug", | |
| "error", | |
| "console", | |
| ]; | |
| return actionKeywords.some((keyword) => lowerContent.includes(keyword)); | |
| } | |
| /** | |
| * Deduplicate tool calls to prevent redundant operations | |
| */ | |
| private deduplicateToolCalls( | |
| toolCalls: Array<{ name: string; args: Record<string, unknown> }>, | |
| ): Array<{ name: string; args: Record<string, unknown> }> { | |
| const deduplicated: Array<{ name: string; args: Record<string, unknown> }> = | |
| []; | |
| const seenOperations = new Set<string>(); | |
| for (const call of toolCalls) { | |
| let signature = `${call.name}:`; | |
| if (call.name === "edit_editor") { | |
| const oldText = call.args.oldText as string; | |
| signature += oldText?.substring(0, 100); | |
| } else if (call.name === "write_editor") { | |
| signature += "FULL_REPLACE"; | |
| } else { | |
| signature += JSON.stringify(call.args); | |
| } | |
| if (!seenOperations.has(signature)) { | |
| seenOperations.add(signature); | |
| deduplicated.push(call); | |
| } else { | |
| console.warn(`Skipping duplicate tool call: ${call.name}`); | |
| } | |
| } | |
| return deduplicated; | |
| } | |
| private formatMessages( | |
| messages: BaseMessage[], | |
| systemPrompt: string, | |
| ): Array<{ role: string; content: string }> { | |
| const formatted = [ | |
| { role: "system", content: systemPrompt }, | |
| ...messages.map((msg) => { | |
| let role = "assistant"; | |
| if (msg instanceof HumanMessage) { | |
| role = "user"; | |
| } else if (msg instanceof ToolMessage) { | |
| const content = `Tool result for ${msg.name}: ${ | |
| typeof msg.content === "string" | |
| ? msg.content | |
| : JSON.stringify(msg.content) | |
| }`; | |
| return { role: "assistant", content }; | |
| } | |
| const content = | |
| typeof msg.content === "string" | |
| ? msg.content | |
| : JSON.stringify(msg.content); | |
| return { role, content }; | |
| }), | |
| ]; | |
| return formatted; | |
| } | |
| private async executeToolsWithSegments( | |
| toolCalls: Array<{ name: string; args: Record<string, unknown> }>, | |
| messageId?: string, | |
| ): Promise<BaseMessage[]> { | |
| const results = []; | |
| const processedCalls = this.deduplicateToolCalls(toolCalls); | |
| const stateTracker = { | |
| editorModified: false, | |
| lastEditContent: "", | |
| }; | |
| for (const call of processedCalls) { | |
| const segmentId = `seg_tool_${Date.now()}_${Math.random()}`; | |
| const validation = this.validateToolCall(call); | |
| if (!validation.valid) { | |
| console.error(`Invalid tool call: ${call.name}`, validation.error); | |
| results.push( | |
| new ToolMessage({ | |
| content: `Error: ${validation.error}`, | |
| tool_call_id: segmentId, | |
| name: call.name, | |
| }), | |
| ); | |
| continue; | |
| } | |
| if (call.name === "edit_editor" && stateTracker.editorModified) { | |
| const currentContent = virtualFileSystem.getGameFile().content; | |
| const oldText = call.args.oldText as string; | |
| if (!currentContent.includes(oldText)) { | |
| console.warn( | |
| `Skipping edit_editor: Text no longer exists after previous edit`, | |
| ); | |
| results.push( | |
| new ToolMessage({ | |
| content: `Skipped: The text to replace was already modified by a previous edit. The current operation is no longer needed.`, | |
| tool_call_id: segmentId, | |
| name: call.name, | |
| }), | |
| ); | |
| continue; | |
| } | |
| } | |
| try { | |
| const argString = JSON.stringify(call.args); | |
| const estimatedTokens = argString.length / 4; | |
| if ( | |
| estimatedTokens > 1000 && | |
| (call.name === "edit_editor" || call.name === "write_editor") | |
| ) { | |
| console.warn( | |
| `Warning: Tool ${call.name} arguments are large (${estimatedTokens} estimated tokens)`, | |
| ); | |
| if (call.name === "edit_editor" && call.args.oldText) { | |
| const oldText = call.args.oldText as string; | |
| if (oldText.split("\n").length > 20) { | |
| results.push( | |
| new ToolMessage({ | |
| content: `Error: The edit is too large (${oldText.split("\n").length} lines). Please break this into smaller edits of max 20 lines each. Use plan_tasks to organize multiple edits.`, | |
| tool_call_id: segmentId, | |
| name: call.name, | |
| }), | |
| ); | |
| continue; | |
| } | |
| } | |
| } | |
| if (this.ws && this.ws.readyState === this.ws.OPEN) { | |
| this.ws.send( | |
| JSON.stringify({ | |
| type: "segment_start", | |
| payload: { | |
| segmentId, | |
| segmentType: "tool-invocation", | |
| messageId, | |
| toolName: call.name, | |
| toolArgs: call.args, | |
| }, | |
| timestamp: Date.now(), | |
| }), | |
| ); | |
| this.ws.send( | |
| JSON.stringify({ | |
| type: "segment_end", | |
| payload: { | |
| segmentId, | |
| toolStatus: "running", | |
| messageId, | |
| }, | |
| timestamp: Date.now(), | |
| }), | |
| ); | |
| } | |
| let result; | |
| let consoleOutput: string[] = []; | |
| const mcpTools = mcpClientManager.getTools(); | |
| const tool = mcpTools.find((t) => t.name === call.name); | |
| if (tool) { | |
| result = await tool.func(call.args); | |
| if (call.name === "edit_editor" || call.name === "write_editor") { | |
| stateTracker.editorModified = true; | |
| stateTracker.lastEditContent = | |
| virtualFileSystem.getGameFile().content; | |
| } | |
| const consoleMatch = result.match(/Console output:\n([\s\S]*?)$/); | |
| if (consoleMatch) { | |
| consoleOutput = consoleMatch[1] | |
| .split("\n") | |
| .filter((line: string) => line.trim()); | |
| } | |
| } else if (call.name === "observe_console") { | |
| result = await observeConsoleTool.func(""); | |
| } else if (call.name === "plan_tasks") { | |
| result = await planTasksTool.func(call.args as { tasks: string[] }); | |
| } else if (call.name === "update_task") { | |
| result = await updateTaskTool.func( | |
| call.args as { | |
| taskId: number; | |
| status: "pending" | "in_progress" | "completed"; | |
| }, | |
| ); | |
| } else if (call.name === "view_tasks") { | |
| result = await viewTasksTool.func({}); | |
| } else { | |
| result = `Unknown tool: ${call.name}`; | |
| } | |
| if (this.ws && this.ws.readyState === this.ws.OPEN) { | |
| this.ws.send( | |
| JSON.stringify({ | |
| type: "segment_end", | |
| payload: { | |
| segmentId, | |
| toolStatus: "completed", | |
| messageId, | |
| }, | |
| timestamp: Date.now(), | |
| }), | |
| ); | |
| const resultSegmentId = `seg_result_${Date.now()}_${Math.random()}`; | |
| this.ws.send( | |
| JSON.stringify({ | |
| type: "segment_start", | |
| payload: { | |
| segmentId: resultSegmentId, | |
| segmentType: "tool-result", | |
| messageId, | |
| toolName: call.name, | |
| }, | |
| timestamp: Date.now(), | |
| }), | |
| ); | |
| this.ws.send( | |
| JSON.stringify({ | |
| type: "segment_end", | |
| payload: { | |
| segmentId: resultSegmentId, | |
| toolOutput: result, | |
| consoleOutput: | |
| consoleOutput.length > 0 ? consoleOutput : undefined, | |
| toolStatus: "completed", | |
| messageId, | |
| }, | |
| timestamp: Date.now(), | |
| }), | |
| ); | |
| } | |
| results.push( | |
| new ToolMessage({ | |
| content: result, | |
| tool_call_id: segmentId, | |
| name: call.name, | |
| }), | |
| ); | |
| } catch (error) { | |
| if (this.ws && this.ws.readyState === this.ws.OPEN) { | |
| this.ws.send( | |
| JSON.stringify({ | |
| type: "segment_end", | |
| payload: { | |
| segmentId, | |
| toolStatus: "error", | |
| toolError: | |
| error instanceof Error ? error.message : String(error), | |
| messageId, | |
| }, | |
| timestamp: Date.now(), | |
| }), | |
| ); | |
| } | |
| results.push( | |
| new ToolMessage({ | |
| content: `Error executing ${call.name}: ${error}`, | |
| tool_call_id: segmentId, | |
| name: call.name, | |
| }), | |
| ); | |
| } | |
| } | |
| return results; | |
| } | |
| private validateToolCall(call: { | |
| name: string; | |
| args: Record<string, unknown>; | |
| }): { valid: boolean; error?: string } { | |
| // Basic validation for known tools | |
| const toolValidations: Record< | |
| string, | |
| (args: Record<string, unknown>) => string | null | |
| > = { | |
| edit_editor: (args) => { | |
| if (!args.oldText || typeof args.oldText !== "string") { | |
| return "Missing or invalid 'oldText' parameter"; | |
| } | |
| if (!args.newText || typeof args.newText !== "string") { | |
| return "Missing or invalid 'newText' parameter"; | |
| } | |
| const lines = (args.oldText as string).split("\n").length; | |
| if (lines > 30) { | |
| return `Edit too large (${lines} lines). Break into smaller edits.`; | |
| } | |
| return null; | |
| }, | |
| write_editor: (args) => { | |
| if (!args.content || typeof args.content !== "string") { | |
| return "Missing or invalid 'content' parameter"; | |
| } | |
| return null; | |
| }, | |
| search_editor: (args) => { | |
| if (!args.query || typeof args.query !== "string") { | |
| return "Missing or invalid 'query' parameter"; | |
| } | |
| if (args.mode && !["text", "regex"].includes(args.mode as string)) { | |
| return "Invalid 'mode' parameter. Must be 'text' or 'regex'"; | |
| } | |
| return null; | |
| }, | |
| read_editor_lines: (args) => { | |
| if (!args.startLine || typeof args.startLine !== "number") { | |
| return "Missing or invalid 'startLine' parameter"; | |
| } | |
| if (args.startLine < 1) { | |
| return "'startLine' must be >= 1"; | |
| } | |
| if (args.endLine && typeof args.endLine !== "number") { | |
| return "Invalid 'endLine' parameter"; | |
| } | |
| return null; | |
| }, | |
| plan_tasks: (args) => { | |
| if (!args.tasks || !Array.isArray(args.tasks)) { | |
| return "Missing or invalid 'tasks' parameter. Must be an array."; | |
| } | |
| if (args.tasks.length === 0) { | |
| return "'tasks' array cannot be empty"; | |
| } | |
| return null; | |
| }, | |
| update_task: (args) => { | |
| if (typeof args.taskId !== "number") { | |
| return "Missing or invalid 'taskId' parameter"; | |
| } | |
| if ( | |
| !args.status || | |
| !["pending", "in_progress", "completed"].includes( | |
| args.status as string, | |
| ) | |
| ) { | |
| return "Invalid 'status' parameter. Must be 'pending', 'in_progress', or 'completed'"; | |
| } | |
| return null; | |
| }, | |
| }; | |
| const validator = toolValidations[call.name]; | |
| if (validator) { | |
| const error = validator(call.args); | |
| if (error) { | |
| return { valid: false, error }; | |
| } | |
| } | |
| return { valid: true }; | |
| } | |
| async processMessage( | |
| message: string, | |
| messageHistory: BaseMessage[] = [], | |
| onStream?: (chunk: string) => void, | |
| messageId?: string, | |
| abortSignal?: AbortSignal, | |
| ): Promise<string> { | |
| if (!this.client) { | |
| throw new Error("Agent not initialized"); | |
| } | |
| const stream = await this.graph.stream( | |
| { | |
| messages: [...messageHistory, new HumanMessage(message)], | |
| hasToolCalls: false, | |
| }, | |
| { | |
| streamMode: ["custom", "updates"] as const, | |
| metadata: { messageId, abortSignal }, | |
| }, | |
| ); | |
| let fullResponse = ""; | |
| for await (const chunk of stream) { | |
| if (abortSignal?.aborted) { | |
| throw new Error("AbortError"); | |
| } | |
| if (Array.isArray(chunk)) { | |
| const [mode, data] = chunk; | |
| if (mode === "custom" && data?.type === "token") { | |
| fullResponse += data.content; | |
| onStream?.(data.content); | |
| } | |
| } | |
| } | |
| return fullResponse; | |
| } | |
| } | |