diff --git a/CLAUDE.md b/CLAUDE.md index 73095105..0d3e95f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -252,7 +252,9 @@ The voice stack is designed to be droppable into another app. Layering, strictly - **`AgentHubVoice` package** (`app/modules/AgentHubVoice`) — app-agnostic voice infrastructure: `RealtimeVoiceEngine`, audio capture/playback, dictation/STT, `VoiceTool`/`VoiceToolRegistry`, realtime session config builder, OpenAI key handling, `VoiceScreenCaptureService` (+ `VoiceScreenCaptureTools` factory for `list_displays`/`capture_screen`), and the HUD contract (`VoiceHUDHost`, `VoiceHUDConfiguration`, `VoiceHUDPresenting`). Depends only on SwiftOpenAI. Never import AgentHub types here. - **`AgentHubVoicePanel` target** (same package) — reusable SwiftUI HUD + onboarding (`VoiceOnboardingView`, `VoiceOption`) + `AppKitVoiceHUDPresenter`. Depends only on `AgentHubVoice`; branding and UserDefaults keys arrive via `VoiceHUDConfiguration` — never hardcode an app's name or preference keys in this target. -- **`AgentHubCore`** — the AgentHub-specific glue: `VoiceAgentToolExecutor`, `VoiceToolCatalog` (session tools; appends the capture tools from the factory), target resolver, completion watcher, transcript reader, `VoiceControlCoordinator`, settings UI, and `AgentHubVoiceHUDHost` (the `VoiceHUDHost` adapter) plus `VoiceHUDConfiguration.agentHub`. Core imports both voice products; the voice package must never import core. +- **`AgentHubCore`** — the AgentHub-specific glue: `VoiceAgentToolExecutor`, `VoiceToolCatalog` (session tools; appends the capture tools from the factory and the MCP tools from `VoiceMCPToolProvider`), target resolver, completion watcher, transcript reader, `VoiceControlCoordinator`, settings UI, and `AgentHubVoiceHUDHost` (the `VoiceHUDHost` adapter) plus `VoiceHUDConfiguration.agentHub`. Core imports both voice products; the voice package must never import core. +- **Voice MCP tools** — `VoiceMCPToolProvider` bridges the user's personal MCP servers (Claude `~/.claude.json` + Codex `~/.codex/config.toml`, merged by name, Claude wins) into voice conversations as namespaced `{server}__{tool}` function tools, proxied through a dedicated `MCPAppDiscoveryService` instance with a longer request timeout (`AgentHubProvider.voiceMCPDiscoveryService`). Servers are opt-in per name via `AgentHubDefaults.voiceMCPEnabledServers` (Voice settings checklist, default off) — only enabled servers are ever spawned/contacted; MCP configs may carry secrets in `env` and must never be logged. Tool handlers have their own deadline and truncate output so a slow server can't freeze the mic-muted conversation. +- **Voice assistant mode** — the HUD target chip's "Assistant · no session" option (`AgentHubDefaults.voiceAssistantMode`, mirrored by `VoiceHUDSettingsKeys.assistantMode`) runs conversations as a standalone assistant: no session snapshot, and the registry (`VoiceToolCatalog.makeTools(assistantMode:)`) hard-excludes every session-mutating tool (`send_prompt`, `launch_session`, `create_worktree_tasks`, `approve_pending_tool`) and screen capture, keeping read-only session tools + MCP tools. `RealtimeSessionConfigurationBuilder` keys the persona on `send_prompt` presence — registries without it get the answer-directly assistant persona. Toggling the chip mid-conversation stops the engine so a live session can't keep its old tool scope. To reuse voice in another app: depend on the `AgentHubVoice` package, implement `VoiceHUDHost`, supply a `VoiceHUDConfiguration`, and present `AppKitVoiceHUDPresenter` — plus your own tool catalog for app-specific tools. diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Configuration/AgentHubDefaults.swift b/app/modules/AgentHubCore/Sources/AgentHub/Configuration/AgentHubDefaults.swift index 39f77f12..957793e9 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/Configuration/AgentHubDefaults.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/Configuration/AgentHubDefaults.swift @@ -218,6 +218,16 @@ public enum AgentHubDefaults { /// visualizer. Type: Bool (default: false) public static let voiceShowTranscript = "\(keyPrefix)voice.showTranscript" + /// MCP servers (by name, from the user's Claude/Codex configs) whose tools + /// are exposed to voice conversations. Empty means no MCP tools. + /// Type: [String] (default: empty) + public static let voiceMCPEnabledServers = "\(keyPrefix)voice.mcpEnabledServers" + + /// Whether voice conversations run as a standalone assistant: no session + /// target, no session snapshot, and no tools that push prompts or content + /// into a session. Type: Bool (default: false) + public static let voiceAssistantMode = "\(keyPrefix)voice.assistantMode" + // MARK: - Feature Flags /// Whether smart mode (AI-powered orchestration planning) is enabled diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Configuration/AgentHubProvider.swift b/app/modules/AgentHubCore/Sources/AgentHub/Configuration/AgentHubProvider.swift index 15604c2f..f2724362 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/Configuration/AgentHubProvider.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/Configuration/AgentHubProvider.swift @@ -270,9 +270,19 @@ public final class AgentHubProvider { targetResolver: voiceSessionTargetResolver ) + /// Dedicated MCP gateway for voice tool calls. Separate instance from the + /// MCP Apps discovery service so long-running tool calls get a longer + /// request timeout without affecting app-resource discovery. + public private(set) lazy var voiceMCPDiscoveryService: any MCPAppDiscoveryServiceProtocol = + MCPAppDiscoveryService(requestTimeoutSeconds: 45) + + public private(set) lazy var voiceMCPToolProvider: any VoiceMCPToolProviding = + VoiceMCPToolProvider(discovery: voiceMCPDiscoveryService) + public private(set) lazy var voiceToolCatalog: any VoiceToolCataloging = VoiceToolCatalog( executor: voiceToolExecutor, + mcpToolProvider: voiceMCPToolProvider, onBackgroundWaitCountChanged: { [weak self] count in self?.realtimeVoiceEngine.setAwaitingBackgroundWork(count > 0) } @@ -532,10 +542,15 @@ public final class AgentHubProvider { } public func shutdownMCPAppDiscoveryService() { - let service = mcpAppDiscoveryService + let services: [any MCPAppDiscoveryServiceProtocol] = [ + mcpAppDiscoveryService, + voiceMCPDiscoveryService, + ] let semaphore = DispatchSemaphore(value: 0) Task.detached(priority: .utility) { - await service.shutdown() + for service in services { + await service.shutdown() + } semaphore.signal() } _ = semaphore.wait(timeout: .now() + 2.0) diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Services/AgentHubVoiceHUDHost.swift b/app/modules/AgentHubCore/Sources/AgentHub/Services/AgentHubVoiceHUDHost.swift index 1bf190b2..a6d3aed7 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/Services/AgentHubVoiceHUDHost.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/Services/AgentHubVoiceHUDHost.swift @@ -28,7 +28,8 @@ extension VoiceHUDConfiguration { hudFrame: AgentHubDefaults.voiceHUDFrame, screenCaptureEnabled: AgentHubDefaults.voiceScreenCaptureEnabled, onboardingCompleted: AgentHubDefaults.voiceOnboardingCompleted, - showTranscript: AgentHubDefaults.voiceShowTranscript + showTranscript: AgentHubDefaults.voiceShowTranscript, + assistantMode: AgentHubDefaults.voiceAssistantMode ), accentColor: .brandSecondary ) @@ -62,14 +63,20 @@ public final class AgentHubVoiceHUDHost: VoiceHUDHost { .map(Self.hudTarget) } + private var isAssistantMode: Bool { + defaults.bool(forKey: AgentHubDefaults.voiceAssistantMode) + } + public func makeToolRegistry() -> VoiceToolRegistry { VoiceToolRegistry( - tools: provider?.voiceToolCatalog.makeTools() ?? [] + tools: provider?.voiceToolCatalog.makeTools( + assistantMode: isAssistantMode + ) ?? [] ) } public func makeSessionContext() -> String? { - guard let provider else { return nil } + guard let provider, !isAssistantMode else { return nil } return VoiceSessionContextBuilder.make( summary: provider.voiceToolExecutor.listSessions() ) diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Services/VoiceControlCoordinator.swift b/app/modules/AgentHubCore/Sources/AgentHub/Services/VoiceControlCoordinator.swift index de5347a7..81f80a50 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/Services/VoiceControlCoordinator.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/Services/VoiceControlCoordinator.swift @@ -15,6 +15,7 @@ public final class VoiceControlCoordinator { private let presenter: any VoiceHUDPresenting private let defaults: UserDefaults private let hotKey: GlobalHotKey + private let onHUDShown: (@MainActor () -> Void)? private var isStarted = false public var isHUDVisible: Bool { @@ -25,12 +26,14 @@ public final class VoiceControlCoordinator { registrar: any GlobalHotKeyRegistrarProtocol, presenter: any VoiceHUDPresenting, defaults: UserDefaults = .standard, - hotKey: GlobalHotKey = .voiceHUDDefault + hotKey: GlobalHotKey = .voiceHUDDefault, + onHUDShown: (@MainActor () -> Void)? = nil ) { self.registrar = registrar self.presenter = presenter self.defaults = defaults self.hotKey = hotKey + self.onHUDShown = onHUDShown } public convenience init( @@ -40,7 +43,12 @@ public final class VoiceControlCoordinator { self.init( registrar: CarbonGlobalHotKeyRegistrar(), presenter: provider.makeVoiceHUDPresenter(defaults: defaults), - defaults: defaults + defaults: defaults, + onHUDShown: { [weak provider] in + // Warm the MCP tool cache so the conversation the user is about to + // start sees the enabled servers' tools. + provider?.voiceMCPToolProvider.scheduleRefresh() + } ) } @@ -87,6 +95,9 @@ public final class VoiceControlCoordinator { } public func toggleHUD() { + if !presenter.isVisible { + onHUDShown?() + } presenter.toggle() } } diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Services/VoiceMCPToolProvider.swift b/app/modules/AgentHubCore/Sources/AgentHub/Services/VoiceMCPToolProvider.swift new file mode 100644 index 00000000..06075ad9 --- /dev/null +++ b/app/modules/AgentHubCore/Sources/AgentHub/Services/VoiceMCPToolProvider.swift @@ -0,0 +1,368 @@ +// +// VoiceMCPToolProvider.swift +// AgentHub +// +// Bridges the user's personal MCP servers (Claude ~/.claude.json and Codex +// ~/.codex/config.toml) into voice conversations: enabled servers' tools are +// wrapped as VoiceTools whose handlers proxy tools/call through the MCP +// gateway. Servers spawn only when the user has enabled them in Voice +// settings; configs may carry secrets in env blocks and must never be logged. +// + +import AgentHubMCPUI +import AgentHubVoice +import Foundation + +public struct VoiceMCPServerDescriptor: Identifiable, Sendable, Equatable { + public let name: String + public let providers: [SessionProviderKind] + public let transportDescription: String + public let unsupportedReason: String? + + public var id: String { name } + public var isSupported: Bool { unsupportedReason == nil } + + public init( + name: String, + providers: [SessionProviderKind], + transportDescription: String, + unsupportedReason: String? = nil + ) { + self.name = name + self.providers = providers + self.transportDescription = transportDescription + self.unsupportedReason = unsupportedReason + } +} + +@MainActor +public protocol VoiceMCPToolProviding: AnyObject { + /// Latest built tools for the enabled servers. Also schedules a background + /// refresh so config or tool-list changes are picked up by the next + /// conversation start. + func currentTools() -> [VoiceTool] + + func refresh() async + + /// Coalesced fire-and-forget refresh. + func scheduleRefresh() + + /// All servers found in the user's Claude and Codex configs, merged by name. + /// Reads config files only — never spawns or contacts a server. + func discoverServers() async -> [VoiceMCPServerDescriptor] +} + +@MainActor +public final class VoiceMCPToolProvider: VoiceMCPToolProviding { + private let resolver: any MCPServerConfigurationResolverProtocol + private let discovery: any MCPAppDiscoveryServiceProtocol + private let enabledServerNames: @MainActor () -> Set + private let scopePath: String + private let toolCallTimeoutSeconds: TimeInterval + private let maxOutputCharacters: Int + private var cachedTools: [VoiceTool] = [] + private var refreshTask: Task? + + public init( + resolver: any MCPServerConfigurationResolverProtocol = DefaultMCPServerConfigurationResolver(), + discovery: any MCPAppDiscoveryServiceProtocol, + enabledServerNames: @escaping @MainActor () -> Set = { + Set( + UserDefaults.standard.stringArray( + forKey: AgentHubDefaults.voiceMCPEnabledServers + ) ?? [] + ) + }, + scopePath: String = NSHomeDirectory(), + toolCallTimeoutSeconds: TimeInterval = 60, + maxOutputCharacters: Int = 6_000 + ) { + self.resolver = resolver + self.discovery = discovery + self.enabledServerNames = enabledServerNames + self.scopePath = scopePath + self.toolCallTimeoutSeconds = toolCallTimeoutSeconds + self.maxOutputCharacters = maxOutputCharacters + } + + public func currentTools() -> [VoiceTool] { + scheduleRefresh() + return cachedTools + } + + public func scheduleRefresh() { + guard refreshTask == nil else { return } + refreshTask = Task { [weak self] in + await self?.refresh() + self?.refreshTask = nil + } + } + + public func refresh() async { + let enabled = enabledServerNames() + guard !enabled.isEmpty else { + cachedTools = [] + return + } + var tools: [VoiceTool] = [] + for config in await mergedConfigurations() { + guard enabled.contains(config.name), isSupported(config.transport) else { continue } + do { + let result = try await discovery.listTools( + provider: config.provider, + projectPath: config.projectPath, + serverName: config.name + ) + tools.append(contentsOf: makeTools(config: config, listToolsResult: result)) + } catch { + AppLogger.mcp.error( + "[VoiceMCP] tools/list failed server=\(config.name, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + } + } + cachedTools = tools + } + + public func discoverServers() async -> [VoiceMCPServerDescriptor] { + var order: [String] = [] + var names: [String: (config: MCPServerConfiguration, providers: [SessionProviderKind])] = [:] + for provider in [SessionProviderKind.claude, .codex] { + for config in await resolver.serverConfigurations(provider: provider, projectPath: scopePath) { + if var existing = names[config.name] { + if !existing.providers.contains(provider) { + existing.providers.append(provider) + names[config.name] = existing + } + } else { + names[config.name] = (config, [provider]) + order.append(config.name) + } + } + } + return order.compactMap { name in + guard let entry = names[name] else { return nil } + return VoiceMCPServerDescriptor( + name: name, + providers: entry.providers, + transportDescription: entry.config.transportDescription, + unsupportedReason: unsupportedReason(entry.config.transport) + ) + } + } + + /// Claude and Codex configs merged by server name; when both define the same + /// server, the Claude entry wins so calls route through one client. + private func mergedConfigurations() async -> [MCPServerConfiguration] { + var order: [String] = [] + var byName: [String: MCPServerConfiguration] = [:] + for provider in [SessionProviderKind.claude, .codex] { + let configs = await resolver.serverConfigurations(provider: provider, projectPath: scopePath) + for config in configs where byName[config.name] == nil { + byName[config.name] = config + order.append(config.name) + } + } + return order.compactMap { byName[$0] } + } + + private func isSupported(_ transport: MCPServerTransport) -> Bool { + unsupportedReason(transport) == nil + } + + private func unsupportedReason(_ transport: MCPServerTransport) -> String? { + switch transport { + case .stdio, .streamableHTTP, .sse: + nil + case .unsupportedAuthentication(let reason): + reason + case .unsupported(let transport): + "Transport '\(transport)' is not supported." + } + } + + private func makeTools( + config: MCPServerConfiguration, + listToolsResult: AgentHubMCPUIJSONValue + ) -> [VoiceTool] { + guard let object = listToolsResult.jsonObject as? [String: Any], + let rawTools = object["tools"] as? [[String: Any]] else { + return [] + } + return rawTools.compactMap { raw in + guard let toolName = raw["name"] as? String, !toolName.isEmpty else { return nil } + let description = (raw["description"] as? String) + .flatMap { $0.isEmpty ? nil : $0 } + ?? "Tool \(toolName) from the \(config.name) MCP server." + return makeTool( + config: config, + toolName: toolName, + description: "[\(config.name)] \(Self.clipped(description, limit: 500))", + schema: Self.functionSchema(from: raw["inputSchema"]) + ) + } + } + + private func makeTool( + config: MCPServerConfiguration, + toolName: String, + description: String, + schema: [String: VoiceJSONValue] + ) -> VoiceTool { + let discovery = discovery + let timeout = toolCallTimeoutSeconds + let maxOutput = maxOutputCharacters + let provider = config.provider + let projectPath = config.projectPath + let serverName = config.name + return VoiceTool( + name: Self.namespacedToolName(server: serverName, tool: toolName), + description: description, + parameters: schema + ) { data in + let arguments = try? JSONDecoder().decode(AgentHubMCPUIJSONValue.self, from: data) + do { + let result = try await Self.withDeadline(seconds: timeout) { + try await discovery.callTool( + provider: provider, + projectPath: projectPath, + serverName: serverName, + name: toolName, + arguments: arguments + ) + } + return Self.encodeToolResult(result, maxOutputCharacters: maxOutput) + } catch { + return Self.errorJSON( + "The \(serverName) tool call failed: \(error.localizedDescription)" + ) + } + } + } + + // MARK: - Formatting + + static func namespacedToolName(server: String, tool: String) -> String { + let maxLength = 64 + let separator = "__" + let sanitizedTool = sanitized(tool) + let sanitizedServer = sanitized(server) + let serverBudget = maxLength - sanitizedTool.count - separator.count + guard serverBudget >= 1 else { + return String(sanitizedTool.prefix(maxLength)) + } + return "\(sanitizedServer.prefix(serverBudget))\(separator)\(sanitizedTool)" + } + + private static func sanitized(_ name: String) -> String { + let allowed = CharacterSet( + charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" + ) + return String( + String.UnicodeScalarView( + name.unicodeScalars.map { allowed.contains($0) ? $0 : "_" } + ) + ) + } + + static func functionSchema(from rawSchema: Any?) -> [String: VoiceJSONValue] { + guard let rawSchema = rawSchema as? [String: Any], + JSONSerialization.isValidJSONObject(rawSchema), + let data = try? JSONSerialization.data(withJSONObject: rawSchema), + var schema = try? JSONDecoder().decode([String: VoiceJSONValue].self, from: data) else { + return [ + "type": "object", + "properties": .object([:]), + "additionalProperties": .bool(false), + ] + } + if schema["type"] == nil { + schema["type"] = "object" + } + return schema + } + + static func encodeToolResult( + _ result: AgentHubMCPUIJSONValue, + maxOutputCharacters: Int + ) -> String { + let object = result.jsonObject as? [String: Any] + let content = object?["content"] as? [[String: Any]] ?? [] + var texts: [String] = [] + var nonTextItems = 0 + for item in content { + if let text = item["text"] as? String { + texts.append(text) + } else { + nonTextItems += 1 + } + } + var output = texts.joined(separator: "\n") + if output.isEmpty, content.isEmpty, let object, + let data = try? JSONSerialization.data(withJSONObject: object) { + // Servers may return structured results without a content array. + output = String(decoding: data, as: UTF8.self) + } + let truncated = output.count > maxOutputCharacters + if truncated { + output = String(output.prefix(maxOutputCharacters)) + } + var payload: [String: Any] = [ + "status": (object?["isError"] as? Bool) == true ? "tool_error" : "ok", + "output": output, + ] + if truncated { + payload["truncated"] = true + } + if nonTextItems > 0 { + payload["non_text_items_omitted"] = nonTextItems + } + return json(payload) + } + + private static func errorJSON(_ message: String) -> String { + json(["status": "error", "message": message]) + } + + private static func json(_ object: [String: Any]) -> String { + guard let data = try? JSONSerialization.data( + withJSONObject: object, + options: [.sortedKeys] + ) else { + return #"{"status":"error"}"# + } + return String(decoding: data, as: UTF8.self) + } + + private static func clipped(_ text: String, limit: Int) -> String { + text.count > limit ? String(text.prefix(limit)) : text + } + + private static func withDeadline( + seconds: TimeInterval, + operation: @escaping @Sendable () async throws -> T + ) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw VoiceMCPToolProviderError.timedOut(seconds) + } + defer { group.cancelAll() } + guard let first = try await group.next() else { + throw VoiceMCPToolProviderError.timedOut(seconds) + } + return first + } + } +} + +enum VoiceMCPToolProviderError: LocalizedError { + case timedOut(TimeInterval) + + var errorDescription: String? { + switch self { + case .timedOut(let seconds): + "The MCP tool call timed out after \(Int(seconds)) seconds." + } + } +} diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Services/VoiceToolCatalog.swift b/app/modules/AgentHubCore/Sources/AgentHub/Services/VoiceToolCatalog.swift index b6ef36ae..9e1b6d28 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/Services/VoiceToolCatalog.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/Services/VoiceToolCatalog.swift @@ -3,7 +3,16 @@ import Foundation @MainActor public protocol VoiceToolCataloging: AnyObject { - func makeTools() -> [VoiceTool] + /// `assistantMode` builds a standalone-assistant registry: read-only + /// session visibility plus MCP tools, and none of the tools that push + /// prompts or content into a session. + func makeTools(assistantMode: Bool) -> [VoiceTool] +} + +public extension VoiceToolCataloging { + func makeTools() -> [VoiceTool] { + makeTools(assistantMode: false) + } } @MainActor @@ -16,6 +25,7 @@ public final class VoiceToolCatalog: VoiceToolCataloging { private let executor: any VoiceAgentToolExecuting private let screenCapture: any VoiceScreenCapturing private let isScreenCaptureEnabled: @MainActor @Sendable () -> Bool + private let mcpToolProvider: (any VoiceMCPToolProviding)? private let onBackgroundUpdate: @MainActor @Sendable (String) -> Void private let onBackgroundWaitCountChanged: (@MainActor @Sendable (Int) -> Void)? @@ -34,36 +44,49 @@ public final class VoiceToolCatalog: VoiceToolCataloging { forKey: AgentHubDefaults.voiceScreenCaptureEnabled ) as? Bool) ?? true }, + mcpToolProvider: (any VoiceMCPToolProviding)? = nil, onBackgroundWaitCountChanged: (@MainActor @Sendable (Int) -> Void)? = nil, onBackgroundUpdate: @escaping @MainActor @Sendable (String) -> Void ) { self.executor = executor self.screenCapture = screenCapture self.isScreenCaptureEnabled = isScreenCaptureEnabled + self.mcpToolProvider = mcpToolProvider self.onBackgroundWaitCountChanged = onBackgroundWaitCountChanged self.onBackgroundUpdate = onBackgroundUpdate } - public func makeTools() -> [VoiceTool] { + public func makeTools(assistantMode: Bool) -> [VoiceTool] { var tools = [ listSessionsTool(), sessionStatusTool(), readResponseTool(), readHistoryTool(), - sendPromptTool(), watchSessionTool(), stopWatchingTool(), focusSessionTool(), listWorktreesTool(), - launchSessionTool(), - createWorktreeTasksTool(), - approvalTool(), ] - if isScreenCaptureEnabled() { - tools.append(contentsOf: VoiceScreenCaptureTools.make( - capture: screenCapture, - isEnabled: isScreenCaptureEnabled - )) + if !assistantMode { + // Session-mutating tools stay out of assistant registries so an + // assistant conversation can never push prompts, approvals, or new + // sessions into the user's coding work. Screen capture is excluded too: + // its file paths are only consumable through send_prompt. + tools.append(contentsOf: [ + sendPromptTool(), + launchSessionTool(), + createWorktreeTasksTool(), + approvalTool(), + ]) + if isScreenCaptureEnabled() { + tools.append(contentsOf: VoiceScreenCaptureTools.make( + capture: screenCapture, + isEnabled: isScreenCaptureEnabled + )) + } + } + if let mcpToolProvider { + tools.append(contentsOf: mcpToolProvider.currentTools()) } return tools } diff --git a/app/modules/AgentHubCore/Sources/AgentHub/UI/VoiceMCPToolsSettingsSection.swift b/app/modules/AgentHubCore/Sources/AgentHub/UI/VoiceMCPToolsSettingsSection.swift new file mode 100644 index 00000000..511e624b --- /dev/null +++ b/app/modules/AgentHubCore/Sources/AgentHub/UI/VoiceMCPToolsSettingsSection.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct VoiceMCPToolsSettingsSection: View { + let servers: [VoiceMCPServerDescriptor] + let isEnabled: (VoiceMCPServerDescriptor) -> Bool + let onToggle: (VoiceMCPServerDescriptor, Bool) -> Void + + var body: some View { + Section("MCP Tools") { + if servers.isEmpty { + Text("No MCP servers found in your Claude (~/.claude.json) or Codex (~/.codex/config.toml) configuration.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(servers) { server in + VoiceMCPServerRow( + server: server, + isEnabled: isEnabled(server), + onToggle: { onToggle(server, $0) } + ) + } + Text("Enabled servers run locally when a voice conversation needs them; tool results become part of the conversation sent to OpenAI.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } +} + +private struct VoiceMCPServerRow: View { + let server: VoiceMCPServerDescriptor + let isEnabled: Bool + let onToggle: (Bool) -> Void + + var body: some View { + if server.isSupported { + Toggle(isOn: Binding(get: { isEnabled }, set: onToggle)) { + VStack(alignment: .leading, spacing: 2) { + Text(server.name) + Text(detailText) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } else { + VStack(alignment: .leading, spacing: 2) { + Text(server.name) + .foregroundStyle(.secondary) + Text(server.unsupportedReason ?? "This server is not supported.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private var detailText: String { + let providers = server.providers + .map { $0 == .claude ? "Claude" : "Codex" } + .joined(separator: " · ") + return "\(providers) — \(server.transportDescription)" + } +} diff --git a/app/modules/AgentHubCore/Sources/AgentHub/UI/VoiceSettingsView.swift b/app/modules/AgentHubCore/Sources/AgentHub/UI/VoiceSettingsView.swift index f76e566a..d727c8ca 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/UI/VoiceSettingsView.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/UI/VoiceSettingsView.swift @@ -40,6 +40,8 @@ public struct VoiceSettingsView: View { private var showTranscript = false @State private var showsOnboarding = false + @State private var mcpServers: [VoiceMCPServerDescriptor] = [] + @State private var enabledMCPServers: Set = [] public init() {} @@ -65,6 +67,12 @@ public struct VoiceSettingsView: View { onShowOnboarding: { showsOnboarding = true } ) + VoiceMCPToolsSettingsSection( + servers: mcpServers, + isEnabled: { enabledMCPServers.contains($0.name) }, + onToggle: setMCPServer + ) + VoiceRealtimeSettingsSection( realtimeModel: $realtimeModel, voiceName: $voiceName, @@ -76,6 +84,7 @@ public struct VoiceSettingsView: View { .formStyle(.grouped) .task { await loadAPIKey() + await loadMCPServers() } .onChange(of: voiceEnabled) { _, enabled in agentHub?.voiceControlCoordinator.setEnabled(enabled) @@ -100,6 +109,28 @@ public struct VoiceSettingsView: View { } } + private func loadMCPServers() async { + enabledMCPServers = Set( + UserDefaults.standard.stringArray( + forKey: AgentHubDefaults.voiceMCPEnabledServers + ) ?? [] + ) + mcpServers = await agentHub?.voiceMCPToolProvider.discoverServers() ?? [] + } + + private func setMCPServer(_ server: VoiceMCPServerDescriptor, enabled: Bool) { + if enabled { + enabledMCPServers.insert(server.name) + } else { + enabledMCPServers.remove(server.name) + } + UserDefaults.standard.set( + enabledMCPServers.sorted(), + forKey: AgentHubDefaults.voiceMCPEnabledServers + ) + agentHub?.voiceMCPToolProvider.scheduleRefresh() + } + private func saveAPIKey() { guard let provider = agentHub?.openAIKeyProvider else { return } isSaving = true diff --git a/app/modules/AgentHubCore/Tests/AgentHubTests/VoiceMCPToolProviderTests.swift b/app/modules/AgentHubCore/Tests/AgentHubTests/VoiceMCPToolProviderTests.swift new file mode 100644 index 00000000..a2ede05b --- /dev/null +++ b/app/modules/AgentHubCore/Tests/AgentHubTests/VoiceMCPToolProviderTests.swift @@ -0,0 +1,356 @@ +import AgentHubMCPUI +import AgentHubVoice +import Foundation +import Testing +@testable import AgentHubCore + +private struct StubResolver: MCPServerConfigurationResolverProtocol { + let claude: [MCPServerConfiguration] + let codex: [MCPServerConfiguration] + + func serverConfigurations( + provider: SessionProviderKind, + projectPath: String + ) async -> [MCPServerConfiguration] { + provider == .claude ? claude : codex + } +} + +private struct StubError: LocalizedError { + var errorDescription: String? { "boom" } +} + +private actor StubDiscovery: MCPAppDiscoveryServiceProtocol { + private var toolsByServer: [String: AgentHubMCPUIJSONValue] = [:] + private var callToolResult: AgentHubMCPUIJSONValue = .object([:]) + private var callToolError: Error? + private var callToolDelaySeconds: Double = 0 + private(set) var listToolsServers: [String] = [] + private(set) var callRequests: [ + (provider: SessionProviderKind, server: String, tool: String, arguments: AgentHubMCPUIJSONValue?) + ] = [] + + func setToolList(server: String, json: String) throws { + toolsByServer[server] = try JSONDecoder().decode( + AgentHubMCPUIJSONValue.self, + from: Data(json.utf8) + ) + } + + func setCallResult(json: String) throws { + callToolResult = try JSONDecoder().decode( + AgentHubMCPUIJSONValue.self, + from: Data(json.utf8) + ) + } + + func setCallError(_ error: Error?) { + callToolError = error + } + + func setCallDelay(seconds: Double) { + callToolDelaySeconds = seconds + } + + func callTool( + provider: SessionProviderKind, + projectPath: String, + serverName: String, + name: String, + arguments: AgentHubMCPUIJSONValue? + ) async throws -> AgentHubMCPUIJSONValue { + callRequests.append((provider, serverName, name, arguments)) + if callToolDelaySeconds > 0 { + try await Task.sleep(nanoseconds: UInt64(callToolDelaySeconds * 1_000_000_000)) + } + if let callToolError { + throw callToolError + } + return callToolResult + } + + func readResource( + provider: SessionProviderKind, + projectPath: String, + serverName: String, + uri: String + ) async throws -> AgentHubMCPUIJSONValue { + .null + } + + func listResources( + provider: SessionProviderKind, + projectPath: String, + serverName: String + ) async throws -> AgentHubMCPUIJSONValue { + .null + } + + func listTools( + provider: SessionProviderKind, + projectPath: String, + serverName: String + ) async throws -> AgentHubMCPUIJSONValue { + listToolsServers.append(serverName) + guard let tools = toolsByServer[serverName] else { + throw MCPAppDiscoveryError.serverNotFound(serverName) + } + return tools + } +} + +@MainActor +struct VoiceMCPToolProviderTests { + private func stdioConfig( + _ name: String, + provider: SessionProviderKind = .claude + ) -> MCPServerConfiguration { + MCPServerConfiguration( + provider: provider, + projectPath: "/Users/test", + name: name, + command: "/usr/bin/true" + ) + } + + private func makeProvider( + resolver: StubResolver, + discovery: StubDiscovery, + enabled: Set, + toolCallTimeoutSeconds: TimeInterval = 5, + maxOutputCharacters: Int = 6_000 + ) -> VoiceMCPToolProvider { + VoiceMCPToolProvider( + resolver: resolver, + discovery: discovery, + enabledServerNames: { enabled }, + scopePath: "/Users/test", + toolCallTimeoutSeconds: toolCallTimeoutSeconds, + maxOutputCharacters: maxOutputCharacters + ) + } + + private let slackToolsJSON = """ + {"tools":[{"name":"send_message","description":"Send a Slack message.",\ + "inputSchema":{"type":"object","properties":{"channel":{"type":"string"}},\ + "required":["channel"]}}]} + """ + + @Test + func refreshBuildsNamespacedToolsForEnabledSupportedServersOnly() async throws { + let unsupported = MCPServerConfiguration( + provider: .claude, + projectPath: "/Users/test", + name: "figma", + transport: .unsupportedAuthentication("Auth not supported.") + ) + let resolver = StubResolver( + claude: [stdioConfig("slack"), unsupported], + codex: [stdioConfig("slack", provider: .codex), stdioConfig("docs", provider: .codex)] + ) + let discovery = StubDiscovery() + try await discovery.setToolList(server: "slack", json: slackToolsJSON) + try await discovery.setToolList( + server: "docs", + json: #"{"tools":[{"name":"search"}]}"# + ) + let provider = makeProvider( + resolver: resolver, + discovery: discovery, + enabled: ["slack", "docs", "figma"] + ) + + await provider.refresh() + let tools = provider.currentTools() + + #expect(tools.map(\.name) == ["slack__send_message", "docs__search"]) + #expect(tools.allSatisfy { $0.parameters["type"] != nil }) + // The unsupported server is never contacted; the duplicated slack entry + // resolves through Claude, not Codex. + #expect(await discovery.listToolsServers == ["slack", "docs"]) + let slackTool = try #require(tools.first) + #expect(slackTool.description.contains("[slack]")) + _ = await VoiceToolRegistry(tools: tools).execute( + name: "slack__send_message", + arguments: #"{"channel":"general"}"# + ) + let call = try #require(await discovery.callRequests.first) + #expect(call.provider == .claude) + } + + @Test + func handlerProxiesCallToolAndEncodesTextOutput() async throws { + let resolver = StubResolver(claude: [stdioConfig("slack")], codex: []) + let discovery = StubDiscovery() + try await discovery.setToolList(server: "slack", json: slackToolsJSON) + try await discovery.setCallResult( + json: #"{"content":[{"type":"text","text":"posted to #general"}],"isError":false}"# + ) + let provider = makeProvider(resolver: resolver, discovery: discovery, enabled: ["slack"]) + await provider.refresh() + let registry = VoiceToolRegistry(tools: provider.currentTools()) + + let output = await registry.execute( + name: "slack__send_message", + arguments: #"{"channel":"general"}"# + ) + + #expect(output.contains(#""status":"ok""#)) + #expect(output.contains("posted to #general")) + let call = try #require(await discovery.callRequests.first) + #expect(call.server == "slack") + #expect(call.tool == "send_message") + let arguments = try #require(call.arguments?.jsonObject as? [String: Any]) + #expect(arguments["channel"] as? String == "general") + } + + @Test + func handlerSurfacesErrorsAndTimeoutsAsErrorJSON() async throws { + let resolver = StubResolver(claude: [stdioConfig("slack")], codex: []) + let discovery = StubDiscovery() + try await discovery.setToolList(server: "slack", json: slackToolsJSON) + await discovery.setCallError(StubError()) + let provider = makeProvider( + resolver: resolver, + discovery: discovery, + enabled: ["slack"], + toolCallTimeoutSeconds: 0.05 + ) + await provider.refresh() + let registry = VoiceToolRegistry(tools: provider.currentTools()) + + let failed = await registry.execute( + name: "slack__send_message", + arguments: #"{"channel":"general"}"# + ) + #expect(failed.contains(#""status":"error""#)) + #expect(failed.contains("boom")) + + await discovery.setCallError(nil) + await discovery.setCallDelay(seconds: 2) + let timedOut = await registry.execute( + name: "slack__send_message", + arguments: #"{"channel":"general"}"# + ) + #expect(timedOut.contains(#""status":"error""#)) + #expect(timedOut.contains("timed out")) + } + + @Test + func longAndErrorToolResultsAreEncodedFaithfully() async throws { + let resolver = StubResolver(claude: [stdioConfig("slack")], codex: []) + let discovery = StubDiscovery() + try await discovery.setToolList(server: "slack", json: slackToolsJSON) + let longText = String(repeating: "x", count: 50) + try await discovery.setCallResult( + json: #"{"content":[{"type":"text","text":"\#(longText)"}],"isError":true}"# + ) + let provider = makeProvider( + resolver: resolver, + discovery: discovery, + enabled: ["slack"], + maxOutputCharacters: 10 + ) + await provider.refresh() + let registry = VoiceToolRegistry(tools: provider.currentTools()) + + let output = await registry.execute( + name: "slack__send_message", + arguments: #"{"channel":"general"}"# + ) + + #expect(output.contains(#""status":"tool_error""#)) + #expect(output.contains(#""truncated":true"#)) + #expect(!output.contains(longText)) + } + + @Test + func emptyEnabledListClearsToolsWithoutContactingServers() async throws { + let resolver = StubResolver(claude: [stdioConfig("slack")], codex: []) + let discovery = StubDiscovery() + try await discovery.setToolList(server: "slack", json: slackToolsJSON) + let provider = makeProvider(resolver: resolver, discovery: discovery, enabled: []) + + await provider.refresh() + + #expect(provider.currentTools().isEmpty) + #expect(await discovery.listToolsServers.isEmpty) + } + + @Test + func discoverServersMergesProvidersAndFlagsUnsupported() async throws { + let unsupported = MCPServerConfiguration( + provider: .claude, + projectPath: "/Users/test", + name: "figma", + transport: .unsupportedAuthentication("Auth not supported.") + ) + let resolver = StubResolver( + claude: [stdioConfig("slack"), unsupported], + codex: [stdioConfig("slack", provider: .codex)] + ) + let provider = makeProvider( + resolver: resolver, + discovery: StubDiscovery(), + enabled: [] + ) + + let servers = await provider.discoverServers() + + #expect(servers.map(\.name) == ["slack", "figma"]) + #expect(servers.first?.providers == [.claude, .codex]) + #expect(servers.first?.isSupported == true) + #expect(servers.last?.isSupported == false) + #expect(servers.last?.unsupportedReason == "Auth not supported.") + } + + @Test + func namespacedToolNameSanitizesAndTruncates() { + #expect( + VoiceMCPToolProvider.namespacedToolName(server: "slack", tool: "send_message") + == "slack__send_message" + ) + #expect( + VoiceMCPToolProvider.namespacedToolName(server: "my server!", tool: "do.it") + == "my_server___do_it" + ) + let long = VoiceMCPToolProvider.namespacedToolName( + server: String(repeating: "s", count: 80), + tool: "tool" + ) + #expect(long.count == 64) + #expect(long.hasSuffix("__tool")) + let hugeTool = VoiceMCPToolProvider.namespacedToolName( + server: "srv", + tool: String(repeating: "t", count: 80) + ) + #expect(hugeTool.count == 64) + } + + @Test + func functionSchemaFallsBackToEmptyObjectSchema() { + let missing = VoiceMCPToolProvider.functionSchema(from: nil) + #expect(stringValue(missing["type"]) == "object") + #expect(boolValue(missing["additionalProperties"]) == false) + + let untyped = VoiceMCPToolProvider.functionSchema( + from: ["properties": ["a": ["type": "string"]]] + ) + #expect(stringValue(untyped["type"]) == "object") + #expect(untyped["properties"] != nil) + } + + private func stringValue(_ value: VoiceJSONValue?) -> String? { + if case .string(let string)? = value { + return string + } + return nil + } + + private func boolValue(_ value: VoiceJSONValue?) -> Bool? { + if case .bool(let bool)? = value { + return bool + } + return nil + } +} diff --git a/app/modules/AgentHubCore/Tests/AgentHubTests/VoiceToolCatalogTests.swift b/app/modules/AgentHubCore/Tests/AgentHubTests/VoiceToolCatalogTests.swift index ffbf057d..7ad3a58d 100644 --- a/app/modules/AgentHubCore/Tests/AgentHubTests/VoiceToolCatalogTests.swift +++ b/app/modules/AgentHubCore/Tests/AgentHubTests/VoiceToolCatalogTests.swift @@ -141,8 +141,72 @@ private struct MockScreenCapture: VoiceScreenCapturing { } } +@MainActor +private final class StubVoiceMCPToolProvider: VoiceMCPToolProviding { + var tools: [VoiceTool] = [] + + func currentTools() -> [VoiceTool] { tools } + func refresh() async {} + func scheduleRefresh() {} + func discoverServers() async -> [VoiceMCPServerDescriptor] { [] } +} + @MainActor struct VoiceToolCatalogTests { + @Test + func assistantModeExcludesSessionMutatingAndCaptureToolsButKeepsMCP() { + let mcpProvider = StubVoiceMCPToolProvider() + mcpProvider.tools = [ + VoiceTool( + name: "gmail__search_messages", + description: "Search the user's mailbox.", + parameters: ["type": "object"] + ) { _ in "{}" } + ] + let tools = VoiceToolCatalog( + executor: MockVoiceToolExecutor(), + screenCapture: MockScreenCapture(), + isScreenCaptureEnabled: { true }, + mcpToolProvider: mcpProvider, + onBackgroundUpdate: { _ in } + ).makeTools(assistantMode: true) + + #expect( + Set(tools.map(\.name)) == [ + "list_sessions", + "get_session_status", + "read_session_response", + "read_session_history", + "watch_session", + "stop_watching", + "focus_session", + "list_worktrees", + "gmail__search_messages", + ] + ) + } + + @Test + func mcpToolsAreAppendedWhenProviderIsSupplied() { + let mcpProvider = StubVoiceMCPToolProvider() + mcpProvider.tools = [ + VoiceTool( + name: "slack__send_message", + description: "Send a Slack message.", + parameters: ["type": "object"] + ) { _ in "{}" } + ] + let tools = VoiceToolCatalog( + executor: MockVoiceToolExecutor(), + screenCapture: MockScreenCapture(), + isScreenCaptureEnabled: { false }, + mcpToolProvider: mcpProvider, + onBackgroundUpdate: { _ in } + ).makeTools() + + #expect(tools.contains { $0.name == "slack__send_message" }) + } + @Test func exposesAllSchemasAndDisablesApprovalRetry() { let tools = VoiceToolCatalog( diff --git a/app/modules/AgentHubVoice/Sources/AgentHubVoice/HUD/VoiceHUDContract.swift b/app/modules/AgentHubVoice/Sources/AgentHubVoice/HUD/VoiceHUDContract.swift index 966641bf..b0936551 100644 --- a/app/modules/AgentHubVoice/Sources/AgentHubVoice/HUD/VoiceHUDContract.swift +++ b/app/modules/AgentHubVoice/Sources/AgentHubVoice/HUD/VoiceHUDContract.swift @@ -107,6 +107,11 @@ public struct VoiceHUDSettingsKeys: Sendable { /// Bool key: show live transcript text in the HUD instead of the voice /// visualizer. Off by default. public let showTranscript: String + /// Bool key: conversations run as a standalone assistant — no session + /// target, no session snapshot, and no tools that push content into a + /// session. The host reads the same key when building the tool registry. + /// Off by default. + public let assistantMode: String public init( mode: String, @@ -119,7 +124,8 @@ public struct VoiceHUDSettingsKeys: Sendable { hudFrame: String, screenCaptureEnabled: String, onboardingCompleted: String, - showTranscript: String = "voice.showTranscript" + showTranscript: String = "voice.showTranscript", + assistantMode: String = "voice.assistantMode" ) { self.mode = mode self.realtimeModel = realtimeModel @@ -132,6 +138,7 @@ public struct VoiceHUDSettingsKeys: Sendable { self.screenCaptureEnabled = screenCaptureEnabled self.onboardingCompleted = onboardingCompleted self.showTranscript = showTranscript + self.assistantMode = assistantMode } } diff --git a/app/modules/AgentHubVoice/Sources/AgentHubVoice/Realtime/RealtimeSessionConfigurationBuilder.swift b/app/modules/AgentHubVoice/Sources/AgentHubVoice/Realtime/RealtimeSessionConfigurationBuilder.swift index 618eed41..d9a59e7b 100644 --- a/app/modules/AgentHubVoice/Sources/AgentHubVoice/Realtime/RealtimeSessionConfigurationBuilder.swift +++ b/app/modules/AgentHubVoice/Sources/AgentHubVoice/Realtime/RealtimeSessionConfigurationBuilder.swift @@ -6,6 +6,24 @@ public enum RealtimeSessionConfigurationBuilder { You are AgentHub's concise voice controller. Keep spoken responses brief and natural. """ + /// Persona for registries without session-mutating tools (no `send_prompt`): + /// a standalone assistant that answers directly instead of delegating. + private static let assistantPersona = """ + You are a concise, hands-free voice assistant. Keep spoken responses brief and natural. + Answer the user directly using your tools. In this mode you cannot send prompts, files, + or any content into a coding session — never claim you did, and never suggest routing an + answer through a session; just answer yourself. + """ + + /// Read-only session guidance for assistant registries that still expose + /// session visibility tools. + private static let assistantSessionReadDiscipline = """ + You have read-only visibility into the user's coding sessions. Before referring to a + session, call list_sessions and use only session IDs returned by tools; never invent or + guess a session ID. When the user asks what a session said, found, or produced, call + read_session_response and answer with a concise spoken summary of its content. + """ + private static let followUserLanguage = """ Reply in the language the user is currently speaking. If the language is unclear, use English. Do not switch languages because of background audio or your own spoken response. @@ -54,20 +72,30 @@ public enum RealtimeSessionConfigurationBuilder { language: String? = nil, sessionContext: String? = nil ) -> String { - var combined: String + // `send_prompt` marks a session-controller registry; without it the + // conversation is a standalone assistant and must not be instructed to + // route anything through sessions. + let isSessionController = tools.tools.contains { $0.name == "send_prompt" } + let hasSessionVisibility = tools.tools.contains { $0.name == "list_sessions" } + + var blocks: [String] = [isSessionController ? persona : assistantPersona] if let language, let name = languageName(for: language) { // A pinned language must REPLACE the follow-the-user's-language // directive, not join it: sending both contradictory rules lets // background audio or distorted transcription flip the reply language. - let pinnedLanguage = """ + blocks.append(""" Always speak and respond in \(name), regardless of the language the \ user's audio appears to be in. Never switch languages mid-conversation. - """ - combined = [persona, pinnedLanguage, sessionDiscipline] - .joined(separator: "\n") + """) } else { - combined = instructions + blocks.append(followUserLanguage) + } + if isSessionController { + blocks.append(sessionDiscipline) + } else if hasSessionVisibility { + blocks.append(assistantSessionReadDiscipline) } + var combined = blocks.joined(separator: "\n") let hasScreenCapture = tools.tools.contains { $0.name == "capture_screen" } if hasScreenCapture { combined += "\n" + screenCaptureInstructions @@ -80,6 +108,16 @@ public enum RealtimeSessionConfigurationBuilder { if hasSessionHistory { combined += "\n" + sessionHistoryInstructions } + let mcpServers = mcpServerNames(in: tools) + if !mcpServers.isEmpty { + combined += """ + \nYou also have external tools from the user's MCP servers: \ + \(mcpServers.joined(separator: ", ")). Their tool names are prefixed \ + with the server name. When the user asks what you can do or which \ + tools you have, name these servers and briefly say what their tools \ + offer. + """ + } if let sessionContext = sessionContext?.trimmingCharacters( in: .whitespacesAndNewlines ), !sessionContext.isEmpty { @@ -132,4 +170,18 @@ public enum RealtimeSessionConfigurationBuilder { static func languageName(for code: String) -> String? { Locale(identifier: "en_US").localizedString(forLanguageCode: code) } + + /// MCP-bridged tools are namespaced `{server}__{tool}`; every other tool + /// name in the catalog is a plain snake_case verb, so a `__` separator + /// reliably marks an MCP tool. + static func mcpServerNames(in tools: VoiceToolRegistry) -> [String] { + Set( + tools.tools.compactMap { tool -> String? in + guard let range = tool.name.range(of: "__"), + range.lowerBound != tool.name.startIndex else { return nil } + return String(tool.name[.. Void + let onSelectAssistant: () -> Void var body: some View { Menu { + Button { + onSelectAssistant() + } label: { + Label( + "Assistant · no session", + systemImage: isAssistantMode ? "checkmark" : "sparkles" + ) + } + + Divider() + Button("Automatic target") { onSelect(nil) } @@ -27,12 +40,12 @@ struct VoiceTargetChip: View { } } label: { HStack(spacing: 8) { - Image(systemName: "scope") - Text(target?.name ?? "No session target") + Image(systemName: isAssistantMode ? "sparkles" : "scope") + Text(chipTitle) .lineLimit(1) Spacer() - if let detail = target?.detail { - Text(detail) + if let chipDetail { + Text(chipDetail) .foregroundStyle(.secondary) } Image(systemName: "chevron.up.chevron.down") @@ -46,4 +59,14 @@ struct VoiceTargetChip: View { .menuStyle(.borderlessButton) .accessibilityLabel("Voice session target") } + + private var chipTitle: String { + if isAssistantMode { return "Assistant" } + return target?.name ?? "No session target" + } + + private var chipDetail: String? { + if isAssistantMode { return "no session" } + return target?.detail + } } diff --git a/app/modules/AgentHubVoice/Tests/AgentHubVoiceTests/RealtimeSessionConfigurationBuilderTests.swift b/app/modules/AgentHubVoice/Tests/AgentHubVoiceTests/RealtimeSessionConfigurationBuilderTests.swift index 23480e6d..c99f7789 100644 --- a/app/modules/AgentHubVoice/Tests/AgentHubVoiceTests/RealtimeSessionConfigurationBuilderTests.swift +++ b/app/modules/AgentHubVoice/Tests/AgentHubVoiceTests/RealtimeSessionConfigurationBuilderTests.swift @@ -3,6 +3,16 @@ import Testing @testable import AgentHubVoice struct RealtimeSessionConfigurationBuilderTests { + private func stubTool(named name: String) -> VoiceTool { + VoiceTool( + name: name, + description: "Stub", + parameters: ["type": "object"] + ) { _ in + "{}" + } + } + @Test func buildsAudioSemanticVADAndFunctionTools() throws { let tool = VoiceTool( @@ -60,7 +70,10 @@ struct RealtimeSessionConfigurationBuilderTests { language: "es", allowBargeIn: false ), - tools: VoiceToolRegistry(tools: []) + tools: VoiceToolRegistry(tools: [ + stubTool(named: "send_prompt"), + stubTool(named: "list_sessions"), + ]) ) let data = try JSONEncoder().encode(configuration) @@ -88,6 +101,57 @@ struct RealtimeSessionConfigurationBuilderTests { #expect(instructions.contains("explicit confirmation")) } + @Test + func assistantRegistriesGetTheAssistantPersonaWithoutSessionDiscipline() { + // No send_prompt = standalone assistant: it must be told to answer + // directly and must not carry session-controller or approval rules. + let assistant = RealtimeSessionConfigurationBuilder.instructions( + for: VoiceToolRegistry(tools: [stubTool(named: "gmail__search_messages")]) + ) + #expect(assistant.contains("cannot send prompts")) + #expect(!assistant.contains("voice controller")) + #expect(!assistant.contains("explicit confirmation")) + #expect(!assistant.contains("list_sessions")) + + let controller = RealtimeSessionConfigurationBuilder.instructions( + for: VoiceToolRegistry(tools: [stubTool(named: "send_prompt")]) + ) + #expect(controller.contains("voice controller")) + #expect(controller.contains("explicit confirmation")) + #expect(!controller.contains("cannot send prompts")) + } + + @Test + func assistantRegistriesWithSessionVisibilityGetReadOnlyDiscipline() { + let instructions = RealtimeSessionConfigurationBuilder.instructions( + for: VoiceToolRegistry(tools: [ + stubTool(named: "list_sessions"), + stubTool(named: "read_session_response"), + ]) + ) + #expect(instructions.contains("read-only visibility")) + #expect(instructions.contains("call list_sessions")) + #expect(!instructions.contains("explicit confirmation")) + } + + @Test + func mcpServerRosterIsAnnouncedWhenMCPToolsArePresent() { + let withMCP = RealtimeSessionConfigurationBuilder.instructions( + for: VoiceToolRegistry(tools: [ + stubTool(named: "gmail__search_messages"), + stubTool(named: "gmail__send_message"), + stubTool(named: "slack__post"), + stubTool(named: "list_sessions"), + ]) + ) + #expect(withMCP.contains("MCP servers: gmail, slack")) + + let withoutMCP = RealtimeSessionConfigurationBuilder.instructions( + for: VoiceToolRegistry(tools: [stubTool(named: "list_sessions")]) + ) + #expect(!withoutMCP.contains("MCP servers")) + } + @Test func automaticLanguageKeepsFollowTheUserBehavior() { let instructions = RealtimeSessionConfigurationBuilder.instructions(