From ba8bf6a4b0d5e9a3b2fbabf9f29366bb8590db09 Mon Sep 17 00:00:00 2001 From: Thomas Marchand Date: Mon, 22 Dec 2025 08:49:48 +0000 Subject: [PATCH] cleanup --- .cursor/rules/project.mdc | 51 +++- dashboard/src/app/console/console-wrapper.tsx | 1 + .../src/components/ui/confirm-dialog.tsx | 1 + dashboard/src/components/ui/copy-button.tsx | 1 + dashboard/src/components/ui/relative-time.tsx | 1 + dashboard/src/components/ui/shimmer.tsx | 1 + dashboard/src/lib/settings.ts | 1 + dashboard/src/lib/utils.ts | 1 + .../OpenAgentDashboard/Models/Mission.swift | 110 +++++++- .../Services/APIService.swift | 26 ++ .../Views/Components/RunningMissionsBar.swift | 267 ++++++++++++++++++ .../Views/Components/StatusBadge.swift | 8 + .../Views/Control/ControlView.swift | 256 +++++++++++++++-- .../Views/History/HistoryView.swift | 34 ++- ios_dashboard/README.md | 25 +- src/agents/leaf/executor.rs | 25 +- src/agents/mod.rs | 2 +- src/agents/simple.rs | 1 + src/agents/types.rs | 32 +++ src/api/control.rs | 100 +++---- src/memory/embed.rs | 1 + src/tools/github.rs | 1 + 22 files changed, 837 insertions(+), 109 deletions(-) create mode 100644 ios_dashboard/OpenAgentDashboard/Views/Components/RunningMissionsBar.swift diff --git a/.cursor/rules/project.mdc b/.cursor/rules/project.mdc index f3f3c15..50d8dea 100644 --- a/.cursor/rules/project.mdc +++ b/.cursor/rules/project.mdc @@ -1,5 +1,5 @@ --- -description: Core Open Agent architecture - hierarchical agent system with full machine access +description: Core Open Agent architecture - SimpleAgent system with full machine access alwaysApply: true --- @@ -13,26 +13,30 @@ Minimal autonomous coding agent in Rust with **full machine access** (not sandbo |-----------|----------|---------| | Backend (Rust) | `src/` | HTTP API + agent system | | Dashboard (Next.js) | `dashboard/` | Web UI (Bun, not npm) | +| iOS Dashboard | `ios_dashboard/` | Native iOS app (Swift/SwiftUI) | | MCP configs | `.open_agent/mcp/config.json` | Model Context Protocol servers | | Tuning | `.open_agent/tuning.json` | Calibration data | ## Architecture ``` -RootAgent (orchestrator) -├── ComplexityEstimator (leaf) → estimates task difficulty 0-1 -├── ModelSelector (leaf) → U-curve cost optimization -├── TaskExecutor (leaf) → runs tools in a loop -└── Verifier (leaf) → hybrid programmatic + LLM verification +SimpleAgent + └── TaskExecutor → runs tools in a loop with auto-upgrade ``` +The agent system was simplified from a complex hierarchical orchestrator to a single `SimpleAgent` that: +- Automatically upgrades outdated model names via `ModelResolver` +- Uses `TaskExecutor` for tool-based execution +- Supports model overrides per mission/message +- Handles parallel mission execution + ### Module Map ``` src/ -├── agents/ # Hierarchical agent system -│ ├── orchestrator/ # RootAgent, NodeAgent -│ └── leaf/ # ComplexityEstimator, ModelSelector, TaskExecutor, Verifier +├── agents/ # Agent system +│ ├── simple.rs # SimpleAgent (main entry point) +│ └── leaf/ # TaskExecutor ├── budget/ # Cost tracking, pricing, smart retry │ ├── benchmarks.rs # Model capability scores from llm-stats.com │ ├── pricing.rs # OpenRouter pricing + model allowlist @@ -79,19 +83,44 @@ src/ | Method | Path | Purpose | |--------|------|---------| | `POST` | `/api/control/message` | Send message to agent | +| `POST` | `/api/control/tool_result` | Submit frontend tool result | | `GET` | `/api/control/stream` | SSE event stream | | `POST` | `/api/control/cancel` | Cancel current execution | | `GET` | `/api/control/tree` | Get agent tree snapshot (refresh resilience) | | `GET` | `/api/control/progress` | Get execution progress ("Subtask X/Y") | + +### Mission Endpoints + +| Method | Path | Purpose | +|--------|------|---------| | `GET` | `/api/control/missions` | List all missions | -| `POST` | `/api/control/missions` | Create new mission | -| `GET` | `/api/control/missions/current` | Get current mission | +| `POST` | `/api/control/missions` | Create new mission (optional: title, model_override) | +| `GET` | `/api/control/missions/current` | Get current active mission | +| `GET` | `/api/control/missions/:id` | Get specific mission | +| `GET` | `/api/control/missions/:id/tree` | Get mission's agent tree | | `POST` | `/api/control/missions/:id/load` | Switch to mission | +| `POST` | `/api/control/missions/:id/status` | Set mission status | | `POST` | `/api/control/missions/:id/cancel` | Cancel specific mission | +| `POST` | `/api/control/missions/:id/resume` | Resume interrupted mission | | `POST` | `/api/control/missions/:id/parallel` | Start mission in parallel | + +### Parallel Execution Endpoints + +| Method | Path | Purpose | +|--------|------|---------| | `GET` | `/api/control/running` | List running missions | | `GET` | `/api/control/parallel/config` | Get parallel execution config | +### Mission Statuses + +Missions can be in one of these states: +- `active` - Currently being worked on +- `completed` - Successfully finished +- `failed` - Failed with errors +- `interrupted` - Stopped due to server shutdown/cancellation (resumable) +- `blocked` - Blocked by external factors (resumable) +- `not_feasible` - Cannot be completed as specified + ## Model Selection (U-Curve) - **Cheap models**: low token cost, high failure rate, more retries diff --git a/dashboard/src/app/console/console-wrapper.tsx b/dashboard/src/app/console/console-wrapper.tsx index 575a2de..8891f1e 100644 --- a/dashboard/src/app/console/console-wrapper.tsx +++ b/dashboard/src/app/console/console-wrapper.tsx @@ -16,3 +16,4 @@ export function ConsoleWrapper() { + diff --git a/dashboard/src/components/ui/confirm-dialog.tsx b/dashboard/src/components/ui/confirm-dialog.tsx index 2e2f77f..08f8196 100644 --- a/dashboard/src/components/ui/confirm-dialog.tsx +++ b/dashboard/src/components/ui/confirm-dialog.tsx @@ -123,3 +123,4 @@ export function ConfirmDialog({ + diff --git a/dashboard/src/components/ui/copy-button.tsx b/dashboard/src/components/ui/copy-button.tsx index 4af243b..b2d45d3 100644 --- a/dashboard/src/components/ui/copy-button.tsx +++ b/dashboard/src/components/ui/copy-button.tsx @@ -57,3 +57,4 @@ export function CopyButton({ text, className, label = 'Copied!', showOnHover = t + diff --git a/dashboard/src/components/ui/relative-time.tsx b/dashboard/src/components/ui/relative-time.tsx index bed1405..23ff0d0 100644 --- a/dashboard/src/components/ui/relative-time.tsx +++ b/dashboard/src/components/ui/relative-time.tsx @@ -53,3 +53,4 @@ export function RelativeTime({ date, className }: RelativeTimeProps) { + diff --git a/dashboard/src/components/ui/shimmer.tsx b/dashboard/src/components/ui/shimmer.tsx index dabf38b..0362bba 100644 --- a/dashboard/src/components/ui/shimmer.tsx +++ b/dashboard/src/components/ui/shimmer.tsx @@ -89,3 +89,4 @@ export function ShimmerText({ lines = 3, className }: ShimmerProps & { lines?: n + diff --git a/dashboard/src/lib/settings.ts b/dashboard/src/lib/settings.ts index e1b2193..886b1b3 100644 --- a/dashboard/src/lib/settings.ts +++ b/dashboard/src/lib/settings.ts @@ -58,3 +58,4 @@ export function getRuntimeTaskDefaults(): { model?: string; budget_cents?: numbe + diff --git a/dashboard/src/lib/utils.ts b/dashboard/src/lib/utils.ts index 7442303..684f11f 100644 --- a/dashboard/src/lib/utils.ts +++ b/dashboard/src/lib/utils.ts @@ -40,3 +40,4 @@ export function formatRelativeTime(date: Date): string { + diff --git a/ios_dashboard/OpenAgentDashboard/Models/Mission.swift b/ios_dashboard/OpenAgentDashboard/Models/Mission.swift index 07afdf8..b1e19ca 100644 --- a/ios_dashboard/OpenAgentDashboard/Models/Mission.swift +++ b/ios_dashboard/OpenAgentDashboard/Models/Mission.swift @@ -11,14 +11,35 @@ enum MissionStatus: String, Codable, CaseIterable { case active case completed case failed + case interrupted + case blocked + case notFeasible = "not_feasible" var statusType: StatusType { switch self { case .active: return .active case .completed: return .completed case .failed: return .failed + case .interrupted: return .interrupted + case .blocked: return .blocked + case .notFeasible: return .failed } } + + var displayLabel: String { + switch self { + case .active: return "Active" + case .completed: return "Completed" + case .failed: return "Failed" + case .interrupted: return "Interrupted" + case .blocked: return "Blocked" + case .notFeasible: return "Not Feasible" + } + } + + var canResume: Bool { + self == .interrupted || self == .blocked + } } struct MissionHistoryEntry: Codable, Identifiable { @@ -35,9 +56,12 @@ struct Mission: Codable, Identifiable, Hashable { let id: String var status: MissionStatus let title: String? + let modelOverride: String? let history: [MissionHistoryEntry] let createdAt: String let updatedAt: String + let interruptedAt: String? + let resumable: Bool func hash(into hasher: inout Hasher) { hasher.combine(id) @@ -48,9 +72,24 @@ struct Mission: Codable, Identifiable, Hashable { } enum CodingKeys: String, CodingKey { - case id, status, title, history + case id, status, title, history, resumable + case modelOverride = "model_override" case createdAt = "created_at" case updatedAt = "updated_at" + case interruptedAt = "interrupted_at" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + status = try container.decode(MissionStatus.self, forKey: .status) + title = try container.decodeIfPresent(String.self, forKey: .title) + modelOverride = try container.decodeIfPresent(String.self, forKey: .modelOverride) + history = try container.decode([MissionHistoryEntry].self, forKey: .history) + createdAt = try container.decode(String.self, forKey: .createdAt) + updatedAt = try container.decode(String.self, forKey: .updatedAt) + interruptedAt = try container.decodeIfPresent(String.self, forKey: .interruptedAt) + resumable = try container.decodeIfPresent(Bool.self, forKey: .resumable) ?? false } var displayTitle: String { @@ -60,11 +99,20 @@ struct Mission: Codable, Identifiable, Hashable { return "Untitled Mission" } + var displayModel: String? { + guard let model = modelOverride else { return nil } + return model.split(separator: "/").last.map(String.init) + } + var updatedDate: Date? { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return formatter.date(from: updatedAt) ?? ISO8601DateFormatter().date(from: updatedAt) } + + var canResume: Bool { + resumable && status.canResume + } } enum TaskStatus: String, Codable, CaseIterable { @@ -101,6 +149,66 @@ struct TaskState: Codable, Identifiable { } } +// MARK: - Parallel Execution + +struct RunningMissionInfo: Codable, Identifiable { + let missionId: String + let modelOverride: String? + let state: String + let queueLen: Int + let historyLen: Int + let secondsSinceActivity: Int + let expectedDeliverables: Int + + var id: String { missionId } + + enum CodingKeys: String, CodingKey { + case missionId = "mission_id" + case modelOverride = "model_override" + case state + case queueLen = "queue_len" + case historyLen = "history_len" + case secondsSinceActivity = "seconds_since_activity" + case expectedDeliverables = "expected_deliverables" + } + + // Memberwise initializer for previews and testing + init(missionId: String, modelOverride: String?, state: String, queueLen: Int, historyLen: Int, secondsSinceActivity: Int, expectedDeliverables: Int) { + self.missionId = missionId + self.modelOverride = modelOverride + self.state = state + self.queueLen = queueLen + self.historyLen = historyLen + self.secondsSinceActivity = secondsSinceActivity + self.expectedDeliverables = expectedDeliverables + } + + var isRunning: Bool { + state == "running" || state == "waiting_for_tool" + } + + var isStalled: Bool { + isRunning && secondsSinceActivity > 60 + } + + var displayModel: String { + guard let model = modelOverride else { return "Default" } + return model.split(separator: "/").last.map(String.init) ?? model + } +} + +struct ParallelConfig: Codable { + let maxParallelMissions: Int + let runningCount: Int + + enum CodingKeys: String, CodingKey { + case maxParallelMissions = "max_parallel_missions" + case runningCount = "running_count" + } +} + +// MARK: - Runs + struct Run: Codable, Identifiable { let id: String let createdAt: String diff --git a/ios_dashboard/OpenAgentDashboard/Services/APIService.swift b/ios_dashboard/OpenAgentDashboard/Services/APIService.swift index c45d870..21ce426 100644 --- a/ios_dashboard/OpenAgentDashboard/Services/APIService.swift +++ b/ios_dashboard/OpenAgentDashboard/Services/APIService.swift @@ -98,6 +98,32 @@ final class APIService { let _: EmptyResponse = try await post("/api/control/missions/\(id)/status", body: StatusRequest(status: status.rawValue)) } + func resumeMission(id: String) async throws -> Mission { + try await post("/api/control/missions/\(id)/resume", body: EmptyBody()) + } + + func cancelMission(id: String) async throws { + let _: EmptyResponse = try await post("/api/control/missions/\(id)/cancel", body: EmptyBody()) + } + + // MARK: - Parallel Missions + + func getRunningMissions() async throws -> [RunningMissionInfo] { + try await get("/api/control/running") + } + + func startMissionParallel(id: String, content: String, model: String? = nil) async throws { + struct ParallelRequest: Encodable { + let content: String + let model: String? + } + let _: EmptyResponse = try await post("/api/control/missions/\(id)/parallel", body: ParallelRequest(content: content, model: model)) + } + + func getParallelConfig() async throws -> ParallelConfig { + try await get("/api/control/parallel/config") + } + // MARK: - Control func sendMessage(content: String) async throws -> (id: String, queued: Bool) { diff --git a/ios_dashboard/OpenAgentDashboard/Views/Components/RunningMissionsBar.swift b/ios_dashboard/OpenAgentDashboard/Views/Components/RunningMissionsBar.swift new file mode 100644 index 0000000..082355f --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/Views/Components/RunningMissionsBar.swift @@ -0,0 +1,267 @@ +// +// RunningMissionsBar.swift +// OpenAgentDashboard +// +// Compact horizontal bar showing currently running missions +// Allows switching between parallel missions +// + +import SwiftUI + +struct RunningMissionsBar: View { + let runningMissions: [RunningMissionInfo] + let currentMission: Mission? + let viewingMissionId: String? + let onSelectMission: (String) -> Void + let onCancelMission: (String) -> Void + let onRefresh: () -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + // Header with refresh button + headerView + + // Current mission if not in running list + if let mission = currentMission, + !runningMissions.contains(where: { $0.missionId == mission.id }) { + currentMissionChip(mission) + } + + // Running missions + ForEach(runningMissions) { mission in + runningMissionChip(mission) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + .background(.ultraThinMaterial) + } + + // MARK: - Header + + private var headerView: some View { + HStack(spacing: 6) { + Image(systemName: "square.stack.3d.up") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Theme.textTertiary) + + Text("Running") + .font(.caption.weight(.medium)) + .foregroundStyle(Theme.textTertiary) + + Text("(\(runningMissions.count))") + .font(.caption) + .foregroundStyle(Theme.textMuted) + + Button(action: onRefresh) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(Theme.textMuted) + } + .padding(4) + .contentShape(Rectangle()) + } + } + + // MARK: - Current Mission Chip + + private func currentMissionChip(_ mission: Mission) -> some View { + let isViewing = viewingMissionId == mission.id + + return Button { + onSelectMission(mission.id) + } label: { + HStack(spacing: 6) { + // Status dot + Circle() + .fill(Theme.success) + .frame(width: 6, height: 6) + + // Model name + Text(mission.displayModel ?? "Default") + .font(.caption.weight(.medium)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(1) + + // Mission ID + Text(String(mission.id.prefix(8))) + .font(.system(size: 9).monospaced()) + .foregroundStyle(Theme.textMuted) + + // Selection indicator + if isViewing { + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(Theme.accent) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(isViewing ? Theme.accent.opacity(0.15) : Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(isViewing ? Theme.accent.opacity(0.3) : Theme.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + // MARK: - Running Mission Chip + + private func runningMissionChip(_ mission: RunningMissionInfo) -> some View { + let isViewing = viewingMissionId == mission.missionId + let isStalled = mission.isStalled + let isSeverlyStalled = mission.secondsSinceActivity > 120 + + let borderColor: Color = { + if isViewing { return Theme.accent.opacity(0.3) } + if isSeverlyStalled { return Theme.error.opacity(0.3) } + if isStalled { return Theme.warning.opacity(0.3) } + return Theme.border + }() + + let backgroundColor: Color = { + if isViewing { return Theme.accent.opacity(0.15) } + if isSeverlyStalled { return Theme.error.opacity(0.1) } + if isStalled { return Theme.warning.opacity(0.1) } + return Color.white.opacity(0.05) + }() + + return HStack(spacing: 6) { + // Tap area for selection + Button { + onSelectMission(mission.missionId) + } label: { + HStack(spacing: 6) { + // Status dot with animation + Circle() + .fill(statusColor(for: mission)) + .frame(width: 6, height: 6) + .overlay { + if mission.isRunning && !isStalled { + Circle() + .stroke(statusColor(for: mission).opacity(0.5), lineWidth: 1.5) + .frame(width: 10, height: 10) + .opacity(0.6) + } + } + + // Model name + Text(mission.displayModel) + .font(.caption.weight(.medium)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(1) + + // Mission ID + Text(String(mission.missionId.prefix(8))) + .font(.system(size: 9).monospaced()) + .foregroundStyle(Theme.textMuted) + + // Stalled indicator + if isStalled { + HStack(spacing: 2) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 8)) + Text("\(mission.secondsSinceActivity)s") + .font(.system(size: 9).monospaced()) + } + .foregroundStyle(isSeverlyStalled ? Theme.error : Theme.warning) + } + + // Selection indicator + if isViewing { + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(Theme.accent) + } + } + } + .buttonStyle(.plain) + + // Cancel button + Button { + onCancelMission(mission.missionId) + } label: { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(Theme.textMuted) + .frame(width: 18, height: 18) + .background(Color.white.opacity(0.05)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.leading, 10) + .padding(.trailing, 6) + .padding(.vertical, 6) + .background(backgroundColor) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(borderColor, lineWidth: 1) + ) + } + + // MARK: - Helpers + + private func statusColor(for mission: RunningMissionInfo) -> Color { + if mission.secondsSinceActivity > 120 { + return Theme.error + } else if mission.isStalled { + return Theme.warning + } else if mission.isRunning { + return Theme.success + } else { + return Theme.warning + } + } +} + +// MARK: - Preview + +#Preview { + VStack(spacing: 0) { + RunningMissionsBar( + runningMissions: [ + RunningMissionInfo( + missionId: "abc12345-6789-0000-0000-000000000001", + modelOverride: "deepseek/deepseek-v3.2", + state: "running", + queueLen: 0, + historyLen: 5, + secondsSinceActivity: 15, + expectedDeliverables: 0 + ), + RunningMissionInfo( + missionId: "def12345-6789-0000-0000-000000000002", + modelOverride: "qwen/qwen3-235b", + state: "running", + queueLen: 1, + historyLen: 3, + secondsSinceActivity: 75, + expectedDeliverables: 0 + ), + RunningMissionInfo( + missionId: "ghi12345-6789-0000-0000-000000000003", + modelOverride: nil, + state: "running", + queueLen: 0, + historyLen: 10, + secondsSinceActivity: 150, + expectedDeliverables: 0 + ) + ], + currentMission: nil, + viewingMissionId: "abc12345-6789-0000-0000-000000000001", + onSelectMission: { _ in }, + onCancelMission: { _ in }, + onRefresh: {} + ) + + Spacer() + } + .background(Theme.backgroundPrimary) +} diff --git a/ios_dashboard/OpenAgentDashboard/Views/Components/StatusBadge.swift b/ios_dashboard/OpenAgentDashboard/Views/Components/StatusBadge.swift index e890846..a0e8c22 100644 --- a/ios_dashboard/OpenAgentDashboard/Views/Components/StatusBadge.swift +++ b/ios_dashboard/OpenAgentDashboard/Views/Components/StatusBadge.swift @@ -19,6 +19,8 @@ enum StatusType { case connected case disconnected case connecting + case interrupted + case blocked var color: Color { switch self { @@ -32,6 +34,8 @@ enum StatusType { return Theme.error case .cancelled, .disconnected: return Theme.textTertiary + case .interrupted, .blocked: + return Theme.warning } } @@ -52,6 +56,8 @@ enum StatusType { case .connected: return "Connected" case .disconnected: return "Disconnected" case .connecting: return "Connecting" + case .interrupted: return "Interrupted" + case .blocked: return "Blocked" } } @@ -66,6 +72,8 @@ enum StatusType { case .idle: return "moon.fill" case .connected: return "wifi" case .disconnected: return "wifi.slash" + case .interrupted: return "pause.circle.fill" + case .blocked: return "exclamationmark.triangle.fill" } } diff --git a/ios_dashboard/OpenAgentDashboard/Views/Control/ControlView.swift b/ios_dashboard/OpenAgentDashboard/Views/Control/ControlView.swift index 37aa95d..979ace1 100644 --- a/ios_dashboard/OpenAgentDashboard/Views/Control/ControlView.swift +++ b/ios_dashboard/OpenAgentDashboard/Views/Control/ControlView.swift @@ -21,6 +21,12 @@ struct ControlView: View { @State private var isAtBottom = true @State private var copiedMessageId: String? + // Parallel missions state + @State private var runningMissions: [RunningMissionInfo] = [] + @State private var viewingMissionId: String? + @State private var showRunningMissions = false + @State private var pollingTask: Task? + @FocusState private var isInputFocused: Bool private let api = APIService.shared @@ -36,6 +42,11 @@ struct ControlView: View { backgroundGlows VStack(spacing: 0) { + // Running missions bar (when there are parallel missions) + if showRunningMissions && (!runningMissions.isEmpty || currentMission != nil) { + runningMissionsBar + } + // Messages messagesView @@ -76,6 +87,26 @@ struct ControlView: View { } } + ToolbarItem(placement: .topBarLeading) { + // Running missions toggle + Button { + withAnimation(.easeInOut(duration: 0.2)) { + showRunningMissions.toggle() + } + HapticService.selectionChanged() + } label: { + HStack(spacing: 4) { + Image(systemName: "square.stack.3d.up") + .font(.system(size: 14)) + if !runningMissions.isEmpty { + Text("\(runningMissions.count)") + .font(.caption2.weight(.semibold)) + } + } + .foregroundStyle(showRunningMissions ? Theme.accent : Theme.textSecondary) + } + } + ToolbarItem(placement: .topBarTrailing) { Menu { Button { @@ -87,6 +118,15 @@ struct ControlView: View { if let mission = currentMission { Divider() + // Resume button for interrupted/blocked missions + if mission.canResume { + Button { + Task { await resumeMission() } + } label: { + Label("Resume Mission", systemImage: "play.circle") + } + } + Button { Task { await setMissionStatus(.completed) } } label: { @@ -99,7 +139,7 @@ struct ControlView: View { Label("Mark Failed", systemImage: "xmark.circle") } - if mission.status != .active { + if mission.status != .active && !mission.canResume { Button { Task { await setMissionStatus(.active) } } label: { @@ -117,10 +157,22 @@ struct ControlView: View { // Check if we're being opened with a specific mission from History if let pendingId = nav.consumePendingMission() { await loadMission(id: pendingId) + viewingMissionId = pendingId } else { await loadCurrentMission() + viewingMissionId = currentMission?.id } + + // Fetch initial running missions + await refreshRunningMissions() + + // Auto-show bar if there are multiple running missions + if runningMissions.count > 1 { + showRunningMissions = true + } + startStreaming() + startPollingRunningMissions() } .onChange(of: nav.pendingMissionId) { _, newId in // Handle navigation from History while Control is already visible @@ -128,15 +180,42 @@ struct ControlView: View { nav.pendingMissionId = nil Task { await loadMission(id: missionId) + viewingMissionId = missionId } } - + } + .onChange(of: currentMission?.id) { _, newId in + // Sync viewingMissionId with currentMission when it changes + if viewingMissionId == nil, let id = newId { + viewingMissionId = id + } } .onDisappear { streamTask?.cancel() + pollingTask?.cancel() } } + // MARK: - Running Missions Bar + + private var runningMissionsBar: some View { + RunningMissionsBar( + runningMissions: runningMissions, + currentMission: currentMission, + viewingMissionId: viewingMissionId, + onSelectMission: { missionId in + Task { await switchToMission(id: missionId) } + }, + onCancelMission: { missionId in + Task { await cancelMission(id: missionId) } + }, + onRefresh: { + Task { await refreshRunningMissions() } + } + ) + .transition(.move(edge: .top).combined(with: .opacity)) + } + // MARK: - Background private var backgroundGlows: some View { @@ -448,6 +527,7 @@ struct ControlView: View { do { if let mission = try await api.getCurrentMission() { currentMission = mission + viewingMissionId = mission.id messages = mission.history.enumerated().map { index, entry in ChatMessage( id: "\(mission.id)-\(index)", @@ -471,22 +551,21 @@ struct ControlView: View { defer { isLoading = false } do { - let missions = try await api.listMissions() - if let mission = missions.first(where: { $0.id == id }) { - currentMission = mission - messages = mission.history.enumerated().map { index, entry in - ChatMessage( - id: "\(mission.id)-\(index)", - type: entry.isUser ? .user : .assistant(success: true, costCents: 0, model: nil), - content: entry.content - ) - } - HapticService.success() - - // Scroll to bottom after loading - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - shouldScrollToBottom = true - } + let mission = try await api.getMission(id: id) + currentMission = mission + viewingMissionId = mission.id + messages = mission.history.enumerated().map { index, entry in + ChatMessage( + id: "\(mission.id)-\(index)", + type: entry.isUser ? .user : .assistant(success: true, costCents: 0, model: nil), + content: entry.content + ) + } + HapticService.success() + + // Scroll to bottom after loading + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + shouldScrollToBottom = true } } catch { print("Failed to load mission: \(error)") @@ -497,7 +576,19 @@ struct ControlView: View { do { let mission = try await api.createMission() currentMission = mission + viewingMissionId = mission.id messages = [] + + // Refresh running missions to show the new mission + await refreshRunningMissions() + + // Show the bar when creating new missions + if !showRunningMissions && !runningMissions.isEmpty { + withAnimation(.easeInOut(duration: 0.2)) { + showRunningMissions = true + } + } + HapticService.success() } catch { print("Failed to create mission: \(error)") @@ -518,6 +609,33 @@ struct ControlView: View { } } + private func resumeMission() async { + guard let mission = currentMission, mission.canResume else { return } + + do { + let resumed = try await api.resumeMission(id: mission.id) + currentMission = resumed + viewingMissionId = resumed.id + // Reload messages to get the resume prompt + messages = resumed.history.enumerated().map { index, entry in + ChatMessage( + id: "\(resumed.id)-\(index)", + type: entry.isUser ? .user : .assistant(success: true, costCents: 0, model: nil), + content: entry.content + ) + } + + // Refresh running missions + await refreshRunningMissions() + + HapticService.success() + shouldScrollToBottom = true + } catch { + print("Failed to resume mission: \(error)") + HapticService.error() + } + } + private func sendMessage() { let content = inputText.trimmingCharacters(in: .whitespacesAndNewlines) guard !content.isEmpty else { return } @@ -553,25 +671,109 @@ struct ControlView: View { } } + // MARK: - Parallel Missions + + private func refreshRunningMissions() async { + do { + runningMissions = try await api.getRunningMissions() + } catch { + print("Failed to refresh running missions: \(error)") + } + } + + private func startPollingRunningMissions() { + pollingTask = Task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(3)) + guard !Task.isCancelled else { break } + await refreshRunningMissions() + } + } + } + + private func switchToMission(id: String) async { + guard id != viewingMissionId else { return } + + isLoading = true + defer { isLoading = false } + + do { + // Load the mission from API + let mission = try await api.getMission(id: id) + + // Update state + viewingMissionId = id + + // If this is not a parallel mission, also update currentMission + if runningMissions.contains(where: { $0.missionId == id }) { + // This is a parallel mission - just load its history + messages = mission.history.enumerated().map { index, entry in + ChatMessage( + id: "\(mission.id)-\(index)", + type: entry.isUser ? .user : .assistant(success: true, costCents: 0, model: nil), + content: entry.content + ) + } + } else { + // This is the main mission - load it fully + currentMission = mission + messages = mission.history.enumerated().map { index, entry in + ChatMessage( + id: "\(mission.id)-\(index)", + type: entry.isUser ? .user : .assistant(success: true, costCents: 0, model: nil), + content: entry.content + ) + } + } + + HapticService.selectionChanged() + shouldScrollToBottom = true + } catch { + print("Failed to switch mission: \(error)") + HapticService.error() + } + } + + private func cancelMission(id: String) async { + do { + try await api.cancelMission(id: id) + + // Refresh running missions + await refreshRunningMissions() + + // If we were viewing this mission, switch to current + if viewingMissionId == id { + if let currentId = currentMission?.id { + await switchToMission(id: currentId) + } + } + + HapticService.success() + } catch { + print("Failed to cancel mission: \(error)") + HapticService.error() + } + } + private func handleStreamEvent(type: String, data: [String: Any]) { - // Filter events by mission_id - only show events for the current mission + // Filter events by mission_id - only show events for the mission we're viewing // This prevents cross-mission contamination when parallel missions are running let eventMissionId = data["mission_id"] as? String - let currentMissionId = currentMission?.id + let viewingId = viewingMissionId + let currentId = currentMission?.id // Only allow status events from any mission (for global state) - // All other events must match the current mission + // All other events must match the mission we're viewing if type != "status" { if let eventId = eventMissionId { - // Event has a mission_id - must match current mission - if eventId != currentMissionId { + // Event has a mission_id - must match viewing mission + if eventId != viewingId { return // Skip events from other missions } - } else if currentMissionId != nil { + } else if viewingId != nil && viewingId != currentId { // Event has NO mission_id (from main session) - // This is fine if we're on the current/main mission - // But we can't verify, so allow it for now - // TODO: Backend should always include mission_id + // Skip if we're viewing a different (parallel) mission + return } } diff --git a/ios_dashboard/OpenAgentDashboard/Views/History/HistoryView.swift b/ios_dashboard/OpenAgentDashboard/Views/History/HistoryView.swift index 035aa84..cf88119 100644 --- a/ios_dashboard/OpenAgentDashboard/Views/History/HistoryView.swift +++ b/ios_dashboard/OpenAgentDashboard/Views/History/HistoryView.swift @@ -22,15 +22,17 @@ struct HistoryView: View { enum StatusFilter: String, CaseIterable { case all = "All" case active = "Active" + case interrupted = "Interrupted" case completed = "Completed" case failed = "Failed" - var missionStatus: MissionStatus? { + var missionStatuses: [MissionStatus]? { switch self { case .all: return nil - case .active: return .active - case .completed: return .completed - case .failed: return .failed + case .active: return [.active] + case .interrupted: return [.interrupted, .blocked] + case .completed: return [.completed] + case .failed: return [.failed, .notFeasible] } } } @@ -38,7 +40,7 @@ struct HistoryView: View { private var filteredMissions: [Mission] { missions.filter { mission in // Filter by status - if let statusFilter = selectedFilter.missionStatus, mission.status != statusFilter { + if let statuses = selectedFilter.missionStatuses, !statuses.contains(mission.status) { return false } @@ -257,11 +259,11 @@ private struct MissionRow: View { var body: some View { HStack(spacing: 14) { // Icon - Image(systemName: "target") + Image(systemName: mission.canResume ? "play.circle" : "target") .font(.title3) - .foregroundStyle(Theme.accent) + .foregroundStyle(mission.canResume ? Theme.warning : Theme.accent) .frame(width: 40, height: 40) - .background(Theme.accent.opacity(0.15)) + .background((mission.canResume ? Theme.warning : Theme.accent).opacity(0.15)) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) // Content @@ -274,9 +276,23 @@ private struct MissionRow: View { HStack(spacing: 8) { StatusBadge(status: mission.status.statusType, compact: true) + if mission.canResume { + Text("Resumable") + .font(.caption2.weight(.medium)) + .foregroundStyle(Theme.warning) + } + Text("\(mission.history.count) messages") .font(.caption) .foregroundStyle(Theme.textTertiary) + + if let model = mission.displayModel { + Text("•") + .foregroundStyle(Theme.textMuted) + Text(model) + .font(.caption2.monospaced()) + .foregroundStyle(Theme.textTertiary) + } } } @@ -300,7 +316,7 @@ private struct MissionRow: View { .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(Theme.border, lineWidth: 0.5) + .stroke(mission.canResume ? Theme.warning.opacity(0.3) : Theme.border, lineWidth: mission.canResume ? 1 : 0.5) ) } } diff --git a/ios_dashboard/README.md b/ios_dashboard/README.md index 2155278..02017dd 100644 --- a/ios_dashboard/README.md +++ b/ios_dashboard/README.md @@ -5,10 +5,27 @@ Native iOS dashboard for Open Agent with **Liquid Glass** design language. ## Features - **Control** - Chat interface with the AI agent, real-time streaming -- **History** - View past missions, tasks, and runs +- **History** - View past missions with filtering (active, interrupted, completed, failed) - **Terminal** - SSH console via WebSocket - **Files** - Remote file explorer with upload/download +### Mission Management + +- Create new missions with optional model override +- Resume interrupted or blocked missions +- Mark missions as completed/failed +- View mission status (active, completed, failed, interrupted, blocked, not_feasible) +- Model override display per mission + +### Parallel Missions + +- View all running missions in a compact horizontal bar +- Switch between parallel missions with a single tap +- Real-time status indicators (running, stalled, severely stalled) +- Cancel running missions directly from the bar +- Automatic polling for running mission updates (every 3s) +- SSE event filtering by mission_id to prevent cross-contamination + ## Design System Built with "Quiet Luxury + Liquid Glass" aesthetic: @@ -77,6 +94,12 @@ ios_dashboard/ │ │ ├── Terminal/ # SSH console │ │ ├── Files/ # File explorer │ │ └── Components/ # Reusable UI +│ │ ├── GlassButton.swift +│ │ ├── GlassCard.swift +│ │ ├── StatusBadge.swift +│ │ ├── LoadingView.swift +│ │ ├── RunningMissionsBar.swift # Parallel missions UI +│ │ └── ToolUI/ # Tool UI components │ └── Assets.xcassets/ └── OpenAgentDashboard.xcodeproj/ ``` diff --git a/src/agents/leaf/executor.rs b/src/agents/leaf/executor.rs index 378f287..34fa435 100644 --- a/src/agents/leaf/executor.rs +++ b/src/agents/leaf/executor.rs @@ -19,7 +19,7 @@ use serde_json::json; use std::path::Path; use crate::agents::{ - Agent, AgentContext, AgentId, AgentResult, AgentType, LeafAgent, LeafCapability, + Agent, AgentContext, AgentId, AgentResult, AgentType, LeafAgent, LeafCapability, TerminalReason, }; use crate::api::control::{AgentEvent, ControlRunState}; use crate::budget::ExecutionSignals; @@ -43,6 +43,8 @@ pub struct ExecutionLoopResult { pub signals: ExecutionSignals, /// Whether execution succeeded pub success: bool, + /// Why execution terminated (if not successful completion) + pub terminal_reason: Option, } /// Agent that executes tasks using tools. @@ -817,6 +819,7 @@ If you cannot perform the requested analysis, use `complete_mission(blocked, rea usage, signals, success: false, + terminal_reason: Some(TerminalReason::Cancelled), }; } } @@ -845,6 +848,7 @@ If you cannot perform the requested analysis, use `complete_mission(blocked, rea usage, signals, success: false, + terminal_reason: Some(TerminalReason::BudgetExhausted), }; } @@ -880,6 +884,7 @@ If you cannot perform the requested analysis, use `complete_mission(blocked, rea usage, signals, success: false, + terminal_reason: Some(TerminalReason::LlmError), }; } Err(_timeout) => { @@ -917,6 +922,7 @@ If you cannot perform the requested analysis, use `complete_mission(blocked, rea usage, signals, success: false, + terminal_reason: Some(TerminalReason::Stalled), }; } }; @@ -1032,6 +1038,7 @@ If you cannot perform the requested analysis, use `complete_mission(blocked, rea usage, signals, success: false, + terminal_reason: Some(TerminalReason::InfiniteLoop), }; } @@ -1124,6 +1131,7 @@ If you cannot perform the requested analysis, use `complete_mission(blocked, rea usage, signals, success: false, + terminal_reason: Some(TerminalReason::Cancelled), }; } } @@ -1351,6 +1359,7 @@ If you cannot perform the requested analysis, use `complete_mission(blocked, rea usage, signals, success: true, + terminal_reason: None, }; } } @@ -1410,6 +1419,7 @@ If you cannot perform the requested analysis, use `complete_mission(blocked, rea usage, signals, success: false, + terminal_reason: Some(TerminalReason::Stalled), }; } @@ -1458,6 +1468,7 @@ If you cannot perform the requested analysis, use `complete_mission(blocked, rea usage, signals, success: true, + terminal_reason: None, }; } @@ -1484,6 +1495,7 @@ If you cannot perform the requested analysis, use `complete_mission(blocked, rea usage, signals, success: false, + terminal_reason: Some(TerminalReason::LlmError), }; } @@ -1509,6 +1521,7 @@ If you cannot perform the requested analysis, use `complete_mission(blocked, rea usage, signals, success: false, + terminal_reason: Some(TerminalReason::MaxIterations), } } } @@ -1572,6 +1585,11 @@ impl Agent for TaskExecutor { AgentResult::failure(&result.output, result.cost_cents) }; + // Propagate terminal reason from execution loop + if let Some(reason) = result.terminal_reason { + agent_result = agent_result.with_terminal_reason(reason); + } + agent_result = agent_result .with_model(model) .with_data(json!({ @@ -1637,6 +1655,11 @@ impl TaskExecutor { AgentResult::failure(&result.output, result.cost_cents) }; + // Propagate terminal reason from execution loop + if let Some(reason) = result.terminal_reason { + agent_result = agent_result.with_terminal_reason(reason); + } + agent_result = agent_result .with_model(model) .with_data(json!({ diff --git a/src/agents/mod.rs b/src/agents/mod.rs index 6fb6671..66004c4 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -27,7 +27,7 @@ mod simple; pub use simple::SimpleAgent; -pub use types::{AgentId, AgentType, AgentResult, AgentError, Complexity}; +pub use types::{AgentId, AgentType, AgentResult, AgentError, Complexity, TerminalReason}; pub use context::AgentContext; pub use tree::{AgentTree, AgentRef}; pub use tuning::TuningParams; diff --git a/src/agents/simple.rs b/src/agents/simple.rs index 0c9dff9..6c55508 100644 --- a/src/agents/simple.rs +++ b/src/agents/simple.rs @@ -171,6 +171,7 @@ impl Agent for SimpleAgent { "agent": "SimpleAgent", "execution": result.data, })), + terminal_reason: result.terminal_reason, } } } diff --git a/src/agents/types.rs b/src/agents/types.rs index 45e2c95..48a50bb 100644 --- a/src/agents/types.rs +++ b/src/agents/types.rs @@ -60,6 +60,25 @@ impl AgentType { } } +/// Reason why agent execution terminated (for non-successful completions). +/// +/// Used to determine whether auto-complete should trigger, avoiding substring matching. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TerminalReason { + /// Agent hit the maximum iteration limit + MaxIterations, + /// Agent was cancelled by user + Cancelled, + /// Budget was exhausted + BudgetExhausted, + /// Agent stalled (no progress, timeouts) + Stalled, + /// Agent got stuck in an infinite loop + InfiniteLoop, + /// LLM API error + LlmError, +} + /// Result of an agent executing a task. /// /// # Invariants @@ -81,6 +100,11 @@ pub struct AgentResult { /// Detailed result data (type-specific) pub data: Option, + + /// If execution ended due to a terminal condition (not normal completion), + /// this indicates why. Used by auto-complete logic to avoid substring matching. + #[serde(skip_serializing_if = "Option::is_none")] + pub terminal_reason: Option, } impl AgentResult { @@ -92,6 +116,7 @@ impl AgentResult { cost_cents, model_used: None, data: None, + terminal_reason: None, } } @@ -103,6 +128,7 @@ impl AgentResult { cost_cents, model_used: None, data: None, + terminal_reason: None, } } @@ -117,6 +143,12 @@ impl AgentResult { self.data = Some(data); self } + + /// Set the terminal reason (why execution ended abnormally). + pub fn with_terminal_reason(mut self, reason: TerminalReason) -> Self { + self.terminal_reason = Some(reason); + self + } } /// Complexity estimation for a task. diff --git a/src/api/control.rs b/src/api/control.rs index 68737bd..b75b341 100644 --- a/src/api/control.rs +++ b/src/api/control.rs @@ -1196,10 +1196,6 @@ async fn control_actor_loop( let mut queue: VecDeque<(Uuid, String, Option)> = VecDeque::new(); let mut history: Vec<(String, String)> = Vec::new(); // (role, content) pairs (user/assistant) let pricing = Arc::new(ModelPricing::new()); - - // Tracks whether complete_mission was explicitly called by the agent for the current mission. - // This prevents auto-complete logic from overwriting an explicit status set by the agent. - let mut explicit_mission_status_set = false; let mut running: Option> = None; @@ -1453,8 +1449,6 @@ async fn control_actor_loop( if mission_id.is_none() { if let Ok(new_mission) = create_new_mission(&memory, model.as_deref()).await { *current_mission.write().await = Some(new_mission.id); - // Reset explicit status flag for new mission - explicit_mission_status_set = false; tracing::info!("Auto-created mission: {} (model: {:?})", new_mission.id, model); } } @@ -1561,8 +1555,6 @@ async fn control_actor_loop( .map(|e| (e.role.clone(), e.content.clone())) .collect(); *current_mission.write().await = Some(id); - // Reset explicit status flag for new mission context - explicit_mission_status_set = false; let _ = respond.send(Ok(mission)); } Err(e) => { @@ -1579,8 +1571,6 @@ async fn control_actor_loop( Ok(mission) => { history.clear(); *current_mission.write().await = Some(mission.id); - // Reset explicit status flag for new mission - explicit_mission_status_set = false; let _ = respond.send(Ok(mission)); } Err(e) => { @@ -1750,9 +1740,7 @@ async fn control_actor_loop( .map(|e| (e.role.clone(), e.content.clone())) .collect(); *current_mission.write().await = Some(mission_id); - // Reset explicit status flag for resumed mission - explicit_mission_status_set = false; - + // Update mission status back to active if let Some(mem) = &memory { let _ = mem.supabase.update_mission_status(mission_id, "active").await; @@ -1934,9 +1922,6 @@ async fn control_actor_loop( summary, }); tracing::info!("Mission {} marked as {} by agent", id, new_status); - - // Mark that an explicit status was set - prevents auto-complete from overwriting - explicit_mission_status_set = true; } } } @@ -1962,56 +1947,55 @@ async fn control_actor_loop( // Persist to mission persist_mission_history(&memory, ¤t_mission, &history).await; - // P1 FIX: Auto-complete mission if agent execution ended without explicit complete_mission call. + // P1 FIX: Auto-complete mission if agent execution ended in a terminal state + // without an explicit complete_mission call. // This prevents missions from staying "active" forever after max iterations, stalls, etc. - // Only auto-complete if: - // 1. There's a current mission - // 2. The execution finished (success or failure) - // 3. The output indicates terminal state (max iterations, stall, budget exhausted) - // 4. The agent did NOT explicitly call complete_mission (explicit_mission_status_set is false) - let has_terminal_output = agent_result.output.contains("Max iterations") || - agent_result.output.contains("Agent stalled") || - agent_result.output.contains("Budget exhausted") || - agent_result.output.contains("infinite loop") || - agent_result.output.contains("Cancelled"); - - // Only auto-complete if no explicit complete_mission was called - let should_auto_complete = has_terminal_output && !explicit_mission_status_set; - - if should_auto_complete { + // + // We use terminal_reason (structured enum) instead of substring matching to avoid + // false positives when agent output legitimately contains words like "infinite loop". + // We also check the current mission status from DB to handle: + // - Explicit complete_mission calls (which update DB status) + // - Parallel missions (each has its own DB status) + if agent_result.terminal_reason.is_some() { if let Some(mem) = &memory { if let Some(mission_id) = current_mission.read().await.clone() { - let status = if agent_result.success { "completed" } else { "failed" }; - tracing::info!( - "Auto-completing mission {} with status '{}' (terminal output detected)", - mission_id, status - ); - if let Err(e) = mem.supabase.update_mission_status(mission_id, status).await { - tracing::warn!("Failed to auto-complete mission: {}", e); - } else { - // Emit status change event - let new_status = if agent_result.success { - MissionStatus::Completed + // Check current mission status from DB - only auto-complete if still "active" + let current_status = mem.supabase.get_mission(mission_id).await + .ok() + .flatten() + .map(|m| m.status); + + if current_status.as_deref() == Some("active") { + let status = if agent_result.success { "completed" } else { "failed" }; + tracing::info!( + "Auto-completing mission {} with status '{}' (terminal_reason: {:?})", + mission_id, status, agent_result.terminal_reason + ); + if let Err(e) = mem.supabase.update_mission_status(mission_id, status).await { + tracing::warn!("Failed to auto-complete mission: {}", e); } else { - MissionStatus::Failed - }; - let _ = events_tx.send(AgentEvent::MissionStatusChanged { - mission_id, - status: new_status, - summary: Some(format!("Auto-completed: {}", - agent_result.output.chars().take(100).collect::())), - }); + // Emit status change event + let new_status = if agent_result.success { + MissionStatus::Completed + } else { + MissionStatus::Failed + }; + let _ = events_tx.send(AgentEvent::MissionStatusChanged { + mission_id, + status: new_status, + summary: Some(format!("Auto-completed: {}", + agent_result.output.chars().take(100).collect::())), + }); + } + } else { + tracing::debug!( + "Skipping auto-complete: mission {} already has status {:?}", + mission_id, current_status + ); } } } - } else if has_terminal_output && explicit_mission_status_set { - tracing::debug!( - "Skipping auto-complete: mission status was already set explicitly via complete_mission" - ); } - - // Reset the flag after processing this execution - explicit_mission_status_set = false; let _ = events_tx.send(AgentEvent::AssistantMessage { id: Uuid::new_v4(), diff --git a/src/memory/embed.rs b/src/memory/embed.rs index 9e341c8..0a15b68 100644 --- a/src/memory/embed.rs +++ b/src/memory/embed.rs @@ -131,3 +131,4 @@ struct EmbeddingUsage { + diff --git a/src/tools/github.rs b/src/tools/github.rs index 963170e..f82b6bd 100644 --- a/src/tools/github.rs +++ b/src/tools/github.rs @@ -634,3 +634,4 @@ Note: GitHub code search requires authentication. Set GH_TOKEN env var." } +