Files
PhoneClaw/LLM/MLX/Gemma4/Gemma4LanguageModel.swift
KellyvvandClaude Opus 4.6 779be78843 refactor: split MLX service, introduce skill type & runtime budgets
Major restructuring of the agent stack to support content-type skills,
data-driven runtime budgets, and the new translate skill.

LLM/MLX/
- Split MLXLocalLLMService.swift (1500 → 700 lines) into Core/, Gemma4/,
  Installation/ subdirectories
- New BundledModel: LinearBudgetFormula replaces tier tables, model
  parameters fully data-driven via ModelRuntimeProfile
- New RuntimeBudgets: pure functions for text/thinking/multimodal budgets,
  zero hardcoded model IDs
- Drop adjustedTextOutputTokens prepared-subtraction (chunked prefill made
  it obsolete and triggered false-positive truncation on long prompts)

Skills/
- Add SkillType (.device / .content) metadata, parsed from SKILL.md
  frontmatter
- New translate skill (type: content, no tools) with anaphora handling
- Trim translate (4563→1804), device (2357→1680), calendar (2169→1790)
- contacts SKILL.md adds multi-turn disambiguation + cancel guidance,
  semantics owned by body not framework
- SkillRegistry validates allowed-tools against ToolRegistry at load time

Agent/
- ToolCallParser becomes registry-aware: drops tool_call names not in
  ToolRegistry or protocol set (load_skill / list_skills); ToolRegistry
  is now the single white list, validation moves to parser boundary
- Router sticky routing uses semantic user-turn boundary instead of
  fixed suffix(4); correctly handles long agent loops
- AgentEngine system prompt template differentiates device vs content
  skills with explicit invocation rules
- SYSPROMPT.md auto-migration: old single-placeholder template backed up
  to .bak when new dual-placeholder is detected
- PromptBuilder.buildLoadedSkillPrompt receives availableTools list,
  explicitly tells model which tools (or none) are callable

