c118996746
- SwiftUI macOS app with C++17 code analysis engine (ObjC++ bridge) - Agentic AI loop: LLM plans → tool calls → execution → feedback loop - 15 agent tools: file ops, terminal, git, xcode build, code intel - 7 persistent terminal tools with background session management - Chat sidebar with agent step rendering and auto-apply - NVIDIA NIM API integration (Llama 3.3 70B default) - OpenAI tool_calls format with prompt-based fallback - Code editor with syntax highlighting and multi-tab support - File tree, console view, terminal view - Git integration and workspace management
1446 lines
64 KiB
Swift
1446 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)
|
|
|
|
// 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 }
|
|
}
|