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.
- checksums.yaml +4 -4
- data/.cursor/skills/ruby-code-review-levels/SKILL.md +115 -0
- data/.cursor/skills/self-improvement-sandbox-safety/SKILL.md +65 -0
- data/.env.example +25 -0
- data/CHANGELOG.md +40 -0
- data/README.md +135 -4
- data/docs/ARCHITECTURE.md +42 -0
- data/docs/PERFORMANCE.md +22 -0
- data/docs/SESSIONS.md +48 -0
- data/docs/TOOLS.md +53 -0
- data/docs/TOOL_RUNTIME.md +154 -0
- data/docs/superpowers/plans/2026-03-26-production-ready-ollama-agent.md +2454 -0
- data/docs/superpowers/specs/2026-03-26-production-ready-ollama-agent-design.md +400 -0
- data/lib/ollama_agent/agent/agent_config.rb +53 -0
- data/lib/ollama_agent/agent/client_wiring.rb +76 -0
- data/lib/ollama_agent/agent/prompt_wiring.rb +55 -0
- data/lib/ollama_agent/agent/session_wiring.rb +53 -0
- data/lib/ollama_agent/agent.rb +148 -73
- data/lib/ollama_agent/agent_prompt.rb +31 -1
- data/lib/ollama_agent/chat_stream_carry.rb +88 -0
- data/lib/ollama_agent/chat_stream_thinking_format.rb +29 -0
- data/lib/ollama_agent/cli.rb +394 -4
- data/lib/ollama_agent/console.rb +177 -5
- data/lib/ollama_agent/context/manager.rb +100 -0
- data/lib/ollama_agent/context/token_counter.rb +33 -0
- data/lib/ollama_agent/diff_path_validator.rb +32 -10
- data/lib/ollama_agent/env_config.rb +44 -0
- data/lib/ollama_agent/external_agents/TODO-plan.md +1948 -0
- data/lib/ollama_agent/external_agents/argv_interp.rb +21 -0
- data/lib/ollama_agent/external_agents/default_agents.yml +60 -0
- data/lib/ollama_agent/external_agents/delegate_logger.rb +31 -0
- data/lib/ollama_agent/external_agents/delegate_timeout_status.rb +12 -0
- data/lib/ollama_agent/external_agents/env_helpers.rb +38 -0
- data/lib/ollama_agent/external_agents/path_validator.rb +32 -0
- data/lib/ollama_agent/external_agents/probe.rb +122 -0
- data/lib/ollama_agent/external_agents/registry.rb +50 -0
- data/lib/ollama_agent/external_agents/runner.rb +118 -0
- data/lib/ollama_agent/external_agents.rb +9 -0
- data/lib/ollama_agent/global_dotenv.rb +39 -0
- data/lib/ollama_agent/model_env.rb +26 -0
- data/lib/ollama_agent/ollama_chat_thinking_stream.rb +41 -0
- data/lib/ollama_agent/ollama_connection.rb +6 -1
- data/lib/ollama_agent/patch_risk.rb +81 -0
- data/lib/ollama_agent/patch_support.rb +27 -1
- data/lib/ollama_agent/path_sandbox.rb +62 -0
- data/lib/ollama_agent/prompt_skills/clean_ruby.md +131 -0
- data/lib/ollama_agent/prompt_skills/code_review.md +112 -0
- data/lib/ollama_agent/prompt_skills/design_patterns.md +56 -0
- data/lib/ollama_agent/prompt_skills/manifest.yml +25 -0
- data/lib/ollama_agent/prompt_skills/ollama_agent_patterns.md +132 -0
- data/lib/ollama_agent/prompt_skills/rails_best_practices.md +41 -0
- data/lib/ollama_agent/prompt_skills/rails_style.md +138 -0
- data/lib/ollama_agent/prompt_skills/rspec.md +280 -0
- data/lib/ollama_agent/prompt_skills/rubocop.md +7 -0
- data/lib/ollama_agent/prompt_skills/ruby_style.md +121 -0
- data/lib/ollama_agent/prompt_skills/solid.md +270 -0
- data/lib/ollama_agent/prompt_skills/solid_ruby.md +223 -0
- data/lib/ollama_agent/prompt_skills.rb +169 -0
- data/lib/ollama_agent/repo_list.rb +4 -1
- data/lib/ollama_agent/resilience/audit_logger.rb +79 -0
- data/lib/ollama_agent/resilience/retry_middleware.rb +45 -0
- data/lib/ollama_agent/resilience/retry_policy.rb +51 -0
- data/lib/ollama_agent/ruby_index_tool_support.rb +17 -6
- data/lib/ollama_agent/runner.rb +123 -0
- data/lib/ollama_agent/sandboxed_tools/delegate_external.rb +62 -0
- data/lib/ollama_agent/sandboxed_tools/file_read_write.rb +100 -0
- data/lib/ollama_agent/sandboxed_tools/search_text.rb +60 -0
- data/lib/ollama_agent/sandboxed_tools.rb +55 -116
- data/lib/ollama_agent/search_backend.rb +93 -0
- data/lib/ollama_agent/self_improvement/analyzer.rb +34 -0
- data/lib/ollama_agent/self_improvement/improver.rb +340 -0
- data/lib/ollama_agent/self_improvement/modes.rb +25 -0
- data/lib/ollama_agent/self_improvement/ruby_mastery_context.rb +66 -0
- data/lib/ollama_agent/self_improvement.rb +5 -0
- data/lib/ollama_agent/session/session.rb +8 -0
- data/lib/ollama_agent/session/store.rb +68 -0
- data/lib/ollama_agent/streaming/console_streamer.rb +29 -0
- data/lib/ollama_agent/streaming/hooks.rb +39 -0
- data/lib/ollama_agent/tool_arguments.rb +13 -1
- data/lib/ollama_agent/tool_content_parser.rb +1 -1
- data/lib/ollama_agent/tool_runtime/executor.rb +34 -0
- data/lib/ollama_agent/tool_runtime/json_extractor.rb +62 -0
- data/lib/ollama_agent/tool_runtime/loop.rb +72 -0
- data/lib/ollama_agent/tool_runtime/memory.rb +32 -0
- data/lib/ollama_agent/tool_runtime/ollama_json_planner.rb +98 -0
- data/lib/ollama_agent/tool_runtime/plan_extractor.rb +12 -0
- data/lib/ollama_agent/tool_runtime/registry.rb +60 -0
- data/lib/ollama_agent/tool_runtime/tool.rb +24 -0
- data/lib/ollama_agent/tool_runtime.rb +24 -0
- data/lib/ollama_agent/tools/registry.rb +55 -0
- data/lib/ollama_agent/tools_schema.rb +74 -1
- data/lib/ollama_agent/user_prompt.rb +35 -0
- data/lib/ollama_agent/version.rb +1 -1
- data/lib/ollama_agent.rb +25 -0
- data/reproduce_429.rb +40 -0
- data/sig/ollama_agent.rbs +111 -1
- 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
|
+
```
|