diff --git a/.cursor/rules/agent-id-after-profiles.mdc b/.cursor/rules/agent-id-after-profiles.mdc new file mode 100644 index 00000000..47fd30ca --- /dev/null +++ b/.cursor/rules/agent-id-after-profiles.mdc @@ -0,0 +1,13 @@ +--- +description: After profiles, discuss what an agent is and what agentID means +alwaysApply: true +--- + +# Agent versus profile + +Profiles work comes first. After that page and its capability rules are in place, stop and discuss this before changing orchestration code. + +- What an agent is in the profile setup. +- What `agentID` refers to: a profile, a turn, a spawned worker, or something else. + +Do not rename or reinterpret `agentID` until that discussion happens. diff --git a/packages/DBRepository/Sources/DBRepository/DBRepositoryAgentProfiles.swift b/packages/DBRepository/Sources/DBRepository/DBRepositoryAgentProfiles.swift index 44b0f071..c2adb1a0 100644 --- a/packages/DBRepository/Sources/DBRepository/DBRepositoryAgentProfiles.swift +++ b/packages/DBRepository/Sources/DBRepository/DBRepositoryAgentProfiles.swift @@ -8,11 +8,13 @@ public extension DBRepository { let ragJSON = String(data: ragData, encoding: .utf8) ?? "{}" let modelJSON = String(data: profile.modelJSON, encoding: .utf8) ?? "{}" let thinkingJSON = profile.thinkingJSON.flatMap { String(data: $0, encoding: .utf8) } + let capabilitiesData = (try? JSONEncoder().encode(profile.capabilities)) ?? Data("{}".utf8) + let capabilitiesJSON = String(data: capabilitiesData, encoding: .utf8) ?? "{}" try withDatabaseHandle { handle in try Self.execute(""" INSERT INTO agent_profiles ( id, display_name, handle, instructions, model_json, thinking_json, rag_json, - is_enabled, is_builtin, sort_order, created_at, updated_at + is_enabled, is_builtin, sort_order, created_at, updated_at, capabilities_json, alias ) VALUES ( \(quoted(profile.id)), \(quoted(profile.displayName)), @@ -25,7 +27,9 @@ public extension DBRepository { \(profile.isBuiltin ? 1 : 0), \(profile.sortOrder), \(quoted(Self.iso8601Formatter().string(from: profile.createdAt))), - \(quoted(Self.iso8601Formatter().string(from: profile.updatedAt))) + \(quoted(Self.iso8601Formatter().string(from: profile.updatedAt))), + \(quoted(capabilitiesJSON)), + \(sqlValue(profile.alias)) ) ON CONFLICT(id) DO UPDATE SET display_name = excluded.display_name, @@ -37,7 +41,9 @@ public extension DBRepository { is_enabled = excluded.is_enabled, is_builtin = excluded.is_builtin, sort_order = excluded.sort_order, - updated_at = excluded.updated_at; + updated_at = excluded.updated_at, + capabilities_json = excluded.capabilities_json, + alias = excluded.alias; """, on: handle) } } @@ -46,7 +52,7 @@ public extension DBRepository { try withDatabaseHandle { handle in let sql = """ SELECT id, display_name, handle, instructions, model_json, thinking_json, rag_json, - is_enabled, is_builtin, sort_order, created_at, updated_at + is_enabled, is_builtin, sort_order, created_at, updated_at, capabilities_json, alias FROM agent_profiles ORDER BY sort_order ASC, display_name ASC; """ @@ -67,7 +73,7 @@ public extension DBRepository { try withDatabaseHandle { handle in let sql = """ SELECT id, display_name, handle, instructions, model_json, thinking_json, rag_json, - is_enabled, is_builtin, sort_order, created_at, updated_at + is_enabled, is_builtin, sort_order, created_at, updated_at, capabilities_json, alias FROM agent_profiles WHERE id = \(quoted(id)) LIMIT 1; @@ -84,12 +90,19 @@ public extension DBRepository { func agentProfile(handle: String) throws -> AgentProfile? { let normalized = handle.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return try withDatabaseHandle { dbHandle in + if let match = try agentProfile(matching: "handle = \(quoted(normalized))") { + return match + } + return try agentProfile(matching: "alias = \(quoted(normalized))") + } + + private func agentProfile(matching predicate: String) throws -> AgentProfile? { + try withDatabaseHandle { dbHandle in let sql = """ SELECT id, display_name, handle, instructions, model_json, thinking_json, rag_json, - is_enabled, is_builtin, sort_order, created_at, updated_at + is_enabled, is_builtin, sort_order, created_at, updated_at, capabilities_json, alias FROM agent_profiles - WHERE handle = \(quoted(normalized)) + WHERE \(predicate) LIMIT 1; """ var statement: OpaquePointer? @@ -122,14 +135,19 @@ public extension DBRepository { AgentProfileRAGConfig.self, from: Data(text(6).utf8) )) ?? .default + let capabilities = optionalText(12).flatMap { + try? JSONDecoder().decode(AgentProfileCapabilities.self, from: Data($0.utf8)) + } ?? AgentProfileCapabilities() return AgentProfile( id: text(0), displayName: text(1), handle: text(2), + alias: optionalText(13).flatMap { AgentProfileHandle.normalize($0) }, instructions: text(3), modelJSON: Data(text(4).utf8), thinkingJSON: optionalText(5).map { Data($0.utf8) }, rag: rag, + capabilities: capabilities, isEnabled: sqlite3_column_int(statement, 7) != 0, isBuiltin: sqlite3_column_int(statement, 8) != 0, sortOrder: Int(sqlite3_column_int(statement, 9)), diff --git a/packages/DBRepository/Sources/DBRepository/DatabaseSchema.swift b/packages/DBRepository/Sources/DBRepository/DatabaseSchema.swift index 61e40085..670966c7 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 = 11 + public static let latestVersion = 13 public static func migrationSQL(version: Int, isUp: Bool) throws -> String { let migrationName = String(format: "%04d_%@", version, migrationFileBaseName(for: version)) @@ -45,6 +45,10 @@ public enum DatabaseSchema { return "plugin_host_ui" case 11: return "messaging_agent_work" + case 12: + return "agent_profile_capabilities" + case 13: + return "agent_profile_alias" default: return "unknown" } diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0012_agent_profile_capabilities.down.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0012_agent_profile_capabilities.down.sql new file mode 100644 index 00000000..1008dffa --- /dev/null +++ b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0012_agent_profile_capabilities.down.sql @@ -0,0 +1 @@ +ALTER TABLE agent_profiles DROP COLUMN capabilities_json; diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0012_agent_profile_capabilities.up.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0012_agent_profile_capabilities.up.sql new file mode 100644 index 00000000..4e513c5c --- /dev/null +++ b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0012_agent_profile_capabilities.up.sql @@ -0,0 +1 @@ +ALTER TABLE agent_profiles ADD COLUMN capabilities_json TEXT NOT NULL DEFAULT '{}'; diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0013_agent_profile_alias.down.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0013_agent_profile_alias.down.sql new file mode 100644 index 00000000..52448126 --- /dev/null +++ b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0013_agent_profile_alias.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_agent_profiles_alias; +ALTER TABLE agent_profiles DROP COLUMN alias; diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0013_agent_profile_alias.up.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0013_agent_profile_alias.up.sql new file mode 100644 index 00000000..874c0dfe --- /dev/null +++ b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0013_agent_profile_alias.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE agent_profiles ADD COLUMN alias TEXT; +CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_profiles_alias ON agent_profiles(alias) WHERE alias IS NOT NULL; diff --git a/packages/DBRepository/Tests/DBRepositoryTests/DBAgentProfileTests.swift b/packages/DBRepository/Tests/DBRepositoryTests/DBAgentProfileTests.swift index 38944d0e..f6dd70b7 100644 --- a/packages/DBRepository/Tests/DBRepositoryTests/DBAgentProfileTests.swift +++ b/packages/DBRepository/Tests/DBRepositoryTests/DBAgentProfileTests.swift @@ -24,6 +24,13 @@ final class DBAgentProfileTests: XCTestCase { let loaded = try await repository.agentProfile(handle: "reviewer") XCTAssertEqual(loaded?.displayName, "Reviewer") + var aliased = profile + aliased.alias = "orc" + try await repository.upsertAgentProfile(aliased) + let byAlias = try await repository.agentProfile(handle: "orc") + XCTAssertEqual(byAlias?.handle, "reviewer") + XCTAssertEqual(byAlias?.alias, "orc") + let builtin = AgentProfile.orchestratorDefault(modelJSON: Data(#"{"openai":"gpt-5.6-luna"}"#.utf8)) try await repository.upsertAgentProfile(builtin) try await repository.deleteAgentProfile(id: builtin.id) diff --git a/packages/MCPServer/Sources/MCPServer/Orchestration/AgentProfileDelegateToolModule.swift b/packages/MCPServer/Sources/MCPServer/Orchestration/AgentProfileDelegateToolModule.swift index 2dbf9e1c..0e0ec962 100644 --- a/packages/MCPServer/Sources/MCPServer/Orchestration/AgentProfileDelegateToolModule.swift +++ b/packages/MCPServer/Sources/MCPServer/Orchestration/AgentProfileDelegateToolModule.swift @@ -15,7 +15,7 @@ public enum AgentProfileDelegateToolModule { "profile_handle": .object([ "type": .string("string"), "description": .string( - "Target profile handle without $: developer, researcher, or general." + "Target profile handle without $: developer, researcher, or generalist." ) ]), "task": .object([ diff --git a/packages/Structure/Sources/AgentRuntime/AgentProfile.swift b/packages/Structure/Sources/AgentRuntime/AgentProfile.swift index b467cace..4240ef86 100644 --- a/packages/Structure/Sources/AgentRuntime/AgentProfile.swift +++ b/packages/Structure/Sources/AgentRuntime/AgentProfile.swift @@ -1,5 +1,79 @@ import Foundation +/// What a profile is allowed to do beyond its instructions and model. +public struct AgentProfileCapabilities: Codable, Sendable, Hashable { + public var allowsSubagent: Bool + public var allowedSubagentHandles: [String] + public var maxSimultaneousSubagents: Int + public var allowsScheduling: Bool + /// When true, every installed plugin is available. Otherwise only `allowedPluginIDs`. + public var allowsAllPlugins: Bool + public var allowedPluginIDs: [String] + + private enum CodingKeys: String, CodingKey { + case allowsSubagent + case allowedSubagentHandles + case maxSimultaneousSubagents + case allowsScheduling + case allowsAllPlugins + case allowedPluginIDs + } + + public init( + allowsSubagent: Bool = false, + allowedSubagentHandles: [String] = [], + maxSimultaneousSubagents: Int = 1, + allowsScheduling: Bool = false, + allowsAllPlugins: Bool = true, + allowedPluginIDs: [String] = [] + ) { + self.allowsSubagent = allowsSubagent + self.allowedSubagentHandles = allowedSubagentHandles + self.maxSimultaneousSubagents = min(max(maxSimultaneousSubagents, 1), 8) + self.allowsScheduling = allowsScheduling + self.allowsAllPlugins = allowsAllPlugins + self.allowedPluginIDs = allowedPluginIDs + } + + public static let specialist = AgentProfileCapabilities(allowsSubagent: true) + + public static func orchestratorDefault() -> AgentProfileCapabilities { + AgentProfileCapabilities( + allowsSubagent: false, + allowedSubagentHandles: AgentProfileHandle.delegateTargets, + maxSimultaneousSubagents: 3, + allowsScheduling: true, + allowsAllPlugins: true + ) + } + + public func allowsPlugin(_ pluginID: String) -> Bool { + if allowsAllPlugins { return true } + return allowedPluginIDs.contains(pluginID) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + allowsSubagent = try container.decodeIfPresent(Bool.self, forKey: .allowsSubagent) ?? false + allowedSubagentHandles = try container.decodeIfPresent([String].self, forKey: .allowedSubagentHandles) ?? [] + let limit = try container.decodeIfPresent(Int.self, forKey: .maxSimultaneousSubagents) ?? 1 + maxSimultaneousSubagents = min(max(limit, 1), 8) + allowsScheduling = try container.decodeIfPresent(Bool.self, forKey: .allowsScheduling) ?? false + allowsAllPlugins = try container.decodeIfPresent(Bool.self, forKey: .allowsAllPlugins) ?? true + allowedPluginIDs = try container.decodeIfPresent([String].self, forKey: .allowedPluginIDs) ?? [] + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(allowsSubagent, forKey: .allowsSubagent) + try container.encode(allowedSubagentHandles, forKey: .allowedSubagentHandles) + try container.encode(maxSimultaneousSubagents, forKey: .maxSimultaneousSubagents) + try container.encode(allowsScheduling, forKey: .allowsScheduling) + try container.encode(allowsAllPlugins, forKey: .allowsAllPlugins) + try container.encode(allowedPluginIDs, forKey: .allowedPluginIDs) + } +} + public struct AgentProfileRAGConfig: Codable, Sendable, Hashable { public var useDefaultInstructions: Bool public var customInstructions: String? @@ -25,12 +99,12 @@ 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 generalist = "generalist" - public static let allBuiltins = [orchestrator, developer, researcher, general] + public static let allBuiltins = [orchestrator, developer, researcher, generalist] /// Profiles the orchestrator may delegate to via `agent_profile_delegate`. - public static let delegateTargets = [developer, researcher, general] + public static let delegateTargets = [developer, researcher, generalist] public static func normalize(_ raw: String) -> String? { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() @@ -235,10 +309,13 @@ public struct AgentProfile: Codable, Sendable, Hashable, Identifiable { public let id: String public var displayName: String public var handle: String + /// Extra address. `$orc` reaches the same profile as `$orchestrator` when this is `orc`. + public var alias: String? public var instructions: String public var modelJSON: Data public var thinkingJSON: Data? public var rag: AgentProfileRAGConfig + public var capabilities: AgentProfileCapabilities public var isEnabled: Bool public var isBuiltin: Bool public var sortOrder: Int @@ -249,10 +326,12 @@ public struct AgentProfile: Codable, Sendable, Hashable, Identifiable { id: String = UUID().uuidString, displayName: String, handle: String, + alias: String? = nil, instructions: String, modelJSON: Data, thinkingJSON: Data? = nil, rag: AgentProfileRAGConfig = .default, + capabilities: AgentProfileCapabilities = AgentProfileCapabilities(), isEnabled: Bool = true, isBuiltin: Bool = false, sortOrder: Int = 0, @@ -262,10 +341,12 @@ public struct AgentProfile: Codable, Sendable, Hashable, Identifiable { self.id = id self.displayName = displayName self.handle = handle + self.alias = alias self.instructions = instructions self.modelJSON = modelJSON self.thinkingJSON = thinkingJSON self.rag = rag + self.capabilities = capabilities self.isEnabled = isEnabled self.isBuiltin = isBuiltin self.sortOrder = sortOrder @@ -299,9 +380,9 @@ public struct AgentProfile: Codable, Sendable, Hashable, Identifiable { 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 + - `generalist` — everyday workhorse tasks when no specialist fits - Use `general` when unsure which specialist fits. Summarize delegated outcomes in plain \ + Use `generalist` when unsure which specialist fits. Summarize delegated outcomes in plain \ language and report blockers early. Stay concise unless the user asks for detail. @@ -309,6 +390,7 @@ public struct AgentProfile: Codable, Sendable, Hashable, Identifiable { modelJSON: modelJSON, thinkingJSON: thinkingJSON, rag: .default, + capabilities: .orchestratorDefault(), isEnabled: true, isBuiltin: true, sortOrder: 0 @@ -330,6 +412,7 @@ public struct AgentProfile: Codable, Sendable, Hashable, Identifiable { modelJSON: modelJSON, thinkingJSON: thinkingJSON, rag: .default, + capabilities: .specialist, isEnabled: true, isBuiltin: true, sortOrder: 1 @@ -350,19 +433,20 @@ public struct AgentProfile: Codable, Sendable, Hashable, Identifiable { modelJSON: modelJSON, thinkingJSON: thinkingJSON, rag: .default, + capabilities: .specialist, isEnabled: true, isBuiltin: true, sortOrder: 2 ) } - public static func generalDefault(modelJSON: Data, thinkingJSON: Data? = nil) -> AgentProfile { + public static func generalistDefault(modelJSON: Data, thinkingJSON: Data? = nil) -> AgentProfile { AgentProfile( id: "builtin-general", - displayName: "General", - handle: AgentProfileHandle.general, + displayName: "Generalist", + handle: AgentProfileHandle.generalist, instructions: """ - You are Derrick's General profile — the workhorse for everyday tasks: writing, planning, \ + You are Derrick's Generalist profile — the workhorse for everyday tasks: writing, planning, \ brainstorming, mixed requests, and anything that does not need a specialist. Be practical, \ direct, and helpful. @@ -371,6 +455,7 @@ public struct AgentProfile: Codable, Sendable, Hashable, Identifiable { modelJSON: modelJSON, thinkingJSON: thinkingJSON, rag: .default, + capabilities: .specialist, isEnabled: true, isBuiltin: true, sortOrder: 3 @@ -382,7 +467,7 @@ public struct AgentProfile: Codable, Sendable, Hashable, Identifiable { orchestratorDefault(modelJSON: modelJSON, thinkingJSON: thinkingJSON), developerDefault(modelJSON: modelJSON, thinkingJSON: thinkingJSON), researcherDefault(modelJSON: modelJSON, thinkingJSON: thinkingJSON), - generalDefault(modelJSON: modelJSON, thinkingJSON: thinkingJSON), + generalistDefault(modelJSON: modelJSON, thinkingJSON: thinkingJSON), ] } } @@ -394,6 +479,7 @@ public struct AgentProfileTurnContext: Codable, Sendable, Hashable { public let modelJSON: Data public let thinkingJSON: Data? public let rag: AgentProfileRAGConfig + public let capabilities: AgentProfileCapabilities public init(profile: AgentProfile) { handle = profile.handle @@ -402,5 +488,6 @@ public struct AgentProfileTurnContext: Codable, Sendable, Hashable { modelJSON = profile.modelJSON thinkingJSON = profile.thinkingJSON rag = profile.rag + capabilities = profile.capabilities } } diff --git a/packages/Structure/Sources/MCPToolCatalog/AllowedMCPTool.swift b/packages/Structure/Sources/MCPToolCatalog/AllowedMCPTool.swift index f4e00270..540549d4 100644 --- a/packages/Structure/Sources/MCPToolCatalog/AllowedMCPTool.swift +++ b/packages/Structure/Sources/MCPToolCatalog/AllowedMCPTool.swift @@ -12,7 +12,7 @@ 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). + /// Orchestrator delegates a sub-task to another agent profile ($developer, $researcher, $generalist). case agentProfileDelegate = "agent_profile_delegate" /// One-shot durable job (optional delay). Local orchestration → JobService. case jobsCreate = "jobs_create" @@ -61,7 +61,7 @@ public enum AllowedMCPTool: String, CaseIterable, Sendable, Codable, Hashable { 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." + return "Delegate a task to another agent profile (developer, researcher, or generalist) 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/Sources/Plugin/Messaging/ConnectorMentionRouting.swift b/packages/Structure/Sources/Plugin/Messaging/ConnectorMentionRouting.swift index f5b52364..ba88cc32 100644 --- a/packages/Structure/Sources/Plugin/Messaging/ConnectorMentionRouting.swift +++ b/packages/Structure/Sources/Plugin/Messaging/ConnectorMentionRouting.swift @@ -72,7 +72,7 @@ public enum AgentProfileHelpFormatter: Sendable { "- $\(entry.handle) (\(entry.displayName))" } let profileList = lines.isEmpty - ? "- $orchestrator (Orchestrator)\n- $developer (Developer)\n- $researcher (Researcher)\n- $general (General)" + ? "- $orchestrator (Orchestrator)\n- $developer (Developer)\n- $researcher (Researcher)\n- $generalist (Generalist)" : lines.joined(separator: "\n") return """ The user mentioned Derrick without a specific request. Briefly list the available agent profiles: diff --git a/packages/Structure/Tests/StructureTests/AgentProfileTests.swift b/packages/Structure/Tests/StructureTests/AgentProfileTests.swift index e660a2f3..f3b62924 100644 --- a/packages/Structure/Tests/StructureTests/AgentProfileTests.swift +++ b/packages/Structure/Tests/StructureTests/AgentProfileTests.swift @@ -88,14 +88,28 @@ import Testing #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)) + #expect(profiles.map(\.handle).contains(AgentProfileHandle.generalist)) + } + + @Test func emptyCapabilitiesJSONUsesDefaults() throws { + let decoded = try JSONDecoder().decode(AgentProfileCapabilities.self, from: Data("{}".utf8)) + #expect(decoded.allowsSubagent == false) + #expect(decoded.allowsAllPlugins) + #expect(decoded.allowsPlugin("slack-connector-1")) + var limited = AgentProfileCapabilities(allowsAllPlugins: false, allowedPluginIDs: ["news"]) + #expect(limited.allowsPlugin("news")) + #expect(!limited.allowsPlugin("slack-connector-1")) + let orchestrator = AgentProfileCapabilities.orchestratorDefault() + #expect(orchestrator.allowsScheduling) + #expect(orchestrator.allowedSubagentHandles.contains(AgentProfileHandle.generalist)) + #expect(AgentProfileCapabilities.specialist.allowsSubagent) } @Test func delegateTargetsExcludeOrchestrator() { #expect(AgentProfileHandle.delegateTargets == [ AgentProfileHandle.developer, AgentProfileHandle.researcher, - AgentProfileHandle.general, + AgentProfileHandle.generalist, ]) } diff --git a/ui/SharedAgentRuntime/AgentProfiles/AgentProfileBuiltinFactory.swift b/ui/SharedAgentRuntime/AgentProfiles/AgentProfileBuiltinFactory.swift index f1c183af..56233ee3 100644 --- a/ui/SharedAgentRuntime/AgentProfiles/AgentProfileBuiltinFactory.swift +++ b/ui/SharedAgentRuntime/AgentProfiles/AgentProfileBuiltinFactory.swift @@ -12,7 +12,7 @@ public enum AgentProfileBuiltinFactory { 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), + AgentProfile.generalistDefault(modelJSON: lunaHigh.modelJSON, thinkingJSON: lunaHigh.thinkingJSON), ] } diff --git a/ui/SharedAgentRuntime/Conversation/ConversationModel.swift b/ui/SharedAgentRuntime/Conversation/ConversationModel.swift index b2e0015b..50792c27 100644 --- a/ui/SharedAgentRuntime/Conversation/ConversationModel.swift +++ b/ui/SharedAgentRuntime/Conversation/ConversationModel.swift @@ -102,17 +102,37 @@ final class ConversationModel { ) let profileDelegateHandler: @Sendable (String, String) async throws -> String = { handle, task in - guard TurnProcessContext.activeProfileHandle == AgentProfileHandle.orchestrator else { + let capabilities = TurnProcessContext.activeProfileCapabilities ?? AgentProfileCapabilities() + let normalized = AgentProfileHandle.normalize( + handle.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "$", with: "") + ) ?? handle.lowercased() + guard !capabilities.allowsSubagent else { throw NSError( domain: "AgentProfileDelegate", code: 403, userInfo: [ NSLocalizedDescriptionKey: - "agent_profile_delegate is only available to the orchestrator profile." + "Subagents cannot call their own subagents." ] ) } - return try await ProfileDelegateRunner.run( + guard capabilities.allowedSubagentHandles.contains(normalized) else { + throw NSError( + domain: "AgentProfileDelegate", + code: 403, + userInfo: [ + NSLocalizedDescriptionKey: + "This profile cannot delegate to \(normalized)." + ] + ) + } + let caller = TurnProcessContext.activeProfileHandle ?? "profile" + try await ProfileSubagentGate.shared.begin( + caller: caller, + limit: capabilities.maxSimultaneousSubagents + ) + do { + let result = try await ProfileDelegateRunner.run( profileHandle: handle, task: task, sessionKey: sessionKey, @@ -124,7 +144,13 @@ final class ConversationModel { mcpToolInstructions: mcpToolInstructions, responseSchema: Self.defaultResponseSchema, interceptor: interceptor - ) + ) + await ProfileSubagentGate.shared.end(caller: caller) + return result + } catch { + await ProfileSubagentGate.shared.end(caller: caller) + throw error + } } let agentsHost = try await makeAgentsOrchestrationHost( @@ -273,7 +299,10 @@ final class ConversationModel { let effectiveThinking: ModelThinkingOption? let userRagBase: String let retrievalLimit: Int - let skillIndexBlock = await Self.skillIndexPromptBlock(repository: repository) + let skillIndexBlock = await Self.skillIndexPromptBlock( + repository: repository, + capabilities: profileContext?.capabilities + ) if let profileContext { effectiveModel = (try? JSONDecoder().decode(LLMModelChoice.self, from: profileContext.modelJSON)) ?? model effectiveThinking = profileContext.thinkingJSON.flatMap { @@ -330,16 +359,17 @@ final class ConversationModel { } let effectiveMcpToolInstructions: String - if profileContext?.handle == AgentProfileHandle.orchestrator { + if let handles = profileContext?.capabilities.allowedSubagentHandles, !handles.isEmpty { effectiveMcpToolInstructions = [ mcpToolInstructions, - Self.profileDelegateToolInstructions, + Self.profileDelegateToolInstructions(allowedHandles: handles), ].joined(separator: "\n\n") } else { effectiveMcpToolInstructions = mcpToolInstructions } try await TurnProcessContext.$activeProfileHandle.withValue(profileContext?.handle) { + try await TurnProcessContext.$activeProfileCapabilities.withValue(profileContext?.capabilities) { try await orchestrator.withWorkerRunner(workerRunner) { try await orchestrator.deliverUserMessage(prompt) { envelope in try await AgentCallContext.$caller.withValue(orchestrator.userFacingRef) { @@ -372,14 +402,32 @@ 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`. - """ + nonisolated private static func requireSchedulingAllowed() throws { + guard let capabilities = TurnProcessContext.activeProfileCapabilities else { return } + guard capabilities.allowsScheduling else { + throw NSError( + domain: "AgentProfileSchedule", + code: 403, + userInfo: [ + NSLocalizedDescriptionKey: + "This profile is immediate-mode only and cannot schedule jobs." + ] + ) + } + } + + private static func profileDelegateToolInstructions(allowedHandles: [String]) -> String { + let names = allowedHandles.joined(separator: ", ") + return """ + 14. Profile delegation (only the profiles listed here): + 1. `agent_profile_delegate` — args `profile_handle` (\(names); 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 `generalist` 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, @@ -515,7 +563,10 @@ final class ConversationModel { } /// Cheap skill routing index for system prompts (progressive disclosure layer 1). - private static func skillIndexPromptBlock(repository: DBRepository) async -> String { + private static func skillIndexPromptBlock( + repository: DBRepository, + capabilities: AgentProfileCapabilities? + ) async -> String { do { let summaries = try await repository.listPluginFactoryReleaseSummaries() var latestByPlugin: [String: PluginFactoryReleaseSummary] = [:] @@ -530,7 +581,12 @@ final class ConversationModel { pluginID: summary.pluginID, version: summary.version ) else { continue } - entries.append(contentsOf: PluginSkillDisclosure.index(from: release)) + let indexed = PluginSkillDisclosure.index(from: release) + if let capabilities { + entries.append(contentsOf: indexed.filter { capabilities.allowsPlugin($0.pluginID) }) + } else { + entries.append(contentsOf: indexed) + } } return PluginSkillDisclosure.indexPromptBlock(entries: entries) } catch { @@ -737,6 +793,7 @@ final class ConversationModel { description: String? ) async throws -> String { debugLog("[jobs_create] begin tool=\(toolName) run_after=\(runAfterSeconds.map(String.init) ?? "nil")") + try requireSchedulingAllowed() let runAt = runAtString.flatMap { JobOrderBuilder.parseRunAtString($0) } let input = JobCreateOrderInput( runAfterSeconds: runAfterSeconds, @@ -788,6 +845,7 @@ final class ConversationModel { wakePrompt: String? ) async throws -> String { debugLog("[jobs_schedule_create] begin name=\(name) recurrence=\(recurrence)") + try requireSchedulingAllowed() let kind: JobRecurrenceKind switch recurrence.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { case "interval": kind = .interval diff --git a/ui/SharedAgentRuntime/Conversation/ProfileDelegateRunner.swift b/ui/SharedAgentRuntime/Conversation/ProfileDelegateRunner.swift index aef68514..04f47b3e 100644 --- a/ui/SharedAgentRuntime/Conversation/ProfileDelegateRunner.swift +++ b/ui/SharedAgentRuntime/Conversation/ProfileDelegateRunner.swift @@ -8,6 +8,29 @@ import MemorySystem import PolicyRuntime import Structure +/// Caps how many delegated profiles one caller may have in flight. +actor ProfileSubagentGate { + static let shared = ProfileSubagentGate() + private var inFlight: [String: Int] = [:] + + func begin(caller: String, limit: Int) throws { + let current = inFlight[caller, default: 0] + guard current < limit else { + throw ProfileDelegateRunnerError.tooManySubagents(limit) + } + inFlight[caller] = current + 1 + } + + func end(caller: String) { + let next = (inFlight[caller] ?? 1) - 1 + if next <= 0 { + inFlight[caller] = nil + } else { + inFlight[caller] = next + } + } +} + /// Runs a collected sub-turn as a delegated agent profile. enum ProfileDelegateRunner { nonisolated static func run( @@ -26,12 +49,12 @@ enum ProfileDelegateRunner { 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) } + guard profile.capabilities.allowsSubagent else { + throw ProfileDelegateRunnerError.invalidTarget(normalized) + } let profileContext = AgentProfileTurnContext(profile: profile) let model = (try? JSONDecoder().decode(LLMModelChoice.self, from: profileContext.modelJSON)) @@ -66,28 +89,31 @@ enum ProfileDelegateRunner { 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 subagentCapabilities = AgentProfileCapabilities(allowsSubagent: true, allowedSubagentHandles: []) + let completeText = try await TurnProcessContext.$activeProfileCapabilities.withValue(subagentCapabilities) { + 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 text = "" + for try await chunk in stream { + if chunk.status == .complete { + text += chunk.chunk ?? "" + } } + return text } let trimmed = completeText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { @@ -100,14 +126,17 @@ enum ProfileDelegateRunner { enum ProfileDelegateRunnerError: Error, LocalizedError { case invalidTarget(String) case profileUnavailable(String) + case tooManySubagents(Int) case emptyResponse var errorDescription: String? { switch self { case .invalidTarget(let handle): - return "Profile \(handle) cannot be delegated to. Use developer, researcher, or general." + return "Profile \(handle) cannot be delegated to. It is not allowed to act as a subagent." case .profileUnavailable(let handle): return "Profile \(handle) is not available." + case .tooManySubagents(let limit): + return "This profile can run at most \(limit) subagents at once." case .emptyResponse: return "Delegated profile produced no response." } diff --git a/ui/SharedAgentRuntime/TurnProcessContext.swift b/ui/SharedAgentRuntime/TurnProcessContext.swift index 5e5759c7..fa22c105 100644 --- a/ui/SharedAgentRuntime/TurnProcessContext.swift +++ b/ui/SharedAgentRuntime/TurnProcessContext.swift @@ -31,6 +31,9 @@ public enum TurnProcessContext { /// Active agent profile handle for the current user-facing turn (orchestrator-only tools). @TaskLocal public static var activeProfileHandle: String? + /// Capabilities of the profile answering the current user-facing turn. + @TaskLocal public static var activeProfileCapabilities: AgentProfileCapabilities? + /// Active `/create-plugin` or `/edit-plugin` factory turn. @TaskLocal public static var pluginFactoryCreationActive: Bool = false diff --git a/ui/ui/AgentProfiles/AgentProfileStore.swift b/ui/ui/AgentProfiles/AgentProfileStore.swift index 2bddf45e..c3a63599 100644 --- a/ui/ui/AgentProfiles/AgentProfileStore.swift +++ b/ui/ui/AgentProfiles/AgentProfileStore.swift @@ -40,6 +40,7 @@ final class AgentProfileStore: ObservableObject { func profile(handle: String) -> AgentProfile? { let normalized = handle.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return enabledProfiles.first { $0.handle == normalized } + ?? enabledProfiles.first { $0.alias == normalized } } func resolveProfile( @@ -69,6 +70,14 @@ final class AgentProfileStore: ObservableObject { } else { throw AgentProfileStoreError.invalidHandle } + if let rawAlias = next.alias?.trimmingCharacters(in: .whitespacesAndNewlines), !rawAlias.isEmpty { + guard let alias = AgentProfileHandle.normalize(rawAlias) else { + throw AgentProfileStoreError.invalidHandle + } + next.alias = alias + } else { + next.alias = nil + } next.updatedAt = .now try await repository.upsertAgentProfile(next) await reload() @@ -85,7 +94,11 @@ final class AgentProfileStore: ObservableObject { private func ensureBuiltins(repository: any AgentProfileCatalog) async throws { for profile in try AgentProfileBuiltinFactory.all() { - try await repository.upsertAgentProfile(profile) + var next = profile + if let existing = try await repository.agentProfile(id: profile.id) { + next.alias = existing.alias + } + try await repository.upsertAgentProfile(next) } } } diff --git a/ui/ui/Messaging/AppWorkspace.swift b/ui/ui/Messaging/AppWorkspace.swift index 4f856ee3..b5ad7038 100644 --- a/ui/ui/Messaging/AppWorkspace.swift +++ b/ui/ui/Messaging/AppWorkspace.swift @@ -6,13 +6,15 @@ enum AppWorkspace: Equatable { case pluginsCreate /// Installed Agent Plugin package browser. case pluginsList + /// Profile list and editor. + case profiles case debugLogs var isPluginsSection: Bool { switch self { case .pluginsCreate, .pluginsList: return true - case .chats, .debugLogs: + case .chats, .profiles, .debugLogs: return false } } diff --git a/ui/ui/Session/ChatSessionStore.swift b/ui/ui/Session/ChatSessionStore.swift index e9924daf..61b1ca35 100644 --- a/ui/ui/Session/ChatSessionStore.swift +++ b/ui/ui/Session/ChatSessionStore.swift @@ -660,7 +660,6 @@ final class ChatSessionStore: ObservableObject { func sendPrompt( _ prompt: String, apiKey: String, - profileHandle: String, reviewerModelJSON: String? = nil, onError: @escaping (String) -> Void ) { @@ -690,14 +689,19 @@ final class ChatSessionStore: ObservableObject { } guard let resolved = AgentProfileStore.shared.resolveProfile( - explicitHandle: profileHandle, + explicitHandle: nil, message: trimmed ) else { - onError("Choose a profile and enter a message.") + onError("Enter a message. Start with $ and a profile name, like $orchestrator, to choose who answers.") return } let profile = resolved.profile let profilePrompt = resolved.prompt + if let pluginID = Self.slashPluginID(from: trimmed), + !profile.capabilities.allowsPlugin(pluginID) { + onError("\(profile.displayName) is not allowed to use /\(pluginID).") + return + } let model = (try? JSONDecoder().decode(LLMModelChoice.self, from: profile.modelJSON)) ?? .defaultHelperModel let thinking = profile.thinkingJSON.flatMap { @@ -706,7 +710,13 @@ final class ChatSessionStore: ObservableObject { tabs[tabIndex].pendingAttachments = [] tabs[tabIndex].turns.append( - ChatTurn(prompt: trimmed, attachments: attachments, response: "") + ChatTurn( + prompt: trimmed, + attachments: attachments, + response: "", + profileHandle: profile.handle, + profileDisplayName: profile.displayName + ) ) tabs[tabIndex].isStreaming = true updateTitleIfNeeded( @@ -919,4 +929,13 @@ final class ChatSessionStore: ObservableObject { if source.count <= 48 { return source } return String(source.prefix(48)) + "…" } + + private static func slashPluginID(from prompt: String) -> String? { + let trimmed = prompt.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("/") else { return nil } + let token = trimmed.split(whereSeparator: \.isWhitespace).first.map(String.init) ?? trimmed + let pluginID = String(token.dropFirst()) + guard !pluginID.isEmpty, !pluginID.contains("/") else { return nil } + return pluginID + } } diff --git a/ui/ui/Views/AgentProfileSettingsView.swift b/ui/ui/Views/AgentProfileSettingsView.swift index 7c342dbe..8a53d33c 100644 --- a/ui/ui/Views/AgentProfileSettingsView.swift +++ b/ui/ui/Views/AgentProfileSettingsView.swift @@ -11,12 +11,22 @@ struct AgentProfileSettingsView: View { @State private var selectedProfileID: String? @State private var draftDisplayName = "" @State private var draftHandle = "" + @State private var draftAlias = "" @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 draftCapabilities = AgentProfileCapabilities() @State private var editorError: String? + @State private var saveBanner: SaveBanner? + @ObservedObject private var plugins = PluginFactoryListStore.shared + + private struct SaveBanner: Equatable { + let id: UUID + let text: String + let kind: InAppNotificationKind + } private var selectedProfile: AgentProfile? { guard let selectedProfileID else { return nil } @@ -25,68 +35,37 @@ struct AgentProfileSettingsView: View { var body: some View { VStack(alignment: .leading, spacing: SettingsLayout.sectionSpacing) { - Text("Agent profiles") - .font(.system(size: 26, weight: .semibold, design: .rounded)) - - Text("Profiles define how Derrick behaves when you message an agent from connectors. Talk to a profile by putting $ and its short name at the start, like $orchestrator. Mentioning a short name later in a sentence does not switch profiles. Replies are posted as [\(DerrickAppSupport.hostAppProductName):orchestrator].") - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - + header HStack(alignment: .top, spacing: 20) { - VStack(alignment: .leading, spacing: 8) { - Text("Profiles") - .font(.headline) - List(selection: $selectedProfileID) { - ForEach(store.profiles) { profile in - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(profile.displayName) - Text("$" + profile.handle) - .font(.caption) - .foregroundStyle(AgentProfileTokenColor.darkGreen) - } - Spacer(minLength: 0) - if !profile.isEnabled { - Text("Off") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - .tag(profile.id as String?) - } - } - .frame(minHeight: 180, maxHeight: 260) - - HStack { - Button("Add profile") { - addProfile() - } - if let selectedProfile, !selectedProfile.isBuiltin { - Button("Delete", role: .destructive) { - Task { await deleteSelected() } - } - } + profileList + .frame(width: 240) + ScrollView { + if selectedProfile != nil { + editor + } else { + Text("Select a profile to edit.") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) } } - .frame(width: 240) - - if selectedProfile != nil { - editor - } else { - Text("Select a profile to edit.") - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } } - if let editorError { Text(editorError) .font(.caption) .foregroundStyle(.red) } } - .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color(red: 248.0 / 255.0, green: 248.0 / 255.0, blue: 246.0 / 255.0)) + .overlay(alignment: .top) { + if let saveBanner { + InAppNotificationToast(text: saveBanner.text, kind: saveBanner.kind) { + self.saveBanner = nil + } + .padding(.top, 12) + } + } .onAppear { syncSelection() } @@ -98,6 +77,73 @@ struct AgentProfileSettingsView: View { } } + private var header: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Profiles") + .font(.system(size: 26, weight: .semibold, design: .rounded)) + Spacer() + Button { + addProfile() + } label: { + Label("New", systemImage: "plus") + } + } + Text("A profile is who answers. In chat or a connector, start a message with $ and its alias, like $orchestrator. Mentioning an alias later in a sentence does not switch profiles.") + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var profileList: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Profiles") + .font(.headline) + List(selection: $selectedProfileID) { + ForEach(store.profiles) { profile in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(profile.displayName) + Text("$" + (profile.alias ?? profile.handle)) + .font(.caption) + .foregroundStyle(AgentProfileTokenColor.darkGreen) + } + Spacer(minLength: 0) + if !profile.isEnabled { + Text("Off") + .font(.caption2) + .foregroundStyle(.secondary) + } + Button { + selectedProfileID = profile.id + } label: { + Image(systemName: "pencil") + } + .buttonStyle(.borderless) + .help("Edit") + Button { + duplicate(profile) + } label: { + Image(systemName: "plus.square.on.square") + } + .buttonStyle(.borderless) + .help("Duplicate") + } + .tag(profile.id) + } + } + .listStyle(.sidebar) + .frame(maxHeight: .infinity) + + if let selectedProfile, !selectedProfile.isBuiltin { + Button("Delete", role: .destructive) { + Task { await deleteSelected() } + } + } + } + } + @ViewBuilder private var editor: some View { VStack(alignment: .leading, spacing: SettingsLayout.sectionSpacing) { @@ -107,15 +153,17 @@ struct AgentProfileSettingsView: View { } profileField( - title: "Short name", - caption: "Talk to this profile with $ plus this name at the start of a message, like $orchestrator. Letters, numbers, and underscores only." + title: "Alias", + caption: "Optional short address. If this is orc, $orc reaches this profile. $\(selectedProfile?.handle ?? "orchestrator") still works. Letters, numbers, and underscores only." ) { - TextField("reviewer", text: $draftHandle) + TextField("", text: $draftAlias) .textFieldStyle(.roundedBorder) - .disabled(selectedProfile?.isBuiltin == true) } - profileField(title: "Instructions") { + profileField( + title: "agents.md", + caption: "What this profile is for. This text is its instructions." + ) { TextEditor(text: $draftInstructions) .font(.body) .frame(minHeight: 120) @@ -160,7 +208,10 @@ struct AgentProfileSettingsView: View { } } - profileField(title: "RAG") { + profileField( + title: "RAG", + caption: "Default retrieval instructions are the built-in session memory guide: when to use tools, and not to invent live facts from model memory." + ) { Toggle("Use default retrieval instructions", isOn: $draftRAG.useDefaultInstructions) if !draftRAG.useDefaultInstructions { TextEditor(text: Binding( @@ -182,6 +233,89 @@ struct AgentProfileSettingsView: View { ) } + profileField(title: "Subagents") { + Toggle("Can be called as a subagent", isOn: $draftCapabilities.allowsSubagent) + .onChange(of: draftCapabilities.allowsSubagent) { _, isSubagent in + if isSubagent { + draftCapabilities.allowedSubagentHandles = [] + } + } + if draftCapabilities.allowsSubagent { + Text("Subagents cannot call their own subagents.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("Profiles this one may call") + .font(.subheadline) + .foregroundStyle(.secondary) + ForEach(store.profiles.filter { $0.handle != draftHandle && $0.capabilities.allowsSubagent }) { other in + Toggle(other.displayName, isOn: Binding( + get: { draftCapabilities.allowedSubagentHandles.contains(other.handle) }, + set: { isOn in + if isOn { + if !draftCapabilities.allowedSubagentHandles.contains(other.handle) { + draftCapabilities.allowedSubagentHandles.append(other.handle) + } + } else { + draftCapabilities.allowedSubagentHandles.removeAll { $0 == other.handle } + } + } + )) + } + Stepper( + "At once: \(draftCapabilities.maxSimultaneousSubagents)", + value: $draftCapabilities.maxSimultaneousSubagents, + in: 1...8 + ) + } + } + + profileField(title: "Job scheduling") { + Toggle("Can schedule jobs", isOn: $draftCapabilities.allowsScheduling) + } + + profileField(title: "Plugins") { + Toggle("All plugins", isOn: Binding( + get: { draftCapabilities.allowsAllPlugins }, + set: { isOn in + draftCapabilities.allowsAllPlugins = isOn + if isOn { + draftCapabilities.allowedPluginIDs = plugins.pluginIDs + } + } + )) + if plugins.pluginIDs.isEmpty { + Text("No plugins installed.") + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach(plugins.pluginIDs, id: \.self) { pluginID in + Toggle(pluginID, isOn: Binding( + get: { + draftCapabilities.allowsAllPlugins + || draftCapabilities.allowedPluginIDs.contains(pluginID) + }, + set: { isOn in + if draftCapabilities.allowsAllPlugins { + draftCapabilities.allowedPluginIDs = plugins.pluginIDs + draftCapabilities.allowsAllPlugins = false + } + if isOn { + if !draftCapabilities.allowedPluginIDs.contains(pluginID) { + draftCapabilities.allowedPluginIDs.append(pluginID) + } + } else { + draftCapabilities.allowedPluginIDs.removeAll { $0 == pluginID } + } + if Set(draftCapabilities.allowedPluginIDs) == Set(plugins.pluginIDs), + !plugins.pluginIDs.isEmpty { + draftCapabilities.allowsAllPlugins = true + } + } + )) + } + } + Toggle("Enabled", isOn: $draftEnabled) .disabled(selectedProfile?.handle == AgentProfileHandle.orchestrator) @@ -190,6 +324,7 @@ struct AgentProfileSettingsView: View { Button("Save profile") { Task { await saveDraft() } } + .buttonStyle(ModalPrimaryButtonStyle()) .keyboardShortcut(.defaultAction) } } @@ -216,10 +351,9 @@ struct AgentProfileSettingsView: View { } private func syncSelection() { - if selectedProfileID == nil { - selectedProfileID = store.profiles.first?.id - } else if store.profiles.contains(where: { $0.id == selectedProfileID }) == false { - selectedProfileID = store.profiles.first?.id + let ids = store.profiles.map(\.id) + if selectedProfileID == nil || !ids.contains(selectedProfileID ?? "") { + selectedProfileID = ids.first } loadDraftFromSelection() } @@ -228,12 +362,14 @@ struct AgentProfileSettingsView: View { guard let profile = selectedProfile else { return } draftDisplayName = profile.displayName draftHandle = profile.handle + draftAlias = profile.alias ?? "" 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 + draftCapabilities = profile.capabilities draftEnabled = profile.isEnabled editorError = nil } @@ -259,19 +395,112 @@ struct AgentProfileSettingsView: View { private func saveDraft() async { guard var profile = selectedProfile else { return } editorError = nil - profile.displayName = draftDisplayName.trimmingCharacters(in: .whitespacesAndNewlines) - profile.handle = draftHandle.trimmingCharacters(in: .whitespacesAndNewlines) - profile.instructions = draftInstructions + let displayName = draftDisplayName.trimmingCharacters(in: .whitespacesAndNewlines) + let alias = draftAlias.trimmingCharacters(in: .whitespacesAndNewlines) + let instructions = draftInstructions.trimmingCharacters(in: .whitespacesAndNewlines) + if let message = validationError(displayName: displayName, alias: alias, instructions: instructions, profileID: profile.id) { + editorError = message + presentSaveBanner(message, kind: .failure) + return + } + profile.displayName = displayName + profile.handle = draftHandle + profile.alias = alias.isEmpty ? nil : alias + profile.instructions = instructions profile.modelJSON = (try? JSONEncoder().encode(draftModel)) ?? profile.modelJSON profile.thinkingJSON = try? JSONEncoder().encode(draftThinking) profile.rag = draftRAG + var capabilities = draftCapabilities + if capabilities.allowsSubagent { + capabilities.allowedSubagentHandles = [] + } + profile.capabilities = capabilities + draftCapabilities = capabilities profile.isEnabled = draftEnabled do { let saved = try await store.upsert(profile) selectedProfileID = saved.id + presentSaveBanner("Profile saved.", kind: .success) } catch { editorError = error.localizedDescription + presentSaveBanner(error.localizedDescription, kind: .failure) + } + } + + private func validationError( + displayName: String, + alias: String, + instructions: String, + profileID: String + ) -> String? { + if displayName.isEmpty { + return "Display name is required." + } + let displayKey = displayName.lowercased() + if store.profiles.contains(where: { + $0.id != profileID && $0.displayName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == displayKey + }) { + return "Display name is already used." + } + if !alias.isEmpty { + guard let normalized = AgentProfileHandle.normalize(alias) else { + return "Alias must use letters, numbers, and underscores." + } + if store.profiles.contains(where: { + $0.id != profileID && ($0.handle == normalized || $0.alias == normalized) + }) { + return "Alias is already used." + } + } + if instructions.isEmpty { + return "agents.md cannot be empty." + } + return nil + } + + private func presentSaveBanner(_ text: String, kind: InAppNotificationKind) { + let banner = SaveBanner(id: UUID(), text: text, kind: kind) + saveBanner = banner + Task { + try? await Task.sleep(for: .seconds(3)) + if saveBanner?.id == banner.id { + saveBanner = nil + } + } + } + + private func duplicate(_ profile: AgentProfile) { + var copy = profile + copy = AgentProfile( + displayName: "Copy of \(profile.displayName)", + handle: uniqueHandle(basedOn: profile.handle), + instructions: profile.instructions, + modelJSON: profile.modelJSON, + thinkingJSON: profile.thinkingJSON, + rag: profile.rag, + capabilities: profile.capabilities, + isEnabled: profile.isEnabled, + isBuiltin: false + ) + Task { + do { + let saved = try await store.upsert(copy) + selectedProfileID = saved.id + } catch { + editorError = error.localizedDescription + } + } + } + + private func uniqueHandle(basedOn handle: String) -> String { + var candidate = "\(handle)_copy" + var suffix = 2 + let taken = Set(store.profiles.map(\.handle)) + while taken.contains(candidate) { + candidate = "\(handle)_copy_\(suffix)" + suffix += 1 } + return candidate } private func deleteSelected() async { diff --git a/ui/ui/Views/ContentView.swift b/ui/ui/Views/ContentView.swift index ac7628c6..d9dca020 100644 --- a/ui/ui/Views/ContentView.swift +++ b/ui/ui/Views/ContentView.swift @@ -8,7 +8,6 @@ import Plugin import PolicyUserInteraction import Structure private let bottomPromptFontSize = CGFloat(11) -private let bottomPromptIconSize = CGFloat(10) struct ChatTurn: Identifiable, Hashable { let id: UUID @@ -18,6 +17,8 @@ struct ChatTurn: Identifiable, Hashable { var thought: String var status: AgentResponseStatus? var toolName: String? + var profileHandle: String? + var profileDisplayName: String? init( id: UUID = UUID(), @@ -26,7 +27,9 @@ struct ChatTurn: Identifiable, Hashable { response: String = "", thought: String = "", status: AgentResponseStatus? = nil, - toolName: String? = nil + toolName: String? = nil, + profileHandle: String? = nil, + profileDisplayName: String? = nil ) { self.id = id self.prompt = prompt @@ -35,6 +38,8 @@ struct ChatTurn: Identifiable, Hashable { self.thought = thought self.status = status self.toolName = toolName + self.profileHandle = profileHandle + self.profileDisplayName = profileDisplayName } /// Thinking is the current plan snapshot. Progress tool chunks append to @@ -267,9 +272,6 @@ struct ContentView: View { @State private var apiKeyDraft = "" @State private var shouldResumeAfterSavingKey = false @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 @@ -289,7 +291,8 @@ struct ContentView: View { } private var currentHelperAPIKey: String? { - LLMProviderCredentialGate.resolveAPIKey(for: selectedProvider, resolver: secretResolver) + let provider = helperModelSettings?.pluginSafetyReviewerModel.provider ?? selectedProvider + return LLMProviderCredentialGate.resolveAPIKey(for: provider, resolver: secretResolver) } private var currentHelperReviewerModelJSON: String? { @@ -301,7 +304,6 @@ struct ContentView: View { private var canSendPrompt: Bool { sessionReady - && hasAPIKey(for: selectedProvider) && !chatSessions.isSelectedTabStreaming && ( !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -321,7 +323,6 @@ struct ContentView: View { guard !hasAPIKey(for: selectedProvider) else { return } guard let provider = configuredProviders.first else { return } selectedProvider = provider - selectedModel = provider.defaultModel } private func refreshProviderCredentialUI() { @@ -345,22 +346,6 @@ struct ContentView: View { && pendingAttachments.count < ChatFileAttachmentPolicy.maximumFileCount } - private var visibleThinkingOptions: [ModelThinkingOption] { - selectedModel.thinkingOptions - } - - private func syncSelectedThinkingForModel() { - if let settings = modelThinkingSettings { - selectedThinking = settings.thinking(for: selectedModel) - } else { - selectedThinking = selectedModel.defaultThinkingOption - } - } - - private var visibleModels: [LLMModelChoice] { - selectedProvider.models - } - private var activeTurns: [ChatTurn] { chatSessions.selectedTab?.turns ?? [] } @@ -420,6 +405,13 @@ struct ContentView: View { DebugLogsView(repository: repository) case .pluginsList: PluginPackageBrowserView(controller: pluginPackageBrowser) + case .profiles: + if let helperModelSettings { + AgentProfileSettingsView( + helperModelSettings: helperModelSettings, + modelThinkingSettings: modelThinkingSettings ?? LLMModelThinkingSettings(repository: helperModelSettings.settingsRepository) + ) + } case .chats: ChatTabBarView(store: chatSessions, filter: .chats) chatsMainContent @@ -464,7 +456,7 @@ struct ContentView: View { requestPromptFocusIfNeeded() case .pluginsCreate: requestPromptFocusIfNeeded() - case .pluginsList, .debugLogs: + case .pluginsList, .profiles, .debugLogs: break } } @@ -796,20 +788,6 @@ struct ContentView: View { await self.performClientBootstrap() } } - .onChange(of: selectedProvider) { _, newProvider in - if selectedModel.provider != newProvider { - selectedModel = newProvider.defaultModel - } - } - .onChange(of: selectedModel) { _, newModel in - if selectedProvider != newModel.provider { - selectedProvider = newModel.provider - } - syncSelectedThinkingForModel() - } - .onChange(of: selectedThinking) { _, newThinking in - modelThinkingSettings?.setThinking(newThinking, for: selectedModel) - } .background(WindowConfigurator()) } @@ -985,7 +963,6 @@ struct ContentView: View { let thinkingSettings = LLMModelThinkingSettings(repository: repo) await thinkingSettings.loadSettings() modelThinkingSettings = thinkingSettings - syncSelectedThinkingForModel() return repo } @@ -1078,14 +1055,16 @@ struct ContentView: View { if isPluginCreator { PluginCreatorIntroHeader() } - ForEach(turns) { turn in + ForEach(Array(turns.enumerated()), id: \.element.id) { index, turn in + let previousHandle = index > 0 ? turns[index - 1].profileHandle : nil PromptCompletionCard( turn: turn, isStreaming: isStreaming, isActiveStreamingTurn: isStreaming && turn.id == turns.last?.id, completionStatus: completionStatus(for: turn), statusMessage: streamingStatusMessage(for: turn), - toolName: turn.toolName + toolName: turn.toolName, + showsProfileHeader: turn.profileHandle != nil && turn.profileHandle != previousHandle ) { copyTurn(turn) } @@ -1264,104 +1243,6 @@ 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 - Text(provider.displayName) - .tag(provider) - .disabled(!hasAPIKey(for: provider)) - } - } - } label: { - HStack(spacing: 5) { - Image(systemName: "chevron.down") - .font(.system(size: bottomPromptIconSize, weight: .medium)) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) - Text(selectedProvider.displayName) - .font(.system(size: bottomPromptFontSize)) - .foregroundStyle( - hasAPIKey(for: selectedProvider) - ? Color(nsColor: .labelColor) - : Color(nsColor: .tertiaryLabelColor) - ) - } - } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .fixedSize() - .disabled(isActiveTabStreaming || configuredProviders.isEmpty) - - Menu { - Picker("Model", selection: $selectedModel) { - ForEach(visibleModels) { model in - Text(model.displayName).tag(model) - } - } - } label: { - HStack(spacing: 5) { - Image(systemName: "chevron.down") - .font(.system(size: bottomPromptIconSize, weight: .medium)) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) - Text(selectedModel.displayName) - .font(.system(size: bottomPromptFontSize)) - .foregroundStyle( - hasAPIKey(for: selectedProvider) - ? Color(nsColor: .labelColor) - : Color(nsColor: .tertiaryLabelColor) - ) - } - } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .fixedSize() - .disabled(isActiveTabStreaming || !hasAPIKey(for: selectedProvider)) - - Menu { - Picker("Thinking", selection: $selectedThinking) { - ForEach(visibleThinkingOptions) { option in - Text(option.displayName).tag(option) - } - } - } label: { - HStack(spacing: 5) { - Image(systemName: "chevron.down") - .font(.system(size: bottomPromptIconSize, weight: .medium)) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) - Text(selectedThinking.displayName) - .font(.system(size: bottomPromptFontSize)) - .foregroundStyle( - hasAPIKey(for: selectedProvider) - ? Color(nsColor: .labelColor) - : Color(nsColor: .tertiaryLabelColor) - ) - } - } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .fixedSize() - .disabled(isActiveTabStreaming || !hasAPIKey(for: selectedProvider)) - Button { if isActiveTabStreaming { chatSessions.cancelSelectedTabStream() @@ -1444,7 +1325,7 @@ struct ContentView: View { promptFocusToken += 1 case .pluginsCreate: promptFocusToken += 1 - case .pluginsList, .debugLogs: + case .pluginsList, .profiles, .debugLogs: break } } @@ -1525,9 +1406,16 @@ struct ContentView: View { } private func startStreaming() { - guard canSendPrompt, hasAPIKey(for: selectedProvider) else { return } + guard canSendPrompt else { return } let currentPrompt = prompt + let provider = providerForPrompt(currentPrompt) + guard hasAPIKey(for: provider) else { + selectedProvider = provider + isPresentingAPIKeyPrompt = true + return + } + prompt = "" promptFocusToken += 1 @@ -1541,8 +1429,7 @@ struct ContentView: View { chatSessions.sendPrompt( currentPrompt, - apiKey: resolveAPIKey() ?? "", - profileHandle: selectedProfileHandle, + apiKey: LLMProviderCredentialGate.resolveAPIKey(for: provider, resolver: secretResolver) ?? "", reviewerModelJSON: currentHelperReviewerModelJSON ) { message in errorMessage = message @@ -1550,6 +1437,16 @@ struct ContentView: View { } } + /// The profile named by `$handle` in the message owns the model. With no `$handle`, that is `$orchestrator`. + private func providerForPrompt(_ prompt: String) -> LLMProviderChoice { + guard let resolved = agentProfiles.resolveProfile(explicitHandle: nil, message: prompt) else { + return .openai + } + let model = (try? JSONDecoder().decode(LLMModelChoice.self, from: resolved.profile.modelJSON)) + ?? .defaultHelperModel + return model.provider + } + /// Chat menu never hosts Create — leave any plugin-creator selection for a real chat. private func ensureChatMenuSelection() { guard let selected = chatSessions.selectedTab, selected.isPluginCreator else { return } @@ -1714,10 +1611,6 @@ struct ContentView: View { pasteboard.setString(text, forType: .string) } - private func resolveAPIKey() -> String? { - LLMProviderCredentialGate.resolveAPIKey(for: selectedModel.provider, resolver: secretResolver) - } - @ViewBuilder private func apiKeyPrompt() -> some View { VStack(spacing: 20) { diff --git a/ui/ui/Views/PromptView.swift b/ui/ui/Views/PromptView.swift index 9315f6f4..10847787 100644 --- a/ui/ui/Views/PromptView.swift +++ b/ui/ui/Views/PromptView.swift @@ -205,6 +205,7 @@ struct PromptCompletionCard: View { let completionStatus: CompletionStatus let statusMessage: String? let toolName: String? + let showsProfileHeader: Bool let onCopy: () -> Void @State private var isCompletionVisible = false @@ -215,6 +216,7 @@ struct PromptCompletionCard: View { completionStatus: CompletionStatus, statusMessage: String? = nil, toolName: String? = nil, + showsProfileHeader: Bool = false, onCopy: @escaping () -> Void ) { self.turn = turn @@ -223,6 +225,7 @@ struct PromptCompletionCard: View { self.completionStatus = completionStatus self.statusMessage = statusMessage self.toolName = toolName + self.showsProfileHeader = showsProfileHeader self.onCopy = onCopy } @@ -251,6 +254,11 @@ struct PromptCompletionCard: View { if isCompletionVisible || !turn.response.isEmpty || isActiveStreamingTurn { VStack(alignment: .leading, spacing: 8) { + if showsProfileHeader, let name = turn.profileDisplayName, !name.isEmpty { + Text(name) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AgentProfileTokenColor.darkGreen) + } VStack(alignment: .leading, spacing: 10) { if completionStatus == .streaming { HostUICompletionStatus(status: statusMessage ?? "Thinking...", toolName: toolName) diff --git a/ui/ui/Views/SidebarView.swift b/ui/ui/Views/SidebarView.swift index 9212bed4..4c2f500f 100644 --- a/ui/ui/Views/SidebarView.swift +++ b/ui/ui/Views/SidebarView.swift @@ -46,6 +46,17 @@ struct SidebarView: View { pluginsSection + SidebarActionRow( + row: SidebarRow( + id: SidebarPrimaryActions.profiles.id, + icon: SidebarPrimaryActions.profiles.icon, + title: SidebarPrimaryActions.profiles.title, + isProminent: workspace == .profiles + ) + ) { + workspace = .profiles + } + SidebarActionRow( row: SidebarRow( id: "chats", @@ -290,6 +301,7 @@ enum SidebarPrimaryActions { static let plugins = SidebarRow(id: "plugins", icon: "puzzlepiece.extension.fill", title: "Plugins") static let pluginsCreate = SidebarRow(id: "plugins-create", icon: "plus.square", title: "Create") static let pluginsList = SidebarRow(id: "plugins-list", icon: "list.bullet", title: "List") + static let profiles = SidebarRow(id: "profiles", icon: "person.2.fill", title: "Profiles") /// Kept for older tests / settings that still refer to the former single Plugins row. static let newPlugin = plugins } diff --git a/ui/uiTests/AgentProfileBuiltinFactoryTests.swift b/ui/uiTests/AgentProfileBuiltinFactoryTests.swift index ea18496c..8ef5fd6b 100644 --- a/ui/uiTests/AgentProfileBuiltinFactoryTests.swift +++ b/ui/uiTests/AgentProfileBuiltinFactoryTests.swift @@ -12,17 +12,17 @@ import Testing 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 generalist = profiles.first { $0.handle == "generalist" }! 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) + let generalistModel = try JSONDecoder().decode(LLMModelChoice.self, from: generalist.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") + #expect(generalistModel.id == "openai:gpt-5.6-luna") let orchestratorThinking = try JSONDecoder().decode( ModelThinkingOption.self,