Skip to content
Draft
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
30 changes: 30 additions & 0 deletions macos/OpenBridge/Agent/AgentSessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,17 @@ final class AgentSessionManager {

func refreshConnectorConfiguration() {
isVMReady = true
vmLoadError = nil
Task { [weak self] in
guard let self else { return }
do {
_ = try await ensureRuntimeReady()
await refreshLoadedSessionRuntimeConfiguration()
} catch {
vmLoadError = error
logger.warning("Failed to refresh local connector configuration: \(error.localizedDescription)")
}
}
}
Comment on lines 160 to 173

func restartConnector() {
Expand All @@ -168,6 +179,25 @@ final class AgentSessionManager {
isVMReady = true
}

func applyLocalEnvironmentPermissionModeChange(_ mode: LocalEnvironmentPermissionMode) {
connector?.applyPermissionModeChange(mode)
Task { [weak self] in
guard let self else { return }
await refreshLoadedSessionRuntimeConfiguration()
}
}

func reloadAIProviderConfiguration() async {
await BridgeAIProviderRegistry.registerProviders()
await refreshLoadedSessionRuntimeConfiguration()
}

private func refreshLoadedSessionRuntimeConfiguration() async {
for session in sessions.values {
await session.refreshRuntimeConfiguration()
}
}

private func disconnectVMConnector(shutdownBridge: Bool = false) {
guard vmConnector != nil || embeddedVMBridge != nil else { return }
vmConnector?.disconnect()
Expand Down
42 changes: 37 additions & 5 deletions macos/OpenBridge/Agent/BridgeAIProviderRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,39 @@ enum BridgeAIProviderRegistry {

static func selectedModel() async -> Model {
let settings = await BridgeAIProviderSecretStore.readSettings()
return runtimeModel(
provider: settings.selectedModelProvider,
id: settings.selectedModelID,
return selectedModel(settings: settings)
}

static func selectedModel(settings: BridgeAIProviderSettings) -> Model {
let selected = enabledModel(
runtimeModel(
provider: settings.selectedModelProvider,
id: settings.selectedModelID,
settings: settings
),
settings: settings
) ?? defaultModel(settings: settings)
)
return selected ?? defaultModel(settings: settings)
}

static func selectedDisplayModel(settings: BridgeAIProviderSettings) -> Model {
let selected = enabledModel(
displayModel(
provider: settings.selectedModelProvider,
id: settings.selectedModelID,
settings: settings
),
settings: settings
)
return selected ?? defaultModel(settings: settings)
}

private static func enabledModel(_ model: Model?, settings: BridgeAIProviderSettings) -> Model? {
model.flatMap { model in
BridgeAIProvider.provider(for: model).flatMap { provider in
settings[provider].isEnabled ? model : nil
}
}
}
Comment on lines +57 to 87

static func runtimeModel(provider: String, id: String) async -> Model? {
Expand Down Expand Up @@ -92,10 +120,14 @@ enum BridgeAIProviderRegistry {
}

static func displayModel(provider: String, id: String) -> Model? {
displayModel(provider: provider, id: id, settings: BridgeAIProviderSettings())
}

private static func displayModel(provider: String, id: String, settings: BridgeAIProviderSettings) -> Model? {
if provider == BridgeAIProvider.openAIChatCompletions.rawValue {
return openAIChatCompletionsModel(
id: id,
config: BridgeAIProviderSettings()[.openAIChatCompletions]
config: settings[.openAIChatCompletions]
)
}
return ModelsCatalog.model(provider: provider, id: id)
Expand Down
41 changes: 34 additions & 7 deletions macos/OpenBridge/Agent/LocalRuntime/LocalAgentSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ final class LocalAgentSession: Identifiable {
private var localBackgroundManager: BackgroundTaskManager?
private var localAgentUnsubscribe: Unsubscribe?
private var localRunTask: Task<Void, Never>?
private var pendingRuntimeConfigurationRefresh = false
private var localAssistantMessageID: String?
private var localAssistantText = ""
private var localToolStates: [String: AssistantToolCallState] = [:]
Expand Down Expand Up @@ -184,7 +185,6 @@ final class LocalAgentSession: Identifiable {
let now = Int64(Date().timeIntervalSince1970)
listCreatedAt = listCreatedAt ?? now
listUpdatedAt = listUpdatedAt ?? now
_ = try await ensureLocalAgent()
}

func teardown() async {
Expand Down Expand Up @@ -270,6 +270,7 @@ final class LocalAgentSession: Identifiable {
AgentSessionManager.shared.clearLocalPermission(sessionId: sessionID)
updateLocalAssistantState(phase: "cancelled", isStreaming: false)
onSessionFinished?("cancelled", nil)
refreshRuntimeConfigurationAfterRunIfNeeded()
return true
}

Expand Down Expand Up @@ -321,24 +322,41 @@ final class LocalAgentSession: Identifiable {

// MARK: - Local KWWK Agent

private func ensureLocalAgent() async throws -> Agent {
if let localAgent {
return localAgent
func refreshRuntimeConfiguration() async {
guard let localAgent else { return }
guard !isProcessing else {
pendingRuntimeConfigurationRefresh = true
return
}
pendingRuntimeConfigurationRefresh = false
localAgent.state.model = await BridgeAIProviderRegistry.selectedModel()
localAgent.state.systemPrompt = await makeLocalAgentSystemPrompt()
Comment on lines +326 to +333
}

await BridgeAIProviderRegistry.registerProviders()
let backgroundManager = BackgroundTaskManager()
private func makeLocalAgentSystemPrompt() async -> String {
let skillManager = SkillManager.shared
let cwd = skillManager.skillDirs.workspace.path
let memoryPrompt = await MemoryRepository.shared.systemPromptSection()
let environmentInventory = try? await AgentSessionManager.shared.localEnvironmentSystemPromptSection()
let systemPrompt = OpenBridgeSystemPromptBuilder.build(
return OpenBridgeSystemPromptBuilder.build(
cwd: cwd,
skills: skillManager.skills,
memory: memoryPrompt,
computerUsePrompt: OpenBridgeComputerUseAgent.systemPromptWithStartupInventory(clientStore: computerUseClientStore),
environmentInventory: environmentInventory
)
}

private func ensureLocalAgent() async throws -> Agent {
if let localAgent {
return localAgent
}

await BridgeAIProviderRegistry.registerProviders()
let backgroundManager = BackgroundTaskManager()
let skillManager = SkillManager.shared
let cwd = skillManager.skillDirs.workspace.path
let systemPrompt = await makeLocalAgentSystemPrompt()
localBackgroundManager = backgroundManager

let config = await CodingAgentConfig(
Expand Down Expand Up @@ -472,12 +490,21 @@ final class LocalAgentSession: Identifiable {
AgentSessionManager.shared.clearLocalPermission(sessionId: sessionID)
updateLocalAssistantState(phase: lastFinishState ?? "completed", isStreaming: false)
onSessionFinished?(lastFinishState ?? "completed", error?.localizedDescription)
refreshRuntimeConfigurationAfterRunIfNeeded()
Task { [weak self] in
guard let self else { return }
await refreshWorkspaceState()
}
}

private func refreshRuntimeConfigurationAfterRunIfNeeded() {
guard pendingRuntimeConfigurationRefresh else { return }
Task { [weak self] in
guard let self else { return }
await refreshRuntimeConfiguration()
}
}

private func requestComputerUseStartConfirmation(apps: [String]) async -> PermissionConfirmationReply {
let confirmationId = "local-\(UUID().uuidString)"
let message = makeComputerUsePermissionRequestMessage(confirmationId: confirmationId, apps: apps)
Expand Down
28 changes: 27 additions & 1 deletion macos/OpenBridge/Agent/LocalRuntime/LocalRuntimeConnector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ private struct PendingConfirmation {
var continuations: [CheckedContinuation<PermissionConfirmationReply, Never>]
let sessionId: String?
let grantKey: PermissionGrantKey?
let respondsToPermissionMode: Bool
}

private enum PermissionReplyReason {
Expand Down Expand Up @@ -149,6 +150,17 @@ final class LocalRuntimeConnector {
killAllProcesses()
}

func applyPermissionModeChange(_ mode: LocalEnvironmentPermissionMode) {
guard environmentKind == .localMacOS else { return }

switch mode {
case .default:
revokeGrantedPermissions()
case .fullAccess:
approvePendingHostAccessPermissions()
}
}
Comment on lines +153 to +162

private func currentArch() -> String {
#if arch(arm64)
return "arm64"
Expand Down Expand Up @@ -497,6 +509,19 @@ private extension LocalRuntimeConnector {
grantedPermissionKeys = Set(grantedPermissionKeys.filter { $0.sessionKey != sessionKey })
}

func revokeGrantedPermissions() {
grantedPermissionKeys.removeAll()
}

func approvePendingHostAccessPermissions() {
let confirmationIDs = pendingConfirmations.compactMap { id, pending in
pending.respondsToPermissionMode ? id : nil
}
for confirmationID in confirmationIDs {
resolveConfirmation(id: confirmationID, approved: true)
}
}

/// Injects a permission_request message into the session that triggered the
/// request (identified by sessionId) and suspends until the user clicks
/// Allow or Deny in the chat UI. Returns false immediately if no session
Expand Down Expand Up @@ -599,7 +624,8 @@ private extension LocalRuntimeConnector {
pendingConfirmations[confirmationId] = PendingConfirmation(
continuations: [continuation],
sessionId: sessionId,
grantKey: grantKey
grantKey: grantKey,
respondsToPermissionMode: computerUseStart == nil
)
if let grantKey {
pendingPermissionConfirmationIDsByGrantKey[grantKey] = confirmationId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ enum OpenBridgeSystemPromptBuilder {
- Treat environment="sandbox" as the default environment for file reads, writes, commands, and project work.
- Do not switch to or target environment="local" on your own initiative. Use environment="local" only when the user explicitly asks for direct host work or when sandbox cannot complete the task.
- environment="local" is protected because it operates directly on this Mac. Host writes, host commands, and sensitive host paths require explicit user approval.
- Local permission is temporary for the current task execution. Do not assume a past approval applies to a later user request.
- Before using bash, write, or edit in environment="local", call request_permission(environment="local", description="...") with a clear, specific description of what you plan to do so the user can make an informed decision.
\(localPermissionModeInstruction())
\(localPermissionPersistenceInstruction())
\(localPermissionRequestInstruction())
- If local permission is pending and you can still make progress in sandbox, continue there. If blocked on approval, wait and explain what you are waiting for.
- Do not operate on sandbox and local filesystems in the same task unless the user explicitly asks for that handoff. Choose one environment for filesystem work.
- After completing sandbox file operations, call current_changes to review staged sandbox changes before finishing.
Expand All @@ -59,6 +60,33 @@ enum OpenBridgeSystemPromptBuilder {
"""
}

private static func localPermissionModeInstruction() -> String {
switch SettingsManager.shared.localEnvironmentPermissionMode {
case .default:
"- Current local permission mode: Default. Host writes, host commands, and sensitive host paths require request_permission before use."
case .fullAccess:
"- Current local permission mode: Full Access. Host writes, host commands, and sensitive host paths are already approved for this app session; do not call request_permission solely for local permission, but still prefer sandbox unless local access is required."
}
}

private static func localPermissionPersistenceInstruction() -> String {
switch SettingsManager.shared.localEnvironmentPermissionMode {
case .default:
"- Local permission is temporary for the current task execution. Do not assume a past approval applies to a later user request."
case .fullAccess:
"- Full Access remains available only while the user keeps this app setting enabled. If the setting changes back to Default, request local permission again before protected host access."
}
}

private static func localPermissionRequestInstruction() -> String {
switch SettingsManager.shared.localEnvironmentPermissionMode {
case .default:
"- Before using bash, write, or edit in environment=\"local\", call request_permission(environment=\"local\", description=\"...\") with a clear, specific description of what you plan to do so the user can make an informed decision."
case .fullAccess:
"- Before using bash, write, or edit in environment=\"local\", confirm local access is truly required; request_permission is not needed while Full Access is active."
}
}
Comment on lines 42 to +88

private static func skillSection(skills: [Skill]) -> String {
let entries = skills
.filter { !$0.disabled && $0.visibility != .hidden }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,16 +166,7 @@ extension ChatEditorViewModel {
selectedModelID = settings.selectedModelID
return
}
let selected = BridgeAIProviderRegistry.displayModel(
provider: settings.selectedModelProvider,
id: settings.selectedModelID
)
.flatMap { model in
BridgeAIProvider.provider(for: model).flatMap { provider in
settings[provider].isEnabled ? model : nil
}
}
?? BridgeAIProviderRegistry.defaultModel(settings: settings)
let selected = BridgeAIProviderRegistry.selectedDisplayModel(settings: settings)
selectedModelProvider = selected.provider
selectedModelID = selected.id
}
Expand Down Expand Up @@ -205,6 +196,7 @@ extension ChatEditorViewModel {
SettingsManager.shared.localEnvironmentPermissionMode != mode
else { return }
SettingsManager.shared.localEnvironmentPermissionMode = mode
AgentSessionManager.shared.applyLocalEnvironmentPermissionModeChange(mode)
}

private func openAIProviderSettings() {
Expand Down
9 changes: 9 additions & 0 deletions macos/OpenBridge/Interface/Chat/ChatEditorViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ final class ChatEditorViewModel {
var voicePendingWaveformPeak: Double = 0
@ObservationIgnored
private var quoteFocusRequestCounter: Int = 0
@ObservationIgnored
private var aiProviderSettingsCancellable: AnyCancellable?

/// Skill selected for the current conversation (delegated to Chat)
var selectedSkill: Skill? {
Expand Down Expand Up @@ -125,6 +127,13 @@ final class ChatEditorViewModel {
text: String = ""
) {
self.text = text
aiProviderSettingsCancellable = NotificationCenter.default
.publisher(for: .aiProviderSettingsDidChange)
.sink { [weak self] _ in
Task { @MainActor [weak self] in
await self?.loadSelectedModel()
}
}
Task { await loadSelectedModel() }
}

Expand Down
Loading
Loading