Skip to content

Commit f42617a

Browse files
michalharakalclaude
andcommitted
refactor: decouple tool calling from kllama, add ChatSession abstraction
Phase 1 of the unified pipeline plan. Tool calling no longer requires GGUFTokenizer — any Tokenizer implementation works. - Extend Tokenizer interface with eosTokenId, bosTokenId, vocabSize - Add ChatSession in llm-agent that bundles runtime + tokenizer + metadata - Refactor ToolCallingDemo and AgentCli to accept Tokenizer, not GGUFTokenizer - Remove GGUFTokenizer cast from kllama Main.kt chat/agent/demo dispatch - Fix JavaAgentLoop instanceof hack with tokenizer.eosTokenId - Update all Tokenizer implementations (GGUF, HF BPE, Tekken, BERT) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fa7ab73 commit f42617a

12 files changed

Lines changed: 310 additions & 64 deletions

File tree

PLAN-unified-pipeline.md

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# Plan: Unified Model Pipeline with Decoupled Tool Calling
2+
3+
## Context
4+
5+
Currently SKaiNET-transformers has:
6+
- **5+ hand-coded runtimes** (LlamaRuntime, Qwen35Runtime, Gemma3nRuntime, ApertusRuntime, VoxtralRuntimes) — each reimplements the forward pass, weight loading, and layer execution
7+
- **Tool calling tightly coupled to kllama** — the AgentLoop, ToolCallingDemo, and chat modes only exist in the kllama runner. Other models (Gemma, Apertus) cannot use tool calling without duplicating code
8+
- **Two execution paths** — legacy hand-coded runtimes AND the newer `OptimizedLLMRuntime` with DSL/compute-graph/AOT. LlamaRuntime and ApertusRuntime are already marked deprecated
9+
10+
The goal: converge on **one unified pipeline** where model definition, weight loading, tokenization, and tool calling are cleanly separated pipeline stages.
11+
12+
## Architecture Overview
13+
14+
```
15+
GGUF/SafeTensors File
16+
|
17+
WeightLoader (parse metadata + tensors)
18+
|
19+
DSL Network Definition (model-specific, declarative)
20+
|
21+
ComputeGraph (DAG)
22+
|
23+
Optimization Pipeline (TransposeElim -> WeightDedup -> LLMFusion -> DCE)
24+
|
25+
ComputeGraphExecutor (fused kernels)
26+
|
27+
InferenceRuntime (unified: forward + generate)
28+
|
29+
TokenizationPipeline (encode/decode, special tokens, byte-level BPE)
30+
|
31+
ChatPipeline (template formatting, tool calling, agent loop)
32+
```
33+
34+
## Phase 1: Decouple Tool Calling from kllama (immediate value) -- DONE
35+
36+
**What was done:**
37+
38+
1. **Enhanced `Tokenizer` interface** with `eosTokenId`, `bosTokenId`, `vocabSize`
39+
- Updated all implementations: `GGUFTokenizer`, `TokenizerImpl`, `HuggingFaceBPETokenizer`, `TekkenTokenizerAdapter`, `HuggingFaceTokenizer` (BERT)
40+
41+
2. **Created `ChatSession` abstraction** in `llm-agent`
42+
- File: `llm-agent/.../chat/ChatSession.kt`
43+
- Bundles `InferenceRuntime` + `Tokenizer` + `ModelMetadata`
44+
- Provides `createAgentLoop()` and `runSingleTurn()` for any runner
45+
46+
3. **Refactored `ToolCallingDemo` and `AgentCli`** to use `Tokenizer` interface instead of `GGUFTokenizer`
47+
- Both now accept any `Tokenizer`, not just `GGUFTokenizer`
48+
- Both use `ChatSession` internally for agent loop creation
49+
50+
4. **Removed `GGUFTokenizer` cast from kllama Main.kt** dispatch
51+
- Chat/agent/demo modes now work with any `Tokenizer`
52+
53+
5. **Fixed `JavaAgentLoop`** — replaced `GGUFTokenizer` instanceof hack with `tokenizer.eosTokenId`
54+
55+
## Phase 2: Unified DSL-Based Model Definition (converge on OptimizedLLMRuntime)
56+
57+
**Problem:** Each model has a hand-coded runtime. `OptimizedLLMRuntime` already supports DSL -> graph -> optimized execution, but only some models use it.
58+
59+
**Changes:**
60+
61+
1. **Define DSL networks for all model families:**
62+
- `llamaNetwork(config)` — LLaMA/Mistral/Qwen2/3 (standard transformer)
63+
- `qwen35Network(config)` — Qwen3.5 (hybrid DeltaNet + full attention)
64+
- `gemmaNetwork(config)` — Gemma (GELU, MatFormer FFN, sliding window)
65+
- `apertusNetwork(config)` — Apertus (xIELU, ungated MLP, QK-norm)
66+
- Each is a pure function returning a `Network<T>` from the DSL
67+
68+
2. **Unified model loading flow:**
69+
```
70+
detectArchitecture(ggufMetadata) -> ModelFamily
71+
ModelFamily.createNetwork(config) -> Network<T>
72+
WeightLoader.loadAndMap(file, network) -> weights
73+
OptimizedLLMRuntime(network, weights, mode=HYBRID) -> InferenceRuntime
74+
```
75+
76+
3. **Remove deprecated hand-coded runtimes** once DSL equivalents are validated:
77+
- `LlamaRuntime` -> `llamaNetwork()` + `OptimizedLLMRuntime`
78+
- `ApertusRuntime` -> `apertusNetwork()` + `OptimizedLLMRuntime`
79+
80+
**Critical files:**
81+
- `llm-core/.../OptimizedLLMRuntime.kt` — already exists, extend
82+
- `llm-core/.../dsl/TransformerDsl.kt` — already has embedding, MHA, SwiGLU, RMSNorm
83+
- `llm-core/.../weights/LLMWeightNameResolvers.kt` — already maps DSL paths -> GGUF names
84+
- New: per-model DSL network definitions
85+
86+
## Phase 3: Tokenization as Pipeline Stage
87+
88+
**Problem:** Tokenization is split between `GGUFTokenizer` (kllama module), `QwenByteLevelBPETokenizer` (llm-core), and model-specific code. The byte-level BPE fix we just made shows the fragility.
89+
90+
**Changes:**
91+
92+
1. **Enhance `Tokenizer` interface** (`llm-core`):
93+
```kotlin
94+
interface Tokenizer {
95+
fun encode(text: String): IntArray
96+
fun decode(token: Int): String
97+
fun decode(tokens: IntArray): String
98+
val eosTokenId: Int
99+
val bosTokenId: Int
100+
val vocabSize: Int
101+
val specialTokens: Set<String>
102+
}
103+
```
104+
105+
2. **Unified tokenizer factory:**
106+
- `TokenizerFactory.fromGGUF(source)` — auto-detects BPE/SentencePiece/WordPiece
107+
- `TokenizerFactory.fromTokenizerJson(json)` — HuggingFace format
108+
- Returns the correct implementation (byte-level BPE for GPT-2/Qwen, SentencePiece for LLaMA, etc.)
109+
110+
3. **Move `GGUFTokenizer` to `llm-core`** so all runners can use it without depending on kllama
111+
112+
## Phase 4: Unified Runner (single CLI entry point)
113+
114+
**Problem:** 6 separate CLI apps with duplicated argument parsing, model loading, and dispatch logic.
115+
116+
**Changes:**
117+
118+
1. **Single `skainet` CLI** that auto-detects model architecture from GGUF metadata:
119+
```bash
120+
skainet -m model.gguf "prompt" # auto-detect, generate
121+
skainet -m model.gguf --chat # auto-detect, chat mode
122+
skainet -m model.gguf --demo "What is 2+2?" # auto-detect, tool calling
123+
```
124+
125+
2. **Architecture registry:**
126+
```kotlin
127+
ModelRegistry.register("llama", ::llamaNetwork)
128+
ModelRegistry.register("qwen3", ::qwenNetwork)
129+
ModelRegistry.register("gemma", ::gemmaNetwork)
130+
```
131+
132+
3. **Auto-detection from GGUF metadata** (already exists in `peekGgufMetadata()`)
133+
134+
## Verification
135+
136+
- All existing unit tests pass (`llm-agent`, `llm-runtime:kllama`, `llm-core`)
137+
- Smoke test suite passes (generation + tool calling)
138+
- Basic generation produces identical output for all model families
139+
- Tool calling works for any model that supports ChatML/Qwen/Llama3 templates
140+
- `OptimizedLLMRuntime` in HYBRID mode matches hand-coded runtime output
141+
142+
## Suggested Implementation Order
143+
144+
1. **Phase 1** first — immediately unblocks tool calling for all models
145+
2. **Phase 3** next — reduces fragility (the GGUFTokenizer byte-level BPE issue)
146+
3. **Phase 2** then — biggest refactor, needs per-model validation
147+
4. **Phase 4** last — depends on all other phases
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package sk.ainet.apps.kllama.chat
2+
3+
import sk.ainet.apps.llm.InferenceRuntime
4+
import sk.ainet.apps.llm.Tokenizer
5+
import sk.ainet.lang.types.DType
6+
7+
/**
8+
* Bundles an [InferenceRuntime] with a [Tokenizer] and [ModelMetadata] to provide
9+
* chat, agent, and tool-calling capabilities for any model.
10+
*
11+
* This decouples tool calling from any specific runner — any runner that can
12+
* produce an [InferenceRuntime] and a [Tokenizer] can create a [ChatSession]
13+
* and get chat/agent/demo modes for free.
14+
*
15+
* Usage:
16+
* ```kotlin
17+
* val session = ChatSession(runtime, tokenizer, metadata)
18+
* session.chat(maxTokens = 512, temperature = 0.7f) // interactive chat
19+
* session.agent(maxTokens = 512, temperature = 0.7f) // interactive agent with tools
20+
* session.demo(maxTokens = 256, temperature = 0.7f) // interactive tool calling demo
21+
* session.demoSingleShot("What is 2+2?", maxTokens = 256) // non-interactive single prompt
22+
* ```
23+
*/
24+
public class ChatSession<T : DType>(
25+
public val runtime: InferenceRuntime<T>,
26+
public val tokenizer: Tokenizer,
27+
public val metadata: ModelMetadata = ModelMetadata(),
28+
templateName: String? = null
29+
) {
30+
private val provider: ToolCallingSupport = ToolCallingSupportResolver.resolveOrFallback(metadata, templateName)
31+
private val template: ChatTemplate = provider.createChatTemplate()
32+
33+
/**
34+
* Run a single agent round with the given prompt and tools.
35+
* Returns the final response text. Non-interactive — suitable for smoke tests.
36+
*
37+
* @param prompt The user prompt.
38+
* @param tools Tools to register. If empty, uses default calculator + list_files.
39+
* @param maxTokens Maximum tokens per generation round.
40+
* @param temperature Sampling temperature.
41+
* @param listener Optional listener for observing the agent loop.
42+
* @return The final assistant response text.
43+
*/
44+
public fun runSingleTurn(
45+
prompt: String,
46+
tools: List<Tool> = emptyList(),
47+
maxTokens: Int = 256,
48+
temperature: Float = 0.7f,
49+
listener: AgentListener? = null
50+
): String {
51+
val registry = ToolRegistry()
52+
tools.forEach { registry.register(it) }
53+
54+
val agentLoop = AgentLoop(
55+
runtime = runtime,
56+
template = template,
57+
toolRegistry = registry,
58+
eosTokenId = tokenizer.eosTokenId,
59+
config = AgentConfig(
60+
maxToolRounds = 5,
61+
maxTokensPerRound = maxTokens,
62+
temperature = temperature
63+
),
64+
decode = { tokenId -> tokenizer.decode(tokenId) }
65+
)
66+
67+
val systemPrompt = "You are a helpful assistant with access to tools."
68+
val messages = mutableListOf(
69+
ChatMessage(role = ChatRole.SYSTEM, content = systemPrompt),
70+
ChatMessage(role = ChatRole.USER, content = prompt)
71+
)
72+
73+
return agentLoop.runWithEncoder(
74+
messages = messages,
75+
encode = { text -> tokenizer.encode(text) },
76+
listener = listener
77+
)
78+
}
79+
80+
/**
81+
* Create an [AgentLoop] configured for this session.
82+
*/
83+
public fun createAgentLoop(
84+
toolRegistry: ToolRegistry,
85+
maxTokens: Int = 512,
86+
temperature: Float = 0.7f
87+
): AgentLoop<T> {
88+
return AgentLoop(
89+
runtime = runtime,
90+
template = template,
91+
toolRegistry = toolRegistry,
92+
eosTokenId = tokenizer.eosTokenId,
93+
config = AgentConfig(
94+
maxToolRounds = 5,
95+
maxTokensPerRound = maxTokens,
96+
temperature = temperature
97+
),
98+
decode = { tokenId -> tokenizer.decode(tokenId) }
99+
)
100+
}
101+
102+
/** The resolved chat template for this session. */
103+
public val chatTemplate: ChatTemplate get() = template
104+
105+
/** The resolved tool calling provider family name. */
106+
public val providerFamily: String get() = provider.family
107+
108+
/** Encode text to token IDs using this session's tokenizer. */
109+
public fun encode(text: String): IntArray = tokenizer.encode(text)
110+
111+
/** Decode a token ID to text using this session's tokenizer. */
112+
public fun decode(tokenId: Int): String = tokenizer.decode(tokenId)
113+
}

llm-core/src/commonMain/kotlin/sk/ainet/apps/llm/Tokenizer.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,13 @@ interface Tokenizer {
44
fun encode(text: String): IntArray
55
fun decode(tokens: IntArray): String
66
fun decode(token: Int): String
7+
8+
/** End-of-sequence token ID. */
9+
val eosTokenId: Int
10+
11+
/** Beginning-of-sequence token ID. */
12+
val bosTokenId: Int
13+
14+
/** Total vocabulary size. */
15+
val vocabSize: Int
716
}

llm-core/src/commonMain/kotlin/sk/ainet/apps/llm/tokenizer/HuggingFaceBPETokenizer.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@ public class HuggingFaceBPETokenizer internal constructor(
1515
private val vocab: List<String>,
1616
private val tokenToId: Map<String, Int>,
1717
private val scores: FloatArray,
18-
private val bosTokenId: Int,
19-
private val eosTokenId: Int,
18+
override val bosTokenId: Int,
19+
override val eosTokenId: Int,
2020
private val unkTokenId: Int,
2121
private val addBosToken: Boolean,
2222
private val addEosToken: Boolean
2323
) : Tokenizer {
2424

25-
public val vocabSize: Int get() = vocab.size
25+
override val vocabSize: Int get() = vocab.size
2626

2727
override fun encode(text: String): IntArray {
2828
if (text.isEmpty()) return intArrayOf()

llm-inference/bert/src/commonMain/kotlin/sk/ainet/models/bert/HuggingFaceTokenizer.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ public class HuggingFaceTokenizer private constructor(
4040
private val maxLength: Int = 512
4141
) : Tokenizer {
4242

43+
override val eosTokenId: Int get() = tokenToId[SEP_TOKEN] ?: 102
44+
override val bosTokenId: Int get() = tokenToId[CLS_TOKEN] ?: 101
45+
4346
public companion object {
4447
private const val CLS_TOKEN = "[CLS]"
4548
private const val SEP_TOKEN = "[SEP]"
@@ -112,7 +115,7 @@ public class HuggingFaceTokenizer private constructor(
112115
private val sepId: Int = tokenToId[SEP_TOKEN] ?: error("Vocab missing $SEP_TOKEN")
113116
private val unkId: Int = tokenToId[UNK_TOKEN] ?: error("Vocab missing $UNK_TOKEN")
114117

115-
public val vocabSize: Int get() = tokenToId.size
118+
override val vocabSize: Int get() = tokenToId.size
116119

117120
/**
118121
* Encode text into token IDs with [CLS] and [SEP] tokens.

llm-inference/voxtral/src/commonMain/kotlin/sk/ainet/models/voxtral/TekkenTokenizerAdapter.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ public class TekkenTokenizerAdapter(
2525

2626
override fun decode(token: Int): String = tekken.decode(token)
2727

28+
override val eosTokenId: Int = 2
29+
override val bosTokenId: Int = 1
30+
override val vocabSize: Int get() = 32768 // Tekken default
31+
2832
public companion object {
2933
/**
3034
* Parse a tekken.json string and return a [Tokenizer] instance.

llm-runtime/kllama/src/commonMain/kotlin/sk/ainet/apps/kllama/GGUFTokenizer.kt

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ import sk.ainet.io.gguf.StreamingGGUFReader
2424
class GGUFTokenizer private constructor(
2525
private val vocab: List<String>,
2626
private val scores: FloatArray,
27-
private val bosTokenId: Int,
28-
private val eosTokenId: Int,
27+
private val _bosTokenId: Int,
28+
private val _eosTokenId: Int,
2929
private val unkTokenId: Int,
3030
private val strategy: TokenizerStrategy
3131
) : Tokenizer {
@@ -516,16 +516,18 @@ class GGUFTokenizer private constructor(
516516
}
517517
}
518518

519-
val vocabSize: Int get() = vocab.size
520-
521519
/** The detected tokenizer type/strategy in use */
522520
val tokenizerType: TokenizerType get() = strategy.type
523521

524-
/** The BOS (beginning of sentence) token ID */
525-
val bosId: Int get() = bosTokenId
522+
override val bosTokenId: Int get() = _bosTokenId
523+
override val eosTokenId: Int get() = _eosTokenId
524+
override val vocabSize: Int get() = vocab.size
525+
526+
@Deprecated("Use eosTokenId", replaceWith = ReplaceWith("eosTokenId"))
527+
val eosId: Int get() = _eosTokenId
526528

527-
/** The EOS (end of sentence) token ID */
528-
val eosId: Int get() = eosTokenId
529+
@Deprecated("Use bosTokenId", replaceWith = ReplaceWith("bosTokenId"))
530+
val bosId: Int get() = _bosTokenId
529531

530532
// Build reverse lookup for encoding
531533
private val tokenToId: Map<String, Int> by lazy {

llm-runtime/kllama/src/commonMain/kotlin/sk/ainet/apps/kllama/TokenizerImpl.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import sk.ainet.apps.llm.Tokenizer
55
class TokenizerImpl(
66
private val vocab: Array<String?>,
77
private val vocabScores: FloatArray,
8+
override val bosTokenId: Int = 1,
9+
override val eosTokenId: Int = 2,
810
) : Tokenizer {
911

10-
private val vocabSize = vocab.size
12+
override val vocabSize: Int = vocab.size
1113
// ----------------------------------------------------------------------------
1214
// byte pair encoding (BPE) tokenizer, encodes strings into tokens so we can prompt
1315
private fun strLookup(str: String, vocabSize: Int): Int {

llm-runtime/kllama/src/jvmMain/kotlin/sk/ainet/apps/kllama/chat/java/JavaAgentLoop.kt

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,7 @@ public class JavaAgentLoop private constructor(
5151
runtime = session.runtime,
5252
template = template,
5353
toolRegistry = toolRegistry,
54-
eosTokenId = session.tokenizer.let {
55-
if (it is sk.ainet.apps.kllama.GGUFTokenizer) it.eosId else 2
56-
},
54+
eosTokenId = session.tokenizer.eosTokenId,
5755
config = config,
5856
decode = { session.tokenizer.decode(it) }
5957
)
@@ -81,9 +79,7 @@ public class JavaAgentLoop private constructor(
8179
runtime = session.runtime,
8280
template = template,
8381
toolRegistry = toolRegistry,
84-
eosTokenId = session.tokenizer.let {
85-
if (it is sk.ainet.apps.kllama.GGUFTokenizer) it.eosId else 2
86-
},
82+
eosTokenId = session.tokenizer.eosTokenId,
8783
config = config,
8884
decode = { session.tokenizer.decode(it) }
8985
)

0 commit comments

Comments
 (0)