ollama_agent 0.1.0 → 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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/skills/ruby-code-review-levels/SKILL.md +115 -0
  3. data/.cursor/skills/self-improvement-sandbox-safety/SKILL.md +65 -0
  4. data/.env.example +25 -0
  5. data/CHANGELOG.md +40 -0
  6. data/README.md +135 -4
  7. data/docs/ARCHITECTURE.md +42 -0
  8. data/docs/PERFORMANCE.md +22 -0
  9. data/docs/SESSIONS.md +48 -0
  10. data/docs/TOOLS.md +53 -0
  11. data/docs/TOOL_RUNTIME.md +154 -0
  12. data/docs/superpowers/plans/2026-03-26-production-ready-ollama-agent.md +2454 -0
  13. data/docs/superpowers/specs/2026-03-26-production-ready-ollama-agent-design.md +400 -0
  14. data/lib/ollama_agent/agent/agent_config.rb +53 -0
  15. data/lib/ollama_agent/agent/client_wiring.rb +76 -0
  16. data/lib/ollama_agent/agent/prompt_wiring.rb +55 -0
  17. data/lib/ollama_agent/agent/session_wiring.rb +53 -0
  18. data/lib/ollama_agent/agent.rb +148 -73
  19. data/lib/ollama_agent/agent_prompt.rb +31 -1
  20. data/lib/ollama_agent/chat_stream_carry.rb +88 -0
  21. data/lib/ollama_agent/chat_stream_thinking_format.rb +29 -0
  22. data/lib/ollama_agent/cli.rb +394 -4
  23. data/lib/ollama_agent/console.rb +177 -5
  24. data/lib/ollama_agent/context/manager.rb +100 -0
  25. data/lib/ollama_agent/context/token_counter.rb +33 -0
  26. data/lib/ollama_agent/diff_path_validator.rb +32 -10
  27. data/lib/ollama_agent/env_config.rb +44 -0
  28. data/lib/ollama_agent/external_agents/TODO-plan.md +1948 -0
  29. data/lib/ollama_agent/external_agents/argv_interp.rb +21 -0
  30. data/lib/ollama_agent/external_agents/default_agents.yml +60 -0
  31. data/lib/ollama_agent/external_agents/delegate_logger.rb +31 -0
  32. data/lib/ollama_agent/external_agents/delegate_timeout_status.rb +12 -0
  33. data/lib/ollama_agent/external_agents/env_helpers.rb +38 -0
  34. data/lib/ollama_agent/external_agents/path_validator.rb +32 -0
  35. data/lib/ollama_agent/external_agents/probe.rb +122 -0
  36. data/lib/ollama_agent/external_agents/registry.rb +50 -0
  37. data/lib/ollama_agent/external_agents/runner.rb +118 -0
  38. data/lib/ollama_agent/external_agents.rb +9 -0
  39. data/lib/ollama_agent/global_dotenv.rb +39 -0
  40. data/lib/ollama_agent/model_env.rb +26 -0
  41. data/lib/ollama_agent/ollama_chat_thinking_stream.rb +41 -0
  42. data/lib/ollama_agent/ollama_connection.rb +6 -1
  43. data/lib/ollama_agent/patch_risk.rb +81 -0
  44. data/lib/ollama_agent/patch_support.rb +27 -1
  45. data/lib/ollama_agent/path_sandbox.rb +62 -0
  46. data/lib/ollama_agent/prompt_skills/clean_ruby.md +131 -0
  47. data/lib/ollama_agent/prompt_skills/code_review.md +112 -0
  48. data/lib/ollama_agent/prompt_skills/design_patterns.md +56 -0
  49. data/lib/ollama_agent/prompt_skills/manifest.yml +25 -0
  50. data/lib/ollama_agent/prompt_skills/ollama_agent_patterns.md +132 -0
  51. data/lib/ollama_agent/prompt_skills/rails_best_practices.md +41 -0
  52. data/lib/ollama_agent/prompt_skills/rails_style.md +138 -0
  53. data/lib/ollama_agent/prompt_skills/rspec.md +280 -0
  54. data/lib/ollama_agent/prompt_skills/rubocop.md +7 -0
  55. data/lib/ollama_agent/prompt_skills/ruby_style.md +121 -0
  56. data/lib/ollama_agent/prompt_skills/solid.md +270 -0
  57. data/lib/ollama_agent/prompt_skills/solid_ruby.md +223 -0
  58. data/lib/ollama_agent/prompt_skills.rb +169 -0
  59. data/lib/ollama_agent/repo_list.rb +4 -1
  60. data/lib/ollama_agent/resilience/audit_logger.rb +79 -0
  61. data/lib/ollama_agent/resilience/retry_middleware.rb +45 -0
  62. data/lib/ollama_agent/resilience/retry_policy.rb +51 -0
  63. data/lib/ollama_agent/ruby_index_tool_support.rb +17 -6
  64. data/lib/ollama_agent/runner.rb +123 -0
  65. data/lib/ollama_agent/sandboxed_tools/delegate_external.rb +62 -0
  66. data/lib/ollama_agent/sandboxed_tools/file_read_write.rb +100 -0
  67. data/lib/ollama_agent/sandboxed_tools/search_text.rb +60 -0
  68. data/lib/ollama_agent/sandboxed_tools.rb +55 -116
  69. data/lib/ollama_agent/search_backend.rb +93 -0
  70. data/lib/ollama_agent/self_improvement/analyzer.rb +34 -0
  71. data/lib/ollama_agent/self_improvement/improver.rb +340 -0
  72. data/lib/ollama_agent/self_improvement/modes.rb +25 -0
  73. data/lib/ollama_agent/self_improvement/ruby_mastery_context.rb +66 -0
  74. data/lib/ollama_agent/self_improvement.rb +5 -0
  75. data/lib/ollama_agent/session/session.rb +8 -0
  76. data/lib/ollama_agent/session/store.rb +68 -0
  77. data/lib/ollama_agent/streaming/console_streamer.rb +29 -0
  78. data/lib/ollama_agent/streaming/hooks.rb +39 -0
  79. data/lib/ollama_agent/tool_arguments.rb +13 -1
  80. data/lib/ollama_agent/tool_content_parser.rb +1 -1
  81. data/lib/ollama_agent/tool_runtime/executor.rb +34 -0
  82. data/lib/ollama_agent/tool_runtime/json_extractor.rb +62 -0
  83. data/lib/ollama_agent/tool_runtime/loop.rb +72 -0
  84. data/lib/ollama_agent/tool_runtime/memory.rb +32 -0
  85. data/lib/ollama_agent/tool_runtime/ollama_json_planner.rb +98 -0
  86. data/lib/ollama_agent/tool_runtime/plan_extractor.rb +12 -0
  87. data/lib/ollama_agent/tool_runtime/registry.rb +60 -0
  88. data/lib/ollama_agent/tool_runtime/tool.rb +24 -0
  89. data/lib/ollama_agent/tool_runtime.rb +24 -0
  90. data/lib/ollama_agent/tools/registry.rb +55 -0
  91. data/lib/ollama_agent/tools_schema.rb +74 -1
  92. data/lib/ollama_agent/user_prompt.rb +35 -0
  93. data/lib/ollama_agent/version.rb +1 -1
  94. data/lib/ollama_agent.rb +25 -0
  95. data/reproduce_429.rb +40 -0
  96. data/sig/ollama_agent.rbs +111 -1
  97. metadata +78 -2
