feat: add Claude Fable 5 support (#186)
This commit is contained in:
parent
7c3862b032
commit
e44486ff16
@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [3.4.2] - Unreleased
|
||||
|
||||
### Added
|
||||
- `peekaboo agent` now supports explicit Claude Fable 5 (`claude-fable-5`) selection with 1M context and 128K max output while keeping Anthropic defaults on Opus 4.8 for zero-retention compatibility.
|
||||
|
||||
## [3.4.1] - 2026-06-10
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -18,17 +18,21 @@ extension AgentCommand {
|
||||
.first
|
||||
.map { String($0).lowercased() }
|
||||
|
||||
if let configuration {
|
||||
if let configuredModel = PeekabooAIService(configuration: configuration).resolveConfiguredModel(trimmed),
|
||||
case .custom = configuredModel {
|
||||
return configuredModel.supportsTools ? configuredModel : nil
|
||||
}
|
||||
if trimmed.caseInsensitiveCompare("claude") == .orderedSame ||
|
||||
trimmed.caseInsensitiveCompare("anthropic") == .orderedSame {
|
||||
return .anthropic(.opus48)
|
||||
}
|
||||
|
||||
if let explicitProvider,
|
||||
configuration.listCustomProviders().contains(where: { providerID, provider in
|
||||
provider.enabled && providerID.caseInsensitiveCompare(explicitProvider) == .orderedSame
|
||||
}) {
|
||||
return nil
|
||||
if let configuration {
|
||||
switch self.parseConfiguredCustomModel(
|
||||
trimmed,
|
||||
explicitProvider: explicitProvider,
|
||||
configuration: configuration
|
||||
) {
|
||||
case let .resolved(model):
|
||||
return model
|
||||
case .unresolved:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,6 +40,11 @@ extension AgentCommand {
|
||||
return nil
|
||||
}
|
||||
|
||||
return self.supportedParsedModel(parsed, explicitProvider: explicitProvider)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func supportedParsedModel(_ parsed: LanguageModel, explicitProvider: String?) -> LanguageModel? {
|
||||
switch parsed {
|
||||
case let .openai(model):
|
||||
if Self.supportedOpenAIInputs.contains(model) {
|
||||
@ -43,7 +52,7 @@ extension AgentCommand {
|
||||
}
|
||||
case let .anthropic(model):
|
||||
if Self.supportedAnthropicInputs.contains(model) {
|
||||
return .anthropic(.opus48)
|
||||
return .anthropic(model)
|
||||
}
|
||||
case let .google(model):
|
||||
if Self.supportedGoogleInputs.contains(model) {
|
||||
@ -73,6 +82,32 @@ extension AgentCommand {
|
||||
return nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func parseConfiguredCustomModel(
|
||||
_ modelString: String,
|
||||
explicitProvider: String?,
|
||||
configuration: PeekabooCore.ConfigurationManager
|
||||
) -> ConfiguredModelResolution {
|
||||
if let configuredModel = PeekabooAIService(configuration: configuration).resolveConfiguredModel(modelString),
|
||||
case .custom = configuredModel {
|
||||
return .resolved(configuredModel.supportsTools ? configuredModel : nil)
|
||||
}
|
||||
|
||||
if let explicitProvider,
|
||||
configuration.listCustomProviders().contains(where: { providerID, provider in
|
||||
provider.enabled && providerID.caseInsensitiveCompare(explicitProvider) == .orderedSame
|
||||
}) {
|
||||
return .resolved(nil)
|
||||
}
|
||||
|
||||
return .unresolved
|
||||
}
|
||||
|
||||
private enum ConfiguredModelResolution {
|
||||
case resolved(LanguageModel?)
|
||||
case unresolved
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func validatedModelSelection(configuration: PeekabooCore.ConfigurationManager? = nil) throws -> LanguageModel? {
|
||||
guard let modelString = self.model else { return nil }
|
||||
@ -96,6 +131,7 @@ extension AgentCommand {
|
||||
]
|
||||
|
||||
private static let supportedAnthropicInputs: Set<LanguageModel.Anthropic> = [
|
||||
.fable5,
|
||||
.opus48,
|
||||
.opus47,
|
||||
.opus45,
|
||||
|
||||
@ -89,7 +89,7 @@ struct AgentCommand: RuntimeOptionsConfigurable {
|
||||
@Option(
|
||||
name: .long,
|
||||
help: """
|
||||
AI model to use (for example: gpt-5.5, claude-opus-4-8, \
|
||||
AI model to use (for example: gpt-5.5, claude-fable-5, \
|
||||
gemini-3.5-flash, grok-4.3, minimax-m2.7, minimax-cn/m2.7, \
|
||||
ollama/<model>, lmstudio/<model>, or <custom-provider>/<model>)
|
||||
"""
|
||||
|
||||
@ -28,16 +28,19 @@ struct AgentCommandTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Supported Anthropic aliases map to Claude Opus 4.8`() throws {
|
||||
func `Supported Anthropic aliases parse current models`() throws {
|
||||
let command = try AgentCommand.parse([])
|
||||
|
||||
#expect(command.parseModelString("claude-fable-5") == .anthropic(.fable5))
|
||||
#expect(command.parseModelString("fable") == .anthropic(.fable5))
|
||||
#expect(command.parseModelString("claude-opus-4.8") == .anthropic(.opus48))
|
||||
#expect(command.parseModelString("claude-opus-4.7") == .anthropic(.opus48))
|
||||
#expect(command.parseModelString("claude-sonnet-4.6") == .anthropic(.opus48))
|
||||
#expect(command.parseModelString("claude-sonnet-4.5") == .anthropic(.opus48))
|
||||
#expect(command.parseModelString("Claude-Sonnet-4.5") == .anthropic(.opus48))
|
||||
#expect(command.parseModelString("claude-opus-4.7") == .anthropic(.opus47))
|
||||
#expect(command.parseModelString("claude-sonnet-4.6") == .anthropic(.sonnet46))
|
||||
#expect(command.parseModelString("claude-sonnet-4.5") == .anthropic(.sonnet45))
|
||||
#expect(command.parseModelString("Claude-Sonnet-4.5") == .anthropic(.sonnet45))
|
||||
#expect(command.parseModelString("claude") == .anthropic(.opus48))
|
||||
#expect(command.parseModelString("claude-opus-4") == .anthropic(.opus48))
|
||||
#expect(command.parseModelString("anthropic") == .anthropic(.opus48))
|
||||
#expect(command.parseModelString("claude-opus-4") == .anthropic(.opus4))
|
||||
#expect(command.parseModelString("claude-3-sonnet") == nil)
|
||||
}
|
||||
|
||||
@ -104,7 +107,7 @@ struct AgentCommandTests {
|
||||
|
||||
#expect(command.parseModelString(" gpt-5 ") == .openai(.gpt55))
|
||||
#expect(command.parseModelString("\tgpt-5\n") == .openai(.gpt55))
|
||||
#expect(command.parseModelString(" claude-sonnet-4.5 ") == .anthropic(.opus48))
|
||||
#expect(command.parseModelString(" claude-sonnet-4.5 ") == .anthropic(.sonnet45))
|
||||
#expect(command.parseModelString(" gemini-3-flash ") == .google(.gemini3Flash))
|
||||
#expect(command.parseModelString(" minimax-m2.7 ") == .minimax(.m27))
|
||||
#expect(command.parseModelString(" minimax-cn/m2.7 ") == .minimaxCN(.m27))
|
||||
@ -198,7 +201,7 @@ struct AgentCommandTests {
|
||||
"""
|
||||
{
|
||||
"aiProviders": {
|
||||
"providers": "openai/gpt-5.5,anthropic/claude-opus-4-8"
|
||||
"providers": "openai/gpt-5.5,anthropic/claude-fable-5"
|
||||
}
|
||||
}
|
||||
""",
|
||||
@ -207,7 +210,7 @@ struct AgentCommandTests {
|
||||
let command = try AgentCommand.parse([])
|
||||
let service = PeekabooAIService(configuration: PeekabooCore.ConfigurationManager.shared)
|
||||
|
||||
#expect(command.firstAvailableToolModel(from: service) == .anthropic(.opus48))
|
||||
#expect(command.firstAvailableToolModel(from: service) == .anthropic(.fable5))
|
||||
}
|
||||
}
|
||||
|
||||
@ -352,7 +355,7 @@ struct ModelSelectionIntegrationTests {
|
||||
|
||||
command.model = "claude-opus-4.7"
|
||||
let parsedClaude = command.model.flatMap { command.parseModelString($0) }
|
||||
#expect(parsedClaude == .anthropic(.opus48))
|
||||
#expect(parsedClaude == .anthropic(.opus47))
|
||||
|
||||
command.model = "gpt-4o"
|
||||
let remapped = command.model.flatMap { command.parseModelString($0) }
|
||||
@ -369,6 +372,7 @@ struct ModelSelectionIntegrationTests {
|
||||
|
||||
let testCases: [(String, LanguageModel)] = [
|
||||
("gpt-5.5", .openai(.gpt55)),
|
||||
("claude-fable-5", .anthropic(.fable5)),
|
||||
("claude-opus-4.8", .anthropic(.opus48)),
|
||||
("gemini-3.5-flash", .google(.gemini35Flash)),
|
||||
("MiniMax-M2.7", .minimax(.m27)),
|
||||
|
||||
@ -40,7 +40,7 @@ public struct AIAssistantWindow: View {
|
||||
|
||||
Picker("Model", selection: self.$selectedModel) {
|
||||
Text("GPT-5.5").tag(Model.openai(.gpt55))
|
||||
Text("Claude Opus 4.7").tag(Model.anthropic(.opus47))
|
||||
Text("Claude Opus 4.8").tag(Model.anthropic(.opus48))
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
@ -104,6 +104,7 @@ public struct AIAssistantWindow: View {
|
||||
system: self.systemPrompt.isEmpty ? nil : self.systemPrompt,
|
||||
settings: .default,
|
||||
tools: nil)
|
||||
.id(self.selectedModel.description)
|
||||
}
|
||||
.navigationTitle("AI Assistant")
|
||||
.toolbar {
|
||||
@ -139,7 +140,7 @@ public struct CompactAIAssistant: View {
|
||||
|
||||
Picker("Model", selection: self.$model) {
|
||||
Text("GPT-5.5").tag(Model.openai(.gpt55))
|
||||
Text("Claude Opus 4.7").tag(Model.anthropic(.opus47))
|
||||
Text("Claude Opus 4.8").tag(Model.anthropic(.opus48))
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.controlSize(.small)
|
||||
@ -154,6 +155,7 @@ public struct CompactAIAssistant: View {
|
||||
system: self.systemPrompt.isEmpty ? nil : self.systemPrompt,
|
||||
settings: .default,
|
||||
tools: nil)
|
||||
.id(self.model.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -239,7 +239,7 @@ public struct MessageBubble: View {
|
||||
|
||||
#Preview {
|
||||
PeekabooChatView(
|
||||
model: LanguageModel.anthropic(.opus47),
|
||||
model: LanguageModel.anthropic(.opus48),
|
||||
system: "You are a helpful assistant specialized in macOS automation and development.")
|
||||
.frame(width: 400, height: 600)
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ func formatModelName(_ model: String) -> String {
|
||||
case "gpt-5.4-nano": "GPT-5.4 nano"
|
||||
case "gpt-5": "GPT-5"
|
||||
case "gpt-5-mini": "GPT-5 mini"
|
||||
case "claude-fable-5": "Claude Fable 5"
|
||||
case "claude-opus-4-8": "Claude Opus 4.8"
|
||||
case "claude-opus-4-7": "Claude Opus 4.7"
|
||||
case "claude-sonnet-4-6": "Claude Sonnet 4.6"
|
||||
|
||||
@ -242,6 +242,7 @@ struct AISettingsView: View {
|
||||
("gpt-5-mini", "GPT-5 mini"),
|
||||
]),
|
||||
("anthropic", [
|
||||
("claude-fable-5", "Claude Fable 5"),
|
||||
("claude-opus-4-8", "Claude Opus 4.8"),
|
||||
("claude-opus-4-7", "Claude Opus 4.7"),
|
||||
("claude-sonnet-4-6", "Claude Sonnet 4.6"),
|
||||
@ -335,6 +336,8 @@ struct AISettingsView: View {
|
||||
"gpt-5-mini": "Cost-optimized GPT-5 Mini with the same tools + 400K context " +
|
||||
"at a friendlier price.",
|
||||
// Anthropic models
|
||||
"claude-fable-5": "Claude Fable 5 with 1M context for demanding " +
|
||||
"reasoning and long-horizon agent tasks.",
|
||||
"claude-opus-4-8": "Claude Opus 4.8 with 1M context for long-running " +
|
||||
"automation and computer-use tasks.",
|
||||
"claude-sonnet-4-6": "Claude Sonnet 4.6 with new tools + computer use, " +
|
||||
|
||||
@ -49,7 +49,7 @@ final class SessionTitleGenerator {
|
||||
|
||||
private static func timeoutTitle() async -> String {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
try await Task.sleep(nanoseconds: 8_000_000_000)
|
||||
} catch {
|
||||
return self.fallbackTitle
|
||||
}
|
||||
@ -63,7 +63,7 @@ final class SessionTitleGenerator {
|
||||
hasAnthropic: Bool) async -> String
|
||||
{
|
||||
do {
|
||||
let model = self.selectModel(
|
||||
let model = Self.selectModel(
|
||||
providers: providers,
|
||||
hasOpenAI: hasOpenAI,
|
||||
hasAnthropic: hasAnthropic)
|
||||
@ -72,7 +72,7 @@ final class SessionTitleGenerator {
|
||||
let result = try await generateText(
|
||||
model: model,
|
||||
messages: [.user(prompt)],
|
||||
settings: GenerationSettings(maxTokens: 20, temperature: 0.3))
|
||||
settings: self.generationSettings(for: model))
|
||||
|
||||
return self.validatedTitle(result.text)
|
||||
} catch {
|
||||
@ -80,13 +80,16 @@ final class SessionTitleGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
private func selectModel(
|
||||
static func selectModel(
|
||||
providers: [String],
|
||||
hasOpenAI: Bool,
|
||||
hasAnthropic: Bool) -> LanguageModel
|
||||
{
|
||||
if providers.contains("anthropic/claude-fable-5"), hasAnthropic {
|
||||
return .anthropic(.fable5)
|
||||
}
|
||||
if providers.contains(where: { $0 == "anthropic" || $0.hasPrefix("anthropic/") }), hasAnthropic {
|
||||
return .anthropic(.opus47)
|
||||
return .anthropic(.opus48)
|
||||
}
|
||||
if providers.contains(where: { $0 == "openai" || $0.hasPrefix("openai/") }), hasOpenAI {
|
||||
return .openai(.gpt55)
|
||||
@ -94,7 +97,16 @@ final class SessionTitleGenerator {
|
||||
if providers.contains(where: { $0 == "ollama" || $0.hasPrefix("ollama/") }) {
|
||||
return .ollama(.llama33)
|
||||
}
|
||||
return .anthropic(.opus47)
|
||||
return .anthropic(.opus48)
|
||||
}
|
||||
|
||||
private func generationSettings(for model: LanguageModel) -> GenerationSettings {
|
||||
switch model {
|
||||
case .anthropic(.fable5):
|
||||
GenerationSettings(maxTokens: 256, reasoningEffort: .low)
|
||||
default:
|
||||
GenerationSettings(maxTokens: 20, temperature: 0.3)
|
||||
}
|
||||
}
|
||||
|
||||
private func buildPrompt(for task: String) -> String {
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
import Tachikoma
|
||||
import Testing
|
||||
@testable import Peekaboo
|
||||
|
||||
@Suite(.tags(.services, .unit))
|
||||
@MainActor
|
||||
struct SessionTitleGeneratorTests {
|
||||
@Test
|
||||
func `Explicit Fable title provider selects Fable`() {
|
||||
let model = SessionTitleGenerator.selectModel(
|
||||
providers: ["openai/gpt-5.5", "anthropic/claude-fable-5"],
|
||||
hasOpenAI: false,
|
||||
hasAnthropic: true)
|
||||
|
||||
#expect(model == .anthropic(.fable5))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Bare Anthropic title provider keeps Opus default`() {
|
||||
let model = SessionTitleGenerator.selectModel(
|
||||
providers: ["anthropic"],
|
||||
hasOpenAI: false,
|
||||
hasAnthropic: true)
|
||||
|
||||
#expect(model == .anthropic(.opus48))
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,9 @@
|
||||
|
||||
## [3.4.2] - Unreleased
|
||||
|
||||
### Added
|
||||
- `peekaboo agent` now supports explicit Claude Fable 5 (`claude-fable-5`) selection with 1M context and 128K max output while keeping Anthropic defaults on Opus 4.8 for zero-retention compatibility.
|
||||
|
||||
## [3.4.1] - 2026-06-10
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -4,22 +4,151 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import PeekabooAutomation
|
||||
import Tachikoma
|
||||
|
||||
@available(macOS 14.0, *)
|
||||
extension PeekabooAgentService {
|
||||
func generationSettings(for model: LanguageModel) -> GenerationSettings {
|
||||
switch model {
|
||||
let maxTokens = self.configuredMaxTokens(for: model)
|
||||
let temperature = self.shouldOmitTemperature(for: model) ? nil : self.configuredTemperature(for: model)
|
||||
|
||||
return switch model {
|
||||
case .openai(.gpt55), .openai(.gpt54), .openai(.gpt54Mini), .openai(.gpt54Nano), .openai(.gpt5):
|
||||
GenerationSettings(
|
||||
maxTokens: 4096,
|
||||
maxTokens: maxTokens,
|
||||
temperature: temperature,
|
||||
providerOptions: .init(openai: .init(verbosity: .medium)))
|
||||
case .anthropic:
|
||||
GenerationSettings(maxTokens: 4096)
|
||||
GenerationSettings(maxTokens: maxTokens, temperature: temperature)
|
||||
case .google:
|
||||
GenerationSettings(maxTokens: 4096)
|
||||
GenerationSettings(maxTokens: maxTokens, temperature: temperature)
|
||||
default:
|
||||
GenerationSettings(maxTokens: 4096)
|
||||
GenerationSettings(maxTokens: maxTokens, temperature: temperature)
|
||||
}
|
||||
}
|
||||
|
||||
private func configuredMaxTokens(for model: LanguageModel) -> Int {
|
||||
let configuredMaxTokens = self.services.configuration.getAgentMaxTokens()
|
||||
let providerMaxTokens = self.maxOutputTokens(for: model)
|
||||
return max(1, min(configuredMaxTokens, providerMaxTokens))
|
||||
}
|
||||
|
||||
private func configuredTemperature(for model: LanguageModel) -> Double {
|
||||
let configuredTemperature = self.services.configuration.getAgentTemperature()
|
||||
guard configuredTemperature.isFinite else { return 0.7 }
|
||||
let maxTemperature = switch model {
|
||||
case .anthropic, .anthropicCompatible:
|
||||
1.0
|
||||
case let .custom(provider):
|
||||
if self.customProviderUsesAnthropicTemperatureLimit(provider) {
|
||||
1.0
|
||||
} else {
|
||||
2.0
|
||||
}
|
||||
default:
|
||||
2.0
|
||||
}
|
||||
return min(maxTemperature, max(0.0, configuredTemperature))
|
||||
}
|
||||
|
||||
private func customProviderUsesAnthropicTemperatureLimit(_ provider: any ModelProvider) -> Bool {
|
||||
if provider is AnthropicProvider || provider is AnthropicCompatibleProvider {
|
||||
return true
|
||||
}
|
||||
|
||||
guard let parsed = ProviderParser.parse(provider.modelId) else {
|
||||
return false
|
||||
}
|
||||
|
||||
if CustomProviderRegistry.shared.get(parsed.provider)?.kind == .anthropic {
|
||||
return true
|
||||
}
|
||||
|
||||
return self.services.configuration.getCustomProvider(id: parsed.provider)?.type == .anthropic
|
||||
}
|
||||
|
||||
private func shouldOmitTemperature(for model: LanguageModel) -> Bool {
|
||||
switch model {
|
||||
case let .openaiCompatible(modelId, _):
|
||||
return self.isOpenAIGPT5TemperatureExcludedModel(modelId)
|
||||
case let .custom(provider):
|
||||
guard let parsed = ProviderParser.parse(provider.modelId) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let isOpenAICompatible = CustomProviderRegistry.shared.get(parsed.provider)?.kind == .openai ||
|
||||
self.services.configuration.getCustomProvider(id: parsed.provider)?.type == .openai
|
||||
return isOpenAICompatible && self.isOpenAIGPT5TemperatureExcludedModel(parsed.model)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func isOpenAIGPT5TemperatureExcludedModel(_ modelId: String) -> Bool {
|
||||
switch self.normalizedOpenAIModelID(modelId) {
|
||||
case "chat-latest",
|
||||
"gpt-5.5",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.4-nano",
|
||||
"gpt-5",
|
||||
"gpt-5-pro",
|
||||
"gpt-5-mini",
|
||||
"gpt-5-nano":
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private func normalizedOpenAIModelID(_ modelId: String) -> String {
|
||||
let normalized = modelId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard let parsed = ProviderParser.parse(normalized) else {
|
||||
return normalized
|
||||
}
|
||||
|
||||
switch parsed.provider.lowercased() {
|
||||
case "openai", "chatgpt":
|
||||
return self.normalizedOpenAIModelID(parsed.model)
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
private func maxOutputTokens(for model: LanguageModel) -> Int {
|
||||
switch model {
|
||||
case let .openai(openAIModel):
|
||||
switch openAIModel {
|
||||
case .chatLatest,
|
||||
.gpt55,
|
||||
.gpt54,
|
||||
.gpt54Mini,
|
||||
.gpt54Nano,
|
||||
.gpt5,
|
||||
.gpt5Pro,
|
||||
.gpt5Mini,
|
||||
.gpt5Nano:
|
||||
128_000
|
||||
case .gpt5ChatLatest:
|
||||
16384
|
||||
case .custom:
|
||||
4096
|
||||
}
|
||||
case let .anthropic(anthropicModel):
|
||||
anthropicModel.maxOutputTokens
|
||||
case let .custom(provider):
|
||||
provider.capabilities.maxOutputTokens
|
||||
case .google:
|
||||
8192
|
||||
case .minimax, .minimaxCN:
|
||||
8192
|
||||
case .mistral, .groq, .grok, .ollama, .lmstudio, .azureOpenAI, .replicate:
|
||||
4096
|
||||
case let .openRouter(modelId), let .together(modelId), let .openaiCompatible(modelId, _):
|
||||
AnthropicModelCapabilityInference.capabilities(for: modelId)?.maxOutputTokens ?? 4096
|
||||
case let .anthropicCompatible(modelId, _):
|
||||
AnthropicModelCapabilityInference.capabilities(for: modelId)?.maxOutputTokens ?? 8192
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,6 +178,7 @@ extension PeekabooAgentService {
|
||||
|
||||
let eventTask = Task { @MainActor in
|
||||
let delegate = unsafeDelegate.wrappedValue
|
||||
delegate.agentDidEmitEvent(.started(task: input))
|
||||
for await event in eventStream {
|
||||
delegate.agentDidEmitEvent(event)
|
||||
}
|
||||
@ -58,33 +188,44 @@ extension PeekabooAgentService {
|
||||
eventContinuation.yield(event)
|
||||
}
|
||||
|
||||
defer {
|
||||
eventContinuation.finish()
|
||||
eventTask.cancel()
|
||||
}
|
||||
|
||||
let streamingDelegate = await MainActor.run {
|
||||
StreamingEventDelegate { chunk in
|
||||
await eventHandler.send(.assistantMessage(content: chunk))
|
||||
}
|
||||
}
|
||||
|
||||
let sessionContext = try await self.prepareSession(
|
||||
task: input,
|
||||
model: self.defaultLanguageModel,
|
||||
label: "audio-stream",
|
||||
logBehavior: .always)
|
||||
do {
|
||||
let sessionContext = try await self.prepareSession(
|
||||
task: input,
|
||||
model: self.defaultLanguageModel,
|
||||
label: "audio-stream",
|
||||
logBehavior: .always)
|
||||
|
||||
let result = try await self.executeWithStreaming(
|
||||
context: sessionContext,
|
||||
model: self.defaultLanguageModel,
|
||||
maxSteps: maxSteps,
|
||||
streamingDelegate: streamingDelegate,
|
||||
queueMode: queueMode,
|
||||
eventHandler: eventHandler)
|
||||
let result = if self.defaultLanguageModel.supportsStreaming {
|
||||
try await self.executeWithStreaming(
|
||||
context: sessionContext,
|
||||
model: self.defaultLanguageModel,
|
||||
maxSteps: maxSteps,
|
||||
streamingDelegate: streamingDelegate,
|
||||
queueMode: queueMode,
|
||||
eventHandler: eventHandler)
|
||||
} else {
|
||||
try await self.executeWithoutStreaming(
|
||||
context: sessionContext,
|
||||
model: self.defaultLanguageModel,
|
||||
maxSteps: maxSteps,
|
||||
eventHandler: eventHandler)
|
||||
}
|
||||
|
||||
await eventHandler.send(.completed(summary: result.content, usage: result.usage))
|
||||
return result
|
||||
await eventHandler.send(.completed(summary: result.content, usage: result.usage))
|
||||
eventContinuation.finish()
|
||||
await eventTask.value
|
||||
return result
|
||||
} catch {
|
||||
eventContinuation.finish()
|
||||
eventTask.cancel()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -180,6 +321,7 @@ extension PeekabooAgentService {
|
||||
context: SessionContext,
|
||||
model: LanguageModel,
|
||||
maxSteps: Int = 20,
|
||||
eventHandler: EventHandler? = nil,
|
||||
enhancementOptions: AgentEnhancementOptions? = nil) async throws -> AgentExecutionResult
|
||||
{
|
||||
let tools = await self.buildToolset(for: model)
|
||||
@ -189,7 +331,7 @@ extension PeekabooAgentService {
|
||||
model: model,
|
||||
tools: tools,
|
||||
sessionId: context.id,
|
||||
eventHandler: nil,
|
||||
eventHandler: eventHandler,
|
||||
enhancementOptions: enhancementOptions)
|
||||
|
||||
let outcome = try await self.runGenerationLoop(
|
||||
@ -255,12 +397,22 @@ extension PeekabooAgentService {
|
||||
}
|
||||
|
||||
let request = ProviderRequest(
|
||||
messages: state.messages,
|
||||
messages: state.messages.sanitizedForProviderContext(
|
||||
model: configuration.model,
|
||||
configuration: resolvedConfiguration,
|
||||
peekabooConfiguration: self.services.configuration),
|
||||
tools: configuration.tools.isEmpty ? nil : configuration.tools,
|
||||
settings: self.generationSettings(for: configuration.model))
|
||||
let response = try await provider.generateText(request: request)
|
||||
|
||||
if response.finishReason == .contentFilter {
|
||||
throw TachikomaError.apiError("Model refused to answer")
|
||||
}
|
||||
|
||||
state.content += response.text
|
||||
if !response.text.isEmpty {
|
||||
await configuration.eventHandler?.send(.assistantMessage(content: response.text))
|
||||
}
|
||||
if let usage = response.usage {
|
||||
hasUsage = true
|
||||
totalInputTokens += usage.inputTokens
|
||||
@ -275,13 +427,21 @@ extension PeekabooAgentService {
|
||||
state.usage = Usage(inputTokens: totalInputTokens, outputTokens: totalOutputTokens, cost: totalCost)
|
||||
}
|
||||
|
||||
let recordedAssistantTurn = self.appendResponseHistory(
|
||||
from: response,
|
||||
model: configuration.model,
|
||||
configuration: resolvedConfiguration,
|
||||
peekabooConfiguration: self.services.configuration,
|
||||
to: &state.messages)
|
||||
|
||||
let toolCalls = response.toolCalls ?? []
|
||||
if toolCalls.isEmpty {
|
||||
self.appendFinalStep(
|
||||
text: response.text,
|
||||
to: &state.messages,
|
||||
steps: &state.steps,
|
||||
stepIndex: stepIndex)
|
||||
stepIndex: stepIndex,
|
||||
appendMessage: !recordedAssistantTurn)
|
||||
break
|
||||
}
|
||||
|
||||
@ -290,7 +450,9 @@ extension PeekabooAgentService {
|
||||
toolCalls: toolCalls,
|
||||
context: toolContext,
|
||||
currentMessages: &state.messages,
|
||||
stepIndex: stepIndex)
|
||||
stepIndex: stepIndex,
|
||||
appendAssistantMessage: !recordedAssistantTurn,
|
||||
emitToolStartEvents: true)
|
||||
state.steps.append(step)
|
||||
state.toolCallCount += step.toolResults.count
|
||||
|
||||
|
||||
@ -61,26 +61,38 @@ extension PeekabooAgentService {
|
||||
eventContinuation.yield(event)
|
||||
}
|
||||
|
||||
defer {
|
||||
eventContinuation.finish()
|
||||
eventTask.cancel()
|
||||
}
|
||||
|
||||
let streamingDelegate = StreamingEventDelegate { chunk in
|
||||
await eventHandler.send(.assistantMessage(content: chunk))
|
||||
}
|
||||
|
||||
let result = try await self.executeWithStreaming(
|
||||
context: sessionContext,
|
||||
model: selectedModel,
|
||||
maxSteps: maxSteps,
|
||||
streamingDelegate: streamingDelegate,
|
||||
queueMode: queueMode,
|
||||
eventHandler: eventHandler,
|
||||
enhancementOptions: enhancementOptions)
|
||||
do {
|
||||
let result = if selectedModel.supportsStreaming {
|
||||
try await self.executeWithStreaming(
|
||||
context: sessionContext,
|
||||
model: selectedModel,
|
||||
maxSteps: maxSteps,
|
||||
streamingDelegate: streamingDelegate,
|
||||
queueMode: queueMode,
|
||||
eventHandler: eventHandler,
|
||||
enhancementOptions: enhancementOptions)
|
||||
} else {
|
||||
try await self.executeWithoutStreaming(
|
||||
context: sessionContext,
|
||||
model: selectedModel,
|
||||
maxSteps: maxSteps,
|
||||
eventHandler: eventHandler,
|
||||
enhancementOptions: enhancementOptions)
|
||||
}
|
||||
|
||||
await eventHandler.send(.completed(summary: result.content, usage: result.usage))
|
||||
return result
|
||||
await eventHandler.send(.completed(summary: result.content, usage: result.usage))
|
||||
eventContinuation.finish()
|
||||
await eventTask.value
|
||||
return result
|
||||
} catch {
|
||||
eventContinuation.finish()
|
||||
eventTask.cancel()
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
return try await self.executeWithoutStreaming(
|
||||
context: sessionContext,
|
||||
@ -95,7 +107,8 @@ extension PeekabooAgentService {
|
||||
sessionId: String,
|
||||
model: LanguageModel? = nil,
|
||||
maxSteps: Int = 20,
|
||||
eventDelegate: (any AgentEventDelegate)? = nil) async throws -> AgentExecutionResult
|
||||
eventDelegate: (any AgentEventDelegate)? = nil,
|
||||
enhancementOptions: AgentEnhancementOptions? = .default) async throws -> AgentExecutionResult
|
||||
{
|
||||
let continuationPrompt = "Continue from where we left off."
|
||||
return try await self.continueSession(
|
||||
@ -105,7 +118,8 @@ extension PeekabooAgentService {
|
||||
maxSteps: maxSteps,
|
||||
dryRun: false,
|
||||
eventDelegate: eventDelegate,
|
||||
verbose: self.isVerbose)
|
||||
verbose: self.isVerbose,
|
||||
enhancementOptions: enhancementOptions)
|
||||
}
|
||||
|
||||
// MARK: - Session Management
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import PeekabooAutomation
|
||||
import Tachikoma
|
||||
|
||||
@available(macOS 14.0, *)
|
||||
@ -12,28 +13,40 @@ extension PeekabooAgentService {
|
||||
let text: String
|
||||
let toolCalls: [AgentToolCall]
|
||||
let usage: Usage?
|
||||
let finishReason: FinishReason?
|
||||
let reasoningBlocks: [ReasoningBlock]
|
||||
}
|
||||
|
||||
struct ReasoningBlock {
|
||||
var text: String
|
||||
let signature: String
|
||||
let signature: String?
|
||||
let type: String
|
||||
}
|
||||
|
||||
private enum BufferedStreamEvent {
|
||||
case event(AgentEvent)
|
||||
}
|
||||
|
||||
func collectStreamOutput(
|
||||
from streamResult: StreamTextResult,
|
||||
model: LanguageModel,
|
||||
eventHandler: EventHandler?,
|
||||
stepIndex: Int) async throws -> StreamProcessingOutput
|
||||
{
|
||||
var stepText = ""
|
||||
var reasoningBlocks: [ReasoningBlock] = []
|
||||
var activeReasoningIndex: Int?
|
||||
var lastSignedReasoningIndex: Int?
|
||||
var pendingReasoningText = ""
|
||||
var pendingReasoningType = "thinking"
|
||||
var stepToolCalls: [AgentToolCall] = []
|
||||
var seenToolCallIds = Set<String>()
|
||||
var isThinking = false
|
||||
var bufferedEvents: [BufferedStreamEvent] = []
|
||||
let buffersAssistantTextUntilDone = self.buffersAgentTextStreamUntilDone(for: model)
|
||||
var usage: Usage?
|
||||
var finishReason: FinishReason?
|
||||
var didReceiveDone = false
|
||||
|
||||
if self.isVerbose {
|
||||
self.logger.debug("Starting to process stream for step \(stepIndex)")
|
||||
@ -46,62 +59,141 @@ extension PeekabooAgentService {
|
||||
|
||||
switch delta.type {
|
||||
case .textDelta:
|
||||
self.flushPendingReasoningText(
|
||||
&pendingReasoningText,
|
||||
into: lastSignedReasoningIndex,
|
||||
type: pendingReasoningType,
|
||||
reasoningBlocks: &reasoningBlocks)
|
||||
guard let content = delta.content else { continue }
|
||||
await self.handleTextDelta(
|
||||
content,
|
||||
stepText: &stepText,
|
||||
isThinking: &isThinking,
|
||||
bufferedEvents: &bufferedEvents,
|
||||
buffersAssistantTextUntilDone: buffersAssistantTextUntilDone,
|
||||
eventHandler: eventHandler)
|
||||
|
||||
case .toolCall:
|
||||
self.flushPendingReasoningText(
|
||||
&pendingReasoningText,
|
||||
into: lastSignedReasoningIndex,
|
||||
type: pendingReasoningType,
|
||||
reasoningBlocks: &reasoningBlocks)
|
||||
if let toolCall = delta.toolCall {
|
||||
try await self.handleToolCallDelta(
|
||||
toolCall,
|
||||
stepToolCalls: &stepToolCalls,
|
||||
seenToolCallIds: &seenToolCallIds,
|
||||
bufferedEvents: &bufferedEvents,
|
||||
buffersEventsUntilDone: buffersAssistantTextUntilDone,
|
||||
eventHandler: eventHandler)
|
||||
}
|
||||
|
||||
case .reasoning:
|
||||
let reasoningType = delta.reasoningType ?? "thinking"
|
||||
if let signature = delta.reasoningSignature {
|
||||
let signedText = pendingReasoningText + (delta.content ?? "")
|
||||
reasoningBlocks.append(ReasoningBlock(
|
||||
text: pendingReasoningText,
|
||||
text: signedText,
|
||||
signature: signature,
|
||||
type: delta.reasoningType ?? "thinking"))
|
||||
activeReasoningIndex = reasoningBlocks.count - 1
|
||||
type: reasoningType))
|
||||
activeReasoningIndex = signedText.isEmpty ? reasoningBlocks.count - 1 : nil
|
||||
lastSignedReasoningIndex = reasoningBlocks.count - 1
|
||||
pendingReasoningText = ""
|
||||
pendingReasoningType = "thinking"
|
||||
} else if reasoningType == "redacted_thinking", let content = delta.content {
|
||||
reasoningBlocks.append(ReasoningBlock(
|
||||
text: content,
|
||||
signature: nil,
|
||||
type: reasoningType))
|
||||
activeReasoningIndex = nil
|
||||
pendingReasoningText = ""
|
||||
pendingReasoningType = "thinking"
|
||||
await self.handleReasoningDelta(
|
||||
nil,
|
||||
bufferedEvents: &bufferedEvents,
|
||||
buffersEventsUntilDone: buffersAssistantTextUntilDone,
|
||||
eventHandler: eventHandler)
|
||||
continue
|
||||
}
|
||||
|
||||
if let content = delta.content {
|
||||
if let activeReasoningIndex {
|
||||
reasoningBlocks[activeReasoningIndex].text += content
|
||||
if delta.reasoningSignature == nil, let content = delta.content {
|
||||
if let activeIndex = activeReasoningIndex {
|
||||
reasoningBlocks[activeIndex].text += content
|
||||
activeReasoningIndex = nil
|
||||
} else {
|
||||
pendingReasoningType = reasoningType
|
||||
pendingReasoningText += content
|
||||
}
|
||||
}
|
||||
|
||||
let displayContent = delta.content.flatMap { $0.isEmpty ? nil : $0 }
|
||||
await self.handleReasoningDelta(displayContent, eventHandler: eventHandler)
|
||||
await self.handleReasoningDelta(
|
||||
displayContent,
|
||||
bufferedEvents: &bufferedEvents,
|
||||
buffersEventsUntilDone: buffersAssistantTextUntilDone,
|
||||
eventHandler: eventHandler)
|
||||
|
||||
case .done:
|
||||
didReceiveDone = true
|
||||
self.flushPendingReasoningText(
|
||||
&pendingReasoningText,
|
||||
into: lastSignedReasoningIndex,
|
||||
type: pendingReasoningType,
|
||||
reasoningBlocks: &reasoningBlocks)
|
||||
usage = delta.usage
|
||||
finishReason = delta.finishReason
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if buffersAssistantTextUntilDone, !didReceiveDone {
|
||||
throw TachikomaError.apiError("Provider stream ended without a terminal event")
|
||||
}
|
||||
|
||||
if buffersAssistantTextUntilDone, finishReason != .contentFilter {
|
||||
for event in bufferedEvents {
|
||||
switch event {
|
||||
case let .event(agentEvent):
|
||||
await eventHandler?.send(agentEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return StreamProcessingOutput(
|
||||
text: stepText,
|
||||
toolCalls: stepToolCalls,
|
||||
usage: usage,
|
||||
finishReason: finishReason,
|
||||
reasoningBlocks: reasoningBlocks)
|
||||
}
|
||||
|
||||
private func flushPendingReasoningText(
|
||||
_ pendingReasoningText: inout String,
|
||||
into reasoningIndex: Int?,
|
||||
type: String,
|
||||
reasoningBlocks: inout [ReasoningBlock])
|
||||
{
|
||||
guard !pendingReasoningText.isEmpty else { return }
|
||||
if let reasoningIndex, reasoningBlocks.indices.contains(reasoningIndex) {
|
||||
reasoningBlocks[reasoningIndex].text += pendingReasoningText
|
||||
} else {
|
||||
reasoningBlocks.append(ReasoningBlock(
|
||||
text: pendingReasoningText,
|
||||
signature: nil,
|
||||
type: type))
|
||||
}
|
||||
pendingReasoningText = ""
|
||||
}
|
||||
|
||||
private func handleTextDelta(
|
||||
_ content: String,
|
||||
stepText: inout String,
|
||||
isThinking: inout Bool,
|
||||
bufferedEvents: inout [BufferedStreamEvent],
|
||||
buffersAssistantTextUntilDone: Bool,
|
||||
eventHandler: EventHandler?) async
|
||||
{
|
||||
if self.isVerbose {
|
||||
@ -109,19 +201,26 @@ extension PeekabooAgentService {
|
||||
}
|
||||
|
||||
stepText += content
|
||||
|
||||
let trimmed = content.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let eventHandler else { return }
|
||||
|
||||
if content.contains("<thinking>") || content.contains("Let me") ||
|
||||
content.contains("I need to") || content.contains("I'll")
|
||||
{
|
||||
isThinking = true
|
||||
}
|
||||
|
||||
let trimmed = content.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let eventHandler else { return }
|
||||
|
||||
if isThinking {
|
||||
await eventHandler.send(.thinkingMessage(content: content))
|
||||
let event: AgentEvent = if isThinking {
|
||||
.thinkingMessage(content: content)
|
||||
} else {
|
||||
await eventHandler.send(.assistantMessage(content: content))
|
||||
.assistantMessage(content: content)
|
||||
}
|
||||
|
||||
if buffersAssistantTextUntilDone {
|
||||
bufferedEvents.append(.event(event))
|
||||
} else {
|
||||
await eventHandler.send(event)
|
||||
}
|
||||
}
|
||||
|
||||
@ -129,6 +228,8 @@ extension PeekabooAgentService {
|
||||
_ toolCall: AgentToolCall,
|
||||
stepToolCalls: inout [AgentToolCall],
|
||||
seenToolCallIds: inout Set<String>,
|
||||
bufferedEvents: inout [BufferedStreamEvent],
|
||||
buffersEventsUntilDone: Bool,
|
||||
eventHandler: EventHandler?) async throws
|
||||
{
|
||||
if self.isVerbose {
|
||||
@ -148,15 +249,77 @@ extension PeekabooAgentService {
|
||||
let argumentsData = try JSONEncoder().encode(toolCall.arguments)
|
||||
let argumentsJSON = AgentToolCallArgumentPreview.redacted(from: argumentsData)
|
||||
|
||||
if isFirstOccurrence {
|
||||
await eventHandler.send(.toolCallStarted(name: toolCall.name, arguments: argumentsJSON))
|
||||
let event: AgentEvent = if isFirstOccurrence {
|
||||
.toolCallStarted(name: toolCall.name, arguments: argumentsJSON)
|
||||
} else {
|
||||
await eventHandler.send(.toolCallUpdated(name: toolCall.name, arguments: argumentsJSON))
|
||||
.toolCallUpdated(name: toolCall.name, arguments: argumentsJSON)
|
||||
}
|
||||
|
||||
if buffersEventsUntilDone {
|
||||
bufferedEvents.append(.event(event))
|
||||
} else {
|
||||
await eventHandler.send(event)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleReasoningDelta(_ content: String?, eventHandler: EventHandler?) async {
|
||||
private func handleReasoningDelta(
|
||||
_ content: String?,
|
||||
bufferedEvents: inout [BufferedStreamEvent],
|
||||
buffersEventsUntilDone: Bool,
|
||||
eventHandler: EventHandler?) async
|
||||
{
|
||||
guard let content, let eventHandler else { return }
|
||||
await eventHandler.send(.thinkingMessage(content: content))
|
||||
let event = AgentEvent.thinkingMessage(content: content)
|
||||
if buffersEventsUntilDone {
|
||||
bufferedEvents.append(.event(event))
|
||||
} else {
|
||||
await eventHandler.send(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 14.0, *)
|
||||
extension PeekabooAgentService {
|
||||
func buffersAgentTextStreamUntilDone(for model: LanguageModel) -> Bool {
|
||||
switch model {
|
||||
case .openai,
|
||||
.openaiCompatible,
|
||||
.openRouter,
|
||||
.together,
|
||||
.replicate,
|
||||
.google,
|
||||
.mistral,
|
||||
.groq,
|
||||
.grok,
|
||||
.azureOpenAI:
|
||||
return true
|
||||
case let .anthropic(model):
|
||||
return AnthropicModelCapabilityInference.hasStreamingRefusalRisk(modelId: model.modelId)
|
||||
case let .anthropicCompatible(modelId, _):
|
||||
return AnthropicModelCapabilityInference.hasStreamingRefusalRisk(modelId: modelId)
|
||||
case let .custom(provider):
|
||||
guard let parsed = ProviderParser.parse(provider.modelId) else {
|
||||
return AnthropicModelCapabilityInference.hasStreamingRefusalRisk(modelId: provider.modelId)
|
||||
}
|
||||
if let registeredProvider = CustomProviderRegistry.shared.get(parsed.provider) {
|
||||
switch registeredProvider.kind {
|
||||
case .openai:
|
||||
return true
|
||||
case .anthropic:
|
||||
return AnthropicModelCapabilityInference.hasStreamingRefusalRisk(modelId: parsed.model)
|
||||
}
|
||||
}
|
||||
if let configuredProvider = self.services.configuration.getCustomProvider(id: parsed.provider) {
|
||||
switch configuredProvider.type {
|
||||
case .openai:
|
||||
return true
|
||||
case .anthropic:
|
||||
return AnthropicModelCapabilityInference.hasStreamingRefusalRisk(modelId: parsed.model)
|
||||
}
|
||||
}
|
||||
return AnthropicModelCapabilityInference.hasStreamingRefusalRisk(modelId: parsed.model)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,9 @@
|
||||
// PeekabooCore
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import PeekabooAutomation
|
||||
import Tachikoma
|
||||
|
||||
@available(macOS 14.0, *)
|
||||
@ -68,6 +70,7 @@ extension PeekabooAgentService {
|
||||
pendingUserMessages: [ModelMessage] = []) async throws -> StreamingLoopOutcome
|
||||
{
|
||||
var state = StreamingLoopState(messages: initialMessages)
|
||||
let resolvedConfiguration = TachikomaConfiguration.resolve(.current)
|
||||
let toolContext = ToolHandlingContext(
|
||||
model: configuration.model,
|
||||
tools: configuration.tools,
|
||||
@ -106,6 +109,7 @@ extension PeekabooAgentService {
|
||||
|
||||
let output = try await self.collectStreamOutput(
|
||||
from: streamResult,
|
||||
model: configuration.model,
|
||||
eventHandler: configuration.eventHandler,
|
||||
stepIndex: stepIndex)
|
||||
|
||||
@ -114,20 +118,32 @@ extension PeekabooAgentService {
|
||||
state.usage = usage
|
||||
}
|
||||
|
||||
if case .anthropic = configuration.model {
|
||||
let shouldReplayReasoning = ReasoningReplayTarget(
|
||||
model: configuration.model,
|
||||
configuration: resolvedConfiguration,
|
||||
peekabooConfiguration: self.services.configuration) != nil
|
||||
if shouldReplayReasoning {
|
||||
for block in output.reasoningBlocks {
|
||||
state.messages.append(ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text(block.text)],
|
||||
channel: .thinking,
|
||||
metadata: .init(customData: [
|
||||
"anthropic.thinking.signature": block.signature,
|
||||
"anthropic.thinking.type": block.type,
|
||||
])))
|
||||
self.appendReasoningBlock(
|
||||
ProviderReasoningBlock(
|
||||
text: block.text,
|
||||
signature: block.signature,
|
||||
type: block.type),
|
||||
model: configuration.model,
|
||||
configuration: resolvedConfiguration,
|
||||
peekabooConfiguration: self.services.configuration,
|
||||
to: &state.messages)
|
||||
}
|
||||
}
|
||||
|
||||
if output.finishReason == .contentFilter {
|
||||
throw TachikomaError.apiError("Model refused to answer")
|
||||
}
|
||||
|
||||
if output.toolCalls.isEmpty {
|
||||
if shouldReplayReasoning, output.text.isEmpty, !output.reasoningBlocks.isEmpty {
|
||||
self.appendReasoningOnlyBoundary(to: &state.messages)
|
||||
}
|
||||
self.appendFinalStep(
|
||||
text: output.text,
|
||||
to: &state.messages,
|
||||
@ -186,9 +202,10 @@ extension PeekabooAgentService {
|
||||
text: String,
|
||||
to messages: inout [ModelMessage],
|
||||
steps: inout [GenerationStep],
|
||||
stepIndex: Int)
|
||||
stepIndex: Int,
|
||||
appendMessage: Bool = true)
|
||||
{
|
||||
if !text.isEmpty {
|
||||
if appendMessage, !text.isEmpty {
|
||||
messages.append(ModelMessage.assistant(text))
|
||||
}
|
||||
|
||||
@ -199,23 +216,247 @@ extension PeekabooAgentService {
|
||||
toolResults: []))
|
||||
}
|
||||
|
||||
func appendReasoningOnlyBoundary(to messages: inout [ModelMessage]) {
|
||||
messages.append(ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text("")],
|
||||
metadata: .init(customData: ["tachikoma.internal.boundary": "reasoning_only"])))
|
||||
}
|
||||
|
||||
func appendAnthropicReasoningBlock(
|
||||
text: String,
|
||||
signature: String?,
|
||||
type: String,
|
||||
model: LanguageModel? = nil,
|
||||
configuration: TachikomaConfiguration,
|
||||
peekabooConfiguration: ConfigurationManager? = nil,
|
||||
to messages: inout [ModelMessage])
|
||||
{
|
||||
var customData = ["anthropic.thinking.type": type]
|
||||
if let model, let target = ReasoningReplayTarget(
|
||||
model: model,
|
||||
configuration: configuration,
|
||||
peekabooConfiguration: peekabooConfiguration)
|
||||
{
|
||||
customData["anthropic.thinking.model"] = target.modelId
|
||||
customData["tachikoma.reasoning.provider"] = target.provider
|
||||
customData["tachikoma.reasoning.model"] = target.modelId
|
||||
if let endpointIdentity = target.endpointIdentity {
|
||||
customData["tachikoma.reasoning.base_url"] = endpointIdentity
|
||||
}
|
||||
}
|
||||
if let signature, !signature.isEmpty {
|
||||
customData["anthropic.thinking.signature"] = signature
|
||||
}
|
||||
messages.append(ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text(text)],
|
||||
channel: .thinking,
|
||||
metadata: .init(customData: customData)))
|
||||
}
|
||||
|
||||
func appendReasoningBlock(
|
||||
_ block: ProviderReasoningBlock,
|
||||
model: LanguageModel,
|
||||
configuration: TachikomaConfiguration,
|
||||
peekabooConfiguration: ConfigurationManager? = nil,
|
||||
to messages: inout [ModelMessage])
|
||||
{
|
||||
messages.append(ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text(block.text)],
|
||||
channel: .thinking,
|
||||
metadata: .init(customData: self.reasoningMetadata(
|
||||
for: block,
|
||||
model: model,
|
||||
configuration: configuration,
|
||||
peekabooConfiguration: peekabooConfiguration))))
|
||||
}
|
||||
|
||||
func appendResponseHistory(
|
||||
from response: ProviderResponse,
|
||||
model: LanguageModel,
|
||||
configuration: TachikomaConfiguration,
|
||||
peekabooConfiguration: ConfigurationManager? = nil,
|
||||
to messages: inout [ModelMessage])
|
||||
-> Bool
|
||||
{
|
||||
let historyStart = messages.count
|
||||
let nativeMessages = response.assistantMessages
|
||||
messages.append(contentsOf: nativeMessages)
|
||||
|
||||
for block in response.reasoning where !nativeMessages.containsReasoningBlock(block) {
|
||||
messages.append(ModelMessage(
|
||||
role: .assistant,
|
||||
content: [.text(block.text)],
|
||||
channel: .thinking,
|
||||
metadata: .init(customData: self.reasoningMetadata(
|
||||
for: block,
|
||||
model: model,
|
||||
configuration: configuration,
|
||||
peekabooConfiguration: peekabooConfiguration))))
|
||||
}
|
||||
|
||||
let toolCalls = response.toolCalls ?? []
|
||||
let missingToolCalls = toolCalls.filter { !nativeMessages.containsToolCall(id: $0.id) }
|
||||
let isMissingText = !nativeMessages.containsAssistantText(response.text)
|
||||
let addedHistory = messages[historyStart...]
|
||||
let needsReasoningOnlyBoundary = !addedHistory.isEmpty &&
|
||||
addedHistory.allSatisfy { $0.channel == .thinking } &&
|
||||
response.text.isEmpty &&
|
||||
missingToolCalls.isEmpty
|
||||
guard isMissingText || !missingToolCalls.isEmpty || needsReasoningOnlyBoundary else {
|
||||
return messages.count > historyStart
|
||||
}
|
||||
|
||||
var fallbackContent: [ModelMessage.ContentPart] = []
|
||||
if isMissingText || needsReasoningOnlyBoundary {
|
||||
fallbackContent.append(.text(response.text))
|
||||
}
|
||||
fallbackContent.append(contentsOf: missingToolCalls.map { .toolCall($0) })
|
||||
let metadata: MessageMetadata? = needsReasoningOnlyBoundary
|
||||
? .init(customData: ["tachikoma.internal.boundary": "reasoning_only"])
|
||||
: nil
|
||||
messages.append(ModelMessage(role: .assistant, content: fallbackContent, metadata: metadata))
|
||||
return true
|
||||
}
|
||||
|
||||
private func reasoningMetadata(
|
||||
for block: ProviderReasoningBlock,
|
||||
model: LanguageModel,
|
||||
configuration: TachikomaConfiguration,
|
||||
peekabooConfiguration: ConfigurationManager? = nil)
|
||||
-> [String: String]
|
||||
{
|
||||
if let rawJSON = block.rawJSON,
|
||||
let openRouterMetadata = self.openRouterReasoningMetadata(
|
||||
key: "openrouter.reasoning_details",
|
||||
value: rawJSON,
|
||||
type: block.type,
|
||||
model: model,
|
||||
configuration: configuration)
|
||||
{
|
||||
return openRouterMetadata
|
||||
}
|
||||
|
||||
if block.type == "openrouter_reasoning",
|
||||
let openRouterMetadata = self.openRouterReasoningMetadata(
|
||||
key: "openrouter.reasoning",
|
||||
value: block.text,
|
||||
type: block.type,
|
||||
model: model,
|
||||
configuration: configuration)
|
||||
{
|
||||
return openRouterMetadata
|
||||
}
|
||||
|
||||
var customData: [String: String] = if let target = ReasoningReplayTarget(
|
||||
model: model,
|
||||
configuration: configuration,
|
||||
peekabooConfiguration: peekabooConfiguration)
|
||||
{
|
||||
{
|
||||
var metadata = [
|
||||
"anthropic.thinking.type": block.type,
|
||||
"anthropic.thinking.model": target.modelId,
|
||||
"tachikoma.reasoning.provider": target.provider,
|
||||
"tachikoma.reasoning.model": target.modelId,
|
||||
]
|
||||
if let endpointIdentity = target.endpointIdentity {
|
||||
metadata["tachikoma.reasoning.base_url"] = endpointIdentity
|
||||
}
|
||||
return metadata
|
||||
}()
|
||||
} else {
|
||||
["tachikoma.reasoning.type": block.type]
|
||||
}
|
||||
|
||||
if let signature = block.signature, !signature.isEmpty {
|
||||
customData["anthropic.thinking.signature"] = signature
|
||||
customData["tachikoma.reasoning.signature"] = signature
|
||||
}
|
||||
return customData
|
||||
}
|
||||
|
||||
private func openRouterReasoningMetadata(
|
||||
key: String,
|
||||
value: String,
|
||||
type: String,
|
||||
model: LanguageModel,
|
||||
configuration: TachikomaConfiguration)
|
||||
-> [String: String]?
|
||||
{
|
||||
guard case let .openRouter(modelId) = model else { return nil }
|
||||
|
||||
let baseURL = configuration.getBaseURL(for: .custom("openrouter")) ?? "https://openrouter.ai/api/v1"
|
||||
var metadata = [
|
||||
key: value,
|
||||
"tachikoma.reasoning.type": type,
|
||||
"tachikoma.reasoning.provider": "openrouter",
|
||||
"tachikoma.reasoning.model": modelId,
|
||||
]
|
||||
if let endpointIdentity = self.canonicalReasoningEndpointIdentity(baseURL) {
|
||||
metadata["tachikoma.reasoning.base_url"] = endpointIdentity
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
private func canonicalReasoningEndpointIdentity(_ rawValue: String?) -> String? {
|
||||
guard
|
||||
let trimmed = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!trimmed.isEmpty,
|
||||
var components = URLComponents(string: trimmed),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased()
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
components.scheme = scheme
|
||||
components.host = host
|
||||
components.user = nil
|
||||
components.password = nil
|
||||
components.fragment = nil
|
||||
while components.path.count > 1, components.path.hasSuffix("/") {
|
||||
components.path.removeLast()
|
||||
}
|
||||
|
||||
guard let value = components.string, let data = value.data(using: .utf8) else { return nil }
|
||||
let digest = SHA256.hash(data: data)
|
||||
.map { String(format: "%02x", $0) }
|
||||
.joined()
|
||||
return "sha256:\(digest)"
|
||||
}
|
||||
|
||||
func handleToolCalls(
|
||||
stepText: String,
|
||||
toolCalls: [AgentToolCall],
|
||||
context: ToolHandlingContext,
|
||||
currentMessages: inout [ModelMessage],
|
||||
stepIndex: Int) async throws -> GenerationStep
|
||||
stepIndex: Int,
|
||||
appendAssistantMessage: Bool = true,
|
||||
emitToolStartEvents: Bool = false) async throws -> GenerationStep
|
||||
{
|
||||
self.appendAssistantMessage(
|
||||
stepText: stepText,
|
||||
toolCalls: toolCalls,
|
||||
to: ¤tMessages)
|
||||
if appendAssistantMessage {
|
||||
self.appendAssistantMessage(
|
||||
stepText: stepText,
|
||||
toolCalls: toolCalls,
|
||||
to: ¤tMessages)
|
||||
}
|
||||
|
||||
var toolResults: [AgentToolResult] = []
|
||||
|
||||
for (index, toolCall) in toolCalls.enumerated() {
|
||||
if emitToolStartEvents {
|
||||
try await self.sendToolStartEvent(toolCall, eventHandler: context.eventHandler)
|
||||
}
|
||||
|
||||
guard let tool = context.tool(named: toolCall.name) else {
|
||||
let unavailableResult = self.makeUnavailableToolResult(for: toolCall)
|
||||
await self.sendToolCompletionEvent(
|
||||
name: toolCall.name,
|
||||
payload: self.toolResultPayload(from: unavailableResult.result, toolName: toolCall.name),
|
||||
eventHandler: context.eventHandler)
|
||||
currentMessages.append(ModelMessage(role: .tool, content: [.toolResult(unavailableResult)]))
|
||||
toolResults.append(unavailableResult)
|
||||
continue
|
||||
@ -300,7 +541,7 @@ extension PeekabooAgentService {
|
||||
|
||||
do {
|
||||
let executionContext = ToolExecutionContext(
|
||||
messages: currentMessages,
|
||||
messages: currentMessages.sanitizedForToolContext(),
|
||||
model: context.model,
|
||||
settings: self.generationSettings(for: context.model),
|
||||
sessionId: context.sessionId,
|
||||
@ -461,6 +702,13 @@ extension PeekabooAgentService {
|
||||
await eventHandler.send(.toolCallCompleted(name: name, result: payload))
|
||||
}
|
||||
|
||||
private func sendToolStartEvent(_ toolCall: AgentToolCall, eventHandler: EventHandler?) async throws {
|
||||
guard let eventHandler else { return }
|
||||
let argumentsData = try JSONEncoder().encode(toolCall.arguments)
|
||||
let argumentsJSON = AgentToolCallArgumentPreview.redacted(from: argumentsData)
|
||||
await eventHandler.send(.toolCallStarted(name: toolCall.name, arguments: argumentsJSON))
|
||||
}
|
||||
|
||||
private func toolResultPayload(from result: AnyAgentToolValue, toolName: String) -> String {
|
||||
do {
|
||||
let jsonObject = try result.toJSON()
|
||||
@ -504,3 +752,236 @@ extension PeekabooAgentService {
|
||||
return json
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 14.0, *)
|
||||
extension [ModelMessage] {
|
||||
func sanitizedForProviderContext(
|
||||
model: LanguageModel,
|
||||
configuration: TachikomaConfiguration,
|
||||
peekabooConfiguration: ConfigurationManager? = nil)
|
||||
-> [ModelMessage]
|
||||
{
|
||||
let target = ReasoningReplayTarget(
|
||||
model: model,
|
||||
configuration: configuration,
|
||||
peekabooConfiguration: peekabooConfiguration)
|
||||
var previousSourceWasRetainedThinking = false
|
||||
var sanitizedMessages: [ModelMessage] = []
|
||||
sanitizedMessages.reserveCapacity(self.count)
|
||||
|
||||
for message in self {
|
||||
guard message.channel == .thinking ||
|
||||
message.metadata?.customData?["tachikoma.internal.boundary"] == "reasoning_only"
|
||||
else {
|
||||
sanitizedMessages.append(message)
|
||||
previousSourceWasRetainedThinking = false
|
||||
continue
|
||||
}
|
||||
|
||||
guard message.channel == .thinking else {
|
||||
if target?.allowsReasoningBoundaries == true, previousSourceWasRetainedThinking {
|
||||
sanitizedMessages.append(message)
|
||||
}
|
||||
previousSourceWasRetainedThinking = false
|
||||
continue
|
||||
}
|
||||
guard let target else {
|
||||
previousSourceWasRetainedThinking = false
|
||||
continue
|
||||
}
|
||||
let customData = message.metadata?.customData ?? [:]
|
||||
let shouldKeep = target.matches(customData) ||
|
||||
(target.allowsLegacyUnknown &&
|
||||
customData["anthropic.thinking.model"] == nil &&
|
||||
customData["anthropic.thinking.type"] != nil)
|
||||
if shouldKeep {
|
||||
sanitizedMessages.append(message)
|
||||
}
|
||||
previousSourceWasRetainedThinking = shouldKeep
|
||||
}
|
||||
|
||||
return sanitizedMessages
|
||||
}
|
||||
|
||||
fileprivate func sanitizedForToolContext() -> [ModelMessage] {
|
||||
self.filter { message in
|
||||
message.channel != .thinking &&
|
||||
message.metadata?.customData?["tachikoma.internal.boundary"] != "reasoning_only"
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func containsAssistantText(_ text: String) -> Bool {
|
||||
guard !text.isEmpty else { return true }
|
||||
let assistantTexts = self.flatMap { message -> [String] in
|
||||
guard message.role == .assistant, message.channel != .thinking else {
|
||||
return []
|
||||
}
|
||||
return message.content.compactMap { part in
|
||||
if case let .text(value) = part {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return assistantTexts.contains(text) || assistantTexts.joined() == text
|
||||
}
|
||||
|
||||
fileprivate func containsReasoningBlock(_ reasoning: ProviderReasoningBlock) -> Bool {
|
||||
self.contains { message in
|
||||
message.role == .assistant && message.channel == .thinking && message.content.contains { part in
|
||||
guard case let .text(value) = part else { return false }
|
||||
if let signature = reasoning.signature, !signature.isEmpty {
|
||||
return message.metadata?.customData?["anthropic.thinking.signature"] == signature ||
|
||||
message.metadata?.customData?["tachikoma.reasoning.signature"] == signature
|
||||
}
|
||||
return value == reasoning.text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func containsToolCall(id: String) -> Bool {
|
||||
self.contains { message in
|
||||
message.role == .assistant && message.content.contains { part in
|
||||
if case let .toolCall(toolCall) = part {
|
||||
return toolCall.id == id
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 14.0, *)
|
||||
private struct ReasoningReplayTarget {
|
||||
let provider: String
|
||||
let modelId: String
|
||||
let baseURL: String?
|
||||
let allowsReasoningBoundaries: Bool
|
||||
let allowsLegacyUnknown: Bool
|
||||
|
||||
init?(
|
||||
model: LanguageModel,
|
||||
configuration: TachikomaConfiguration,
|
||||
peekabooConfiguration: ConfigurationManager? = nil)
|
||||
{
|
||||
switch model {
|
||||
case let .anthropic(anthropicModel):
|
||||
self.provider = "anthropic"
|
||||
self.modelId = anthropicModel.modelId
|
||||
self.baseURL = configuration.getBaseURL(for: .anthropic) ?? Provider.anthropic.defaultBaseURL
|
||||
self.allowsReasoningBoundaries = true
|
||||
self.allowsLegacyUnknown = !LanguageModel.Anthropic.isFable(modelId: anthropicModel.modelId)
|
||||
case let .anthropicCompatible(modelId, baseURL):
|
||||
self.provider = "anthropic-compatible"
|
||||
self.modelId = modelId
|
||||
self.baseURL = baseURL
|
||||
self.allowsReasoningBoundaries = true
|
||||
self.allowsLegacyUnknown = !LanguageModel.Anthropic.isFable(modelId: modelId)
|
||||
case let .openRouter(modelId):
|
||||
self.provider = "openrouter"
|
||||
self.modelId = modelId
|
||||
self.baseURL = configuration.getBaseURL(for: .custom("openrouter")) ?? "https://openrouter.ai/api/v1"
|
||||
self.allowsReasoningBoundaries = true
|
||||
self.allowsLegacyUnknown = false
|
||||
case let .minimax(model):
|
||||
self.provider = "minimax"
|
||||
self.modelId = model.modelId
|
||||
self.baseURL = configuration.getBaseURL(for: .minimax) ?? Provider.minimax.defaultBaseURL
|
||||
self.allowsReasoningBoundaries = true
|
||||
self.allowsLegacyUnknown = true
|
||||
case let .minimaxCN(model):
|
||||
self.provider = "minimax-cn"
|
||||
self.modelId = model.modelId
|
||||
self.baseURL = configuration.getBaseURL(for: .minimaxCN) ?? Provider.minimaxCN.defaultBaseURL
|
||||
self.allowsReasoningBoundaries = true
|
||||
self.allowsLegacyUnknown = true
|
||||
case let .custom(provider):
|
||||
if let anthropicProvider = provider as? AnthropicProvider {
|
||||
self.provider = "anthropic"
|
||||
self.modelId = anthropicProvider.modelId
|
||||
self.baseURL = anthropicProvider.baseURL ?? Provider.anthropic.defaultBaseURL
|
||||
self.allowsReasoningBoundaries = true
|
||||
self.allowsLegacyUnknown = !LanguageModel.Anthropic.isFable(modelId: anthropicProvider.modelId)
|
||||
return
|
||||
}
|
||||
if let compatibleProvider = provider as? AnthropicCompatibleProvider {
|
||||
self.provider = "anthropic-compatible"
|
||||
self.modelId = compatibleProvider.modelId
|
||||
self.baseURL = compatibleProvider.baseURL
|
||||
self.allowsReasoningBoundaries = true
|
||||
self.allowsLegacyUnknown = !LanguageModel.Anthropic.isFable(modelId: compatibleProvider.modelId)
|
||||
return
|
||||
}
|
||||
guard let parsed = ProviderParser.parse(provider.modelId) else {
|
||||
return nil
|
||||
}
|
||||
if let registeredProvider = CustomProviderRegistry.shared.get(parsed.provider),
|
||||
registeredProvider.kind == .anthropic
|
||||
{
|
||||
self.provider = "custom-anthropic"
|
||||
self.modelId = parsed.model
|
||||
self.baseURL = registeredProvider.baseURL
|
||||
self.allowsReasoningBoundaries = true
|
||||
self.allowsLegacyUnknown = !LanguageModel.Anthropic.isFable(modelId: parsed.model)
|
||||
return
|
||||
}
|
||||
guard Self.isAnthropicCustomProvider(
|
||||
providerID: parsed.provider,
|
||||
peekabooConfiguration: peekabooConfiguration)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
self.provider = "custom-anthropic"
|
||||
self.modelId = provider.modelId
|
||||
self.baseURL = provider.baseURL
|
||||
self.allowsReasoningBoundaries = true
|
||||
self.allowsLegacyUnknown = !LanguageModel.Anthropic.isFable(modelId: provider.modelId)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func isAnthropicCustomProvider(
|
||||
providerID: String,
|
||||
peekabooConfiguration: ConfigurationManager?)
|
||||
-> Bool
|
||||
{
|
||||
if CustomProviderRegistry.shared.get(providerID)?.kind == .anthropic {
|
||||
return true
|
||||
}
|
||||
return peekabooConfiguration?.getCustomProvider(id: providerID)?.type == .anthropic
|
||||
}
|
||||
|
||||
func matches(_ customData: [String: String]) -> Bool {
|
||||
customData["tachikoma.reasoning.provider"] == self.provider &&
|
||||
customData["tachikoma.reasoning.model"] == self.modelId &&
|
||||
customData["tachikoma.reasoning.base_url"] == self.endpointIdentity
|
||||
}
|
||||
|
||||
var endpointIdentity: String? {
|
||||
guard
|
||||
let trimmed = self.baseURL?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!trimmed.isEmpty,
|
||||
var components = URLComponents(string: trimmed),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased()
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
components.scheme = scheme
|
||||
components.host = host
|
||||
components.user = nil
|
||||
components.password = nil
|
||||
components.fragment = nil
|
||||
while components.path.count > 1, components.path.hasSuffix("/") {
|
||||
components.path.removeLast()
|
||||
}
|
||||
|
||||
guard let value = components.string, let data = value.data(using: .utf8) else { return nil }
|
||||
let digest = SHA256.hash(data: data)
|
||||
.map { String(format: "%02x", $0) }
|
||||
.joined()
|
||||
return "sha256:\(digest)"
|
||||
}
|
||||
}
|
||||
|
||||
@ -263,11 +263,11 @@ public final class PeekabooAgentService: AgentServiceProtocol {
|
||||
endTime: Date()))
|
||||
}
|
||||
|
||||
// If we have an event delegate, use streaming
|
||||
if eventDelegate != nil {
|
||||
// If we have an event delegate, emit events even for non-streaming models.
|
||||
if let eventDelegate {
|
||||
// SAFETY: We ensure that the delegate is only accessed on MainActor
|
||||
// This is a legacy API pattern that predates Swift's strict concurrency
|
||||
let unsafeDelegate = UnsafeTransfer<any AgentEventDelegate>(eventDelegate!)
|
||||
let unsafeDelegate = UnsafeTransfer<any AgentEventDelegate>(eventDelegate)
|
||||
|
||||
// Create event stream infrastructure
|
||||
let (eventStream, eventContinuation) = AsyncStream<AgentEvent>.makeStream()
|
||||
@ -289,35 +289,46 @@ public final class PeekabooAgentService: AgentServiceProtocol {
|
||||
eventContinuation.yield(event)
|
||||
}
|
||||
|
||||
defer {
|
||||
eventContinuation.finish()
|
||||
eventTask.cancel()
|
||||
}
|
||||
|
||||
// Create event delegate wrapper for streaming
|
||||
let streamingDelegate = StreamingEventDelegate { chunk in
|
||||
await eventHandler.send(.assistantMessage(content: chunk))
|
||||
}
|
||||
|
||||
let sessionContext = try await self.prepareSession(
|
||||
task: task,
|
||||
model: selectedModel,
|
||||
label: "streaming",
|
||||
logBehavior: .always)
|
||||
do {
|
||||
let sessionContext = try await self.prepareSession(
|
||||
task: task,
|
||||
model: selectedModel,
|
||||
label: "streaming",
|
||||
logBehavior: .always)
|
||||
|
||||
let result = try await self.executeWithStreaming(
|
||||
context: sessionContext,
|
||||
model: selectedModel,
|
||||
maxSteps: maxSteps,
|
||||
streamingDelegate: streamingDelegate,
|
||||
queueMode: queueMode,
|
||||
eventHandler: eventHandler,
|
||||
enhancementOptions: enhancementOptions)
|
||||
let result = if selectedModel.supportsStreaming {
|
||||
try await self.executeWithStreaming(
|
||||
context: sessionContext,
|
||||
model: selectedModel,
|
||||
maxSteps: maxSteps,
|
||||
streamingDelegate: streamingDelegate,
|
||||
queueMode: queueMode,
|
||||
eventHandler: eventHandler,
|
||||
enhancementOptions: enhancementOptions)
|
||||
} else {
|
||||
try await self.executeWithoutStreaming(
|
||||
context: sessionContext,
|
||||
model: selectedModel,
|
||||
maxSteps: maxSteps,
|
||||
eventHandler: eventHandler,
|
||||
enhancementOptions: enhancementOptions)
|
||||
}
|
||||
|
||||
// Send completion event with usage information
|
||||
await eventHandler.send(.completed(summary: result.content, usage: result.usage))
|
||||
|
||||
return result
|
||||
// Send completion event with usage information
|
||||
await eventHandler.send(.completed(summary: result.content, usage: result.usage))
|
||||
eventContinuation.finish()
|
||||
await eventTask.value
|
||||
return result
|
||||
} catch {
|
||||
eventContinuation.finish()
|
||||
eventTask.cancel()
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
// Non-streaming execution
|
||||
let sessionContext = try await self.prepareSession(
|
||||
@ -342,6 +353,20 @@ public final class PeekabooAgentService: AgentServiceProtocol {
|
||||
{
|
||||
// Execute a task with streaming output
|
||||
let selectedModel = self.resolveModel(model)
|
||||
if !selectedModel.supportsStreaming {
|
||||
let sessionContext = try await self.prepareSession(
|
||||
task: task,
|
||||
model: selectedModel,
|
||||
label: "(non-streaming)",
|
||||
logBehavior: .verboseOnly)
|
||||
let result = try await self.executeWithoutStreaming(
|
||||
context: sessionContext,
|
||||
model: selectedModel,
|
||||
maxSteps: 20)
|
||||
await streamHandler(result.content)
|
||||
return result
|
||||
}
|
||||
|
||||
// For streaming without event handler, create a dummy delegate that discards chunks
|
||||
let dummyDelegate = StreamingEventDelegate { _ in /* discard */ }
|
||||
let sessionContext = try await self.prepareSession(
|
||||
|
||||
@ -32,7 +32,7 @@ public struct AnalyzeTool: MCPTool {
|
||||
{ "image_path": "/tmp/chart.png", "question": "Which category has the highest value in this bar chart?" }
|
||||
The AI will analyze the image and attempt to answer your question based on its visual content.
|
||||
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
|
||||
"""
|
||||
}
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ public struct ClickTool: MCPTool {
|
||||
Clicks on UI elements or coordinates.
|
||||
Supports element queries, specific IDs from `see` or `inspect_ui`, or raw coordinates.
|
||||
Background delivery is the default. Set `foreground` to true when the next step needs keyboard focus.
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
|
||||
"""
|
||||
}
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ public struct DockTool: MCPTool {
|
||||
Actions: launch, right-click (with menu selection), hide, show, list
|
||||
Can list all dock items including persistent and running applications.
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5
|
||||
and anthropic/claude-opus-4-7
|
||||
and anthropic/claude-opus-4-8
|
||||
"""
|
||||
}
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ public struct DragTool: MCPTool {
|
||||
Perform drag and drop operations between UI elements or coordinates.
|
||||
Supports element queries, specific IDs, or raw coordinates for both start and end points.
|
||||
Includes focus options for handling windows in different spaces.
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
|
||||
"""
|
||||
}
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ public struct HotkeyTool: MCPTool {
|
||||
Presses keyboard shortcuts.
|
||||
Simulates one primary key plus optional modifiers, like Cmd+C or Ctrl+Shift+T.
|
||||
If app/pid/window targeting is supplied, sends the hotkey to that process in the background by default.
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
|
||||
"""
|
||||
}
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ public struct ListTool: MCPTool {
|
||||
- { "item_type": "running_applications" }
|
||||
- { "item_type": "application_windows", "app": "Notes", "include_window_details": ["ids", "bounds"] }
|
||||
- { "item_type": "server_status" }
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
|
||||
"""
|
||||
|
||||
public var inputSchema: Value {
|
||||
|
||||
@ -27,7 +27,7 @@ public struct MenuTool: MCPTool {
|
||||
- Save document: { "action": "click", "app": "TextEdit", "path": "File > Save" }
|
||||
- Copy selection: { "action": "click", "app": "Safari", "path": "Edit > Copy" }
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5
|
||||
and anthropic/claude-opus-4-7
|
||||
and anthropic/claude-opus-4-8
|
||||
"""
|
||||
}
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ public struct MoveTool: MCPTool {
|
||||
Move the mouse cursor to a specific position or UI element.
|
||||
Supports absolute coordinates, UI element targeting, or centering on screen.
|
||||
Can animate movement smoothly over a specified duration.
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
|
||||
"""
|
||||
}
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ public struct PerformActionTool: MCPTool {
|
||||
"""
|
||||
Invokes a named accessibility action on an element, such as AXPress or AXShowMenu.
|
||||
Use with element IDs from `see` or `inspect_ui` when a semantic action is available.
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
|
||||
"""
|
||||
}
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ public struct PermissionsTool: MCPTool {
|
||||
Check macOS system permissions required for automation.
|
||||
Verifies both Screen Recording and Accessibility permissions.
|
||||
Returns the current permission status for each required permission.
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
|
||||
"""
|
||||
|
||||
public var inputSchema: Value {
|
||||
|
||||
@ -20,7 +20,7 @@ public struct ScrollTool: MCPTool {
|
||||
Can target specific elements or scroll at current mouse position.
|
||||
Supports smooth scrolling and configurable speed.
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5
|
||||
and anthropic/claude-opus-4-7
|
||||
and anthropic/claude-opus-4-8
|
||||
"""
|
||||
}
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ public struct SeeTool: MCPTool {
|
||||
Returns Peekaboo element IDs (B1 for buttons, T1 for text fields, etc.) that can be
|
||||
used with interaction commands and creates/updates a snapshot that tracks UI state.
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5
|
||||
and anthropic/claude-opus-4-7.
|
||||
and anthropic/claude-opus-4-8.
|
||||
"""
|
||||
}
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ public struct SetValueTool: MCPTool {
|
||||
"""
|
||||
Sets an accessibility element value directly without synthesizing keystrokes.
|
||||
Use for forms and controls after `see` or `inspect_ui` returns an element ID. Requires a settable AX value.
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
|
||||
"""
|
||||
}
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ public struct SleepTool: MCPTool {
|
||||
public let description = """
|
||||
Pauses execution for a specified duration.
|
||||
Useful for waiting between UI actions or allowing animations to complete.
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
|
||||
"""
|
||||
|
||||
public var inputSchema: Value {
|
||||
|
||||
@ -49,7 +49,7 @@ public struct SpaceTool: MCPTool {
|
||||
- Move window to space 3: { "action": "move-window", "app": "Safari", "to": 3 }
|
||||
- Move window to current space: { "action": "move-window", "app": "TextEdit", "to_current": true }
|
||||
- Move and follow: { "action": "move-window", "app": "Terminal", "to": 2, "follow": true }
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
|
||||
"""
|
||||
}
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ public struct SwipeTool: MCPTool {
|
||||
Performs a swipe/drag gesture from one point to another.
|
||||
Useful for dragging elements, swiping through content, or gesture-based interactions.
|
||||
Creates smooth movement with configurable duration.
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
|
||||
"""
|
||||
}
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ public struct TypeTool: MCPTool {
|
||||
Can target specific elements or type at current keyboard focus. Uses background delivery by default when a
|
||||
target process is known; set `foreground` when the app must be focused first.
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5
|
||||
and anthropic/claude-opus-4-7
|
||||
and anthropic/claude-opus-4-8
|
||||
"""
|
||||
}
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@ public struct WindowTool: MCPTool {
|
||||
- { "action": "move", "app": "TextEdit", "x": 100, "y": 100 }
|
||||
- { "action": "set-bounds", "app": "Terminal", "x": 0, "y": 0, "width": 1280, "height": 720 }
|
||||
- { "action": "close", "app": "Safari", "title": "Grindr Web" }
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
|
||||
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
|
||||
"""
|
||||
}
|
||||
|
||||
|
||||
@ -48,7 +48,7 @@ private enum ConfigurationDefaults {
|
||||
static let configurationTemplate = """
|
||||
{
|
||||
"aiProviders": {
|
||||
"providers": "openai/gpt-5.5,anthropic/claude-opus-4-8"
|
||||
"providers": "openai/gpt-5.5,anthropic/claude-opus-4-8"
|
||||
},
|
||||
"defaults": {
|
||||
"savePath": "~/Desktop/Screenshots",
|
||||
|
||||
@ -3,6 +3,47 @@ import Foundation
|
||||
import ImageIO
|
||||
import Tachikoma
|
||||
|
||||
public struct InferredAnthropicModelCapabilities: Sendable {
|
||||
public let contextLength: Int
|
||||
public let maxOutputTokens: Int
|
||||
public let supportsStreaming: Bool
|
||||
}
|
||||
|
||||
public enum AnthropicModelCapabilityInference {
|
||||
public static func capabilities(for modelId: String) -> InferredAnthropicModelCapabilities? {
|
||||
guard let model = self.anthropicModel(for: modelId) else { return nil }
|
||||
return InferredAnthropicModelCapabilities(
|
||||
contextLength: model.contextLength,
|
||||
maxOutputTokens: model.maxOutputTokens,
|
||||
supportsStreaming: model.supportsStreaming)
|
||||
}
|
||||
|
||||
public static func hasStreamingRefusalRisk(modelId: String) -> Bool {
|
||||
if let capabilities = self.capabilities(for: modelId) {
|
||||
return !capabilities.supportsStreaming
|
||||
}
|
||||
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId)
|
||||
}
|
||||
|
||||
private static func anthropicModel(for modelId: String) -> LanguageModel.Anthropic? {
|
||||
let trimmed = modelId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
let parsedModel = ProviderParser.parse(trimmed)?.model
|
||||
let candidates = [trimmed, parsedModel].compactMap(\.self)
|
||||
for candidate in candidates {
|
||||
guard case let .anthropic(model) = LanguageModel.parse(from: "anthropic/\(candidate)") else {
|
||||
continue
|
||||
}
|
||||
if case .custom = model {
|
||||
continue
|
||||
}
|
||||
return model
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private final class PeekabooCustomProviderModel: ModelProvider, @unchecked Sendable {
|
||||
enum Kind {
|
||||
case openai
|
||||
@ -26,7 +67,8 @@ private final class PeekabooCustomProviderModel: ModelProvider, @unchecked Senda
|
||||
apiKey: String?,
|
||||
additionalHeaders: [String: String],
|
||||
supportsVision: Bool,
|
||||
supportsTools: Bool)
|
||||
supportsTools: Bool,
|
||||
maxOutputTokens: Int?)
|
||||
{
|
||||
self.providerID = providerID
|
||||
self.resolvedModelID = resolvedModelID
|
||||
@ -35,10 +77,18 @@ private final class PeekabooCustomProviderModel: ModelProvider, @unchecked Senda
|
||||
self.baseURL = baseURL
|
||||
self.apiKey = apiKey
|
||||
self.additionalHeaders = additionalHeaders
|
||||
let inferredAnthropicCapabilities = kind == .anthropic
|
||||
? AnthropicModelCapabilityInference.capabilities(for: resolvedModelID)
|
||||
: nil
|
||||
let inferredMaxOutputTokens = inferredAnthropicCapabilities?.maxOutputTokens ?? 4096
|
||||
let hasStreamingRefusalRisk = inferredAnthropicCapabilities.map { !$0.supportsStreaming } ??
|
||||
LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: resolvedModelID)
|
||||
self.capabilities = ModelCapabilities(
|
||||
supportsVision: supportsVision,
|
||||
supportsTools: supportsTools,
|
||||
supportsStreaming: true)
|
||||
supportsStreaming: !hasStreamingRefusalRisk,
|
||||
contextLength: inferredAnthropicCapabilities?.contextLength ?? 128_000,
|
||||
maxOutputTokens: maxOutputTokens ?? inferredMaxOutputTokens)
|
||||
}
|
||||
|
||||
func generateText(request: ProviderRequest) async throws -> ProviderResponse {
|
||||
@ -95,7 +145,10 @@ private final class PeekabooCustomProviderModel: ModelProvider, @unchecked Senda
|
||||
modelId: self.resolvedModelID,
|
||||
baseURL: self.baseURL ?? "",
|
||||
configuration: self.compatibleConfiguration(),
|
||||
additionalHeaders: self.additionalHeaders)
|
||||
additionalHeaders: self.additionalHeaders,
|
||||
reasoningProvider: "custom-anthropic",
|
||||
reasoningModelId: self.modelId,
|
||||
reasoningBaseURL: self.baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
@ -595,7 +648,8 @@ public final class PeekabooAIService {
|
||||
apiKey: configuration.resolveCredentialReference(provider.options.apiKey),
|
||||
additionalHeaders: provider.options.headers ?? [:],
|
||||
supportsVision: model?.supportsVision ?? true,
|
||||
supportsTools: model?.supportsTools ?? true)
|
||||
supportsTools: model?.supportsTools ?? true,
|
||||
maxOutputTokens: model?.maxTokens)
|
||||
}
|
||||
|
||||
private static func customProviderID(matching providerID: String, configuration: ConfigurationManager) -> String? {
|
||||
|
||||
@ -32,12 +32,14 @@ struct AnthropicModelTests {
|
||||
|
||||
@Test
|
||||
func `Anthropic default model selection`() {
|
||||
// Test that Claude Opus is the default
|
||||
// Tachikoma keeps the library-level Claude shortcut stable; Peekaboo chooses Fable separately.
|
||||
let defaultModel = Model.default
|
||||
let claudeModel = Model.claude
|
||||
|
||||
#expect(defaultModel.providerName == "Anthropic")
|
||||
#expect(claudeModel.providerName == "Anthropic")
|
||||
#expect(defaultModel.modelId == "claude-opus-4-8")
|
||||
#expect(claudeModel.modelId == "claude-opus-4-8")
|
||||
|
||||
// Test model shortcuts
|
||||
let anthropicModels = [
|
||||
@ -80,6 +82,7 @@ struct AnthropicModelTests {
|
||||
@Test
|
||||
func `Anthropic vision model capabilities`() {
|
||||
let visionCapableModels = [
|
||||
Model.anthropic(.fable5),
|
||||
Model.anthropic(.opus45),
|
||||
Model.anthropic(.sonnet46),
|
||||
Model.anthropic(.haiku45),
|
||||
@ -107,6 +110,7 @@ struct AnthropicModelTests {
|
||||
#expect(opus45.modelId != haiku45.modelId)
|
||||
|
||||
// Current Anthropic context windows are model-specific, not a simple family hierarchy.
|
||||
#expect(Model.anthropic(.fable5).contextLength == 1_000_000)
|
||||
#expect(Model.anthropic(.opus48).contextLength == 1_000_000)
|
||||
#expect(sonnet46.contextLength == 1_000_000)
|
||||
#expect(opus45.contextLength == 500_000)
|
||||
@ -116,15 +120,20 @@ struct AnthropicModelTests {
|
||||
|
||||
@Test
|
||||
func `Anthropic current models support tools`() {
|
||||
let fable5 = Model.anthropic(.fable5)
|
||||
let opus48 = Model.anthropic(.opus48)
|
||||
let sonnet46 = Model.anthropic(.sonnet46)
|
||||
|
||||
#expect(fable5.providerName == "Anthropic")
|
||||
#expect(opus48.providerName == "Anthropic")
|
||||
#expect(sonnet46.providerName == "Anthropic")
|
||||
|
||||
#expect(!fable5.modelId.contains("thinking"))
|
||||
#expect(!opus48.modelId.contains("thinking"))
|
||||
#expect(!sonnet46.modelId.contains("thinking"))
|
||||
|
||||
#expect(fable5.supportsTools == true)
|
||||
#expect(fable5.supportsStreaming == false)
|
||||
#expect(opus48.supportsTools == true)
|
||||
#expect(sonnet46.supportsTools == true)
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import Tachikoma
|
||||
import Testing
|
||||
@testable import PeekabooAutomation
|
||||
|
||||
@Suite(.serialized)
|
||||
struct PeekabooAIServiceProviderTests {
|
||||
@Test
|
||||
@MainActor
|
||||
@ -123,6 +124,119 @@ struct PeekabooAIServiceProviderTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `Custom provider advertises configured model max tokens`() throws {
|
||||
try self.withIsolatedEnvironment(
|
||||
["PEEKABOO_CUSTOM_PROVIDER_KEY": "resolved-secret"],
|
||||
configurationJSON: """
|
||||
{
|
||||
"aiProviders": { "providers": "local-proxy/large" },
|
||||
"customProviders": {
|
||||
"local-proxy": {
|
||||
"name": "Local Proxy",
|
||||
"type": "openai",
|
||||
"enabled": true,
|
||||
"options": {
|
||||
"baseURL": "http://localhost:8317/v1",
|
||||
"apiKey": "${PEEKABOO_CUSTOM_PROVIDER_KEY}"
|
||||
},
|
||||
"models": {
|
||||
"large": {
|
||||
"name": "Large Local Model",
|
||||
"supportsVision": true,
|
||||
"supportsTools": true,
|
||||
"maxTokens": 8192
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""") {
|
||||
let service = PeekabooAIService()
|
||||
let model = try #require(service.availableModels().first)
|
||||
let provider = try service.tachikomaConfiguration(for: model).makeProvider(for: model)
|
||||
|
||||
#expect(provider.capabilities.maxOutputTokens == 8192)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `Custom Opus 4 8 provider advertises nonstreaming capability`() throws {
|
||||
try self.withIsolatedEnvironment(
|
||||
["PEEKABOO_CUSTOM_PROVIDER_KEY": "resolved-secret"],
|
||||
configurationJSON: """
|
||||
{
|
||||
"aiProviders": { "providers": "local-anthropic/claude-opus-4-8" },
|
||||
"customProviders": {
|
||||
"local-anthropic": {
|
||||
"name": "Local Anthropic",
|
||||
"type": "anthropic",
|
||||
"enabled": true,
|
||||
"options": {
|
||||
"baseURL": "http://localhost:8317",
|
||||
"apiKey": "${PEEKABOO_CUSTOM_PROVIDER_KEY}"
|
||||
},
|
||||
"models": {
|
||||
"claude-opus-4-8": {
|
||||
"name": "Claude Opus 4.8",
|
||||
"supportsTools": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""") {
|
||||
let service = PeekabooAIService()
|
||||
let model = try #require(service.availableModels().first)
|
||||
let provider = try service.tachikomaConfiguration(for: model).makeProvider(for: model)
|
||||
|
||||
#expect(model.modelId == "local-anthropic/claude-opus-4-8")
|
||||
#expect(!provider.capabilities.supportsStreaming)
|
||||
#expect(provider.capabilities.contextLength == 1_000_000)
|
||||
#expect(provider.capabilities.maxOutputTokens == 128_000)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `Custom Opus 4 7 provider advertises inferred current Claude capabilities`() throws {
|
||||
try self.withIsolatedEnvironment(
|
||||
["PEEKABOO_CUSTOM_PROVIDER_KEY": "resolved-secret"],
|
||||
configurationJSON: """
|
||||
{
|
||||
"aiProviders": { "providers": "local-anthropic/claude-opus-4-7" },
|
||||
"customProviders": {
|
||||
"local-anthropic": {
|
||||
"name": "Local Anthropic",
|
||||
"type": "anthropic",
|
||||
"enabled": true,
|
||||
"options": {
|
||||
"baseURL": "http://localhost:8317",
|
||||
"apiKey": "${PEEKABOO_CUSTOM_PROVIDER_KEY}"
|
||||
},
|
||||
"models": {
|
||||
"claude-opus-4-7": {
|
||||
"name": "Claude Opus 4.7",
|
||||
"supportsTools": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""") {
|
||||
let service = PeekabooAIService()
|
||||
let model = try #require(service.availableModels().first)
|
||||
let provider = try service.tachikomaConfiguration(for: model).makeProvider(for: model)
|
||||
|
||||
#expect(model.modelId == "local-anthropic/claude-opus-4-7")
|
||||
#expect(provider.capabilities.supportsStreaming)
|
||||
#expect(provider.capabilities.contextLength == 1_000_000)
|
||||
#expect(provider.capabilities.maxOutputTokens == 128_000)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `Saved custom provider models become default candidates`() throws {
|
||||
@ -607,6 +721,24 @@ struct PeekabooAIServiceProviderTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `Explicit Fable provider list does not fall back to unrelated credentials`() throws {
|
||||
try self.withIsolatedEnvironment(
|
||||
["GEMINI_API_KEY": "key"],
|
||||
configurationJSON: """
|
||||
{
|
||||
"aiProviders": {
|
||||
"providers": "openai/gpt-5.5,anthropic/claude-fable-5"
|
||||
}
|
||||
}
|
||||
""") {
|
||||
let service = PeekabooAIService()
|
||||
#expect(service.resolvedDefaultModel == .openai(.gpt55))
|
||||
#expect(service.availableModels() == [.openai(.gpt55), .anthropic(.fable5)])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `Explicit Grok provider list preserves server-redirected model slug`() throws {
|
||||
|
||||
@ -0,0 +1,200 @@
|
||||
import Foundation
|
||||
import Tachikoma
|
||||
import Testing
|
||||
@testable import PeekabooAgentRuntime
|
||||
@testable import PeekabooAutomation
|
||||
@testable import PeekabooCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct PeekabooAgentGenerationSettingsTests {
|
||||
@Test
|
||||
@MainActor
|
||||
func `Configured custom Anthropic provider clamps temperature to Anthropic range`() throws {
|
||||
try self.withTemporaryConfig(
|
||||
"""
|
||||
{
|
||||
"aiProviders": { "providers": "local-anthropic/claude-opus-4-7" },
|
||||
"agent": { "temperature": 1.8, "maxTokens": 128000 },
|
||||
"customProviders": {
|
||||
"local-anthropic": {
|
||||
"name": "Local Anthropic",
|
||||
"type": "anthropic",
|
||||
"enabled": true,
|
||||
"options": {
|
||||
"baseURL": "https://anthropic-compatible.example",
|
||||
"apiKey": "test-key"
|
||||
},
|
||||
"models": {
|
||||
"claude-opus-4-7": {
|
||||
"name": "Claude Opus 4.7",
|
||||
"supportsTools": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""") {
|
||||
let configuration = ConfigurationManager.shared
|
||||
_ = configuration.loadConfiguration()
|
||||
let aiService = PeekabooAIService(configuration: configuration)
|
||||
let model = try #require(aiService.availableModels().first)
|
||||
let agentService = try PeekabooAgentService(services: PeekabooServices())
|
||||
|
||||
#expect(model.modelId == "local-anthropic/claude-opus-4-7")
|
||||
#expect(agentService.generationSettings(for: model).temperature == 1.0)
|
||||
#expect(agentService.generationSettings(for: model).maxTokens == 128_000)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `Anthropic compatible current Claude models clamp max tokens to inferred provider caps`() throws {
|
||||
try self.withTemporaryConfig(
|
||||
"""
|
||||
{
|
||||
"agent": { "maxTokens": 128000 }
|
||||
}
|
||||
""") {
|
||||
let agentService = try PeekabooAgentService(services: PeekabooServices())
|
||||
|
||||
#expect(agentService.generationSettings(for: .anthropicCompatible(
|
||||
modelId: "claude-opus-4-7",
|
||||
baseURL: "https://anthropic-compatible.example")).maxTokens == 128_000)
|
||||
#expect(agentService.generationSettings(for: .anthropicCompatible(
|
||||
modelId: "claude-sonnet-4-6",
|
||||
baseURL: "https://anthropic-compatible.example")).maxTokens == 64000)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `OpenAI compatible GPT 5 provider-qualified model omits unsupported temperature`() throws {
|
||||
try self.withTemporaryConfig(
|
||||
"""
|
||||
{
|
||||
"agent": { "temperature": 0.7 }
|
||||
}
|
||||
""") {
|
||||
let agentService = try PeekabooAgentService(services: PeekabooServices())
|
||||
let settings = agentService.generationSettings(for: .openaiCompatible(
|
||||
modelId: "openai/gpt-5.5",
|
||||
baseURL: "https://openai-compatible.example"))
|
||||
|
||||
#expect(settings.temperature == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `Configured custom OpenAI GPT 5 provider omits unsupported temperature`() throws {
|
||||
try self.withTemporaryConfig(
|
||||
"""
|
||||
{
|
||||
"aiProviders": { "providers": "local-openai/gpt-5.5" },
|
||||
"agent": { "temperature": 0.7 },
|
||||
"customProviders": {
|
||||
"local-openai": {
|
||||
"name": "Local OpenAI",
|
||||
"type": "openai",
|
||||
"enabled": true,
|
||||
"options": {
|
||||
"baseURL": "https://openai-compatible.example",
|
||||
"apiKey": "test-key"
|
||||
},
|
||||
"models": {
|
||||
"gpt-5.5": {
|
||||
"name": "GPT-5.5",
|
||||
"supportsTools": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""") {
|
||||
let configuration = ConfigurationManager.shared
|
||||
_ = configuration.loadConfiguration()
|
||||
let aiService = PeekabooAIService(configuration: configuration)
|
||||
let model = try #require(aiService.availableModels().first)
|
||||
let agentService = try PeekabooAgentService(services: PeekabooServices())
|
||||
|
||||
#expect(model.modelId == "local-openai/gpt-5.5")
|
||||
#expect(agentService.generationSettings(for: model).temperature == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `Configured custom OpenAI provider-qualified GPT 5 model omits unsupported temperature`() throws {
|
||||
try self.withTemporaryConfig(
|
||||
"""
|
||||
{
|
||||
"aiProviders": { "providers": "local-openai/openai/gpt-5.5" },
|
||||
"agent": { "temperature": 0.7 },
|
||||
"customProviders": {
|
||||
"local-openai": {
|
||||
"name": "Local OpenAI",
|
||||
"type": "openai",
|
||||
"enabled": true,
|
||||
"options": {
|
||||
"baseURL": "https://openai-compatible.example",
|
||||
"apiKey": "test-key"
|
||||
},
|
||||
"models": {
|
||||
"openai/gpt-5.5": {
|
||||
"name": "GPT-5.5",
|
||||
"supportsTools": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""") {
|
||||
let configuration = ConfigurationManager.shared
|
||||
_ = configuration.loadConfiguration()
|
||||
let aiService = PeekabooAIService(configuration: configuration)
|
||||
let model = try #require(aiService.availableModels().first)
|
||||
let agentService = try PeekabooAgentService(services: PeekabooServices())
|
||||
|
||||
#expect(model.modelId == "local-openai/openai/gpt-5.5")
|
||||
#expect(agentService.generationSettings(for: model).temperature == nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func withTemporaryConfig(_ configurationJSON: String, body: () throws -> Void) throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("peekaboo-config-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
try configurationJSON.write(
|
||||
to: tempDir.appendingPathComponent("config.json"),
|
||||
atomically: true,
|
||||
encoding: .utf8)
|
||||
|
||||
let keys = [
|
||||
"PEEKABOO_CONFIG_DIR",
|
||||
"PEEKABOO_CONFIG_DISABLE_MIGRATION",
|
||||
"PEEKABOO_AI_PROVIDERS",
|
||||
]
|
||||
let previous = Dictionary(uniqueKeysWithValues: keys.map { key in
|
||||
(key, getenv(key).map { String(cString: $0) })
|
||||
})
|
||||
|
||||
setenv("PEEKABOO_CONFIG_DIR", tempDir.path, 1)
|
||||
setenv("PEEKABOO_CONFIG_DISABLE_MIGRATION", "1", 1)
|
||||
unsetenv("PEEKABOO_AI_PROVIDERS")
|
||||
ConfigurationManager.shared.resetForTesting()
|
||||
|
||||
defer {
|
||||
for key in keys {
|
||||
if case let value?? = previous[key] {
|
||||
setenv(key, value, 1)
|
||||
} else {
|
||||
unsetenv(key)
|
||||
}
|
||||
}
|
||||
ConfigurationManager.shared.resetForTesting()
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
try body()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
import Tachikoma
|
||||
import Testing
|
||||
@testable import PeekabooAgentRuntime
|
||||
@testable import PeekabooCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct PeekabooAgentOpenRouterStreamingReasoningTests {
|
||||
@Test
|
||||
@MainActor
|
||||
func `Streaming OpenRouter reasoning is replayed with OpenRouter metadata`() async throws {
|
||||
let provider = StreamingOpenRouterReasoningReplayProvider()
|
||||
let configuration = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
configuration.setProviderFactoryOverride { _, _ in provider }
|
||||
|
||||
let previousConfiguration = TachikomaConfiguration.default
|
||||
TachikomaConfiguration.default = configuration
|
||||
defer {
|
||||
TachikomaConfiguration.default = previousConfiguration
|
||||
}
|
||||
|
||||
let agentService = try PeekabooAgentService(
|
||||
services: PeekabooServices(),
|
||||
defaultModel: .openRouter(modelId: "openai/gpt-oss-120b"))
|
||||
|
||||
_ = try await agentService.executeTask(
|
||||
"Use a tool, then continue.",
|
||||
maxSteps: 2,
|
||||
model: .openRouter(modelId: "openai/gpt-oss-120b"),
|
||||
eventDelegate: NoopAgentEventDelegate(),
|
||||
enhancementOptions: nil)
|
||||
|
||||
let secondRequestMessages = try #require(provider.secondRequestMessages)
|
||||
let reasoningMessage = try #require(secondRequestMessages.first { message in
|
||||
message.channel == .thinking
|
||||
})
|
||||
let customData = reasoningMessage.metadata?.customData ?? [:]
|
||||
|
||||
#expect(reasoningMessage.content == [.text("streamed openrouter thinking")])
|
||||
#expect(customData["openrouter.reasoning"] == "streamed openrouter thinking")
|
||||
#expect(customData["anthropic.thinking.type"] == nil)
|
||||
#expect(customData["tachikoma.reasoning.provider"] == "openrouter")
|
||||
#expect(customData["tachikoma.reasoning.model"] == "openai/gpt-oss-120b")
|
||||
#expect(customData["tachikoma.reasoning.base_url"] != nil)
|
||||
}
|
||||
}
|
||||
|
||||
private final class StreamingOpenRouterReasoningReplayProvider: ModelProvider, @unchecked Sendable {
|
||||
let modelId = "streaming-openrouter-reasoning-replay-provider"
|
||||
let baseURL: String? = nil
|
||||
let apiKey: String? = nil
|
||||
let capabilities = ModelCapabilities()
|
||||
|
||||
private let lock = NSLock()
|
||||
private var requestCount = 0
|
||||
private var capturedSecondRequestMessages: [ModelMessage]?
|
||||
|
||||
var secondRequestMessages: [ModelMessage]? {
|
||||
self.lock.withLock { self.capturedSecondRequestMessages }
|
||||
}
|
||||
|
||||
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
|
||||
ProviderResponse(text: "done", finishReason: .stop)
|
||||
}
|
||||
|
||||
func streamText(request: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, any Error> {
|
||||
let requestNumber = self.lock.withLock {
|
||||
self.requestCount += 1
|
||||
if self.requestCount == 2 {
|
||||
self.capturedSecondRequestMessages = request.messages
|
||||
}
|
||||
return self.requestCount
|
||||
}
|
||||
|
||||
return AsyncThrowingStream { continuation in
|
||||
if requestNumber == 1 {
|
||||
continuation.yield(.reasoning("streamed openrouter thinking", type: "openrouter_reasoning"))
|
||||
continuation.yield(.tool(AgentToolCall(
|
||||
id: "missing-tool",
|
||||
name: "missing_test_tool",
|
||||
arguments: [:])))
|
||||
continuation.yield(.done(finishReason: .toolCalls))
|
||||
} else {
|
||||
continuation.yield(.text("done"))
|
||||
continuation.yield(.done(finishReason: .stop))
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class NoopAgentEventDelegate: AgentEventDelegate {
|
||||
func agentDidEmitEvent(_: AgentEvent) {}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,255 @@
|
||||
import Tachikoma
|
||||
import Testing
|
||||
@testable import PeekabooAgentRuntime
|
||||
@testable import PeekabooCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct PeekabooAgentStreamingContentFilterTests {
|
||||
@Test
|
||||
@MainActor
|
||||
func `Buffered provider content filter does not emit blocked reasoning text`() async throws {
|
||||
let provider = ReasoningContentFilterProvider(reasoning: "blocked private reasoning")
|
||||
let configuration = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
configuration.setProviderFactoryOverride { _, _ in provider }
|
||||
|
||||
let previousConfiguration = TachikomaConfiguration.default
|
||||
TachikomaConfiguration.default = configuration
|
||||
defer {
|
||||
TachikomaConfiguration.default = previousConfiguration
|
||||
}
|
||||
|
||||
let delegate = CapturingReasoningEventDelegate()
|
||||
let agentService = try PeekabooAgentService(
|
||||
services: PeekabooServices(),
|
||||
defaultModel: .openai(.gpt55))
|
||||
|
||||
await #expect(throws: (any Error).self) {
|
||||
_ = try await agentService.executeTask(
|
||||
"trigger filter",
|
||||
maxSteps: 1,
|
||||
model: .openai(.gpt55),
|
||||
eventDelegate: delegate,
|
||||
enhancementOptions: nil)
|
||||
}
|
||||
|
||||
#expect(!delegate.events.containsThinkingMessage("blocked private reasoning"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `Anthropic buffering is limited to refusal-risk models`() throws {
|
||||
let agentService = try PeekabooAgentService(
|
||||
services: PeekabooServices(),
|
||||
defaultModel: .anthropic(.opus48))
|
||||
|
||||
#expect(agentService.buffersAgentTextStreamUntilDone(for: .anthropic(.fable5)))
|
||||
#expect(agentService.buffersAgentTextStreamUntilDone(for: .anthropic(.opus48)))
|
||||
#expect(!agentService.buffersAgentTextStreamUntilDone(for: .anthropic(.opus47)))
|
||||
#expect(!agentService.buffersAgentTextStreamUntilDone(for: .anthropic(.sonnet46)))
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `Anthropic compatible buffering follows model risk`() throws {
|
||||
let agentService = try PeekabooAgentService(
|
||||
services: PeekabooServices(),
|
||||
defaultModel: .anthropic(.opus48))
|
||||
|
||||
#expect(agentService.buffersAgentTextStreamUntilDone(for: .anthropicCompatible(
|
||||
modelId: "claude-opus-4-8",
|
||||
baseURL: "https://custom.anthropic.example/v1")))
|
||||
#expect(!agentService.buffersAgentTextStreamUntilDone(for: .anthropicCompatible(
|
||||
modelId: "claude-opus-4-7",
|
||||
baseURL: "https://custom.anthropic.example/v1")))
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `Custom Anthropic buffering follows model risk`() throws {
|
||||
let agentService = try PeekabooAgentService(
|
||||
services: PeekabooServices(),
|
||||
defaultModel: .anthropic(.opus48))
|
||||
|
||||
let opus48Provider = ReasoningContentFilterProvider(
|
||||
modelId: "claude-opus-4-8",
|
||||
reasoning: "blocked custom reasoning")
|
||||
let opus47Provider = ReasoningContentFilterProvider(
|
||||
modelId: "claude-opus-4-7",
|
||||
reasoning: "safe custom reasoning")
|
||||
|
||||
#expect(agentService.buffersAgentTextStreamUntilDone(for: .custom(provider: opus48Provider)))
|
||||
#expect(!agentService.buffersAgentTextStreamUntilDone(for: .custom(provider: opus47Provider)))
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `Buffered provider missing terminal event does not emit partial output`() async throws {
|
||||
let provider = TruncatedBufferedProvider()
|
||||
let configuration = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
configuration.setProviderFactoryOverride { _, _ in provider }
|
||||
|
||||
let previousConfiguration = TachikomaConfiguration.default
|
||||
TachikomaConfiguration.default = configuration
|
||||
defer {
|
||||
TachikomaConfiguration.default = previousConfiguration
|
||||
}
|
||||
|
||||
let delegate = CapturingReasoningEventDelegate()
|
||||
let agentService = try PeekabooAgentService(
|
||||
services: PeekabooServices(),
|
||||
defaultModel: .openai(.gpt55))
|
||||
|
||||
await #expect(throws: (any Error).self) {
|
||||
_ = try await agentService.executeTask(
|
||||
"trigger truncated stream",
|
||||
maxSteps: 1,
|
||||
model: .openai(.gpt55),
|
||||
eventDelegate: delegate,
|
||||
enhancementOptions: nil)
|
||||
}
|
||||
|
||||
#expect(!delegate.events.containsAssistantMessage("partial assistant text"))
|
||||
#expect(delegate.events.firstToolStart(named: "missing_test_tool") == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `Buffered provider nil finish reason still flushes terminal stream`() async throws {
|
||||
let provider = NilFinishBufferedProvider()
|
||||
let configuration = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
configuration.setProviderFactoryOverride { _, _ in provider }
|
||||
|
||||
let previousConfiguration = TachikomaConfiguration.default
|
||||
TachikomaConfiguration.default = configuration
|
||||
defer {
|
||||
TachikomaConfiguration.default = previousConfiguration
|
||||
}
|
||||
|
||||
let delegate = CapturingReasoningEventDelegate()
|
||||
let agentService = try PeekabooAgentService(
|
||||
services: PeekabooServices(),
|
||||
defaultModel: .openai(.gpt55))
|
||||
|
||||
let response = try await agentService.executeTask(
|
||||
"trigger nil finish stream",
|
||||
maxSteps: 1,
|
||||
model: .openai(.gpt55),
|
||||
eventDelegate: delegate,
|
||||
enhancementOptions: nil)
|
||||
|
||||
#expect(response.content == "assistant text")
|
||||
#expect(delegate.events.containsAssistantMessage("assistant text"))
|
||||
#expect(delegate.events.firstToolStart(named: "terminal_test_tool") != nil)
|
||||
}
|
||||
}
|
||||
|
||||
private final class ReasoningContentFilterProvider: ModelProvider, @unchecked Sendable {
|
||||
let modelId: String
|
||||
let baseURL: String? = nil
|
||||
let apiKey: String? = nil
|
||||
let capabilities = ModelCapabilities()
|
||||
private let reasoning: String
|
||||
|
||||
init(modelId: String = "reasoning-content-filter-provider", reasoning: String) {
|
||||
self.modelId = modelId
|
||||
self.reasoning = reasoning
|
||||
}
|
||||
|
||||
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
|
||||
ProviderResponse(
|
||||
text: "blocked partial text",
|
||||
finishReason: .contentFilter)
|
||||
}
|
||||
|
||||
func streamText(request _: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, any Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
continuation.yield(.reasoning(self.reasoning))
|
||||
continuation.yield(.text("blocked partial text"))
|
||||
continuation.yield(.done(finishReason: .contentFilter))
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class TruncatedBufferedProvider: ModelProvider, @unchecked Sendable {
|
||||
let modelId = "truncated-buffered-provider"
|
||||
let baseURL: String? = nil
|
||||
let apiKey: String? = nil
|
||||
let capabilities = ModelCapabilities()
|
||||
|
||||
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
|
||||
ProviderResponse(text: "partial assistant text", finishReason: nil)
|
||||
}
|
||||
|
||||
func streamText(request _: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, any Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
continuation.yield(.text("partial assistant text"))
|
||||
continuation.yield(.tool(AgentToolCall(
|
||||
id: "truncated-tool",
|
||||
name: "missing_test_tool",
|
||||
arguments: [:])))
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class NilFinishBufferedProvider: ModelProvider, @unchecked Sendable {
|
||||
let modelId = "nil-finish-buffered-provider"
|
||||
let baseURL: String? = nil
|
||||
let apiKey: String? = nil
|
||||
let capabilities = ModelCapabilities()
|
||||
|
||||
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
|
||||
ProviderResponse(text: "assistant text", finishReason: nil)
|
||||
}
|
||||
|
||||
func streamText(request _: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, any Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
continuation.yield(.text("assistant text"))
|
||||
continuation.yield(.tool(AgentToolCall(
|
||||
id: "terminal-tool",
|
||||
name: "terminal_test_tool",
|
||||
arguments: [:])))
|
||||
continuation.yield(.done(finishReason: nil))
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class CapturingReasoningEventDelegate: AgentEventDelegate {
|
||||
private(set) var events: [AgentEvent] = []
|
||||
|
||||
func agentDidEmitEvent(_ event: AgentEvent) {
|
||||
self.events.append(event)
|
||||
}
|
||||
}
|
||||
|
||||
extension [AgentEvent] {
|
||||
fileprivate func containsAssistantMessage(_ expected: String) -> Bool {
|
||||
self.contains { event in
|
||||
if case let .assistantMessage(content) = event {
|
||||
return content == expected
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func containsThinkingMessage(_ expected: String) -> Bool {
|
||||
self.contains { event in
|
||||
if case let .thinkingMessage(content) = event {
|
||||
return content == expected
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func firstToolStart(named expected: String) -> Int? {
|
||||
self.firstIndex { event in
|
||||
if case let .toolCallStarted(name, _) = event {
|
||||
return name == expected
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
import Tachikoma
|
||||
import Testing
|
||||
@testable import PeekabooAgentRuntime
|
||||
@testable import PeekabooCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct PeekabooAgentStreamingReasoningBoundaryTests {
|
||||
@Test
|
||||
@MainActor
|
||||
func `Streaming native reasoning only response records assistant boundary`() async throws {
|
||||
let provider = StreamingReasoningOnlyProvider()
|
||||
let configuration = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
configuration.setProviderFactoryOverride { _, _ in provider }
|
||||
|
||||
let previousConfiguration = TachikomaConfiguration.default
|
||||
TachikomaConfiguration.default = configuration
|
||||
defer {
|
||||
TachikomaConfiguration.default = previousConfiguration
|
||||
}
|
||||
|
||||
let agentService = try PeekabooAgentService(
|
||||
services: PeekabooServices(),
|
||||
defaultModel: .anthropic(.opus47))
|
||||
|
||||
let result = try await agentService.executeTaskStreaming(
|
||||
"think only",
|
||||
model: .anthropic(.opus47)) { _ in }
|
||||
|
||||
let thinkingIndex = try #require(result.messages.firstIndex { $0.channel == .thinking })
|
||||
let boundaryMessage = try #require(result.messages.dropFirst(thinkingIndex + 1).first)
|
||||
|
||||
#expect(boundaryMessage.role == .assistant)
|
||||
#expect(boundaryMessage.content == [.text("")])
|
||||
#expect(boundaryMessage.metadata?.customData?["tachikoma.internal.boundary"] == "reasoning_only")
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `Streaming reasoning clears signature-first block before next pending block`() async throws {
|
||||
let stream = AsyncThrowingStream<TextStreamDelta, any Error> { continuation in
|
||||
continuation.yield(.reasoning("", signature: "sig-first", type: "thinking"))
|
||||
continuation.yield(.reasoning("first", type: "thinking"))
|
||||
continuation.yield(.reasoning("second", type: "thinking"))
|
||||
continuation.yield(.reasoning("", signature: "sig-second", type: "thinking"))
|
||||
continuation.yield(.done(finishReason: .stop))
|
||||
continuation.finish()
|
||||
}
|
||||
let agentService = try PeekabooAgentService(
|
||||
services: PeekabooServices(),
|
||||
defaultModel: .anthropic(.opus47))
|
||||
|
||||
let output = try await agentService.collectStreamOutput(
|
||||
from: StreamTextResult(
|
||||
stream: stream,
|
||||
model: .anthropic(.opus47),
|
||||
settings: GenerationSettings()),
|
||||
model: .anthropic(.opus47),
|
||||
eventHandler: nil,
|
||||
stepIndex: 0)
|
||||
|
||||
#expect(output.reasoningBlocks.count == 2)
|
||||
#expect(output.reasoningBlocks.first?.text == "first")
|
||||
#expect(output.reasoningBlocks.first?.signature == "sig-first")
|
||||
#expect(output.reasoningBlocks.dropFirst().first?.text == "second")
|
||||
#expect(output.reasoningBlocks.dropFirst().first?.signature == "sig-second")
|
||||
}
|
||||
}
|
||||
|
||||
private final class StreamingReasoningOnlyProvider: ModelProvider, @unchecked Sendable {
|
||||
let modelId = "streaming-reasoning-only-provider"
|
||||
let baseURL: String? = nil
|
||||
let apiKey: String? = nil
|
||||
let capabilities = ModelCapabilities()
|
||||
|
||||
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
|
||||
ProviderResponse(text: "", finishReason: .stop)
|
||||
}
|
||||
|
||||
func streamText(request _: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, any Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
continuation.yield(.reasoning("streamed native thinking", signature: "sig-native-stream", type: "thinking"))
|
||||
continuation.yield(.done(finishReason: .stop))
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -62,7 +62,7 @@ npx -y @steipete/peekaboo
|
||||
# "command": "npx",
|
||||
# "args": ["-y", "@steipete/peekaboo"],
|
||||
# "env": {
|
||||
# "PEEKABOO_AI_PROVIDERS": "openai/gpt-5.5,anthropic/claude-opus-4-7"
|
||||
# "PEEKABOO_AI_PROVIDERS": "openai/gpt-5.5,anthropic/claude-opus-4-8"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit 9754b309dd4bab4d9eef10c120aa5877a34c1c20
|
||||
Subproject commit 0326be775e5f10831ed9c16b14c0b24229b19285
|
||||
@ -70,7 +70,7 @@ If your client supports environment variables, add provider and logging settings
|
||||
"command": "npx",
|
||||
"args": ["-y", "@steipete/peekaboo", "mcp"],
|
||||
"env": {
|
||||
"PEEKABOO_AI_PROVIDERS": "openai/gpt-5.5,anthropic/claude-opus-4-7",
|
||||
"PEEKABOO_AI_PROVIDERS": "openai/gpt-5.5,anthropic/claude-opus-4-8",
|
||||
"PEEKABOO_LOG_LEVEL": "info"
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@ read_when:
|
||||
| `--chat` | Force the interactive chat loop even when stdin/stdout are not TTYs. |
|
||||
| `--dry-run` | Emit the planned steps without actually invoking tools. |
|
||||
| `--max-steps <n>` | Cap how many tool invocations the agent may issue before aborting (default: 100). |
|
||||
| `--model gpt-5.5|claude-opus-4.7|gemini-3-flash|minimax|minimax-cn/<model>|openrouter/<provider>/<model>|ollama/<model>|lmstudio/<model>` | Override the default model (`gpt-5.5`). Input is validated against supported hosted providers and local model providers. |
|
||||
| `--model gpt-5.5|claude-fable-5|gemini-3-flash|minimax|minimax-cn/<model>|openrouter/<provider>/<model>|ollama/<model>|lmstudio/<model>` | Override the default model (`gpt-5.5`). Input is validated against supported hosted providers and local model providers. |
|
||||
| `--resume` / `--resume-session <id>` | Continue the most recent session or a specific session ID. |
|
||||
| `--list-sessions` | Print cached sessions (id, task, timestamps, message count) instead of running anything. |
|
||||
| `--no-cache` | Always create a fresh session even if one is already active. |
|
||||
|
||||
@ -19,7 +19,7 @@ pages instead of duplicating provider lists in multiple places.
|
||||
| Provider | Models we test | Credential |
|
||||
| --- | --- | --- |
|
||||
| **OpenAI** | gpt-5, gpt-5-mini, gpt-4.1 | `OPENAI_API_KEY` |
|
||||
| **Anthropic** | claude-opus-4-7, claude-sonnet-4-6, claude-haiku-4-5 | `ANTHROPIC_API_KEY` |
|
||||
| **Anthropic** | claude-fable-5, claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5 | `ANTHROPIC_API_KEY` |
|
||||
| **xAI** | grok-4 | `XAI_API_KEY` |
|
||||
| **Google** | gemini-3.1-pro-preview, gemini-3-flash | `GEMINI_API_KEY` |
|
||||
| **MiniMax** | MiniMax-M2.7, MiniMax-M2.7-highspeed | `MINIMAX_API_KEY` |
|
||||
@ -54,7 +54,8 @@ See [configuration.md](configuration.md) for the full precedence table.
|
||||
## Picking a model
|
||||
|
||||
```bash
|
||||
peekaboo agent --model claude-opus-4-7 "summarize this window"
|
||||
peekaboo agent --model claude-fable-5 "summarize this window"
|
||||
peekaboo agent --model claude-opus-4-8 "summarize this window"
|
||||
peekaboo agent --model gemini-3-flash "summarize this window"
|
||||
peekaboo agent --model minimax "summarize this window"
|
||||
peekaboo agent --model minimax-cn/MiniMax-M2.7 "summarize this window"
|
||||
@ -64,7 +65,7 @@ peekaboo agent --model ollama/llama3.1:8b "describe this screenshot"
|
||||
peekaboo agent --model lmstudio/openai/gpt-oss-120b "summarize this window"
|
||||
```
|
||||
|
||||
Defaults come from `agent.defaultModel` in `~/.peekaboo/config.json`. Set a per-project default with `PEEKABOO_AGENT_MODEL`.
|
||||
Defaults come from `agent.defaultModel` in `~/.peekaboo/config.json`. Anthropic defaults stay on Opus 4.8 for zero-retention compatibility; select Fable explicitly when your Anthropic organization allows it. Set a per-project default with `PEEKABOO_AGENT_MODEL`.
|
||||
|
||||
## Tool calling
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user