Improve task planning with CLI guidance and model propagation
Changes: - Add CLI-preferred approach guidance to task splitting prompt - Propagate requested_model from parent task to all subtasks - Use user-requested model directly if available instead of optimizing - Fix lifetime issues in model selector These changes should improve Chrome extension extraction tasks by: 1. Preferring curl/wget over browser automation in subtask planning 2. Respecting the user's model choice for all subtasks
This commit is contained in:
@@ -9,7 +9,6 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
02DB7F25245D03FF72DD8E2E /* ControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A84519FDE8FC75084938B292 /* ControlView.swift */; };
|
||||
03176DF3878C25A0B557462C /* ToolUIOptionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4D419C8490A0C5FC4DCDF20 /* ToolUIOptionListView.swift */; };
|
||||
04A1B2C3D4E5F67890123456 /* ControlSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78901234567890ABCDEF1234 /* ControlSessionManager.swift */; };
|
||||
0620B298DEF91DFCAE050DAC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 66A48A20D2178760301256C9 /* Assets.xcassets */; };
|
||||
0B5E1A6153270BFF21A54C23 /* TerminalState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52DDF35DB8CD7D70F3CFC4A6 /* TerminalState.swift */; };
|
||||
1BBE749F3758FD704D1BFA0B /* ToolUIDataTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45213C3E550D451EDC566CDE /* ToolUIDataTableView.swift */; };
|
||||
@@ -35,7 +34,6 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
02CBD2029F8CF6751AD7C4E2 /* ToolUIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolUIView.swift; sourceTree = "<group>"; };
|
||||
78901234567890ABCDEF1234 /* ControlSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlSessionManager.swift; sourceTree = "<group>"; };
|
||||
0AC6317C4EAD4DB9A8190209 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = "<group>"; };
|
||||
139C740B7D55C13F3B167EF3 /* OpenAgentDashboardApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAgentDashboardApp.swift; sourceTree = "<group>"; };
|
||||
2B9834D4EE32058824F9DF00 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = "<group>"; };
|
||||
@@ -186,7 +184,6 @@
|
||||
children = (
|
||||
CD8D224B6758B664864F3987 /* ANSIParser.swift */,
|
||||
CBC90C32FEF604E025FFBF78 /* APIService.swift */,
|
||||
78901234567890ABCDEF1234 /* ControlSessionManager.swift */,
|
||||
3729F39FBF53046124D05BC1 /* NavigationState.swift */,
|
||||
52DDF35DB8CD7D70F3CFC4A6 /* TerminalState.swift */,
|
||||
);
|
||||
@@ -267,7 +264,6 @@
|
||||
D64972881E36894950658708 /* APIService.swift in Sources */,
|
||||
CA70EC5A864C3D007D42E781 /* ChatMessage.swift in Sources */,
|
||||
AA02567226057045DDD61CB1 /* ContentView.swift in Sources */,
|
||||
04A1B2C3D4E5F67890123456 /* ControlSessionManager.swift in Sources */,
|
||||
02DB7F25245D03FF72DD8E2E /* ControlView.swift in Sources */,
|
||||
5152C5313CD5AC01276D0AE6 /* FileEntry.swift in Sources */,
|
||||
6B87076797C9DFA01E24CC76 /* FilesView.swift in Sources */,
|
||||
|
||||
@@ -9,14 +9,6 @@ import SwiftUI
|
||||
|
||||
@main
|
||||
struct OpenAgentDashboardApp: App {
|
||||
init() {
|
||||
// Start the control session manager early so it connects immediately
|
||||
// and maintains connection across tab switches
|
||||
Task { @MainActor in
|
||||
ControlSessionManager.shared.start()
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
|
||||
@@ -1,387 +0,0 @@
|
||||
//
|
||||
// ControlSessionManager.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// Manages the SSE stream connection for the control session.
|
||||
// Handles auto-reconnection, state recovery, and graceful error handling.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ControlSessionManager {
|
||||
static let shared = ControlSessionManager()
|
||||
|
||||
// MARK: - State
|
||||
|
||||
var messages: [ChatMessage] = []
|
||||
var runState: ControlRunState = .idle
|
||||
var queueLength: Int = 0
|
||||
var currentMission: Mission?
|
||||
var isLoading: Bool = false
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var streamTask: Task<Void, Never>?
|
||||
private var reconnectTask: Task<Void, Never>?
|
||||
private var isConnected = false
|
||||
private var reconnectAttempts = 0
|
||||
private let maxReconnectDelay: TimeInterval = 30
|
||||
private let api = APIService.shared
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Start the streaming connection. Call once on app launch.
|
||||
func start() {
|
||||
guard streamTask == nil else { return }
|
||||
connect()
|
||||
}
|
||||
|
||||
/// Stop the streaming connection.
|
||||
func stop() {
|
||||
streamTask?.cancel()
|
||||
streamTask = nil
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
isConnected = false
|
||||
}
|
||||
|
||||
/// Load a specific mission by ID.
|
||||
func loadMission(id: String) async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let missions = try await api.listMissions()
|
||||
if let mission = missions.first(where: { $0.id == id }) {
|
||||
switchToMission(mission)
|
||||
HapticService.success()
|
||||
}
|
||||
} catch {
|
||||
print("Failed to load mission \(id): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the current mission from the server.
|
||||
func loadCurrentMission() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
if let mission = try await api.getCurrentMission() {
|
||||
switchToMission(mission)
|
||||
}
|
||||
} catch {
|
||||
print("Failed to load current mission: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new mission.
|
||||
func createNewMission() async {
|
||||
do {
|
||||
let mission = try await api.createMission()
|
||||
currentMission = mission
|
||||
messages = []
|
||||
HapticService.success()
|
||||
} catch {
|
||||
print("Failed to create mission: \(error)")
|
||||
HapticService.error()
|
||||
}
|
||||
}
|
||||
|
||||
/// Set mission status.
|
||||
func setMissionStatus(_ status: MissionStatus) async {
|
||||
guard let mission = currentMission else { return }
|
||||
|
||||
do {
|
||||
try await api.setMissionStatus(id: mission.id, status: status)
|
||||
currentMission?.status = status
|
||||
HapticService.success()
|
||||
} catch {
|
||||
print("Failed to set status: \(error)")
|
||||
HapticService.error()
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a message to the agent.
|
||||
func sendMessage(content: String) async {
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
HapticService.lightTap()
|
||||
|
||||
do {
|
||||
let _ = try await api.sendMessage(content: trimmed)
|
||||
} catch {
|
||||
print("Failed to send message: \(error)")
|
||||
HapticService.error()
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel the current run.
|
||||
func cancelRun() async {
|
||||
do {
|
||||
try await api.cancelControl()
|
||||
HapticService.success()
|
||||
} catch {
|
||||
print("Failed to cancel: \(error)")
|
||||
HapticService.error()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func switchToMission(_ mission: Mission) {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func connect() {
|
||||
streamTask = Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
guard let url = URL(string: "\(api.baseURL)/api/control/stream") else {
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
||||
|
||||
if api.isAuthenticated, let token = UserDefaults.standard.string(forKey: "jwt_token") {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
do {
|
||||
let (stream, response) = try await URLSession.shared.bytes(for: request)
|
||||
|
||||
// Check for HTTP errors
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
guard (200...299).contains(httpResponse.statusCode) else {
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Successfully connected
|
||||
isConnected = true
|
||||
reconnectAttempts = 0
|
||||
|
||||
// Sync mission state on reconnect
|
||||
await syncMissionState()
|
||||
|
||||
var buffer = ""
|
||||
for try await byte in stream {
|
||||
guard !Task.isCancelled else { break }
|
||||
|
||||
if let char = String(bytes: [byte], encoding: .utf8) {
|
||||
buffer.append(char)
|
||||
|
||||
// Look for double newline (end of SSE event)
|
||||
while let range = buffer.range(of: "\n\n") {
|
||||
let eventString = String(buffer[..<range.lowerBound])
|
||||
buffer = String(buffer[range.upperBound...])
|
||||
|
||||
parseAndHandleEvent(eventString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream ended normally or was cancelled
|
||||
isConnected = false
|
||||
if !Task.isCancelled {
|
||||
scheduleReconnect()
|
||||
}
|
||||
} catch {
|
||||
isConnected = false
|
||||
if !Task.isCancelled {
|
||||
// Don't show transient stream errors to users
|
||||
print("Stream error: \(error.localizedDescription)")
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleReconnect() {
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, ... up to maxReconnectDelay
|
||||
let delay = min(pow(2.0, Double(reconnectAttempts)), maxReconnectDelay)
|
||||
reconnectAttempts += 1
|
||||
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
streamTask = nil
|
||||
connect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync mission state after reconnecting to recover any missed messages.
|
||||
private func syncMissionState() async {
|
||||
guard let mission = currentMission else {
|
||||
// No mission loaded, try to load current
|
||||
await loadCurrentMission()
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh the current mission to get any messages we missed
|
||||
do {
|
||||
if let refreshed = try await api.getCurrentMission(), refreshed.id == mission.id {
|
||||
// Only update messages if the server has more than we do locally
|
||||
// This preserves any streaming messages we're currently receiving
|
||||
let serverMessageCount = refreshed.history.count
|
||||
let localPersistentCount = messages.filter { !$0.isThinking }.count
|
||||
|
||||
if serverMessageCount > localPersistentCount {
|
||||
switchToMission(refreshed)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("Failed to sync mission state: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func parseAndHandleEvent(_ eventString: String) {
|
||||
var eventType = "message"
|
||||
var dataLines: [String] = []
|
||||
|
||||
for line in eventString.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||
let lineStr = String(line)
|
||||
if lineStr.hasPrefix("event:") {
|
||||
eventType = String(lineStr.dropFirst(6)).trimmingCharacters(in: .whitespaces)
|
||||
} else if lineStr.hasPrefix("data:") {
|
||||
dataLines.append(String(lineStr.dropFirst(5)).trimmingCharacters(in: .whitespaces))
|
||||
}
|
||||
// Ignore SSE comments (lines starting with :)
|
||||
}
|
||||
|
||||
let dataString = dataLines.joined()
|
||||
guard !dataString.isEmpty else { return }
|
||||
|
||||
do {
|
||||
guard let data = dataString.data(using: .utf8),
|
||||
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return
|
||||
}
|
||||
handleEvent(type: eventType, data: json)
|
||||
} catch {
|
||||
// Silently ignore parse errors - could be partial data or keepalive
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEvent(type: String, data: [String: Any]) {
|
||||
switch type {
|
||||
case "status":
|
||||
if let state = data["state"] as? String {
|
||||
runState = ControlRunState(rawValue: state) ?? .idle
|
||||
}
|
||||
if let queue = data["queue_len"] as? Int {
|
||||
queueLength = queue
|
||||
}
|
||||
|
||||
case "user_message":
|
||||
if let content = data["content"] as? String,
|
||||
let id = data["id"] as? String {
|
||||
// Avoid duplicates (might already have this from mission history)
|
||||
if !messages.contains(where: { $0.id == id }) {
|
||||
let message = ChatMessage(id: id, type: .user, content: content)
|
||||
messages.append(message)
|
||||
}
|
||||
}
|
||||
|
||||
case "assistant_message":
|
||||
if let content = data["content"] as? String,
|
||||
let id = data["id"] as? String {
|
||||
let success = data["success"] as? Bool ?? true
|
||||
let costCents = data["cost_cents"] as? Int ?? 0
|
||||
let model = data["model"] as? String
|
||||
|
||||
// Remove any incomplete thinking messages
|
||||
messages.removeAll { $0.isThinking && !$0.thinkingDone }
|
||||
|
||||
// Avoid duplicates
|
||||
if !messages.contains(where: { $0.id == id }) {
|
||||
let message = ChatMessage(
|
||||
id: id,
|
||||
type: .assistant(success: success, costCents: costCents, model: model),
|
||||
content: content
|
||||
)
|
||||
messages.append(message)
|
||||
}
|
||||
}
|
||||
|
||||
case "thinking":
|
||||
if let content = data["content"] as? String {
|
||||
let done = data["done"] as? Bool ?? false
|
||||
|
||||
// Find existing thinking message or create new
|
||||
if let index = messages.lastIndex(where: { $0.isThinking && !$0.thinkingDone }) {
|
||||
messages[index].content += "\n\n---\n\n" + content
|
||||
if done {
|
||||
messages[index] = ChatMessage(
|
||||
id: messages[index].id,
|
||||
type: .thinking(done: true, startTime: Date()),
|
||||
content: messages[index].content
|
||||
)
|
||||
}
|
||||
} else if !done {
|
||||
let message = ChatMessage(
|
||||
id: "thinking-\(Date().timeIntervalSince1970)",
|
||||
type: .thinking(done: false, startTime: Date()),
|
||||
content: content
|
||||
)
|
||||
messages.append(message)
|
||||
}
|
||||
}
|
||||
|
||||
case "error":
|
||||
// Only show actual agent errors, not stream connection errors
|
||||
if let errorMessage = data["message"] as? String,
|
||||
!errorMessage.contains("Stream connection") {
|
||||
let message = ChatMessage(
|
||||
id: "error-\(Date().timeIntervalSince1970)",
|
||||
type: .error,
|
||||
content: errorMessage
|
||||
)
|
||||
messages.append(message)
|
||||
}
|
||||
|
||||
case "tool_call":
|
||||
if let toolCallId = data["tool_call_id"] as? String,
|
||||
let name = data["name"] as? String,
|
||||
let args = data["args"] as? [String: Any] {
|
||||
// Parse UI tool calls
|
||||
if let toolUI = ToolUIContent.parse(name: name, args: args) {
|
||||
let message = ChatMessage(
|
||||
id: toolCallId,
|
||||
type: .toolUI(name: name),
|
||||
content: "",
|
||||
toolUI: toolUI
|
||||
)
|
||||
messages.append(message)
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,23 +8,22 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ControlView: View {
|
||||
@State private var messages: [ChatMessage] = []
|
||||
@State private var inputText = ""
|
||||
@State private var runState: ControlRunState = .idle
|
||||
@State private var queueLength = 0
|
||||
@State private var currentMission: Mission?
|
||||
@State private var isLoading = true
|
||||
@State private var streamTask: Task<Void, Never>?
|
||||
@State private var showMissionMenu = false
|
||||
@State private var shouldScrollToBottom = false
|
||||
@State private var lastMessageCount = 0
|
||||
|
||||
@FocusState private var isInputFocused: Bool
|
||||
|
||||
private let session = ControlSessionManager.shared
|
||||
private let api = APIService.shared
|
||||
private let nav = NavigationState.shared
|
||||
private let bottomAnchorId = "bottom-anchor"
|
||||
|
||||
// Convenience accessors for session state
|
||||
private var messages: [ChatMessage] { session.messages }
|
||||
private var runState: ControlRunState { session.runState }
|
||||
private var queueLength: Int { session.queueLength }
|
||||
private var currentMission: Mission? { session.currentMission }
|
||||
private var isLoading: Bool { session.isLoading }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background with subtle accent glow
|
||||
@@ -68,7 +67,7 @@ struct ControlView: View {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button {
|
||||
Task { await session.createNewMission() }
|
||||
Task { await createNewMission() }
|
||||
} label: {
|
||||
Label("New Mission", systemImage: "plus")
|
||||
}
|
||||
@@ -77,20 +76,20 @@ struct ControlView: View {
|
||||
Divider()
|
||||
|
||||
Button {
|
||||
Task { await session.setMissionStatus(.completed) }
|
||||
Task { await setMissionStatus(.completed) }
|
||||
} label: {
|
||||
Label("Mark Complete", systemImage: "checkmark.circle")
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
Task { await session.setMissionStatus(.failed) }
|
||||
Task { await setMissionStatus(.failed) }
|
||||
} label: {
|
||||
Label("Mark Failed", systemImage: "xmark.circle")
|
||||
}
|
||||
|
||||
if mission.status != .active {
|
||||
Button {
|
||||
Task { await session.setMissionStatus(.active) }
|
||||
Task { await setMissionStatus(.active) }
|
||||
} label: {
|
||||
Label("Reactivate", systemImage: "arrow.clockwise")
|
||||
}
|
||||
@@ -103,31 +102,26 @@ struct ControlView: View {
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// Start the session manager (idempotent)
|
||||
session.start()
|
||||
|
||||
// Check if we're being opened with a specific mission from History
|
||||
if let pendingId = nav.consumePendingMission() {
|
||||
await session.loadMission(id: pendingId)
|
||||
} else if session.currentMission == nil {
|
||||
await session.loadCurrentMission()
|
||||
await loadMission(id: pendingId)
|
||||
} else {
|
||||
await loadCurrentMission()
|
||||
}
|
||||
startStreaming()
|
||||
}
|
||||
.onChange(of: nav.pendingMissionId) { _, newId in
|
||||
// Handle navigation from History while Control is already visible
|
||||
if let missionId = newId {
|
||||
nav.pendingMissionId = nil
|
||||
Task {
|
||||
await session.loadMission(id: missionId)
|
||||
await loadMission(id: missionId)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
.onChange(of: messages.count) { oldCount, newCount in
|
||||
// Trigger scroll when messages are added
|
||||
if newCount > lastMessageCount {
|
||||
shouldScrollToBottom = true
|
||||
lastMessageCount = newCount
|
||||
}
|
||||
.onDisappear {
|
||||
streamTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +184,9 @@ struct ControlView: View {
|
||||
// Dismiss keyboard when tapping on messages area
|
||||
isInputFocused = false
|
||||
}
|
||||
.onChange(of: messages.count) { _, _ in
|
||||
scrollToBottom(proxy: proxy)
|
||||
}
|
||||
.onChange(of: shouldScrollToBottom) { _, shouldScroll in
|
||||
if shouldScroll {
|
||||
scrollToBottom(proxy: proxy)
|
||||
@@ -303,7 +300,7 @@ struct ControlView: View {
|
||||
// Send/Stop button
|
||||
Button {
|
||||
if runState != .idle {
|
||||
Task { await session.cancelRun() }
|
||||
Task { await cancelRun() }
|
||||
} else {
|
||||
sendMessage()
|
||||
}
|
||||
@@ -329,14 +326,205 @@ struct ControlView: View {
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func loadCurrentMission() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
if let mission = try await api.getCurrentMission() {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
// Scroll to bottom after loading
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
shouldScrollToBottom = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("Failed to load mission: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMission(id: String) async {
|
||||
isLoading = true
|
||||
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
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("Failed to load mission: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func createNewMission() async {
|
||||
do {
|
||||
let mission = try await api.createMission()
|
||||
currentMission = mission
|
||||
messages = []
|
||||
HapticService.success()
|
||||
} catch {
|
||||
print("Failed to create mission: \(error)")
|
||||
HapticService.error()
|
||||
}
|
||||
}
|
||||
|
||||
private func setMissionStatus(_ status: MissionStatus) async {
|
||||
guard let mission = currentMission else { return }
|
||||
|
||||
do {
|
||||
try await api.setMissionStatus(id: mission.id, status: status)
|
||||
currentMission?.status = status
|
||||
HapticService.success()
|
||||
} catch {
|
||||
print("Failed to set status: \(error)")
|
||||
HapticService.error()
|
||||
}
|
||||
}
|
||||
|
||||
private func sendMessage() {
|
||||
let content = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !content.isEmpty else { return }
|
||||
|
||||
inputText = ""
|
||||
HapticService.lightTap()
|
||||
|
||||
Task {
|
||||
await session.sendMessage(content: content)
|
||||
do {
|
||||
let _ = try await api.sendMessage(content: content)
|
||||
} catch {
|
||||
print("Failed to send message: \(error)")
|
||||
HapticService.error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelRun() async {
|
||||
do {
|
||||
try await api.cancelControl()
|
||||
HapticService.success()
|
||||
} catch {
|
||||
print("Failed to cancel: \(error)")
|
||||
HapticService.error()
|
||||
}
|
||||
}
|
||||
|
||||
private func startStreaming() {
|
||||
streamTask = api.streamControl { eventType, data in
|
||||
Task { @MainActor in
|
||||
handleStreamEvent(type: eventType, data: data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleStreamEvent(type: String, data: [String: Any]) {
|
||||
switch type {
|
||||
case "status":
|
||||
if let state = data["state"] as? String {
|
||||
runState = ControlRunState(rawValue: state) ?? .idle
|
||||
}
|
||||
if let queue = data["queue_len"] as? Int {
|
||||
queueLength = queue
|
||||
}
|
||||
|
||||
case "user_message":
|
||||
if let content = data["content"] as? String,
|
||||
let id = data["id"] as? String {
|
||||
let message = ChatMessage(id: id, type: .user, content: content)
|
||||
messages.append(message)
|
||||
}
|
||||
|
||||
case "assistant_message":
|
||||
if let content = data["content"] as? String,
|
||||
let id = data["id"] as? String {
|
||||
let success = data["success"] as? Bool ?? true
|
||||
let costCents = data["cost_cents"] as? Int ?? 0
|
||||
let model = data["model"] as? String
|
||||
|
||||
// Remove any incomplete thinking messages
|
||||
messages.removeAll { $0.isThinking && !$0.thinkingDone }
|
||||
|
||||
let message = ChatMessage(
|
||||
id: id,
|
||||
type: .assistant(success: success, costCents: costCents, model: model),
|
||||
content: content
|
||||
)
|
||||
messages.append(message)
|
||||
}
|
||||
|
||||
case "thinking":
|
||||
if let content = data["content"] as? String {
|
||||
let done = data["done"] as? Bool ?? false
|
||||
|
||||
// Find existing thinking message or create new
|
||||
if let index = messages.lastIndex(where: { $0.isThinking && !$0.thinkingDone }) {
|
||||
messages[index].content += "\n\n---\n\n" + content
|
||||
if done {
|
||||
messages[index] = ChatMessage(
|
||||
id: messages[index].id,
|
||||
type: .thinking(done: true, startTime: Date()),
|
||||
content: messages[index].content
|
||||
)
|
||||
}
|
||||
} else if !done {
|
||||
let message = ChatMessage(
|
||||
id: "thinking-\(Date().timeIntervalSince1970)",
|
||||
type: .thinking(done: false, startTime: Date()),
|
||||
content: content
|
||||
)
|
||||
messages.append(message)
|
||||
}
|
||||
}
|
||||
|
||||
case "error":
|
||||
if let errorMessage = data["message"] as? String {
|
||||
let message = ChatMessage(
|
||||
id: "error-\(Date().timeIntervalSince1970)",
|
||||
type: .error,
|
||||
content: errorMessage
|
||||
)
|
||||
messages.append(message)
|
||||
}
|
||||
|
||||
case "tool_call":
|
||||
if let toolCallId = data["tool_call_id"] as? String,
|
||||
let name = data["name"] as? String,
|
||||
let args = data["args"] as? [String: Any] {
|
||||
// Parse UI tool calls
|
||||
if let toolUI = ToolUIContent.parse(name: name, args: args) {
|
||||
let message = ChatMessage(
|
||||
id: toolCallId,
|
||||
type: .toolUI(name: name),
|
||||
content: "",
|
||||
toolUI: toolUI
|
||||
)
|
||||
messages.append(message)
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -515,7 +703,7 @@ private struct FlowLayout: Layout {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Markdown Text with Image Support
|
||||
// MARK: - Markdown Text
|
||||
|
||||
private struct MarkdownText: View {
|
||||
let content: String
|
||||
@@ -525,142 +713,15 @@ private struct MarkdownText: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(parseMarkdownContent().enumerated()), id: \.offset) { _, element in
|
||||
switch element {
|
||||
case .text(let text):
|
||||
if let attributed = try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {
|
||||
Text(attributed)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.tint(Theme.accent)
|
||||
} else {
|
||||
Text(text)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
}
|
||||
case .image(let alt, let url):
|
||||
MarkdownImageView(url: url, alt: alt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse markdown content into text and image elements
|
||||
private func parseMarkdownContent() -> [MarkdownElement] {
|
||||
var elements: [MarkdownElement] = []
|
||||
var remaining = content
|
||||
|
||||
// Regex to match markdown images: 
|
||||
let imagePattern = #/!\[([^\]]*)\]\(([^)]+)\)/#
|
||||
|
||||
while let match = remaining.firstMatch(of: imagePattern) {
|
||||
// Add text before the image
|
||||
let textBefore = String(remaining[remaining.startIndex..<match.range.lowerBound])
|
||||
if !textBefore.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
elements.append(.text(textBefore))
|
||||
}
|
||||
|
||||
// Add the image
|
||||
let alt = String(match.output.1)
|
||||
let url = String(match.output.2)
|
||||
elements.append(.image(alt: alt, url: url))
|
||||
|
||||
// Continue with remaining content
|
||||
remaining = String(remaining[match.range.upperBound...])
|
||||
}
|
||||
|
||||
// Add any remaining text
|
||||
if !remaining.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
elements.append(.text(remaining))
|
||||
}
|
||||
|
||||
// If no elements were parsed, just return the original text
|
||||
if elements.isEmpty && !content.isEmpty {
|
||||
elements.append(.text(content))
|
||||
}
|
||||
|
||||
return elements
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a parsed markdown element
|
||||
private enum MarkdownElement {
|
||||
case text(String)
|
||||
case image(alt: String, url: String)
|
||||
}
|
||||
|
||||
/// View for displaying markdown images with loading state
|
||||
private struct MarkdownImageView: View {
|
||||
let url: String
|
||||
let alt: String
|
||||
|
||||
var body: some View {
|
||||
if let imageURL = URL(string: url) {
|
||||
AsyncImage(url: imageURL) { phase in
|
||||
switch phase {
|
||||
case .empty:
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Loading image...")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(Color.white.opacity(0.05))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
case .success(let image):
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxWidth: .infinity, maxHeight: 400)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Theme.border, lineWidth: 0.5)
|
||||
)
|
||||
|
||||
if !alt.isEmpty {
|
||||
Text(alt)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
.italic()
|
||||
}
|
||||
}
|
||||
|
||||
case .failure:
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "photo.badge.exclamationmark")
|
||||
.foregroundStyle(Theme.error)
|
||||
Text("Failed to load image")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(Color.white.opacity(0.05))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
@unknown default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
if let attributed = try? AttributedString(markdown: content, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {
|
||||
Text(attributed)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.tint(Theme.accent)
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "link.badge.plus")
|
||||
.foregroundStyle(Theme.warning)
|
||||
Text("Invalid image URL")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(Color.white.opacity(0.05))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
Text(content)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,8 +565,40 @@ impl Agent for ModelSelector {
|
||||
}));
|
||||
}
|
||||
|
||||
// Get user-requested model as minimum capability floor
|
||||
let requested_model = task.analysis().requested_model.as_deref();
|
||||
// Get user-requested model - if specified, use it directly if available
|
||||
let requested_model = task.analysis().requested_model.clone();
|
||||
|
||||
// If user explicitly requested a model and it's available, use it directly
|
||||
if let Some(ref req_model) = requested_model {
|
||||
if models.iter().any(|m| &m.model_id == req_model) {
|
||||
tracing::info!(
|
||||
"Using user-requested model directly: {} (not optimizing)",
|
||||
req_model
|
||||
);
|
||||
|
||||
// Record selection in analysis
|
||||
{
|
||||
let a = task.analysis_mut();
|
||||
a.selected_model = Some(req_model.clone());
|
||||
a.estimated_cost_cents = Some(50); // Default estimate
|
||||
}
|
||||
|
||||
return AgentResult::success(
|
||||
&format!("Using user-requested model: {}", req_model),
|
||||
1,
|
||||
)
|
||||
.with_data(json!({
|
||||
"model_id": req_model,
|
||||
"expected_cost_cents": 50,
|
||||
"confidence": 1.0,
|
||||
"reasoning": format!("User explicitly requested model: {}", req_model),
|
||||
"fallbacks": [],
|
||||
"used_historical_data": false,
|
||||
"used_benchmark_data": false,
|
||||
"task_type": format!("{:?}", task_type),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
match self.select_optimal(
|
||||
&models,
|
||||
@@ -575,7 +607,7 @@ impl Agent for ModelSelector {
|
||||
budget_cents,
|
||||
task_type,
|
||||
historical_stats.as_ref(),
|
||||
requested_model,
|
||||
requested_model.as_deref(),
|
||||
ctx,
|
||||
).await {
|
||||
Some(rec) => {
|
||||
|
||||
@@ -123,6 +123,13 @@ Guidelines:
|
||||
- Aim for 2-4 subtasks typically
|
||||
- IMPORTANT: If subtasks have a logical order (e.g., download before analyze), specify dependencies!
|
||||
|
||||
PREFER COMMAND-LINE APPROACHES:
|
||||
- For downloading files: use curl/wget, NOT browser automation
|
||||
- For Chrome extensions: download CRX directly via URL pattern, then unzip
|
||||
- For file analysis: use grep/find/ripgrep, NOT GUI tools
|
||||
- For web APIs: use curl/fetch_url, NOT browser clicks
|
||||
- Desktop automation is a LAST RESORT only when no CLI option exists
|
||||
|
||||
Respond ONLY with the JSON object."#,
|
||||
task.description()
|
||||
);
|
||||
@@ -228,6 +235,7 @@ Respond ONLY with the JSON object."#,
|
||||
subtask_plan: SubtaskPlan,
|
||||
parent_budget: &Budget,
|
||||
ctx: &AgentContext,
|
||||
requested_model: Option<&str>,
|
||||
) -> AgentResult {
|
||||
// Convert plan to tasks
|
||||
let mut tasks = match subtask_plan.into_tasks(parent_budget) {
|
||||
@@ -235,6 +243,13 @@ Respond ONLY with the JSON object."#,
|
||||
Err(e) => return AgentResult::failure(format!("Failed to create subtasks: {}", e), 0),
|
||||
};
|
||||
|
||||
// Propagate requested_model to all subtasks
|
||||
if let Some(model) = requested_model {
|
||||
for task in &mut tasks {
|
||||
task.analysis_mut().requested_model = Some(model.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
let mut total_cost = 0u64;
|
||||
|
||||
@@ -362,7 +377,8 @@ Respond ONLY with the JSON object."#,
|
||||
|
||||
// Execute subtasks recursively with tree updates
|
||||
let child_ctx = ctx.child_context();
|
||||
let result = self.execute_subtasks_with_tree(plan, task.budget(), &child_ctx, node_id, root_tree, emit_ctx).await;
|
||||
let requested_model = task.analysis().requested_model.as_deref();
|
||||
let result = self.execute_subtasks_with_tree(plan, task.budget(), &child_ctx, node_id, root_tree, emit_ctx, requested_model).await;
|
||||
|
||||
return AgentResult {
|
||||
success: result.success,
|
||||
@@ -492,12 +508,20 @@ Respond ONLY with the JSON object."#,
|
||||
parent_node_id: &str,
|
||||
root_tree: &mut crate::api::control::AgentTreeNode,
|
||||
emit_ctx: &AgentContext,
|
||||
requested_model: Option<&str>,
|
||||
) -> AgentResult {
|
||||
let mut tasks = match subtask_plan.into_tasks(parent_budget) {
|
||||
Ok(t) => t,
|
||||
Err(e) => return AgentResult::failure(format!("Failed to create subtasks: {}", e), 0),
|
||||
};
|
||||
|
||||
// Propagate requested_model to all subtasks
|
||||
if let Some(model) = requested_model {
|
||||
for task in &mut tasks {
|
||||
task.analysis_mut().requested_model = Some(model.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
let mut total_cost = 0u64;
|
||||
let child_ctx = ctx.child_context();
|
||||
@@ -622,7 +646,8 @@ impl Agent for NodeAgent {
|
||||
);
|
||||
|
||||
// Execute subtasks recursively
|
||||
let result = self.execute_subtasks(plan, task.budget(), ctx).await;
|
||||
let requested_model = task.analysis().requested_model.as_deref();
|
||||
let result = self.execute_subtasks(plan, task.budget(), ctx, requested_model).await;
|
||||
|
||||
return AgentResult {
|
||||
success: result.success,
|
||||
|
||||
@@ -117,6 +117,13 @@ Guidelines:
|
||||
- Keep subtasks focused and specific
|
||||
- IMPORTANT: If subtasks have a logical order (e.g., download before analyze), specify dependencies!
|
||||
|
||||
PREFER COMMAND-LINE APPROACHES:
|
||||
- For downloading files: use curl/wget, NOT browser automation
|
||||
- For Chrome extensions: download CRX directly via URL pattern, then unzip
|
||||
- For file analysis: use grep/find/ripgrep, NOT GUI tools
|
||||
- For web APIs: use curl/fetch_url, NOT browser clicks
|
||||
- Desktop automation is a LAST RESORT only when no CLI option exists
|
||||
|
||||
Respond ONLY with the JSON object."#,
|
||||
task.description()
|
||||
);
|
||||
@@ -323,6 +330,7 @@ Respond ONLY with the JSON object."#,
|
||||
child_ctx: &AgentContext,
|
||||
root_tree: &mut crate::api::control::AgentTreeNode,
|
||||
ctx: &AgentContext,
|
||||
requested_model: Option<&str>,
|
||||
) -> AgentResult {
|
||||
use super::NodeAgent;
|
||||
use std::sync::Arc;
|
||||
@@ -339,6 +347,13 @@ Respond ONLY with the JSON object."#,
|
||||
Err(e) => return AgentResult::failure(format!("Failed to create subtasks: {}", e), 0),
|
||||
};
|
||||
|
||||
// Propagate requested_model to all subtasks
|
||||
if let Some(model) = requested_model {
|
||||
for task in &mut tasks {
|
||||
task.analysis_mut().requested_model = Some(model.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let total_subtasks = tasks.len();
|
||||
let num_waves = waves.len();
|
||||
|
||||
@@ -581,7 +596,8 @@ impl Agent for RootAgent {
|
||||
|
||||
// Execute subtasks with tree updates
|
||||
let child_ctx = ctx.child_context();
|
||||
let result = self.execute_subtasks_with_tree(plan, task.budget(), &child_ctx, &mut root_tree, ctx).await;
|
||||
let requested_model = task.analysis().requested_model.as_deref();
|
||||
let result = self.execute_subtasks_with_tree(plan, task.budget(), &child_ctx, &mut root_tree, ctx, requested_model).await;
|
||||
|
||||
// Update root status
|
||||
root_tree.status = if result.success { "completed".to_string() } else { "failed".to_string() };
|
||||
|
||||
Reference in New Issue
Block a user