@@ -0,0 +1,2454 @@
1
+ # Production-Ready ollama_agent Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add six production-readiness layers (Tool Registry, Streaming, Resilience, Context Manager, Session Persistence, Runner API) to the existing ollama_agent gem without breaking any existing behavior.
6
+
7
+ **Architecture:** Each layer is an independently shippable PR added on top of the proven v0.1.x core. All new features are opt-in via new env vars or CLI flags. The existing `Agent`, `CLI`, and `SandboxedTools` APIs remain 100% backward-compatible.
8
+
9
+ **Tech Stack:** Ruby ≥ 3.2, ollama-client ~> 1.1, Thor, Prism, RSpec, existing gem structure under `lib/ollama_agent/`
10
+
11
+ ---
12
+
13
+ ## File Map
14
+
15
+ ### Layer 1 — Tool Registry + write_file
16
+ | Action | Path |
17
+ |--------|------|
18
+ | Create | `lib/ollama_agent/tools/registry.rb` |
19
+ | Create | `lib/ollama_agent/tools/built_in.rb` |
20
+ | Modify | `lib/ollama_agent/sandboxed_tools.rb` — replace `case` with registry dispatch; add `execute_write_file_tool` |
21
+ | Modify | `lib/ollama_agent/tools_schema.rb` — add `write_file` schema; update `tools_for` to merge custom schemas |
22
+ | Modify | `lib/ollama_agent/agent_prompt.rb` — add `write_file` instruction line |
23
+ | Modify | `lib/ollama_agent.rb` — require `tools/registry` and `tools/built_in` |
24
+ | Create | `spec/ollama_agent/tools/registry_spec.rb` |
25
+ | Modify | `spec/ollama_agent/sandboxed_tools_spec.rb` — add write_file specs |
26
+
27
+ ### Layer 2 — Streaming + Hooks
28
+ | Action | Path |
29
+ |--------|------|
30
+ | Create | `lib/ollama_agent/streaming/hooks.rb` |
31
+ | Create | `lib/ollama_agent/streaming/console_streamer.rb` |
32
+ | Modify | `lib/ollama_agent/agent.rb` — wire `@hooks`; add `stream_assistant_message` branch |
33
+ | Modify | `lib/ollama_agent/cli.rb` — add `--stream` flag; attach `ConsoleStreamer` |
34
+ | Modify | `lib/ollama_agent.rb` — require streaming files |
35
+ | Create | `spec/ollama_agent/streaming/hooks_spec.rb` |
36
+
37
+ ### Layer 3 — Resilience (Retry + Audit Logger)
38
+ | Action | Path |
39
+ |--------|------|
40
+ | Create | `lib/ollama_agent/resilience/retry_middleware.rb` |
41
+ | Create | `lib/ollama_agent/resilience/audit_logger.rb` |
42
+ | Modify | `lib/ollama_agent/agent.rb` — wrap client with RetryMiddleware; subscribe AuditLogger to hooks |
43
+ | Modify | `lib/ollama_agent/cli.rb` — add `--audit`, `--max-retries` flags |
44
+ | Modify | `lib/ollama_agent.rb` — require resilience files |
45
+ | Create | `spec/ollama_agent/resilience/retry_middleware_spec.rb` |
46
+ | Create | `spec/ollama_agent/resilience/audit_logger_spec.rb` |
47
+
48
+ ### Layer 4 — Context Manager
49
+ | Action | Path |
50
+ |--------|------|
51
+ | Create | `lib/ollama_agent/context/token_counter.rb` |
52
+ | Create | `lib/ollama_agent/context/manager.rb` |
53
+ | Modify | `lib/ollama_agent/agent.rb` — insert `ContextManager#trim` before every `chat` call |
54
+ | Modify | `lib/ollama_agent/cli.rb` — add `--max-tokens`, `--context-summarize` flags |
55
+ | Modify | `lib/ollama_agent.rb` — require context files |
56
+ | Create | `spec/ollama_agent/context/token_counter_spec.rb` |
57
+ | Create | `spec/ollama_agent/context/manager_spec.rb` |
58
+
59
+ ### Layer 5 — Session Persistence
60
+ | Action | Path |
61
+ |--------|------|
62
+ | Create | `lib/ollama_agent/session/session.rb` |
63
+ | Create | `lib/ollama_agent/session/store.rb` |
64
+ | Modify | `lib/ollama_agent/agent.rb` — accept `session_store:` kwarg; append messages after each turn |
65
+ | Modify | `lib/ollama_agent/cli.rb` — add `--session`, `--resume` flags; add `sessions` command |
66
+ | Modify | `lib/ollama_agent.rb` — require session files |
67
+ | Create | `spec/ollama_agent/session/store_spec.rb` |
68
+
69
+ ### Layer 6 — Runner + Library API
70
+ | Action | Path |
71
+ |--------|------|
72
+ | Create | `lib/ollama_agent/runner.rb` |
73
+ | Modify | `lib/ollama_agent.rb` — require runner; expose `OllamaAgent::Tools` alias |
74
+ | Modify | `lib/ollama_agent/version.rb` — bump to `0.2.0` |
75
+ | Create | `spec/ollama_agent/runner_spec.rb` |
76
+ | Create | `docs/ARCHITECTURE.md` |
77
+ | Create | `docs/TOOLS.md` |
78
+ | Create | `docs/SESSIONS.md` |
79
+
80
+ ---
81
+
82
+ ## Layer 1 — Tool Registry + `write_file`
83
+
84
+ ### Task 1.1: Create the Tool Registry
85
+
86
+ **Files:**
87
+ - Create: `lib/ollama_agent/tools/registry.rb`
88
+ - Create: `spec/ollama_agent/tools/registry_spec.rb`
89
+
90
+ - [ ] **Step 1.1.1: Write the failing specs**
91
+
92
+ ```ruby
93
+ # spec/ollama_agent/tools/registry_spec.rb
94
+ # frozen_string_literal: true
95
+
96
+ require "spec_helper"
97
+ require_relative "../../../lib/ollama_agent/tools/registry"
98
+
99
+ RSpec.describe OllamaAgent::Tools::Registry do
100
+ before { described_class.reset! }
101
+ after { described_class.reset! }
102
+
103
+ describe ".register and .execute" do
104
+ it "executes a registered custom tool handler" do
105
+ described_class.register("my_tool",
106
+ schema: { type: "object", properties: {}, required: [] }
107
+ ) { |args, root:, read_only:| "result:#{args["x"]}" }
108
+
109
+ result = described_class.execute_custom("my_tool", { "x" => "42" }, root: "/tmp", read_only: false)
110
+ expect(result).to eq("result:42")
111
+ end
112
+
113
+ it "returns an error message for an unknown custom tool" do
114
+ result = described_class.execute_custom("nope", {}, root: "/tmp", read_only: false)
115
+ expect(result).to include("Unknown custom tool")
116
+ end
117
+
118
+ it "reports registered? correctly" do
119
+ expect(described_class.custom_tool?("my_tool")).to be false
120
+ described_class.register("my_tool", schema: {}) { "x" }
121
+ expect(described_class.custom_tool?("my_tool")).to be true
122
+ end
123
+ end
124
+
125
+ describe ".custom_schemas" do
126
+ it "returns tool schemas in ollama tool format" do
127
+ described_class.register("do_thing",
128
+ schema: { description: "does a thing", properties: { x: { type: "string" } }, required: ["x"] }
129
+ ) { "ok" }
130
+
131
+ schemas = described_class.custom_schemas
132
+ expect(schemas.size).to eq(1)
133
+ expect(schemas.first[:type]).to eq("function")
134
+ expect(schemas.first.dig(:function, :name)).to eq("do_thing")
135
+ end
136
+
137
+ it "returns empty array when no custom tools registered" do
138
+ expect(described_class.custom_schemas).to eq([])
139
+ end
140
+ end
141
+
142
+ describe ".reset!" do
143
+ it "clears all registrations" do
144
+ described_class.register("t", schema: {}) { "x" }
145
+ described_class.reset!
146
+ expect(described_class.custom_tool?("t")).to be false
147
+ end
148
+ end
149
+ end
150
+ ```
151
+
152
+ - [ ] **Step 1.1.2: Run specs to confirm they fail**
153
+
154
+ ```bash
155
+ bundle exec rspec spec/ollama_agent/tools/registry_spec.rb --no-color 2>&1 | tail -5
156
+ ```
157
+ Expected: `LoadError` or `NameError` (file doesn't exist yet).
158
+
159
+ - [ ] **Step 1.1.3: Create the registry**
160
+
161
+ ```ruby
162
+ # lib/ollama_agent/tools/registry.rb
163
+ # frozen_string_literal: true
164
+
165
+ module OllamaAgent
166
+ # Public namespace for tool registration.
167
+ # Library consumers call: OllamaAgent::Tools.register(:name, schema: {...}) { |args, root:, read_only:| ... }
168
+ module Tools
169
+ # Delegate class-methods to Registry so OllamaAgent::Tools.register(...) works.
170
+ def self.register(name, schema:, &block) = Registry.register(name, schema: schema, &block)
171
+ def self.custom_tool?(name) = Registry.custom_tool?(name)
172
+ def self.execute_custom(name, args, **kw) = Registry.execute_custom(name, args, **kw)
173
+ def self.custom_schemas = Registry.custom_schemas
174
+ def self.reset! = Registry.reset!
175
+
176
+ # Internal registry — used by SandboxedTools and tools_schema.rb.
177
+ module Registry
178
+ @custom_tools = {}
179
+
180
+ class << self
181
+ # Register a custom tool.
182
+ # schema: Hash with :description and :properties (and optionally :required) — the `function` body.
183
+ # The block receives (args_hash, root: String, read_only: Boolean).
184
+ def register(name, schema:, &handler)
185
+ @custom_tools[name.to_s] = { schema: schema, handler: handler }
186
+ end
187
+
188
+ def custom_tool?(name)
189
+ @custom_tools.key?(name.to_s)
190
+ end
191
+
192
+ # Execute a registered custom tool. Returns a string result.
193
+ def execute_custom(name, args, root:, read_only:)
194
+ entry = @custom_tools[name.to_s]
195
+ return "Unknown custom tool: #{name}" unless entry
196
+
197
+ entry[:handler].call(args, root: root, read_only: read_only)
198
+ end
199
+
200
+ # Returns tool schemas in the format Ollama's /api/chat expects.
201
+ def custom_schemas
202
+ @custom_tools.map do |name, entry|
203
+ {
204
+ type: "function",
205
+ function: entry[:schema].merge(name: name)
206
+ }
207
+ end
208
+ end
209
+
210
+ # Clear all registrations. Used in tests.
211
+ def reset!
212
+ @custom_tools = {}
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
218
+ ```
219
+
220
+ - [ ] **Step 1.1.4: Run specs to confirm they pass**
221
+
222
+ ```bash
223
+ bundle exec rspec spec/ollama_agent/tools/registry_spec.rb --no-color
224
+ ```
225
+ Expected: `4 examples, 0 failures`
226
+
227
+ - [ ] **Step 1.1.5: Commit**
228
+
229
+ ```bash
230
+ git add lib/ollama_agent/tools/registry.rb spec/ollama_agent/tools/registry_spec.rb
231
+ git commit -m "feat(tools): add custom Tool Registry for library consumers"
232
+ ```
233
+
234
+ ---
235
+
236
+ ### Task 1.2: Add `write_file` tool
237
+
238
+ **Files:**
239
+ - Modify: `lib/ollama_agent/tools_schema.rb`
240
+ - Modify: `lib/ollama_agent/sandboxed_tools.rb`
241
+ - Modify: `lib/ollama_agent/agent_prompt.rb`
242
+ - Modify: `spec/ollama_agent/sandboxed_tools_spec.rb`
243
+
244
+ - [ ] **Step 1.2.1: Write failing specs for write_file**
245
+
246
+ Append to `spec/ollama_agent/sandboxed_tools_spec.rb` (after the last `end` of the existing `describe "#execute_tool"` block):
247
+
248
+ ```ruby
249
+ context "write_file" do
250
+ it "creates a new file under the project root" do
251
+ agent = OllamaAgent::Agent.new(root: tmpdir, confirm_patches: false)
252
+ result = agent.send(:execute_tool, "write_file", { "path" => "new.rb", "content" => "# hello\n" })
253
+ expect(result).to include("wrote").or include("ok").or eq("Written: new.rb")
254
+ expect(File.read(File.join(tmpdir, "new.rb"))).to eq("# hello\n")
255
+ end
256
+
257
+ it "overwrites an existing file" do
258
+ File.write(File.join(tmpdir, "existing.rb"), "old\n")
259
+ agent = OllamaAgent::Agent.new(root: tmpdir, confirm_patches: false)
260
+ agent.send(:execute_tool, "write_file", { "path" => "existing.rb", "content" => "new\n" })
261
+ expect(File.read(File.join(tmpdir, "existing.rb"))).to eq("new\n")
262
+ end
263
+
264
+ it "rejects paths outside the project root" do
265
+ agent = OllamaAgent::Agent.new(root: tmpdir, confirm_patches: false)
266
+ result = agent.send(:execute_tool, "write_file", { "path" => "../../etc/passwd", "content" => "x" })
267
+ expect(result).to include("project root")
268
+ end
269
+
270
+ it "is disabled in read-only mode" do
271
+ agent = OllamaAgent::Agent.new(root: tmpdir, confirm_patches: false, read_only: true)
272
+ result = agent.send(:execute_tool, "write_file", { "path" => "f.rb", "content" => "x" })
273
+ expect(result).to include("read-only")
274
+ end
275
+
276
+ it "returns an error when path argument is missing" do
277
+ agent = OllamaAgent::Agent.new(root: tmpdir, confirm_patches: false)
278
+ result = agent.send(:execute_tool, "write_file", { "content" => "x" })
279
+ expect(result).to include("Missing required").and include("path")
280
+ end
281
+ end
282
+ ```
283
+
284
+ - [ ] **Step 1.2.2: Run to confirm failure**
285
+
286
+ ```bash
287
+ bundle exec rspec spec/ollama_agent/sandboxed_tools_spec.rb --no-color 2>&1 | tail -8
288
+ ```
289
+ Expected: `5 failures` for the new write_file examples.
290
+
291
+ - [ ] **Step 1.2.3: Add write_file schema to tools_schema.rb**
292
+
293
+ In `lib/ollama_agent/tools_schema.rb`, add after the `edit_file` tool definition (before the `].freeze` on line 78), and update `tools_for`:
294
+
295
+ ```ruby
296
+ # INSERT after the edit_file hash (before ].freeze):
297
+ {
298
+ type: "function",
299
+ function: {
300
+ name: "write_file",
301
+ description: "Create or overwrite a file under the project root with full UTF-8 content. " \
302
+ "Use for new files or complete rewrites. Prefer edit_file for surgical changes.",
303
+ parameters: {
304
+ type: "object",
305
+ properties: {
306
+ path: { type: "string", description: "File path relative to project root" },
307
+ content: { type: "string", description: "Full file content to write" }
308
+ },
309
+ required: %w[path content]
310
+ }
311
+ }
312
+ }
313
+ ```
314
+
315
+ Then update `READ_ONLY_TOOLS` (it already excludes edit_file; verify write_file is also excluded by checking the reject filter still uses `"edit_file"` only — update it):
316
+
317
+ ```ruby
318
+ # Replace the READ_ONLY_TOOLS line:
319
+ READ_ONLY_TOOLS = TOOLS.reject { |t| %w[edit_file write_file].include?(t.dig(:function, :name)) }.freeze
320
+ ```
321
+
322
+ Then update `tools_for` to merge custom schemas:
323
+
324
+ ```ruby
325
+ def self.tools_for(read_only:, orchestrator:)
326
+ base = read_only ? READ_ONLY_TOOLS : TOOLS
327
+ base = base + OllamaAgent::Tools::Registry.custom_schemas
328
+ return base unless orchestrator
329
+
330
+ base + (read_only ? ORCHESTRATOR_READ_ONLY_TOOLS : ORCHESTRATOR_TOOLS)
331
+ end
332
+ ```
333
+
334
+ - [ ] **Step 1.2.4: Add execute_write_file_tool to sandboxed_tools.rb**
335
+
336
+ Add to the `case` block in `SandboxedTools#execute_tool` (after the `edit_file` when clause):
337
+
338
+ ```ruby
339
+ when "write_file" then execute_write_file_tool(args)
340
+ ```
341
+
342
+ Then check for custom registry before the case:
343
+
344
+ ```ruby
345
+ def execute_tool(name, args)
346
+ args = coerce_tool_arguments(args)
347
+
348
+ # Custom tools registered by library consumers
349
+ if Tools::Registry.custom_tool?(name)
350
+ return Tools::Registry.execute_custom(name, args, root: @root, read_only: @read_only)
351
+ end
352
+
353
+ case name
354
+ when "read_file" then execute_read_file(args)
355
+ when "search_code" then execute_search_code(args)
356
+ when "list_files" then execute_list_files(args)
357
+ when "edit_file" then execute_edit_file_tool(args)
358
+ when "write_file" then execute_write_file_tool(args)
359
+ when "list_external_agents" then execute_list_external_agents(args)
360
+ when "delegate_to_agent" then execute_delegate_to_agent_tool(args)
361
+ else "Unknown tool: #{name}"
362
+ end
363
+ end
364
+ ```
365
+
366
+ Add the `execute_write_file_tool` method (after `execute_edit_file_tool`):
367
+
368
+ ```ruby
369
+ def execute_write_file_tool(args)
370
+ path = tool_arg(args, "path")
371
+ content = tool_arg(args, "content")
372
+ return missing_tool_argument("write_file", "path") if blank_tool_value?(path)
373
+ return missing_tool_argument("write_file", "content") if content.nil?
374
+
375
+ write_file(path, content)
376
+ end
377
+
378
+ def write_file(path, content)
379
+ return disallowed_path_message(path) unless path_allowed?(path)
380
+ return "write_file is disabled in read-only mode." if @read_only
381
+
382
+ if @confirm_patches
383
+ puts Console.patch_title("Proposed write_file for #{path}:")
384
+ puts content.to_s[0, 2000]
385
+ print Console.apply_prompt("Write file? (y/n) ")
386
+ return "Cancelled by user" unless $stdin.gets.to_s.chomp.casecmp("y").zero?
387
+ end
388
+
389
+ abs = resolve_path(path)
390
+ FileUtils.mkdir_p(File.dirname(abs))
391
+ File.write(abs, content.to_s, encoding: Encoding::UTF_8)
392
+ "Written: #{path}"
393
+ rescue Errno::EACCES => e
394
+ "Error writing file: #{e.message}"
395
+ end
396
+ ```
397
+
398
+ Add `require "fileutils"` to the top of `sandboxed_tools.rb` if not already present (it uses FileUtils in write_file — check; it already requires open3 and pathname; add fileutils).
399
+
400
+ - [ ] **Step 1.2.5: Update agent_prompt.rb**
401
+
402
+ In `AgentPrompt.text`, add one line to the tools list at the top:
403
+
404
+ ```ruby
405
+ # Replace this line:
406
+ You are a coding assistant with tools: list_files, read_file, search_code, edit_file.
407
+ # With:
408
+ You are a coding assistant with tools: list_files, read_file, search_code, edit_file, write_file.
409
+ ```
410
+
411
+ Also add after the `edit_file` paragraph:
412
+
413
+ ```ruby
414
+ Use write_file to create a new file or fully replace an existing file with complete content.
415
+ Prefer edit_file for surgical changes to existing files; reserve write_file for new files or full rewrites.
416
+ ```
417
+
418
+ - [ ] **Step 1.2.6: Require registry in lib/ollama_agent.rb**
419
+
420
+ Add before the `module OllamaAgent` block:
421
+
422
+ ```ruby
423
+ require_relative "ollama_agent/tools/registry"
424
+ ```
425
+
426
+ Add after the existing requires (order matters — before agent.rb which uses SandboxedTools):
427
+
428
+ ```ruby
429
+ # lib/ollama_agent.rb — updated require block:
430
+ require_relative "ollama_agent/version"
431
+ require "ollama_client"
432
+ require_relative "ollama_agent/tools/registry" # NEW — must load before agent
433
+ require_relative "ollama_agent/console"
434
+ require_relative "ollama_agent/agent"
435
+ require_relative "ollama_agent/cli"
436
+ ```
437
+
438
+ - [ ] **Step 1.2.7: Run specs to confirm write_file specs pass**
439
+
440
+ ```bash
441
+ bundle exec rspec spec/ollama_agent/sandboxed_tools_spec.rb spec/ollama_agent/tools/registry_spec.rb --no-color
442
+ ```
443
+ Expected: all examples pass.
444
+
445
+ - [ ] **Step 1.2.8: Run the full suite to confirm nothing regressed**
446
+
447
+ ```bash
448
+ bundle exec rspec --no-color 2>&1 | tail -5
449
+ ```
450
+ Expected: `0 failures`
451
+
452
+ - [ ] **Step 1.2.9: Commit**
453
+
454
+ ```bash
455
+ git add lib/ollama_agent/tools/registry.rb \
456
+ lib/ollama_agent/tools_schema.rb \
457
+ lib/ollama_agent/sandboxed_tools.rb \
458
+ lib/ollama_agent/agent_prompt.rb \
459
+ lib/ollama_agent.rb \
460
+ spec/ollama_agent/tools/registry_spec.rb \
461
+ spec/ollama_agent/sandboxed_tools_spec.rb
462
+ git commit -m "feat(tools): add write_file tool and extensible Tools::Registry"
463
+ ```
464
+
465
+ ---
466
+
467
+ ## Layer 2 — Streaming + Hooks
468
+
469
+ ### Task 2.1: Streaming::Hooks event bus
470
+
471
+ **Files:**
472
+ - Create: `lib/ollama_agent/streaming/hooks.rb`
473
+ - Create: `spec/ollama_agent/streaming/hooks_spec.rb`
474
+
475
+ - [ ] **Step 2.1.1: Write failing specs**
476
+
477
+ ```ruby
478
+ # spec/ollama_agent/streaming/hooks_spec.rb
479
+ # frozen_string_literal: true
480
+
481
+ require "spec_helper"
482
+ require_relative "../../../lib/ollama_agent/streaming/hooks"
483
+
484
+ RSpec.describe OllamaAgent::Streaming::Hooks do
485
+ subject(:hooks) { described_class.new }
486
+
487
+ describe "#on and #emit" do
488
+ it "calls a registered handler when the event is emitted" do
489
+ received = []
490
+ hooks.on(:on_token) { |p| received << p[:token] }
491
+ hooks.emit(:on_token, { token: "hello", turn: 1 })
492
+ expect(received).to eq(["hello"])
493
+ end
494
+
495
+ it "calls multiple handlers for the same event" do
496
+ calls = []
497
+ hooks.on(:on_complete) { |_| calls << :a }
498
+ hooks.on(:on_complete) { |_| calls << :b }
499
+ hooks.emit(:on_complete, { messages: [], turns: 1 })
500
+ expect(calls).to contain_exactly(:a, :b)
501
+ end
502
+
503
+ it "does nothing when no handler is registered for an event" do
504
+ expect { hooks.emit(:on_token, { token: "x", turn: 1 }) }.not_to raise_error
505
+ end
506
+
507
+ it "silently ignores unknown event names on emit" do
508
+ expect { hooks.emit(:unknown_event, {}) }.not_to raise_error
509
+ end
510
+ end
511
+
512
+ describe "#subscribed?" do
513
+ it "returns false when no handler registered" do
514
+ expect(hooks.subscribed?(:on_token)).to be false
515
+ end
516
+
517
+ it "returns true after a handler is registered" do
518
+ hooks.on(:on_token) { |_| }
519
+ expect(hooks.subscribed?(:on_token)).to be true
520
+ end
521
+ end
522
+
523
+ describe "EVENTS constant" do
524
+ it "includes all expected event names" do
525
+ expected = %i[on_token on_chunk on_tool_call on_tool_result on_complete on_error on_retry]
526
+ expected.each do |event|
527
+ expect(described_class::EVENTS).to include(event)
528
+ end
529
+ end
530
+ end
531
+ end
532
+ ```
533
+
534
+ - [ ] **Step 2.1.2: Run to confirm failure**
535
+
536
+ ```bash
537
+ bundle exec rspec spec/ollama_agent/streaming/hooks_spec.rb --no-color 2>&1 | tail -5
538
+ ```
539
+ Expected: `LoadError`
540
+
541
+ - [ ] **Step 2.1.3: Implement Streaming::Hooks**
542
+
543
+ ```ruby
544
+ # lib/ollama_agent/streaming/hooks.rb
545
+ # frozen_string_literal: true
546
+
547
+ module OllamaAgent
548
+ module Streaming
549
+ # Lightweight event bus for agent lifecycle events.
550
+ # All layers share one Hooks instance per Agent run.
551
+ class Hooks
552
+ EVENTS = %i[on_token on_chunk on_tool_call on_tool_result on_complete on_error on_retry].freeze
553
+
554
+ def initialize
555
+ @handlers = Hash.new { |h, k| h[k] = [] }
556
+ end
557
+
558
+ # Register a handler block for a named event.
559
+ def on(event, &block)
560
+ @handlers[event] << block
561
+ end
562
+
563
+ # Fire all handlers for the event with the given payload hash.
564
+ # Handler errors are swallowed to prevent a bad subscriber from crashing the agent.
565
+ def emit(event, payload)
566
+ @handlers[event].each do |handler|
567
+ handler.call(payload)
568
+ rescue StandardError
569
+ nil
570
+ end
571
+ end
572
+
573
+ # Returns true if at least one handler is registered for the event.
574
+ def subscribed?(event)
575
+ @handlers[event].any?
576
+ end
577
+ end
578
+ end
579
+ end
580
+ ```
581
+
582
+ - [ ] **Step 2.1.4: Run specs to confirm pass**
583
+
584
+ ```bash
585
+ bundle exec rspec spec/ollama_agent/streaming/hooks_spec.rb --no-color
586
+ ```
587
+ Expected: `7 examples, 0 failures`
588
+
589
+ - [ ] **Step 2.1.5: Write ConsoleStreamer spec and implementation**
590
+
591
+ ```ruby
592
+ # spec/ollama_agent/streaming/console_streamer_spec.rb
593
+ # frozen_string_literal: true
594
+
595
+ require "spec_helper"
596
+ require_relative "../../../lib/ollama_agent/streaming/hooks"
597
+ require_relative "../../../lib/ollama_agent/streaming/console_streamer"
598
+
599
+ RSpec.describe OllamaAgent::Streaming::ConsoleStreamer do
600
+ subject(:streamer) { described_class.new }
601
+ let(:hooks) { OllamaAgent::Streaming::Hooks.new }
602
+
603
+ it "registers handlers for on_token, on_tool_call, on_tool_result, and on_complete" do
604
+ streamer.attach(hooks)
605
+ %i[on_token on_tool_call on_tool_result on_complete].each do |event|
606
+ expect(hooks.subscribed?(event)).to be true
607
+ end
608
+ end
609
+
610
+ it "prints a token when on_token fires" do
611
+ streamer.attach(hooks)
612
+ expect { hooks.emit(:on_token, { token: "hi", turn: 1 }) }.to output("hi").to_stdout
613
+ end
614
+ end
615
+ ```
616
+
617
+ Run: `bundle exec rspec spec/ollama_agent/streaming/console_streamer_spec.rb --no-color`
618
+ Expected: pass after ConsoleStreamer is created in Task 2.2.4.
619
+
620
+ - [ ] **Step 2.1.6: Commit**
621
+
622
+ ```bash
623
+ git add lib/ollama_agent/streaming/hooks.rb spec/ollama_agent/streaming/hooks_spec.rb
624
+ git commit -m "feat(streaming): add Streaming::Hooks event bus"
625
+ ```
626
+
627
+ ---
628
+
629
+ ### Task 2.2: Wire Hooks into Agent + ConsoleStreamer
630
+
631
+ **Files:**
632
+ - Create: `lib/ollama_agent/streaming/console_streamer.rb`
633
+ - Modify: `lib/ollama_agent/agent.rb`
634
+ - Modify: `lib/ollama_agent/cli.rb`
635
+ - Modify: `lib/ollama_agent.rb`
636
+
637
+ - [ ] **Step 2.2.1: Write failing spec for Agent hooks wiring**
638
+
639
+ Add to `spec/ollama_agent/agent_spec.rb` (before the final `end`):
640
+
641
+ ```ruby
642
+ describe "streaming hooks" do
643
+ it "exposes a Hooks instance" do
644
+ client = instance_double(Ollama::Client)
645
+ allow(client).to receive(:chat).and_return(
646
+ Ollama::Response.new("message" => { "role" => "assistant", "content" => "done" })
647
+ )
648
+ agent = described_class.new(client: client, root: root)
649
+ expect(agent.hooks).to be_a(OllamaAgent::Streaming::Hooks)
650
+ end
651
+
652
+ it "emits on_tool_call and on_tool_result when a tool executes" do
653
+ File.write(File.join(root, "f.txt"), "content")
654
+
655
+ tool_response = Ollama::Response.new(
656
+ "message" => {
657
+ "role" => "assistant", "content" => "",
658
+ "tool_calls" => [
659
+ { "id" => "1", "function" => { "name" => "read_file",
660
+ "arguments" => { "path" => "f.txt" }.to_json } }
661
+ ]
662
+ }
663
+ )
664
+ final = Ollama::Response.new("message" => { "role" => "assistant", "content" => "ok" })
665
+
666
+ client = instance_double(Ollama::Client)
667
+ allow(client).to receive(:chat).and_return(tool_response, final)
668
+
669
+ tool_calls = []
670
+ tool_results = []
671
+ agent = described_class.new(client: client, root: root, confirm_patches: false)
672
+ agent.hooks.on(:on_tool_call) { |p| tool_calls << p[:name] }
673
+ agent.hooks.on(:on_tool_result) { |p| tool_results << p[:name] }
674
+
675
+ agent.run("read f")
676
+ expect(tool_calls).to eq(["read_file"])
677
+ expect(tool_results).to eq(["read_file"])
678
+ end
679
+
680
+ it "emits on_complete when the loop finishes" do
681
+ client = instance_double(Ollama::Client)
682
+ allow(client).to receive(:chat).and_return(
683
+ Ollama::Response.new("message" => { "role" => "assistant", "content" => "done" })
684
+ )
685
+ agent = described_class.new(client: client, root: root)
686
+ completed = false
687
+ agent.hooks.on(:on_complete) { |_| completed = true }
688
+ agent.run("hello")
689
+ expect(completed).to be true
690
+ end
691
+ end
692
+ ```
693
+
694
+ - [ ] **Step 2.2.2: Run to confirm failure**
695
+
696
+ ```bash
697
+ bundle exec rspec spec/ollama_agent/agent_spec.rb --no-color 2>&1 | tail -8
698
+ ```
699
+ Expected: `NoMethodError: undefined method 'hooks'`
700
+
701
+ - [ ] **Step 2.2.3: Update agent.rb to wire Hooks**
702
+
703
+ In `lib/ollama_agent/agent.rb`:
704
+
705
+ 1. Add require at top: `require_relative "streaming/hooks"`
706
+
707
+ 2. Add `attr_reader :hooks` to the public attrs (alongside `:client, :root`):
708
+ ```ruby
709
+ attr_reader :client, :root, :hooks
710
+ ```
711
+
712
+ 3. In `initialize`, add at the end (before `@client = ...`):
713
+ ```ruby
714
+ @hooks = Streaming::Hooks.new
715
+ ```
716
+
717
+ 4. Update `append_tool_results` to emit events:
718
+ ```ruby
719
+ def append_tool_results(messages, tool_calls)
720
+ tool_calls.each do |tool_call|
721
+ @hooks.emit(:on_tool_call, { name: tool_call.name, args: tool_call.arguments || {}, turn: current_turn })
722
+ result = execute_tool(tool_call.name, tool_call.arguments || {})
723
+ @hooks.emit(:on_tool_result, { name: tool_call.name, result: result.to_s, turn: current_turn })
724
+ messages << tool_message(tool_call, result)
725
+ end
726
+ end
727
+ ```
728
+
729
+ 5. Add a `@current_turn` counter. In `execute_agent_turns`:
730
+ ```ruby
731
+ def execute_agent_turns(messages)
732
+ @current_turn = 0
733
+ max_turns.times do
734
+ @current_turn += 1
735
+ message = chat_assistant_message(messages)
736
+ tool_calls = tool_calls_from(message)
737
+ messages << message.to_h
738
+ break if tool_calls.empty?
739
+
740
+ append_tool_results(messages, tool_calls)
741
+ end
742
+
743
+ @hooks.emit(:on_complete, { messages: messages, turns: @current_turn })
744
+ warn "ollama_agent: maximum tool rounds (#{max_turns}) reached" if ENV["OLLAMA_AGENT_DEBUG"] == "1" && @current_turn >= max_turns
745
+ end
746
+
747
+ def current_turn
748
+ @current_turn || 0
749
+ end
750
+ ```
751
+
752
+ Note: The original `execute_agent_turns` used `return` to break — update it to use `break` instead to allow `on_complete` to always fire.
753
+
754
+ - [ ] **Step 2.2.4: Create ConsoleStreamer**
755
+
756
+ ```ruby
757
+ # lib/ollama_agent/streaming/console_streamer.rb
758
+ # frozen_string_literal: true
759
+
760
+ require_relative "../console"
761
+
762
+ module OllamaAgent
763
+ module Streaming
764
+ # Attaches to a Hooks instance to print live streaming output to stdout.
765
+ # Auto-attached by CLI when --stream is passed and stdout is a TTY.
766
+ class ConsoleStreamer
767
+ def attach(hooks)
768
+ hooks.on(:on_token) { |p| print p[:token]; $stdout.flush }
769
+ hooks.on(:on_tool_call) { |p| warn Console.tool_call_line(p[:name], p[:args]) }
770
+ hooks.on(:on_tool_result) { |p| warn Console.tool_result_line(p[:name], p[:result]) }
771
+ hooks.on(:on_complete) { puts } # final newline after last token
772
+ end
773
+ end
774
+ end
775
+ end
776
+ ```
777
+
778
+ Add two helper methods to `lib/ollama_agent/console.rb` (at end of module):
779
+
780
+ ```ruby
781
+ def self.tool_call_line(name, args)
782
+ keys = args.keys.first(2).join(", ")
783
+ colorize("[tool→] #{name}(#{keys})", :cyan)
784
+ end
785
+
786
+ def self.tool_result_line(name, result)
787
+ preview = result.to_s[0, 60].gsub(/\s+/, " ")
788
+ colorize("[tool←] #{name}: #{preview}", :dim)
789
+ end
790
+ ```
791
+
792
+ Check that `colorize` exists in `Console` — it does (used in `patch_title`). If the exact method name differs, look at the existing console.rb and use the equivalent pattern.
793
+
794
+ - [ ] **Step 2.2.5: Add --stream flag to CLI**
795
+
796
+ In `lib/ollama_agent/cli.rb`, add to the `ask` method options block:
797
+
798
+ ```ruby
799
+ method_option :stream, type: :boolean, default: false,
800
+ desc: "Stream tokens to terminal as they arrive (OLLAMA_AGENT_STREAM=1)"
801
+ ```
802
+
803
+ Add the same option to `orchestrate`, `self_review`, and `improve` method_option blocks.
804
+
805
+ In `build_agent` (and `build_orchestrator_agent`), after `Agent.new(...)`, attach the streamer if requested:
806
+
807
+ ```ruby
808
+ def build_agent
809
+ orch = orchestrator_mode?
810
+ agent = Agent.new(
811
+ model: options[:model],
812
+ root: resolved_root_for_self_review,
813
+ confirm_patches: !options[:yes],
814
+ http_timeout: options[:timeout],
815
+ think: options[:think],
816
+ orchestrator: orch,
817
+ confirm_delegation: orch ? !options[:yes] : true,
818
+ **skill_agent_options
819
+ )
820
+ attach_console_streamer(agent) if stream_enabled?
821
+ agent
822
+ end
823
+
824
+ def stream_enabled?
825
+ options[:stream] || ENV.fetch("OLLAMA_AGENT_STREAM", "0") == "1"
826
+ end
827
+
828
+ def attach_console_streamer(agent)
829
+ Streaming::ConsoleStreamer.new.attach(agent.hooks)
830
+ end
831
+ ```
832
+
833
+ - [ ] **Step 2.2.6: Require streaming in lib/ollama_agent.rb**
834
+
835
+ ```ruby
836
+ require_relative "ollama_agent/streaming/hooks"
837
+ require_relative "ollama_agent/streaming/console_streamer"
838
+ ```
839
+
840
+ Add before `require_relative "ollama_agent/agent"`.
841
+
842
+ - [ ] **Step 2.2.7: Run all specs**
843
+
844
+ ```bash
845
+ bundle exec rspec --no-color 2>&1 | tail -5
846
+ ```
847
+ Expected: `0 failures`
848
+
849
+ - [ ] **Step 2.2.8: Commit**
850
+
851
+ ```bash
852
+ git add lib/ollama_agent/streaming/ \
853
+ lib/ollama_agent/agent.rb \
854
+ lib/ollama_agent/cli.rb \
855
+ lib/ollama_agent/console.rb \
856
+ lib/ollama_agent.rb \
857
+ spec/ollama_agent/streaming/ \
858
+ spec/ollama_agent/agent_spec.rb
859
+ git commit -m "feat(streaming): wire Hooks into Agent; add ConsoleStreamer and --stream flag"
860
+ ```
861
+
862
+ ---
863
+
864
+ ## Layer 3 — Resilience (Retry + Audit Logger)
865
+
866
+ ### Task 3.1: RetryMiddleware
867
+
868
+ **Files:**
869
+ - Create: `lib/ollama_agent/resilience/retry_middleware.rb`
870
+ - Create: `spec/ollama_agent/resilience/retry_middleware_spec.rb`
871
+
872
+ - [ ] **Step 3.1.1: Write failing specs**
873
+
874
+ ```ruby
875
+ # spec/ollama_agent/resilience/retry_middleware_spec.rb
876
+ # frozen_string_literal: true
877
+
878
+ require "spec_helper"
879
+ require_relative "../../../lib/ollama_agent/resilience/retry_middleware"
880
+ require_relative "../../../lib/ollama_agent/streaming/hooks"
881
+
882
+ RSpec.describe OllamaAgent::Resilience::RetryMiddleware do
883
+ let(:hooks) { OllamaAgent::Streaming::Hooks.new }
884
+
885
+ def make_client(responses)
886
+ client = double("client")
887
+ allow(client).to receive(:chat).and_invoke(*responses.map { |r|
888
+ r.is_a?(Class) ? ->(**_) { raise r } : ->(**_) { r }
889
+ })
890
+ client
891
+ end
892
+
893
+ describe "#chat" do
894
+ it "passes through when the first call succeeds" do
895
+ response = double("response")
896
+ client = make_client([response])
897
+ mw = described_class.new(client: client, max_attempts: 3, hooks: hooks, base_delay: 0)
898
+ expect(mw.chat(messages: [], tools: [], model: "m")).to eq(response)
899
+ end
900
+
901
+ it "retries on Timeout::Error and succeeds on the second attempt" do
902
+ response = double("response")
903
+ client = make_client([Timeout::Error, response])
904
+ mw = described_class.new(client: client, max_attempts: 3, hooks: hooks, base_delay: 0)
905
+ expect(mw.chat(messages: [], tools: [], model: "m")).to eq(response)
906
+ end
907
+
908
+ it "raises after exhausting max_attempts" do
909
+ client = make_client([Timeout::Error, Timeout::Error, Timeout::Error])
910
+ mw = described_class.new(client: client, max_attempts: 3, hooks: hooks, base_delay: 0)
911
+ expect { mw.chat(messages: [], tools: [], model: "m") }.to raise_error(Timeout::Error)
912
+ end
913
+
914
+ it "does not retry non-retryable errors" do
915
+ client = make_client([ArgumentError])
916
+ mw = described_class.new(client: client, max_attempts: 3, hooks: hooks, base_delay: 0)
917
+ expect { mw.chat(messages: [], tools: [], model: "m") }.to raise_error(ArgumentError)
918
+ end
919
+
920
+ it "emits on_retry hook on each retry attempt" do
921
+ response = double("response")
922
+ client = make_client([Timeout::Error, response])
923
+ mw = described_class.new(client: client, max_attempts: 3, hooks: hooks, base_delay: 0)
924
+ retries = []
925
+ hooks.on(:on_retry) { |p| retries << p[:attempt] }
926
+ mw.chat(messages: [], tools: [], model: "m")
927
+ expect(retries).to eq([1])
928
+ end
929
+
930
+ it "does not retry when max_attempts is 1" do
931
+ client = make_client([Timeout::Error])
932
+ mw = described_class.new(client: client, max_attempts: 1, hooks: hooks, base_delay: 0)
933
+ expect { mw.chat(messages: [], tools: [], model: "m") }.to raise_error(Timeout::Error)
934
+ end
935
+ end
936
+ end
937
+ ```
938
+
939
+ - [ ] **Step 3.1.2: Run to confirm failure**
940
+
941
+ ```bash
942
+ bundle exec rspec spec/ollama_agent/resilience/retry_middleware_spec.rb --no-color 2>&1 | tail -5
943
+ ```
944
+ Expected: `LoadError`
945
+
946
+ - [ ] **Step 3.1.3: Implement RetryMiddleware**
947
+
948
+ ```ruby
949
+ # lib/ollama_agent/resilience/retry_middleware.rb
950
+ # frozen_string_literal: true
951
+
952
+ require "timeout"
953
+
954
+ module OllamaAgent
955
+ module Resilience
956
+ # Wraps Ollama::Client#chat with exponential backoff retry for transient errors.
957
+ class RetryMiddleware
958
+ DEFAULT_MAX_ATTEMPTS = 3
959
+ DEFAULT_BASE_DELAY = 2.0
960
+
961
+ # Ollama::TimeoutError is the primary retryable. It may inherit from Timeout::Error.
962
+ # Check `Ollama::TimeoutError.ancestors` in your ollama-client version; add Timeout::Error
963
+ # as a fallback if Ollama::TimeoutError is not defined.
964
+ RETRYABLE = begin
965
+ [Ollama::TimeoutError, Errno::ECONNREFUSED, Errno::ECONNRESET]
966
+ rescue NameError
967
+ [Timeout::Error, Errno::ECONNREFUSED, Errno::ECONNRESET]
968
+ end.freeze
969
+
970
+ def initialize(client:, max_attempts: DEFAULT_MAX_ATTEMPTS, hooks: nil, base_delay: DEFAULT_BASE_DELAY)
971
+ @client = client
972
+ @max_attempts = max_attempts.to_i
973
+ @hooks = hooks
974
+ @base_delay = base_delay.to_f
975
+ end
976
+
977
+ def chat(**args)
978
+ attempt = 0
979
+ begin
980
+ @client.chat(**args)
981
+ rescue *RETRYABLE => e
982
+ attempt += 1
983
+ raise if attempt >= @max_attempts
984
+
985
+ delay = backoff(attempt)
986
+ @hooks&.emit(:on_retry, { error: e, attempt: attempt, delay_ms: (delay * 1000).round })
987
+ sleep delay
988
+ retry
989
+ end
990
+ end
991
+
992
+ private
993
+
994
+ def backoff(attempt)
995
+ jitter = rand * 0.5
996
+ [@base_delay * (2**(attempt - 1)) + jitter, 30.0].min
997
+ end
998
+ end
999
+ end
1000
+ end
1001
+ ```
1002
+
1003
+ - [ ] **Step 3.1.4: Run specs**
1004
+
1005
+ ```bash
1006
+ bundle exec rspec spec/ollama_agent/resilience/retry_middleware_spec.rb --no-color
1007
+ ```
1008
+ Expected: `6 examples, 0 failures`
1009
+
1010
+ - [ ] **Step 3.1.5: Wire RetryMiddleware into Agent**
1011
+
1012
+ In `lib/ollama_agent/agent.rb`, add at top: `require_relative "resilience/retry_middleware"`
1013
+
1014
+ Update `initialize` to accept `max_retries:`:
1015
+ ```ruby
1016
+ # Add to the parameter list:
1017
+ max_retries: nil,
1018
+ # Add to the body:
1019
+ @max_retries = max_retries
1020
+ ```
1021
+
1022
+ Update `build_default_client` to wrap the Ollama client:
1023
+ ```ruby
1024
+ def build_default_client
1025
+ config = Ollama::Config.new
1026
+ @http_timeout_seconds = resolved_http_timeout_seconds
1027
+ config.timeout = @http_timeout_seconds
1028
+ OllamaConnection.apply_env_to_config(config)
1029
+ ollama_client = Ollama::Client.new(config: config)
1030
+ Resilience::RetryMiddleware.new(
1031
+ client: ollama_client,
1032
+ max_attempts: resolved_max_retries,
1033
+ hooks: @hooks,
1034
+ base_delay: resolved_retry_base_delay
1035
+ )
1036
+ end
1037
+
1038
+ def resolved_max_retries
1039
+ return @max_retries unless @max_retries.nil?
1040
+
1041
+ v = ENV.fetch("OLLAMA_AGENT_MAX_RETRIES", nil)
1042
+ return Resilience::RetryMiddleware::DEFAULT_MAX_ATTEMPTS if v.nil? || v.strip.empty?
1043
+
1044
+ Integer(v)
1045
+ rescue ArgumentError, TypeError
1046
+ Resilience::RetryMiddleware::DEFAULT_MAX_ATTEMPTS
1047
+ end
1048
+
1049
+ def resolved_retry_base_delay
1050
+ v = ENV.fetch("OLLAMA_AGENT_RETRY_BASE_DELAY", nil)
1051
+ return Resilience::RetryMiddleware::DEFAULT_BASE_DELAY if v.nil? || v.strip.empty?
1052
+
1053
+ Float(v)
1054
+ rescue ArgumentError, TypeError
1055
+ Resilience::RetryMiddleware::DEFAULT_BASE_DELAY
1056
+ end
1057
+ ```
1058
+
1059
+ Note: The existing `agent_spec.rb` uses `instance_double(Ollama::Client)` — these pass `client:` directly, bypassing `build_default_client`. No existing specs break.
1060
+
1061
+ - [ ] **Step 3.1.6: Run full suite**
1062
+
1063
+ ```bash
1064
+ bundle exec rspec --no-color 2>&1 | tail -5
1065
+ ```
1066
+ Expected: `0 failures`
1067
+
1068
+ - [ ] **Step 3.1.7: Commit**
1069
+
1070
+ ```bash
1071
+ git add lib/ollama_agent/resilience/retry_middleware.rb \
1072
+ lib/ollama_agent/agent.rb \
1073
+ spec/ollama_agent/resilience/retry_middleware_spec.rb
1074
+ git commit -m "feat(resilience): add RetryMiddleware with exponential backoff"
1075
+ ```
1076
+
1077
+ ---
1078
+
1079
+ ### Task 3.2: AuditLogger
1080
+
1081
+ **Files:**
1082
+ - Create: `lib/ollama_agent/resilience/audit_logger.rb`
1083
+ - Create: `spec/ollama_agent/resilience/audit_logger_spec.rb`
1084
+
1085
+ - [ ] **Step 3.2.1: Write failing specs**
1086
+
1087
+ ```ruby
1088
+ # spec/ollama_agent/resilience/audit_logger_spec.rb
1089
+ # frozen_string_literal: true
1090
+
1091
+ require "spec_helper"
1092
+ require "json"
1093
+ require "tmpdir"
1094
+ require_relative "../../../lib/ollama_agent/resilience/audit_logger"
1095
+ require_relative "../../../lib/ollama_agent/streaming/hooks"
1096
+
1097
+ RSpec.describe OllamaAgent::Resilience::AuditLogger do
1098
+ let(:log_dir) { Dir.mktmpdir }
1099
+ let(:hooks) { OllamaAgent::Streaming::Hooks.new }
1100
+
1101
+ after { FileUtils.remove_entry(log_dir) }
1102
+
1103
+ def attach_and_emit(event, payload)
1104
+ logger = described_class.new(log_dir: log_dir, hooks: hooks)
1105
+ logger.attach
1106
+ hooks.emit(event, payload)
1107
+ end
1108
+
1109
+ def read_log_lines
1110
+ files = Dir.glob(File.join(log_dir, "*.ndjson"))
1111
+ return [] if files.empty?
1112
+
1113
+ File.read(files.first).lines.map { |l| JSON.parse(l) }
1114
+ end
1115
+
1116
+ describe "#attach" do
1117
+ it "writes a tool_call entry to the log on on_tool_call" do
1118
+ attach_and_emit(:on_tool_call, { name: "read_file", args: { "path" => "x.rb" }, turn: 1 })
1119
+ lines = read_log_lines
1120
+ expect(lines.size).to eq(1)
1121
+ expect(lines.first["event"]).to eq("tool_call")
1122
+ expect(lines.first["name"]).to eq("read_file")
1123
+ end
1124
+
1125
+ it "writes a tool_result entry on on_tool_result" do
1126
+ attach_and_emit(:on_tool_result, { name: "read_file", result: "content here", turn: 1 })
1127
+ lines = read_log_lines
1128
+ expect(lines.first["event"]).to eq("tool_result")
1129
+ end
1130
+
1131
+ it "writes an agent_complete entry on on_complete" do
1132
+ attach_and_emit(:on_complete, { messages: [], turns: 3 })
1133
+ lines = read_log_lines
1134
+ expect(lines.first["event"]).to eq("agent_complete")
1135
+ expect(lines.first["turns"]).to eq(3)
1136
+ end
1137
+
1138
+ it "writes an http_retry entry on on_retry" do
1139
+ attach_and_emit(:on_retry, { error: Timeout::Error.new("t"), attempt: 1, delay_ms: 2000 })
1140
+ lines = read_log_lines
1141
+ expect(lines.first["event"]).to eq("http_retry")
1142
+ expect(lines.first["attempt"]).to eq(1)
1143
+ end
1144
+
1145
+ it "does not raise when the log dir is not writable" do
1146
+ logger = described_class.new(log_dir: "/proc/nonexistent_dir_that_cannot_exist", hooks: hooks)
1147
+ expect { logger.attach }.not_to raise_error
1148
+ hooks.emit(:on_tool_call, { name: "t", args: {}, turn: 1 })
1149
+ # should not raise even though write fails
1150
+ end
1151
+
1152
+ it "creates the log directory automatically if missing" do
1153
+ missing = File.join(log_dir, "nested", "logs")
1154
+ logger = described_class.new(log_dir: missing, hooks: hooks)
1155
+ logger.attach
1156
+ hooks.emit(:on_complete, { messages: [], turns: 1 })
1157
+ expect(Dir.exist?(missing)).to be true
1158
+ end
1159
+ end
1160
+ end
1161
+ ```
1162
+
1163
+ - [ ] **Step 3.2.2: Run to confirm failure**
1164
+
1165
+ ```bash
1166
+ bundle exec rspec spec/ollama_agent/resilience/audit_logger_spec.rb --no-color 2>&1 | tail -5
1167
+ ```
1168
+ Expected: `LoadError`
1169
+
1170
+ - [ ] **Step 3.2.3: Implement AuditLogger**
1171
+
1172
+ ```ruby
1173
+ # lib/ollama_agent/resilience/audit_logger.rb
1174
+ # frozen_string_literal: true
1175
+
1176
+ require "fileutils"
1177
+ require "json"
1178
+
1179
+ module OllamaAgent
1180
+ module Resilience
1181
+ # Subscribes to Streaming::Hooks and writes structured NDJSON audit logs.
1182
+ # Activated by OLLAMA_AGENT_AUDIT=1 or audit: true in Runner.build.
1183
+ class AuditLogger
1184
+ DEFAULT_MAX_RESULT_BYTES = 4_096
1185
+
1186
+ def initialize(log_dir:, hooks:, max_result_bytes: nil)
1187
+ @log_dir = log_dir
1188
+ @hooks = hooks
1189
+ @max_result_bytes = max_result_bytes || env_max_result_bytes
1190
+ end
1191
+
1192
+ def attach
1193
+ @hooks.on(:on_tool_call) { |p| write_entry(tool_call_entry(p)) }
1194
+ @hooks.on(:on_tool_result) { |p| write_entry(tool_result_entry(p)) }
1195
+ @hooks.on(:on_complete) { |p| write_entry(complete_entry(p)) }
1196
+ @hooks.on(:on_error) { |p| write_entry(error_entry(p)) }
1197
+ @hooks.on(:on_retry) { |p| write_entry(retry_entry(p)) }
1198
+ end
1199
+
1200
+ private
1201
+
1202
+ def write_entry(hash)
1203
+ FileUtils.mkdir_p(@log_dir)
1204
+ path = log_path
1205
+ File.open(path, "a", encoding: Encoding::UTF_8) do |f|
1206
+ f.puts(JSON.generate(hash))
1207
+ end
1208
+ rescue StandardError
1209
+ nil # best-effort: logging failure must never crash the agent
1210
+ end
1211
+
1212
+ def log_path
1213
+ date = Time.now.strftime("%Y-%m-%d")
1214
+ File.join(@log_dir, "#{date}.ndjson")
1215
+ end
1216
+
1217
+ def ts
1218
+ Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
1219
+ end
1220
+
1221
+ def tool_call_entry(p)
1222
+ { ts: ts, event: "tool_call", name: p[:name], args: p[:args], turn: p[:turn] }
1223
+ end
1224
+
1225
+ def tool_result_entry(p)
1226
+ result = p[:result].to_s
1227
+ result = result.byteslice(0, @max_result_bytes) if result.bytesize > @max_result_bytes
1228
+ { ts: ts, event: "tool_result", name: p[:name], bytes: p[:result].to_s.bytesize,
1229
+ result_preview: result, turn: p[:turn] }
1230
+ end
1231
+
1232
+ def complete_entry(p)
1233
+ { ts: ts, event: "agent_complete", turns: p[:turns] }
1234
+ end
1235
+
1236
+ def error_entry(p)
1237
+ { ts: ts, event: "agent_error", error: p[:error].class.name, message: p[:error].message,
1238
+ turn: p[:turn] }
1239
+ end
1240
+
1241
+ def retry_entry(p)
1242
+ { ts: ts, event: "http_retry", attempt: p[:attempt], delay_ms: p[:delay_ms],
1243
+ error: p[:error].class.name }
1244
+ end
1245
+
1246
+ def env_max_result_bytes
1247
+ v = ENV.fetch("OLLAMA_AGENT_AUDIT_MAX_RESULT_BYTES", nil)
1248
+ return DEFAULT_MAX_RESULT_BYTES if v.nil? || v.strip.empty?
1249
+
1250
+ Integer(v)
1251
+ rescue ArgumentError, TypeError
1252
+ DEFAULT_MAX_RESULT_BYTES
1253
+ end
1254
+ end
1255
+ end
1256
+ end
1257
+ ```
1258
+
1259
+ - [ ] **Step 3.2.4: Run specs**
1260
+
1261
+ ```bash
1262
+ bundle exec rspec spec/ollama_agent/resilience/audit_logger_spec.rb --no-color
1263
+ ```
1264
+ Expected: `6 examples, 0 failures`
1265
+
1266
+ - [ ] **Step 3.2.5: Wire AuditLogger into Agent**
1267
+
1268
+ In `lib/ollama_agent/agent.rb`, add: `require_relative "resilience/audit_logger"`
1269
+
1270
+ Add `audit:` kwarg to `initialize`:
1271
+ ```ruby
1272
+ # In parameter list:
1273
+ audit: false,
1274
+ # In body:
1275
+ @audit = audit
1276
+ ```
1277
+
1278
+ At end of `initialize` (after `@hooks = Streaming::Hooks.new`):
1279
+ ```ruby
1280
+ attach_audit_logger if resolved_audit_enabled
1281
+ ```
1282
+
1283
+ Add private method:
1284
+ ```ruby
1285
+ def resolved_audit_enabled
1286
+ return @audit unless @audit == false
1287
+
1288
+ ENV.fetch("OLLAMA_AGENT_AUDIT", "0") == "1"
1289
+ end
1290
+
1291
+ def audit_log_dir
1292
+ custom = ENV.fetch("OLLAMA_AGENT_AUDIT_LOG_PATH", nil)
1293
+ return custom if custom && !custom.strip.empty?
1294
+
1295
+ File.join(@root, ".ollama_agent", "logs")
1296
+ end
1297
+
1298
+ def attach_audit_logger
1299
+ Resilience::AuditLogger.new(log_dir: audit_log_dir, hooks: @hooks).attach
1300
+ end
1301
+ ```
1302
+
1303
+ - [ ] **Step 3.2.6: Add --audit flag to CLI**
1304
+
1305
+ In `lib/ollama_agent/cli.rb`, add to `ask` (and `orchestrate`, `self_review`, `improve`) option blocks:
1306
+ ```ruby
1307
+ method_option :audit, type: :boolean, default: false,
1308
+ desc: "Enable structured audit log under .ollama_agent/logs/ (OLLAMA_AGENT_AUDIT=1)"
1309
+ method_option :max_retries, type: :numeric,
1310
+ desc: "HTTP retry attempts (0=disable, default 3)"
1311
+ ```
1312
+
1313
+ Pass through to `Agent.new` in `build_agent`:
1314
+ ```ruby
1315
+ audit: options[:audit],
1316
+ max_retries: options[:max_retries],
1317
+ ```
1318
+
1319
+ - [ ] **Step 3.2.7: Require resilience files in lib/ollama_agent.rb**
1320
+
1321
+ ```ruby
1322
+ require_relative "ollama_agent/resilience/retry_middleware"
1323
+ require_relative "ollama_agent/resilience/audit_logger"
1324
+ ```
1325
+
1326
+ - [ ] **Step 3.2.8: Run full suite**
1327
+
1328
+ ```bash
1329
+ bundle exec rspec --no-color 2>&1 | tail -5
1330
+ ```
1331
+ Expected: `0 failures`
1332
+
1333
+ - [ ] **Step 3.2.9: Commit**
1334
+
1335
+ ```bash
1336
+ git add lib/ollama_agent/resilience/audit_logger.rb \
1337
+ lib/ollama_agent/agent.rb \
1338
+ lib/ollama_agent/cli.rb \
1339
+ lib/ollama_agent.rb \
1340
+ spec/ollama_agent/resilience/audit_logger_spec.rb
1341
+ git commit -m "feat(resilience): add AuditLogger with NDJSON structured logging and --audit flag"
1342
+ ```
1343
+
1344
+ ---
1345
+
1346
+ ## Layer 4 — Context Manager
1347
+
1348
+ ### Task 4.1: TokenCounter + Context::Manager
1349
+
1350
+ **Files:**
1351
+ - Create: `lib/ollama_agent/context/token_counter.rb`
1352
+ - Create: `lib/ollama_agent/context/manager.rb`
1353
+ - Create: `spec/ollama_agent/context/manager_spec.rb`
1354
+
1355
+ - [ ] **Step 4.1.1: Write failing specs**
1356
+
1357
+ ```ruby
1358
+ # spec/ollama_agent/context/manager_spec.rb
1359
+ # frozen_string_literal: true
1360
+
1361
+ require "spec_helper"
1362
+ require_relative "../../../lib/ollama_agent/context/token_counter"
1363
+ require_relative "../../../lib/ollama_agent/context/manager"
1364
+
1365
+ RSpec.describe OllamaAgent::Context::Manager do
1366
+ def sys_msg(content = "system prompt")
1367
+ { role: "system", content: content }
1368
+ end
1369
+
1370
+ def user_msg(content)
1371
+ { role: "user", content: content }
1372
+ end
1373
+
1374
+ def assistant_msg(content)
1375
+ { role: "assistant", content: content }
1376
+ end
1377
+
1378
+ def tool_msg(name, content)
1379
+ { role: "tool", name: name, content: content }
1380
+ end
1381
+
1382
+ describe "#trim (sliding window)" do
1383
+ it "returns messages unchanged when under budget" do
1384
+ manager = described_class.new(max_tokens: 10_000)
1385
+ messages = [sys_msg, user_msg("hi"), assistant_msg("hello")]
1386
+ expect(manager.trim(messages)).to eq(messages)
1387
+ end
1388
+
1389
+ it "never trims the system message" do
1390
+ system_content = "system " * 1000 # ~2000 chars ≈ 500 tokens
1391
+ manager = described_class.new(max_tokens: 600)
1392
+ messages = [sys_msg(system_content), user_msg("short"), assistant_msg("short")]
1393
+ trimmed = manager.trim(messages)
1394
+ expect(trimmed.first[:role]).to eq("system")
1395
+ expect(trimmed.first[:content]).to eq(system_content)
1396
+ end
1397
+
1398
+ it "never trims the most recent user message" do
1399
+ big_history = Array.new(30) { |i| i.even? ? user_msg("x " * 200) : assistant_msg("y " * 200) }
1400
+ last_user = user_msg("final question")
1401
+ messages = [sys_msg] + big_history + [last_user]
1402
+ manager = described_class.new(max_tokens: 500)
1403
+ trimmed = manager.trim(messages)
1404
+ expect(trimmed.last).to eq(last_user)
1405
+ end
1406
+
1407
+ it "does not mutate the original messages array" do
1408
+ messages = [sys_msg, user_msg("x " * 500), assistant_msg("y"), user_msg("last")]
1409
+ original = messages.dup
1410
+ manager = described_class.new(max_tokens: 100)
1411
+ manager.trim(messages)
1412
+ expect(messages).to eq(original)
1413
+ end
1414
+
1415
+ it "trims oldest messages first when over budget" do
1416
+ old_user = user_msg("old message " * 100)
1417
+ old_asst = assistant_msg("old reply " * 100)
1418
+ last_user = user_msg("recent")
1419
+ messages = [sys_msg, old_user, old_asst, last_user]
1420
+ manager = described_class.new(max_tokens: 50)
1421
+ trimmed = manager.trim(messages)
1422
+ expect(trimmed).not_to include(old_user)
1423
+ expect(trimmed).not_to include(old_asst)
1424
+ expect(trimmed).to include(last_user)
1425
+ end
1426
+ end
1427
+
1428
+ describe OllamaAgent::Context::TokenCounter do
1429
+ it "estimates tokens as chars / 4" do
1430
+ expect(described_class.estimate("hello")).to eq(1) # 5 chars / 4 = 1
1431
+ expect(described_class.estimate("x" * 400)).to eq(100)
1432
+ end
1433
+ end
1434
+ end
1435
+ ```
1436
+
1437
+ - [ ] **Step 4.1.2: Run to confirm failure**
1438
+
1439
+ ```bash
1440
+ bundle exec rspec spec/ollama_agent/context/manager_spec.rb --no-color 2>&1 | tail -5
1441
+ ```
1442
+ Expected: `LoadError`
1443
+
1444
+ - [ ] **Step 4.1.3: Implement TokenCounter**
1445
+
1446
+ ```ruby
1447
+ # lib/ollama_agent/context/token_counter.rb
1448
+ # frozen_string_literal: true
1449
+
1450
+ module OllamaAgent
1451
+ module Context
1452
+ # Estimates token count. Uses tiktoken_ruby if available; falls back to chars/4.
1453
+ module TokenCounter
1454
+ module_function
1455
+
1456
+ def estimate(text)
1457
+ count_with_tiktoken(text.to_s)
1458
+ rescue LoadError, StandardError
1459
+ (text.to_s.length / 4.0).ceil
1460
+ end
1461
+
1462
+ private
1463
+
1464
+ def count_with_tiktoken(text)
1465
+ require "tiktoken_ruby"
1466
+ enc = Tiktoken.encoding_for_model("gpt-4") rescue Tiktoken.get_encoding("cl100k_base")
1467
+ enc.encode(text).length
1468
+ end
1469
+ end
1470
+ end
1471
+ end
1472
+ ```
1473
+
1474
+ - [ ] **Step 4.1.4: Implement Context::Manager**
1475
+
1476
+ ```ruby
1477
+ # lib/ollama_agent/context/manager.rb
1478
+ # frozen_string_literal: true
1479
+
1480
+ require_relative "token_counter"
1481
+
1482
+ module OllamaAgent
1483
+ module Context
1484
+ # Trims the messages array to fit within a token budget before each chat call.
1485
+ # Never mutates the input. Never removes the system message or the last user message.
1486
+ class Manager
1487
+ DEFAULT_MAX_TOKENS = 8_192
1488
+ SYSTEM_RESERVE = 1_024
1489
+ SUMMARY_THRESHOLD = 0.85
1490
+
1491
+ def initialize(max_tokens: nil, summarize: false, client: nil, model: nil)
1492
+ @max_tokens = (max_tokens || env_max_tokens).to_i
1493
+ @summarize = summarize
1494
+ @client = client
1495
+ @model = model
1496
+ end
1497
+
1498
+ # Returns a (possibly shorter) copy of messages that fits within the token budget.
1499
+ def trim(messages)
1500
+ return messages if under_budget?(messages)
1501
+
1502
+ trimmed = messages.dup
1503
+ # Identify protected indices: system (index 0) and last user message
1504
+ last_user_idx = trimmed.rindex { |m| m[:role] == "user" }
1505
+
1506
+ # Drop oldest non-protected messages until under budget
1507
+ i = 1 # skip system message at 0
1508
+ while over_budget?(trimmed) && i < trimmed.size
1509
+ next_i = advance_past_protected(trimmed, i, last_user_idx)
1510
+ break if next_i.nil?
1511
+
1512
+ trimmed.delete_at(next_i)
1513
+ # Recompute last_user_idx after deletion
1514
+ last_user_idx = trimmed.rindex { |m| m[:role] == "user" }
1515
+ end
1516
+
1517
+ trimmed
1518
+ end
1519
+
1520
+ private
1521
+
1522
+ def under_budget?(messages)
1523
+ !over_budget?(messages)
1524
+ end
1525
+
1526
+ def over_budget?(messages)
1527
+ total_tokens(messages) > (@max_tokens * SUMMARY_THRESHOLD).to_i
1528
+ end
1529
+
1530
+ def total_tokens(messages)
1531
+ messages.sum { |m| TokenCounter.estimate(m[:content].to_s) }
1532
+ end
1533
+
1534
+ def advance_past_protected(messages, start, last_user_idx)
1535
+ (start...messages.size).find do |i|
1536
+ messages[i][:role] != "system" && i != last_user_idx
1537
+ end
1538
+ end
1539
+
1540
+ def env_max_tokens
1541
+ v = ENV.fetch("OLLAMA_AGENT_MAX_TOKENS", nil)
1542
+ return DEFAULT_MAX_TOKENS if v.nil? || v.strip.empty?
1543
+
1544
+ Integer(v)
1545
+ rescue ArgumentError, TypeError
1546
+ DEFAULT_MAX_TOKENS
1547
+ end
1548
+ end
1549
+ end
1550
+ end
1551
+ ```
1552
+
1553
+ - [ ] **Step 4.1.5: Run specs**
1554
+
1555
+ ```bash
1556
+ bundle exec rspec spec/ollama_agent/context/manager_spec.rb --no-color
1557
+ ```
1558
+ Expected: `6 examples, 0 failures`
1559
+
1560
+ > **Note on summarize mode:** The `summarize: true` path in `Context::Manager` calls the Ollama client to generate a summary — a full integration test requires a running Ollama server. Add this integration spec under `spec/integration/` (guarded by `skip "requires Ollama server"`) once the basic specs pass. The sliding-window path (default) is fully covered by the specs above.
1561
+
1562
+ - [ ] **Step 4.1.6: Wire Context::Manager into Agent**
1563
+
1564
+ In `lib/ollama_agent/agent.rb`, add: `require_relative "context/manager"`
1565
+
1566
+ Add `max_tokens:` and `context_summarize:` to `initialize`:
1567
+ ```ruby
1568
+ # In parameter list:
1569
+ max_tokens: nil,
1570
+ context_summarize: false,
1571
+ # In body:
1572
+ @context_manager = Context::Manager.new(
1573
+ max_tokens: max_tokens,
1574
+ summarize: context_summarize,
1575
+ client: nil, # summarize mode will set this later
1576
+ model: @model
1577
+ )
1578
+ ```
1579
+
1580
+ In `execute_agent_turns`, trim messages before each chat call:
1581
+ ```ruby
1582
+ def execute_agent_turns(messages)
1583
+ @current_turn = 0
1584
+ max_turns.times do
1585
+ @current_turn += 1
1586
+ trimmed = @context_manager.trim(messages)
1587
+ message = chat_assistant_message(trimmed)
1588
+ tool_calls = tool_calls_from(message)
1589
+ messages << message.to_h
1590
+ break if tool_calls.empty?
1591
+
1592
+ append_tool_results(messages, tool_calls)
1593
+ end
1594
+ @hooks.emit(:on_complete, { messages: messages, turns: @current_turn })
1595
+ end
1596
+ ```
1597
+
1598
+ - [ ] **Step 4.1.7: Add CLI flags**
1599
+
1600
+ In `lib/ollama_agent/cli.rb`, add to `ask` (and other commands):
1601
+ ```ruby
1602
+ method_option :max_tokens, type: :numeric, desc: "Context window token budget (default 8192)"
1603
+ method_option :context_summarize, type: :boolean, default: false,
1604
+ desc: "Summarize trimmed context (default: sliding window)"
1605
+ ```
1606
+
1607
+ Pass through in `build_agent`:
1608
+ ```ruby
1609
+ max_tokens: options[:max_tokens],
1610
+ context_summarize: options[:context_summarize],
1611
+ ```
1612
+
1613
+ - [ ] **Step 4.1.8: Require context files in lib/ollama_agent.rb**
1614
+
1615
+ ```ruby
1616
+ require_relative "ollama_agent/context/token_counter"
1617
+ require_relative "ollama_agent/context/manager"
1618
+ ```
1619
+
1620
+ - [ ] **Step 4.1.9: Run full suite**
1621
+
1622
+ ```bash
1623
+ bundle exec rspec --no-color 2>&1 | tail -5
1624
+ ```
1625
+ Expected: `0 failures`
1626
+
1627
+ - [ ] **Step 4.1.10: Commit**
1628
+
1629
+ ```bash
1630
+ git add lib/ollama_agent/context/ \
1631
+ lib/ollama_agent/agent.rb \
1632
+ lib/ollama_agent/cli.rb \
1633
+ lib/ollama_agent.rb \
1634
+ spec/ollama_agent/context/
1635
+ git commit -m "feat(context): add Context::Manager for token budget + sliding window trim"
1636
+ ```
1637
+
1638
+ ---
1639
+
1640
+ ## Layer 5 — Session Persistence
1641
+
1642
+ ### Task 5.1: Session::Store
1643
+
1644
+ **Files:**
1645
+ - Create: `lib/ollama_agent/session/session.rb`
1646
+ - Create: `lib/ollama_agent/session/store.rb`
1647
+ - Create: `spec/ollama_agent/session/store_spec.rb`
1648
+
1649
+ - [ ] **Step 5.1.1: Write failing specs**
1650
+
1651
+ ```ruby
1652
+ # spec/ollama_agent/session/store_spec.rb
1653
+ # frozen_string_literal: true
1654
+
1655
+ require "spec_helper"
1656
+ require "json"
1657
+ require "tmpdir"
1658
+ require_relative "../../../lib/ollama_agent/session/session"
1659
+ require_relative "../../../lib/ollama_agent/session/store"
1660
+
1661
+ RSpec.describe OllamaAgent::Session::Store do
1662
+ let(:root) { Dir.mktmpdir }
1663
+ after { FileUtils.remove_entry(root) }
1664
+
1665
+ describe ".save and .load" do
1666
+ it "saves a message and loads it back" do
1667
+ described_class.save(session_id: "s1", root: root, message: { role: "user", content: "hello" })
1668
+ messages = described_class.load(session_id: "s1", root: root)
1669
+ expect(messages.size).to eq(1)
1670
+ expect(messages.first["role"]).to eq("user")
1671
+ expect(messages.first["content"]).to eq("hello")
1672
+ end
1673
+
1674
+ it "appends messages (crash-safe: one line per call)" do
1675
+ described_class.save(session_id: "s2", root: root, message: { role: "user", content: "a" })
1676
+ described_class.save(session_id: "s2", root: root, message: { role: "assistant", content: "b" })
1677
+ messages = described_class.load(session_id: "s2", root: root)
1678
+ expect(messages.size).to eq(2)
1679
+ end
1680
+
1681
+ it "returns empty array for unknown session" do
1682
+ expect(described_class.load(session_id: "nope", root: root)).to eq([])
1683
+ end
1684
+ end
1685
+
1686
+ describe ".list" do
1687
+ it "lists sessions for a root, newest first" do
1688
+ described_class.save(session_id: "alpha", root: root, message: { role: "user", content: "x" })
1689
+ sleep 0.01 # ensure different mtime
1690
+ described_class.save(session_id: "beta", root: root, message: { role: "user", content: "y" })
1691
+ list = described_class.list(root: root)
1692
+ expect(list.map { |s| s[:session_id] }).to eq(%w[beta alpha])
1693
+ end
1694
+
1695
+ it "returns empty array when no sessions exist" do
1696
+ expect(described_class.list(root: root)).to eq([])
1697
+ end
1698
+ end
1699
+
1700
+ describe ".resume" do
1701
+ it "returns messages ready for Agent seeding" do
1702
+ described_class.save(session_id: "r1", root: root, message: { role: "user", content: "task" })
1703
+ described_class.save(session_id: "r1", root: root, message: { role: "assistant", content: "done" })
1704
+ messages = described_class.resume(session_id: "r1", root: root)
1705
+ expect(messages.size).to eq(2)
1706
+ expect(messages.first).to be_a(Hash)
1707
+ expect(messages.first["role"]).to eq("user")
1708
+ end
1709
+
1710
+ it "returns empty array when session does not exist" do
1711
+ expect(described_class.resume(session_id: "gone", root: root)).to eq([])
1712
+ end
1713
+ end
1714
+
1715
+ describe ".sessions_dir" do
1716
+ it "returns path under .ollama_agent/sessions/" do
1717
+ expect(described_class.sessions_dir(root)).to end_with(".ollama_agent/sessions")
1718
+ end
1719
+ end
1720
+ end
1721
+ ```
1722
+
1723
+ - [ ] **Step 5.1.2: Run to confirm failure**
1724
+
1725
+ ```bash
1726
+ bundle exec rspec spec/ollama_agent/session/store_spec.rb --no-color 2>&1 | tail -5
1727
+ ```
1728
+ Expected: `LoadError`
1729
+
1730
+ - [ ] **Step 5.1.3: Implement Session::Session**
1731
+
1732
+ ```ruby
1733
+ # lib/ollama_agent/session/session.rb
1734
+ # frozen_string_literal: true
1735
+
1736
+ module OllamaAgent
1737
+ module Session
1738
+ # Lightweight value object for session metadata.
1739
+ SessionMeta = Struct.new(:session_id, :path, :started_at, keyword_init: true)
1740
+ end
1741
+ end
1742
+ ```
1743
+
1744
+ - [ ] **Step 5.1.4: Implement Session::Store**
1745
+
1746
+ ```ruby
1747
+ # lib/ollama_agent/session/store.rb
1748
+ # frozen_string_literal: true
1749
+
1750
+ require "fileutils"
1751
+ require "json"
1752
+ require_relative "session"
1753
+
1754
+ module OllamaAgent
1755
+ module Session
1756
+ # NDJSON-based session persistence under <root>/.ollama_agent/sessions/.
1757
+ # Each call to .save appends one JSON line — crash-safe by design.
1758
+ module Store
1759
+ module_function
1760
+
1761
+ def sessions_dir(root)
1762
+ File.join(root, ".ollama_agent", "sessions")
1763
+ end
1764
+
1765
+ # Append one message to a session file.
1766
+ def save(session_id:, root:, message:)
1767
+ dir = sessions_dir(root)
1768
+ FileUtils.mkdir_p(dir)
1769
+ path = session_path(dir, session_id)
1770
+ File.open(path, "a", encoding: Encoding::UTF_8) do |f|
1771
+ f.puts(JSON.generate(message.transform_keys(&:to_s)))
1772
+ end
1773
+ rescue StandardError
1774
+ nil # best-effort; never crash the agent
1775
+ end
1776
+
1777
+ # Load all saved messages for a session.
1778
+ def load(session_id:, root:)
1779
+ path = session_path(sessions_dir(root), session_id)
1780
+ return [] unless File.file?(path)
1781
+
1782
+ File.readlines(path, encoding: Encoding::UTF_8)
1783
+ .map(&:chomp)
1784
+ .reject(&:empty?)
1785
+ .map { |line| JSON.parse(line) }
1786
+ rescue StandardError
1787
+ []
1788
+ end
1789
+
1790
+ # Load messages ready to seed Agent#run.
1791
+ def resume(session_id:, root:)
1792
+ load(session_id: session_id, root: root)
1793
+ end
1794
+
1795
+ # List sessions for a root, newest first.
1796
+ def list(root:)
1797
+ dir = sessions_dir(root)
1798
+ return [] unless Dir.exist?(dir)
1799
+
1800
+ Dir.glob(File.join(dir, "*.ndjson"))
1801
+ .sort_by { |f| -File.mtime(f).to_i }
1802
+ .map do |path|
1803
+ id = File.basename(path, ".ndjson")
1804
+ mtime = File.mtime(path).utc.strftime("%Y-%m-%dT%H:%M:%SZ")
1805
+ SessionMeta.new(session_id: id, path: path, started_at: mtime)
1806
+ end
1807
+ end
1808
+
1809
+ private
1810
+
1811
+ def session_path(dir, session_id)
1812
+ safe_id = session_id.to_s.gsub(/[^a-zA-Z0-9_\-]/, "_")
1813
+ File.join(dir, "#{safe_id}.ndjson")
1814
+ end
1815
+ end
1816
+ end
1817
+ end
1818
+ ```
1819
+
1820
+ - [ ] **Step 5.1.5: Run specs**
1821
+
1822
+ ```bash
1823
+ bundle exec rspec spec/ollama_agent/session/store_spec.rb --no-color
1824
+ ```
1825
+ Expected: `7 examples, 0 failures`
1826
+
1827
+ - [ ] **Step 5.1.6: Wire Session::Store into Agent**
1828
+
1829
+ In `lib/ollama_agent/agent.rb`, add: `require_relative "session/store"`
1830
+
1831
+ Add `session_id:` kwarg to `initialize`:
1832
+ ```ruby
1833
+ # In parameter list:
1834
+ session_id: nil,
1835
+ resume: false,
1836
+ # In body:
1837
+ @session_id = session_id
1838
+ @resume = resume
1839
+ ```
1840
+
1841
+ In `run`:
1842
+ ```ruby
1843
+ def run(query)
1844
+ prior = @session_id && @resume ? Session::Store.resume(session_id: @session_id, root: @root) : []
1845
+ messages = prior.empty? ? [{ role: "system", content: system_prompt }] : prior
1846
+ messages << { role: "user", content: query }
1847
+
1848
+ execute_agent_turns(messages)
1849
+ end
1850
+ ```
1851
+
1852
+ In `append_tool_results`, after appending tool result message:
1853
+ ```ruby
1854
+ messages << tool_message(tool_call, result)
1855
+ Session::Store.save(session_id: @session_id, root: @root, message: messages.last) if @session_id
1856
+ ```
1857
+
1858
+ Also save the assistant message and user message — update `execute_agent_turns`:
1859
+ ```ruby
1860
+ def execute_agent_turns(messages)
1861
+ @current_turn = 0
1862
+ max_turns.times do
1863
+ @current_turn += 1
1864
+ trimmed = @context_manager.trim(messages)
1865
+ message = chat_assistant_message(trimmed)
1866
+ tool_calls = tool_calls_from(message)
1867
+ messages << message.to_h
1868
+ save_message_to_session(message.to_h)
1869
+ break if tool_calls.empty?
1870
+
1871
+ append_tool_results(messages, tool_calls)
1872
+ end
1873
+ @hooks.emit(:on_complete, { messages: messages, turns: @current_turn })
1874
+ end
1875
+
1876
+ def save_message_to_session(msg)
1877
+ return unless @session_id
1878
+
1879
+ Session::Store.save(session_id: @session_id, root: @root, message: msg)
1880
+ end
1881
+ ```
1882
+
1883
+ - [ ] **Step 5.1.7: Add --session, --resume flags to CLI; add sessions command**
1884
+
1885
+ In `lib/ollama_agent/cli.rb`, add to `ask`:
1886
+ ```ruby
1887
+ method_option :session, type: :string, desc: "Named session id (saves/resumes conversation)"
1888
+ method_option :resume, type: :boolean, default: false,
1889
+ desc: "Resume the named (or most recent) session"
1890
+ ```
1891
+
1892
+ In `build_agent`:
1893
+ ```ruby
1894
+ session_id: resolved_session_id,
1895
+ resume: options[:resume],
1896
+ ```
1897
+
1898
+ Add helper:
1899
+ ```ruby
1900
+ def resolved_session_id
1901
+ return options[:session] if options[:session]
1902
+ return nil unless options[:resume]
1903
+
1904
+ # Resume most recent session if no name given
1905
+ list = Session::Store.list(root: resolved_root_for_self_review)
1906
+ list.first&.fetch(:session_id)
1907
+ end
1908
+ ```
1909
+
1910
+ Add `sessions` command:
1911
+ ```ruby
1912
+ desc "sessions", "List saved sessions for the current project root"
1913
+ method_option :root, type: :string, desc: "Project root (default: OLLAMA_AGENT_ROOT or cwd)"
1914
+ def sessions
1915
+ root = resolved_root_for_self_review
1916
+ list = Session::Store.list(root: root)
1917
+ if list.empty?
1918
+ puts "No sessions found in #{root}"
1919
+ return
1920
+ end
1921
+ puts format("%-30s %s", "SESSION ID", "STARTED")
1922
+ list.each { |s| puts format("%-30s %s", s.session_id, s.started_at) }
1923
+ end
1924
+ ```
1925
+
1926
+ - [ ] **Step 5.1.8: Require session files in lib/ollama_agent.rb**
1927
+
1928
+ ```ruby
1929
+ require_relative "ollama_agent/session/session"
1930
+ require_relative "ollama_agent/session/store"
1931
+ ```
1932
+
1933
+ - [ ] **Step 5.1.9: Run full suite**
1934
+
1935
+ ```bash
1936
+ bundle exec rspec --no-color 2>&1 | tail -5
1937
+ ```
1938
+ Expected: `0 failures`
1939
+
1940
+ - [ ] **Step 5.1.10: Commit**
1941
+
1942
+ ```bash
1943
+ git add lib/ollama_agent/session/ \
1944
+ lib/ollama_agent/agent.rb \
1945
+ lib/ollama_agent/cli.rb \
1946
+ lib/ollama_agent.rb \
1947
+ spec/ollama_agent/session/
1948
+ git commit -m "feat(session): add Session::Store for crash-safe NDJSON session persistence"
1949
+ ```
1950
+
1951
+ ---
1952
+
1953
+ ## Layer 6 — Runner + Library API
1954
+
1955
+ ### Task 6.1: OllamaAgent::Runner facade
1956
+
1957
+ **Files:**
1958
+ - Create: `lib/ollama_agent/runner.rb`
1959
+ - Create: `spec/ollama_agent/runner_spec.rb`
1960
+ - Modify: `lib/ollama_agent.rb`
1961
+ - Modify: `lib/ollama_agent/version.rb`
1962
+
1963
+ - [ ] **Step 6.1.1: Write failing specs**
1964
+
1965
+ ```ruby
1966
+ # spec/ollama_agent/runner_spec.rb
1967
+ # frozen_string_literal: true
1968
+
1969
+ require "spec_helper"
1970
+ require "tmpdir"
1971
+
1972
+ RSpec.describe OllamaAgent::Runner do
1973
+ let(:tmpdir) { Dir.mktmpdir }
1974
+ after { FileUtils.remove_entry(tmpdir) }
1975
+
1976
+ def stub_client_with(content)
1977
+ client = instance_double(Ollama::Client)
1978
+ allow(client).to receive(:chat).and_return(
1979
+ Ollama::Response.new("message" => { "role" => "assistant", "content" => content })
1980
+ )
1981
+ client
1982
+ end
1983
+
1984
+ describe ".build" do
1985
+ it "returns a Runner instance" do
1986
+ expect(described_class.build(root: tmpdir)).to be_a(described_class)
1987
+ end
1988
+
1989
+ it "exposes a Hooks instance via #hooks" do
1990
+ runner = described_class.build(root: tmpdir)
1991
+ expect(runner.hooks).to be_a(OllamaAgent::Streaming::Hooks)
1992
+ end
1993
+
1994
+ it "accepts stream: true without error" do
1995
+ expect { described_class.build(root: tmpdir, stream: false) }.not_to raise_error
1996
+ end
1997
+ end
1998
+
1999
+ describe "#run" do
2000
+ it "executes a query against the agent" do
2001
+ runner = described_class.build(root: tmpdir)
2002
+ # inject a stub client to avoid hitting real Ollama
2003
+ agent = OllamaAgent::Agent.new(
2004
+ client: stub_client_with("All done."),
2005
+ root: tmpdir,
2006
+ confirm_patches: false
2007
+ )
2008
+ allow(runner).to receive(:agent).and_return(agent)
2009
+ expect { runner.run("hello") }.not_to raise_error
2010
+ end
2011
+ end
2012
+
2013
+ describe "custom tool registration via OllamaAgent::Tools" do
2014
+ before { OllamaAgent::Tools.reset! }
2015
+ after { OllamaAgent::Tools.reset! }
2016
+
2017
+ it "registers a custom tool accessible via OllamaAgent::Tools" do
2018
+ OllamaAgent::Tools.register(:my_tool, schema: { description: "test", properties: {}, required: [] }) do |_args, root:, read_only:|
2019
+ "custom result"
2020
+ end
2021
+ expect(OllamaAgent::Tools.custom_tool?("my_tool")).to be true
2022
+ end
2023
+ end
2024
+ end
2025
+ ```
2026
+
2027
+ - [ ] **Step 6.1.2: Run to confirm failure**
2028
+
2029
+ ```bash
2030
+ bundle exec rspec spec/ollama_agent/runner_spec.rb --no-color 2>&1 | tail -5
2031
+ ```
2032
+ Expected: `NameError` or `LoadError`
2033
+
2034
+ - [ ] **Step 6.1.3: Implement Runner**
2035
+
2036
+ ```ruby
2037
+ # lib/ollama_agent/runner.rb
2038
+ # frozen_string_literal: true
2039
+
2040
+ require_relative "agent"
2041
+ require_relative "streaming/hooks"
2042
+ require_relative "streaming/console_streamer"
2043
+ require_relative "session/store"
2044
+
2045
+ module OllamaAgent
2046
+ # Stable public facade for library consumers.
2047
+ # All kwargs have sensible defaults. Configure via Runner.build, then call #run.
2048
+ #
2049
+ # @example
2050
+ # runner = OllamaAgent::Runner.build(root: "/my/project", stream: true, audit: true)
2051
+ # runner.hooks.on(:on_token) { |p| print p[:token] }
2052
+ # runner.run("Refactor the auth module")
2053
+ class Runner
2054
+ # @return [Streaming::Hooks] the hooks bus — attach subscribers before calling #run
2055
+ attr_reader :hooks
2056
+
2057
+ # @return [String, nil] the current session id
2058
+ attr_reader :session_id
2059
+
2060
+ # Build a configured Runner.
2061
+ #
2062
+ # @param root [String] project root directory (default: Dir.pwd)
2063
+ # @param model [String, nil] Ollama model name
2064
+ # @param stream [Boolean] enable streaming token output
2065
+ # @param session_id [String, nil] named session for persistence
2066
+ # @param resume [Boolean] load prior session messages before running
2067
+ # @param max_tokens [Integer, nil] context window budget
2068
+ # @param context_summarize [Boolean] use summarize vs sliding-window trim
2069
+ # @param max_retries [Integer] HTTP retry attempts (0 = disable)
2070
+ # @param audit [Boolean] enable structured audit logging
2071
+ # @param read_only [Boolean] disable write tools
2072
+ # @param skills_enabled [Boolean] include bundled prompt skills
2073
+ # @param skill_paths [Array<String>, nil] extra .md paths
2074
+ # @param confirm_patches [Boolean] prompt before applying patches
2075
+ # @param orchestrator [Boolean] enable external agent delegation
2076
+ # @param think [String, nil] thinking mode (true/false/high/medium/low)
2077
+ # @param http_timeout [Integer, nil] HTTP timeout in seconds
2078
+ # @return [Runner]
2079
+ # rubocop:disable Metrics/ParameterLists
2080
+ def self.build(
2081
+ root: Dir.pwd,
2082
+ model: nil,
2083
+ stream: false,
2084
+ session_id: nil,
2085
+ resume: false,
2086
+ max_tokens: nil,
2087
+ context_summarize: false,
2088
+ max_retries: nil,
2089
+ audit: false,
2090
+ read_only: false,
2091
+ skills_enabled: true,
2092
+ skill_paths: nil,
2093
+ confirm_patches: true,
2094
+ orchestrator: false,
2095
+ think: nil,
2096
+ http_timeout: nil
2097
+ )
2098
+ new(
2099
+ root: root, model: model, stream: stream,
2100
+ session_id: session_id, resume: resume,
2101
+ max_tokens: max_tokens, context_summarize: context_summarize,
2102
+ max_retries: max_retries, audit: audit, read_only: read_only,
2103
+ skills_enabled: skills_enabled, skill_paths: skill_paths,
2104
+ confirm_patches: confirm_patches, orchestrator: orchestrator,
2105
+ think: think, http_timeout: http_timeout
2106
+ )
2107
+ end
2108
+ # rubocop:enable Metrics/ParameterLists
2109
+
2110
+ # Execute a query. Blocks until the agent loop completes.
2111
+ # @param query [String]
2112
+ def run(query)
2113
+ agent.run(query)
2114
+ end
2115
+
2116
+ # Start an interactive REPL. Blocks until the user types 'exit'.
2117
+ def start_repl
2118
+ puts Console.welcome_banner("Ollama Agent (type 'exit' to quit)")
2119
+ loop do
2120
+ print Console.prompt_prefix
2121
+ input = $stdin.gets
2122
+ break if input.nil?
2123
+
2124
+ line = input.chomp
2125
+ break if line == "exit"
2126
+
2127
+ agent.run(line)
2128
+ end
2129
+ end
2130
+
2131
+ protected
2132
+
2133
+ # Exposed for spec stubbing only.
2134
+ def agent
2135
+ @agent
2136
+ end
2137
+
2138
+ private
2139
+
2140
+ # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
2141
+ def initialize(root:, model:, stream:, session_id:, resume:, max_tokens:, context_summarize:,
2142
+ max_retries:, audit:, read_only:, skills_enabled:, skill_paths:, confirm_patches:,
2143
+ orchestrator:, think:, http_timeout:)
2144
+ @session_id = session_id
2145
+ @hooks = Streaming::Hooks.new
2146
+
2147
+ @agent = Agent.new(
2148
+ root: root,
2149
+ model: model,
2150
+ confirm_patches: confirm_patches,
2151
+ http_timeout: http_timeout,
2152
+ think: think,
2153
+ read_only: read_only,
2154
+ skills_enabled: skills_enabled,
2155
+ skill_paths: skill_paths ? Array(skill_paths) : nil,
2156
+ orchestrator: orchestrator,
2157
+ session_id: session_id,
2158
+ resume: resume,
2159
+ max_tokens: max_tokens,
2160
+ context_summarize: context_summarize,
2161
+ max_retries: max_retries,
2162
+ audit: audit
2163
+ )
2164
+
2165
+ # Share the Runner's hooks bus with the Agent
2166
+ @agent.instance_variable_set(:@hooks, @hooks)
2167
+
2168
+ Streaming::ConsoleStreamer.new.attach(@hooks) if stream
2169
+ end
2170
+ # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
2171
+ end
2172
+ end
2173
+ ```
2174
+
2175
+ - [ ] **Step 6.1.4: Require Runner in lib/ollama_agent.rb**
2176
+
2177
+ `OllamaAgent::Tools` delegate methods are already defined in `tools/registry.rb` (Task 1.1). Just add the runner require:
2178
+
2179
+ ```ruby
2180
+ require_relative "ollama_agent/runner"
2181
+ ```
2182
+
2183
+ Add at the end of the requires block in `lib/ollama_agent.rb` (after session requires).
2184
+
2185
+ - [ ] **Step 6.1.5: Bump version to 0.2.0**
2186
+
2187
+ ```ruby
2188
+ # lib/ollama_agent/version.rb
2189
+ module OllamaAgent
2190
+ VERSION = "0.2.0"
2191
+ end
2192
+ ```
2193
+
2194
+ - [ ] **Step 6.1.6: Run specs**
2195
+
2196
+ ```bash
2197
+ bundle exec rspec spec/ollama_agent/runner_spec.rb --no-color
2198
+ ```
2199
+ Expected: all pass.
2200
+
2201
+ - [ ] **Step 6.1.7: Run full suite**
2202
+
2203
+ ```bash
2204
+ bundle exec rspec --no-color 2>&1 | tail -5
2205
+ ```
2206
+ Expected: `0 failures`
2207
+
2208
+ - [ ] **Step 6.1.8: Run RuboCop**
2209
+
2210
+ ```bash
2211
+ bundle exec rubocop --no-color 2>&1 | tail -10
2212
+ ```
2213
+ Fix any new offenses introduced in the new files. Common ones: `Metrics/ParameterLists` (add rubocop:disable comments as done throughout the gem), `Metrics/MethodLength`, `Style/FrozenStringLiteralComment` (ensure all new files start with `# frozen_string_literal: true`).
2214
+
2215
+ - [ ] **Step 6.1.9: Commit**
2216
+
2217
+ ```bash
2218
+ git add lib/ollama_agent/runner.rb \
2219
+ lib/ollama_agent.rb \
2220
+ lib/ollama_agent/version.rb \
2221
+ spec/ollama_agent/runner_spec.rb
2222
+ git commit -m "feat(runner): add OllamaAgent::Runner stable library facade; bump to v0.2.0"
2223
+ ```
2224
+
2225
+ ---
2226
+
2227
+ ### Task 6.2: Documentation
2228
+
2229
+ **Files:**
2230
+ - Create: `docs/ARCHITECTURE.md`
2231
+ - Create: `docs/TOOLS.md`
2232
+ - Create: `docs/SESSIONS.md`
2233
+
2234
+ - [ ] **Step 6.2.1: Write docs/ARCHITECTURE.md**
2235
+
2236
+ ```markdown
2237
+ # Architecture
2238
+
2239
+ ollama_agent is a layered gem. Each layer is independently opt-in.
2240
+
2241
+ ## Data Flow
2242
+
2243
+ ```
2244
+ CLI / Runner.run(query)
2245
+ → Session::Store.resume (if --resume)
2246
+ → Agent#run
2247
+ → Context::Manager.trim(messages)
2248
+ → OllamaConnection + Resilience::RetryMiddleware
2249
+ → Ollama::Client#chat
2250
+ → Streaming::Hooks.emit(:on_token, ...)
2251
+ → Tools::Registry / SandboxedTools.execute_tool(name, args)
2252
+ → Resilience::AuditLogger (via hooks)
2253
+ → Session::Store.save (after each turn)
2254
+ → Streaming::Hooks.emit(:on_complete, ...)
2255
+ ```
2256
+
2257
+ ## Layers
2258
+
2259
+ | Layer | Files | Opt-in via |
2260
+ |-------|-------|-----------|
2261
+ | Core agent | `agent.rb`, `sandboxed_tools.rb` | Always on |
2262
+ | Tool Registry | `tools/registry.rb` | `OllamaAgent::Tools.register(...)` |
2263
+ | Streaming | `streaming/hooks.rb`, `streaming/console_streamer.rb` | `--stream` / `OLLAMA_AGENT_STREAM=1` |
2264
+ | Resilience | `resilience/retry_middleware.rb`, `resilience/audit_logger.rb` | On by default (retries); `--audit` for logging |
2265
+ | Context Manager | `context/manager.rb` | `--max-tokens N` / `OLLAMA_AGENT_MAX_TOKENS` |
2266
+ | Session | `session/store.rb` | `--session NAME` |
2267
+ | Runner API | `runner.rb` | `require "ollama_agent"; OllamaAgent::Runner.build(...)` |
2268
+ ```
2269
+
2270
+ - [ ] **Step 6.2.2: Write docs/TOOLS.md**
2271
+
2272
+ ```markdown
2273
+ # Custom Tool Registration
2274
+
2275
+ Register a custom tool before calling `Runner.build`. The tool is automatically injected into the model's tool list.
2276
+
2277
+ ```ruby
2278
+ require "ollama_agent"
2279
+
2280
+ OllamaAgent::Tools.register(
2281
+ :run_tests,
2282
+ schema: {
2283
+ description: "Run the RSpec test suite and return the output",
2284
+ properties: {
2285
+ suite: { type: "string", description: "Path to spec file or directory (default: spec/)" }
2286
+ },
2287
+ required: []
2288
+ }
2289
+ ) do |args, root:, read_only:|
2290
+ return "run_tests is disabled in read-only mode." if read_only
2291
+
2292
+ suite = args["suite"] || "spec/"
2293
+ `cd #{root} && bundle exec rspec #{suite} 2>&1`
2294
+ end
2295
+
2296
+ runner = OllamaAgent::Runner.build(root: "/my/project")
2297
+ runner.run("Fix the failing tests, then run them to confirm they pass")
2298
+ ```
2299
+
2300
+ ## Handler signature
2301
+
2302
+ ```ruby
2303
+ OllamaAgent::Tools.register(:tool_name, schema: { ... }) do |args, root:, read_only:|
2304
+ # args — Hash of tool arguments from the model
2305
+ # root — String absolute path to the project root
2306
+ # read_only — Boolean; return an error string if true and the tool writes files
2307
+ "return value as String"
2308
+ end
2309
+ ```
2310
+
2311
+ ## Schema format
2312
+
2313
+ The `schema:` hash is the `function` body (without `name` — that comes from the first argument):
2314
+
2315
+ ```ruby
2316
+ schema: {
2317
+ description: "What this tool does",
2318
+ properties: {
2319
+ param_name: { type: "string", description: "what it is" }
2320
+ },
2321
+ required: ["param_name"]
2322
+ }
2323
+ ```
2324
+ ```
2325
+
2326
+ - [ ] **Step 6.2.3: Write docs/SESSIONS.md**
2327
+
2328
+ ```markdown
2329
+ # Session Persistence
2330
+
2331
+ Sessions save conversation history to `.ollama_agent/sessions/` under the project root.
2332
+
2333
+ ## CLI usage
2334
+
2335
+ ```bash
2336
+ # Start a named session
2337
+ ollama_agent ask --session my-refactor "Refactor the CLI module"
2338
+
2339
+ # Resume it later (picks up exactly where it left off)
2340
+ ollama_agent ask --session my-refactor --resume "Now update the specs too"
2341
+
2342
+ # Resume in interactive REPL
2343
+ ollama_agent ask -i --session my-refactor --resume
2344
+
2345
+ # Resume most recent session (no name needed)
2346
+ ollama_agent ask --resume
2347
+
2348
+ # List all sessions for the current project
2349
+ ollama_agent sessions
2350
+ ```
2351
+
2352
+ ## Library API
2353
+
2354
+ ```ruby
2355
+ runner = OllamaAgent::Runner.build(
2356
+ root: "/my/project",
2357
+ session_id: "my-refactor",
2358
+ resume: true
2359
+ )
2360
+ runner.run("Continue — now also add integration tests")
2361
+ ```
2362
+
2363
+ ## File format
2364
+
2365
+ Sessions are NDJSON files — one JSON object per line, human-readable and `jq`-able:
2366
+
2367
+ ```
2368
+ .ollama_agent/sessions/my-refactor.ndjson
2369
+ ```
2370
+
2371
+ ```bash
2372
+ # View the last 5 messages
2373
+ tail -5 .ollama_agent/sessions/my-refactor.ndjson | jq .
2374
+ ```
2375
+
2376
+ Messages are appended after every agent turn — if the agent crashes mid-session, all completed turns are preserved.
2377
+ ```
2378
+
2379
+ - [ ] **Step 6.2.4: Commit docs**
2380
+
2381
+ ```bash
2382
+ git add docs/ARCHITECTURE.md docs/TOOLS.md docs/SESSIONS.md
2383
+ git commit -m "docs: add ARCHITECTURE, TOOLS, and SESSIONS guides for v0.2.0"
2384
+ ```
2385
+
2386
+ ---
2387
+
2388
+ ## Final Verification
2389
+
2390
+ - [ ] **Run the complete test suite**
2391
+
2392
+ ```bash
2393
+ bundle exec rspec --no-color --format progress
2394
+ ```
2395
+ Expected: all examples pass, `0 failures`.
2396
+
2397
+ - [ ] **Run RuboCop on all new files**
2398
+
2399
+ ```bash
2400
+ bundle exec rubocop lib/ollama_agent/tools/ \
2401
+ lib/ollama_agent/streaming/ \
2402
+ lib/ollama_agent/resilience/ \
2403
+ lib/ollama_agent/context/ \
2404
+ lib/ollama_agent/session/ \
2405
+ lib/ollama_agent/runner.rb \
2406
+ --no-color
2407
+ ```
2408
+ Expected: no offenses (or only pre-approved disable comments matching existing gem style).
2409
+
2410
+ - [ ] **Smoke test the CLI with a real (or stubbed) Ollama server**
2411
+
2412
+ ```bash
2413
+ # Confirm existing commands still work (--help check; no server needed)
2414
+ bundle exec ruby exe/ollama_agent help
2415
+ bundle exec ruby exe/ollama_agent help ask
2416
+ bundle exec ruby exe/ollama_agent sessions
2417
+ bundle exec ruby exe/ollama_agent agents
2418
+ ```
2419
+ Expected: help text includes `--stream`, `--session`, `--resume`, `--audit`, `--max-tokens`, `--max-retries`.
2420
+
2421
+ - [ ] **Final commit — update CHANGELOG**
2422
+
2423
+ Add to `CHANGELOG.md` under `[Unreleased]`:
2424
+
2425
+ ```markdown
2426
+ ## [0.2.0] - 2026-03-26
2427
+
2428
+ ### Added
2429
+ - `write_file` tool — create or overwrite files (complements `edit_file` for surgical diffs)
2430
+ - `OllamaAgent::Tools.register` — extensible tool registry for library consumers
2431
+ - `Streaming::Hooks` — event bus (`on_token`, `on_tool_call`, `on_tool_result`, `on_complete`, `on_error`, `on_retry`)
2432
+ - `--stream` / `OLLAMA_AGENT_STREAM=1` — live streaming token output
2433
+ - `Resilience::RetryMiddleware` — exponential backoff on timeout/503/429 (default 3 retries)
2434
+ - `Resilience::AuditLogger` — NDJSON audit log under `.ollama_agent/logs/` (`--audit` / `OLLAMA_AGENT_AUDIT=1`)
2435
+ - `Context::Manager` — sliding-window token trim before each chat call (`--max-tokens`)
2436
+ - `Session::Store` — crash-safe NDJSON session persistence (`--session`, `--resume`)
2437
+ - `ollama_agent sessions` — list saved sessions
2438
+ - `OllamaAgent::Runner` — stable public library facade with SemVer contract from 0.2.0
2439
+ - `docs/ARCHITECTURE.md`, `docs/TOOLS.md`, `docs/SESSIONS.md`
2440
+
2441
+ ### Changed
2442
+ - `READ_ONLY_TOOLS` now excludes both `edit_file` and `write_file`
2443
+ - `Agent` now exposes `#hooks` (Streaming::Hooks), `#session_id`
2444
+
2445
+ ### New environment variables
2446
+ - `OLLAMA_AGENT_STREAM`, `OLLAMA_AGENT_MAX_TOKENS`, `OLLAMA_AGENT_CONTEXT_SUMMARIZE`
2447
+ - `OLLAMA_AGENT_MAX_RETRIES`, `OLLAMA_AGENT_RETRY_BASE_DELAY`
2448
+ - `OLLAMA_AGENT_AUDIT`, `OLLAMA_AGENT_AUDIT_LOG_PATH`, `OLLAMA_AGENT_AUDIT_MAX_RESULT_BYTES`
2449
+ ```
2450
+
2451
+ ```bash
2452
+ git add CHANGELOG.md
2453
+ git commit -m "chore: update CHANGELOG for v0.2.0"
2454
+ ```