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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .cursor/rules/agent-id-after-profiles.mdc
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -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,
Expand All @@ -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)
}
}
Expand All @@ -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;
"""
Expand All @@ -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;
Expand All @@ -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?
Expand Down Expand Up @@ -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)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE agent_profiles DROP COLUMN capabilities_json;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE agent_profiles ADD COLUMN capabilities_json TEXT NOT NULL DEFAULT '{}';
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_agent_profiles_alias;
ALTER TABLE agent_profiles DROP COLUMN alias;
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
107 changes: 97 additions & 10 deletions packages/Structure/Sources/AgentRuntime/AgentProfile.swift
Original file line number Diff line number Diff line change
@@ -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?
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -299,16 +380,17 @@ 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.
""",
modelJSON: modelJSON,
thinkingJSON: thinkingJSON,
rag: .default,
capabilities: .orchestratorDefault(),
isEnabled: true,
isBuiltin: true,
sortOrder: 0
Expand All @@ -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
Expand All @@ -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.

Expand All @@ -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
Expand All @@ -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),
]
}
}
Expand All @@ -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
Expand All @@ -402,5 +488,6 @@ public struct AgentProfileTurnContext: Codable, Sendable, Hashable {
modelJSON = profile.modelJSON
thinkingJSON = profile.thinkingJSON
rag = profile.rag
capabilities = profile.capabilities
}
}
Loading
Loading