This commit is contained in:
Thomas Marchand
2025-12-22 08:49:48 +00:00
parent 2f62842ecd
commit ba8bf6a4b0
22 changed files with 837 additions and 109 deletions

View File

@@ -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

View File

@@ -16,3 +16,4 @@ export function ConsoleWrapper() {

View File

@@ -123,3 +123,4 @@ export function ConfirmDialog({

View File

@@ -57,3 +57,4 @@ export function CopyButton({ text, className, label = 'Copied!', showOnHover = t

View File

@@ -53,3 +53,4 @@ export function RelativeTime({ date, className }: RelativeTimeProps) {

View File

@@ -89,3 +89,4 @@ export function ShimmerText({ lines = 3, className }: ShimmerProps & { lines?: n

View File

@@ -58,3 +58,4 @@ export function getRuntimeTaskDefaults(): { model?: string; budget_cents?: numbe

View File

@@ -40,3 +40,4 @@ export function formatRelativeTime(date: Date): string {

View File

@@ -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

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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"
}
}

View File

@@ -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<Void, Never>?
@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
}
}

View File

@@ -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)
)
}
}

View File

@@ -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/
```

View File

@@ -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<TerminalReason>,
}
/// 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!({

View File

@@ -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;

View File

@@ -171,6 +171,7 @@ impl Agent for SimpleAgent {
"agent": "SimpleAgent",
"execution": result.data,
})),
terminal_reason: result.terminal_reason,
}
}
}

View File

@@ -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<serde_json::Value>,
/// 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<TerminalReason>,
}
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.

View File

@@ -1196,10 +1196,6 @@ async fn control_actor_loop(
let mut queue: VecDeque<(Uuid, String, Option<String>)> = 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<tokio::task::JoinHandle<(Uuid, String, crate::agents::AgentResult)>> =
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, &current_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::<String>())),
});
// 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::<String>())),
});
}
} 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(),

View File

@@ -131,3 +131,4 @@ struct EmbeddingUsage {

View File

@@ -634,3 +634,4 @@ Note: GitHub code search requires authentication. Set GH_TOKEN env var."
}