Skip to content

Commit 85d87c2

Browse files
committed
Add adapter support to FoundationModels backend
Add support for loading .fmadapter files into the Foundation Models backend. Introduces adapterURL storage and APIs: loadAdapter(from:), clearAdapter(), and currentAdapterPath(). initialize(...) now accepts adapters (builds a SystemLanguageModel from an adapter file) and can throw on adapter errors; callers updated to handle try/await and report failures. CLI additions: new --adapter arg, CLIError.adapterNotFound, parsing/handling of /adapter commands (load, show, clear), status output showing the current adapter, and help/usage entries. Path handling includes stripping escapes/quotes, tilde expansion, and file existence checks.
1 parent 13a30b1 commit 85d87c2

File tree

2 files changed

+126
-12
lines changed

2 files changed

+126
-12
lines changed

Sources/PerspectiveCLI/Backends/FoundationModelsBackend.swift

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ actor FoundationModelsBackend {
1818
private var streamingEnabled = true
1919
private var temperature: Double = 0.7
2020
private var generationOptions: GenerationOptions { GenerationOptions(temperature: temperature) }
21+
private var adapterURL: URL?
2122

2223
// MARK: - Initialization
2324

@@ -60,29 +61,64 @@ actor FoundationModelsBackend {
6061

6162
/// Initialize or reinitialize the FM session.
6263
/// When `enableTools` is true, tools and tool-usage instructions are included.
63-
func initialize(customPrompt: String? = nil, enableTools: Bool = false) {
64+
func initialize(customPrompt: String? = nil, enableTools: Bool = false) throws {
6465
var instructions = Self.systemInstructions
6566
if enableTools {
6667
instructions += "\n\n" + Self.toolInstructions
6768
}
6869
if let customPrompt, !customPrompt.isEmpty {
6970
instructions = customPrompt + "\n\n" + instructions
7071
}
72+
73+
let model: SystemLanguageModel
74+
if let adapterURL {
75+
let adapter = try SystemLanguageModel.Adapter(fileURL: adapterURL)
76+
model = SystemLanguageModel(adapter: adapter)
77+
} else {
78+
model = SystemLanguageModel.default
79+
}
80+
7181
if enableTools {
7282
let tools = ToolRegistry.shared.allTools()
7383
session = LanguageModelSession(
74-
model: SystemLanguageModel.default,
84+
model: model,
7585
tools: tools,
7686
instructions: instructions
7787
)
7888
} else {
7989
session = LanguageModelSession(
80-
model: SystemLanguageModel.default,
90+
model: model,
8191
instructions: instructions
8292
)
8393
}
8494
}
8595

96+
// MARK: - Adapter Management
97+
98+
/// Load an adapter from a .fmadapter file path.
99+
func loadAdapter(from path: String) throws {
100+
// Strip shell escape backslashes and quotes, then expand ~
101+
let cleaned = path
102+
.replacingOccurrences(of: "\\", with: "")
103+
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
104+
let expanded = NSString(string: cleaned).expandingTildeInPath
105+
let url = URL(fileURLWithPath: expanded)
106+
guard FileManager.default.fileExists(atPath: url.path) else {
107+
throw CLIError.adapterNotFound(expanded)
108+
}
109+
adapterURL = url
110+
}
111+
112+
/// Clear the currently loaded adapter.
113+
func clearAdapter() {
114+
adapterURL = nil
115+
}
116+
117+
/// Returns the path of the currently loaded adapter, if any.
118+
func currentAdapterPath() -> String? {
119+
adapterURL?.path
120+
}
121+
86122
/// Returns the built-in default system prompt.
87123
static func defaultSystemPrompt() -> String {
88124
return systemInstructions

Sources/PerspectiveCLI/PerspectiveCLI.swift

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,16 @@ struct PerspectiveCLI {
4545
enum CLIError: LocalizedError {
4646
case sessionNotInitialized
4747
case backendUnavailable(String)
48+
case adapterNotFound(String)
4849

4950
var errorDescription: String? {
5051
switch self {
5152
case .sessionNotInitialized:
5253
return "Session not initialized. Try /reset to reinitialize."
5354
case .backendUnavailable(let reason):
5455
return "Backend unavailable: \(reason)"
56+
case .adapterNotFound(let path):
57+
return "Adapter file not found: \(path)"
5558
}
5659
}
5760
}
@@ -66,6 +69,7 @@ struct CLIArguments {
6669
var stream: Bool = false
6770
var systemPrompt: String?
6871
var tools: Bool = false
72+
var adapter: String?
6973
var help: Bool = false
7074

7175
init() {
@@ -89,6 +93,8 @@ struct CLIArguments {
8993
systemPrompt = iter.next()
9094
case "--tools":
9195
tools = true
96+
case "--adapter":
97+
adapter = iter.next()
9298
case "--help", "-h":
9399
help = true
94100
default:
@@ -147,6 +153,14 @@ actor CLIApp {
147153
if let model = args.mlxModel {
148154
await mlxBackend.setModelId(model)
149155
}
156+
if let adapterPath = args.adapter {
157+
do {
158+
try await fmBackend.loadAdapter(from: adapterPath)
159+
printSuccess("Adapter loaded: \(adapterPath)")
160+
} catch {
161+
printError("Failed to load adapter: \(error.localizedDescription)")
162+
}
163+
}
150164
}
151165

152166
/// Initialize the active backend, returning true on success.
@@ -159,7 +173,12 @@ actor CLIApp {
159173
return false
160174
}
161175
if !quiet { printSuccess(msg) }
162-
await fmBackend.initialize(customPrompt: customSystemPrompt, enableTools: toolsEnabled)
176+
do {
177+
try await fmBackend.initialize(customPrompt: customSystemPrompt, enableTools: toolsEnabled)
178+
} catch {
179+
printError("Failed to initialize FM: \(error.localizedDescription)")
180+
return false
181+
}
163182
if !quiet { printSuccess("FM session initialized") }
164183
return true
165184
case .mlx:
@@ -237,8 +256,12 @@ actor CLIApp {
237256

238257
// Initialize FM backend if available
239258
if fmAvailable && activeBackend == .fm {
240-
await fmBackend.initialize(customPrompt: customSystemPrompt, enableTools: toolsEnabled)
241-
printSuccess("FM session initialized")
259+
do {
260+
try await fmBackend.initialize(customPrompt: customSystemPrompt, enableTools: toolsEnabled)
261+
printSuccess("FM session initialized")
262+
} catch {
263+
printError("Failed to initialize FM: \(error.localizedDescription)")
264+
}
242265
}
243266

244267
// Initialize MLX backend if selected via args
@@ -306,8 +329,12 @@ actor CLIApp {
306329
switch activeBackend {
307330
case .fm:
308331
await fmBackend.resetSession()
309-
await fmBackend.initialize(customPrompt: customSystemPrompt, enableTools: toolsEnabled)
310-
printSuccess("FM conversation reset")
332+
do {
333+
try await fmBackend.initialize(customPrompt: customSystemPrompt, enableTools: toolsEnabled)
334+
printSuccess("FM conversation reset")
335+
} catch {
336+
printError("Failed to reinitialize FM: \(error.localizedDescription)")
337+
}
311338
case .mlx:
312339
await mlxBackend.resetSession()
313340
do {
@@ -330,8 +357,12 @@ actor CLIApp {
330357
activeBackend = .fm
331358
let (available, _) = FoundationModelsBackend.checkAvailability()
332359
if available {
333-
await fmBackend.initialize(customPrompt: customSystemPrompt, enableTools: toolsEnabled)
334-
printSuccess("Switched to Foundation Models backend")
360+
do {
361+
try await fmBackend.initialize(customPrompt: customSystemPrompt, enableTools: toolsEnabled)
362+
printSuccess("Switched to Foundation Models backend")
363+
} catch {
364+
printError("Failed to initialize FM: \(error.localizedDescription)")
365+
}
335366
} else {
336367
printError("Foundation Models not available on this device")
337368
activeBackend = .mlx
@@ -452,6 +483,39 @@ actor CLIApp {
452483
printWarning("Invalid temperature. Use a value between 0.0 and \(maxTemp)")
453484
}
454485

486+
case "/adapter":
487+
if let path = await fmBackend.currentAdapterPath() {
488+
printInfo("Current adapter: \(path)")
489+
} else {
490+
printInfo("No adapter loaded")
491+
}
492+
493+
case "/adapter clear":
494+
await fmBackend.clearAdapter()
495+
printSuccess("Adapter cleared")
496+
if activeBackend == .fm {
497+
await reinitializeActiveBackend()
498+
}
499+
500+
case _ where cmd.hasPrefix("/adapter "):
501+
let path = String(command.dropFirst("/adapter ".count))
502+
.trimmingCharacters(in: .whitespacesAndNewlines)
503+
if path.isEmpty {
504+
printWarning("Usage: /adapter <path-to-.fmadapter>")
505+
} else {
506+
let resolved = NSString(string: path).expandingTildeInPath
507+
do {
508+
try await fmBackend.loadAdapter(from: resolved)
509+
printSuccess("Adapter loaded: \(resolved)")
510+
if activeBackend == .fm {
511+
printInfo("Reinitializing FM session with adapter...")
512+
await reinitializeActiveBackend()
513+
}
514+
} catch {
515+
printError("Failed to load adapter: \(error.localizedDescription)")
516+
}
517+
}
518+
455519
case "/status":
456520
printInfo("Perspective CLI Status")
457521
printInfo("─────────────────────")
@@ -470,6 +534,12 @@ actor CLIApp {
470534
printInfo(" FM temperature: \(fmTemp)")
471535
printInfo(" FM streaming: \(fmStream ? "on" : "off")")
472536
printInfo(" Tools: \(toolsEnabled ? "enabled" : "disabled")")
537+
// Adapter
538+
if let adapterPath = await fmBackend.currentAdapterPath() {
539+
printInfo(" FM adapter: \(adapterPath)")
540+
} else {
541+
printInfo(" FM adapter: none")
542+
}
473543
// MLX settings
474544
let mlxModel = await mlxBackend.getModelId()
475545
let mlxTemp = await mlxBackend.getTemperature()
@@ -497,8 +567,12 @@ actor CLIApp {
497567
switch activeBackend {
498568
case .fm:
499569
await fmBackend.resetSession()
500-
await fmBackend.initialize(customPrompt: customSystemPrompt, enableTools: toolsEnabled)
501-
printSuccess("FM session reinitialized")
570+
do {
571+
try await fmBackend.initialize(customPrompt: customSystemPrompt, enableTools: toolsEnabled)
572+
printSuccess("FM session reinitialized")
573+
} catch {
574+
printError("Failed to reinitialize FM: \(error.localizedDescription)")
575+
}
502576
case .mlx:
503577
await mlxBackend.resetSession()
504578
do {
@@ -582,6 +656,9 @@ func printHelp() {
582656
printInfo(" /system clear - Clear custom system prompt")
583657
printInfo(" /temperature <n> - Set temperature (FM: 0.0-1.0, MLX: 0.0-2.0)")
584658
printInfo(" /stream - Toggle streaming (FM only)")
659+
printInfo(" /adapter <path> - Load a .fmadapter file (FM only)")
660+
printInfo(" /adapter - Show current adapter")
661+
printInfo(" /adapter clear - Remove loaded adapter")
585662
printInfo(" /tools - Show tool status and list")
586663
printInfo(" /tools enable - Enable tool calling (FM only)")
587664
printInfo(" /tools disable - Disable tool calling")
@@ -619,6 +696,7 @@ func printUsage() {
619696
print(" -s, --stream Enable streaming output (FM)")
620697
print(" --system <text> Set a custom system prompt")
621698
print(" --tools Enable tool calling (FM)")
699+
print(" --adapter <path> Load a .fmadapter file (FM)")
622700
print(" -h, --help Show this help")
623701
print("")
624702
print("Examples:")

0 commit comments

Comments
 (0)