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
5 changes: 4 additions & 1 deletion macos/M1K3App/AppEnvironment+ChatHistory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,10 @@ extension AppEnvironment {

/// The agent-loop iteration base the thermal cap eases DOWN from. Matches
/// AgentRAGResponder's default — one named home so the two can't drift.
nonisolated static let baseMaxIterations = 3
/// Raised from 3 → 5 (2026-09-22): 3 allowed exactly one tool call
/// (call → observe → conclude); two calls needed 5, and CoolHeadPolicy
/// eased it to 2 or 1, making multi-tool turns structurally impossible.
nonisolated static let baseMaxIterations = 5

/// Conservative fixed reserve (tokens) for the NON-history parts of the prompt
/// — persona+exemplars (~1130) + tools spec (~600) + grounding chunks (~1100)
Expand Down
20 changes: 13 additions & 7 deletions macos/Sources/M1K3Chat/AgentRAGResponder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ public struct AgentRAGResponder: RAGResponding, Sendable {
toolsProvider: @escaping @Sendable () -> [any AgentTool],
topK: Int = 5,
memoryTopK: Int = 5,
maxIterations: Int = 3,
maxIterations: Int = 5,
sourceCollector: ToolSourceCollector? = nil,
thinkingModeProvider: @escaping @Sendable () -> ThinkingMode = { .auto },
brainNameProvider: @escaping @Sendable () -> String = { "" },
Expand Down Expand Up @@ -233,7 +233,7 @@ public struct AgentRAGResponder: RAGResponding, Sendable {
provider: any InferenceProvider,
tools: [any AgentTool],
topK: Int = 5,
maxIterations: Int = 3
maxIterations: Int = 5
) {
self.init(
store: store, embedder: embedder, provider: provider,
Expand Down Expand Up @@ -1132,19 +1132,22 @@ public struct AgentRAGResponder: RAGResponding, Sendable {
"""
RULES:
\(carveOut)
- Use tools proactively. When a question could benefit from a lookup, \
search, or check — call the tool rather than guessing. Combine multiple \
tool calls across iterations when the question has several parts.
- Pure small talk — greetings, banter — needs no tools or knowledge: \
reply IMMEDIATELY starting with "CONCLUSION:", in your own voice, picking up one \
reply starting with "CONCLUSION:", in your own voice, picking up one \
real thread (what they said, a memory of them, the hour). A question about the \
current world is NOT small talk, even phrased casually.
- If the KNOWLEDGE already answers the question, reply IMMEDIATELY \
starting with "CONCLUSION:" — do not use tools.
- If the KNOWLEDGE already fully answers the question, reply \
starting with "CONCLUSION:" — citing it directly.
- Cite knowledge sources inline with citation tokens like \
[Title §heading]; never invent citations.
- Never present a fact, figure, or date you can't ground or verify \
as certain; if you're unsure, say so plainly. Honesty beats a confident guess.
- If a search or lookup comes back empty or fails, answer with explicit \
uncertainty — name what you couldn't confirm — rather than presenting a guess as fact.
- Use at most two tool calls, never repeating one with the same argument.
- Never repeat a tool call with the same argument.
- Questions about yourself — your configuration, design, or abilities — \
are answered from your persona; never search stored documents for them.
\(routing)
Expand All @@ -1153,10 +1156,13 @@ public struct AgentRAGResponder: RAGResponding, Sendable {
"""
RULES:
\(carveOut)
- Use tools proactively. When a question could benefit from a lookup, \
search, or check — call the tool rather than guessing. Call multiple \
tools in a single turn when the question has several parts.
- Pure small talk — greetings, banter — needs no tools or knowledge — reply in \
your own voice and pick up one real thread (what they said, a memory of them, the \
hour). A question about the current world is NOT small talk, even phrased casually.
- If the KNOWLEDGE above answers the question, answer from it directly.
- If the KNOWLEDGE above fully answers the question, answer from it directly.
- Cite knowledge sources inline with citation tokens like \
[Title §heading]; never invent citations.
- Never present a fact, figure, or date you can't ground or verify \
Expand Down
69 changes: 69 additions & 0 deletions macos/Sources/M1K3Inference/AFMNativeTool.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//
// AFMNativeTool.swift
// M1K3Inference
//
// Bridges M1K3's ToolDefinition → the macOS 26+ FoundationModels `Tool`
// protocol, so Apple's on-device model sees STRUCTURED tool definitions
// (JSON Schema + descriptions) instead of a text catalogue. The model
// decides which tool to call natively; the wrapper's `call(arguments:)`
// returns a STUB result — the real execution happens in LocalAgent's
// dispatch core (repeat-guard, exclusion classes, activity events).
//
// Each wrapper carries a single-string `@Generable` argument struct —
// every current M1K3 AgentTool takes one text parameter under "query".

#if compiler(>=6.2)
import Foundation
@_weakLinked import FoundationModels

@available(macOS 26.0, iOS 26.0, visionOS 26.0, *)
@Generable
public struct AFMToolArguments: Sendable {
@Guide(description: "The input query or argument to pass to this tool.")
public var query: String
}

@available(macOS 26.0, iOS 26.0, visionOS 26.0, *)
public struct AFMNativeTool: Tool, @unchecked Sendable {
public typealias Arguments = AFMToolArguments
public typealias Output = String

public let name: String
public let description: String

private let onCall: @Sendable (String, String) -> Void

public init(
name: String,
description: String,
onCall: @escaping @Sendable (String, String) -> Void
) {
self.name = name
self.description = description
self.onCall = onCall
}

public func call(arguments: AFMToolArguments) async throws -> String {
onCall(name, arguments.query)
return "(Tool result will follow.)"
}
}

// MARK: - Factory

@available(macOS 26.0, iOS 26.0, visionOS 26.0, *)
public extension AFMNativeTool {
static func wrap(
_ tools: [ToolDefinition],
onCall: @escaping @Sendable (String, String) -> Void
) -> [AFMNativeTool] {
tools.map { def in
AFMNativeTool(
name: def.name,
description: def.description,
onCall: onCall
)
}
}
}
#endif
140 changes: 140 additions & 0 deletions macos/Sources/M1K3Inference/AFMNativeToolTurnSession.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
//
// AFMNativeToolTurnSession.swift
// M1K3Inference
//
// A ToolTurnSession backed by LanguageModelSession(tools:) — the macOS 26+
// native tool-calling path. The session registers AFMNativeTool wrappers so
// the model sees structured JSON-Schema tool definitions and can call them
// natively. The wrappers return STUB results — the real execution stays in
// LocalAgent's dispatch core, which owns the repeat-guard, exclusion classes
// and activity events.
//
// Flow per send():
// 1. Accumulate messages into a full transcript (the agent sends only deltas).
// 2. Render the full transcript → a text prompt.
// 3. Call session.respond(to:, options:) with toolCallingMode: .allowed.
// 4. If the model called a tool → the wrapper fires onCall and records it.
// Return .toolCalls so the agent dispatches the REAL tool.
// 5. If no tool was called → return .text(answer).
//
// A FRESH LanguageModelSession is created per send() — same cost as the
// AFMToolDecision path (Phase 15 review note 1). The full transcript is
// re-rendered each call so the model always sees the complete conversation.

#if compiler(>=6.2)
import Foundation
@_weakLinked import FoundationModels
import M1K3LogCore
import os

/// The agent loop uses a session strictly serially (one send at a time,
/// awaited before the next), so the unsynchronized transcript is safe.
@available(macOS 26.0, iOS 26.0, visionOS 26.0, *)
final class AFMNativeToolTurnSession: ToolTurnSession, @unchecked Sendable {
private let instructions: String
private let tools: [AFMNativeTool]
private let toolDefinitions: [ToolDefinition]
private static let log = M1K3Log.logger(.afm)
private let callLog: ToolCallLog
private var transcript: [ToolMessage] = []

final class ToolCallLog: @unchecked Sendable {
private let lock = NSLock()
private var entries: [(name: String, query: String)] = []

func append(_ name: String, _ query: String) {
lock.withLock { entries.append((name, query)) }
}

func drain() -> [(name: String, query: String)] {
lock.withLock {
let result = entries
entries.removeAll()
return result
}
}
}

init(instructions: String, toolDefinitions: [ToolDefinition]) {
let log = ToolCallLog()
self.toolDefinitions = toolDefinitions
callLog = log
tools = AFMNativeTool.wrap(toolDefinitions) { name, query in
log.append(name, query)
}
self.instructions = instructions
}

func send(
_ messages: [ToolMessage],
onToken: @escaping @Sendable (String) -> Void
) async throws -> ToolTurn {
_ = callLog.drain()
transcript.append(contentsOf: messages)

// Empty tool list: the text catalogue is omitted because the FM
// session already carries structured definitions via `tools:`.
// Rendering both doubled the token count past Mini's 4096 window.
let body = AFMToolPrompt.render(messages: transcript, tools: [])
let imageURLs = AFMToolPrompt.imageURLs(from: transcript)
let standing = AFMToolPrompt.systemInstructions(from: transcript) ?? instructions

let session = LanguageModelSession(
tools: tools,
instructions: standing
)

do {
let response: LanguageModelSession.Response<String>

#if compiler(>=6.4)
if #available(macOS 27.0, iOS 27.0, visionOS 27.0, *) {
let options = GenerationOptions(toolCallingMode: .allowed)
if !imageURLs.isEmpty {
response = try await session.respond(options: options) {
body
for url in imageURLs {
Attachment(imageURL: url)
}
}
} else {
response = try await session.respond(to: body, options: options)
}
} else {
response = try await session.respond(to: body)
}
#else
response = try await session.respond(to: body)
#endif

let calls = callLog.drain()

if !calls.isEmpty {
let parsed = calls.map { call in
ParsedToolCall(
name: call.name,
arguments: [AFMToolMapping.argumentKey: .string(call.query)]
)
}
Self.log.notice(
"afm native tools: \(calls.count, privacy: .public) call(s) — \(calls.map(\.name).joined(separator: ", "), privacy: .public)"
)
return .toolCalls(parsed)
}

onToken(response.content)
return .text(response.content)

} catch is CancellationError {
throw CancellationError()
} catch {
let described = String(describing: error)
let preview = LogPreview.preview(described, max: 200)
Self.log.error(
"afm native tool session failed: \(preview, privacy: .public)"
)
return .text("")
}
}
}
#endif
15 changes: 8 additions & 7 deletions macos/Sources/M1K3Inference/AFMToolMapping.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,14 @@ public enum AFMToolPrompt {

lines.append("")
lines.append(
"Decide the single next step. You do NOT inherently know the current "
+ "date/time, the user's private notes or documents, or any live / "
+ "up-to-the-minute information — you MUST call the matching tool for "
+ "those rather than guessing. Call one tool if it would help answer "
+ "the request; only give your final answer when you genuinely can "
+ "answer now (the tools have already given you what you need, or no "
+ "tool applies)."
"Decide the single next step. The tools listed above are yours to USE "
+ "— calling them is your job, not a secret. You do NOT inherently "
+ "know the current date/time, the user's private notes or documents, "
+ "or any live information — CALL the matching tool for those rather "
+ "than saying \"I can't\" or guessing. Never say you lack access to "
+ "something a listed tool provides. Call one tool if it would help "
+ "answer the request; give your final answer only when the tools have "
+ "already given you what you need, or no tool applies."
)
return lines.joined(separator: "\n")
}
Expand Down
52 changes: 34 additions & 18 deletions macos/Sources/M1K3Inference/AppleFoundationModelsProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -290,10 +290,13 @@ public struct AppleFoundationModelsProvider: InferenceProvider {
/// neutral instructions so they don't speak as M1K3.
private let instructions: @Sendable () -> String

/// Opt-in for the Phase-15 AFM-native tool-calling path. Default OFF: the
/// provider reports `supportsToolCalls == false`, so `LocalAgent` keeps the
/// prompt-ReAct floor and launch routing is unchanged. Flipped on only by the
/// eval harness (and, later, a Settings toggle) to exercise the spike.
/// AFM-native tool-calling path (Phase 15 → production). Default ON: the
/// provider reports `supportsToolCalls == true`, so `LocalAgent` takes the
/// native loop and Mini sees structured tool definitions via
/// `@Generable AFMToolDecision` instead of text-scraped ACTION: markers.
/// The RULES softening + iteration budget increase (2026-09-22) make this
/// viable — Mini's tool-use was 0/30 on the ReAct floor before #328, and
/// still only 15/30 after.
private let nativeToolCalling: Bool

/// Opt-in: after each generation settles, arm a fresh prewarmed session so
Expand Down Expand Up @@ -337,7 +340,7 @@ public struct AppleFoundationModelsProvider: InferenceProvider {
// (32K+) can afford and Mini cannot. Every token saved goes directly to
// conversation replay depth (+41% measured).
instructions: @escaping @Sendable () -> String = { M1K3Persona.miniSystemPrompt },
nativeToolCalling: Bool = false,
nativeToolCalling: Bool = true,
prewarmsBetweenTurns: Bool = false,
prewarmsPromptPrefix: Bool = true
) {
Expand Down Expand Up @@ -519,23 +522,36 @@ private struct AFMToolDecision {
/// Same-file extension so the conformance keeps reading the provider's `private`
/// `instructions` + `nativeToolCalling` without widening their visibility.
extension AppleFoundationModelsProvider: ToolCallingProvider {
/// Runtime capability: only when the spike is opted IN *and* the on-device
/// model is actually available. Default-OFF flag ⇒ ReAct floor ⇒ launch
/// routing unchanged.
/// Runtime capability: when native tool calling is ON *and* the on-device
/// model is available. Default-ON since 2026-09-22 — the RULES softening,
/// iteration budget increase (3→5), and ReAct prompt improvements make
/// Mini's native path the better route.
public var supportsToolCalls: Bool {
nativeToolCalling && isAvailable
}

/// Spike-scoped costs to retire before any production wiring (review
/// 2026-06-15): (1) a FRESH `LanguageModelSession` per call + the default
/// `StatelessToolTurnSession` re-sending the whole transcript ⇒ no KV reuse,
/// iteration ≥2 re-prefills the persona (a chunk of the ~20–30s/call). A real
/// `ToolTurnSession` holding one AFM session across the turn would cut it. (2)
/// the cap-reached `synthesizeNativeConclusion` turn is a plain `.user`, but
/// this path still forces the `AFMToolDecision` schema — the `isFinal=true`
/// branch absorbs it (toolName/toolInput wasted), a non-obvious coupling.
/// Both are acceptable for a spike whose verdict is "don't route agentic to
/// AFM" regardless; named so they aren't inherited silently.
/// Native FM tool session: creates an `AFMNativeToolTurnSession` backed by
/// `LanguageModelSession(tools:)` — the model sees JSON-Schema tool definitions
/// and can call them via the `Tool` protocol. The wrappers return stub results;
/// real execution stays in LocalAgent's dispatch core.
///
/// Falls back to the default `StatelessToolTurnSession` (which calls
/// `continueToolTurn` per iteration) on older runtimes.
public func makeToolTurnSession(
tools: [ToolDefinition],
options _: ToolTurnOptions
) async throws -> any ToolTurnSession {
AFMNativeToolTurnSession(
instructions: instructions(),
toolDefinitions: tools
)
}

/// Legacy fallback: `@Generable AFMToolDecision` constrained decoding.
/// Kept for callers that go through `continueToolTurn` directly (the
/// default `StatelessToolTurnSession` shape). With the native session
/// override above, this path only fires on older runtimes or when
/// `makeToolTurnSession` is bypassed.
public func continueToolTurn(messages: [ToolMessage], tools: [ToolDefinition]) async throws -> ToolTurn {
let body = AFMToolPrompt.render(messages: messages, tools: tools)
let imageURLs = AFMToolPrompt.imageURLs(from: messages)
Expand Down
Loading
Loading