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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions App/InterlessApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ private struct WorkspaceShell: View {
cancelModelLoad: { session.cancelModelLoad() },
saveHuggingFaceToken: session.saveHuggingFaceToken,
deleteHuggingFaceToken: session.deleteHuggingFaceToken,
saveAnthropicAPIKey: session.saveAnthropicAPIKey,
deleteAnthropicAPIKey: session.deleteAnthropicAPIKey,
saveOpenAIAPIKey: session.saveOpenAIAPIKey,
deleteOpenAIAPIKey: session.deleteOpenAIAPIKey,
retryRecoveryAction: session.retryRecoveryAction,
dismissRecoveryItem: session.dismissRecoveryItem,
clearRecoveryJournal: session.clearRecoveryJournal,
Expand Down
64 changes: 60 additions & 4 deletions AppCore/AppDependencyFactory.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import Agents
import CloudInference
import Core
import MLXEngine
import Persistence
Expand Down Expand Up @@ -214,7 +215,9 @@ public struct LiveAppDependencyFactory: AppDependencyFactory {
config: config?.effective,
settings: currentSettings,
resourceBudget: ResourceBudget.resolved(for: currentSettings.resourceProfile))
let canAdvertiseNativeTools = runtime.settings.toolCallFormat != nil
let orchestratorModelID = Self.agentModelID(agentCatalog: agentCatalog, agentIDs: ["build", "plan"], fallback: runtime.settings.orchestratorModelID)
let canAdvertiseNativeTools = Self.advertisesNativeTools(
modelID: orchestratorModelID, toolCallFormat: runtime.settings.toolCallFormat)
return await Self.makeAgent(
root: root,
store: store,
Expand Down Expand Up @@ -256,14 +259,18 @@ public struct LiveAppDependencyFactory: AppDependencyFactory {
// Read-only sub-agent in its own context: same workspace tools minus
// writes/network and the task tool (no recursion). Synchronous, so it
// reuses the orchestrator gate and stays serial / 8GB-safe.
// The sub-agent runs on the utility role's model, so its tool
// advertisement keys off that id (cloud → native tools regardless of
// toolCallFormat).
let subagentModelID = Self.agentModelID(agentCatalog: agentCatalog, agentIDs: ["general"], fallback: runtime.settings.utilityModelID)
let subagent = await Self.makeAgent(
root: root,
store: store,
controller: resolvedController,
settings: runtime.settings,
metricsRecorder: metricsRecorder,
includesWorkspaceContext: true,
advertisesTools: runtime.settings.toolCallFormat != nil,
advertisesTools: Self.advertisesNativeTools(modelID: subagentModelID, toolCallFormat: runtime.settings.toolCallFormat),
explorationOnly: true,
readOnly: true,
snapshotStore: snapshotStore,
Expand All @@ -289,10 +296,16 @@ public struct LiveAppDependencyFactory: AppDependencyFactory {
let runtimeSettings = runtime.settings
let errors = runtimeSettings.validationErrors()
guard errors.isEmpty else { throw AppRuntimeError.invalidModelSettings(errors) }
let singleAgentMode = Self.usesSingleAgentMode(runtimeSettings)
let orchestratorModelID = Self.agentModelID(agentCatalog: agentCatalog, agentIDs: ["build", "plan"], fallback: runtimeSettings.orchestratorModelID)
let utilityModelID = Self.agentModelID(agentCatalog: agentCatalog, agentIDs: ["general"], fallback: runtimeSettings.utilityModelID)
let singleModelID = Self.agentModelID(agentCatalog: agentCatalog, agentIDs: ["general", "build"], fallback: runtimeSettings.orchestratorModelID)
let singleAgentMode = Self.effectiveSingleAgentMode(
settings: runtimeSettings, orchestratorID: orchestratorModelID, utilityID: utilityModelID)
try Self.validateCloudUsage(
orchestrator: singleAgentMode ? singleModelID : orchestratorModelID,
utility: singleAgentMode ? "" : utilityModelID,
embeddings: runtimeSettings.embeddingsModelID,
allowCloudModels: runtimeSettings.allowCloudModels)
await resolvedController.unload(role: .orchestrator)
await resolvedController.unload(role: .utility)
await resolvedController.unload(role: .embeddings)
Expand Down Expand Up @@ -353,6 +366,28 @@ public struct LiveAppDependencyFactory: AppDependencyFactory {
})
}

/// Gates hosted (cloud) model usage: cloud orchestrator/utility roles require
/// explicit consent, and cloud embedding models are unsupported. Reuses the
/// `invalidModelSettings` surface so the message reaches the UI like any other
/// settings problem.
static func validateCloudUsage(
orchestrator: String,
utility: String,
embeddings: String,
allowCloudModels: Bool
) throws {
var errors: [String] = []
if CloudModelResolver.isCloud(embeddings) {
errors.append("Cloud embedding models are not supported; use a local embeddings model.")
}
if !allowCloudModels {
for id in [orchestrator, utility] where CloudModelResolver.isCloud(id) {
errors.append("\"\(id)\" is a cloud model. Enable \"Allow cloud models\" in Settings to use it.")
}
}
guard errors.isEmpty else { throw AppRuntimeError.invalidModelSettings(errors) }
}

private static func makeAgent(
root: URL,
store: any WorkspaceIndexStore,
Expand All @@ -374,7 +409,10 @@ public struct LiveAppDependencyFactory: AppDependencyFactory {
settings: settings,
resourceBudget: budget)
let runtimeSettings = runtime.settings
let singleAgentMode = usesSingleAgentMode(runtimeSettings)
let orchestratorModelID = agentModelID(agentCatalog: agentCatalog, agentIDs: ["build", "plan"], fallback: runtimeSettings.orchestratorModelID)
let utilityModelID = agentModelID(agentCatalog: agentCatalog, agentIDs: ["general"], fallback: runtimeSettings.utilityModelID)
let singleAgentMode = effectiveSingleAgentMode(
settings: runtimeSettings, orchestratorID: orchestratorModelID, utilityID: utilityModelID)
var policy = runtime.toolPolicy
if readOnly {
// Sub-agents are read-only regardless of workspace config: deny writes,
Expand Down Expand Up @@ -479,6 +517,24 @@ public struct LiveAppDependencyFactory: AppDependencyFactory {
settings.usesSingleAgentMode()
}

/// Single-agent collapse is about LOCAL RAM, not cloud. Cloud roles cost zero
/// local memory, so only collapse to one agent when small-RAM AND both roles
/// are local (two local models won't fit). Otherwise allow per-role mixing
/// (e.g. cloud orchestrator + local/cloud sub-agent) even on an 8 GB Mac.
static func effectiveSingleAgentMode(
settings: ModelSettingsViewState, orchestratorID: String, utilityID: String
) -> Bool {
guard usesSingleAgentMode(settings) else { return false }
return !CloudModelResolver.isCloud(orchestratorID) && !CloudModelResolver.isCloud(utilityID)
}

/// Native tool-calling is available for any cloud model (provider-native) and
/// for local models only when a tool-call format is configured. `toolCallFormat`
/// is a local text-grammar concept and must not gate cloud roles.
static func advertisesNativeTools(modelID: String, toolCallFormat: ModelToolCallFormat?) -> Bool {
CloudModelResolver.isCloud(modelID) || toolCallFormat != nil
}

private static func mergeSearchHits(
lexical: [SearchHit],
semantic: [SearchHit],
Expand Down
66 changes: 66 additions & 0 deletions AppCore/WorkspaceSessionModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,72 @@ public final class WorkspaceSessionModel {
}
}

public func saveAnthropicAPIKey(_ key: String) {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
Task {
do {
try await secretStore.save(
trimmed,
service: InterlessSecrets.service,
account: InterlessSecrets.anthropicAPIKeyAccount)
appendNotice(severity: .info, title: "Key saved", message: "Anthropic API key was saved in Keychain.")
await publish(.init(kind: .model, message: "Saved cloud provider key", metadata: ["provider": "anthropic", "store": "keychain"]))
} catch {
appendNotice(severity: .error, title: "Key save failed", message: String(describing: error))
await recordFailure(kind: .model, message: "Failed to save Anthropic API key.")
}
}
}

public func deleteAnthropicAPIKey() {
Task {
do {
try await secretStore.delete(
service: InterlessSecrets.service,
account: InterlessSecrets.anthropicAPIKeyAccount)
appendNotice(severity: .info, title: "Key deleted", message: "Anthropic API key was removed from Keychain.")
await publish(.init(kind: .model, message: "Deleted cloud provider key", metadata: ["provider": "anthropic", "store": "keychain"]))
} catch {
appendNotice(severity: .error, title: "Key delete failed", message: String(describing: error))
await recordFailure(kind: .model, message: "Failed to delete Anthropic API key.")
}
}
}

public func saveOpenAIAPIKey(_ key: String) {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
Task {
do {
try await secretStore.save(
trimmed,
service: InterlessSecrets.service,
account: InterlessSecrets.openAIAPIKeyAccount)
appendNotice(severity: .info, title: "Key saved", message: "OpenAI API key was saved in Keychain.")
await publish(.init(kind: .model, message: "Saved cloud provider key", metadata: ["provider": "openai", "store": "keychain"]))
} catch {
appendNotice(severity: .error, title: "Key save failed", message: String(describing: error))
await recordFailure(kind: .model, message: "Failed to save OpenAI API key.")
}
}
}

public func deleteOpenAIAPIKey() {
Task {
do {
try await secretStore.delete(
service: InterlessSecrets.service,
account: InterlessSecrets.openAIAPIKeyAccount)
appendNotice(severity: .info, title: "Key deleted", message: "OpenAI API key was removed from Keychain.")
await publish(.init(kind: .model, message: "Deleted cloud provider key", metadata: ["provider": "openai", "store": "keychain"]))
} catch {
appendNotice(severity: .error, title: "Key delete failed", message: String(describing: error))
await recordFailure(kind: .model, message: "Failed to delete OpenAI API key.")
}
}
}

public func clearPersistedHistory() {
Task {
do {
Expand Down
Loading