ollama_agent 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/skills/ruby-code-review-levels/SKILL.md +115 -0
  3. data/.cursor/skills/self-improvement-sandbox-safety/SKILL.md +65 -0
  4. data/.env.example +25 -0
  5. data/CHANGELOG.md +40 -0
  6. data/README.md +135 -4
  7. data/docs/ARCHITECTURE.md +42 -0
  8. data/docs/PERFORMANCE.md +22 -0
  9. data/docs/SESSIONS.md +48 -0
  10. data/docs/TOOLS.md +53 -0
  11. data/docs/TOOL_RUNTIME.md +154 -0
  12. data/docs/superpowers/plans/2026-03-26-production-ready-ollama-agent.md +2454 -0
  13. data/docs/superpowers/specs/2026-03-26-production-ready-ollama-agent-design.md +400 -0
  14. data/lib/ollama_agent/agent/agent_config.rb +53 -0
  15. data/lib/ollama_agent/agent/client_wiring.rb +76 -0
  16. data/lib/ollama_agent/agent/prompt_wiring.rb +55 -0
  17. data/lib/ollama_agent/agent/session_wiring.rb +53 -0
  18. data/lib/ollama_agent/agent.rb +148 -73
  19. data/lib/ollama_agent/agent_prompt.rb +31 -1
  20. data/lib/ollama_agent/chat_stream_carry.rb +88 -0
  21. data/lib/ollama_agent/chat_stream_thinking_format.rb +29 -0
  22. data/lib/ollama_agent/cli.rb +394 -4
  23. data/lib/ollama_agent/console.rb +177 -5
  24. data/lib/ollama_agent/context/manager.rb +100 -0
  25. data/lib/ollama_agent/context/token_counter.rb +33 -0
  26. data/lib/ollama_agent/diff_path_validator.rb +32 -10
  27. data/lib/ollama_agent/env_config.rb +44 -0
  28. data/lib/ollama_agent/external_agents/TODO-plan.md +1948 -0
  29. data/lib/ollama_agent/external_agents/argv_interp.rb +21 -0
  30. data/lib/ollama_agent/external_agents/default_agents.yml +60 -0
  31. data/lib/ollama_agent/external_agents/delegate_logger.rb +31 -0
  32. data/lib/ollama_agent/external_agents/delegate_timeout_status.rb +12 -0
  33. data/lib/ollama_agent/external_agents/env_helpers.rb +38 -0
  34. data/lib/ollama_agent/external_agents/path_validator.rb +32 -0
  35. data/lib/ollama_agent/external_agents/probe.rb +122 -0
  36. data/lib/ollama_agent/external_agents/registry.rb +50 -0
  37. data/lib/ollama_agent/external_agents/runner.rb +118 -0
  38. data/lib/ollama_agent/external_agents.rb +9 -0
  39. data/lib/ollama_agent/global_dotenv.rb +39 -0
  40. data/lib/ollama_agent/model_env.rb +26 -0
  41. data/lib/ollama_agent/ollama_chat_thinking_stream.rb +41 -0
  42. data/lib/ollama_agent/ollama_connection.rb +6 -1
  43. data/lib/ollama_agent/patch_risk.rb +81 -0
  44. data/lib/ollama_agent/patch_support.rb +27 -1
  45. data/lib/ollama_agent/path_sandbox.rb +62 -0
  46. data/lib/ollama_agent/prompt_skills/clean_ruby.md +131 -0
  47. data/lib/ollama_agent/prompt_skills/code_review.md +112 -0
  48. data/lib/ollama_agent/prompt_skills/design_patterns.md +56 -0
  49. data/lib/ollama_agent/prompt_skills/manifest.yml +25 -0
  50. data/lib/ollama_agent/prompt_skills/ollama_agent_patterns.md +132 -0
  51. data/lib/ollama_agent/prompt_skills/rails_best_practices.md +41 -0
  52. data/lib/ollama_agent/prompt_skills/rails_style.md +138 -0
  53. data/lib/ollama_agent/prompt_skills/rspec.md +280 -0
  54. data/lib/ollama_agent/prompt_skills/rubocop.md +7 -0
  55. data/lib/ollama_agent/prompt_skills/ruby_style.md +121 -0
  56. data/lib/ollama_agent/prompt_skills/solid.md +270 -0
  57. data/lib/ollama_agent/prompt_skills/solid_ruby.md +223 -0
  58. data/lib/ollama_agent/prompt_skills.rb +169 -0
  59. data/lib/ollama_agent/repo_list.rb +4 -1
  60. data/lib/ollama_agent/resilience/audit_logger.rb +79 -0
  61. data/lib/ollama_agent/resilience/retry_middleware.rb +45 -0
  62. data/lib/ollama_agent/resilience/retry_policy.rb +51 -0
  63. data/lib/ollama_agent/ruby_index_tool_support.rb +17 -6
  64. data/lib/ollama_agent/runner.rb +123 -0
  65. data/lib/ollama_agent/sandboxed_tools/delegate_external.rb +62 -0
  66. data/lib/ollama_agent/sandboxed_tools/file_read_write.rb +100 -0
  67. data/lib/ollama_agent/sandboxed_tools/search_text.rb +60 -0
  68. data/lib/ollama_agent/sandboxed_tools.rb +55 -116
  69. data/lib/ollama_agent/search_backend.rb +93 -0
  70. data/lib/ollama_agent/self_improvement/analyzer.rb +34 -0
  71. data/lib/ollama_agent/self_improvement/improver.rb +340 -0
  72. data/lib/ollama_agent/self_improvement/modes.rb +25 -0
  73. data/lib/ollama_agent/self_improvement/ruby_mastery_context.rb +66 -0
  74. data/lib/ollama_agent/self_improvement.rb +5 -0
  75. data/lib/ollama_agent/session/session.rb +8 -0
  76. data/lib/ollama_agent/session/store.rb +68 -0
  77. data/lib/ollama_agent/streaming/console_streamer.rb +29 -0
  78. data/lib/ollama_agent/streaming/hooks.rb +39 -0
  79. data/lib/ollama_agent/tool_arguments.rb +13 -1
  80. data/lib/ollama_agent/tool_content_parser.rb +1 -1
  81. data/lib/ollama_agent/tool_runtime/executor.rb +34 -0
  82. data/lib/ollama_agent/tool_runtime/json_extractor.rb +62 -0
  83. data/lib/ollama_agent/tool_runtime/loop.rb +72 -0
  84. data/lib/ollama_agent/tool_runtime/memory.rb +32 -0
  85. data/lib/ollama_agent/tool_runtime/ollama_json_planner.rb +98 -0
  86. data/lib/ollama_agent/tool_runtime/plan_extractor.rb +12 -0
  87. data/lib/ollama_agent/tool_runtime/registry.rb +60 -0
  88. data/lib/ollama_agent/tool_runtime/tool.rb +24 -0
  89. data/lib/ollama_agent/tool_runtime.rb +24 -0
  90. data/lib/ollama_agent/tools/registry.rb +55 -0
  91. data/lib/ollama_agent/tools_schema.rb +74 -1
  92. data/lib/ollama_agent/user_prompt.rb +35 -0
  93. data/lib/ollama_agent/version.rb +1 -1
  94. data/lib/ollama_agent.rb +25 -0
  95. data/reproduce_429.rb +40 -0
  96. data/sig/ollama_agent.rbs +111 -1
  97. metadata +78 -2
