agent-mini 0.2.0__tar.gz → 0.3.1__tar.gz

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.
Files changed (65) hide show
  1. {agent_mini-0.2.0 → agent_mini-0.3.1}/.gitignore +1 -0
  2. {agent_mini-0.2.0 → agent_mini-0.3.1}/PKG-INFO +27 -5
  3. {agent_mini-0.2.0 → agent_mini-0.3.1}/README.md +26 -4
  4. agent_mini-0.3.1/docs/TECHNICAL_IMPLEMENTATION.md +496 -0
  5. agent_mini-0.3.1/evals/README.md +154 -0
  6. agent_mini-0.3.1/evals/fixtures/buggy_stack/pytest.ini +2 -0
  7. agent_mini-0.3.1/evals/fixtures/buggy_stack/src/stack.py +22 -0
  8. agent_mini-0.3.1/evals/fixtures/buggy_stack/tests/test_stack.py +24 -0
  9. agent_mini-0.3.1/evals/fixtures/py_project/pytest.ini +2 -0
  10. agent_mini-0.3.1/evals/fixtures/py_project/src/mymath.py +11 -0
  11. agent_mini-0.3.1/evals/fixtures/py_project/tests/test_mymath.py +16 -0
  12. agent_mini-0.3.1/evals/fixtures/small_repo/README.md +5 -0
  13. agent_mini-0.3.1/evals/fixtures/small_repo/src/config.py +2 -0
  14. agent_mini-0.3.1/evals/fixtures/small_repo/src/server.py +8 -0
  15. agent_mini-0.3.1/evals/run.py +512 -0
  16. agent_mini-0.3.1/evals/tasks/codebase_qa.yaml +14 -0
  17. agent_mini-0.3.1/evals/tasks/error_recovery.yaml +13 -0
  18. agent_mini-0.3.1/evals/tasks/fix_failing_test.yaml +14 -0
  19. agent_mini-0.3.1/evals/tasks/list_directory_basic.yaml +14 -0
  20. agent_mini-0.3.1/evals/tasks/multi_file_edit.yaml +16 -0
  21. agent_mini-0.3.1/evals/tasks/refactor_rename.yaml +15 -0
  22. agent_mini-0.3.1/evals/tasks/search_and_summarize.yaml +14 -0
  23. agent_mini-0.3.1/evals/tasks/shell_pipeline.yaml +14 -0
  24. agent_mini-0.3.1/evals/tasks/write_file_from_scratch.yaml +15 -0
  25. {agent_mini-0.2.0 → agent_mini-0.3.1}/pyproject.toml +1 -1
  26. {agent_mini-0.2.0 → agent_mini-0.3.1}/src/agent_mini/__init__.py +1 -1
  27. agent_mini-0.3.1/src/agent_mini/agent/context.py +112 -0
  28. {agent_mini-0.2.0 → agent_mini-0.3.1}/src/agent_mini/agent/loop.py +44 -36
  29. {agent_mini-0.2.0 → agent_mini-0.3.1}/src/agent_mini/agent/memory.py +25 -9
  30. {agent_mini-0.2.0 → agent_mini-0.3.1}/src/agent_mini/agent/token_estimator.py +11 -4
  31. {agent_mini-0.2.0 → agent_mini-0.3.1}/src/agent_mini/agent/tools.py +213 -84
  32. {agent_mini-0.2.0 → agent_mini-0.3.1}/src/agent_mini/channels/telegram.py +10 -9
  33. {agent_mini-0.2.0 → agent_mini-0.3.1}/src/agent_mini/cli.py +120 -20
  34. {agent_mini-0.2.0 → agent_mini-0.3.1}/src/agent_mini/providers/local.py +4 -1
  35. {agent_mini-0.2.0 → agent_mini-0.3.1}/src/agent_mini/providers/ollama.py +34 -3
  36. agent_mini-0.3.1/tests/test_context.py +120 -0
  37. {agent_mini-0.2.0 → agent_mini-0.3.1}/tests/test_loop.py +24 -0
  38. agent_mini-0.3.1/tests/test_providers.py +86 -0
  39. {agent_mini-0.2.0 → agent_mini-0.3.1}/tests/test_token_estimator.py +4 -0
  40. {agent_mini-0.2.0 → agent_mini-0.3.1}/tests/test_tools.py +86 -6
  41. {agent_mini-0.2.0 → agent_mini-0.3.1}/uv.lock +1 -1
  42. agent_mini-0.2.0/src/agent_mini/agent/context.py +0 -48
  43. agent_mini-0.2.0/tests/test_context.py +0 -66
  44. {agent_mini-0.2.0 → agent_mini-0.3.1}/.github/workflows/ci.yml +0 -0
  45. {agent_mini-0.2.0 → agent_mini-0.3.1}/.github/workflows/publish.yml +0 -0
  46. {agent_mini-0.2.0 → agent_mini-0.3.1}/CONTRIBUTING.md +0 -0
  47. {agent_mini-0.2.0 → agent_mini-0.3.1}/LICENSE +0 -0
  48. {agent_mini-0.2.0 → agent_mini-0.3.1}/SECURITY.md +0 -0
  49. {agent_mini-0.2.0 → agent_mini-0.3.1}/config.example.json +0 -0
  50. {agent_mini-0.2.0 → agent_mini-0.3.1}/src/agent_mini/__main__.py +0 -0
  51. {agent_mini-0.2.0 → agent_mini-0.3.1}/src/agent_mini/agent/__init__.py +0 -0
  52. {agent_mini-0.2.0 → agent_mini-0.3.1}/src/agent_mini/agent/vision.py +0 -0
  53. {agent_mini-0.2.0 → agent_mini-0.3.1}/src/agent_mini/bus.py +0 -0
  54. {agent_mini-0.2.0 → agent_mini-0.3.1}/src/agent_mini/channels/__init__.py +0 -0
  55. {agent_mini-0.2.0 → agent_mini-0.3.1}/src/agent_mini/channels/base.py +0 -0
  56. {agent_mini-0.2.0 → agent_mini-0.3.1}/src/agent_mini/config.py +0 -0
  57. {agent_mini-0.2.0 → agent_mini-0.3.1}/src/agent_mini/providers/__init__.py +0 -0
  58. {agent_mini-0.2.0 → agent_mini-0.3.1}/src/agent_mini/providers/base.py +0 -0
  59. {agent_mini-0.2.0 → agent_mini-0.3.1}/src/agent_mini/providers/openai.py +0 -0
  60. {agent_mini-0.2.0 → agent_mini-0.3.1}/src/agent_mini/sessions.py +0 -0
  61. {agent_mini-0.2.0 → agent_mini-0.3.1}/tests/__init__.py +0 -0
  62. {agent_mini-0.2.0 → agent_mini-0.3.1}/tests/test_config.py +0 -0
  63. {agent_mini-0.2.0 → agent_mini-0.3.1}/tests/test_json_repair.py +0 -0
  64. {agent_mini-0.2.0 → agent_mini-0.3.1}/tests/test_memory.py +0 -0
  65. {agent_mini-0.2.0 → agent_mini-0.3.1}/tests/test_phases.py +0 -0
