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
Original file line number Diff line number Diff line change
Expand Up @@ -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("""
Expand Down Expand Up @@ -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)),
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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))
Expand Down Expand Up @@ -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
)
}
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 = 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))
Expand Down Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE messaging_threads DROP COLUMN default_agent_profile_handle;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE messaging_threads ADD COLUMN default_agent_profile_handle TEXT;
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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) }
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
78 changes: 67 additions & 11 deletions packages/Structure/Sources/AgentRuntime/AgentProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -114,30 +119,37 @@ 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,
sortOrder: 0
)
}

public static func developerDefault(modelJSON: Data) -> AgentProfile {
public static func developerDefault(modelJSON: Data, thinkingJSON: Data? = nil) -> AgentProfile {
AgentProfile(
id: "builtin-developer",
displayName: "Developer",
Expand All @@ -150,17 +162,61 @@ 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,
sortOrder: 1
)
}

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),
]
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading