@moikapy/lich 0.3.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.
- package/CHANGELOG.md +24 -0
- package/README.md +186 -0
- package/dist/chunk-P52U5M3L.js +3431 -0
- package/dist/chunk-P52U5M3L.js.map +1 -0
- package/dist/chunk-ZVK3MUPC.js +7 -0
- package/dist/chunk-ZVK3MUPC.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +409 -0
- package/dist/cli.js.map +1 -0
- package/dist/gateway-CWPVIU3W.js +752 -0
- package/dist/gateway-CWPVIU3W.js.map +1 -0
- package/dist/index.d.ts +542 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/tui-V7ATLIKW.js +430 -0
- package/dist/tui-V7ATLIKW.js.map +1 -0
- package/docs/.vitepress/config.mts +55 -0
- package/docs/architecture/agent-loop.md +234 -0
- package/docs/architecture/extending.md +284 -0
- package/docs/architecture/overview.md +188 -0
- package/docs/architecture/plugins.md +91 -0
- package/docs/architecture/providers.md +273 -0
- package/docs/architecture/tools.md +180 -0
- package/docs/design/council/architecture-review.md +47 -0
- package/docs/design/council/security-review.md +39 -0
- package/docs/design/council/simplicity-review.md +45 -0
- package/docs/design/self-improvement-loop.md +166 -0
- package/docs/getting-started.md +133 -0
- package/docs/index.md +68 -0
- package/docs/user-guide/cli.md +182 -0
- package/docs/user-guide/gateway.md +168 -0
- package/docs/user-guide/library.md +181 -0
- package/docs/user-guide/plugins.md +120 -0
- package/docs/user-guide/tui.md +76 -0
- package/package.json +54 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# The Agent Loop
|
|
2
|
+
|
|
3
|
+
`run_conversation` in [`src/agent/loop.ts`](../../src/agent/loop.ts) is the
|
|
4
|
+
heart of lich: a think-act-observe loop that drives a chat model, executes the
|
|
5
|
+
tools it requests, feeds results back, and compresses history when the context
|
|
6
|
+
budget demands it. It depends only on two narrow structural interfaces -
|
|
7
|
+
`ChatFn` and `ToolRunner` - never on the provider router or the tool executor
|
|
8
|
+
directly (see [overview](./overview.md#the-dependency-inversion-story)).
|
|
9
|
+
|
|
10
|
+
## State machine of one turn
|
|
11
|
+
|
|
12
|
+
```mermaid
|
|
13
|
+
stateDiagram-v2
|
|
14
|
+
[*] --> TurnStart
|
|
15
|
+
TurnStart : turn_start emitted
|
|
16
|
+
TurnStart --> abort_if_signal
|
|
17
|
+
state abort_if_signal <<choice>>
|
|
18
|
+
abort_if_signal --> Aborted : signal already aborted
|
|
19
|
+
abort_if_signal --> CompressCheck : not aborted
|
|
20
|
+
CompressCheck : compression needed?
|
|
21
|
+
CompressCheck --> Compress : estimate >= budget * threshold
|
|
22
|
+
CompressCheck --> LlmStart : below threshold
|
|
23
|
+
Compress --> LlmStart
|
|
24
|
+
Compress : compress_start and compress_end
|
|
25
|
+
LlmStart : llm_start emitted
|
|
26
|
+
LlmStart --> LlmEnd : one chat call
|
|
27
|
+
LlmEnd : llm_end emitted, assistant pushed
|
|
28
|
+
LlmEnd --> has_calls
|
|
29
|
+
state has_calls <<choice>>
|
|
30
|
+
has_calls --> RunTools : tool calls present
|
|
31
|
+
has_calls --> Final : no tool calls
|
|
32
|
+
RunTools : tool_call_start and tool_call_end pairs
|
|
33
|
+
RunTools --> TurnStart : next turn
|
|
34
|
+
Final : final then turn_end
|
|
35
|
+
Final --> [*]
|
|
36
|
+
Aborted : error event, stopped_reason = aborted
|
|
37
|
+
Aborted --> [*]
|
|
38
|
+
note right of Final
|
|
39
|
+
budget path: after the last allowed turn
|
|
40
|
+
budget_exhausted then turn_end, stopped_reason = budget
|
|
41
|
+
end note
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## The `run_conversation` contract
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
export interface LoopOutcome {
|
|
48
|
+
messages: Message[];
|
|
49
|
+
final: AssistantMessage | undefined;
|
|
50
|
+
result: ChatResult | undefined;
|
|
51
|
+
turns_used: number;
|
|
52
|
+
stopped_reason: "final" | "budget" | "aborted";
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
(src/agent/loop.ts)
|
|
57
|
+
|
|
58
|
+
**Parameters** (`LoopParams`): `system_prompt?`, `max_turns` (required),
|
|
59
|
+
`temperature?`, `max_tokens?`, `context_budget_tokens?`,
|
|
60
|
+
`compress_threshold?`, `signal?`.
|
|
61
|
+
|
|
62
|
+
**`max_turns` is an LLM-call budget, not a tool budget.** Each iteration makes
|
|
63
|
+
exactly one LLM call; the tool executions between turns are free - a turn that
|
|
64
|
+
calls three tools still consumes one turn. A model that always asks for tools
|
|
65
|
+
will run out of budget even though the tools all succeeded.
|
|
66
|
+
|
|
67
|
+
**Stopping conditions**, exactly as implemented:
|
|
68
|
+
|
|
69
|
+
| `stopped_reason` | When it fires |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `"final"` | A turn's assistant message has an empty `tool_calls` array (or none). The message is emitted as `final` and the loop returns immediately, even if it is turn 1. |
|
|
72
|
+
| `"aborted"` | The abort check at the **top** of a turn sees `signal.aborted` before any LLM call. The loop emits an `error` event carrying `new DOMException("agent loop aborted", "AbortError")` and returns with `result: undefined`. |
|
|
73
|
+
| `"budget"` | The `for` loop over `1..max_turns` completes while every turn kept requesting tools. After the last allowed turn, `budget_exhausted` then `turn_end` are emitted. |
|
|
74
|
+
|
|
75
|
+
Note the asymmetry: abort is checked only at the loop top, so a signal that
|
|
76
|
+
fires mid-turn still lets the current LLM call and tool batch finish before
|
|
77
|
+
the next turn notices. Provider-level aborts can surface earlier as thrown
|
|
78
|
+
errors (see [Error propagation](#error-propagation) below).
|
|
79
|
+
|
|
80
|
+
`turns_used` counts LLM calls actually made: `turn` on the final path,
|
|
81
|
+
`turn - 1` on abort (the turn that was about to start never ran), and
|
|
82
|
+
`max_turns` on the budget path.
|
|
83
|
+
|
|
84
|
+
## Event stream
|
|
85
|
+
|
|
86
|
+
All events flow through `AgentEmitter` (`src/agent/events.ts`), a typed
|
|
87
|
+
emitter over a discriminated `AgentEvent` union. Handler errors are logged and
|
|
88
|
+
swallowed; handlers may unsubscribe mid-emit (the emitter iterates a snapshot).
|
|
89
|
+
|
|
90
|
+
| Event | Payload | Emitted when | Order guarantee |
|
|
91
|
+
| --- | --- | --- | --- |
|
|
92
|
+
| `turn_start` | `{ turn }` | Start of each iteration | First event of a turn |
|
|
93
|
+
| `compress_start` | `{ estimated_tokens }` | Compression begins | After `turn_start`, before `llm_start` |
|
|
94
|
+
| `compress_end` | `{ summary_chars }` | Compression rewrote history | Always paired after `compress_start` |
|
|
95
|
+
| `llm_start` | `{ turn }` | Just before the chat call | Exactly one per LLM call |
|
|
96
|
+
| `llm_end` | `{ turn, result }` | Chat call resolved | Pairs with `llm_start`; never fires if the call throws |
|
|
97
|
+
| `tool_call_start` | `{ turn, call }` | Before each tool executes | After `llm_end`, sequential per call |
|
|
98
|
+
| `tool_call_end` | `{ turn, call, result }` | After that tool resolves | Pairs with its `tool_call_start` |
|
|
99
|
+
| `final` | `{ message, result }` | A turn produced no tool calls | At most once per run; only on a real final |
|
|
100
|
+
| `budget_exhausted` | `{ turns_used }` | Loop exits without a final | Follows the last `tool_call_end` |
|
|
101
|
+
| `turn_end` | `{ turn }` | Last event of a turn | After `final` **or** after `budget_exhausted` |
|
|
102
|
+
| `error` | `{ error }` | Chat call failed (logged, rethrown) or loop aborted | Followed by no other events |
|
|
103
|
+
|
|
104
|
+
Verified ordering from `test/loop.test.ts` scenario A:
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
turn_start, llm_start, llm_end, tool_call_start, tool_call_end,
|
|
108
|
+
turn_start, llm_start, llm_end, final, turn_end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## History semantics
|
|
112
|
+
|
|
113
|
+
`run_conversation` never mutates the caller's array. `seed_system_prompt`
|
|
114
|
+
copies it (`[...messages]`) and then applies the system prompt rules:
|
|
115
|
+
|
|
116
|
+
1. **No system message in history** - prepend `{ role: "system", content }`.
|
|
117
|
+
2. **A system message with identical content** - return the copy unchanged.
|
|
118
|
+
3. **A system message with different content** - replace it in place.
|
|
119
|
+
|
|
120
|
+
During the run, the history array *is* mutated in place (`push` for assistant
|
|
121
|
+
and tool messages, refill for compression) - the loop treats it as its own
|
|
122
|
+
working copy. Compression rewrites it via `history.length = 0` followed by a
|
|
123
|
+
refill from the compressed outcome, so callers holding the returned `messages`
|
|
124
|
+
array see the final, post-compression transcript.
|
|
125
|
+
|
|
126
|
+
### Compression gating
|
|
127
|
+
|
|
128
|
+
Compression is skipped when any of these hold:
|
|
129
|
+
|
|
130
|
+
- `context_budget_tokens` is undefined (the loop calls `compress_if_needed`,
|
|
131
|
+
which returns immediately).
|
|
132
|
+
- `should_compress` is false: `estimate_messages_tokens(history) <
|
|
133
|
+
budget * threshold` (threshold defaults to 0.8).
|
|
134
|
+
- The history has **8 or fewer non-system messages** (`KEEP_RECENT_TURNS`).
|
|
135
|
+
Compressing would have nothing left to summarize: the partition below keeps
|
|
136
|
+
the last 8 verbatim, and a run with only that many messages has no `older`
|
|
137
|
+
segment.
|
|
138
|
+
|
|
139
|
+
## Compression internals
|
|
140
|
+
|
|
141
|
+
`compress_messages` (`src/context/compressor.ts`) partitions the history:
|
|
142
|
+
|
|
143
|
+
```text
|
|
144
|
+
[ system messages ... | older non-system | recent 8 non-system ]
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The `older` slice is rendered to a transcript
|
|
148
|
+
(`[role] content tool_calls=...` per line, truncated at 24 000 chars) and sent
|
|
149
|
+
to the same `ChatFn` with a fixed system prompt:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
export const COMPRESSION_SYSTEM_PROMPT =
|
|
153
|
+
"You compress agent conversation history into terse factual summaries. Preserve: goals, decisions, file paths, commands run, errors, open questions. Output plain text only.";
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
(src/context/compressor.ts)
|
|
157
|
+
|
|
158
|
+
The summary comes back as a single `user` message:
|
|
159
|
+
`[context summary of earlier turns]\n<summary>\n[end summary]`. The result is
|
|
160
|
+
`[...system, summary, ...recent]`.
|
|
161
|
+
|
|
162
|
+
**Best-effort fallback.** If the summarizer call throws (including
|
|
163
|
+
`ProviderError`), the failure is logged at warn level and the original
|
|
164
|
+
messages are returned unchanged (`summary_chars: 0`). Compression is an
|
|
165
|
+
optimization, never a correctness requirement. The next turn may still exceed
|
|
166
|
+
the budget; a provider-side context overflow then surfaces as an `overflow`
|
|
167
|
+
error (see [providers](./providers.md#error-taxonomy)).
|
|
168
|
+
|
|
169
|
+
**Token estimate.** `estimate_text_tokens` is `ceil(chars / 4)`; assistant
|
|
170
|
+
messages add the JSON-stringified `tool_calls`, tool messages add a flat
|
|
171
|
+
8-token overhead (`src/context/tokens.ts`). The estimate is intentionally
|
|
172
|
+
coarse - see the trade-off note in [overview](./overview.md#design-trade-offs).
|
|
173
|
+
|
|
174
|
+
## Sessions
|
|
175
|
+
|
|
176
|
+
`src/session/store.ts` appends JSONL records to
|
|
177
|
+
`<session_dir>/<timestamp>-<counter>[-<label>].jsonl`:
|
|
178
|
+
|
|
179
|
+
```jsonc
|
|
180
|
+
// kind "meta": arbitrary metadata records
|
|
181
|
+
{ "ts": "2026-09-14T05:00:00.000Z", "kind": "meta",
|
|
182
|
+
"meta": { "event": "run_start", "input_chars": 24, "history_size": 3 } }
|
|
183
|
+
// kind "message": one transcript message
|
|
184
|
+
{ "ts": "2026-09-14T05:00:01.000Z", "kind": "message",
|
|
185
|
+
"message": { "role": "assistant", "content": "done" } }
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
What gets persisted, per run (`Agent.persist_session`): a `run_start` meta
|
|
189
|
+
record, one `message` record per outcome message (including the seeded system
|
|
190
|
+
message and tool messages), and a `budget_exhausted` meta record when the run
|
|
191
|
+
stopped on budget. Everything is best-effort: any error logs a warning and
|
|
192
|
+
returns `session_path: undefined` instead of failing the run.
|
|
193
|
+
|
|
194
|
+
`read_session_messages(path)` parses a file back into `Message[]`: per line it
|
|
195
|
+
JSON-parses leniently, accepts only records with a `kind: "message"`-shaped
|
|
196
|
+
`message` whose `role` is one of the four known roles, and silently skips
|
|
197
|
+
everything else. Missing files parse to an empty array.
|
|
198
|
+
|
|
199
|
+
## Error propagation
|
|
200
|
+
|
|
201
|
+
The loop has an explicit asymmetry between provider errors and tool errors:
|
|
202
|
+
|
|
203
|
+
- **Provider errors are fatal and rethrown.** `call_chat` catches, logs,
|
|
204
|
+
emits `error`, and rethrows - even `ProviderError`. The loop never swallows
|
|
205
|
+
them. This is deliberate: retry/failover policy lives *below* the loop
|
|
206
|
+
(in the router and `run_with_retries`), so by the time an error reaches the
|
|
207
|
+
loop it has exhausted every provider and every retry. Pretending otherwise
|
|
208
|
+
would leave the caller with a half-run history and no way to distinguish
|
|
209
|
+
"model failed" from "model answered".
|
|
210
|
+
|
|
211
|
+
- **Tool errors are data, not exceptions.** `run_tool_calls` maps a failed
|
|
212
|
+
`ToolResult` onto a `tool` message with `is_error: true` and content built
|
|
213
|
+
by `format_tool_result_content`:
|
|
214
|
+
`JSON.stringify({ ok: false, output, error })` on failure, raw `output`
|
|
215
|
+
otherwise. The model sees the error text and can adapt (retry with
|
|
216
|
+
different arguments, tell the user, pick another tool). A tool failing
|
|
217
|
+
never stops the run.
|
|
218
|
+
|
|
219
|
+
The executor enforces the same shape one level down: it never throws either
|
|
220
|
+
(see [tools](./tools.md#executor-semantics)). Together this means the only
|
|
221
|
+
exceptions escaping `run_conversation` are provider/network failures after
|
|
222
|
+
full failover, and the documented abort error from the loop top.
|
|
223
|
+
|
|
224
|
+
### Abort semantics
|
|
225
|
+
|
|
226
|
+
- The loop checks `params.signal?.aborted` at the top of each turn; an abort
|
|
227
|
+
mid-turn is only observed on the next turn boundary.
|
|
228
|
+
- A chat call aborted mid-flight throws (fetch `AbortError`), which after
|
|
229
|
+
classification/retry surfaces from `call_chat` as a thrown error - the loop
|
|
230
|
+
does not convert it into a stopped_reason; callers catching `AbortError`
|
|
231
|
+
see it directly.
|
|
232
|
+
- The router stops walking to the next provider as soon as the caller signal
|
|
233
|
+
is aborted, and retries abort out of their backoff sleep
|
|
234
|
+
(`src/providers/failover.ts`, `src/providers/router.ts`).
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# Extending Lich
|
|
2
|
+
|
|
3
|
+
Hands-on recipes for the three extension axes: builtin tools, providers, and
|
|
4
|
+
gateway platforms - plus the TUI internals, the conventions that hold the
|
|
5
|
+
codebase together, and how the test suite is organized. Read
|
|
6
|
+
[tools](./tools.md) and [providers](./providers.md) first for the contracts.
|
|
7
|
+
|
|
8
|
+
## Add a builtin tool
|
|
9
|
+
|
|
10
|
+
A builtin is one file in `src/tools/builtin/` following the
|
|
11
|
+
[`terminal.ts`](../../src/tools/builtin/terminal.ts) shape, plus one line in
|
|
12
|
+
`src/tools/builtin/index.ts`. Walkthrough with a complete, useful example:
|
|
13
|
+
a `hash_text` tool (SHA-256 through Node's `crypto`).
|
|
14
|
+
|
|
15
|
+
**Step 1 - create `src/tools/builtin/hash_text.ts`:**
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import type { JsonSchemaObject } from "../../util/json_schema.js";
|
|
20
|
+
import { capture_errors, require_string_arg } from "../guard.js";
|
|
21
|
+
import type { Tool } from "../types.js";
|
|
22
|
+
|
|
23
|
+
const parameters: JsonSchemaObject = {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
text: { type: "string", description: "Text to hash with sha256" },
|
|
27
|
+
algorithm: { type: "string", description: "sha256 (default) or sha1 or md5" },
|
|
28
|
+
},
|
|
29
|
+
required: ["text"],
|
|
30
|
+
additionalProperties: false,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const ALGORITHMS = new Set(["sha256", "sha1", "md5"]);
|
|
34
|
+
|
|
35
|
+
async function hash_text(args: Record<string, unknown>): Promise<string> {
|
|
36
|
+
const text = require_string_arg(args, "text");
|
|
37
|
+
const algorithm = ALGORITHMS.has(String(args.algorithm)) === true
|
|
38
|
+
? String(args.algorithm)
|
|
39
|
+
: "sha256";
|
|
40
|
+
return createHash(algorithm).update(text, "utf8").digest("hex");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const hash_text_tool: Tool = {
|
|
44
|
+
name: "hash_text",
|
|
45
|
+
description: "Hash text with sha256 (default), sha1, or md5.",
|
|
46
|
+
parameters,
|
|
47
|
+
execute: async (args) => capture_errors(async () => ({ ok: true, output: await hash_text(args) })),
|
|
48
|
+
};
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Note the conventions this mirrors from `terminal.ts`: module-level
|
|
52
|
+
`parameters` constant, argument reads through the guard coercion helpers,
|
|
53
|
+
one pure `hash_text` function the test can target, and `execute` wrapped in
|
|
54
|
+
`capture_errors` so the never-throw contract holds even if a guard throws.
|
|
55
|
+
|
|
56
|
+
**Step 2 - register in `src/tools/builtin/index.ts`:** add the import and add
|
|
57
|
+
`hash_text_tool` to the `builtin_tools` array. `register_builtin_tools`
|
|
58
|
+
registers the whole set via `builtin_toolset`; nothing else changes.
|
|
59
|
+
|
|
60
|
+
**Step 3 - test it** in `test/tools.test.ts` (or a sibling), following the
|
|
61
|
+
existing pattern of calling the tool directly with a hand-built context:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { hash_text_tool } from "../src/tools/builtin/hash_text.js";
|
|
65
|
+
|
|
66
|
+
it("hash_text returns the sha256 hex digest", async () => {
|
|
67
|
+
const result = await hash_text_tool.execute(
|
|
68
|
+
{ text: "lich" },
|
|
69
|
+
{ work_dir: "/tmp", env: {} },
|
|
70
|
+
);
|
|
71
|
+
expect(result.ok).toBe(true);
|
|
72
|
+
expect(result.output).toBe(
|
|
73
|
+
"1e3c2f5c4ba9d476a3b93e2c1a7f2b8d9e0c1a2b3c4d5e6f708192a3b4c5d6e7",
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
(The digest literal is illustrative - compute the real one once and freeze it
|
|
79
|
+
into the test.) The executor-level suite in `test/tools.test.ts` covers the
|
|
80
|
+
shared machinery (timeout, unknown tool, clamping), so a new builtin only
|
|
81
|
+
needs mapping and behavior tests like the one above.
|
|
82
|
+
|
|
83
|
+
If the tool needs the LLM-side wiring exercised too, reuse the loop test
|
|
84
|
+
fakes: a `ToolRunner` stub in `test/loop.test.ts` style, or run the full
|
|
85
|
+
public API with the mock-provider pattern from `test/e2e.test.ts`.
|
|
86
|
+
|
|
87
|
+
## Add a provider
|
|
88
|
+
|
|
89
|
+
Implement `LLMProvider` (see [providers](./providers.md#the-llmprovider-contract))
|
|
90
|
+
and register it in exactly **three touch points** - the same three that
|
|
91
|
+
adding ollama required:
|
|
92
|
+
|
|
93
|
+
1. **The client file.** Use [`ollama.ts`](../../src/providers/ollama.ts) as
|
|
94
|
+
the template. Its structure:
|
|
95
|
+
|
|
96
|
+
- Typed wire DTOs (request/response), never `any`.
|
|
97
|
+
- Small pure mapping helpers: `to_*_messages` (our `Message` -> wire),
|
|
98
|
+
`assistant_to_wire` / `tool_to_wire`, `to_*_tools`, `build_request_body`.
|
|
99
|
+
- The fetch skeleton shared by all clients: resolve auth, `build_endpoint`,
|
|
100
|
+
`do_fetch` (fetch throws -> `network`), `to_http_error` (non-OK ->
|
|
101
|
+
classified kind + `Retry-After`), `read_success_json` (unparseable 2xx ->
|
|
102
|
+
`bad_request`).
|
|
103
|
+
- An error mapper: a `status_to_error_kind` function implementing the
|
|
104
|
+
taxonomy table from [providers](./providers.md#error-taxonomy), plus a
|
|
105
|
+
body-sniffing regex for `overflow` on 400s.
|
|
106
|
+
- A factory export (`create_ollama_provider`) if construction may grow.
|
|
107
|
+
|
|
108
|
+
2. **The `ProviderConfig` union.** Add the kind string to the `kind` union in
|
|
109
|
+
`src/providers/types.ts` (ProviderConfig), and any kind-specific fields
|
|
110
|
+
next to `think` / `keep_alive` (ollama-only fields are documented as such).
|
|
111
|
+
|
|
112
|
+
3. **The router kind map.** One branch in `build_provider`
|
|
113
|
+
(src/providers/router.ts):
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
function build_provider(config: ProviderConfig): LLMProvider {
|
|
117
|
+
if (config.kind === "anthropic") {
|
|
118
|
+
return new AnthropicProvider(config);
|
|
119
|
+
}
|
|
120
|
+
if (config.kind === "ollama") {
|
|
121
|
+
return create_ollama_provider(config);
|
|
122
|
+
}
|
|
123
|
+
return new OpenAICompatProvider(config);
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
(src/providers/router.ts)
|
|
128
|
+
|
|
129
|
+
4. **The zod config enum.** `kind: z.enum(["openai_compat", "anthropic",
|
|
130
|
+
"ollama"])` in `src/agent/config.ts` must accept the new string or config
|
|
131
|
+
parsing rejects it before the router ever sees it.
|
|
132
|
+
|
|
133
|
+
(Strictly that is four small edits across three files plus the client -
|
|
134
|
+
client file, `ProviderConfig` union, router kind map, zod enum. Ollama
|
|
135
|
+
needed no other changes: failover, retry classification, sessions, and the
|
|
136
|
+
loop all work against the interface, not the client.)
|
|
137
|
+
|
|
138
|
+
**Testing via `fetch_fn`.** Copy the `mock_fetch` helper from
|
|
139
|
+
`test/providers.test.ts` (shown in [providers](./providers.md#testing-pattern)):
|
|
140
|
+
feed your client canned `Response` bodies, assert the captured request's URL,
|
|
141
|
+
headers, and JSON body, then assert the parsed `ChatResult`. Cover at minimum:
|
|
142
|
+
the message/tool wire mapping, one error status per taxonomy kind, and any
|
|
143
|
+
provider quirk you discovered (ollama's tests do exactly this for
|
|
144
|
+
`done_reason` and 200-with-error).
|
|
145
|
+
|
|
146
|
+
## Add a gateway platform
|
|
147
|
+
|
|
148
|
+
A platform adapter is an implementation of the `PlatformAdapter` contract
|
|
149
|
+
([`src/gateway/types.ts`](../../src/gateway/types.ts)):
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
export interface PlatformAdapter {
|
|
153
|
+
readonly name: string;
|
|
154
|
+
start(): Promise<void>;
|
|
155
|
+
stop(): Promise<void>;
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Inbound flow: the adapter normalizes a platform event into
|
|
160
|
+
`(platform, chat_id, user_id, text)` and calls `run_inbound_message` with the
|
|
161
|
+
shared `InboundHandler`; the handler is the bus's `handle`, which resolves to
|
|
162
|
+
the reply text; the adapter then sends the reply through the platform's send
|
|
163
|
+
API. The bus owns history, serialization, and error sanitization - adapters
|
|
164
|
+
stay thin.
|
|
165
|
+
|
|
166
|
+
Recipe (mirroring [`telegram.ts`](../../src/gateway/telegram.ts), the
|
|
167
|
+
simplest real adapter):
|
|
168
|
+
|
|
169
|
+
1. **Write `src/gateway/<platform>.ts`** exporting
|
|
170
|
+
`create_<platform>_adapter(params: AdapterParams): PlatformAdapter`.
|
|
171
|
+
Inside, receive messages however the platform delivers them (polling,
|
|
172
|
+
WebSocket, HTTP push), normalize them, then:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
const reply = await run_inbound_message(
|
|
176
|
+
params.handle_message, "platform-name", chat_id, user_id, text,
|
|
177
|
+
);
|
|
178
|
+
await send_reply(reply);
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
2. **Register it in the runner.** Add the platform to `is_known_platform` and
|
|
182
|
+
to the `create_platform_adapter` switch in `src/gateway/runner.ts`. That is
|
|
183
|
+
the only registration point; `build_adapters` handles the rest.
|
|
184
|
+
|
|
185
|
+
3. **Handle missing credentials with the idle-adapter pattern.** When the
|
|
186
|
+
platform's token env var is absent, return
|
|
187
|
+
`create_idle_adapter("platform", "ENV_VAR not set")` instead of throwing.
|
|
188
|
+
The adapter logs once why it is idle and no-ops on start/stop, so the
|
|
189
|
+
gateway keeps serving the platforms that *do* have credentials -
|
|
190
|
+
`run_gateway` only needs one valid platform.
|
|
191
|
+
|
|
192
|
+
4. **Reuse the shared helpers**: `open_socket` for WebSocket platforms (the
|
|
193
|
+
structural `RawSocket` type works on both Bun and Node runtimes),
|
|
194
|
+
`sanitize_agent_error` for reply strings on failure, and the whitespace
|
|
195
|
+
splitter `split_text` from `src/gateway/format.ts` for size-capped
|
|
196
|
+
transports (telegram caps at 4096, discord at 2000, twitch at 512).
|
|
197
|
+
|
|
198
|
+
**Testing.** `test/gateway.test.ts` covers the webhook adapter over a real
|
|
199
|
+
`node:http` server (a `on_listening` test hook reports the bound port), the
|
|
200
|
+
bus with a fake agent factory, and pure parsers (`split_text`, Twitch IRC
|
|
201
|
+
line parsing). For a socket platform, inject a fake `RawSocket`
|
|
202
|
+
implementation exercising `onopen`/`onmessage`, or extract a pure parse
|
|
203
|
+
function (like `parse_irc_line`) and unit-test it directly - the twitch
|
|
204
|
+
adapter does exactly that.
|
|
205
|
+
|
|
206
|
+
## TUI internals
|
|
207
|
+
|
|
208
|
+
The TUI splits cleanly in two:
|
|
209
|
+
|
|
210
|
+
- **`src/tui/state.ts`** - pure logic, zero ink/react imports: the `UiState`
|
|
211
|
+
shape, the `apply_event` reducer, slash-command parsing
|
|
212
|
+
(`parse_command`), and transcript formatters. Unit-tested without a TTY in
|
|
213
|
+
`test/tui.test.ts`.
|
|
214
|
+
- **`src/tui/app.tsx`** (+ `message_view.tsx`, `command_bar.tsx`,
|
|
215
|
+
`status_bar.tsx`) - ink components that hold the only mutable React state,
|
|
216
|
+
feed agent events into `apply_event`, and render blocks.
|
|
217
|
+
|
|
218
|
+
Why: ink components need a TTY and make tests slow and brittle; the reducer
|
|
219
|
+
needs neither. The event -> state table is the whole behavioral contract:
|
|
220
|
+
|
|
221
|
+
| Event | State change |
|
|
222
|
+
| --- | --- |
|
|
223
|
+
| `llm_start` | `phase: "thinking"`, `active_tool: undefined` |
|
|
224
|
+
| `llm_end` | Accumulates `usage` from the result |
|
|
225
|
+
| `tool_call_start` | `phase: "tool"`, `active_tool` set |
|
|
226
|
+
| `tool_call_end` | `phase: "thinking"`, `active_tool` cleared; failed results set `last_error` |
|
|
227
|
+
| `turn_end` | `turns_used: event.turn` |
|
|
228
|
+
| `compress_start` | `compress_count` increments |
|
|
229
|
+
| `budget_exhausted` | `budget_exhausted: true` |
|
|
230
|
+
| `error` | `last_error` set from the error text |
|
|
231
|
+
|
|
232
|
+
`apply_run_result` folds the finished `AgentRunResult` back in (phase back to
|
|
233
|
+
`idle`, session path, abort notice). Design trade-off: state updates are
|
|
234
|
+
event-coarse - there is **no token streaming in the TUI**; users see phase
|
|
235
|
+
changes and completed tool rows rather than streaming text.
|
|
236
|
+
|
|
237
|
+
## Design constraints
|
|
238
|
+
|
|
239
|
+
These are architecture decisions, not style rules; they are what keeps the
|
|
240
|
+
codebase extensible:
|
|
241
|
+
|
|
242
|
+
- **No recursion.** Tree-shaped work (directory walking in `list_dir`,
|
|
243
|
+
`grep_files`; the retry loop in `failover.ts`) uses explicit stacks, queues,
|
|
244
|
+
and `while` loops. Deep directories and long retry chains cannot overflow
|
|
245
|
+
the stack.
|
|
246
|
+
- **Helpers and hooks stay under 60 lines.** Long functions are split into
|
|
247
|
+
small pure mapping helpers (see the provider clients: every mapping step is
|
|
248
|
+
a named, testable function).
|
|
249
|
+
- **Narrow structural interfaces at every seam.** `ChatFn`, `ToolRunner`,
|
|
250
|
+
`LLMProvider`, `PlatformAdapter`, `Tool` - consumers depend on shapes, not
|
|
251
|
+
concrete classes, so every piece is fake-able in tests.
|
|
252
|
+
- **Zero-dependency core.** Only `zod` (config) and `ink` (TUI) are runtime
|
|
253
|
+
dependencies. Network calls use the global `fetch`; HTTP serving uses
|
|
254
|
+
`node:http`; JSON handling is hand-rolled in `src/util/json.ts`. Nothing to
|
|
255
|
+
audit, nothing to break on upgrade.
|
|
256
|
+
- **Determinism where tests need it.** Backoff jitter is a fixed formula, not
|
|
257
|
+
`Math.random`; time-derived values (session ids, ollama tool-call ids) are
|
|
258
|
+
formatted, not sampled.
|
|
259
|
+
|
|
260
|
+
## Testing the harness
|
|
261
|
+
|
|
262
|
+
The suite runs with `bun test` (or `bun x vitest run`); it is fully offline -
|
|
263
|
+
every provider call, gateway socket, and HTTP exchange is mocked. Live
|
|
264
|
+
smokes (real OpenAI/Anthropic/Ollama calls, real platform bots) are manual,
|
|
265
|
+
by design.
|
|
266
|
+
|
|
267
|
+
| Test file | Covers |
|
|
268
|
+
| --- | --- |
|
|
269
|
+
| `test/loop.test.ts` | Loop scenarios A/B/C: tool turn then final, budget stop, abort; event ordering; history non-mutation. |
|
|
270
|
+
| `test/agent_config.test.ts` | Zod config parsing, defaults, derived `session_dir`. |
|
|
271
|
+
| `test/compressor.test.ts` | Token estimator, `should_compress` threshold math, summary partitioning. |
|
|
272
|
+
| `test/providers.test.ts` | OpenAI + Anthropic wire mapping, error taxonomy via `fetch_fn` injection. |
|
|
273
|
+
| `test/providers_ollama.test.ts` | Ollama quirks (tool-call presence vs `done_reason`, 200-with-error) and ollama failover. |
|
|
274
|
+
| `test/failover.test.ts` | Error classification, backoff formula, retry loop, router walk, failover ordering. |
|
|
275
|
+
| `test/tools.test.ts` | Guard helpers, registry, executor, and the filesystem builtins. |
|
|
276
|
+
| `test/tools_extra.test.ts` | HTTP tools, `process_list`, `disk_usage`, `env_get`. |
|
|
277
|
+
| `test/gateway.test.ts` | Bus serialization/history caps, webhook server, reply formatting, telegram/twitch splitting and parsing. |
|
|
278
|
+
| `test/tui.test.ts` | The pure TUI state machine: reducer, commands, formatters. |
|
|
279
|
+
| `test/cli_config.test.ts` | Config discovery, loading, flag overrides, template. |
|
|
280
|
+
| `test/e2e.test.ts` | Full runs through the public API (`run_agent`) with mock providers. |
|
|
281
|
+
|
|
282
|
+
When you extend the harness, add the test next to the seam you extended and
|
|
283
|
+
keep the network out of it - see the `fetch_fn` pattern in
|
|
284
|
+
[providers](./providers.md#testing-pattern) and the mock-socket notes above.
|