Skip to content
Open
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
8 changes: 8 additions & 0 deletions Logue.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@
2912A07E0CA39107DCE54499 /* DocumentPersistenceSplitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E1246768E9657B7146D7062 /* DocumentPersistenceSplitTests.swift */; };
2944C84805092418E45892EA /* MCPCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0EC77C2B3E1C387145AC15 /* MCPCatalog.swift */; };
294DE2CCA2CF984F9088FA89 /* SpaceFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = B612ADDBC4E7FB7917F6D72A /* SpaceFile.swift */; };
2973EEEA0E69B58153B61738 /* SkillInvocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32016A19FB7B3A19A78DF890 /* SkillInvocation.swift */; };
2AADC5238C76ED6A02544A34 /* AgentConversationListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92DB0F899AF6F08FEAF8AA46 /* AgentConversationListView.swift */; };
2AF2DBAEFCA1E332ECBA2A1D /* DeviceLossPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17F31B485FED361E8702AA2D /* DeviceLossPolicyTests.swift */; };
2AFCB1DEDD52C307A5201BC7 /* UpcomingEventCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510929670B711C38C5D0F08F /* UpcomingEventCard.swift */; };
Expand Down Expand Up @@ -444,6 +445,7 @@
8E93C898597CE89098CAA75E /* MessageActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55FA53C32F4D2968C07CEE5 /* MessageActions.swift */; };
8F052589F2A1FE3183EC5F56 /* TranscriptSentenceMerge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B96B5DBA01C6D2AC2AFD19 /* TranscriptSentenceMerge.swift */; };
900D62D26E62CC1BD86F6152 /* AudioTimelineMixer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CCEAA072E5CE2EDA0828E14 /* AudioTimelineMixer.swift */; };
90A49AA4762ECCB5320A9EBF /* SkillInvocationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7BC3FCFA1A0116B83C06470 /* SkillInvocationTests.swift */; };
90CE4DBE3D79C429616DB00A /* AgentConversation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 160A71994367176C72C11277 /* AgentConversation.swift */; };
91504F26FF724A765B016AF2 /* whatsnew-wikilinks.png in Resources */ = {isa = PBXBuildFile; fileRef = F7E1089CE3E964A5BC0545A8 /* whatsnew-wikilinks.png */; };
9179F7B5B38B3E68AB712CC5 /* MCPServersSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407A90F8E61C4E0576B95ADD /* MCPServersSection.swift */; };
Expand Down Expand Up @@ -914,6 +916,7 @@
31ED31FAD045668E64BA99B1 /* LLMTestEvalExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMTestEvalExtensions.swift; sourceTree = "<group>"; };
31F21D2EC731B88B30A3B044 /* TimelineAudioConversionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineAudioConversionTests.swift; sourceTree = "<group>"; };
31F56338AE354AB4607F3597 /* JavaScriptTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JavaScriptTool.swift; sourceTree = "<group>"; };
32016A19FB7B3A19A78DF890 /* SkillInvocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkillInvocation.swift; sourceTree = "<group>"; };
3224E9FBD3E9D86A60DAA79C /* LLMEngineStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMEngineStatus.swift; sourceTree = "<group>"; };
33AE1CBE332B50E515AAA7EE /* RecordingSessionManager+Recovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecordingSessionManager+Recovery.swift"; sourceTree = "<group>"; };
3423F242140F7A2AE832B0FD /* DocumentFilenameTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentFilenameTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1268,6 +1271,7 @@
A6440E04631318411820648E /* MCPWireFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCPWireFormat.swift; sourceTree = "<group>"; };
A6BFFF957D5FFFEB3DD160BF /* MeetingListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingListView.swift; sourceTree = "<group>"; };
A7BB6836738E8815F0F59EBE /* WriteDocumentTools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteDocumentTools.swift; sourceTree = "<group>"; };
A7BC3FCFA1A0116B83C06470 /* SkillInvocationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkillInvocationTests.swift; sourceTree = "<group>"; };
A81080B89B0F0AC01017D6D7 /* ModelManager+HuggingFace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ModelManager+HuggingFace.swift"; sourceTree = "<group>"; };
A81121A106491181334F7EFE /* TranscriptReplacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptReplacement.swift; sourceTree = "<group>"; };
A864D7C5937FF77CB9835242 /* DocumentListContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentListContentView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1712,6 +1716,7 @@
398EDF0A083883200C30D278 /* SidebarSelectionMigrationTests.swift */,
5EC0BFF05793C2090F8A90C1 /* SidebarWidthLimitTests.swift */,
4C9B5A0EF0F9DF7AB61FB25C /* SkillExecutionTests.swift */,
A7BC3FCFA1A0116B83C06470 /* SkillInvocationTests.swift */,
3B10ECDC2A200BF826E8AF38 /* SkillModelTests.swift */,
DA05DBB227EC6FF0BA790263 /* SkillStoreTests.swift */,
20A9DCED765C659BAD6BA11C /* SortformerTimelineTests.swift */,
Expand Down Expand Up @@ -2456,6 +2461,7 @@
23BA15F0B350AB9FBBC3AC7E /* AgentSkill.swift */,
FAFDB80BDB51BE96C075692A /* SkillCatalog.swift */,
5EA9FF274B62CED370A907AD /* SkillFile.swift */,
32016A19FB7B3A19A78DF890 /* SkillInvocation.swift */,
F5FA7A75D8EF4325E4699083 /* SkillLayering.swift */,
965FEFAD362D49C3EE00089C /* SkillName.swift */,
B33D38585FFCB1764C45F7DF /* SkillStore.swift */,
Expand Down Expand Up @@ -2997,6 +3003,7 @@
D5DE12695588CC6B29787019 /* SidebarSelectionMigrationTests.swift in Sources */,
F5591335FF5786E244D72E2E /* SidebarWidthLimitTests.swift in Sources */,
8B9B5EE514FCA092639CC439 /* SkillExecutionTests.swift in Sources */,
90A49AA4762ECCB5320A9EBF /* SkillInvocationTests.swift in Sources */,
595BE98FA71A505D0DB5DCB8 /* SkillModelTests.swift in Sources */,
2752CE3A9D504B735C0F4333 /* SkillStoreTests.swift in Sources */,
480E2F2ECBABB70296373573 /* SortformerTimelineTests.swift in Sources */,
Expand Down Expand Up @@ -3490,6 +3497,7 @@
2E44F9F45806D57C7D99BC21 /* SkeletonView.swift in Sources */,
A449E25873E30D531486C971 /* SkillCatalog.swift in Sources */,
7C21B5457EDE7D56A9E81F41 /* SkillFile.swift in Sources */,
2973EEEA0E69B58153B61738 /* SkillInvocation.swift in Sources */,
A55E8790B32F7788B503FFFC /* SkillLayering.swift in Sources */,
AF02C63201E3C3ED826BF85B /* SkillName.swift in Sources */,
23A3C37522CE6FD3D4B231AC /* SkillStore.swift in Sources */,
Expand Down
12 changes: 9 additions & 3 deletions Logue/Agent/AgentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -250,20 +250,26 @@ final class AgentCoordinator {
}
}

