Skip to content

Commit 622cf50

Browse files
committed
feat: add Namer sub-agent role for titling conversations
Introduce a specialized 'namer' sub-agent dedicated to generating concise conversation titles. This internal agent uses a strict output format to handle naming tasks. Changes include: - Add `namer` case to `SubAgentRole` and `SubAgentActivityRole` enums - Add `conversationTitling` task type and map it to the namer role - Define UI properties for the namer role (amber color, textformat.abc icon) - Update animated role icons to support the new agent type
1 parent 0f89382 commit 622cf50

22 files changed

Lines changed: 1063 additions & 96 deletions

Axon/Models/SubAgentActivityAttributes.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,14 @@ enum SubAgentActivityRole: String, Codable, Hashable {
5353
case scout
5454
case mechanic
5555
case designer
56+
case namer
5657

5758
var displayName: String {
5859
switch self {
5960
case .scout: return "Scout"
6061
case .mechanic: return "Mechanic"
6162
case .designer: return "Designer"
63+
case .namer: return "Namer"
6264
}
6365
}
6466

@@ -67,6 +69,7 @@ enum SubAgentActivityRole: String, Codable, Hashable {
6769
case .scout: return "binoculars"
6870
case .mechanic: return "wrench.and.screwdriver"
6971
case .designer: return "square.and.pencil"
72+
case .namer: return "textformat.abc"
7073
}
7174
}
7275

@@ -75,6 +78,7 @@ enum SubAgentActivityRole: String, Codable, Hashable {
7578
case .scout: return "Exploring"
7679
case .mechanic: return "Fixing"
7780
case .designer: return "Designing"
81+
case .namer: return "Naming"
7882
}
7983
}
8084

@@ -84,6 +88,7 @@ enum SubAgentActivityRole: String, Codable, Hashable {
8488
case .scout: return "mercury" // Standard cyan-ish
8589
case .mechanic: return "lichen" // Green tint
8690
case .designer: return "amethyst" // Purple tint
91+
case .namer: return "amber"
8792
}
8893
}
8994
}
@@ -166,6 +171,7 @@ extension SubAgentActivityRole {
166171
case .scout: self = .scout
167172
case .mechanic: self = .mechanic
168173
case .designer: self = .designer
174+
case .namer: self = .namer
169175
}
170176
}
171177
}

Axon/Models/SubAgentJob/ModelTaskAffinity.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ enum TaskType: String, Codable, CaseIterable, Sendable {
149149
case codeExecution // Mechanic: running code
150150
case fileModification // Mechanic: editing files
151151
case codeGeneration // Mechanic: writing new code
152+
case conversationTitling // Namer: concise conversation title generation
152153
case taskDecomposition // Designer: breaking down tasks
153154
case agentSelection // Designer: choosing which agent
154155
case reasoning // Deep reasoning tasks
@@ -164,6 +165,7 @@ enum TaskType: String, Codable, CaseIterable, Sendable {
164165
case .codeExecution: return "Code Execution"
165166
case .fileModification: return "File Modification"
166167
case .codeGeneration: return "Code Generation"
168+
case .conversationTitling: return "Conversation Titling"
167169
case .taskDecomposition: return "Task Decomposition"
168170
case .agentSelection: return "Agent Selection"
169171
case .reasoning: return "Reasoning"
@@ -180,6 +182,8 @@ enum TaskType: String, Codable, CaseIterable, Sendable {
180182
return [.scout]
181183
case .codeExecution, .fileModification, .codeGeneration:
182184
return [.mechanic]
185+
case .conversationTitling:
186+
return [.namer]
183187
case .taskDecomposition, .agentSelection:
184188
return [.designer]
185189
case .reasoning, .summarization, .translation, .dataExtraction:

Axon/Models/SubAgentJob/SubAgentRole.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ enum SubAgentRole: String, Codable, CaseIterable, Identifiable, Sendable {
1616
case scout // Lightweight reconnaissance, read-only, fast/cheap models
1717
case mechanic // Focused execution, read+write, balanced models
1818
case designer // Meta-reasoning, task decomposition, capable models
19+
case namer // Internal read-only titling agent with strict output format
1920

2021
var id: String { rawValue }
2122

@@ -26,6 +27,7 @@ enum SubAgentRole: String, Codable, CaseIterable, Identifiable, Sendable {
2627
case .scout: return "Scout"
2728
case .mechanic: return "Mechanic"
2829
case .designer: return "Designer"
30+
case .namer: return "Namer"
2931
}
3032
}
3133

@@ -34,6 +36,7 @@ enum SubAgentRole: String, Codable, CaseIterable, Identifiable, Sendable {
3436
case .scout: return "binoculars"
3537
case .mechanic: return "wrench.and.screwdriver"
3638
case .designer: return "square.and.pencil"
39+
case .namer: return "textformat.abc"
3740
}
3841
}
3942

@@ -45,6 +48,8 @@ enum SubAgentRole: String, Codable, CaseIterable, Identifiable, Sendable {
4548
return "Focused execution agent. Carries out specific tasks with precision based on inherited context."
4649
case .designer:
4750
return "Meta-level reasoning agent. Analyzes tasks, decomposes problems, and recommends agent assignments."
51+
case .namer:
52+
return "Internal title agent. Generates concise conversation titles with strict one-line output."
4853
}
4954
}
5055

@@ -82,6 +87,15 @@ enum SubAgentRole: String, Codable, CaseIterable, Identifiable, Sendable {
8287
maxTokenBudget: 16_000,
8388
maxDurationSeconds: 120
8489
)
90+
case .namer:
91+
return SubAgentPermissions(
92+
canRead: true,
93+
canWrite: false,
94+
allowedTools: [],
95+
memoryScope: .none,
96+
maxTokenBudget: 1_000,
97+
maxDurationSeconds: 30
98+
)
8599
}
86100
}
87101

