Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Apps/CLI/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `peekaboo agent --model` now accepts GPT-5.6 Sol, Terra, and Luna (`gpt-5.6` selects Sol) plus Claude Sonnet 5.

### Fixed
- Resuming an agent session without `--model` now preserves its credential-free provider-qualified model selection instead of silently switching to the current default; ambiguous legacy sessions fail closed and require an explicit override.
- Multi-step Ollama agent runs now preserve native tool-call history and recursive schemas, surface streamed server errors, and fail with a resumable saved session when pending tool work exhausts the validated `1...100` step budget.
- Custom-provider models marked `supportsTools: false` now get actionable agent guidance, and `config models-provider --save` preserves existing model capabilities, limits, and parameters in both human and JSON modes.
- OpenRouter, Together, and OpenAI-compatible GPT-5.6 routes now preserve the 372K context/128K output capability profile, omit unsupported temperature, and recognize routing suffixes such as `:online`.
- Adding a macOS application bundle to the Dock now places it with applications instead of mistaking its on-disk directory for a folder.
- Bare `peekaboo paste` now pastes the current clipboard, while payload-only flags without a payload fail validation even when `--restore-delay-ms` explicitly uses its 150ms default; `list apps` also accepts the `app list` visibility flags and emits preferred snake_case keys alongside legacy keys.
Expand Down
3 changes: 2 additions & 1 deletion Apps/CLI/Sources/PeekabooCLI/CLI/PeekabooEntryPoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ func executePeekabooCLI(arguments: [String]) async -> Int32 {
}

private func containsJSONOutputFlag(_ arguments: [String]) -> Bool {
arguments.contains("--json") || arguments.contains("-j") || arguments.contains("--json-output")
arguments.contains("--json") || arguments.contains("-j") || arguments.contains("--json-output") ||
arguments.contains("--jsonOutput")
}