func sendWithoutAppendingUser(conversationID: UUID, oneShotWebSearch: Bool = false) {
func sendWithoutAppendingUser(
conversationID: UUID,
oneShotWebSearch: Bool = false,
skill: AgentSkill? = nil
) {
guard !isProcessingAnyConversation else { return }
run.dismissError()
processingTask?.cancel()
if oneShotWebSearch {
setOneShotIncludeWebTools(true)
}
let skillRun = setActiveSkill(skill)
processingTask = Task { [weak self] in
guard let self else { return }
defer {
if oneShotWebSearch {
Task { @MainActor [weak self] in
Task { @MainActor [weak self] in
if oneShotWebSearch {
self?.setOneShotIncludeWebTools(false)
}
self?.clearActiveSkill(generation: skillRun)
}
}
await runGraph(conversationID: conversationID)
Expand Down
131 changes: 131 additions & 0 deletions Logue/Agent/Skills/SkillInvocation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import Foundation

/// Reading a skill invocation out of what the user typed.
///
/// #64 says a skill is invoked **by name**, so typing it has to work — a picker alone means
/// the fastest way to run a saved instruction is to reach for the mouse. `/weekly-review do
/// the last week` runs that skill on "do the last week".
///
/// Pure, and not a method on either composer, for the reason `AskRouter` is: a decision made
/// inside a `View` is one the other surface cannot reach, and this one has to be identical
/// on both. `AskSurface` is deliberately not an input.
enum SkillInvocation {
/// The character that starts an invocation.
static let marker: Character = "/"

/// What the text turned out to be.
enum Outcome: Equatable {
/// No invocation was attempted. The text is the message.
case none(text: String)
/// A skill was named and found. The text is what remains after the name.
case invoked(skill: AgentSkill, text: String)
/// A skill was named and not found.
///
/// Deliberately its own case rather than falling back to `.none`. Someone who typed
/// `/weekly-reveiw` meant to run something; sending it to the model as an ordinary
/// message produces a confident answer to a question they did not ask, and nothing
/// anywhere says the skill did not run. The composer refuses and names what it tried.
case unknown(name: String)
}

/// Reads `text` as a possible invocation.
///
/// - Parameter skills: everything invocable, in the order the store lists it.
static func resolve(_ text: String, against skills: [AgentSkill]) -> Outcome {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.first == marker else { return .none(text: text) }

let afterMarker = trimmed.dropFirst()
// The name ends at the first whitespace; everything after it is the message.
let split = afterMarker.firstIndex(where: { $0.isWhitespace })
let rawName = String(afterMarker[afterMarker.startIndex ..< (split ?? afterMarker.endIndex)])
let remainder = split.map { String(afterMarker[afterMarker.index(after: $0)...]) } ?? ""

// A bare "/" is someone who has started typing, not a failed invocation. Treating it
// as unknown would put an error under the cursor before they had finished the word.
guard !rawName.isEmpty else { return .none(text: text) }

let wanted = SkillName.invocation(from: rawName)
guard let skill = skills.first(where: { $0.invocation == wanted }) else {
return .unknown(name: rawName)
}
return .invoked(skill: skill, text: remainder.trimmingCharacters(in: .whitespacesAndNewlines))
}

/// What a composer should actually do with what was typed.
///
/// The whole decision in one place: read the name, pick between a typed name and an
/// armed chip, and settle whether a skill applies to this route at all. Both composers
/// call this and neither re-derives any of it — the precedence rule written twice is the
/// per-surface drift the rest of #61 exists to stop.
enum Turn: Equatable {
/// Send it. `skill` is nil when none applies.
case send(skill: AgentSkill?, message: String)
/// Do not send. Show this.
case refuse(reason: String)
}

/// - Parameters:
/// - armed: the skill armed by the chip, if any.
/// - route: where this send is going. A skill only applies to the agent loop.
static func turn(
for text: String,
armed: AgentSkill?,
route: AskRoute,
in skills: [AgentSkill]
) -> Turn {
let message: String
let skill: AgentSkill?

switch resolve(text, against: skills) {
case let .unknown(name):
return .refuse(reason: unknownMessage(name: name))
case let .invoked(found, remainder):
// A typed name wins over an armed chip: it is the more specific instruction, and
// the one under the cursor as Return is pressed.
(skill, message) = (found, remainder)
case let .none(plain):
(skill, message) = (armed, plain)
}

// A skill layers onto the agent's system prompt and narrows the agent's tools —
// neither of which a Deep Research run or an image generation has. Dropping it
// silently would be the same failure as answering an unknown name as a plain
// message: the user asked for something and got something else, with nothing saying
// so. Refused, and named.
if let skill, route != .agentLoop {
return .refuse(reason: doesNotApplyMessage(skill: skill, route: route))
}
return .send(skill: skill, message: message)
}

/// Why a skill was not run for this send.
static func doesNotApplyMessage(skill: AgentSkill, route: AskRoute) -> String {
let what = switch route {
case .deepResearch: "Deep Research"
case .imagePlayground: "image generation"
case .agentLoop: "this"
}
return "“\(SkillName.title(from: skill.title))” can't be used with \(what). "
+ "Turn one of them off and send again."
}

/// What to say when a name matched nothing.
///
/// Names the thing that was tried. "No such skill" leaves the user checking whether they
/// mistyped the name or never made the skill.
static func unknownMessage(name: String) -> String {
"No skill called “\(DisplayText.clamp(DisplayText.singleLine(name), to: 40))”. "
+ "Type / to see the ones you have."
}

/// Skills whose invocation begins with a partly-typed name, for a completion list.
///
/// Ordered by the store, not by closeness of match: the list must not reshuffle under
/// the user as they type, because they are aiming at a position in it.
static func completions(for partial: String, in skills: [AgentSkill]) -> [AgentSkill] {
let wanted = SkillName.invocation(from: partial)
guard !wanted.isEmpty else { return skills }
return skills.filter { $0.invocation.hasPrefix(wanted) }
}
}
1 change: 1 addition & 0 deletions Logue/UI/UICopy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ enum UICopy {
static let addFiles = "Add photos & files"
static let searchTheWeb = "Search the web"
static let deepResearchMenu = "Deep research"
static let skills = "Skills"
static let toolSettings = "Tool settings…"
static let composerMenuHelp = "Attach, search, deep research…"
static let composerMenuLabel = "More actions"
Expand Down
20 changes: 18 additions & 2 deletions Logue/Views/Agent/AgentChatView+Input.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ extension AgentChatView {
var isDeepResearch: Bool = false
let isProcessing: Bool
let isBusy: Bool
/// The skill armed for the next send. The parent owns it, because the parent is what
/// hands it to the coordinator — and because the island holds its own, so this must
/// not be shared state.
@Binding var armedSkill: AgentSkill?
var onSend: () -> Void
var onCancel: () -> Void

Expand Down Expand Up @@ -148,13 +152,24 @@ extension AgentChatView {
/// True when any per-send mode is on. Drives whether the chip row
/// renders (turning the pill into a 2-row card).
private var hasActiveModes: Bool {
isWebSearchOnce || isDeepResearch
isWebSearchOnce || isDeepResearch || armedSkill != nil
}

/// Horizontal row of chips for the modes currently on. Each chip has
/// an `×` to turn that mode off without opening the + menu.
private var activeModeChips: some View {
HStack(spacing: 6) {
// Same category as a mode: what the send is about to *do*, so it is visible
// before it happens rather than discovered afterwards.
if let armedSkill {
ModeChip(
title: armedSkill.title,
systemImage: "wand.and.stars",
tint: AppThemeConstants.brandPrimary
) {
self.armedSkill = nil
}
}
if isWebSearchOnce {
ModeChip(
title: UICopy.Input.webSearch,
Expand Down Expand Up @@ -207,7 +222,8 @@ extension AgentChatView {
ComposerPlusMenu(
surface: .mainWindow,
isDisabled: isProcessing || isBusy,
onAttach: { openFilePicker() }
onAttach: { openFilePicker() },
onPickSkill: { armedSkill = $0 }
)
}

Expand Down
Loading
Loading