@@ -36,3 +36,4 @@ config.json
36
36
  .ruff_cache/
37
37
 
38
38
  archived
39
+ evals/results
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-mini
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Ultra-lightweight personal AI agent — shell, files, web, memory — powered by local or cloud LLMs
5
5
  Project-URL: Homepage, https://github.com/mohsinkaleem/agent-mini
6
6
  Project-URL: Repository, https://github.com/mohsinkaleem/agent-mini
@@ -46,8 +46,9 @@ A minimal, local-first AI agent you can actually understand and extend.
46
46
  - **Local-first** — Ollama as default, OpenAI if you want cloud, or any OpenAI-compatible server
47
47
  - **Zero frameworks** — pure `httpx` + `asyncio`, no LangChain, no LiteLLM
48
48
  - **Built-in tools** — shell, files, web search, persistent memory
49
+ - **Vision** — drop an image path or URL into any message; works on Ollama, OpenAI, and OpenAI-compatible providers
49
50
  - **Extensible** — drop a Python file in `~/.agent-mini/plugins/` and it's a tool
50
- - **Small-model optimized** — token-aware context pruning, tool call repair, model-tier tuning
51
+ - **Small-model optimized** — tier-scaled system prompt, token-aware context pruning, tool-call repair, and a [task-eval harness](evals/) to measure whether the tuning actually pays off
51
52
 
52
53
  ## Quick Start
53
54
 
@@ -133,7 +134,7 @@ Available out of the box — no API keys needed:
133
134
  | `list_directory` | Browse filesystem |
134
135
  | `search_files` | Grep / ripgrep across files |
135
136
  | `web_search` | DuckDuckGo search (free, no key) |
136
- | `web_fetch` | Fetch any URL as plain text |
137
+ | `web_fetch` | Fetch a public URL as plain text (private/loopback hosts blocked) |
137
138
  | `memory_store` | Save to persistent memory |
138
139
  | `memory_recall` | Fuzzy search memory (TF-IDF) |
139
140
 
@@ -214,7 +215,9 @@ Control tool access:
214
215
  { "tools": { "sandboxLevel": "readonly" } }
215
216
  ```
216
217
 
217
- Dangerous shell commands (`rm -rf`, `sudo`, `mkfs`, etc.) are blocked by default.
218
+ Dangerous shell commands (`rm -rf`, `sudo`, `mkfs`, etc.) are blocked by default. Treat the shell blocklist as **friction, not a boundary** — a determined caller can bypass it, so don't run the agent in an untrusted context and expect the blocklist to save you.
219
+
220
+ `web_fetch` is guarded against basic SSRF: requests to `localhost`, loopback (`::1`), RFC-1918 private ranges, and link-local (including the cloud metadata endpoint `169.254.169.254`) are refused, and the final URL is re-checked after every redirect. DNS rebinding can still bypass — use `sandboxLevel: readonly` if you need a stronger guarantee.
218
221
 
219
222
  ---
220
223
 
@@ -236,11 +239,14 @@ Agent Mini is a **ReAct loop** — the LLM reasons, picks a tool, observes the r
236
239
 
237
240
  Key design choices for small/local models:
238
241
 
242
+ - **Tier-scaled system prompt** — tiny models get a compact rules block and no memory recall; larger tiers get the full descriptive prompt and recent context
243
+ - **Inline tool list** — `<available_tools>` block in the system prompt so small models can see tool names at a glance without inferring from the JSON schema
239
244
  - **Token-aware context** — estimates token usage and prunes old tool results when approaching the model's effective context window
240
- - **Model tier classification** — auto-detects tiny/small/medium/cloud models and adjusts context budgets, iteration limits, and output caps
245
+ - **Model tier classification** — auto-detects tiny/small/medium/cloud (including large open-weight sizes like `:32b`, `:70b`, `8x7b`) and adjusts context budgets, iteration limits, and output caps
241
246
  - **Tool call repair** — fixes malformed JSON from small models (trailing commas, single quotes, unquoted keys)
242
247
  - **Loop detection** — catches repeated identical tool calls and nudges the LLM to try a different approach
243
248
  - **History summarization** — compresses long conversations to stay within context
249
+ - **[Task-eval harness](evals/)** — measures success rate, iterations, tokens, and JSON-repair fire-rate across model tiers so the tuning above is testable, not hand-waved
244
250
 
245
251
  ---
246
252
 
@@ -284,6 +290,7 @@ Key paths:
284
290
  | `agent-mini init` | Interactive setup wizard |
285
291
  | `agent-mini chat` | Interactive chat |
286
292
  | `agent-mini chat -m "..."` | Single message |
293
+ | `agent-mini chat --workspace <dir>` | Override the workspace for a single run (also honours `AGENT_MINI_WORKSPACE`) |
287
294
  | `agent-mini gateway` | Start Telegram bot |
288
295
  | `agent-mini status` | Show config status |
289
296
 
@@ -324,6 +331,21 @@ uv run pytest tests/ -v
324
331
  uv run ruff check src/ tests/
325
332
  ```
326
333
 
