Files
CxIDE/Services/AgentService.swift
T
cx-git-agent 7e79fe89ca Add WebsiteService for static website generation and local preview
- Implemented WebsiteService to create and manage website projects from templates.
- Added functionality to start and stop a local HTTP server for project previews.
- Defined multiple built-in website templates (blank, landing page, portfolio, blog, documentation, dashboard) with corresponding HTML, CSS, and JS generation methods.
- Introduced WebsiteProject and WebsiteTemplate models to encapsulate project data and template details.
- Included error handling for project creation and template management.
2026-04-21 19:10:12 -05:00

1447 lines
64 KiB
Swift

// AgentService.swift
// CxIDE In-process MCP agent service bridging CxSwiftAgent to the IDE.
//
// Wraps MCPServer + AgentConfig + AgentMemory for zero-latency tool calls
// without requiring an HTTP server. Provides high-level IDE agent actions
// (explain, review, fix, test, diagnose) and a chat-style message interface.
import Foundation
import SwiftUI
@MainActor
final class AgentService: ObservableObject {
// MARK: - Published State
@Published var status: AgentStatus = .idle
@Published var messages: [AgentMessage] = []
@Published var availableTools: [AgentToolInfo] = []
@Published var isInitialized: Bool = false
@Published var sandboxMode: Bool = false
// MARK: - Chat & Model State
@Published var chatSessions: [ChatSession] = []
@Published var activeChatID: UUID?
@Published var selectedProvider: AIProvider = .local
@Published var selectedModel: AIModel = AIProvider.local.models[0]
@Published var chatMode: ChatMode = .agent
/// Callback for when the agent creates/modifies a file EditorViewModel hooks into this
var onFileCreated: ((String) -> Void)?
/// Callback for when the agent runs a terminal command shows in the IDE terminal
var onTerminalCommand: ((String, String) -> Void)? // (command, output)
var activeChat: ChatSession? {
chatSessions.first { $0.id == activeChatID }
}
func newChat() {
let session = ChatSession(model: selectedModel)
chatSessions.insert(session, at: 0)
activeChatID = session.id
}
func selectChat(_ session: ChatSession) {
activeChatID = session.id
}
func deleteChat(_ session: ChatSession) {
chatSessions.removeAll { $0.id == session.id }
if activeChatID == session.id {
activeChatID = chatSessions.first?.id
}
}
func selectProvider(_ provider: AIProvider) {
selectedProvider = provider
if let first = provider.models.first {
selectedModel = first
}
updateLLMConfig()
}
func selectModel(_ model: AIModel) {
selectedModel = model
selectedProvider = model.provider
updateLLMConfig()
}
/// Syncs the selected model/provider into env vars so callLLM picks them up
private func updateLLMConfig() {
setenv("CX_LLM_ENDPOINT", selectedProvider.endpoint, 1)
setenv("CX_LLM_MODEL", selectedModel.id, 1)
}
func sendChatMessage(_ text: String) async {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
// Create a session if needed
if activeChatID == nil { newChat() }
guard var session = chatSessions.first(where: { $0.id == activeChatID }) else { return }
// Add user message
let userMsg = ChatMessage(role: .user, content: trimmed)
session.messages.append(userMsg)
updateSession(session)
status = .thinking
// Insert a streaming placeholder for the assistant response
let streamingMsg = ChatMessage(role: .assistant, content: "", isStreaming: true)
let streamingID = streamingMsg.id
session.messages.append(streamingMsg)
updateSession(session)
// Direct tool invocation bypasses the agentic loop
if trimmed.hasPrefix("/") {
let result = await handleDirectToolCall(trimmed)
finalizeMessage(streamingID: streamingID, result: result, userText: trimmed)
return
}
// Run the agentic loop: LLM plans calls tools loops until done
let result = await agenticLoop(prompt: trimmed, streamingID: streamingID)
// Finalize the message
finalizeMessage(streamingID: streamingID, result: result, userText: trimmed)
}
/// Update the streaming message with final content
private func finalizeMessage(streamingID: UUID, result: ToolCallResult, userText: String) {
var toolResults: [ChatMessage.ToolResult] = []
if userText.hasPrefix("/") {
let toolName = String(userText.dropFirst().split(separator: " ").first ?? "")
toolResults.append(ChatMessage.ToolResult(toolName: toolName, output: result.content, isError: result.isError))
}
if var current = chatSessions.first(where: { $0.id == activeChatID }),
let msgIdx = current.messages.firstIndex(where: { $0.id == streamingID }) {
current.messages[msgIdx].content = result.content
current.messages[msgIdx].toolResults = toolResults
current.messages[msgIdx].isStreaming = false
current.messages[msgIdx].isError = result.isError
current.messages[msgIdx].autoApplied = result.autoApplied
updateSession(current)
// Auto-title from first user message
if current.messages.filter({ $0.role == .user }).count == 1 {
current.title = String(userText.prefix(50))
updateSession(current)
}
}
status = .idle
// Legacy compat
addMessage(.user, userText)
if result.isError {
addMessage(.agent, result.content, isError: true)
} else {
addMessage(.agent, result.content)
}
}
/// Update the streaming placeholder with intermediate content so the user sees progress
private func updateStreamingMessage(id: UUID, content: String) {
if var current = chatSessions.first(where: { $0.id == activeChatID }),
let msgIdx = current.messages.firstIndex(where: { $0.id == id }) {
current.messages[msgIdx].content = content
current.messages[msgIdx].isStreaming = true
updateSession(current)
}
}
// MARK: - Agentic Loop
/// Core agentic loop: sends prompt + tool definitions to LLM, parses tool calls,
/// executes them, feeds results back, and loops until the LLM produces a final answer.
/// This is what makes it work like Copilot's coding agent.
private func agenticLoop(prompt: String, streamingID: UUID) async -> ToolCallResult {
let maxIterations = 12
var conversationMessages = buildAgenticMessages(prompt: prompt)
var allToolResults: [ChatMessage.ToolResult] = []
var filesModified: [String] = []
var progressLog = ""
for iteration in 0..<maxIterations {
// Call LLM with tool definitions
status = iteration == 0 ? .thinking : .executing("step \(iteration + 1)")
let llmResponse = await callAgenticLLM(messages: conversationMessages)
guard let response = llmResponse else {
return ToolCallResult(content: progressLog + "\n\nError: Failed to get LLM response.", isError: true)
}
// Check if LLM wants to call tools
let toolCalls = parseToolCalls(from: response)
if toolCalls.isEmpty {
// LLM is done extract the final text response
let finalText = response["content"] as? String ?? ""
let fullResponse: String
if progressLog.isEmpty {
fullResponse = finalText
} else {
fullResponse = finalText
}
var result = ToolCallResult(content: fullResponse, isError: false)
// Auto-apply any code blocks in the final response
if fullResponse.contains("```") {
let extracted = Self.extractCodeAndFilename(from: fullResponse, prompt: prompt)
if !extracted.code.isEmpty && !filesModified.contains(extracted.filename) {
let writeResult = await callTool(name: "file_write", arguments: [
"path": extracted.filename,
"content": extracted.code
])
if !writeResult.isError {
result.autoApplied = true
filesModified.append(extracted.filename)
onFileCreated?(extracted.filename)
}
} else if !filesModified.isEmpty {
result.autoApplied = true
}
} else if !filesModified.isEmpty {
result.autoApplied = true
}
return result
}
// Execute each tool call and collect results
var toolResultMessages: [[String: Any]] = []
for toolCall in toolCalls {
let toolName = toolCall["name"] as? String ?? ""
let toolArgs = toolCall["arguments"] as? [String: Any] ?? [:]
let toolCallID = toolCall["id"] as? String ?? UUID().uuidString
// Show progress
let stepLabel = "🔧 \(toolName)"
progressLog += progressLog.isEmpty ? stepLabel : "\n\(stepLabel)"
updateStreamingMessage(id: streamingID, content: progressLog + "\n⏳ Running...")
// Execute the tool
let toolResult = await callTool(name: toolName, arguments: toolArgs)
let truncatedOutput = String(toolResult.content.prefix(3000))
allToolResults.append(ChatMessage.ToolResult(
toolName: toolName, output: truncatedOutput, isError: toolResult.isError
))
// Track file modifications
if ["file_write", "file_patch"].contains(toolName), !toolResult.isError {
let filePath = toolArgs["path"] as? String ?? ""
if !filePath.isEmpty {
filesModified.append(filePath)
onFileCreated?(filePath)
}
}
// Forward terminal commands to IDE terminal view
if toolName.hasPrefix("terminal_") {
let cmd = toolArgs["command"] as? String ?? toolArgs["input"] as? String ?? toolName
onTerminalCommand?(cmd, truncatedOutput)
}
// Update progress
let statusEmoji = toolResult.isError ? "" : ""
progressLog = progressLog.replacingOccurrences(of: "⏳ Running...", with: "")
let lastLine = progressLog.components(separatedBy: "\n").last ?? ""
if lastLine.contains(toolName) {
progressLog = progressLog.replacingOccurrences(
of: "🔧 \(toolName)",
with: "\(statusEmoji) \(toolName)"
)
}
updateStreamingMessage(id: streamingID, content: progressLog)
// Build the tool result message for the LLM
toolResultMessages.append([
"role": "tool",
"tool_call_id": toolCallID,
"content": truncatedOutput
])
}
// Add the assistant message (with tool_calls) and tool results to conversation
conversationMessages.append(response)
conversationMessages.append(contentsOf: toolResultMessages)
// Small delay to avoid rate limiting
try? await Task.sleep(nanoseconds: 200_000_000)
}
// Exceeded max iterations
return ToolCallResult(
content: progressLog + "\n\n⚠️ Reached maximum steps. Here's what was done so far.",
isError: false,
autoApplied: !filesModified.isEmpty
)
}
/// Build the initial messages array for the agentic loop
private func buildAgenticMessages(prompt: String) -> [[String: Any]] {
let workspaceRoot = config?.workspaceRoot ?? NSHomeDirectory()
let systemPrompt = """
You are CxIDE Agent, an expert AI coding agent embedded in a macOS IDE. \
You have access to tools to read files, write files, edit files, search code, run shell commands, \
manage background processes, and build projects. You MUST use these tools to complete tasks — do NOT just describe what to do.
WORKSPACE: \(workspaceRoot)
TERMINAL TOOLS:
- terminal_exec: Run a command and wait for output (ls, grep, swift build, npm install, etc.)
- terminal_exec_bg: Start a long-running process (servers, watchers) — returns a session ID
- terminal_send: Send input/keystrokes to a background process
- terminal_read: Read recent output from a background process
- terminal_kill: Stop a background process
- terminal_list: List active background sessions
RULES:
1. When asked to create or modify code, USE the tools (file_write, file_patch, file_read) to actually do it.
2. ALWAYS read relevant files first (file_read, file_tree) before editing to understand context.
3. Use file_patch for surgical edits to existing files (search/replace). Use file_write for new files.
4. Use terminal_exec to run commands, install packages, compile, test, etc.
5. Use terminal_exec_bg for servers or long-running tasks. Use terminal_read to check their output.
6. After writing code, verify by running or building (xcode_build, terminal_exec with swift build, etc.).
7. Give a brief summary of what you did at the end.
8. For multi-file changes, handle them one at a time.
9. Write complete, working code — not pseudocode or placeholders.
10. If the user asks a question (not a task), just answer directly without using tools.
"""
// Build conversation history
var messages: [[String: Any]] = [
["role": "system", "content": systemPrompt]
]
// Add recent conversation history
if let session = chatSessions.first(where: { $0.id == activeChatID }) {
let history = session.messages
.filter { $0.role == .user || $0.role == .assistant }
.filter { !$0.isStreaming }
.suffix(16)
.dropLast() // drop the current user message we just added
for msg in history {
messages.append([
"role": msg.role == .user ? "user" : "assistant",
"content": msg.content
])
}
}
messages.append(["role": "user", "content": prompt])
return messages
}
/// Call the LLM with tool definitions so it can decide which tools to invoke
private func callAgenticLLM(messages: [[String: Any]]) async -> [String: Any]? {
let endpoint = ProcessInfo.processInfo.environment["CX_LLM_ENDPOINT"]
?? config?.llmEndpoint ?? ""
let model = ProcessInfo.processInfo.environment["CX_LLM_MODEL"]
?? config?.llmModel ?? ""
let apiKey = ProcessInfo.processInfo.environment["NVIDIA_API_KEY"]
?? config?.nvidiaApiKey ?? ""
guard let url = URL(string: endpoint), !endpoint.isEmpty else { return nil }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 60
if !apiKey.isEmpty {
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
}
// Define tools the LLM can call
let tools: [[String: Any]] = [
toolDef("file_read", "Read a file's contents", [
"path": ["type": "string", "description": "File path relative to workspace"]
], required: ["path"]),
toolDef("file_write", "Create or overwrite a file with content", [
"path": ["type": "string", "description": "File path relative to workspace"],
"content": ["type": "string", "description": "Full file content to write"]
], required: ["path", "content"]),
toolDef("file_patch", "Edit an existing file by replacing exact text", [
"path": ["type": "string", "description": "File path"],
"search": ["type": "string", "description": "Exact text to find (must match exactly)"],
"replace": ["type": "string", "description": "Text to replace it with"]
], required: ["path", "search", "replace"]),
toolDef("file_search", "Search for text across files", [
"pattern": ["type": "string", "description": "Text pattern to search for"],
"path": ["type": "string", "description": "Directory to search (default: .)"],
"include": ["type": "string", "description": "File extension filter, e.g. .swift"]
], required: ["pattern"]),
toolDef("file_tree", "Show directory tree structure", [
"path": ["type": "string", "description": "Root path (default: .)"],
"depth": ["type": "integer", "description": "Max depth (default: 3)"]
], required: []),
toolDef("file_list", "List files in a directory", [
"path": ["type": "string", "description": "Directory path"]
], required: []),
toolDef("terminal_exec", "Execute a shell command and wait for output. Use for quick commands (ls, cat, grep, swift build, npm install, etc). Max 120s timeout.", [
"command": ["type": "string", "description": "Shell command to execute"],
"cwd": ["type": "string", "description": "Working directory (default: workspace root)"],
"timeout": ["type": "integer", "description": "Timeout seconds (default: 30, max: 120)"]
], required: ["command"]),
toolDef("terminal_exec_bg", "Start a long-running background process (server, watcher, build). Returns session ID for later interaction.", [
"command": ["type": "string", "description": "Command to run in background"],
"label": ["type": "string", "description": "Label for the session (e.g. dev-server)"],
"cwd": ["type": "string", "description": "Working directory"]
], required: ["command"]),
toolDef("terminal_send", "Send input to a running background process.", [
"id": ["type": "string", "description": "Session ID from terminal_exec_bg"],
"input": ["type": "string", "description": "Text to send"]
], required: ["id", "input"]),
toolDef("terminal_read", "Read recent output from a background process.", [
"id": ["type": "string", "description": "Session ID"],
"lines": ["type": "integer", "description": "Lines to read (default: 50)"]
], required: ["id"]),
toolDef("terminal_kill", "Stop a background process.", [
"id": ["type": "string", "description": "Session ID to kill"]
], required: ["id"]),
toolDef("terminal_list", "List all active background terminal sessions.", [:], required: []),
toolDef("xcode_build", "Build the Xcode project", [
"scheme": ["type": "string", "description": "Xcode scheme name"],
"project": ["type": "string", "description": "Project path"]
], required: []),
toolDef("git_status", "Show git status", [:], required: []),
toolDef("git_diff", "Show git diff", [:], required: []),
]
let body: [String: Any] = [
"model": model,
"messages": messages,
"tools": tools,
"tool_choice": "auto",
"temperature": 0.1,
"max_tokens": 4096,
]
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else { return nil }
if httpResponse.statusCode != 200 {
// If tool_choice not supported, fall back to non-tool LLM call
let responseBody = String(data: data, encoding: .utf8) ?? ""
if responseBody.contains("tool") || httpResponse.statusCode == 400 {
return await fallbackLLMCall(messages: messages)
}
return nil
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let choices = json["choices"] as? [[String: Any]],
let message = choices.first?["message"] as? [String: Any] else {
return nil
}
return message
} catch {
return nil
}
}
/// Fallback for LLMs that don't support tool_choice use prompt-based tool calling
private func fallbackLLMCall(messages: [[String: Any]]) async -> [String: Any]? {
let endpoint = ProcessInfo.processInfo.environment["CX_LLM_ENDPOINT"]
?? config?.llmEndpoint ?? ""
let model = ProcessInfo.processInfo.environment["CX_LLM_MODEL"]
?? config?.llmModel ?? ""
let apiKey = ProcessInfo.processInfo.environment["NVIDIA_API_KEY"]
?? config?.nvidiaApiKey ?? ""
guard let url = URL(string: endpoint) else { return nil }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 60
if !apiKey.isEmpty {
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
}
// Add tool-calling instructions to system prompt
var modifiedMessages = messages
if var sysMsg = modifiedMessages.first, sysMsg["role"] as? String == "system" {
let existingContent = sysMsg["content"] as? String ?? ""
sysMsg["content"] = existingContent + """
\nWhen you need to use a tool, respond with a JSON block in this exact format:
```tool_call
{"name": "tool_name", "arguments": {"arg1": "value1"}}
```
You can include multiple tool_call blocks. After tool results are provided, continue your work.
Available tools: file_read, file_write, file_patch, file_search, file_tree, file_list, \
terminal_exec, terminal_exec_bg, terminal_send, terminal_read, terminal_kill, terminal_list, \
xcode_build, git_status, git_diff.
"""
modifiedMessages[0] = sysMsg
}
let body: [String: Any] = [
"model": model,
"messages": modifiedMessages,
"temperature": 0.1,
"max_tokens": 4096,
]
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { return nil }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let choices = json["choices"] as? [[String: Any]],
let message = choices.first?["message"] as? [String: Any] else {
return nil
}
// Parse prompt-based tool calls from the content
let content = message["content"] as? String ?? ""
let promptToolCalls = parsePromptBasedToolCalls(from: content)
if !promptToolCalls.isEmpty {
// Convert to standard tool_calls format
let toolCallsFormatted: [[String: Any]] = promptToolCalls.map { tc in
let argsString: String
if let argsData = try? JSONSerialization.data(withJSONObject: tc["arguments"] as Any),
let s = String(data: argsData, encoding: .utf8) {
argsString = s
} else {
argsString = "{}"
}
return [
"id": tc["id"] as? String ?? UUID().uuidString,
"type": "function",
"function": [
"name": tc["name"] as Any,
"arguments": argsString
] as [String: Any]
] as [String: Any]
}
// Strip tool_call blocks from content for clean display
var cleanContent = content
let toolCallPattern = "```tool_call\\s*\\n[\\s\\S]*?```"
if let regex = try? NSRegularExpression(pattern: toolCallPattern) {
cleanContent = regex.stringByReplacingMatches(
in: content, range: NSRange(content.startIndex..., in: content), withTemplate: ""
).trimmingCharacters(in: .whitespacesAndNewlines)
}
return [
"role": "assistant",
"content": cleanContent.isEmpty ? NSNull() : cleanContent,
"tool_calls": toolCallsFormatted
] as [String: Any]
}
return message
} catch {
return nil
}
}
/// Parse tool calls from the LLM response (OpenAI tool_calls format)
private func parseToolCalls(from message: [String: Any]) -> [[String: Any]] {
// Standard OpenAI tool_calls format
guard let toolCalls = message["tool_calls"] as? [[String: Any]] else {
// Try prompt-based parsing from content
if let content = message["content"] as? String, content.contains("```tool_call") {
return parsePromptBasedToolCalls(from: content)
}
return []
}
return toolCalls.compactMap { tc in
guard let function = tc["function"] as? [String: Any],
let name = function["name"] as? String else { return nil }
let id = tc["id"] as? String ?? UUID().uuidString
// Parse arguments may be a string (JSON) or already a dict
var arguments: [String: Any] = [:]
if let argsString = function["arguments"] as? String,
let argsData = argsString.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: argsData) as? [String: Any] {
arguments = parsed
} else if let argsDict = function["arguments"] as? [String: Any] {
arguments = argsDict
}
return ["id": id, "name": name, "arguments": arguments] as [String: Any]
}
}
/// Parse tool calls from prompt-based format: ```tool_call\n{...}\n```
private func parsePromptBasedToolCalls(from content: String) -> [[String: Any]] {
var results: [[String: Any]] = []
let parts = content.components(separatedBy: "```tool_call")
for part in parts.dropFirst() {
guard let endIdx = part.range(of: "```") else { continue }
let jsonStr = String(part[part.startIndex..<endIdx.lowerBound])
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let data = jsonStr.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let name = parsed["name"] as? String else { continue }
results.append([
"id": UUID().uuidString,
"name": name,
"arguments": parsed["arguments"] as? [String: Any] ?? [:]
])
}
return results
}
/// Helper to build a tool definition for the LLM
private func toolDef(_ name: String, _ description: String, _ properties: [String: Any], required: [String]) -> [String: Any] {
var schema: [String: Any] = ["type": "object", "properties": properties]
if !required.isEmpty { schema["required"] = required }
return [
"type": "function",
"function": [
"name": name,
"description": description,
"parameters": schema
] as [String: Any]
]
}
private func updateSession(_ session: ChatSession) {
if let idx = chatSessions.firstIndex(where: { $0.id == session.id }) {
chatSessions[idx] = session
}
}
// MARK: - Agent Internals (nonisolated)
private var server: MCPServer?
private var config: AgentConfig?
private var memory: AgentMemory?
// Tool category mapping
private static let toolCategories: [String: String] = [
"file_read": "File Ops", "file_write": "File Ops", "file_patch": "File Ops",
"file_search": "File Ops", "file_list": "File Ops", "file_tree": "File Ops",
"file_info": "File Ops", "file_delete": "File Ops", "file_move": "File Ops",
"file_find": "File Ops",
"code_complete": "Code Intel", "code_explain": "Code Intel", "code_review": "Code Intel",
"code_refactor": "Code Intel", "code_fix": "Code Intel", "code_document": "Code Intel",
"code_test": "Code Intel", "code_security": "Code Intel", "code_symbols": "Code Intel",
"code_diff_explain": "Code Intel",
"git_status": "Git", "git_diff": "Git", "git_log": "Git", "git_commit": "Git",
"git_branch": "Git", "git_blame": "Git", "git_stash": "Git", "git_show": "Git",
"project_detect": "Project", "project_run": "Project", "project_deps": "Project",
"project_config": "Project", "project_create": "Project",
"terminal_exec": "Terminal", "terminal_exec_bg": "Terminal",
"terminal_send": "Terminal", "terminal_read": "Terminal",
"terminal_kill": "Terminal", "terminal_list": "Terminal",
"terminal_env": "Terminal",
"xcode_build": "Xcode", "xcode_test": "Xcode", "xcode_analyze": "Xcode",
"xcode_schemes": "Xcode", "xcode_devices": "Xcode", "xcode_swift_check": "Xcode",
"xcode_clean": "Xcode", "xcode_archive": "Xcode", "xcode_provisioning": "Xcode",
"xcode_swift_format": "Xcode",
"diag_workspace": "Diagnostics", "diag_lint": "Diagnostics", "diag_todo": "Diagnostics",
"diag_duplicates": "Diagnostics", "diag_complexity": "Diagnostics",
"autopilot_plan": "AutoPilot", "autopilot_execute": "AutoPilot",
"autopilot_memory": "AutoPilot", "autopilot_context": "AutoPilot",
"autopilot_diff": "AutoPilot", "autopilot_status": "AutoPilot",
]
private static let readOnlyTools: Set<String> = [
"file_read", "file_search", "file_list", "file_tree", "file_info", "file_find",
"code_explain", "code_review", "code_symbols", "code_diff_explain",
"git_status", "git_diff", "git_log", "git_blame", "git_show",
"project_detect", "project_deps", "project_config",
"terminal_env", "terminal_read", "terminal_list",
"xcode_schemes", "xcode_devices", "xcode_provisioning",
"diag_workspace", "diag_lint", "diag_todo", "diag_duplicates", "diag_complexity",
"autopilot_memory", "autopilot_context", "autopilot_status",
]
// MARK: - Initialization
func initialize(workspacePath: String) {
// Guard against double initialization
if isInitialized {
// Re-initialize with new workspace path
server = nil
config = nil
memory = nil
availableTools = []
}
// Set environment for AgentConfig
setenv("CX_WORKSPACE", workspacePath, 1)
setenv("CX_TRANSPORT", "embedded", 1)
setenv("CX_LOG_LEVEL", "info", 1)
setenv("NVIDIA_API_KEY", "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", 1)
// Point LLM at the selected provider's endpoint
setenv("CX_LLM_ENDPOINT", selectedProvider.endpoint, 1)
setenv("CX_LLM_MODEL", selectedModel.id, 1)
let agentConfig = AgentConfig.fromEnvironment()
let agentMemory = AgentMemory()
let agentServer = MCPServer(config: agentConfig, memory: agentMemory)
// Register all tool modules
FileOpsTools.register(on: agentServer, config: agentConfig, memory: agentMemory)
CodeIntelTools.register(on: agentServer, config: agentConfig, memory: agentMemory)
GitTools.register(on: agentServer, config: agentConfig, memory: agentMemory)
ProjectTools.register(on: agentServer, config: agentConfig, memory: agentMemory)
TerminalTools.register(on: agentServer, config: agentConfig, memory: agentMemory)
XcodeTools.register(on: agentServer, config: agentConfig, memory: agentMemory)
DiagnosticsTools.register(on: agentServer, config: agentConfig, memory: agentMemory)
AutoPilotTools.register(on: agentServer, config: agentConfig, memory: agentMemory)
WebsiteTools.register(on: agentServer, config: agentConfig, memory: agentMemory)
// Register workspace root
agentServer.registerRoot(uri: "file://\(workspacePath)", name: "workspace")
self.server = agentServer
self.config = agentConfig
self.memory = agentMemory
self.sandboxMode = agentConfig.sandboxMode
// Build tool catalog
buildToolCatalog()
isInitialized = true
addMessage(.system, "CxSwiftAgent initialized — \(agentServer.toolCount) tools available.")
}
private func buildToolCatalog() {
guard let server else { return }
availableTools = server.toolNames.map { name in
AgentToolInfo(
name: name,
description: toolDescription(for: name),
category: Self.toolCategories[name] ?? "General",
isReadOnly: Self.readOnlyTools.contains(name)
)
}
}
private func toolDescription(for name: String) -> String {
let descriptions: [String: String] = [
"file_read": "Read file contents",
"file_write": "Write content to a file",
"file_patch": "Apply patches to a file",
"file_search": "Search for text in files",
"file_list": "List directory contents",
"file_tree": "Show directory tree",
"file_info": "Get file metadata",
"file_delete": "Delete a file",
"file_move": "Move or rename a file",
"file_find": "Find files by pattern",
"code_explain": "Explain code behavior",
"code_review": "Review code for issues",
"code_complete": "Generate code completions",
"code_refactor": "Suggest refactoring improvements",
"code_fix": "Fix code issues",
"code_document": "Generate documentation",
"code_test": "Generate unit tests",
"code_security": "Security audit",
"code_symbols": "Extract code symbols",
"code_diff_explain": "Explain a diff",
"git_status": "Show Git status",
"git_diff": "Show Git diff",
"git_log": "Show Git history",
"git_commit": "Create a commit",
"git_branch": "Branch operations",
"git_blame": "Show line-by-line blame",
"git_stash": "Stash operations",
"git_show": "Show commit details",
"project_detect": "Detect project type",
"project_run": "Run the project",
"project_deps": "List dependencies",
"project_config": "Show project configuration",
"project_create": "Create a new project",
"terminal_exec": "Execute a shell command",
"terminal_exec_bg": "Start a background process",
"terminal_send": "Send input to a background process",
"terminal_read": "Read background process output",
"terminal_kill": "Stop a background process",
"terminal_list": "List active background sessions",
"terminal_env": "Show environment variables",
"xcode_build": "Build with Xcode",
"xcode_test": "Run Xcode tests",
"xcode_analyze": "Xcode static analysis",
"xcode_schemes": "List Xcode schemes",
"xcode_devices": "List available devices",
"xcode_swift_check": "Swift syntax check",
"xcode_clean": "Clean build folder",
"xcode_archive": "Archive for distribution",
"xcode_provisioning": "Show provisioning info",
"xcode_swift_format": "Format Swift code",
"diag_workspace": "Workspace health diagnostics",
"diag_lint": "Lint code for style issues",
"diag_todo": "Find TODO/FIXME comments",
"diag_duplicates": "Detect duplicate code",
"diag_complexity": "Analyze code complexity",
"autopilot_plan": "Create an execution plan",
"autopilot_execute": "Execute a plan step",
"autopilot_memory": "Query agent memory",
"autopilot_context": "Get current context",
"autopilot_diff": "Show pending changes",
"autopilot_status": "Agent status",
]
return descriptions[name] ?? name
}
// MARK: - Core Tool Calling
func callTool(name: String, arguments: [String: Any] = [:]) async -> ToolCallResult {
guard let server else {
return ToolCallResult(content: "Agent not initialized.", isError: true)
}
status = .executing(name)
// Build JSON-RPC request
let rpcRequest: [String: Any] = [
"jsonrpc": "2.0",
"method": "tools/call",
"params": [
"name": name,
"arguments": arguments,
] as [String: Any],
"id": UUID().uuidString,
]
let jsonString = JSON.serialize(rpcRequest)
let response = await server.handleJsonRpc(jsonString)
status = .idle
guard let response,
let result = response["result"] as? [String: Any] else {
let errMsg = (response?["error"] as? [String: Any])?["message"] as? String ?? "Unknown error"
return ToolCallResult(content: errMsg, isError: true)
}
let isError = result["isError"] as? Bool ?? false
let contentBlocks = result["content"] as? [[String: Any]] ?? []
let textParts = contentBlocks.compactMap { $0["text"] as? String }
let combinedText = textParts.joined(separator: "\n")
return ToolCallResult(content: combinedText, isError: isError)
}
struct ToolCallResult {
let content: String
let isError: Bool
var autoApplied: Bool = false
}
// MARK: - Chat Interface
func sendMessage(_ text: String) async {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
addMessage(.user, trimmed)
status = .thinking
// Route to appropriate tool based on message intent
let result = await routeMessage(trimmed)
if result.isError {
addMessage(.agent, result.content, isError: true)
} else {
addMessage(.agent, result.content)
}
status = .idle
}
private func routeMessage(_ text: String) async -> ToolCallResult {
let lower = text.lowercased()
// Direct tool invocation: "/tool_name arg1 arg2"
if lower.hasPrefix("/") {
return await handleDirectToolCall(text)
}
// Intent-based routing
if lower.contains("list files") || lower.contains("show files") || lower.contains("ls") {
return await callTool(name: "file_list", arguments: ["path": "."])
}
if lower.contains("file tree") || lower.contains("project tree") || lower.contains("directory tree") {
return await callTool(name: "file_tree", arguments: ["path": ".", "depth": 3])
}
if lower.contains("git status") || lower.contains("repo status") {
return await callTool(name: "git_status")
}
if lower.contains("git log") || lower.contains("commit history") {
return await callTool(name: "git_log", arguments: ["count": 10])
}
if lower.contains("git diff") {
return await callTool(name: "git_diff")
}
if lower.contains("diagnos") || lower.contains("workspace health") {
return await callTool(name: "diag_workspace", arguments: ["path": "."])
}
if lower.contains("lint") || lower.contains("style check") {
return await callTool(name: "diag_lint", arguments: ["path": "."])
}
if lower.contains("todo") || lower.contains("fixme") {
return await callTool(name: "diag_todo", arguments: ["path": "."])
}
if lower.contains("duplicate") {
return await callTool(name: "diag_duplicates", arguments: ["path": "."])
}
if lower.contains("complexity") {
return await callTool(name: "diag_complexity", arguments: ["path": "."])
}
if lower.contains("schemes") || lower.contains("xcode scheme") {
return await callTool(name: "xcode_schemes")
}
if lower.contains("devices") {
return await callTool(name: "xcode_devices")
}
if lower.contains("detect project") || lower.contains("project type") {
return await callTool(name: "project_detect", arguments: ["path": "."])
}
if lower.contains("dependencies") || lower.contains("deps") {
return await callTool(name: "project_deps", arguments: ["path": "."])
}
if lower.contains("environment") || lower.contains("env var") {
return await callTool(name: "terminal_env")
}
if lower.starts(with: "read ") || lower.starts(with: "show ") || lower.starts(with: "cat ") {
let path = extractPath(from: text)
return await callTool(name: "file_read", arguments: ["path": path])
}
if lower.starts(with: "find ") || lower.starts(with: "search ") {
let query = String(text.dropFirst(lower.starts(with: "find ") ? 5 : 7))
return await callTool(name: "file_search", arguments: ["query": query, "path": "."])
}
if lower.contains("build") && lower.contains("xcode") {
return await callTool(name: "xcode_build")
}
if lower.contains("test") && lower.contains("xcode") {
return await callTool(name: "xcode_test")
}
if lower.contains("clean") {
return await callTool(name: "xcode_clean")
}
// Create/generate file: "create a swift file for X", "make a model for Y", "generate a view"
if lower.contains("create") || lower.contains("make") || lower.contains("generate") || lower.contains("new file") || lower.contains("write a") || lower.contains("add a") {
// If it looks like a code generation request, use the agentic flow
let codeKeywords = ["file", "class", "struct", "enum", "model", "view", "controller",
"service", "protocol", "extension", "function", "func", "module",
"component", "screen", "page", "widget", "helper", "util", "manager",
"swift", "python", "javascript", "typescript", "html", "css"]
if codeKeywords.contains(where: { lower.contains($0) }) {
return await agenticCreateFile(prompt: text)
}
}
// Direct file write: "create file <path> with content ..."
if lower.starts(with: "create file ") || lower.starts(with: "new file ") {
let rest = String(text.dropFirst(lower.starts(with: "create") ? 12 : 9))
let parts = rest.components(separatedBy: " with content ")
let path = parts[0].trimmingCharacters(in: .whitespaces)
let content = parts.count > 1 ? parts[1] : ""
return await callTool(name: "file_write", arguments: ["path": path, "content": content])
}
// Edit/write file: "write to <path> ..."
if lower.starts(with: "write to ") || lower.starts(with: "edit ") {
let prefix = lower.starts(with: "write to ") ? 9 : 5
let rest = String(text.dropFirst(prefix))
let parts = rest.components(separatedBy: " content ")
let path = parts[0].trimmingCharacters(in: .whitespaces)
let content = parts.count > 1 ? parts[1] : ""
return await callTool(name: "file_write", arguments: ["path": path, "content": content])
}
// Run project
if lower.contains("run project") || lower.contains("run the project") || lower == "run" {
return await callTool(name: "project_run", arguments: ["path": "."])
}
// Build
if lower.contains("build") || lower.contains("compile") {
return await callTool(name: "xcode_build")
}
// Shell command: "run command <cmd>" or "exec <cmd>"
if lower.starts(with: "run command ") || lower.starts(with: "exec ") || lower.starts(with: "shell ") {
let prefix = lower.starts(with: "run command ") ? 12 : (lower.starts(with: "exec ") ? 5 : 6)
let cmd = String(text.dropFirst(prefix))
return await callTool(name: "terminal_exec", arguments: ["command": cmd])
}
// Delete file
if lower.starts(with: "delete ") || lower.starts(with: "remove ") {
let path = extractPath(from: String(text.dropFirst(7)))
return await callTool(name: "file_delete", arguments: ["path": path])
}
// Move/rename file
if lower.starts(with: "move ") || lower.starts(with: "rename ") {
let rest = String(text.dropFirst(lower.starts(with: "move ") ? 5 : 7))
let parts = rest.components(separatedBy: " to ")
if parts.count == 2 {
return await callTool(name: "file_move", arguments: [
"source": parts[0].trimmingCharacters(in: .whitespaces),
"destination": parts[1].trimmingCharacters(in: .whitespaces)
])
}
}
// Git commit
if lower.starts(with: "commit ") {
let msg = String(text.dropFirst(7))
return await callTool(name: "git_commit", arguments: ["message": msg])
}
// Archive
if lower.contains("archive") {
return await callTool(name: "xcode_archive")
}
// Format
if lower.contains("format") {
return await callTool(name: "xcode_swift_format")
}
if lower.contains("available tools") || lower.contains("list tools") || lower == "help" {
return listToolsResult()
}
// Code analysis intents (natural language) include history for continuity
let history = buildConversationHistory()
if lower.contains("explain") || lower.contains("what does") || lower.contains("how does") || lower.contains("what is") {
return await callTool(name: "code_explain", arguments: ["code": text, "context": "user query", "history": history])
}
if lower.contains("review") || lower.contains("check my") || lower.contains("look at") || lower.contains("any issues") {
return await callTool(name: "code_review", arguments: ["code": text, "context": "user query", "history": history])
}
if lower.contains("fix") || lower.contains("error") || lower.contains("bug") || lower.contains("wrong") || lower.contains("broken") {
return await callTool(name: "code_fix", arguments: ["code": text, "context": "user query", "history": history])
}
if lower.contains("test") || lower.contains("unit test") || lower.contains("generate test") {
return await callTool(name: "code_test", arguments: ["code": text, "context": "user query", "history": history])
}
if lower.contains("security") || lower.contains("vulnerab") || lower.contains("audit") {
return await callTool(name: "code_security", arguments: ["code": text, "context": "user query", "history": history])
}
if lower.contains("refactor") || lower.contains("improve") || lower.contains("optimize") || lower.contains("better") {
return await callTool(name: "code_refactor", arguments: ["code": text, "context": "user query", "history": history])
}
if lower.contains("document") || lower.contains("docstring") || lower.contains("comment") {
return await callTool(name: "code_document", arguments: ["code": text, "context": "user query", "history": history])
}
if lower.contains("symbol") || lower.contains("function") || lower.contains("class") || lower.contains("struct") {
return await callTool(name: "code_symbols", arguments: ["path": "."])
}
if lower.contains("status") {
return await callTool(name: "git_status")
}
if lower.contains("analyze") {
return await callTool(name: "xcode_analyze")
}
// Conversational / general queries use LLM with proper coding assistant prompt
return await callTool(name: "code_explain", arguments: [
"code": text,
"context": "general_assistant",
"history": history
])
}
/// Builds a JSON-encoded conversation history from the active chat session
/// Includes up to the last 20 messages for context, excluding the current message
private func buildConversationHistory() -> String {
guard let session = chatSessions.first(where: { $0.id == activeChatID }) else {
return "[]"
}
// Get recent messages (skip the last user message since it's the current one)
let relevantMessages = session.messages
.filter { $0.role == .user || $0.role == .assistant }
.filter { !$0.isStreaming }
.suffix(20)
.dropLast() // drop the current user message
let history: [[String: String]] = relevantMessages.map { msg in
[
"role": msg.role == .user ? "user" : "assistant",
"content": msg.content
]
}
guard let data = try? JSONSerialization.data(withJSONObject: history),
let json = String(data: data, encoding: .utf8) else {
return "[]"
}
return json
}
// MARK: - Agentic Flows
/// Multi-step: ask LLM to generate code, then write it to a file
private func agenticCreateFile(prompt: String) async -> ToolCallResult {
// Step 1: Ask LLM to generate the code with a filename
let genResult = await callTool(name: "code_complete", arguments: [
"prompt": """
\(prompt)
IMPORTANT: You must respond with ONLY valid source code.
On the VERY FIRST LINE, put a comment with the filename, e.g.: // Filename: Models/UserModel.swift
Then write the complete file contents. Do NOT include markdown fences, explanations, or instructions.
If no path is specified, choose a sensible location based on the project structure (e.g., Models/, Views/, Services/).
""",
"language": "swift"
])
if genResult.isError {
return genResult
}
let generated = genResult.content.trimmingCharacters(in: .whitespacesAndNewlines)
// Step 2: Extract filename from first line
var filename = ""
var code = generated
let lines = generated.components(separatedBy: "\n")
if let firstLine = lines.first {
let lower = firstLine.lowercased()
if lower.contains("filename:") {
// Extract path after "Filename:"
if let range = firstLine.range(of: "Filename:", options: .caseInsensitive) {
filename = String(firstLine[range.upperBound...])
.trimmingCharacters(in: .whitespaces)
.trimmingCharacters(in: CharacterSet(charactersIn: "`\"'"))
}
code = lines.dropFirst().joined(separator: "\n")
} else if lower.contains("// ") && lower.hasSuffix(".swift") {
// Try: "// MyFile.swift"
filename = firstLine
.replacingOccurrences(of: "//", with: "")
.trimmingCharacters(in: .whitespaces)
code = lines.dropFirst().joined(separator: "\n")
}
}
// Strip any markdown code fences the LLM might have added
code = code
.replacingOccurrences(of: "```swift\n", with: "")
.replacingOccurrences(of: "```swift", with: "")
.replacingOccurrences(of: "```\n", with: "")
.replacingOccurrences(of: "```", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
// Fallback filename
if filename.isEmpty {
// Try to extract from class/struct name in code
let codeLines = code.components(separatedBy: "\n")
for line in codeLines {
let trimLine = line.trimmingCharacters(in: .whitespaces)
for keyword in ["class ", "struct ", "enum ", "protocol "] {
if trimLine.hasPrefix(keyword) || trimLine.contains("final \(keyword)") || trimLine.contains("public \(keyword)") {
let afterKeyword: String
if let range = trimLine.range(of: keyword) {
afterKeyword = String(trimLine[range.upperBound...])
} else { continue }
let typeName = afterKeyword
.split(separator: ":").first?
.split(separator: " ").first?
.split(separator: "{").first?
.trimmingCharacters(in: .whitespaces) ?? ""
if !typeName.isEmpty {
filename = "\(typeName).swift"
break
}
}
}
if !filename.isEmpty { break }
}
if filename.isEmpty {
filename = "NewFile.swift"
}
}
// Step 3: Write the file
let writeResult = await callTool(name: "file_write", arguments: [
"path": filename,
"content": code
])
if writeResult.isError {
return writeResult
}
// Step 4: Notify the editor to open the newly created file
onFileCreated?(filename)
// Return both the creation confirmation and the code
return ToolCallResult(
content: "Created **\(filename)** and opened it in the editor.\n\n```swift\n\(code)\n```",
isError: false,
autoApplied: true
)
}
private func handleDirectToolCall(_ text: String) async -> ToolCallResult {
// Parse "/tool_name {json_args}" or "/tool_name"
let stripped = String(text.dropFirst()) // remove "/"
let parts = stripped.split(separator: " ", maxSplits: 1)
let toolName = String(parts[0])
var arguments: [String: Any] = [:]
if parts.count > 1 {
let argString = String(parts[1])
if let parsed = JSON.parseDict(argString) {
arguments = parsed
} else {
// Treat as a simple path argument
arguments = ["path": argString]
}
}
addMessage(.tool, "Calling: \(toolName)", toolName: toolName)
return await callTool(name: toolName, arguments: arguments)
}
private func extractPath(from text: String) -> String {
let words = text.split(separator: " ")
if words.count > 1 {
return words.dropFirst().joined(separator: " ")
}
return "."
}
/// Extract code block and infer filename from LLM response text
static func extractCodeAndFilename(from text: String, prompt: String) -> (code: String, filename: String) {
// Extract first code block
let parts = text.components(separatedBy: "```")
guard parts.count >= 3 else { return ("", "") }
let codeBlock = parts[1]
let lines = codeBlock.components(separatedBy: "\n")
// First line may be the language identifier (e.g., "swift")
let code: String
if lines.count > 1 {
let firstLine = lines[0].trimmingCharacters(in: .whitespaces).lowercased()
if firstLine == "swift" || firstLine == "python" || firstLine == "javascript" ||
firstLine == "typescript" || firstLine == "html" || firstLine == "css" ||
firstLine == "json" || firstLine == "yaml" || firstLine == "c" || firstLine == "cpp" {
code = lines.dropFirst().joined(separator: "\n")
} else {
code = codeBlock
}
} else {
code = codeBlock
}
let trimmedCode = code.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedCode.isEmpty else { return ("", "") }
// Try to find filename from the text before the code block
var filename = ""
// Check if the response mentions a filename like "Created **Foo.swift**" or "// Filename: Foo.swift"
let beforeCode = parts[0]
let filePatterns = ["**", "`"]
for marker in filePatterns {
if let startRange = beforeCode.range(of: marker),
let endRange = beforeCode[startRange.upperBound...].range(of: marker) {
let candidate = String(beforeCode[startRange.upperBound..<endRange.lowerBound])
if candidate.contains(".") && !candidate.contains(" ") && candidate.count < 100 {
filename = candidate
break
}
}
}
// Try to extract from type declarations in the code
if filename.isEmpty {
let codeLines = trimmedCode.components(separatedBy: "\n")
for line in codeLines {
let trimLine = line.trimmingCharacters(in: .whitespaces)
for keyword in ["class ", "struct ", "enum ", "protocol "] {
if trimLine.hasPrefix(keyword) || trimLine.contains("final \(keyword)") ||
trimLine.contains("public \(keyword)") || trimLine.contains("private \(keyword)") {
if let range = trimLine.range(of: keyword) {
let after = String(trimLine[range.upperBound...])
let typeName = after
.split(separator: ":").first?
.split(separator: " ").first?
.split(separator: "{").first?
.split(separator: "<").first?
.trimmingCharacters(in: .whitespaces) ?? ""
if !typeName.isEmpty && typeName.count < 60 {
filename = "\(typeName).swift"
break
}
}
}
}
if !filename.isEmpty { break }
}
}
// Last resort: derive from the prompt
if filename.isEmpty {
let promptWords = prompt.lowercased()
.replacingOccurrences(of: "create", with: "")
.replacingOccurrences(of: "make", with: "")
.replacingOccurrences(of: "generate", with: "")
.replacingOccurrences(of: "write", with: "")
.replacingOccurrences(of: "a ", with: "")
.replacingOccurrences(of: "new ", with: "")
.replacingOccurrences(of: "swift ", with: "")
.replacingOccurrences(of: "file ", with: "")
.replacingOccurrences(of: "for ", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
let name = promptWords.split(separator: " ").first.map(String.init) ?? "Generated"
let capitalized = name.prefix(1).uppercased() + name.dropFirst()
filename = "\(capitalized).swift"
}
return (trimmedCode, filename)
}
private func listToolsResult() -> ToolCallResult {
let grouped = Dictionary(grouping: availableTools) { $0.category }
var output = "**CxSwiftAgent — \(availableTools.count) Tools**\n\n"
for category in grouped.keys.sorted() {
let tools = grouped[category]!
output += "**\(category)** (\(tools.count))\n"
for tool in tools.sorted(by: { $0.name < $1.name }) {
let ro = tool.isReadOnly ? " 🔒" : ""
output += " • `\(tool.name)` — \(tool.description)\(ro)\n"
}
output += "\n"
}
return ToolCallResult(content: output, isError: false)
}
// MARK: - High-Level IDE Actions
func explainCode(_ code: String) async {
addMessage(.user, "Explain this code")
status = .executing("code_explain")
let result = await callTool(name: "code_explain", arguments: ["code": code])
addMessage(.agent, result.content, isError: result.isError)
status = .idle
}
func reviewCode(_ code: String) async {
addMessage(.user, "Review this code")
status = .executing("code_review")
let result = await callTool(name: "code_review", arguments: ["code": code])
addMessage(.agent, result.content, isError: result.isError)
status = .idle
}
func fixCode(_ code: String) async {
addMessage(.user, "Fix issues in this code")
status = .executing("code_fix")
let result = await callTool(name: "code_fix", arguments: ["code": code])
addMessage(.agent, result.content, isError: result.isError)
status = .idle
}
func generateTests(_ code: String) async {
addMessage(.user, "Generate tests for this code")
status = .executing("code_test")
let result = await callTool(name: "code_test", arguments: ["code": code])
addMessage(.agent, result.content, isError: result.isError)
status = .idle
}
func analyzeWorkspace() async {
addMessage(.user, "Analyze workspace health")
status = .executing("diag_workspace")
let result = await callTool(name: "diag_workspace", arguments: ["path": "."])
addMessage(.agent, result.content, isError: result.isError)
status = .idle
}
func runDiagnostics() async {
addMessage(.user, "Run full diagnostics")
status = .thinking
// Run multiple diagnostic tools
var output = "**Full Diagnostics Report**\n\n"
let lintResult = await callTool(name: "diag_lint", arguments: ["path": "."])
output += "**Lint Results**\n\(lintResult.content)\n\n"
let todoResult = await callTool(name: "diag_todo", arguments: ["path": "."])
output += "**TODO/FIXME**\n\(todoResult.content)\n\n"
let complexityResult = await callTool(name: "diag_complexity", arguments: ["path": "."])
output += "**Complexity Analysis**\n\(complexityResult.content)\n\n"
let dupeResult = await callTool(name: "diag_duplicates", arguments: ["path": "."])
output += "**Duplicate Detection**\n\(dupeResult.content)\n"
addMessage(.agent, output)
status = .idle
}
func extractSymbols(_ code: String) async -> String {
let result = await callTool(name: "code_symbols", arguments: ["code": code])
return result.content
}
func securityAudit(_ code: String) async {
addMessage(.user, "Security audit")
status = .executing("code_security")
let result = await callTool(name: "code_security", arguments: ["code": code])
addMessage(.agent, result.content, isError: result.isError)
status = .idle
}
func formatCode(_ code: String) async -> String {
let result = await callTool(name: "xcode_swift_format", arguments: ["code": code])
return result.content
}
// MARK: - Memory
func getContext() async -> String {
guard let memory else { return "No memory available." }
return await memory.generateContext()
}
// MARK: - Helpers
private func addMessage(_ role: AgentMessage.Role, _ content: String,
toolName: String? = nil, isError: Bool = false) {
messages.append(AgentMessage(
role: role, content: content,
toolName: toolName, isStreaming: false, isError: isError
))
// Cap messages
if messages.count > 500 {
messages = Array(messages.suffix(500))
}
}
func clearMessages() {
messages.removeAll()
addMessage(.system, "Chat cleared.")
}
var messageCount: Int { messages.count }
var toolCount: Int { availableTools.count }
}