From 8cd7eeba17e5e87b069a014f5bef01ea9ca238f0 Mon Sep 17 00:00:00 2001 From: David Choi Date: Tue, 8 Sep 2026 08:00:04 -0400 Subject: [PATCH 1/2] Add researcher and general profiles with orchestrator delegation. Introduces builtin profile defaults (sol/terra/luna at high thinking), an agent_profile_delegate tool for router-first orchestration, and profile routing in main chat alongside messaging. Co-authored-by: Cursor --- .../AgentProfileDelegateToolModule.swift | 52 +++++ .../Sources/AgentRuntime/AgentProfile.swift | 78 ++++++-- .../MCPToolCatalog/AllowedMCPTool.swift | 4 + .../StructureTests/AgentProfileTests.swift | 14 +- ui/JobService/MessagingAgentTurnClient.swift | 7 +- .../AgentProfileBuiltinFactory.swift | 44 +++++ .../Conversation/ConversationModel.swift | 179 ++++++++++++------ .../Conversation/ProfileDelegateRunner.swift | 116 ++++++++++++ .../TurnProcessContext.swift | 3 + ui/ui/AgentProfiles/AgentProfileStore.swift | 7 +- ui/ui/Session/ChatSessionStore.swift | 27 ++- ui/ui/Views/ContentView.swift | 26 ++- .../AgentProfileBuiltinFactoryTests.swift | 33 ++++ 13 files changed, 505 insertions(+), 85 deletions(-) create mode 100644 packages/MCPServer/Sources/MCPServer/Orchestration/AgentProfileDelegateToolModule.swift create mode 100644 ui/SharedAgentRuntime/AgentProfiles/AgentProfileBuiltinFactory.swift create mode 100644 ui/SharedAgentRuntime/Conversation/ProfileDelegateRunner.swift create mode 100644 ui/uiTests/AgentProfileBuiltinFactoryTests.swift diff --git a/packages/MCPServer/Sources/MCPServer/Orchestration/AgentProfileDelegateToolModule.swift b/packages/MCPServer/Sources/MCPServer/Orchestration/AgentProfileDelegateToolModule.swift new file mode 100644 index 00000000..2dbf9e1c --- /dev/null +++ b/packages/MCPServer/Sources/MCPServer/Orchestration/AgentProfileDelegateToolModule.swift @@ -0,0 +1,52 @@ +import Foundation +import MCP +import Structure + +/// Catalog module for `agent_profile_delegate`. +public enum AgentProfileDelegateToolModule { + public static func makeRegistration( + handler: @escaping @Sendable (_ profileHandle: String, _ task: String) async throws -> String + ) -> MCPToolRegistration { + MCPToolRegistration( + tool: .agentProfileDelegate, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "profile_handle": .object([ + "type": .string("string"), + "description": .string( + "Target profile handle without $: developer, researcher, or general." + ) + ]), + "task": .object([ + "type": .string("string"), + "description": .string("Concrete task instructions for the delegated profile.") + ]) + ]), + "required": .array([.string("profile_handle"), .string("task")]) + ]) + ) { arguments in + let handle = stringArg(arguments, "profile_handle") ?? "" + let task = stringArg(arguments, "task") ?? "" + guard !handle.isEmpty, !task.isEmpty else { + throw NSError( + domain: "AgentProfileDelegate", + code: 400, + userInfo: [NSLocalizedDescriptionKey: "profile_handle and task are required"] + ) + } + return try await handler(handle, task) + } + } + + private static func stringArg(_ arguments: [String: Value], _ key: String) -> String? { + guard let value = arguments[key] else { return nil } + switch value { + case .string(let s): return s + case .int(let i): return String(i) + case .double(let d): return String(d) + case .bool(let b): return b ? "true" : "false" + default: return nil + } + } +} diff --git a/packages/Structure/Sources/AgentRuntime/AgentProfile.swift b/packages/Structure/Sources/AgentRuntime/AgentProfile.swift index 2ca11101..be9c211b 100644 --- a/packages/Structure/Sources/AgentRuntime/AgentProfile.swift +++ b/packages/Structure/Sources/AgentRuntime/AgentProfile.swift @@ -24,8 +24,13 @@ public struct AgentProfileRAGConfig: Codable, Sendable, Hashable { public enum AgentProfileHandle { public static let orchestrator = "orchestrator" public static let developer = "developer" + public static let researcher = "researcher" + public static let general = "general" - public static let allBuiltins = [orchestrator, developer] + public static let allBuiltins = [orchestrator, developer, researcher, general] + + /// Profiles the orchestrator may delegate to via `agent_profile_delegate`. + public static let delegateTargets = [developer, researcher, general] public static func normalize(_ raw: String) -> String? { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() @@ -114,22 +119,29 @@ public struct AgentProfile: Codable, Sendable, Hashable, Identifiable { rag.useSessionMemory ? rag.retrievalLimit : 0 } - public static func orchestratorDefault(modelJSON: Data) -> AgentProfile { + public static func orchestratorDefault(modelJSON: Data, thinkingJSON: Data? = nil) -> AgentProfile { AgentProfile( id: "builtin-orchestrator", displayName: "Orchestrator", handle: AgentProfileHandle.orchestrator, instructions: """ - You are Derrick's orchestrator — the default generalist profile. + You are Derrick's orchestrator — a router-first generalist. + + Understand what the user wants. You may research or clarify yourself before routing. \ + Handle simple requests directly when delegation is unnecessary. - Your job is to understand what the user wants, break work into clear steps, and \ - coordinate execution. For implementation, debugging, code review, or technical changes, \ - prefer delegating to the Developer profile ($developer). Summarize outcomes for the \ - user in plain language and report blockers early. + When another profile fits better, delegate with `agent_profile_delegate`: + - `developer` — code, debugging, implementation, technical execution + - `researcher` — research, summarization, synthesis from sources + - `general` — everyday workhorse tasks when no specialist fits + + Use `general` when unsure which specialist fits. Summarize delegated outcomes in plain \ + language and report blockers early. Stay concise unless the user asks for detail. """, modelJSON: modelJSON, + thinkingJSON: thinkingJSON, rag: .default, isEnabled: true, isBuiltin: true, @@ -137,7 +149,7 @@ public struct AgentProfile: Codable, Sendable, Hashable, Identifiable { ) } - public static func developerDefault(modelJSON: Data) -> AgentProfile { + public static func developerDefault(modelJSON: Data, thinkingJSON: Data? = nil) -> AgentProfile { AgentProfile( id: "builtin-developer", displayName: "Developer", @@ -150,6 +162,7 @@ public struct AgentProfile: Codable, Sendable, Hashable, Identifiable { When scope is unclear, ask one focused clarifying question before diving in. """, modelJSON: modelJSON, + thinkingJSON: thinkingJSON, rag: .default, isEnabled: true, isBuiltin: true, @@ -157,10 +170,53 @@ public struct AgentProfile: Codable, Sendable, Hashable, Identifiable { ) } - public static func builtinProfiles(modelJSON: Data) -> [AgentProfile] { + public static func researcherDefault(modelJSON: Data, thinkingJSON: Data? = nil) -> AgentProfile { + AgentProfile( + id: "builtin-researcher", + displayName: "Researcher", + handle: AgentProfileHandle.researcher, + instructions: """ + You are Derrick's Researcher profile. Find, read, and synthesize information. Summarize \ + clearly with sources when available. Prefer accurate synthesis over speculation. + + When research is incomplete, say what is known, what is uncertain, and what would help next. + """, + modelJSON: modelJSON, + thinkingJSON: thinkingJSON, + rag: .default, + isEnabled: true, + isBuiltin: true, + sortOrder: 2 + ) + } + + public static func generalDefault(modelJSON: Data, thinkingJSON: Data? = nil) -> AgentProfile { + AgentProfile( + id: "builtin-general", + displayName: "General", + handle: AgentProfileHandle.general, + instructions: """ + You are Derrick's General profile — the workhorse for everyday tasks: writing, planning, \ + brainstorming, mixed requests, and anything that does not need a specialist. Be practical, \ + direct, and helpful. + + When scope is unclear, ask one focused clarifying question before proceeding. + """, + modelJSON: modelJSON, + thinkingJSON: thinkingJSON, + rag: .default, + isEnabled: true, + isBuiltin: true, + sortOrder: 3 + ) + } + + public static func builtinProfiles(modelJSON: Data, thinkingJSON: Data? = nil) -> [AgentProfile] { [ - orchestratorDefault(modelJSON: modelJSON), - developerDefault(modelJSON: modelJSON), + orchestratorDefault(modelJSON: modelJSON, thinkingJSON: thinkingJSON), + developerDefault(modelJSON: modelJSON, thinkingJSON: thinkingJSON), + researcherDefault(modelJSON: modelJSON, thinkingJSON: thinkingJSON), + generalDefault(modelJSON: modelJSON, thinkingJSON: thinkingJSON), ] } } diff --git a/packages/Structure/Sources/MCPToolCatalog/AllowedMCPTool.swift b/packages/Structure/Sources/MCPToolCatalog/AllowedMCPTool.swift index 2f97faba..2976abe7 100644 --- a/packages/Structure/Sources/MCPToolCatalog/AllowedMCPTool.swift +++ b/packages/Structure/Sources/MCPToolCatalog/AllowedMCPTool.swift @@ -12,6 +12,8 @@ public enum AllowedMCPTool: String, CaseIterable, Sendable, Codable, Hashable { case agentsList = "agents_list" case agentsSend = "agents_send" case agentsCancel = "agents_cancel" + /// Orchestrator delegates a sub-task to another agent profile ($developer, $researcher, $general). + case agentProfileDelegate = "agent_profile_delegate" /// One-shot durable job (optional delay). Local orchestration → JobService. case jobsCreate = "jobs_create" /// Recurring or one-shot schedule template. Local orchestration → JobService. @@ -54,6 +56,8 @@ public enum AllowedMCPTool: String, CaseIterable, Sendable, Codable, Hashable { return "Send a message to a parent or child agent only (no peer messaging)." case .agentsCancel: return "Cancel a child agent (or self) in the current session." + case .agentProfileDelegate: + return "Delegate a task to another agent profile (developer, researcher, or general) and return its result." case .jobsCreate: return "Create a one-shot background job (optional delay). Freezes a tool call; optional wake of this agent after the tool runs." case .jobsScheduleCreate: diff --git a/packages/Structure/Tests/StructureTests/AgentProfileTests.swift b/packages/Structure/Tests/StructureTests/AgentProfileTests.swift index 19cdf7ce..a9382b28 100644 --- a/packages/Structure/Tests/StructureTests/AgentProfileTests.swift +++ b/packages/Structure/Tests/StructureTests/AgentProfileTests.swift @@ -37,11 +37,21 @@ import Testing #expect(decoded.displayName == "Orchestrator") } - @Test func builtinProfilesIncludeOrchestratorAndDeveloper() { + @Test func builtinProfilesIncludeAllBuiltins() { let modelJSON = Data(#"{"openai":"gpt-5.6-luna"}"#.utf8) let profiles = AgentProfile.builtinProfiles(modelJSON: modelJSON) - #expect(profiles.count == 2) + #expect(profiles.count == 4) #expect(profiles.map(\.handle).contains(AgentProfileHandle.orchestrator)) #expect(profiles.map(\.handle).contains(AgentProfileHandle.developer)) + #expect(profiles.map(\.handle).contains(AgentProfileHandle.researcher)) + #expect(profiles.map(\.handle).contains(AgentProfileHandle.general)) + } + + @Test func delegateTargetsExcludeOrchestrator() { + #expect(AgentProfileHandle.delegateTargets == [ + AgentProfileHandle.developer, + AgentProfileHandle.researcher, + AgentProfileHandle.general, + ]) } } diff --git a/ui/JobService/MessagingAgentTurnClient.swift b/ui/JobService/MessagingAgentTurnClient.swift index 240271f2..77a44c0f 100644 --- a/ui/JobService/MessagingAgentTurnClient.swift +++ b/ui/JobService/MessagingAgentTurnClient.swift @@ -41,11 +41,8 @@ enum MessagingAgentTurnClient { } private static func ensureBuiltins(repository: DBRepository) async throws { - let modelJSON = try JSONEncoder().encode(LLMModelChoice.defaultHelperModel) - for profile in AgentProfile.builtinProfiles(modelJSON: modelJSON) { - if try await repository.agentProfile(handle: profile.handle) == nil { - try await repository.upsertAgentProfile(profile) - } + for profile in try AgentProfileBuiltinFactory.all() { + try await repository.upsertAgentProfile(profile) } } diff --git a/ui/SharedAgentRuntime/AgentProfiles/AgentProfileBuiltinFactory.swift b/ui/SharedAgentRuntime/AgentProfiles/AgentProfileBuiltinFactory.swift new file mode 100644 index 00000000..f1c183af --- /dev/null +++ b/ui/SharedAgentRuntime/AgentProfiles/AgentProfileBuiltinFactory.swift @@ -0,0 +1,44 @@ +import Foundation +import LLMAgentClient +import Structure + +/// Built-in agent profiles with product-default models and thinking levels. +public enum AgentProfileBuiltinFactory { + public static func all() throws -> [AgentProfile] { + let solHigh = try wire(model: .openai(.gpt56Sol), thinkingID: "high") + let terraHigh = try wire(model: .openai(.gpt56Terra), thinkingID: "high") + let lunaHigh = try wire(model: .openai(.gpt56Luna), thinkingID: "high") + return [ + AgentProfile.orchestratorDefault(modelJSON: solHigh.modelJSON, thinkingJSON: solHigh.thinkingJSON), + AgentProfile.developerDefault(modelJSON: terraHigh.modelJSON, thinkingJSON: terraHigh.thinkingJSON), + AgentProfile.researcherDefault(modelJSON: terraHigh.modelJSON, thinkingJSON: terraHigh.thinkingJSON), + AgentProfile.generalDefault(modelJSON: lunaHigh.modelJSON, thinkingJSON: lunaHigh.thinkingJSON), + ] + } + + private struct Wire { + let modelJSON: Data + let thinkingJSON: Data + } + + private static func wire(model: LLMModelChoice, thinkingID: String) throws -> Wire { + guard let thinking = model.thinkingOptions.first(where: { $0.id == thinkingID }) else { + throw AgentProfileBuiltinFactoryError.missingThinking(model: model.id, thinkingID: thinkingID) + } + return Wire( + modelJSON: try JSONEncoder().encode(model), + thinkingJSON: try JSONEncoder().encode(thinking) + ) + } +} + +public enum AgentProfileBuiltinFactoryError: Error, LocalizedError { + case missingThinking(model: String, thinkingID: String) + + public var errorDescription: String? { + switch self { + case .missingThinking(let model, let thinkingID): + return "Missing thinking option \(thinkingID) for model \(model)." + } + } +} diff --git a/ui/SharedAgentRuntime/Conversation/ConversationModel.swift b/ui/SharedAgentRuntime/Conversation/ConversationModel.swift index c534ce68..95e02e85 100644 --- a/ui/SharedAgentRuntime/Conversation/ConversationModel.swift +++ b/ui/SharedAgentRuntime/Conversation/ConversationModel.swift @@ -23,47 +23,7 @@ final class ConversationModel { let mcpToolInstructions: String private let helperModelSettings: LLMModelSettings private let repository: DBRepository - let responseSchema: AgentSchema = AgentSchema( - type: .object, - properties: [ - "status": AgentSchema(type: .string, description: "One of: '\(AgentResponseStatus.thinking.rawValue)', '\(AgentResponseStatus.toolCall.rawValue)', '\(AgentResponseStatus.toolBatch.rawValue)', '\(AgentResponseStatus.complete.rawValue)'. CacheBust: \(UUID().uuidString)"), - "thought": AgentSchema(type: .string, description: "Your internal plan or reasoning steps"), - "assistant_response": AgentSchema(type: .string, description: "The markdown, json or csv message content meant for user."), - "tool_call": AgentSchema( - type: .object, - properties: [ - "tool_name": AgentSchema(type: .string, description: "Name of the tool to execute"), - "arguments": AgentSchema( - type: .string, - description: "Single-line JSON object string of tool arguments. Escape newlines as \\n and quotes as \\\". Keep compact; nested scripts must use \\n not real line breaks." - ) - ], - required: ["tool_name", "arguments"] - ), - "tool_batch": AgentSchema( - type: .object, - properties: [ - "invocations": AgentSchema( - type: .array, - items: AgentSchema( - type: .object, - properties: [ - "tool_name": AgentSchema(type: .string, description: "Name of the tool to execute"), - "arguments": AgentSchema( - type: .string, - description: "Single-line JSON object string of tool arguments. Escape newlines as \\n and quotes as \\\"." - ) - ], - required: ["tool_name", "arguments"] - ), - description: "Array of tool invocation objects" - ), - ], - required: ["invocations"] - ) - ], - required: ["status"], - ) + let responseSchema: AgentSchema = ConversationModel.defaultResponseSchema private init( sessionKey: MemorySessionKey, @@ -101,10 +61,10 @@ final class ConversationModel { repository: repository ) try await orchestrator.bootstrapUserFacingAgent() - var sessionKey = orchestrator.memorySessionKey - if let agentIDOverride { - sessionKey = MemorySessionKey(sessionID: sessionKey.sessionID, agentID: agentIDOverride) - } + let baseSessionKey = orchestrator.memorySessionKey + let sessionKey = agentIDOverride.map { + MemorySessionKey(sessionID: baseSessionKey.sessionID, agentID: $0) + } ?? baseSessionKey let ragInstructions = try PromptResources.conversationRAGInstructions(prefixTxt: PromptResources.currentDatePrefix()) let summarizerInstructions = try PromptResources.memorySummarizerInstructions() let mcpToolInstructions = [ @@ -122,11 +82,57 @@ final class ConversationModel { debugLog("Memory bootstrap started session=\(sessionKey.sessionID) agent=\(sessionKey.agentID)") debugLog("Database directory: \(await repository.databaseDirectoryURL.path)") + let memoryCoordinator = MemoryCoordinator( + store: repository, + summarizer: summarizer, + policy: TieredMemoryCompactionPolicy(), + budget: budget + ) + let interceptor = DefaultPolicyInterceptor( + policy: StoreBackedCompletionContentPolicy(store: repository, applicationName: "ui") + ) + + let delegateAgentsHost = try await makeAgentsOrchestrationHost( + orchestrator: orchestrator, + sessionID: sessionKey.sessionID, + agentID: sessionKey.agentID, + helperModelSettings: helperModelSettings, + includeProfileDelegate: false + ) + + let profileDelegateHandler: @Sendable (String, String) async throws -> String = { handle, task in + guard TurnProcessContext.activeProfileHandle == AgentProfileHandle.orchestrator else { + throw NSError( + domain: "AgentProfileDelegate", + code: 403, + userInfo: [ + NSLocalizedDescriptionKey: + "agent_profile_delegate is only available to the orchestrator profile." + ] + ) + } + return try await ProfileDelegateRunner.run( + profileHandle: handle, + task: task, + sessionKey: sessionKey, + memoryCoordinator: memoryCoordinator, + policyStore: repository, + repository: repository, + agentsClient: delegateAgentsHost.client, + ragInstructions: ragInstructions, + mcpToolInstructions: mcpToolInstructions, + responseSchema: Self.defaultResponseSchema, + interceptor: interceptor + ) + } + let agentsHost = try await makeAgentsOrchestrationHost( orchestrator: orchestrator, sessionID: sessionKey.sessionID, agentID: sessionKey.agentID, - helperModelSettings: helperModelSettings + helperModelSettings: helperModelSettings, + includeProfileDelegate: true, + profileDelegateHandler: profileDelegateHandler ) let principal = ServicePrincipal.agent( sessionID: sessionKey.sessionID, @@ -147,12 +153,7 @@ final class ConversationModel { return ConversationModel( sessionKey: sessionKey, orchestrator: orchestrator, - memoryCoordinator: MemoryCoordinator( - store: repository, - summarizer: summarizer, - policy: TieredMemoryCompactionPolicy(), - budget: budget - ), + memoryCoordinator: memoryCoordinator, policyStore: repository, agentsOrchestrationHost: agentsHost, toolClient: toolClient, @@ -163,6 +164,48 @@ final class ConversationModel { ) } + private static let defaultResponseSchema = AgentSchema( + type: .object, + properties: [ + "status": AgentSchema(type: .string, description: "One of: '\(AgentResponseStatus.thinking.rawValue)', '\(AgentResponseStatus.toolCall.rawValue)', '\(AgentResponseStatus.toolBatch.rawValue)', '\(AgentResponseStatus.complete.rawValue)'. CacheBust: \(UUID().uuidString)"), + "thought": AgentSchema(type: .string, description: "Your internal plan or reasoning steps"), + "assistant_response": AgentSchema(type: .string, description: "The markdown, json or csv message content meant for user."), + "tool_call": AgentSchema( + type: .object, + properties: [ + "tool_name": AgentSchema(type: .string, description: "Name of the tool to execute"), + "arguments": AgentSchema( + type: .string, + description: "Single-line JSON object string of tool arguments. Escape newlines as \\n and quotes as \\\". Keep compact; nested scripts must use \\n not real line breaks." + ) + ], + required: ["tool_name", "arguments"] + ), + "tool_batch": AgentSchema( + type: .object, + properties: [ + "invocations": AgentSchema( + type: .array, + items: AgentSchema( + type: .object, + properties: [ + "tool_name": AgentSchema(type: .string, description: "Name of the tool to execute"), + "arguments": AgentSchema( + type: .string, + description: "Single-line JSON object string of tool arguments. Escape newlines as \\n and quotes as \\\"." + ) + ], + required: ["tool_name", "arguments"] + ), + description: "Array of tool invocation objects" + ), + ], + required: ["invocations"] + ) + ], + required: ["status"], + ) + func stream( prompt: String, apiKey: String, @@ -282,6 +325,17 @@ final class ConversationModel { } } + let effectiveMcpToolInstructions: String + if profileContext?.handle == AgentProfileHandle.orchestrator { + effectiveMcpToolInstructions = [ + mcpToolInstructions, + Self.profileDelegateToolInstructions, + ].joined(separator: "\n\n") + } else { + effectiveMcpToolInstructions = mcpToolInstructions + } + + try await TurnProcessContext.$activeProfileHandle.withValue(profileContext?.handle) { try await orchestrator.withWorkerRunner(workerRunner) { try await orchestrator.deliverUserMessage(prompt) { envelope in try await AgentCallContext.$caller.withValue(orchestrator.userFacingRef) { @@ -295,7 +349,7 @@ final class ConversationModel { policyStore: policyStore, mcpClient: toolClient, ragInstructions: userRagBase, - mcpToolInstructions: mcpToolInstructions, + mcpToolInstructions: effectiveMcpToolInstructions, responseSchema: responseSchema, interceptor: interceptor, approvalPresenter: approvalPresenter, @@ -313,8 +367,16 @@ final class ConversationModel { } } } + } } + private static let profileDelegateToolInstructions = """ + 14. Profile delegation (orchestrator only; when listed in the catalog): + 1. `agent_profile_delegate` — args `profile_handle` (developer, researcher, or general; no $) and `task` (concrete instructions). Blocks until the profile finishes; use the returned text in your next step. + 2. Prefer `researcher` for research and summarization, `developer` for code, and `general` when no specialist fits. + 3. Delegated profiles do not talk to the user directly; synthesize their result into your `assistant_response`. + """ + private func collectPluginCredentialsIfNeeded( pluginID: String, fields: [PluginSecretDescriptor], @@ -457,7 +519,7 @@ final class ConversationModel { } /// Builds the existing conversation pipeline stream for one envelope body (turn engine unchanged). - nonisolated private static func makePolicyStream( + nonisolated static func makePolicyStream( prompt: String, apiKey: String, model: LLMModelChoice, @@ -553,7 +615,9 @@ final class ConversationModel { orchestrator: SessionOrchestrator, sessionID: String, agentID: String, - helperModelSettings: LLMModelSettings + helperModelSettings: LLMModelSettings, + includeProfileDelegate: Bool, + profileDelegateHandler: (@Sendable (String, String) async throws -> String)? = nil ) async throws -> MCPLocalBridge { let principal = ServicePrincipal.agent(sessionID: sessionID, agentID: agentID) let placer: any JobOrderPlacing = JobServiceClientOrderPlacer(from: .agent) @@ -587,6 +651,11 @@ final class ConversationModel { try await orchestrator.cancel(agentID: agentID) } ) + if includeProfileDelegate, let profileDelegateHandler { + await server.register( + AgentProfileDelegateToolModule.makeRegistration(handler: profileDelegateHandler) + ) + } await server.register( JobOrchestrationToolModule.createJobRegistration { runAfterSeconds, runAtString, toolName, toolArgumentsJSON, wakeAfter, wakePrompt, description diff --git a/ui/SharedAgentRuntime/Conversation/ProfileDelegateRunner.swift b/ui/SharedAgentRuntime/Conversation/ProfileDelegateRunner.swift new file mode 100644 index 00000000..3a9f8666 --- /dev/null +++ b/ui/SharedAgentRuntime/Conversation/ProfileDelegateRunner.swift @@ -0,0 +1,116 @@ +import Foundation +import MCP +import AgentRuntime +import DBRepository +import LLMAgentClient +import MCPClient +import MCPServer +import MemorySystem +import PolicyRuntime +import Structure + +/// Runs a collected sub-turn as a delegated agent profile. +enum ProfileDelegateRunner { + nonisolated static func run( + profileHandle: String, + task: String, + sessionKey: MemorySessionKey, + memoryCoordinator: MemoryCoordinator, + policyStore: (any PolicyStore)?, + repository: DBRepository, + agentsClient: MCPClient, + ragInstructions: String, + mcpToolInstructions: String, + responseSchema: AgentSchema, + interceptor: PolicyInterceptor + ) async throws -> String { + let normalized = AgentProfileHandle.normalize( + profileHandle.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "$", with: "") + ) ?? profileHandle.lowercased() + guard AgentProfileHandle.delegateTargets.contains(normalized) else { + throw ProfileDelegateRunnerError.invalidTarget(normalized) + } + guard let profile = try await repository.agentProfile(handle: normalized), profile.isEnabled else { + throw ProfileDelegateRunnerError.profileUnavailable(normalized) + } + + let profileContext = AgentProfileTurnContext(profile: profile) + let model = (try? JSONDecoder().decode(LLMModelChoice.self, from: profileContext.modelJSON)) + ?? .defaultHelperModel + let thinking = profileContext.thinkingJSON.flatMap { + try? JSONDecoder().decode(ModelThinkingOption.self, from: $0) + } + let apiKey = TurnProcessContext.effectiveAPIKey ?? "" + + let delegateSessionKey = MemorySessionKey( + sessionID: sessionKey.sessionID, + agentID: "profile-\(normalized)" + ) + let userRagBase = [ + profileContext.rag.useDefaultInstructions + ? ragInstructions + : profileContext.rag.customInstructions?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + ? profileContext.rag.customInstructions! + : ragInstructions, + profileContext.instructions.trimmingCharacters(in: .whitespacesAndNewlines), + ] + .filter { !$0.isEmpty } + .joined(separator: "\n\n") + let retrievalLimit = profileContext.rag.useSessionMemory ? profileContext.rag.retrievalLimit : 0 + + let delegateToolClient = XPCConversationToolClient( + principal: ServicePrincipal.agent( + sessionID: sessionKey.sessionID, + agentID: delegateSessionKey.agentID + ), + agentsClient: agentsClient, + helperReviewerModelJSONProvider: { nil } + ) + + let stream = await ConversationModel.makePolicyStream( + prompt: task, + apiKey: apiKey, + model: model, + thinking: thinking, + sessionKey: delegateSessionKey, + memoryCoordinator: memoryCoordinator, + policyStore: policyStore, + mcpClient: delegateToolClient, + ragInstructions: userRagBase, + mcpToolInstructions: mcpToolInstructions, + responseSchema: responseSchema, + interceptor: interceptor, + approvalPresenter: nil, + retrievalLimit: retrievalLimit + ) + + var completeText = "" + for try await chunk in stream { + if chunk.status == .complete { + completeText += chunk.chunk ?? "" + } + } + let trimmed = completeText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw ProfileDelegateRunnerError.emptyResponse + } + return trimmed + } +} + +enum ProfileDelegateRunnerError: Error, LocalizedError { + case invalidTarget(String) + case profileUnavailable(String) + case emptyResponse + + var errorDescription: String? { + switch self { + case .invalidTarget(let handle): + return "Profile \(handle) cannot be delegated to. Use developer, researcher, or general." + case .profileUnavailable(let handle): + return "Profile \(handle) is not available." + case .emptyResponse: + return "Delegated profile produced no response." + } + } +} diff --git a/ui/SharedAgentRuntime/TurnProcessContext.swift b/ui/SharedAgentRuntime/TurnProcessContext.swift index aa2dc065..ab0f2532 100644 --- a/ui/SharedAgentRuntime/TurnProcessContext.swift +++ b/ui/SharedAgentRuntime/TurnProcessContext.swift @@ -28,6 +28,9 @@ public enum TurnProcessContext { /// Fire-and-forget policy notices (failure / informational modals) when UI is connected. @TaskLocal public static var policyNoticePublisher: PolicyNoticePublisher? + /// Active agent profile handle for the current user-facing turn (orchestrator-only tools). + @TaskLocal public static var activeProfileHandle: String? + /// Active `/create-plugin` or `/edit-plugin` factory turn (enables sync `web.crawl`). @TaskLocal public static var pluginFactoryCreationActive: Bool = false diff --git a/ui/ui/AgentProfiles/AgentProfileStore.swift b/ui/ui/AgentProfiles/AgentProfileStore.swift index 0a91ecca..6a8888e0 100644 --- a/ui/ui/AgentProfiles/AgentProfileStore.swift +++ b/ui/ui/AgentProfiles/AgentProfileStore.swift @@ -85,11 +85,8 @@ final class AgentProfileStore: ObservableObject { } private func ensureBuiltins(repository: DBRepository) async throws { - let modelJSON = try JSONEncoder().encode(LLMModelChoice.defaultHelperModel) - for profile in AgentProfile.builtinProfiles(modelJSON: modelJSON) { - if try await repository.agentProfile(handle: profile.handle) == nil { - try await repository.upsertAgentProfile(profile) - } + for profile in try AgentProfileBuiltinFactory.all() { + try await repository.upsertAgentProfile(profile) } } } diff --git a/ui/ui/Session/ChatSessionStore.swift b/ui/ui/Session/ChatSessionStore.swift index 45267c51..37aa83c7 100644 --- a/ui/ui/Session/ChatSessionStore.swift +++ b/ui/ui/Session/ChatSessionStore.swift @@ -114,8 +114,7 @@ final class ChatSessionStore: ObservableObject { func sendPrompt( _ prompt: String, apiKey: String, - model: LLMModelChoice, - thinking: ModelThinkingOption, + profileHandle: String, onError: @escaping (String) -> Void ) { if let selected = selectedSessionID, JobSessionID.isJobSession(selected) { @@ -133,6 +132,21 @@ final class ChatSessionStore: ObservableObject { let attachments = tabs[tabIndex].pendingAttachments guard !trimmed.isEmpty || !attachments.isEmpty else { return } + guard let resolved = AgentProfileStore.shared.resolveProfile( + explicitHandle: profileHandle, + message: trimmed + ) else { + onError("Choose a profile and enter a message.") + return + } + let profile = resolved.profile + let profilePrompt = resolved.prompt + let model = (try? JSONDecoder().decode(LLMModelChoice.self, from: profile.modelJSON)) + ?? .defaultHelperModel + let thinking = profile.thinkingJSON.flatMap { + try? JSONDecoder().decode(ModelThinkingOption.self, from: $0) + } ?? model.defaultThinkingOption + tabs[tabIndex].pendingAttachments = [] tabs[tabIndex].turns.append( ChatTurn(prompt: trimmed, attachments: attachments, response: "") @@ -140,7 +154,7 @@ final class ChatSessionStore: ObservableObject { tabs[tabIndex].isStreaming = true updateTitleIfNeeded( sessionID: sessionID, - prompt: trimmed, + prompt: profilePrompt, attachments: attachments, tabIndex: tabIndex ) @@ -148,13 +162,15 @@ final class ChatSessionStore: ObservableObject { let stagedRoot = try? ChatFileAttachmentStager.defaultRootDirectory() let agentPrompt = ChatFileAttachmentPromptComposer.agentPrompt( - userText: trimmed, + userText: profilePrompt, payloads: ChatFileAttachmentInliner.payloads( attachments: attachments, rootDirectory: stagedRoot ) ) + let profileContextJSON = try? JSONEncoder().encode(AgentProfileTurnContext(profile: profile)) + activeTasks[sessionID]?.cancel() activeTasks[sessionID] = Task { defer { @@ -172,7 +188,8 @@ final class ChatSessionStore: ObservableObject { prompt: agentPrompt, apiKey: apiKey, modelJSON: modelJSON, - thinkingJSON: thinkingJSON + thinkingJSON: thinkingJSON, + profileContextJSON: profileContextJSON ) let stream = AgentServiceClient.shared.streamTurn(request) let streamStarted = Date() diff --git a/ui/ui/Views/ContentView.swift b/ui/ui/Views/ContentView.swift index a66aa6ca..9753d1ec 100644 --- a/ui/ui/Views/ContentView.swift +++ b/ui/ui/Views/ContentView.swift @@ -256,6 +256,7 @@ struct ContentView: View { @State private var selectedProvider: LLMProviderChoice = .openai @State private var selectedModel: LLMModelChoice = .openai(.gpt56Luna) @State private var selectedThinking: ModelThinkingOption = OpenAIModel.gpt56Luna.defaultThinkingOption + @State private var selectedProfileHandle: String = AgentProfileHandle.orchestrator @State private var helperModelSettings: LLMModelSettings? @State private var modelThinkingSettings: LLMModelThinkingSettings? @State private var promptFocusToken = 0 @@ -264,6 +265,7 @@ struct ContentView: View { @ObservedObject private var policyEventPresenter = PolicyEventPresenter.shared @ObservedObject private var usageLimitRaisePresenter = UsageLimitRaisePresenter.shared @ObservedObject private var pluginFactoryList = PluginFactoryListStore.shared + @ObservedObject private var agentProfiles = AgentProfileStore.shared @StateObject private var pluginCreationController = PluginCreationController() @State private var pluginAutocompleteHighlight = 0 @State private var pluginAutocompleteDismissed = false @@ -1132,6 +1134,27 @@ struct ContentView: View { Spacer() + Menu { + Picker("Profile", selection: $selectedProfileHandle) { + ForEach(agentProfiles.enabledProfiles, id: \.handle) { profile in + Text(profile.displayName).tag(profile.handle) + } + } + } label: { + HStack(spacing: 5) { + Image(systemName: "chevron.down") + .font(.system(size: bottomPromptIconSize, weight: .medium)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + Text(agentProfiles.profile(handle: selectedProfileHandle)?.displayName ?? "Profile") + .font(.system(size: bottomPromptFontSize)) + .foregroundStyle(Color(nsColor: .labelColor)) + } + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .disabled(isActiveTabStreaming) + Menu { Picker("Provider", selection: $selectedProvider) { ForEach(LLMProviderChoice.allCases) { provider in @@ -1367,8 +1390,7 @@ struct ContentView: View { chatSessions.sendPrompt( currentPrompt, apiKey: resolveAPIKey() ?? "", - model: selectedModel, - thinking: selectedThinking + profileHandle: selectedProfileHandle ) { message in errorMessage = message } diff --git a/ui/uiTests/AgentProfileBuiltinFactoryTests.swift b/ui/uiTests/AgentProfileBuiltinFactoryTests.swift new file mode 100644 index 00000000..ea18496c --- /dev/null +++ b/ui/uiTests/AgentProfileBuiltinFactoryTests.swift @@ -0,0 +1,33 @@ +import Foundation +import LLMAgentClient +import Structure +import Testing +@testable import ui + +@Suite struct AgentProfileBuiltinFactoryTests { + @Test func builtinProfilesUseExpectedModels() throws { + let profiles = try AgentProfileBuiltinFactory.all() + #expect(profiles.count == 4) + + let orchestrator = profiles.first { $0.handle == "orchestrator" }! + let developer = profiles.first { $0.handle == "developer" }! + let researcher = profiles.first { $0.handle == "researcher" }! + let general = profiles.first { $0.handle == "general" }! + + let orchestratorModel = try JSONDecoder().decode(LLMModelChoice.self, from: orchestrator.modelJSON) + let developerModel = try JSONDecoder().decode(LLMModelChoice.self, from: developer.modelJSON) + let researcherModel = try JSONDecoder().decode(LLMModelChoice.self, from: researcher.modelJSON) + let generalModel = try JSONDecoder().decode(LLMModelChoice.self, from: general.modelJSON) + + #expect(orchestratorModel.id == "openai:gpt-5.6-sol") + #expect(developerModel.id == "openai:gpt-5.6-terra") + #expect(researcherModel.id == "openai:gpt-5.6-terra") + #expect(generalModel.id == "openai:gpt-5.6-luna") + + let orchestratorThinking = try JSONDecoder().decode( + ModelThinkingOption.self, + from: orchestrator.thinkingJSON! + ) + #expect(orchestratorThinking.id == "high") + } +} From a9015733af82c65c82a50b6adb70af9b7f79b267 Mon Sep 17 00:00:00 2001 From: David Choi Date: Tue, 8 Sep 2026 19:36:13 -0400 Subject: [PATCH 2/2] Add profile polish: channel defaults, help text, thinking UI. Per-channel default profile on messaging threads, dynamic profile list when @Derrick is mentioned without a request, and thinking level editing in agent profile settings. Co-authored-by: Cursor --- .../DBRepository/DBRepositoryMessaging.swift | 34 +++++++++++++-- .../Sources/DBRepository/DatabaseSchema.swift | 4 +- ..._messaging_thread_default_profile.down.sql | 1 + ...08_messaging_thread_default_profile.up.sql | 1 + .../DBRepositoryTests/DBMessagingTests.swift | 25 +++++++++++ .../MessagingAgentIngressRouter.swift | 13 +++++- .../AppServices/MessagingDTOs.swift | 4 ++ .../Messaging/ConnectorMentionRouting.swift | 41 ++++++++++++++++--- .../ConnectorMentionRoutingTests.swift | 23 +++++++++++ .../Messaging/MessagingConversationView.swift | 40 ++++++++++++++++++ ui/ui/Messaging/MessagingSessionStore.swift | 14 +++++++ ui/ui/Messaging/MessagingStore.swift | 4 ++ ui/ui/Views/AgentProfileSettingsView.swift | 31 ++++++++++++++ 13 files changed, 223 insertions(+), 12 deletions(-) create mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0008_messaging_thread_default_profile.down.sql create mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0008_messaging_thread_default_profile.up.sql diff --git a/packages/DBRepository/Sources/DBRepository/DBRepositoryMessaging.swift b/packages/DBRepository/Sources/DBRepository/DBRepositoryMessaging.swift index 4556f3c9..2cf7415e 100644 --- a/packages/DBRepository/Sources/DBRepository/DBRepositoryMessaging.swift +++ b/packages/DBRepository/Sources/DBRepository/DBRepositoryMessaging.swift @@ -141,6 +141,30 @@ public extension DBRepository { } } + func setMessagingThreadDefaultAgentProfile(threadID: String, handle: String?) throws { + let trimmedID = threadID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedID.isEmpty else { + throw DBRepositoryError.sqliteOperationFailed("Messaging thread id is required.") + } + let normalized = handle?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let valueSQL: String + if let normalized, !normalized.isEmpty { + guard AgentProfileHandle.isValid(normalized) else { + throw DBRepositoryError.sqliteOperationFailed("Invalid agent profile handle.") + } + valueSQL = quoted(normalized) + } else { + valueSQL = "NULL" + } + try withDatabaseHandle { handle in + try Self.execute(""" + UPDATE messaging_threads + SET default_agent_profile_handle = \(valueSQL) + WHERE id = \(quoted(trimmedID)); + """, on: handle) + } + } + func clearMessagingThreadUnread(id: String) throws { try withDatabaseHandle { handle in try Self.execute(""" @@ -339,7 +363,7 @@ public extension DBRepository { try Self.execute(""" INSERT INTO messaging_threads ( id, plugin_id, vendor_thread_id, title, last_activity_at, - muted, unread_count, created_at + muted, unread_count, default_agent_profile_handle, created_at ) VALUES ( \(quoted(thread.id)), \(quoted(thread.pluginID)), @@ -348,6 +372,7 @@ public extension DBRepository { \(quoted(activity)), \(thread.muted ? 1 : 0), \(max(0, thread.unreadCount)), + \(sqlValue(thread.defaultAgentProfileHandle)), \(quoted(created)) ) ON CONFLICT(plugin_id, vendor_thread_id) DO UPDATE SET @@ -477,7 +502,7 @@ public extension DBRepository { private func loadMessagingThreads(pluginID: String, on handle: OpaquePointer) throws -> [MessagingThreadDTO] { let sql = """ SELECT id, plugin_id, vendor_thread_id, title, last_activity_at, - muted, unread_count, created_at + muted, unread_count, created_at, default_agent_profile_handle FROM messaging_threads WHERE plugin_id = \(quoted(pluginID)) ORDER BY last_activity_at DESC, id DESC; @@ -497,7 +522,7 @@ public extension DBRepository { private func loadMessagingThread(id: String, on handle: OpaquePointer) throws -> MessagingThreadDTO? { let sql = """ SELECT id, plugin_id, vendor_thread_id, title, last_activity_at, - muted, unread_count, created_at + muted, unread_count, created_at, default_agent_profile_handle FROM messaging_threads WHERE id = \(quoted(id)) LIMIT 1; @@ -518,7 +543,7 @@ public extension DBRepository { ) throws -> MessagingThreadDTO? { let sql = """ SELECT id, plugin_id, vendor_thread_id, title, last_activity_at, - muted, unread_count, created_at + muted, unread_count, created_at, default_agent_profile_handle FROM messaging_threads WHERE plugin_id = \(quoted(pluginID)) AND vendor_thread_id = \(quoted(vendorThreadID)) @@ -597,6 +622,7 @@ public extension DBRepository { lastActivityAt: Self.iso8601Formatter().date(from: try columnString(statement, index: 4)) ?? .now, muted: sqlite3_column_int(statement, 5) != 0, unreadCount: Int(sqlite3_column_int(statement, 6)), + defaultAgentProfileHandle: columnOptionalString(statement, index: 8), createdAt: Self.iso8601Formatter().date(from: try columnString(statement, index: 7)) ?? .now ) } diff --git a/packages/DBRepository/Sources/DBRepository/DatabaseSchema.swift b/packages/DBRepository/Sources/DBRepository/DatabaseSchema.swift index 67abbbab..6e3121c2 100644 --- a/packages/DBRepository/Sources/DBRepository/DatabaseSchema.swift +++ b/packages/DBRepository/Sources/DBRepository/DatabaseSchema.swift @@ -2,7 +2,7 @@ import Foundation import Structure public enum DatabaseSchema { - public static let latestVersion = 7 + public static let latestVersion = 8 public static func migrationSQL(version: Int, isUp: Bool) throws -> String { let migrationName = String(format: "%04d_%@", version, migrationFileBaseName(for: version)) @@ -37,6 +37,8 @@ public enum DatabaseSchema { return "agent_profiles" case 7: return "messaging_agent_handled" + case 8: + return "messaging_thread_default_profile" default: return "unknown" } diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0008_messaging_thread_default_profile.down.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0008_messaging_thread_default_profile.down.sql new file mode 100644 index 00000000..4c3b9cd8 --- /dev/null +++ b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0008_messaging_thread_default_profile.down.sql @@ -0,0 +1 @@ +ALTER TABLE messaging_threads DROP COLUMN default_agent_profile_handle; diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0008_messaging_thread_default_profile.up.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0008_messaging_thread_default_profile.up.sql new file mode 100644 index 00000000..35f4b6cd --- /dev/null +++ b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0008_messaging_thread_default_profile.up.sql @@ -0,0 +1 @@ +ALTER TABLE messaging_threads ADD COLUMN default_agent_profile_handle TEXT; diff --git a/packages/DBRepository/Tests/DBRepositoryTests/DBMessagingTests.swift b/packages/DBRepository/Tests/DBRepositoryTests/DBMessagingTests.swift index 1ff1434e..f1eb0ece 100644 --- a/packages/DBRepository/Tests/DBRepositoryTests/DBMessagingTests.swift +++ b/packages/DBRepository/Tests/DBRepositoryTests/DBMessagingTests.swift @@ -330,6 +330,31 @@ final class DBMessagingTests: XCTestCase { XCTAssertEqual(Set(threads.map(\.vendorThreadID)), ["C1"]) } + func testMessagingThreadDefaultAgentProfileRoundTrip() async throws { + let repository = try makeRepository() + _ = try await repository.createEmptyDatabaseIfNeeded(username: "app-user", password: "app-secret") + try await repository.upsertMessagingConnector( + MessagingConnectorDTO(pluginID: "slack-connection", displayName: "Slack") + ) + let thread = MessagingThreadDTO( + pluginID: "slack-connection", + vendorThreadID: "C1", + title: "#general" + ) + try await repository.upsertMessagingThread(thread) + + try await repository.setMessagingThreadDefaultAgentProfile( + threadID: thread.id, + handle: AgentProfileHandle.researcher + ) + let loaded = try await repository.messagingThread(id: thread.id) + XCTAssertEqual(loaded?.defaultAgentProfileHandle, AgentProfileHandle.researcher) + + try await repository.setMessagingThreadDefaultAgentProfile(threadID: thread.id, handle: nil) + let cleared = try await repository.messagingThread(id: thread.id) + XCTAssertNil(cleared?.defaultAgentProfileHandle) + } + private func makeRepository() throws -> DBRepository { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) diff --git a/packages/DerrickBackend/Sources/DerrickBackend/MessagingAgentIngressRouter.swift b/packages/DerrickBackend/Sources/DerrickBackend/MessagingAgentIngressRouter.swift index 2d1c536d..b474d9d1 100644 --- a/packages/DerrickBackend/Sources/DerrickBackend/MessagingAgentIngressRouter.swift +++ b/packages/DerrickBackend/Sources/DerrickBackend/MessagingAgentIngressRouter.swift @@ -64,7 +64,12 @@ public enum MessagingAgentIngressRouter: Sendable { return nil } - guard let resolved = ConnectorMentionParser.resolvePrompt(body: body, botUserID: botUserID) else { + guard let resolved = ConnectorMentionParser.resolvePrompt( + body: body, + botUserID: botUserID, + channelDefaultProfileHandle: row.thread.defaultAgentProfileHandle, + profileCatalog: (try? await profileCatalog(repository: repository)) ?? [] + ) else { return nil } @@ -98,4 +103,10 @@ public enum MessagingAgentIngressRouter: Sendable { let authScheme = (derrick["auth_scheme"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return role == "connector" && authScheme == "bot_token" } + + private static func profileCatalog(repository: DBRepository) async throws -> [AgentProfileCatalogEntry] { + try await repository.listAgentProfiles() + .filter(\.isEnabled) + .map { AgentProfileCatalogEntry(handle: $0.handle, displayName: $0.displayName) } + } } diff --git a/packages/Structure/Sources/AppLayerServices/AppServices/MessagingDTOs.swift b/packages/Structure/Sources/AppLayerServices/AppServices/MessagingDTOs.swift index 6ff7b471..356256af 100644 --- a/packages/Structure/Sources/AppLayerServices/AppServices/MessagingDTOs.swift +++ b/packages/Structure/Sources/AppLayerServices/AppServices/MessagingDTOs.swift @@ -38,6 +38,8 @@ public struct MessagingThreadDTO: Codable, Sendable, Hashable, Identifiable { public var lastActivityAt: Date public var muted: Bool public var unreadCount: Int + /// When set, inbound @Derrick messages without `$handle` use this profile instead of orchestrator. + public var defaultAgentProfileHandle: String? public let createdAt: Date public init( @@ -48,6 +50,7 @@ public struct MessagingThreadDTO: Codable, Sendable, Hashable, Identifiable { lastActivityAt: Date = .now, muted: Bool = false, unreadCount: Int = 0, + defaultAgentProfileHandle: String? = nil, createdAt: Date = .now ) { self.id = id @@ -57,6 +60,7 @@ public struct MessagingThreadDTO: Codable, Sendable, Hashable, Identifiable { self.lastActivityAt = lastActivityAt self.muted = muted self.unreadCount = unreadCount + self.defaultAgentProfileHandle = defaultAgentProfileHandle self.createdAt = createdAt } } diff --git a/packages/Structure/Sources/Plugin/Messaging/ConnectorMentionRouting.swift b/packages/Structure/Sources/Plugin/Messaging/ConnectorMentionRouting.swift index c1999ba5..1a2bf441 100644 --- a/packages/Structure/Sources/Plugin/Messaging/ConnectorMentionRouting.swift +++ b/packages/Structure/Sources/Plugin/Messaging/ConnectorMentionRouting.swift @@ -28,6 +28,35 @@ public struct MessagingAgentRoute: Sendable, Hashable { } } +/// Lightweight profile row for inbound help text and routing. +public struct AgentProfileCatalogEntry: Sendable, Hashable { + public let handle: String + public let displayName: String + + public init(handle: String, displayName: String) { + self.handle = handle + self.displayName = displayName + } +} + +public enum AgentProfileHelpFormatter: Sendable { + public static func mentionOnlyPrompt(catalog: [AgentProfileCatalogEntry]) -> String { + let lines = catalog.map { entry in + "- $\(entry.handle) (\(entry.displayName))" + } + let profileList = lines.isEmpty + ? "- $orchestrator (Orchestrator)\n- $developer (Developer)\n- $researcher (Researcher)\n- $general (General)" + : lines.joined(separator: "\n") + return """ + The user mentioned Derrick without a specific request. Briefly list the available agent profiles: + \(profileList) + + Explain they can start a message with $handle (for example $orchestrator or $researcher). \ + Mention that this channel can have its own default profile in Derrick. Offer to help. + """ + } +} + /// Parses connector message bodies for bot mentions and `$handle` profile tokens. public enum ConnectorMentionParser: Sendable { public static let botReplyPrefix = "[\(DerrickAppSupport.hostAppProductName)]" @@ -58,21 +87,21 @@ public enum ConnectorMentionParser: Sendable { public static func resolvePrompt( body: String, - botUserID: String + botUserID: String, + channelDefaultProfileHandle: String? = nil, + profileCatalog: [AgentProfileCatalogEntry] = [] ) -> (profileHandle: String, prompt: String)? { guard mentionsSlackUser(body: body, userID: botUserID) else { return nil } let withoutMention = stripSlackUserMention(body: body, userID: botUserID) let parsed = AgentProfileTokenParser.parse(message: withoutMention) - let handle = parsed.handle ?? AgentProfileHandle.orchestrator + let channelDefault = channelDefaultProfileHandle.flatMap { AgentProfileHandle.normalize($0) } + let handle = parsed.handle ?? channelDefault ?? AgentProfileHandle.orchestrator let prompt = parsed.body.trimmingCharacters(in: .whitespacesAndNewlines) guard !prompt.isEmpty else { - return (handle, defaultMentionOnlyPrompt) + return (handle, AgentProfileHelpFormatter.mentionOnlyPrompt(catalog: profileCatalog)) } return (handle, prompt) } - - public static let defaultMentionOnlyPrompt = - "The user mentioned Derrick without a specific request. Briefly explain they can use $profileName in their message (for example $orchestrator) and offer to help." } public enum SlackBotIdentityResolver: Sendable { diff --git a/packages/Structure/Tests/StructureTests/ConnectorMentionRoutingTests.swift b/packages/Structure/Tests/StructureTests/ConnectorMentionRoutingTests.swift index 2320f81b..f409b773 100644 --- a/packages/Structure/Tests/StructureTests/ConnectorMentionRoutingTests.swift +++ b/packages/Structure/Tests/StructureTests/ConnectorMentionRoutingTests.swift @@ -33,6 +33,29 @@ import Testing #expect(defaultProfile?.prompt == "what is blocking release?") } + @Test func resolvePromptUsesChannelDefaultProfile() { + let resolved = ConnectorMentionParser.resolvePrompt( + body: "<@U123> what is blocking release?", + botUserID: "U123", + channelDefaultProfileHandle: "researcher" + ) + #expect(resolved?.profileHandle == AgentProfileHandle.researcher) + #expect(resolved?.prompt == "what is blocking release?") + } + + @Test func mentionOnlyPromptListsProfiles() { + let prompt = ConnectorMentionParser.resolvePrompt( + body: "<@U123>", + botUserID: "U123", + profileCatalog: [ + AgentProfileCatalogEntry(handle: "orchestrator", displayName: "Orchestrator"), + AgentProfileCatalogEntry(handle: "developer", displayName: "Developer"), + ] + )?.prompt + #expect(prompt?.contains("$orchestrator") == true) + #expect(prompt?.contains("$developer") == true) + } + @Test func outboundFormatterPrefixesBotName() { let formatted = MessagingAgentOutboundFormatter.formatReply("Done.") #expect(formatted == "[Derrick] Done.") diff --git a/ui/ui/Messaging/MessagingConversationView.swift b/ui/ui/Messaging/MessagingConversationView.swift index 5957ac5f..554c6554 100644 --- a/ui/ui/Messaging/MessagingConversationView.swift +++ b/ui/ui/Messaging/MessagingConversationView.swift @@ -3,6 +3,7 @@ import SwiftUI struct MessagingConversationView: View { @ObservedObject var store: MessagingStore + @ObservedObject private var agentProfiles = AgentProfileStore.shared @State private var draft = "" @State private var threadDraft = "" @State private var channelID = "" @@ -363,6 +364,28 @@ struct MessagingConversationView: View { .foregroundStyle(.secondary) } Spacer() + Menu { + Picker("Default profile", selection: channelDefaultProfileBinding) { + Text("Orchestrator").tag(AgentProfileHandle.orchestrator) + ForEach(agentProfiles.enabledProfiles.filter { $0.handle != AgentProfileHandle.orchestrator }, id: \.handle) { profile in + Text(profile.displayName).tag(profile.handle) + } + } + } label: { + HStack(spacing: 4) { + Image(systemName: "person.crop.circle") + .font(.system(size: 12, weight: .medium)) + Text(channelDefaultProfileLabel) + .font(.caption) + } + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.white.opacity(0.9), in: Capsule()) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .help("Default agent profile when someone @s Derrick without $handle") Button { Task { await store.toggleMuteSelectedThread() } } label: { @@ -380,6 +403,23 @@ struct MessagingConversationView: View { .padding(.vertical, 12) } + private var channelDefaultProfileLabel: String { + let handle = store.selectedThread?.defaultAgentProfileHandle ?? AgentProfileHandle.orchestrator + return agentProfiles.profile(handle: handle)?.displayName ?? "Orchestrator" + } + + private var channelDefaultProfileBinding: Binding { + Binding( + get: { + store.selectedThread?.defaultAgentProfileHandle ?? AgentProfileHandle.orchestrator + }, + set: { newHandle in + let normalized = newHandle == AgentProfileHandle.orchestrator ? nil : newHandle + Task { await store.setChannelDefaultProfile(handle: normalized) } + } + ) + } + private var threadHeader: some View { HStack(spacing: 10) { VStack(alignment: .leading, spacing: 2) { diff --git a/ui/ui/Messaging/MessagingSessionStore.swift b/ui/ui/Messaging/MessagingSessionStore.swift index 451fc88a..d568aabe 100644 --- a/ui/ui/Messaging/MessagingSessionStore.swift +++ b/ui/ui/Messaging/MessagingSessionStore.swift @@ -140,6 +140,20 @@ final class MessagingSessionStore: ObservableObject { } } + func setDefaultAgentProfileForSelectedThread(handle: String?) async { + guard let repository, let thread = selectedThread else { return } + do { + try await repository.setMessagingThreadDefaultAgentProfile( + threadID: thread.id, + handle: handle + ) + await reloadThreads(autoOpenMostRecent: false) + refreshSelectedTab() + } catch { + lastError = error.localizedDescription + } + } + func setNearBottom(_ nearBottom: Bool) { isNearBottom = nearBottom if nearBottom { diff --git a/ui/ui/Messaging/MessagingStore.swift b/ui/ui/Messaging/MessagingStore.swift index fdc2afee..19977b6e 100644 --- a/ui/ui/Messaging/MessagingStore.swift +++ b/ui/ui/Messaging/MessagingStore.swift @@ -452,6 +452,10 @@ final class MessagingStore: ObservableObject { await session.toggleMuteSelectedThread() } + func setChannelDefaultProfile(handle: String?) async { + await session.setDefaultAgentProfileForSelectedThread(handle: handle) + } + func setNearBottom(_ nearBottom: Bool) { session.setNearBottom(nearBottom) } diff --git a/ui/ui/Views/AgentProfileSettingsView.swift b/ui/ui/Views/AgentProfileSettingsView.swift index 6c6f7a94..63e9211d 100644 --- a/ui/ui/Views/AgentProfileSettingsView.swift +++ b/ui/ui/Views/AgentProfileSettingsView.swift @@ -13,6 +13,7 @@ struct AgentProfileSettingsView: View { @State private var draftHandle = "" @State private var draftInstructions = "" @State private var draftModel: LLMModelChoice = .defaultHelperModel + @State private var draftThinking: ModelThinkingOption = OpenAIModel.gpt56Luna.defaultThinkingOption @State private var draftRAG = AgentProfileRAGConfig.default @State private var draftEnabled = true @State private var editorError: String? @@ -131,6 +132,32 @@ struct AgentProfileSettingsView: View { } } .labelsHidden() + .onChange(of: draftModel) { _, newModel in + if !newModel.thinkingOptions.contains(where: { $0.id == draftThinking.id }) { + draftThinking = newModel.defaultThinkingOption + } + } + } + + if !draftModel.thinkingOptions.isEmpty { + profileField( + title: "Thinking level", + caption: "Reasoning depth for this profile's model." + ) { + Picker("Thinking level", selection: Binding( + get: { draftThinking.id }, + set: { newID in + if let option = draftModel.thinkingOptions.first(where: { $0.id == newID }) { + draftThinking = option + } + } + )) { + ForEach(draftModel.thinkingOptions, id: \.id) { option in + Text(option.displayName).tag(option.id) + } + } + .labelsHidden() + } } profileField(title: "RAG") { @@ -203,6 +230,9 @@ struct AgentProfileSettingsView: View { draftHandle = profile.handle draftInstructions = profile.instructions draftModel = (try? JSONDecoder().decode(LLMModelChoice.self, from: profile.modelJSON)) ?? .defaultHelperModel + draftThinking = profile.thinkingJSON.flatMap { + try? JSONDecoder().decode(ModelThinkingOption.self, from: $0) + } ?? draftModel.defaultThinkingOption draftRAG = profile.rag draftEnabled = profile.isEnabled editorError = nil @@ -233,6 +263,7 @@ struct AgentProfileSettingsView: View { profile.handle = draftHandle.trimmingCharacters(in: .whitespacesAndNewlines) profile.instructions = draftInstructions profile.modelJSON = (try? JSONEncoder().encode(draftModel)) ?? profile.modelJSON + profile.thinkingJSON = try? JSONEncoder().encode(draftThinking) profile.rag = draftRAG profile.isEnabled = draftEnabled do {