private func commanderErrorMessage(_ error: CommanderProgramError) -> String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import PeekabooAgentRuntime
final class AgentChatEventDelegate: AgentEventDelegate {
private weak var ui: AgentChatUI?
private var lastToolArguments: [String: [String: Any]] = [:]
private(set) var hasReceivedError = false

init(ui: AgentChatUI) {
self.ui = ui
Expand All @@ -28,6 +29,7 @@ final class AgentChatEventDelegate: AgentEventDelegate {
case .verificationCompleted, .desktopContextRefreshed:
break
case let .error(message):
self.hasReceivedError = true
ui.showError(message)
case .completed:
ui.finishStreaming()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,23 @@ struct AgentChatLaunchContext {
let listSessions: Bool
let normalizedTaskInput: String?
let capabilities: TerminalCapabilities
let hasSessionResumption: Bool

init(
chatFlag: Bool,
hasTaskInput: Bool,
listSessions: Bool,
normalizedTaskInput: String?,
capabilities: TerminalCapabilities,
hasSessionResumption: Bool = false
) {
self.chatFlag = chatFlag
self.hasTaskInput = hasTaskInput
self.listSessions = listSessions
self.normalizedTaskInput = normalizedTaskInput
self.capabilities = capabilities
self.hasSessionResumption = hasSessionResumption
}
}

/// Determines how the agent should launch chat mode based on flags and terminal context.
Expand All @@ -31,6 +48,10 @@ struct AgentChatLaunchPolicy {
return .none
}

if context.hasSessionResumption {
return .interactive(initialPrompt: nil)
}

if context.capabilities.isInteractive && !context.capabilities.isPiped && !context.capabilities.isCI {
return .interactive(initialPrompt: nil)
}
Expand Down
6 changes: 6 additions & 0 deletions Apps/CLI/Sources/PeekabooCLI/Commands/AI/AgentChatUI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,12 @@ final class AgentChatUI {
self.requestRender()
}

func updateSessionId(_ sessionId: String) {
self.sessionId = sessionId
self.sessionLine.text = AgentChatUI.sessionDescription(for: sessionId, queueMode: self.queueMode)
self.requestRender()
}

func showToolStart(name: String, summary: String?, icon: String?, displayName: String?) {
let label = displayName ?? name
let detail = summary.flatMap { $0.isEmpty ? nil : $0 }
Expand Down
33 changes: 28 additions & 5 deletions Apps/CLI/Sources/PeekabooCLI/Commands/AI/AgentCommand+Chat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// PeekabooCLI
//

import Commander
import Foundation
import PeekabooAgentRuntime
import PeekabooCore
Expand Down Expand Up @@ -55,7 +56,9 @@ extension AgentCommand {
capabilities: TerminalCapabilities,
queueMode: QueueMode
) async throws {
guard self.ensureChatModePreconditions() else { return }
guard self.ensureChatModePreconditions() else {
throw ExitCode.failure
}

if capabilities.isInteractive && !capabilities.isPiped {
do {
Expand All @@ -67,6 +70,8 @@ extension AgentCommand {
queueMode: queueMode
)
return
} catch is ExitCode {
throw ExitCode.failure
} catch {
self.printAgentExecutionError(
"Failed to launch TauTUI chat: \(error.localizedDescription). Falling back to basic chat."
Expand Down Expand Up @@ -101,7 +106,7 @@ extension AgentCommand {
turnContext.sessionId = try await self.initialChatSessionId(agentService)
} catch {
self.printAgentExecutionError(error.localizedDescription)
return
throw ExitCode.failure
}

self.printChatWelcome(
Expand Down Expand Up @@ -157,7 +162,7 @@ extension AgentCommand {
activeSessionId = try await self.initialChatSessionId(agentService)
} catch {
self.printAgentExecutionError(error.localizedDescription)
return
throw ExitCode.failure
}

let chatUI = AgentChatUI(
Expand Down Expand Up @@ -236,7 +241,13 @@ extension AgentCommand {
} catch is CancellationError {
chatUI.showCancelled()
} catch {
chatUI.showError(error.localizedDescription)
if let sessionId = self.stepLimitSessionId(from: error) {
activeSessionId = sessionId
chatUI.updateSessionId(sessionId)
}
if !tuiDelegate.hasReceivedError {
chatUI.showError(error.localizedDescription)
}
}

currentRun = nil
Expand Down Expand Up @@ -359,7 +370,8 @@ extension AgentCommand {
task: batchedInput,
requestedModel: requestedModel,
maxSteps: self.resolvedMaxSteps,
queueMode: queueMode
queueMode: queueMode,
preserveStepLimitError: true
)
}
}
Expand All @@ -381,6 +393,13 @@ extension AgentCommand {
} catch is CancellationError {
cancelMonitor.stop()
return
} catch {
cancelMonitor.stop()
if let sessionId = self.stepLimitSessionId(from: error) {
context.sessionId = sessionId
return
}
throw error
}

if let updatedSessionId = result.sessionId {
Expand All @@ -390,6 +409,10 @@ extension AgentCommand {
self.printChatTurnSummary(result)
}

func stepLimitSessionId(from error: any Error) -> String? {
(error as? PeekabooAgentService.AgentStepLimitExceededError)?.sessionId
}

private func printChatTurnSummary(_ result: AgentExecutionResult) {
guard !self.quiet else { return }
let duration = String(format: "%.1fs", result.metadata.executionTime)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ extension AgentCommand {
task: String,
requestedModel: LanguageModel?,
maxSteps: Int,
queueMode: QueueMode
queueMode: QueueMode,
preserveStepLimitError: Bool = false
) async throws -> AgentExecutionResult {
let outputDelegate = self.makeDisplayDelegate(for: task)
let streamingDelegate = self.makeStreamingDelegate(using: outputDelegate)
Expand All @@ -139,8 +140,15 @@ extension AgentCommand {
+ "session=\(sessionId) tokens=\(finalTokens)"
)
return result
} catch let error as PeekabooAgentService.AgentStepLimitExceededError where preserveStepLimitError {
if outputDelegate?.hasReceivedError != true {
self.printAgentExecutionError("Agent execution failed: \(error.localizedDescription)")
}
throw error
} catch {
self.printAgentExecutionError("Agent execution failed: \(error.localizedDescription)")
if outputDelegate?.hasReceivedError != true {
self.printAgentExecutionError("Agent execution failed: \(error.localizedDescription)")
}
throw ExitCode.failure
}
}
Expand All @@ -159,6 +167,10 @@ extension AgentCommand {
self.maxSteps ?? 100
}

func validatedMaxStepCount() throws -> Int {
try AgentStepBudget.validate(self.resolvedMaxSteps)
}

func resolvedQueueMode() throws -> QueueMode {
guard let raw = self.queueMode?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return .oneAtATime
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import Tachikoma

@available(macOS 14.0, *)
extension AgentCommand {
func shouldUsePersistedSessionModel(requestedModel: LanguageModel?) -> Bool {
(self.resume || self.resumeSession != nil) && requestedModel == nil
}

@MainActor
func parseModelString(
_ modelString: String,
Expand Down Expand Up @@ -124,6 +128,15 @@ extension AgentCommand {
@MainActor
func validatedModelSelection(configuration: PeekabooCore.ConfigurationManager? = nil) throws -> LanguageModel? {
guard let modelString = self.model else { return nil }

if let configuration,
let configuredModel = PeekabooAIService(configuration: configuration)
.resolveConfiguredModel(modelString),
case .custom = configuredModel,
!configuredModel.supportsTools {
throw Self.configuredCustomModelToolCapabilityError(modelString)
}

guard let parsed = self.parseModelString(modelString, configuration: configuration) else {
// A model that parses but lacks tool support is a real, installed model —
// saying "unsupported" and listing an allowlist implies the name is wrong.
Expand All @@ -141,6 +154,46 @@ extension AgentCommand {
return parsed
}

@MainActor
func unavailableImplicitCustomModelToolCapabilityError(
from service: PeekabooAIService,
configuration: PeekabooCore.ConfigurationManager
) -> PeekabooError? {
let selections: [String]
if configuration.hasExplicitAIProviderList() {
selections = configuration.getAIProviders()
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
} else if let defaultModel = configuration.getAgentModel()?
.trimmingCharacters(in: .whitespacesAndNewlines),
!defaultModel.isEmpty {
selections = [defaultModel]
} else {
return nil
}

for selection in selections {
guard let model = service.resolveConfiguredModel(selection),
case .custom = model,
!model.supportsTools
else {
continue
}
return Self.configuredCustomModelToolCapabilityError(selection)
}

return nil
}

private static func configuredCustomModelToolCapabilityError(_ modelString: String) -> PeekabooError {
PeekabooError.invalidInput(
"Model '\(modelString)' is configured with supportsTools: false, but `peekaboo agent` " +
"requires tool calling. Choose a tool-capable model, or set this model's supportsTools " +
"setting to true only if the endpoint actually supports tool calls."
)
}

private static let supportedOpenAIInputs: Set<LanguageModel.OpenAI> = [
.gpt56Sol,
.gpt56Terra,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Commander
import Foundation
import PeekabooAgentRuntime
import PeekabooCore
Expand Down Expand Up @@ -233,8 +234,10 @@ extension AgentCommand {
)
self.displayResult(result, delegate: outputDelegate)
} catch {
self.printAgentExecutionError("Failed to resume session: \(error.localizedDescription)")
throw error
if outputDelegate?.hasReceivedError != true {
self.printAgentExecutionError("Failed to resume session: \(error.localizedDescription)")
}
throw ExitCode.failure
}
}
}
Loading
Loading