@@ -96,6 +110,8 @@ enum SubAgentRole: String, Codable, CaseIterable, Identifiable, Sendable {
96110
return [.balanced, .capable] // Sonnet, GPT-4o, Gemini Pro
97111
case .designer:
98112
return [.capable, .flagship] // Opus, o3, Gemini-3-Pro
113+
case .namer:
114+
return [.fast, .cheap]
99115
}
100116
}
101117

@@ -171,6 +187,18 @@ enum SubAgentRole: String, Codable, CaseIterable, Identifiable, Sendable {
171187
Add spawn recommendations to your AGENT ASSIGNMENTS section.
172188
Axon will review and authorize each spawn individually.
173189
"""
190+
case .namer:
191+
return """
192+
## Sub-Agent Role: Namer
193+
You generate concise conversation titles.
194+
195+
**Constraints:**
196+
- READ-ONLY
197+
- Return exactly one line
198+
- Required format: TITLE: <3-6 word title>
199+
- No markdown, bullets, numbering, quotes, or extra commentary
200+
- Avoid greetings or generic filler titles
201+
"""
174202
}
175203
}
176204
}

Axon/Services/AgentControl/AgentActionRegistry.swift

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,14 @@ final class AgentActionRegistry: ObservableObject {
225225
let conversation = ConversationService.shared.currentConversation
226226

227227
let fallbackConversationId = selectedConversationId ?? conversation?.id
228-
let fallbackConversationTitle = selectedConversationTitle ?? conversation?.title
228+
let resolvedCurrentConversationTitle: String? = {
229+
guard let conversation else { return nil }
230+
return SettingsStorage.shared.resolvedConversationTitle(
231+
conversationId: conversation.id,
232+
persistedTitle: conversation.title
233+
)
234+
}()
235+
let fallbackConversationTitle = selectedConversationTitle ?? resolvedCurrentConversationTitle
229236
let fallbackView = currentViewRef ?? "chat"
230237

231238
return AgentActionStateSnapshot(
@@ -251,7 +258,14 @@ final class AgentActionRegistry: ObservableObject {
251258
func updateUIState(currentView: String, selectedConversation: Conversation?) {
252259
currentViewRef = currentView
253260
selectedConversationId = selectedConversation?.id
254-
selectedConversationTitle = selectedConversation?.title
261+
if let selectedConversation {
262+
selectedConversationTitle = SettingsStorage.shared.resolvedConversationTitle(
263+
conversationId: selectedConversation.id,
264+
persistedTitle: selectedConversation.title
265+
)
266+
} else {
267+
selectedConversationTitle = nil
268+
}
255269
}
256270

257271
// MARK: UI Request Completion

Axon/Services/AgentOrchestrator/AgentOrchestratorService+ModelSelection.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ extension AgentOrchestratorService {
184184
case .scout: taskType = .webResearch
185185
case .mechanic: taskType = .codeExecution
186186
case .designer: taskType = .taskDecomposition
187+
case .namer: taskType = .conversationTitling
187188
}
188189

189190
// Check affinities

Axon/Services/AgentOrchestrator/AgentOrchestratorService.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,9 @@ final class AgentOrchestratorService: ObservableObject {
577577
if taskLower.contains("summarize") || taskLower.contains("summary") {
578578
return .summarization
579579
}
580+
if taskLower.contains("title:") || taskLower.contains("conversation title") || taskLower.contains("title ") {
581+
return .conversationTitling
582+
}
580583
if taskLower.contains("analyze") || taskLower.contains("read") {
581584
return .documentAnalysis
582585
}
@@ -586,6 +589,7 @@ final class AgentOrchestratorService: ObservableObject {
586589
case .scout: return .webResearch
587590
case .mechanic: return .codeExecution
588591
case .designer: return .taskDecomposition
592+
case .namer: return .conversationTitling
589593
}
590594
}
591595

Axon/Services/Conversation/ConversationService.swift

Lines changed: 21 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ class ConversationService: ObservableObject {
6767

6868
print("[ConversationService] Loaded \(loaded.count) conversations from Core Data")
6969
conversations = loaded
70+
71+
// Startup catch-up for legacy placeholder/first-message titles.
72+
ConversationTitleMaintenanceService.shared.scheduleStartupCatchUp(conversations: loaded)
7073
}
7174

7275
/// Clean up deletion records older than retention period
@@ -435,6 +438,10 @@ class ConversationService: ObservableObject {
435438
messages = []
436439
}
437440

441+
// Remove local title overrides for deleted conversations.
442+
SettingsStorage.shared.setDisplayName(nil, for: id)
443+
SettingsStorage.shared.setGeneratedTitle(nil, for: id)
444+
438445
// For local-only conversations OR when no backend configured, hard delete immediately
439446
if id.hasPrefix("local_") || !apiClient.isBackendConfigured {
440447
try localStore.deleteLocalConversation(id: id)
@@ -754,16 +761,12 @@ class ConversationService: ObservableObject {
754761
// Save both messages to Core Data immediately
755762
try await syncManager.saveMessagesToCoreData([userMessage, assistantMessage], conversationId: conversationId)
756763

757-
// After the assistant completes its response, attempt to generate a concise chat title
758-
// using Apple Intelligence (FoundationModels). This does NOT replace the existing
759-
// initial-title method; it just upgrades the title post-response.
760-
Task { @MainActor in
761-
await self.tryAutoRenameConversationAfterFirstReply(
762-
conversationId: conversationId,
763-
userMessage: userMessage,
764-
assistantMessage: assistantMessage
765-
)
766-
}
764+
// Shared post-reply titling entrypoint (used by both non-streaming and streaming).
765+
schedulePostReplyTitling(
766+
conversationId: conversationId,
767+
userMessage: userMessage,
768+
assistantMessage: assistantMessage
769+
)
767770

768771
// Process and save memories if any were created
769772
if let memories = memories, !memories.isEmpty {
@@ -912,66 +915,18 @@ class ConversationService: ObservableObject {
912915

913916
// MARK: - Auto Title
914917

915-
/// Attempts to auto-rename a conversation after the assistant completes a reply.
916-
///
917-
/// - Important: Will not override user manual renames (displayNameOverride).
918-
/// - Important: Uses only the last (user, assistant) messages to keep latency predictable.
918+
/// Shared post-reply title hook used by both non-streaming and streaming completion paths.
919919
@MainActor
920-
private func tryAutoRenameConversationAfterFirstReply(
920+
func schedulePostReplyTitling(
921921
conversationId: String,
922922
userMessage: Message,
923923
assistantMessage: Message
924-
) async {
925-
// Do not override a manual rename.
926-
if SettingsStorage.shared.displayName(for: conversationId) != nil {
927-
print("[ConversationService] ℹ️ Auto-title skipped: manual rename exists")
928-
return
929-
}
930-
931-
// Only attempt when we have a conversation loaded.
932-
guard let conv = conversations.first(where: { $0.id == conversationId }) else {
933-
print("[ConversationService] ⚠️ Auto-title skipped: conversation not in memory")
934-
return
935-
}
936-
937-
// Heuristic: only auto-rename if the current title looks like the placeholder
938-
// (the app currently uses the first user message prefix).
939-
let placeholderTitle = String(userMessage.content.prefix(50)).trimmingCharacters(in: .whitespacesAndNewlines)
940-
let isPlaceholder = conv.title == "New Chat" || conv.title == placeholderTitle
941-
guard isPlaceholder else {
942-
print("[ConversationService] ℹ️ Auto-title skipped: title already non-placeholder ('\(conv.title)')")
943-
return
944-
}
945-
946-
do {
947-
let generated = try await ConversationTitleOrchestrator.shared.generateTitle(
948-
userMessage: userMessage,
949-
assistantMessage: assistantMessage
950-
)
951-
952-
let trimmed = generated.trimmingCharacters(in: .whitespacesAndNewlines)
953-
guard !trimmed.isEmpty else {
954-
print("[ConversationService] ⚠️ Auto-title generation produced empty title; skipping")
955-
return
956-
}
957-
958-
// 1) Update UI immediately via local display name override.
959-
ConversationTitleOrchestrator.shared.applyTitle(trimmed, to: conversationId)
960-
961-
// 2) Persist/sync as the official conversation title (if possible).
962-
do {
963-
_ = try await updateConversation(id: conversationId, title: trimmed)
964-
} catch {
965-
// In local-only / offline mode this can still succeed (local store), but if it
966-
// fails we keep the displayNameOverride so the UI still shows the new title.
967-
print("[ConversationService] ⚠️ Auto-title could not persist server-side: \(error.localizedDescription)")
968-
}
969-
970-
print("[ConversationService] ✅ Auto-renamed conversation \(conversationId) -> '\(trimmed)'")
971-
} catch {
972-
// Leave the existing title as-is.
973-
print("[ConversationService] ⚠️ Auto-title generation failed: \(error.localizedDescription)")
974-
}
924+
) {
925+
ConversationTitleSubAgentService.shared.schedulePostReplyTitling(
926+
conversationId: conversationId,
927+
userMessage: userMessage,
928+
assistantMessage: assistantMessage
929+
)
975930
}
976931

977932
// MARK: - Message Editing & Deletion

0 commit comments

Comments
 (0)