334
+ ### Task evals
335
+
336
+ The [`evals/`](evals/) directory contains a small, framework-free task-eval
337
+ harness that runs the agent end-to-end against real fixtures (refactor a
338
+ codebase, fix a failing test, answer a codebase question, etc.). Use it to
339
+ measure whether the small-model optimizations actually pay off:
340
+
341
+ ```bash
342
+ python evals/run.py # run all tasks against config model
343
+ python evals/run.py --task refactor_rename # single task
344
+ python evals/run.py --compare results/*.json # cross-tier comparison table
345
+ ```
346
+
347
+ See [evals/README.md](evals/README.md) for the full workflow.
348
+
327
349
  See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
328
350
 
329
351
  ## License
@@ -11,8 +11,9 @@ A minimal, local-first AI agent you can actually understand and extend.
11
11
  - **Local-first** — Ollama as default, OpenAI if you want cloud, or any OpenAI-compatible server
12
12
  - **Zero frameworks** — pure `httpx` + `asyncio`, no LangChain, no LiteLLM
13
13
  - **Built-in tools** — shell, files, web search, persistent memory
14
+ - **Vision** — drop an image path or URL into any message; works on Ollama, OpenAI, and OpenAI-compatible providers
14
15
  - **Extensible** — drop a Python file in `~/.agent-mini/plugins/` and it's a tool
15
- - **Small-model optimized** — token-aware context pruning, tool call repair, model-tier tuning
16
+ - **Small-model optimized** — tier-scaled system prompt, token-aware context pruning, tool-call repair, and a [task-eval harness](evals/) to measure whether the tuning actually pays off
16
17
 
17
18
  ## Quick Start
18
19
 
@@ -98,7 +99,7 @@ Available out of the box — no API keys needed:
98
99
  | `list_directory` | Browse filesystem |
99
100
  | `search_files` | Grep / ripgrep across files |
100
101
  | `web_search` | DuckDuckGo search (free, no key) |
101
- | `web_fetch` | Fetch any URL as plain text |
102
+ | `web_fetch` | Fetch a public URL as plain text (private/loopback hosts blocked) |
102
103
  | `memory_store` | Save to persistent memory |
103
104
  | `memory_recall` | Fuzzy search memory (TF-IDF) |
104
105
 
@@ -179,7 +180,9 @@ Control tool access:
179
180
  { "tools": { "sandboxLevel": "readonly" } }
180
181
  ```
181
182
 
182
- Dangerous shell commands (`rm -rf`, `sudo`, `mkfs`, etc.) are blocked by default.
183
+ Dangerous shell commands (`rm -rf`, `sudo`, `mkfs`, etc.) are blocked by default. Treat the shell blocklist as **friction, not a boundary** — a determined caller can bypass it, so don't run the agent in an untrusted context and expect the blocklist to save you.
184
+
185
+ `web_fetch` is guarded against basic SSRF: requests to `localhost`, loopback (`::1`), RFC-1918 private ranges, and link-local (including the cloud metadata endpoint `169.254.169.254`) are refused, and the final URL is re-checked after every redirect. DNS rebinding can still bypass — use `sandboxLevel: readonly` if you need a stronger guarantee.
183
186
 
184
187
  ---
185
188
 
@@ -201,11 +204,14 @@ Agent Mini is a **ReAct loop** — the LLM reasons, picks a tool, observes the r
201
204
 
202
205
  Key design choices for small/local models:
203
206
 
207
+ - **Tier-scaled system prompt** — tiny models get a compact rules block and no memory recall; larger tiers get the full descriptive prompt and recent context
208
+ - **Inline tool list** — `<available_tools>` block in the system prompt so small models can see tool names at a glance without inferring from the JSON schema
204
209
  - **Token-aware context** — estimates token usage and prunes old tool results when approaching the model's effective context window
205
- - **Model tier classification** — auto-detects tiny/small/medium/cloud models and adjusts context budgets, iteration limits, and output caps
210
+ - **Model tier classification** — auto-detects tiny/small/medium/cloud (including large open-weight sizes like `:32b`, `:70b`, `8x7b`) and adjusts context budgets, iteration limits, and output caps
206
211
  - **Tool call repair** — fixes malformed JSON from small models (trailing commas, single quotes, unquoted keys)
207
212
  - **Loop detection** — catches repeated identical tool calls and nudges the LLM to try a different approach
208
213
  - **History summarization** — compresses long conversations to stay within context
214
+ - **[Task-eval harness](evals/)** — measures success rate, iterations, tokens, and JSON-repair fire-rate across model tiers so the tuning above is testable, not hand-waved
209
215
 
210
216
  ---
211
217
 
@@ -249,6 +255,7 @@ Key paths:
249
255
  | `agent-mini init` | Interactive setup wizard |
250
256
  | `agent-mini chat` | Interactive chat |
251
257
  | `agent-mini chat -m "..."` | Single message |
258
+ | `agent-mini chat --workspace <dir>` | Override the workspace for a single run (also honours `AGENT_MINI_WORKSPACE`) |
252
259
  | `agent-mini gateway` | Start Telegram bot |
253
260
  | `agent-mini status` | Show config status |
254
261
 
@@ -289,6 +296,21 @@ uv run pytest tests/ -v
289
296
  uv run ruff check src/ tests/
290
297
  ```
291
298
 
299
+ ### Task evals
300
+
301
+ The [`evals/`](evals/) directory contains a small, framework-free task-eval
302
+ harness that runs the agent end-to-end against real fixtures (refactor a
303
+ codebase, fix a failing test, answer a codebase question, etc.). Use it to
304
+ measure whether the small-model optimizations actually pay off:
305
+
306
+ ```bash
307
+ python evals/run.py # run all tasks against config model
308
+ python evals/run.py --task refactor_rename # single task
309
+ python evals/run.py --compare results/*.json # cross-tier comparison table
310
+ ```
311
+
312
+ See [evals/README.md](evals/README.md) for the full workflow.
313
+
292
314
  See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
293
315
 
294
316
  ## License
