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:
Thomas Marchand
2025-12-18 17:34:41 +00:00
parent c2cbf70f10
commit e2a06ed393
7 changed files with 304 additions and 569 deletions

View File

@@ -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 */,

View File

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

View File

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

View File

@@ -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: ![alt](url)
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)
}
}
}

View File

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

View File

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

View File

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