@@ -0,0 +1,400 @@
1
+ # Production-Ready `ollama_agent` — Design Spec
2
+
3
+ **Date:** 2026-03-26
4
+ **Status:** Approved
5
+ **Approach:** Additive Layers (B) — existing core untouched; six new layers stacked on top
6
+
7
+ ---
8
+
9
+ ## 1. Goals
10
+
11
+ Transform `ollama_agent` v0.1.x from a solid single-developer CLI tool into a production-ready gem that serves three personas simultaneously:
12
+
13
+ - **Solo developer** — maximum autonomy, streaming, session resume, retries
14
+ - **Team/shared** — audit logs, safe defaults, structured observability
15
+ - **Library consumer** — stable `OllamaAgent::Runner` API, extensible tool registry, YARD docs
16
+
17
+ All new capabilities are **backward-compatible** and **opt-in** via new flags/env vars. No existing CLI flags, env vars, or `Agent.new` kwargs change.
18
+
19
+ ---
20
+
21
+ ## 2. Architecture Overview
22
+
23
+ ```
24
+ CLI / Runner.run(query)
25
+ → Session::Store.resume (if --resume) or Session::Store.new session header
26
+ → Agent#run
27
+ → Context::Manager.trim(messages)
28
+ → OllamaConnection (with Resilience::RetryMiddleware)
29
+ → Ollama::Client#chat (streaming: true when subscribed)
30
+ → Streaming::Hooks.emit(:on_token, chunk)
31
+ → Tools::Registry.execute(name, args)
32
+ → Resilience::AuditLogger.log_tool_call(...)
33
+ → repeat until no tool calls
34
+ → Session::Store.save(messages)
35
+ ```
36
+
37
+ ### New directory structure
38
+
39
+ ```
40
+ lib/ollama_agent/
41
+ ├── agent.rb ← unchanged public API; gains hooks + context manager wiring
42
+ ├── cli.rb ← gains --stream, --session, --resume flags
43
+ ├── sandboxed_tools.rb ← gains write_file; case replaced by Registry dispatch
44
+ ├── tools_schema.rb ← gains write_file schema entry
45
+
46
+ ├── tools/
47
+ │ ├── registry.rb ← OllamaAgent::Tools.register / .execute / .schema_for
48
+ │ ├── built_in.rb ← re-registers existing 4 tools + write_file via registry
49
+ │ └── base.rb ← optional structured tool base class
50
+
51
+ ├── context/
52
+ │ ├── manager.rb ← token budget, sliding window trim, summarize hook
53
+ │ └── token_counter.rb ← char-estimate default; tiktoken_ruby plug-in when present
54
+
55
+ ├── session/
56
+ │ ├── store.rb ← save/load NDJSON under .ollama_agent/sessions/
57
+ │ └── session.rb ← session metadata + message envelope
58
+
59
+ ├── streaming/
60
+ │ ├── hooks.rb ← on_token/on_chunk/on_tool_call/on_tool_result/on_complete/on_error
61
+ │ └── console_streamer.rb ← default CLI subscriber for live token output
62
+
63
+ ├── resilience/
64
+ │ ├── retry_middleware.rb ← exponential backoff on TimeoutError / HTTP 5xx / 429
65
+ │ └── audit_logger.rb ← NDJSON to .ollama_agent/logs/ behind OLLAMA_AGENT_AUDIT=1
66
+
67
+ └── runner.rb ← OllamaAgent::Runner — stable library facade
68
+ ```
69
+
70
+ ---
71
+
72
+ ## 3. Layer 1 — Tool Registry + `write_file`
73
+
74
+ ### 3.1 `write_file` tool
75
+
76
+ - **Purpose:** create or overwrite a file under project root with full UTF-8 content
77
+ - **Schema:** `{ path: String (required), content: String (required) }`
78
+ - **Guards:** same `path_allowed?` sandbox as `edit_file`; blocked in read-only mode; confirmation flow when `confirm_patches: true` (prompt text: "Write file? (y/n)" — distinct from patch prompt)
79
+ - **Audit:** logged as `write_applied` event when audit enabled
80
+ - **Agent prompt addition:** "use `write_file` for new files or full rewrites; prefer `edit_file` for surgical changes"
81
+
82
+ ### 3.2 `Tools::Registry`
83
+
84
+ ```ruby
85
+ # Public API
86
+ OllamaAgent::Tools.register(:name, schema: { ... }) { |args, context:| ... }
87
+ OllamaAgent::Tools.execute(name, args, context:)
88
+ OllamaAgent::Tools.all_schemas(read_only:, orchestrator:)
89
+ ```
90
+
91
+ - Hash-based dispatch replaces `case` in `SandboxedTools#execute_tool`
92
+ - Built-in tools registered at require-time in `tools/built_in.rb`
93
+ - `context` hash passes `{ root:, read_only:, orchestrator: }` to every handler
94
+ - Custom tools registered before `Runner.build` are automatically injected into the model's tool list
95
+ - `TOOLS` / `READ_ONLY_TOOLS` constants kept as backward-compatible aliases
96
+
97
+ ### 3.3 Constraints
98
+
99
+ - Custom tool handlers must respect `context[:read_only]`; registry enforces this for write-class built-ins
100
+ - No metaprogramming — plain `Hash` keyed by string name; explicit registration only
101
+ - Adding a tool does not require changes to `agent.rb`, `cli.rb`, or `tools_schema.rb`
102
+
103
+ ---
104
+
105
+ ## 4. Layer 2 — Context Manager
106
+
107
+ ### 4.1 `Context::Manager`
108
+
109
+ Inserted into `Agent#execute_agent_turns` before every `chat` call. Never mutates messages in place — returns a trimmed copy.
110
+
111
+ **Configuration:**
112
+
113
+ | Env var | Keyword | Default |
114
+ |---------|---------|---------|
115
+ | `OLLAMA_AGENT_MAX_TOKENS` | `max_tokens:` | `8_192` |
116
+ | `OLLAMA_AGENT_CONTEXT_SUMMARIZE` | `context_summarize:` | `false` |
117
+
118
+ **Trim strategies:**
119
+
120
+ | Strategy | Behavior |
121
+ |----------|----------|
122
+ | Sliding window (default) | Drop oldest non-system message pairs until under `SUMMARY_THRESHOLD` (85%) of budget |
123
+ | Summarize (`context_summarize: true`) | Ask model to summarize dropped segment; inject as system message |
124
+
125
+ ### 4.2 Invariants
126
+
127
+ - System prompt is **never** trimmed
128
+ - The most recent `user` message is **never** trimmed
129
+ - `tool` result messages are trimmed together with their preceding `assistant` message (never orphaned)
130
+ - When a single message exceeds budget: truncate with `[truncated by context manager]` suffix — same pattern as `Runner#truncate`
131
+
132
+ ### 4.3 `Context::TokenCounter`
133
+
134
+ - Default: `chars / 4` (safe zero-dep estimate)
135
+ - Auto-upgrades to `tiktoken_ruby` when present in host bundle (`require` rescue'd)
136
+
137
+ ---
138
+
139
+ ## 5. Layer 3 — Session Persistence
140
+
141
+ ### 5.1 Storage format
142
+
143
+ **Location:** `.ollama_agent/sessions/<YYYY-MM-DD>T<HH-MM-SS>_<id>.ndjson` under project root
144
+
145
+ **File format** (NDJSON — one JSON object per line):
146
+ ```jsonc
147
+ // Line 1: session header
148
+ {"v":1,"id":"abc123","model":"gpt-oss:120b-cloud","root":"/proj","started_at":"2026-03-26T14:32:01Z"}
149
+ // Subsequent lines: message envelopes
150
+ {"role":"user","content":"Refactor the CLI","ts":"2026-03-26T14:32:02Z"}
151
+ {"role":"assistant","content":"I'll start by reading cli.rb...","tool_calls":[...],"ts":"..."}
152
+ {"role":"tool","name":"read_file","content":"...","ts":"..."}
153
+ ```
154
+
155
+ ### 5.2 `Session::Store` API
156
+
157
+ ```ruby
158
+ Session::Store.save(session_id:, root:, message:) # append one message (crash-safe)
159
+ Session::Store.load(session_id:, root:) # → Array<Hash>
160
+ Session::Store.list(root:) # → Array<SessionMeta>, newest first
161
+ Session::Store.resume(session_id:, root:) # → messages Array for Agent seeding
162
+ ```
163
+
164
+ ### 5.3 CLI integration
165
+
166
+ ```bash
167
+ ollama_agent ask --session my-refactor "Start refactoring the CLI"
168
+ ollama_agent ask --session my-refactor --resume
169
+ ollama_agent ask -i --session my-refactor --resume # REPL + resume
170
+ ollama_agent sessions # list sessions table
171
+ ```
172
+
173
+ `--resume` without `--session` resumes the most recent session for the current root.
174
+
175
+ ### 5.4 Constraints
176
+
177
+ - Scoped to project root — not global
178
+ - Messages appended after each turn, not batch-written at end (crash-safe)
179
+ - `Context::Manager` trims loaded session messages before first chat call
180
+ - Files are plain text: human-readable, `grep`-able, `jq`-able
181
+
182
+ ---
183
+
184
+ ## 6. Layer 4 — Streaming
185
+
186
+ ### 6.1 `Streaming::Hooks`
187
+
188
+ Lightweight event bus — plain Ruby, zero deps.
189
+
190
+ ```ruby
191
+ hooks = OllamaAgent::Streaming::Hooks.new
192
+ hooks.on(:on_token) { |p| print p[:token] }
193
+ hooks.on(:on_tool_call) { |p| log(p[:name]) }
194
+ hooks.emit(:on_token, { token: "hello", turn: 1 })
195
+ ```
196
+
197
+ **Events:**
198
+
199
+ | Event | Payload keys | When fired |
200
+ |-------|-------------|------------|
201
+ | `on_token` | `token`, `turn` | Each streamed token |
202
+ | `on_chunk` | `delta`, `turn` | Each raw ollama-client chunk |
203
+ | `on_tool_call` | `name`, `args`, `turn` | Before tool executes |
204
+ | `on_tool_result` | `name`, `result`, `turn` | After tool returns |
205
+ | `on_complete` | `messages`, `turns` | Loop finished |
206
+ | `on_error` | `error`, `turn` | Unhandled error in loop |
207
+ | `on_retry` | `error`, `attempt`, `delay_ms` | RetryMiddleware fires |
208
+
209
+ Multiple subscribers per event are supported. All seven events are members of `Hooks::EVENTS`; `on_retry` is included even though it is fired by `RetryMiddleware` (not `Agent`) — the hooks bus is shared across layers.
210
+
211
+ ### 6.2 `Streaming::ConsoleStreamer`
212
+
213
+ Default CLI subscriber. Auto-attached when stdout is a TTY and `OLLAMA_AGENT_STREAM=1` or `--stream` is passed.
214
+
215
+ ### 6.3 `Agent` integration
216
+
217
+ ```ruby
218
+ def chat_assistant_message(messages)
219
+ if @hooks.subscribed?(:on_token)
220
+ stream_assistant_message(messages) # chunk-by-chunk path
221
+ else
222
+ block_assistant_message(messages) # existing behavior — 100% unchanged
223
+ end
224
+ end
225
+ ```
226
+
227
+ Non-streaming path is the default. Streaming is opt-in.
228
+
229
+ ---
230
+
231
+ ## 7. Layer 5 — Resilience
232
+
233
+ ### 7.1 `Resilience::RetryMiddleware`
234
+
235
+ Wraps `Ollama::Client#chat`. Applied in `OllamaConnection` / `Agent#build_default_client`.
236
+
237
+ **Retry policy:**
238
+
239
+ | Condition | Max attempts | Backoff |
240
+ |-----------|-------------|---------|
241
+ | `Ollama::TimeoutError` | 3 | Exponential: 2s, 4s, 8s + jitter |
242
+ | HTTP 503 / 429 | 3 | Same |
243
+ | `Errno::ECONNREFUSED` | 2 | 5s fixed |
244
+ | HTTP 4xx | 0 (fail immediately) | — |
245
+ | Tool errors | 0 (never retry) | — |
246
+
247
+ **Configuration:**
248
+
249
+ | Env var | Keyword | Default |
250
+ |---------|---------|---------|
251
+ | `OLLAMA_AGENT_MAX_RETRIES` | `max_retries:` | `3` |
252
+ | `OLLAMA_AGENT_RETRY_BASE_DELAY` | — | `2.0` seconds |
253
+
254
+ Set `OLLAMA_AGENT_MAX_RETRIES=0` to restore current no-retry behavior.
255
+
256
+ Fires `on_retry` hook on each attempt so AuditLogger and CLI can surface it.
257
+
258
+ ### 7.2 `Resilience::AuditLogger`
259
+
260
+ **Location:** `.ollama_agent/logs/YYYY-MM-DD.ndjson` under project root
261
+
262
+ **Activation:** `OLLAMA_AGENT_AUDIT=1` or `audit: true` in `Runner.build`
263
+
264
+ **Logged events:**
265
+
266
+ ```jsonc
267
+ {"ts":"...","event":"agent_start","model":"...","root":"...","session":"..."}
268
+ {"ts":"...","event":"tool_call","name":"read_file","args":{"path":"lib/cli.rb"},"turn":1}
269
+ {"ts":"...","event":"tool_result","name":"read_file","bytes":4210,"turn":1,"duration_ms":3}
270
+ {"ts":"...","event":"edit_applied","path":"lib/cli.rb","diff_lines":12,"turn":2}
271
+ {"ts":"...","event":"write_applied","path":"lib/new_file.rb","bytes":340,"turn":3}
272
+ {"ts":"...","event":"http_retry","attempt":2,"error":"TimeoutError","delay_ms":4213}
273
+ {"ts":"...","event":"agent_complete","turns":4,"duration_ms":38210}
274
+ ```
275
+
276
+ **Constraints:**
277
+ - Log writes are non-blocking best-effort (`rescue StandardError` around every write)
278
+ - Log dir created automatically on first write
279
+ - Daily rotation (one file per date)
280
+ - `OLLAMA_AGENT_AUDIT_MAX_RESULT_BYTES=4096` caps tool result bodies in log (default: 4096)
281
+ - Subscribes to `Streaming::Hooks` — no coupling to `Agent` internals
282
+
283
+ ---
284
+
285
+ ## 8. Layer 6 — Library API (`OllamaAgent::Runner`)
286
+
287
+ ### 8.1 `Runner.build` factory
288
+
289
+ ```ruby
290
+ OllamaAgent::Runner.build(
291
+ root: Dir.pwd, # project root
292
+ model: nil, # OLLAMA_AGENT_MODEL or ollama-client default
293
+ stream: false, # enable streaming output
294
+ session_id: nil, # named session
295
+ resume: false, # load prior session messages
296
+ max_tokens: nil, # context budget (OLLAMA_AGENT_MAX_TOKENS)
297
+ context_summarize: false, # summarize vs sliding-window trim
298
+ max_retries: 3, # OLLAMA_AGENT_MAX_RETRIES
299
+ audit: false, # OLLAMA_AGENT_AUDIT
300
+ read_only: false, # disable edit_file + write_file
301
+ skills_enabled: true, # bundled prompt skills (matches Agent kwarg)
302
+ skill_paths: nil, # extra .md paths
303
+ confirm_patches: true, # prompt before applying patches
304
+ orchestrator: false, # enable external agent delegation
305
+ think: nil, # thinking mode
306
+ http_timeout: nil # OLLAMA_AGENT_TIMEOUT
307
+ ) → OllamaAgent::Runner
308
+ ```
309
+
310
+ ### 8.2 Instance interface
311
+
312
+ ```ruby
313
+ runner.hooks # → Streaming::Hooks — attach subscribers before run
314
+ runner.session # → Session::Session or nil
315
+ runner.run(query) # → nil — execute one query
316
+ runner.start_repl # → nil — interactive REPL (blocks)
317
+ ```
318
+
319
+ ### 8.3 SemVer contract (from 0.2.0)
320
+
321
+ | Surface | Stability |
322
+ |---------|-----------|
323
+ | `Runner.build` + `#run` + `#hooks` + `#session` | **Stable** |
324
+ | `OllamaAgent::Tools.register` | **Stable** |
325
+ | All `OLLAMA_AGENT_*` env vars | **Stable** |
326
+ | `OllamaAgent::CLI` flags | **Stable** |
327
+ | `OllamaAgent::Agent` kwargs | **Supported** (internal-friendly) |
328
+ | `Context/Session/Streaming/Resilience` internals | **Internal** |
329
+
330
+ ### 8.4 Documentation
331
+
332
+ - `docs/ARCHITECTURE.md` — layer diagram + data flow
333
+ - `docs/TOOLS.md` — custom tool registration guide with examples
334
+ - `docs/SESSIONS.md` — session persistence usage and file format
335
+ - YARD `@param`/`@return` on all public `Runner`, `Tools.register`, and `Hooks#on` methods
336
+
337
+ ---
338
+
339
+ ## 9. New Environment Variables
340
+
341
+ | Variable | Purpose | Default |
342
+ |----------|---------|---------|
343
+ | `OLLAMA_AGENT_STREAM` | Enable streaming output | off |
344
+ | `OLLAMA_AGENT_MAX_TOKENS` | Context window budget | `8192` |
345
+ | `OLLAMA_AGENT_CONTEXT_SUMMARIZE` | Use summarize vs sliding-window trim | off |
346
+ | `OLLAMA_AGENT_MAX_RETRIES` | Max HTTP retry attempts (0 = disable) | `3` |
347
+ | `OLLAMA_AGENT_RETRY_BASE_DELAY` | Base backoff delay in seconds | `2.0` |
348
+ | `OLLAMA_AGENT_AUDIT` | Enable structured audit logging | off |
349
+ | `OLLAMA_AGENT_AUDIT_LOG_PATH` | Override audit log directory | `<root>/.ollama_agent/logs/` |
350
+ | `OLLAMA_AGENT_AUDIT_MAX_RESULT_BYTES` | Cap tool result bodies in audit log | `4096` |
351
+
352
+ ---
353
+
354
+ ## 10. New CLI Flags
355
+
356
+ All new flags added to `ask`, `orchestrate`, `self_review`, `improve` where applicable:
357
+
358
+ | Flag | Purpose |
359
+ |------|---------|
360
+ | `--stream` | Enable streaming token output |
361
+ | `--session NAME` | Named session id |
362
+ | `--resume` | Load prior session messages before running |
363
+ | `--max-tokens N` | Context window budget |
364
+ | `--max-retries N` | HTTP retry limit |
365
+ | `--audit` | Enable audit logging for this run |
366
+
367
+ New top-level command: `ollama_agent sessions` — list sessions for current project root.
368
+
369
+ ---
370
+
371
+ ## 11. Test Coverage Requirements
372
+
373
+ Each new layer ships with specs:
374
+
375
+ | Layer | Required specs |
376
+ |-------|---------------|
377
+ | Tool Registry | register, execute, all_schemas; custom tool injection; read-only guard |
378
+ | `write_file` | create, overwrite, path sandbox, read-only block, confirmation |
379
+ | Context::Manager | trim sliding window, trim summarize, invariants (system/last-user never trimmed) |
380
+ | Session::Store | save, load, list, resume, crash-safe append |
381
+ | Streaming::Hooks | on/emit, multiple subscribers, unknown event no-op |
382
+ | ConsoleStreamer | attaches correct handlers |
383
+ | RetryMiddleware | retries on timeout/503/429, no retry on 4xx, max attempts, backoff |
384
+ | AuditLogger | writes NDJSON, best-effort (write failure doesn't raise), daily rotation |
385
+ | Runner | build factory, run, hooks, session wiring; integration smoke test |
386
+
387
+ Existing specs must remain green with zero changes.
388
+
389
+ ---
390
+
391
+ ## 12. Implementation Order
392
+
393
+ Each layer is an independently shippable PR:
394
+
395
+ 1. **Tool Registry + `write_file`** — foundational; unblocks custom tools
396
+ 2. **Streaming + Hooks** — unblocks AuditLogger (shares hook bus)
397
+ 3. **Resilience (Retry + AuditLogger)** — depends on Hooks
398
+ 4. **Context::Manager** — independent
399
+ 5. **Session::Store** — independent; integrates with Context::Manager
400
+ 6. **Runner + Library API + YARD docs** — integrates all layers; final PR
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ class Agent
5
+ # Value object grouping Agent construction options (Runner and tests build this explicitly).
6
+ class AgentConfig
7
+ attr_reader :model, :root, :confirm_patches, :http_timeout, :think, :read_only, :patch_policy,
8
+ :skill_paths, :skills_enabled, :skills_include, :skills_exclude, :external_skills_enabled,
9
+ :orchestrator, :confirm_delegation, :max_retries, :audit, :session_id, :resume,
10
+ :max_tokens, :context_summarize, :stdin, :stdout
11
+
12
+ # @param confirm_delegation [Boolean, nil] nil means default true
13
+ # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists, Metrics/AbcSize -- value object mirrors Agent keywords
14
+ def initialize(model: nil, root: nil, confirm_patches: true, http_timeout: nil, think: nil,
15
+ read_only: false, patch_policy: nil,
16
+ skill_paths: nil, skills_enabled: nil, skills_include: nil, skills_exclude: nil,
17
+ external_skills_enabled: nil,
18
+ orchestrator: false, confirm_delegation: nil,
19
+ max_retries: nil, audit: nil,
20
+ session_id: nil, resume: false,
21
+ max_tokens: nil, context_summarize: nil,
22
+ stdin: $stdin, stdout: $stdout)
23
+ @model = model
24
+ @root = root
25
+ @confirm_patches = confirm_patches
26
+ @http_timeout = http_timeout
27
+ @think = think
28
+ @read_only = read_only
29
+ @patch_policy = patch_policy
30
+ @skill_paths = skill_paths
31
+ @skills_enabled = skills_enabled
32
+ @skills_include = skills_include
33
+ @skills_exclude = skills_exclude
34
+ @external_skills_enabled = external_skills_enabled
35
+ @orchestrator = orchestrator
36
+ @confirm_delegation = confirm_delegation
37
+ @max_retries = max_retries
38
+ @audit = audit
39
+ @session_id = session_id
40
+ @resume = resume
41
+ @max_tokens = max_tokens
42
+ @context_summarize = context_summarize
43
+ @stdin = stdin
44
+ @stdout = stdout
45
+ end
46
+ # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists, Metrics/AbcSize
47
+
48
+ def resolved_confirm_delegation
49
+ @confirm_delegation.nil? || @confirm_delegation
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ class Agent
5
+ # HTTP client construction, timeouts, retries, and audit logger attachment.
6
+ module ClientWiring
7
+ private
8
+
9
+ # rubocop:disable Metrics/MethodLength
10
+ def build_default_client
11
+ config = Ollama::Config.new
12
+ @http_timeout_seconds = resolved_http_timeout_seconds
13
+ config.timeout = @http_timeout_seconds
14
+ OllamaConnection.apply_env_to_config(config)
15
+ ollama_client = Ollama::Client.new(config: config)
16
+ Resilience::RetryMiddleware.new(
17
+ client: ollama_client,
18
+ max_attempts: resolved_max_retries,
19
+ hooks: @hooks,
20
+ base_delay: resolved_retry_base_delay
21
+ )
22
+ end
23
+ # rubocop:enable Metrics/MethodLength
24
+
25
+ def resolved_max_retries
26
+ return @max_retries unless @max_retries.nil?
27
+
28
+ EnvConfig.fetch_int(
29
+ "OLLAMA_AGENT_MAX_RETRIES",
30
+ Resilience::RetryMiddleware::DEFAULT_MAX_ATTEMPTS,
31
+ strict: EnvConfig.strict_env?
32
+ )
33
+ end
34
+
35
+ def resolved_retry_base_delay
36
+ EnvConfig.fetch_float(
37
+ "OLLAMA_AGENT_RETRY_BASE_DELAY",
38
+ Resilience::RetryMiddleware::DEFAULT_BASE_DELAY,
39
+ strict: EnvConfig.strict_env?
40
+ )
41
+ end
42
+
43
+ def resolved_http_timeout_seconds
44
+ parsed = TimeoutParam.parse_positive(@http_timeout_override)
45
+ return parsed if parsed
46
+
47
+ raw = ENV.fetch("OLLAMA_AGENT_TIMEOUT", nil)
48
+ parsed = TimeoutParam.parse_positive(raw)
49
+ EnvConfig.warn_invalid("OLLAMA_AGENT_TIMEOUT", raw, DEFAULT_HTTP_TIMEOUT) if malformed_timeout_env?(raw, parsed)
50
+
51
+ parsed || DEFAULT_HTTP_TIMEOUT
52
+ end
53
+
54
+ def malformed_timeout_env?(raw, parsed)
55
+ raw && !raw.to_s.strip.empty? && parsed.nil?
56
+ end
57
+
58
+ def resolved_audit_enabled
59
+ return @audit unless @audit.nil?
60
+
61
+ ENV.fetch("OLLAMA_AGENT_AUDIT", "0") == "1"
62
+ end
63
+
64
+ def audit_log_dir
65
+ custom = ENV.fetch("OLLAMA_AGENT_AUDIT_LOG_PATH", nil)
66
+ return custom if custom && !custom.to_s.strip.empty?
67
+
68
+ File.join(@root, ".ollama_agent", "logs")
69
+ end
70
+
71
+ def attach_audit_logger
72
+ Resilience::AuditLogger.new(log_dir: audit_log_dir, hooks: @hooks).attach
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ class Agent
5
+ # System prompt and bundled skill resolution.
6
+ module PromptWiring
7
+ private
8
+
9
+ # rubocop:disable Metrics/MethodLength
10
+ def system_prompt
11
+ base = @read_only ? AgentPrompt.self_review_text : AgentPrompt.text
12
+ composed = PromptSkills.compose(
13
+ base: base,
14
+ skills_enabled: resolved_skills_enabled,
15
+ skills_include: resolved_skills_include,
16
+ skills_exclude: resolved_skills_exclude,
17
+ skill_paths: resolved_skill_paths,
18
+ external_skills_enabled: resolved_external_skills_enabled
19
+ )
20
+ return composed unless @orchestrator
21
+
22
+ [composed, AgentPrompt.orchestrator_addon].join("\n\n---\n\n")
23
+ end
24
+ # rubocop:enable Metrics/MethodLength
25
+
26
+ def resolved_skills_enabled
27
+ return @skills_enabled unless @skills_enabled.nil?
28
+
29
+ PromptSkills.env_truthy("OLLAMA_AGENT_SKILLS", default: true)
30
+ end
31
+
32
+ def resolved_skills_include
33
+ return @skills_include unless @skills_include.nil?
34
+
35
+ PromptSkills.parse_id_list(ENV.fetch("OLLAMA_AGENT_SKILLS_INCLUDE", nil))
36
+ end
37
+
38
+ def resolved_skills_exclude
39
+ return @skills_exclude unless @skills_exclude.nil?
40
+
41
+ PromptSkills.parse_id_list(ENV.fetch("OLLAMA_AGENT_SKILLS_EXCLUDE", nil))
42
+ end
43
+
44
+ def resolved_skill_paths
45
+ @skill_paths
46
+ end
47
+
48
+ def resolved_external_skills_enabled
49
+ return @external_skills_enabled unless @external_skills_enabled.nil?
50
+
51
+ PromptSkills.env_truthy("OLLAMA_AGENT_EXTERNAL_SKILLS", default: true)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ class Agent
5
+ # Session resume/save and tool message formatting for the chat transcript.
6
+ module SessionWiring
7
+ private
8
+
9
+ # rubocop:disable Metrics/CyclomaticComplexity
10
+ def build_messages_for_run(query)
11
+ prior = @session_id && @resume ? Session::Store.resume(session_id: @session_id, root: @root) : []
12
+ messages = prior.empty? ? [{ role: "system", content: system_prompt }] : prior
13
+ first = messages.first
14
+ unless first && (first[:role] == "system" || first["role"] == "system")
15
+ messages.unshift({ role: "system", content: system_prompt })
16
+ end
17
+
18
+ messages << { role: "user", content: query }
19
+ Session::Store.save(session_id: @session_id, root: @root, message: messages.last) if @session_id
20
+
21
+ messages
22
+ end
23
+ # rubocop:enable Metrics/CyclomaticComplexity
24
+
25
+ def append_tool_results(messages, tool_calls)
26
+ tool_calls.each do |tool_call|
27
+ @hooks.emit(:on_tool_call, { name: tool_call.name, args: tool_call.arguments || {}, turn: current_turn })
28
+ result = execute_tool(tool_call.name, tool_call.arguments || {})
29
+ @hooks.emit(:on_tool_result, { name: tool_call.name, result: result.to_s, turn: current_turn })
30
+ messages << tool_message(tool_call, result)
31
+ save_message_to_session(messages.last)
32
+ end
33
+ end
34
+
35
+ def save_message_to_session(msg)
36
+ return unless @session_id
37
+
38
+ Session::Store.save(session_id: @session_id, root: @root, message: msg)
39
+ end
40
+
41
+ def tool_message(tool_call, result)
42
+ msg = {
43
+ role: "tool",
44
+ name: tool_call.name,
45
+ content: result.to_s
46
+ }
47
+ id = tool_call.id
48
+ msg[:tool_call_id] = id if id && !id.to_s.empty?
49
+ msg
50
+ end
51
+ end
52
+ end
53
+ end