@agentick/apple 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,444 @@
1
+ /// Apple Foundation Models Bridge
2
+ ///
3
+ /// A CLI executable that wraps Apple's on-device Foundation Models framework
4
+ /// with a JSON wire protocol compatible with agentick's adapter interface.
5
+ ///
6
+ /// Wire protocol:
7
+ /// Input: JSON on stdin (subset of ModelInput)
8
+ /// Output: JSON on stdout (ModelOutput for non-streaming, NDJSON AdapterDeltas for streaming)
9
+ ///
10
+ /// Build:
11
+ /// swiftc -parse-as-library -framework FoundationModels inference.swift -o apple-fm-bridge
12
+ ///
13
+ /// Usage:
14
+ /// echo '{"messages":[{"role":"user","content":"Hello"}],"stream":false}' | ./apple-fm-bridge
15
+
16
+ import Foundation
17
+ import FoundationModels
18
+
19
+ // ============================================================================
20
+ // Wire Protocol Types (aligned with @agentick/shared)
21
+ // ============================================================================
22
+
23
+ // --- Input ---
24
+
25
+ struct BridgeInput: Decodable {
26
+ let messages: [WireMessage]
27
+ let system: String?
28
+ let temperature: Double?
29
+ let maxTokens: Int?
30
+ let stream: Bool?
31
+ let responseFormat: ResponseFormat?
32
+ }
33
+
34
+ struct ResponseFormat: Decodable {
35
+ let type: String
36
+ let schema: JsonSchema?
37
+ let name: String?
38
+ }
39
+
40
+ struct JsonSchema: Decodable {
41
+ let type: String
42
+ let description: String?
43
+ let properties: [String: SchemaProperty]?
44
+ let items: SchemaProperty? // For array schemas
45
+ }
46
+
47
+ // Use indirect enum to handle recursion
48
+ indirect enum SchemaProperty: Decodable {
49
+ case leaf(type: String, description: String?)
50
+ case object(type: String, description: String?, properties: [String: SchemaProperty])
51
+ case array(type: String, description: String?, items: SchemaProperty)
52
+
53
+ enum CodingKeys: String, CodingKey {
54
+ case type, description, properties, items
55
+ }
56
+
57
+ init(from decoder: Decoder) throws {
58
+ let container = try decoder.container(keyedBy: CodingKeys.self)
59
+ let type = try container.decode(String.self, forKey: .type)
60
+ let description = try container.decodeIfPresent(String.self, forKey: .description)
61
+
62
+ if type == "object" {
63
+ let properties = try container.decodeIfPresent([String: SchemaProperty].self, forKey: .properties) ?? [:]
64
+ self = .object(type: type, description: description, properties: properties)
65
+ } else if type == "array" {
66
+ let items = try container.decode(SchemaProperty.self, forKey: .items)
67
+ self = .array(type: type, description: description, items: items)
68
+ } else {
69
+ self = .leaf(type: type, description: description)
70
+ }
71
+ }
72
+
73
+ var propertyType: String {
74
+ switch self {
75
+ case .leaf(let type, _): return type
76
+ case .object(let type, _, _): return type
77
+ case .array(let type, _, _): return type
78
+ }
79
+ }
80
+
81
+ var propertyDescription: String? {
82
+ switch self {
83
+ case .leaf(_, let desc): return desc
84
+ case .object(_, let desc, _): return desc
85
+ case .array(_, let desc, _): return desc
86
+ }
87
+ }
88
+ }
89
+
90
+ struct WireMessage: Decodable {
91
+ let role: String
92
+ let content: MessageContent
93
+
94
+ enum MessageContent: Decodable {
95
+ case text(String)
96
+ case blocks([ContentBlock])
97
+
98
+ init(from decoder: Decoder) throws {
99
+ let container = try decoder.singleValueContainer()
100
+ if let str = try? container.decode(String.self) {
101
+ self = .text(str)
102
+ } else {
103
+ self = .blocks(try container.decode([ContentBlock].self))
104
+ }
105
+ }
106
+
107
+ var text: String {
108
+ switch self {
109
+ case .text(let str): return str
110
+ case .blocks(let blocks):
111
+ return blocks
112
+ .filter { $0.type == "text" }
113
+ .compactMap { $0.text }
114
+ .joined(separator: "\n")
115
+ }
116
+ }
117
+ }
118
+
119
+ struct ContentBlock: Decodable {
120
+ let type: String
121
+ let text: String?
122
+ }
123
+ }
124
+
125
+ // --- Output (non-streaming, matches ModelOutput) ---
126
+
127
+ struct BridgeOutput: Encodable {
128
+ let model: String
129
+ let createdAt: String
130
+ let message: OutputMessage
131
+ let stopReason: String
132
+ let usage: WireUsage
133
+ }
134
+
135
+ struct OutputMessage: Encodable {
136
+ let role: String
137
+ let content: [OutputContentBlock]
138
+ }
139
+
140
+ struct OutputContentBlock: Encodable {
141
+ let type: String
142
+ let text: String
143
+ }
144
+
145
+ struct WireUsage: Encodable {
146
+ let inputTokens: Int
147
+ let outputTokens: Int
148
+ let totalTokens: Int
149
+ }
150
+
151
+ // --- Output (streaming, matches AdapterDelta) ---
152
+
153
+ struct TextDelta: Encodable {
154
+ let type = "text"
155
+ let delta: String
156
+ }
157
+
158
+ struct MessageEnd: Encodable {
159
+ let type = "message_end"
160
+ let stopReason: String
161
+ let usage: WireUsage
162
+ }
163
+
164
+ struct ErrorOutput: Encodable {
165
+ let type = "error"
166
+ let error: String
167
+ }
168
+
169
+ // ============================================================================
170
+ // Bridge
171
+ // ============================================================================
172
+
173
+ @main
174
+ struct AppleFoundationBridge {
175
+ static let modelId = "apple-foundation-3b"
176
+
177
+ static func main() async {
178
+ let encoder = JSONEncoder()
179
+ encoder.outputFormatting = [.sortedKeys]
180
+
181
+ do {
182
+ try await run(encoder: encoder)
183
+ } catch {
184
+ writeJSON(ErrorOutput(error: String(describing: error)), encoder: encoder)
185
+ }
186
+ }
187
+
188
+ static func run(encoder: JSONEncoder) async throws {
189
+ let inputData = FileHandle.standardInput.readDataToEndOfFile()
190
+ guard !inputData.isEmpty else {
191
+ writeJSON(ErrorOutput(error: "No input on stdin"), encoder: encoder)
192
+ return
193
+ }
194
+
195
+ let input = try JSONDecoder().decode(BridgeInput.self, from: inputData)
196
+
197
+ // Check availability
198
+ let model = SystemLanguageModel.default
199
+ guard model.availability == .available else {
200
+ writeJSON(
201
+ ErrorOutput(error: "Model not available: \(model.availability)"),
202
+ encoder: encoder
203
+ )
204
+ return
205
+ }
206
+
207
+ // Extract system prompt: explicit field takes precedence, then system-role messages
208
+ let systemPrompt = input.system ?? input.messages
209
+ .filter { $0.role == "system" }
210
+ .map { $0.content.text }
211
+ .joined(separator: "\n")
212
+
213
+ // Build conversation prompt from non-system messages
214
+ let prompt = buildPrompt(from: input.messages.filter { $0.role != "system" })
215
+
216
+ guard !prompt.isEmpty else {
217
+ writeJSON(ErrorOutput(error: "No user messages provided"), encoder: encoder)
218
+ return
219
+ }
220
+
221
+ let session = systemPrompt.isEmpty
222
+ ? LanguageModelSession()
223
+ : LanguageModelSession(instructions: systemPrompt)
224
+
225
+ // Check if structured output is requested
226
+ if let responseFormat = input.responseFormat, responseFormat.type == "json_schema" {
227
+ if input.stream == true {
228
+ writeJSON(ErrorOutput(error: "Streaming not supported with json_schema response format"), encoder: encoder)
229
+ return
230
+ }
231
+
232
+ guard let schema = responseFormat.schema else {
233
+ writeJSON(ErrorOutput(error: "json_schema requires schema field"), encoder: encoder)
234
+ return
235
+ }
236
+
237
+ try await generateStructuredResponse(
238
+ session: session,
239
+ prompt: prompt,
240
+ schema: schema,
241
+ encoder: encoder
242
+ )
243
+ } else if input.stream == true {
244
+ try await streamResponse(session: session, prompt: prompt, encoder: encoder)
245
+ } else {
246
+ try await generateResponse(session: session, prompt: prompt, encoder: encoder)
247
+ }
248
+ }
249
+
250
+ // MARK: - Non-streaming
251
+
252
+ static func generateResponse(
253
+ session: LanguageModelSession,
254
+ prompt: String,
255
+ encoder: JSONEncoder
256
+ ) async throws {
257
+ let response = try await session.respond(to: prompt)
258
+ let now = ISO8601DateFormatter().string(from: Date())
259
+
260
+ let output = BridgeOutput(
261
+ model: modelId,
262
+ createdAt: now,
263
+ message: OutputMessage(
264
+ role: "assistant",
265
+ content: [OutputContentBlock(type: "text", text: response.content)]
266
+ ),
267
+ stopReason: "stop",
268
+ usage: WireUsage(inputTokens: 0, outputTokens: 0, totalTokens: 0)
269
+ )
270
+
271
+ writeJSON(output, encoder: encoder)
272
+ }
273
+
274
+ // MARK: - Streaming
275
+
276
+ static func streamResponse(
277
+ session: LanguageModelSession,
278
+ prompt: String,
279
+ encoder: JSONEncoder
280
+ ) async throws {
281
+ let stream = session.streamResponse(to: prompt)
282
+ var emitted = 0
283
+
284
+ for try await partial in stream {
285
+ let content = partial.content
286
+ if content.count > emitted {
287
+ let startIdx = content.index(content.startIndex, offsetBy: emitted)
288
+ let newText = String(content[startIdx...])
289
+ writeLine(TextDelta(delta: newText), encoder: encoder)
290
+ emitted = content.count
291
+ }
292
+ }
293
+
294
+ writeLine(
295
+ MessageEnd(
296
+ stopReason: "stop",
297
+ usage: WireUsage(inputTokens: 0, outputTokens: 0, totalTokens: 0)
298
+ ),
299
+ encoder: encoder
300
+ )
301
+ }
302
+
303
+ // MARK: - Structured Output
304
+
305
+ static func generateStructuredResponse(
306
+ session: LanguageModelSession,
307
+ prompt: String,
308
+ schema: JsonSchema,
309
+ encoder: JSONEncoder
310
+ ) async throws {
311
+ // Convert JSON Schema to DynamicGenerationSchema
312
+ let dynamicSchema = try buildDynamicSchema(from: schema)
313
+ let generationSchema = try GenerationSchema(root: dynamicSchema, dependencies: [])
314
+
315
+ // Generate with schema
316
+ let response = try await session.respond(to: prompt, schema: generationSchema)
317
+ let now = ISO8601DateFormatter().string(from: Date())
318
+
319
+ // Get JSON string directly from GeneratedContent
320
+ let jsonContent = response.content.jsonString
321
+
322
+ let output = BridgeOutput(
323
+ model: modelId,
324
+ createdAt: now,
325
+ message: OutputMessage(
326
+ role: "assistant",
327
+ content: [OutputContentBlock(type: "text", text: jsonContent)]
328
+ ),
329
+ stopReason: "stop",
330
+ usage: WireUsage(inputTokens: 0, outputTokens: 0, totalTokens: 0)
331
+ )
332
+
333
+ writeJSON(output, encoder: encoder)
334
+ }
335
+
336
+ static func buildDynamicSchema(from jsonSchema: JsonSchema) throws -> DynamicGenerationSchema {
337
+ guard jsonSchema.type == "object" else {
338
+ throw NSError(domain: "AppleFMBridge", code: 1, userInfo: [
339
+ NSLocalizedDescriptionKey: "Only object schemas are supported at root level"
340
+ ])
341
+ }
342
+
343
+ guard let properties = jsonSchema.properties else {
344
+ throw NSError(domain: "AppleFMBridge", code: 2, userInfo: [
345
+ NSLocalizedDescriptionKey: "Object schema must have properties"
346
+ ])
347
+ }
348
+
349
+ let dynamicProperties = try properties.map { (key, prop) -> DynamicGenerationSchema.Property in
350
+ DynamicGenerationSchema.Property(
351
+ name: key,
352
+ description: prop.propertyDescription,
353
+ schema: try buildPropertySchema(from: prop)
354
+ )
355
+ }
356
+
357
+ return DynamicGenerationSchema(
358
+ name: jsonSchema.description ?? "response",
359
+ description: jsonSchema.description,
360
+ properties: dynamicProperties
361
+ )
362
+ }
363
+
364
+ static func buildPropertySchema(from prop: SchemaProperty) throws -> DynamicGenerationSchema {
365
+ switch prop {
366
+ case .leaf(let type, _):
367
+ switch type {
368
+ case "string":
369
+ return DynamicGenerationSchema(type: String.self)
370
+ case "integer":
371
+ return DynamicGenerationSchema(type: Int.self)
372
+ case "number":
373
+ return DynamicGenerationSchema(type: Double.self)
374
+ case "boolean":
375
+ return DynamicGenerationSchema(type: Bool.self)
376
+ default:
377
+ throw NSError(domain: "AppleFMBridge", code: 5, userInfo: [
378
+ NSLocalizedDescriptionKey: "Unsupported property type: \(type)"
379
+ ])
380
+ }
381
+ case .object(_, let description, let nestedProps):
382
+ let dynamicProperties = try nestedProps.map { (key, nestedProp) -> DynamicGenerationSchema.Property in
383
+ DynamicGenerationSchema.Property(
384
+ name: key,
385
+ description: nestedProp.propertyDescription,
386
+ schema: try buildPropertySchema(from: nestedProp)
387
+ )
388
+ }
389
+ return DynamicGenerationSchema(
390
+ name: description ?? "nested",
391
+ description: description,
392
+ properties: dynamicProperties
393
+ )
394
+ case .array(_, _, _):
395
+ // Arrays are not properly supported in DynamicGenerationSchema
396
+ // The API exists but the correct pattern isn't documented
397
+ // For now, recommend using comma-separated strings or numbered properties
398
+ throw NSError(domain: "AppleFMBridge", code: 6, userInfo: [
399
+ NSLocalizedDescriptionKey: "Array types are not supported. Use comma-separated strings or numbered object properties instead."
400
+ ])
401
+ }
402
+ }
403
+
404
+ // MARK: - Prompt Construction
405
+
406
+ /// Flatten conversation messages into a single prompt string.
407
+ /// Foundation Models doesn't have a native multi-turn message API,
408
+ /// so we format the conversation as structured text.
409
+ static func buildPrompt(from messages: [WireMessage]) -> String {
410
+ if messages.count == 1 {
411
+ return messages[0].content.text
412
+ }
413
+
414
+ var parts: [String] = []
415
+ for msg in messages {
416
+ let text = msg.content.text
417
+ switch msg.role {
418
+ case "user":
419
+ parts.append("User: \(text)")
420
+ case "assistant":
421
+ parts.append("Assistant: \(text)")
422
+ case "tool_result":
423
+ parts.append("Tool Result: \(text)")
424
+ default:
425
+ parts.append("\(msg.role): \(text)")
426
+ }
427
+ }
428
+ return parts.joined(separator: "\n\n")
429
+ }
430
+
431
+ // MARK: - Output Helpers
432
+
433
+ static func writeJSON<T: Encodable>(_ value: T, encoder: JSONEncoder) {
434
+ guard let data = try? encoder.encode(value) else { return }
435
+ FileHandle.standardOutput.write(data)
436
+ FileHandle.standardOutput.write("\n".data(using: .utf8)!)
437
+ }
438
+
439
+ static func writeLine<T: Encodable>(_ value: T, encoder: JSONEncoder) {
440
+ guard let data = try? encoder.encode(value) else { return }
441
+ FileHandle.standardOutput.write(data)
442
+ FileHandle.standardOutput.write("\n".data(using: .utf8)!)
443
+ }
444
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@agentick/apple",
3
+ "version": "0.7.0",
4
+ "description": "Apple Foundation Models adapter for Agentick — on-device inference via macOS 26+",
5
+ "keywords": [
6
+ "adapter",
7
+ "agent",
8
+ "ai",
9
+ "apple",
10
+ "foundation-models",
11
+ "on-device"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Ryan Lindgren",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/agenticklabs/agentick.git",
18
+ "directory": "packages/adapters/apple"
19
+ },
20
+ "bin": {
21
+ "apple-fm-bridge": "bin/apple-fm-bridge"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "inference.swift"
26
+ ],
27
+ "os": [
28
+ "darwin"
29
+ ],
30
+ "type": "module",
31
+ "main": "src/index.ts",
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "main": "./dist/index.js",
35
+ "types": "./dist/index.d.ts"
36
+ },
37
+ "scripts": {
38
+ "postinstall": "node scripts/compile-bridge.mjs",
39
+ "build": "tsc -p tsconfig.build.json",
40
+ "test": "echo \"Tests run from workspace root\"",
41
+ "typecheck": "tsc -p tsconfig.build.json --noEmit",
42
+ "clean": "rm -rf dist bin tsconfig.build.tsbuildinfo",
43
+ "prepublishOnly": "pnpm build",
44
+ "dev": "tsc --watch"
45
+ },
46
+ "dependencies": {
47
+ "@agentick/core": "workspace:*",
48
+ "@agentick/shared": "workspace:*"
49
+ }
50
+ }
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * # Agentick Apple Foundation Models Adapter
3
+ *
4
+ * On-device inference via Apple's Foundation Models framework (macOS 26+).
5
+ * Uses a compiled Swift bridge executable for communication.
6
+ *
7
+ * ## Features
8
+ *
9
+ * - **On-Device** — ~3B parameter model running locally on Apple Silicon
10
+ * - **Streaming** — Full streaming support via NDJSON
11
+ * - **Vision** — Multimodal input (text + images)
12
+ * - **Private** — All inference on-device, no network required
13
+ * - **Free** — No API keys or usage costs
14
+ *
15
+ * ## Prerequisites
16
+ *
17
+ * 1. macOS 26+ (Tahoe) with Apple Intelligence enabled
18
+ * 2. Compile the Swift bridge:
19
+ * ```bash
20
+ * swiftc -parse-as-library -framework FoundationModels inference.swift -o apple-fm-bridge
21
+ * ```
22
+ *
23
+ * ## Quick Start
24
+ *
25
+ * ```typescript
26
+ * import { apple } from '@agentick/apple';
27
+ *
28
+ * const model = apple({ bridgePath: './apple-fm-bridge' });
29
+ * const app = createApp(Agent, { model });
30
+ * ```
31
+ *
32
+ * @module @agentick/apple
33
+ */
34
+ export * from "./apple-model";
35
+ export * from "./apple";
36
+ export type { AppleAdapterConfig, BridgeInput, BridgeOutput, BridgeChunk } from "./types";