Tools/
- ToolRegistry split into Helpers/Stores/AppPermissions/Handlers/* for
  separation of concerns
- Mark Tools/Handlers/* with explicit allowed argument signatures

UI/
- Add long-press copy contextMenu on user and assistant bubbles
- Enable .textSelection on user bubble

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:22:20 +08:00

325 lines
12 KiB
Swift

import Foundation
import MLX
import MLXFast
import MLXNN
import MLXLMCommon
// MARK: - DecoderLayer
class Gemma4DecoderLayer: Module {
let config: Gemma4TextConfiguration
let layerIdx: Int
let layerType: String
@ModuleInfo(key: "self_attn") var selfAttn: Gemma4Attention
@ModuleInfo var mlp: Gemma4MLP
@ModuleInfo(key: "input_layernorm") var inputLayerNorm: RMSNorm
@ModuleInfo(key: "post_attention_layernorm") var postAttentionLayerNorm: RMSNorm
@ModuleInfo(key: "pre_feedforward_layernorm") var preFeedforwardLayerNorm: RMSNorm
@ModuleInfo(key: "post_feedforward_layernorm") var postFeedforwardLayerNorm: RMSNorm
// PLE (Per-Layer Embeddings) — only for 2B/4B models
@ModuleInfo(key: "per_layer_input_gate") var perLayerInputGate: Linear
@ModuleInfo(key: "per_layer_projection") var perLayerProjection: Linear
@ModuleInfo(key: "post_per_layer_input_norm") var postPerLayerInputNorm: RMSNorm
// Layer scalar
@ParameterInfo(key: "layer_scalar") var layerScalar: MLXArray
init(config: Gemma4TextConfiguration, layerIdx: Int) {
self.config = config
self.layerIdx = layerIdx
self.layerType = config.layerTypes[layerIdx]
self._selfAttn.wrappedValue = Gemma4Attention(config: config, layerIdx: layerIdx)
self._mlp.wrappedValue = Gemma4MLP(config: config, layerIdx: layerIdx)
let eps = config.rmsNormEps
let dim = config.hiddenSize
self._inputLayerNorm.wrappedValue = RMSNorm(dimensions: dim, eps: eps)
self._postAttentionLayerNorm.wrappedValue = RMSNorm(dimensions: dim, eps: eps)
self._preFeedforwardLayerNorm.wrappedValue = RMSNorm(dimensions: dim, eps: eps)
self._postFeedforwardLayerNorm.wrappedValue = RMSNorm(dimensions: dim, eps: eps)
// PLE gating
let pleDim = config.hiddenSizePerLayerInput
precondition(pleDim > 0, "Gemma 4 text path currently expects a PLE-enabled config.")
self._perLayerInputGate.wrappedValue = Linear(dim, pleDim, bias: false)
self._perLayerProjection.wrappedValue = Linear(pleDim, dim, bias: false)
self._postPerLayerInputNorm.wrappedValue = RMSNorm(dimensions: dim, eps: eps)
self._layerScalar.wrappedValue = MLXArray.ones([1])
}
func callAsFunction(
_ x: MLXArray,
mask: MLXFast.ScaledDotProductAttentionMaskMode? = nil,
cache: KVCache? = nil,
perLayerInput: MLXArray? = nil
) -> MLXArray {
var residual = x
// Self-attention block
var h = inputLayerNorm(x)
h = selfAttn(h, mask: mask, cache: cache)
h = postAttentionLayerNorm(h)
h = residual + h
// MLP block
residual = h
h = preFeedforwardLayerNorm(h)
h = mlp(h)
h = postFeedforwardLayerNorm(h)
h = residual + h
// PLE gating
if let pli = perLayerInput {
residual = h
var g = perLayerInputGate(h)
g = geluApproximate(g)
g = g * pli
g = perLayerProjection(g)
g = postPerLayerInputNorm(g)
h = residual + g
}
// Layer scalar
h = h * layerScalar
return h
}
}
// MARK: - Gemma4TextModel
class Gemma4TextModel: Module {
let config: Gemma4TextConfiguration
@ModuleInfo(key: "embed_tokens") var embedTokens: Embedding
@ModuleInfo var layers: [Gemma4DecoderLayer]
@ModuleInfo var norm: RMSNorm
let embedScale: Float
let firstKvSharedLayerIdx: Int
let layerIdxToCacheIdx: [Int]
// PLE embeddings
@ModuleInfo(key: "embed_tokens_per_layer") var embedTokensPerLayer: Embedding
@ModuleInfo(key: "per_layer_model_projection") var perLayerModelProjection: ScaledLinear
@ModuleInfo(key: "per_layer_projection_norm") var perLayerProjectionNorm: RMSNormZeroShift
let embedTokensPerLayerScale: Float
let perLayerInputScale: Float
init(_ config: Gemma4TextConfiguration) {
self.config = config
self.embedScale = pow(Float(config.hiddenSize), 0.5)
self.firstKvSharedLayerIdx = config.firstKvSharedLayerIdx
self._embedTokens.wrappedValue = Embedding(
embeddingCount: config.vocabSize, dimensions: config.hiddenSize)
self._layers.wrappedValue = (0..<config.numHiddenLayers).map {
Gemma4DecoderLayer(config: config, layerIdx: $0)
}
self._norm.wrappedValue = RMSNorm(dimensions: config.hiddenSize, eps: config.rmsNormEps)
// Build KV cache index mapping for KV sharing
var mapping = Array(0..<firstKvSharedLayerIdx)
if firstKvSharedLayerIdx < config.numHiddenLayers {
let concreteLayers = Array(config.layerTypes[..<firstKvSharedLayerIdx])
let sharedFullIdx = concreteLayers.lastIndex(of: "full_attention") ?? 0
let sharedSlidingIdx = concreteLayers.lastIndex(of: "sliding_attention") ?? 0
for i in firstKvSharedLayerIdx..<config.numHiddenLayers {
if config.layerTypes[i] == "full_attention" {
mapping.append(sharedFullIdx)
} else {
mapping.append(sharedSlidingIdx)
}
}
}
self.layerIdxToCacheIdx = mapping
// PLE
let pleDim = config.hiddenSizePerLayerInput
precondition(pleDim > 0, "Gemma 4 text path currently expects a PLE-enabled config.")
self._embedTokensPerLayer.wrappedValue = Embedding(
embeddingCount: config.vocabSizePerLayerInput,
dimensions: config.numHiddenLayers * pleDim
)
self.embedTokensPerLayerScale = pow(Float(pleDim), 0.5)
self.perLayerInputScale = pow(Float(2.0), -0.5)
self._perLayerModelProjection.wrappedValue = ScaledLinear(
inFeatures: config.hiddenSize,
outFeatures: config.numHiddenLayers * pleDim,
scalar: pow(Float(config.hiddenSize), -0.5)
)
self._perLayerProjectionNorm.wrappedValue = RMSNormZeroShift(
dimensions: pleDim, eps: config.rmsNormEps)
}
private func batchedTokenIds(_ inputIds: MLXArray) -> MLXArray {
if inputIds.ndim == 1 {
return inputIds.expandedDimensions(axis: 0)
}
return inputIds
}
private func batchedEmbeddings(_ embeddings: MLXArray) -> MLXArray {
if embeddings.ndim == 2 {
return embeddings.expandedDimensions(axis: 0)
}
return embeddings
}
func getPerLayerInputs(_ inputIds: MLXArray) -> MLXArray {
let batchedIds = batchedTokenIds(inputIds)
var result = embedTokensPerLayer(batchedIds)
result = result * embedTokensPerLayerScale
let shape = batchedIds.shape
return result.reshaped(shape[0], shape[1], config.numHiddenLayers, config.hiddenSizePerLayerInput)
}
func projectPerLayerInputs(
_ inputsEmbeds: MLXArray, perLayerInputs: MLXArray?
) -> MLXArray {
let batchedEmbeds = batchedEmbeddings(inputsEmbeds)
var projection = perLayerModelProjection(batchedEmbeds)
let shape = batchedEmbeds.shape
projection = projection.reshaped(
shape[0], shape[1], config.numHiddenLayers, config.hiddenSizePerLayerInput)
projection = perLayerProjectionNorm(projection)
if let pli = perLayerInputs {
return (projection + pli) * perLayerInputScale
}
return projection
}
func callAsFunction(
_ inputs: MLXArray? = nil,
inputsEmbeds: MLXArray? = nil,
perLayerInputs externalPerLayerInputs: MLXArray? = nil,
cache: [KVCache?]? = nil
) -> MLXArray {
var h: MLXArray
if let embed = inputsEmbeds {
h = batchedEmbeddings(embed)
} else if let ids = inputs {
let batchedIds = batchedTokenIds(ids)
h = embedTokens(batchedIds) * embedScale
} else {
fatalError("Either inputs or inputsEmbeds must be provided")
}
// PLE computation
var perLayerInputs = externalPerLayerInputs
if perLayerInputs == nil, let ids = inputs {
perLayerInputs = getPerLayerInputs(ids)
}
perLayerInputs = projectPerLayerInputs(h, perLayerInputs: perLayerInputs)
// Create attention masks
let firstFullIdx = config.layerTypes.firstIndex(of: "full_attention") ?? 0
let firstSlidingIdx = config.layerTypes.firstIndex(of: "sliding_attention") ?? 0
let fullCache = (firstFullIdx < (cache?.count ?? 0)) ? cache?[firstFullIdx] : nil
let slidingCache = (firstSlidingIdx < (cache?.count ?? 0)) ? cache?[firstSlidingIdx] : nil
let globalMask = createAttentionMask(h: h, cache: fullCache)
let slidingMask = createAttentionMask(
h: h, cache: slidingCache, windowSize: config.slidingWindow)
// Run through decoder layers
for (i, layer) in layers.enumerated() {
let cacheIdx = layerIdxToCacheIdx[i]
let c = (cacheIdx < (cache?.count ?? 0)) ? cache?[cacheIdx] : nil
let isGlobal = layer.layerType == "full_attention"
let mask = isGlobal ? globalMask : slidingMask
var pli: MLXArray? = nil
if let p = perLayerInputs {
pli = p[0..., 0..., i, 0...]
}
h = layer(h, mask: mask, cache: c, perLayerInput: pli)
}
return norm(h)
}
}
// MARK: - LanguageModel
public class Gemma4LanguageModel: Module, KVCacheDimensionProvider {
@ModuleInfo var model: Gemma4TextModel
let config: Gemma4TextConfiguration
let finalLogitSoftcapping: Float?
public var kvHeads: [Int]
public init(_ config: Gemma4TextConfiguration) {
self.config = config
self.model = Gemma4TextModel(config)
self.finalLogitSoftcapping = config.finalLogitSoftcapping
self.kvHeads = Array(repeating: config.numKeyValueHeads, count: config.numHiddenLayers)
}
public func newCache(parameters: GenerateParameters?) -> [any KVCache] {
var caches: [any KVCache] = []
let concreteLayers = Array(config.layerTypes[..<model.firstKvSharedLayerIdx])
for layerType in concreteLayers {
if layerType == "full_attention" {
caches.append(StandardKVCache())
} else {
caches.append(RotatingKVCache(maxSize: config.slidingWindow, keep: 0))
}
}
return caches
}
public func callAsFunction(
_ inputs: MLXArray? = nil,
cache: [KVCache]? = nil,
inputsEmbeds: MLXArray? = nil,
perLayerInputs: MLXArray? = nil
) -> LMOutput {
let optionalCache = cache?.map { $0 as KVCache? }
let out = model(
inputs,
inputsEmbeds: inputsEmbeds,
perLayerInputs: perLayerInputs,
cache: optionalCache
)
// Tied weights: use embedding as output projection
var logits = model.embedTokens.asLinear(out)
// Final logit softcapping
if let softcap = finalLogitSoftcapping, softcap > 0 {
let s = MLXArray(softcap)
logits = tanh(logits / s) * s
}
return LMOutput(logits: logits)
}
public func sanitize(
weights: [String: MLXArray],
quantizationConfig: BaseConfiguration.Quantization? = nil
) -> [String: MLXArray] {
var processed = [String: MLXArray]()
for (key, value) in weights {
guard key.hasPrefix("language_model.") else { continue }
if key.contains("rotary_emb") { continue }
if key.contains("input_max") || key.contains("input_min")
|| key.contains("output_max") || key.contains("output_min")
{
continue
}
processed[key] = value
}
return processed
}
}