@@ -0,0 +1,496 @@
1
+ # Agent Mini — Technical Implementation
2
+
3
+ A deep dive into how Agent Mini is built. The whole codebase is ~3,000 lines of Python, no frameworks (LangChain, LiteLLM, etc.), just `httpx` + `asyncio` + `click` + `rich`. This document explains the moving parts and the design decisions behind each one.
4
+
5
+ ---
6
+
7
+ ## 1. High-Level Architecture
8
+
9
+ Agent Mini is a **ReAct-style loop** — the LLM thinks, picks a tool, observes the result, and repeats until it returns a text answer. Around that loop we have:
10
+
11
+ - **Providers** — pluggable LLM backends (Ollama, OpenAI, any OpenAI-compatible server).
12
+ - **Tools** — shell, files, web, memory. Extensible via plugin drop-ins.
13
+ - **Channels** — how the user talks to the agent (CLI REPL, Telegram).
14
+ - **Bus** — routes a `(channel, user_id)` pair to its own conversation history.
15
+ - **Sessions** — JSON-backed conversation persistence, auto-saved every turn.
16
+ - **Config / Memory** — JSON files under `~/.agent-mini/`.
17
+
18
+ ```mermaid
19
+ flowchart LR
20
+ U[User] -->|CLI or Telegram| CH[Channel]
21
+ CH --> BUS[MessageBus]
22
+ BUS --> LOOP[AgentLoop]
23
+ LOOP --> CTX[Context builder<br/>system prompt]
24
+ LOOP --> PROV[Provider<br/>ollama / openai / local]
25
+ LOOP --> TE[ToolExecutor]
26
+ TE --> FS[File system]
27
+ TE --> SH[Shell]
28
+ TE --> WEB[Web]
29
+ TE --> MEM[Memory JSON]
30
+ LOOP --> SES[Sessions JSON]
31
+ PROV -.HTTP.-> LLM((LLM))
32
+ ```
33
+
34
+ Source map:
35
+
36
+ - Loop and prompt: [src/agent_mini/agent/loop.py](../src/agent_mini/agent/loop.py), [src/agent_mini/agent/context.py](../src/agent_mini/agent/context.py)
37
+ - Tools: [src/agent_mini/agent/tools.py](../src/agent_mini/agent/tools.py)
38
+ - Providers: [src/agent_mini/providers/base.py](../src/agent_mini/providers/base.py), [ollama.py](../src/agent_mini/providers/ollama.py), [local.py](../src/agent_mini/providers/local.py), [openai.py](../src/agent_mini/providers/openai.py)
39
+ - Channels: [src/agent_mini/channels/telegram.py](../src/agent_mini/channels/telegram.py)
40
+ - Bus + sessions: [src/agent_mini/bus.py](../src/agent_mini/bus.py), [src/agent_mini/sessions.py](../src/agent_mini/sessions.py)
41
+ - CLI: [src/agent_mini/cli.py](../src/agent_mini/cli.py)
42
+ - Config: [src/agent_mini/config.py](../src/agent_mini/config.py)
43
+ - Memory: [src/agent_mini/agent/memory.py](../src/agent_mini/agent/memory.py)
44
+ - Vision: [src/agent_mini/agent/vision.py](../src/agent_mini/agent/vision.py)
45
+ - Model tiers / token estimator: [src/agent_mini/agent/token_estimator.py](../src/agent_mini/agent/token_estimator.py)
46
+
47
+ ---
48
+
49
+ ## 2. Startup & Wiring
50
+
51
+ The `agent-mini` command is a Click group defined in [src/agent_mini/cli.py](../src/agent_mini/cli.py). Sub-commands:
52
+
53
+ | Command | What it does |
54
+ |---|---|
55
+ | `init` | Interactive wizard → writes `~/.agent-mini/config.json` |
56
+ | `chat [-m MSG] [-s SESSION] [--workspace DIR]` | Single-shot or REPL |
57
+ | `gateway` | Starts the Telegram bot |
58
+ | `status` | Prints active provider, channels, sandbox, memory |
59
+
60
+ The chat command wires everything together in `_chat()`:
61
+
62
+ ```
63
+ load_config → create_provider → Memory(...) → AgentLoop(provider, config, memory)
64
+ → resume/create session → REPL loop → agent.run(...) → save_session(...)
65
+ ```
66
+
67
+ The `--workspace` flag (and `AGENT_MINI_WORKSPACE` env var) overrides `config["workspace"]` and forces `restrictToWorkspace: true`. This is what the [evals harness](../evals/README.md) uses to sandbox each task into a temp dir without touching the user's real config.
68
+
69
+ ---
70
+
71
+ ## 3. The Agent Loop
72
+
73
+ Everything runs through `AgentLoop.run()` in [loop.py](../src/agent_mini/agent/loop.py).
74
+
75
+ ### 3.1 Per-turn flow
76
+
77
+ 1. Rebuild the system prompt every turn (date, workspace, recent memories, model tier are all live).
78
+ 2. Prepend system prompt to the persisted `conversation`, append the new user turn.
79
+ 3. If the user message contains image references (paths or URLs), the user turn becomes an OpenAI-style multi-part `content` list — see [vision.py](../src/agent_mini/agent/vision.py).
80
+ 4. Enter an iteration budget (default from tier; user override in `config["agent"]["maxIterations"]`).
81
+
82
+ Inside each iteration:
83
+
84
+ ```
85
+ prune old tool results → call provider (with retry+jitter)
86
+ → if response has tool_calls: exec in parallel, append tool msgs, continue
87
+ → else: return text, persist turn, maybe summarize history, done
88
+ ```
89
+
90
+ Relevant code: [loop.py L92-L235](../src/agent_mini/agent/loop.py#L92-L235).
91
+
92
+ ### 3.2 Retry with jitter
93
+
94
+ `_call_provider_with_retry` retries on transient errors (HTTP 429/500/502/503/504, `ConnectError`, `ReadTimeout`, `OSError`) up to 3 times with exponential backoff plus random jitter: `wait = 2**attempt + random.uniform(0, 1)`. Non-transient errors fail through as an error string. See [loop.py L306-L336](../src/agent_mini/agent/loop.py#L306-L336).
95
+
96
+ ### 3.3 Parallel tool execution
97
+
98
+ When the LLM returns multiple tool calls in one response, they're dispatched concurrently with `asyncio.gather`, tagged with `tool_call_id`, and appended in order:
99
+
100
+ ```python
101
+ results = await asyncio.gather(*[_exec(tc) for tc in response.tool_calls])
102
+ ```
103
+
104
+ Each result is truncated to the **tier-specific output limit** (tiny 2 KB, small 4 KB, medium 8 KB, cloud 50 KB) with a head+tail split so the model sees both ends. If a tool returned `Error: ...`, the message is postfixed with a self-reflection nudge:
105
+
106
+ > [The tool call failed. Analyze what went wrong and try a different approach.]
107
+
108
+ ### 3.4 Loop / repetition detection
109
+
110
+ Every tool call is hashed with MD5 over `{name, args}` and pushed onto a sliding window of the last 6 signatures. If the last **four** are identical, a synthetic `user` message is appended:
111
+
112
+ > You have repeated the same tool call multiple times with the same result. Try a completely different approach.
113
+
114
+ This catches small models that get stuck retrying the same failing shell command. See [loop.py L213-L227](../src/agent_mini/agent/loop.py#L213-L227).
115
+
116
+ ### 3.5 Context pruning
117
+
118
+ `_prune_tool_results` is called every iteration on a **copy** of `messages` (never mutates history). It:
119
+
120
+ - protects the last 3 assistant turns and their tool results,
121
+ - soft-trims older large tool messages to head 1500 + tail 1500,
122
+ - hard-clears very old ones with `[Old tool result cleared to save context]`.
123
+
124
+ This keeps the request payload small without losing recent reasoning steps.
125
+
126
+ ### 3.6 History summarization
127
+
128
+ After a text-only response, if the persisted conversation exceeds 75% of the model's effective context, `_summarize_history` fires. It:
129
+
130
+ 1. Finds the cut point that would bring us back below 50%.
131
+ 2. Calls the provider with a tight summarization prompt (`temperature=0.3`, no tools).
132
+ 3. Replaces the pruned tail with a single **`role: "user"`** turn containing `[Previous context summary]\n...`.
133
+
134
+ Why `user`, not `system`? The real system prompt is re-prepended fresh every turn in `run()`, so a stored `system` entry would give small models two leading system messages and dilute the constraint-first anchoring they rely on. See [loop.py L268-L304](../src/agent_mini/agent/loop.py#L268-L304).
135
+
136
+ ### 3.7 Token / cost tracking
137
+
138
+ Two counters live on the loop: `session_usage` and per-turn `turn_usage`, both `{prompt_tokens, completion_tokens, total_tokens}`. Providers surface `usage` on `ChatResponse` when the backend reports it (Ollama's `prompt_eval_count`/`eval_count`; OpenAI's `usage`).
139
+
140
+ ---
141
+
142
+ ## 4. Model-Tier-Aware Tuning
143
+
144
+ [token_estimator.py](../src/agent_mini/agent/token_estimator.py) is the small-model spine of the project. Model name → tier → different behavior everywhere.
145
+
146
+ ### 4.1 Classification
147
+
148
+ Regex patterns match from most-specific to least. Cloud models (`gpt-4`, `claude`, `gemini`, `deepseek-v2/v3`) win first, then large open-weight sizes (32B, 34B, 40B, 65B, 70B, 72B, 8x7B, 8x22B) also map to `cloud`. The `(?<![\d.])` lookbehind is important — it prevents `1.5b` (a tiny model) matching the `5b` in the small-tier regex.
149
+
150
+ | Tier | Effective context | Max iterations | Tool output cap |
151
+ |---|---|---|---|
152
+ | tiny (1–3B) | 3 000 | 10 | 2 000 chars |
153
+ | small (4–8B) | 6 000 | 15 | 4 000 chars |
154
+ | medium (9–14B) | 12 000 | 20 | 8 000 chars |
155
+ | cloud / large open-weight | 32 000 | 25 | 50 000 chars |
156
+
157
+ ### 4.2 What changes per tier
158
+
159
+ - **System prompt** (`context.py`): tiny models get a stripped `_TINY_RULES` block instead of the full `_SYSTEM_PROMPT_TEMPLATE` — every token trades against reasoning budget.
160
+ - **Memory recall in prompt**: tiny=0, small=3, medium=5, cloud=5 recent memories. Tiny models get confused by unrelated context.
161
+ - **Inline tool listing**: an `<available_tools>` block is always emitted so small models can eyeball tool names without reasoning over the JSON schema.
162
+ - **Max iterations, context budget, output cap**: all pulled from the tier tables above unless the user pins a value in config.
163
+
164
+ ### 4.3 Token estimation
165
+
166
+ Cheap: `~4 chars per token`. Vision image parts count as ~85 tokens each. Tool-call arguments are counted separately. Precise enough to drive the 75% / 50% summarization thresholds.
167
+
168
+ ---
169
+
170
+ ## 5. System Prompt Construction
171
+
172
+ [context.py](../src/agent_mini/agent/context.py):
173
+
174
+ ```
175
+ [tier-scaled preamble + rules]
176
+ [<available_tools> ... </available_tools>]
177
+ [## User instructions <- from config.agent.systemPrompt]
178
+ [## Recent memories <- top-N by tier]
179
+ ```
180
+
181
+ Two anti-injection details worth noting:
182
+
183
+ - The template uses `str.format(date=..., workspace=...)` — user-controlled fields never go through `.format()`. Custom `systemPrompt` and memory values are string-concatenated, so a stray `{}` from the user won't blow up formatting.
184
+ - Memory content is appended raw — treat it as effectively user-provided when auditing.
185
+
186
+ ---
187
+
188
+ ## 6. Providers
189
+
190
+ ### 6.1 The abstraction
191
+
192
+ [providers/base.py](../src/agent_mini/providers/base.py) defines:
193
+
194
+ ```python
195
+ @dataclass
196
+ class ToolCall: id: str; name: str; arguments: dict
197
+
198
+ @dataclass
199
+ class ChatResponse: content, tool_calls, thinking, usage
200
+
201
+ class BaseProvider(ABC):
202
+ async def chat(messages, tools, temperature) -> ChatResponse
203
+ async def chat_stream(messages, on_delta, tools, temperature, on_thinking) -> ChatResponse
204
+ ```
205
+
206
+ Providers normalize to `ChatResponse`; the loop is provider-agnostic.
207
+
208
+ ### 6.2 JSON repair for tool arguments
209
+
210
+ Small models emit malformed JSON. `parse_arguments` first tries `json.loads`, then `_repair_json` which:
211
+
212
+ 1. Strips ``` fences.
213
+ 2. Drops trailing commas before `}` / `]`.
214
+ 3. Swaps single→double quotes when there are no doubles.
215
+ 4. Quotes unquoted keys (`{foo: 1}` → `{"foo": 1}`).
216
+
217
+ Only successful `dict` results are returned; anything else logs a warning and drops the call. See [providers/base.py L37-L88](../src/agent_mini/providers/base.py#L37-L88).
218
+
219
+ ### 6.3 Ollama
220
+
221
+ [ollama.py](../src/agent_mini/providers/ollama.py) — POSTs to `/api/chat`. Streams NDJSON.
222
+
223
+ Notable transforms in `_clean_messages`:
224
+
225
+ - `role: "tool"` messages are rewritten to `role: "user"` with a `[Tool result for ...]:` prefix, for compatibility with older Ollama builds that don't fully speak the tool-role protocol.
226
+ - OpenAI-style multi-part vision content (`[{type: "image_url", ...}]`) is flattened to Ollama's `{content, images: ["<base64>"]}` shape, stripping `data:...;base64,` prefixes when present.
227
+ - `think` (`false | true | "low" | "medium" | "high"`) is passed through unchanged; thinking deltas are streamed via a separate `on_thinking` callback.
228
+
229
+ ### 6.4 OpenAI-compatible (`LocalProvider`)
230
+
231
+ [local.py](../src/agent_mini/providers/local.py) — plain `/chat/completions` client. Two subtleties:
232
+
233
+ - Streaming tool calls arrive in fragments indexed by `tc["index"]`. We accumulate them in a dict, then sort by that **integer** index — not by `id` (opaque string) which would sort `"call_10"` before `"call_2"` lexicographically.
234
+ - Arguments arrive as string fragments concatenated across chunks; we run the full string through `parse_arguments` (repair-aware) at the end.
235
+
236
+ ### 6.5 OpenAI
237
+
238
+ [openai.py](../src/agent_mini/providers/openai.py) is a 20-line subclass of `LocalProvider` with `base_url = https://api.openai.com/v1` and `name = "openai"`. That's the whole thing — OpenAI *is* the reference implementation of its own protocol.
239
+
240
+ ---
241
+
242
+ ## 7. Tools
243
+
244
+ All in [tools.py](../src/agent_mini/agent/tools.py). Tool definitions use the OpenAI function-calling JSON shape:
245
+
246
+ ```python
247
+ {"type": "function", "function": {"name": ..., "description": ..., "parameters": {...}}}
248
+ ```
249
+
250
+ ### 7.1 Built-in tools
251
+
252
+ Registered in `_CORE_TOOLS` and `_WEB_TOOLS`:
253
+
254
+ | Tool | Notes |
255
+ |---|---|
256
+ | `shell_exec` | Runs via `asyncio.create_subprocess_shell` inside workspace `cwd`, 120 s timeout, output capped at 50 KB. Blocklist regex enforced. |
257
+ | `read_file` / `write_file` / `append_file` / `code_edit` | Text I/O. `code_edit` requires **exact single-match** find-and-replace and returns a specific error for 0 or >1 matches so the model can adjust. `read_file` caps content at 100 KB. |
258
+ | `list_directory` | Sorted, directories first, capped at 200 entries. |
259
+ | `search_files` | Prefers `rg` (ripgrep) with `--hidden --glob '!.git'`; falls back to `grep -rn`. Output capped at 100 KB. `returncode` 0 or 1 both count as success (1 = no matches). |
260
+ | `web_search` | POSTs to `https://html.duckduckgo.com/html/`; parses result blocks with regex; unwraps DuckDuckGo redirect `uddg` param; returns top 8 as Markdown. |
261
+ | `web_fetch` | SSRF-guarded, HTML → plain text via a stdlib `HTMLParser` subclass. |
262
+ | `memory_store` / `memory_recall` | See section 8. |
263
+
264
+ ### 7.2 SSRF guard
265
+
266
+ `_check_url_ssrf` enforces:
267
+
268
+ - scheme in `{http, https}`,
269
+ - literal IPs must not be private/loopback/link-local/reserved,
270
+ - host names are resolved via `getaddrinfo` and every returned `A`/`AAAA` record is checked — catches DNS names pointing into RFC-1918.
271
+
272
+ After the request, the **final** URL (post-redirect) is re-checked so an attacker can't 302 into `169.254.169.254`. Documented caveat: DNS rebinding can still bypass — `sandboxLevel: readonly` is the stronger guarantee.
273
+
274
+ ### 7.3 Shell command blocklist
275
+
276
+ Default patterns:
277
+
278
+ ```
279
+ \brm\s+-[^\s]*r[^\s]*f rm -rf variants
280
+ \bsudo\b privilege escalation
281
+ \bmkfs\b filesystem format
282
+ \bdd\s+if= raw disk write
283
+ :\(\)\s*\{ fork bomb
284
+ \b(chmod|chown)\s+...\s+/$ root permission change
285
+ ```
286
+
287
+ `tools.blockedCommands` extends (not replaces) the defaults. README calls this out honestly: **friction, not a boundary** — a determined shell caller can bypass. Don't run the agent against untrusted input and expect the blocklist to save you.
288
+
289
+ ### 7.4 Sandbox levels
290
+
291
+ `config.tools.sandboxLevel`:
292
+
293
+ - `unrestricted` — all tools, all paths.
294
+ - `workspace` (default) — all tools, but `_resolve_path` refuses anything outside `config.workspace`.
295
+ - `readonly` — hard-blocks `shell_exec`, `write_file`, `append_file`, `code_edit` at the `execute()` gate. Read, search, memory, and web tools still work.
296
+
297
+ Path resolution: `_resolve_path` expands `~`, resolves absolute paths, joins relative paths to the workspace, and in restricted mode raises `PermissionError` if the resolved path is not `is_relative_to(workspace)`. The tool exception handler catches this and returns `Error: PermissionError: ...` to the model.
298
+
299
+ ### 7.5 Plugins
300
+
301
+ `_load_plugins()` scans `~/.agent-mini/plugins/*.py`, `importlib.util.spec_from_file_location` each one, and looks for a `TOOL_DEF` dict and `handler` callable (sync or async — the executor `await`s it if it's a coroutine). Plugin definitions get concatenated onto `get_tool_defs()` and dispatched from the `_` case of the `execute()` match.
302
+
303
+ ```python
304
+ # ~/.agent-mini/plugins/timestamp.py
305
+ TOOL_DEF = {
306
+ "type": "function",
307
+ "function": {
308
+ "name": "get_timestamp",
309
+ "description": "Get current UTC timestamp.",
310
+ "parameters": {"type": "object", "properties": {}, "required": []},
311
+ },
312
+ }
313
+ async def handler(arguments: dict) -> str:
314
+ from datetime import datetime, timezone
315
+ return datetime.now(timezone.utc).isoformat()
316
+ ```
317
+
318
+ No registration, no manifest — drop the file, restart the agent.
319
+
320
+ ---
321
+
322
+ ## 8. Persistent Memory
323
+
324
+ [memory.py](../src/agent_mini/agent/memory.py) — a JSON file at `~/.agent-mini/memory.json`, list of `{key, value, timestamp}`.
325
+
326
+ ### 8.1 Storage
327
+
328
+ `store()` appends a dict, trims to `maxEntries` from the tail, and rewrites the file. Corrupted JSON → warn, start fresh (never fatal).
329
+
330
+ ### 8.2 Recall — hand-rolled TF-IDF
331
+
332
+ No embeddings, no vector DB. `recall(query)` does:
333
+
334
+ 1. **Tokenize + stem** — regex `[a-z0-9]+`, then a compact Porter-style suffix stripper that catches common inflections (`-ing`, `-ment`, `-ness`, `-ies`→`y`, `-es`, `-ed`, `-ly`, `-s`, plus ~15 more complex rules like `-ational` → `-ate`).
335
+ 2. **IDF**: `log((N+1)/(df+1)) + 1` per query term.
336
+ 3. **Score each doc**: sum of `(tf/doc_len) * idf` for exact term hits, plus a substring-fallback bonus of `0.3 * idf/doc_len` for partial matches (so `python` matches `pythonic`).
337
+ 4. Return top 10 sorted by score, formatted as `[timestamp] key: value`.
338
+
339
+ That's it — no external deps, works fine for the hundreds-of-entries scale a personal agent hits.
340
+
341
+ ### 8.3 Prompt integration
342
+
343
+ `Memory.get_recent(n)` returns the tail of the store; the context builder inlines them into the system prompt under `## Recent memories` (0/3/5/5 by tier). Manual recall via the `memory_recall` tool is the fuzzy-search path.
344
+
345
+ ---
346
+
347
+ ## 9. Sessions
348
+
349
+ [sessions.py](../src/agent_mini/sessions.py) — JSON files at `~/.agent-mini/sessions/<id>.json`:
350
+
351
+ ```json
352
+ {
353
+ "id": "20260728_143022",
354
+ "updated": "2026-07-28T14:30:22.123456",
355
+ "metadata": {},
356
+ "conversation": [ ...role/content dicts... ]
357
+ }
358
+ ```
359
+
360
+ `save_session` is called after every REPL turn (see `_chat` in cli.py). IDs default to `YYYYMMDD_HHMMSS`. `list_sessions` returns all files sorted newest-first with a computed preview of the first user message. `/load <id>` mutates the in-memory `conversation` list; `/save` exports the conversation to Markdown (renders tool calls / results in collapsible `<details>` blocks).
361
+
362
+ ---
363
+
364
+ ## 10. Channels & Bus
365
+
366
+ ### 10.1 MessageBus
367
+
368
+ [bus.py](../src/agent_mini/bus.py) is thin. It maintains an in-memory dict keyed by `f"{channel}:{user_id}"` mapping to the conversation list, and calls `agent.run()`. This is what isolates two Telegram users from each other, or the CLI from the bot.
369
+
370
+ ### 10.2 Telegram channel
371
+
372
+ [telegram.py](../src/agent_mini/channels/telegram.py) uses `python-telegram-bot ≥ 21`. Highlights:
373
+
374
+ - **Allowlist**: `allowFrom` is a set of user IDs or usernames; `"*"` disables the check.
375
+ - **Typing indicator** via `send_action("typing")` at the start of each message.
376
+ - **Streaming**: sends an initial `"..."` placeholder and edits it in place. `_flush` debounces edits (min 0.7 s between updates unless ≥80 new chars). It also short-circuits when the preview is unchanged, avoiding Telegram's `message is not modified` error.
377
+ - **Chunking**: replies longer than 4 000 chars are split (Telegram's message limit is 4 096).
378
+
379
+ `agent-mini gateway` iterates over enabled channels and calls `start(bus.handle_message)`. Currently only Telegram is implemented, but the `BaseChannel` interface makes adding new ones cheap.
380
+
381
+ ---
382
+
383
+ ## 11. Vision
384
+
385
+ [vision.py](../src/agent_mini/agent/vision.py) scans the user message for words that look like image paths (`.png/.jpg/.jpeg/.gif/.webp/.bmp`) or `https?://…\.png` URLs. If any are found, the user message becomes an OpenAI-style multi-part list:
386
+
387
+ ```python
388
+ [
389
+ {"type": "text", "text": "what is in this image?"},
390
+ {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
391
+ ]
392
+ ```
393
+
394
+ - Local files are base64-encoded with the correct MIME type.
395
+ - Remote URLs are passed through unmodified.
396
+ - The Ollama provider translates this shape into its native `{content, images: [...]}` in `_clean_messages`, so the same message works on all three providers.
397
+
398
+ ---
399
+
400
+ ## 12. Configuration
401
+
402
+ [config.py](../src/agent_mini/config.py) mirrors the JSON config as typed dataclasses (`AppConfig`, `ProvidersConfig`, `AgentConfig`, `ToolsConfig`, etc.). The `AppConfig.from_dict` helper does key-filtered construction with `_pick`, so unknown keys in the JSON don't blow up instantiation — forward-compatible reads.
403
+
404
+ Key paths (all under `~/.agent-mini/` by default):
405
+
406
+ | Path | Purpose |
407
+ |---|---|
408
+ | `config.json` | User config |
409
+ | `workspace/` | Default tool sandbox root |
410
+ | `memory.json` | Persistent memory store |
411
+ | `plugins/*.py` | User tool plugins |
412
+ | `sessions/*.json` | Saved conversations |
413
+
414
+ Runtime overrides: `--workspace <dir>` and `AGENT_MINI_WORKSPACE` env var (used by the evals runner to isolate each task).
415
+
416
+ ---
417
+
418
+ ## 13. Evals Harness
419
+
420
+ [evals/](../evals/) is a framework-free task runner. Each YAML task under `evals/tasks/` describes:
421
+
422
+ - fixtures to copy into a temp workspace,
423
+ - a prompt to send to the agent,
424
+ - success predicates (file exists, `pytest` passes, regex in output, etc.),
425
+ - caps on iterations / tokens.
426
+
427
+ `evals/run.py` invokes the same `AgentLoop` with `AGENT_MINI_WORKSPACE` pointing at that temp dir, records success/iterations/tokens/tool-call counts, and writes a JSON report under `evals/results/`. `--compare results/*.json` prints a cross-model table. This is what lets tier-scaling changes be measured rather than intuited.
428
+
429
+ ---
430
+
431
+ ## 14. Error Handling & Observability
432
+
433
+ - **Provider errors**: transient → retry with jitter; permanent → the loop returns `f"Error communicating with LLM: {e}"` as the final text (still gets saved to conversation).
434
+ - **Tool errors**: caught in `execute()`, returned as `Error: {type}: {msg}`. The loop postfixes the self-reflection nudge so the model retries with a different approach.
435
+ - **Max iteration exhaustion**: the user turn is still appended with an assistant message `[Stopped: reached max iterations without completing.]` — so the next turn doesn't have amnesia about what happened.
436
+ - **Logging**: `logging.getLogger("agent-mini")`. `-v/--verbose` on `chat` / `gateway` flips to DEBUG. Tool calls log at INFO with a 200-char argument preview and 🔧 emoji marker; tool results at DEBUG with a 300-char preview.
437
+
438
+ ---
439
+
440
+ ## 15. Design Decisions Worth Calling Out
441
+
442
+ - **No agent framework.** No LangChain, no LiteLLM, no Semantic Kernel. Every abstraction is one file long, and there's nothing under the hood that isn't in the repo. If a tuning knob matters, it's a constant at the top of a Python file, not a nested YAML key you have to grep an SDK for.
443
+ - **Tier-scaled everything.** The same code runs a `qwen2.5:1.5b` and a `gpt-4o` — but the prompt, iteration cap, output cap, memory budget, and context budget all shift by tier. This is measured, not vibed: [evals/results/](../evals/results/) has runs across `qwen3.5:4b`, `9b`, `27b`.
444
+ - **JSON-only persistence.** `memory.json`, `sessions/*.json`, `config.json`. No SQLite, no vector DB. You can `cat`, `jq`, diff, and back up your entire agent state.
445
+ - **Provider ↔ loop contract is one dataclass** (`ChatResponse`). Adding a new backend is 100 lines of HTTP client plus a mapping function.
446
+ - **Streaming is optional at the loop level** but pushed all the way to the Telegram edit-in-place flow so long tool-heavy responses feel responsive.
447
+ - **SSRF + shell blocklist are documented as friction, not boundaries.** Honest security posture — the sandbox level is the real control.
448
+
449
+ ---
450
+
451
+ ## 16. End-to-End Example: "read foo.py and add a docstring"
452
+
453
+ Sequence for a Telegram user sending this message:
454
+
455
+ ```mermaid
456
+ sequenceDiagram
457
+ participant U as User
458
+ participant TG as TelegramChannel
459
+ participant B as MessageBus
460
+ participant L as AgentLoop
461
+ participant P as OllamaProvider
462
+ participant M as LLM
463
+ participant T as ToolExecutor
464
+ participant FS as Filesystem
465
+
466
+ U->>TG: "read foo.py and add a docstring"
467
+ TG->>B: handle_message("telegram", uid, text, _emit)
468
+ B->>L: run(text, session, on_stream=_emit)
469
+ L->>L: build_system_prompt(tier, tools, mem)
470
+ L->>P: chat_stream(messages, tools)
471
+ P->>M: POST /api/chat (stream)
472
+ M-->>P: tool_calls=[read_file(path=foo.py)]
473
+ P-->>L: ChatResponse(tool_calls=[...])
474
+ L->>T: execute("read_file", {"path":"foo.py"})
475
+ T->>FS: _resolve_path + read_text
476
+ FS-->>T: content
477
+ T-->>L: content string (truncated to tier cap)
478
+ L->>P: chat_stream(messages + tool result)
479
+ P->>M: POST /api/chat
480
+ M-->>P: tool_calls=[code_edit(...)]
481
+ P-->>L: ChatResponse
482
+ L->>T: execute("code_edit", ...)
483
+ T->>FS: read → replace → write
484
+ FS-->>T: ok
485
+ T-->>L: "Edited foo.py..."
486
+ L->>P: chat_stream(...)
487
+ P->>M: POST /api/chat
488
+ M-->>P: text: "Added docstring."
489
+ P-->>L: ChatResponse(content=..., no tool_calls)
490
+ L->>L: append turns, maybe summarize
491
+ L-->>B: "Added docstring."
492
+ B-->>TG: reply
493
+ TG->>U: edits placeholder → final text
494
+ ```
495
+
496
+ Every arrow above is a real function you can grep for in ~3 000 lines of Python.