cleanup
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -16,3 +16,4 @@ export function ConsoleWrapper() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -123,3 +123,4 @@ export function ConfirmDialog({
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -57,3 +57,4 @@ export function CopyButton({ text, className, label = 'Copied!', showOnHover = t
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -53,3 +53,4 @@ export function RelativeTime({ date, className }: RelativeTimeProps) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -89,3 +89,4 @@ export function ShimmerText({ lines = 3, className }: ShimmerProps & { lines?: n
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -58,3 +58,4 @@ export function getRuntimeTaskDefaults(): { model?: string; budget_cents?: numbe
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -40,3 +40,4 @@ export function formatRelativeTime(date: Date): string {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/
|
||||
```
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -171,6 +171,7 @@ impl Agent for SimpleAgent {
|
||||
"agent": "SimpleAgent",
|
||||
"execution": result.data,
|
||||
})),
|
||||
terminal_reason: result.terminal_reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, ¤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::<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(),
|
||||
|
||||
@@ -131,3 +131,4 @@ struct EmbeddingUsage {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -634,3 +634,4 @@ Note: GitHub code search requires authentication. Set GH_TOKEN env var."
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user