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
@@ -3,40 +3,429 @@
3
3
  require "thor"
4
4
 
5
5
  require_relative "agent"
6
+ require_relative "external_agents"
7
+ require_relative "prompt_skills"
6
8
 
7
9
  module OllamaAgent
8
10
  # Thor CLI for single-shot and interactive agent sessions.
11
+ # rubocop:disable Metrics/ClassLength -- Thor commands and shared helpers
9
12
  class CLI < Thor
13
+ def self.exit_on_failure?
14
+ true
15
+ end
16
+
10
17
  desc "ask [QUERY]", "Run a natural-language task (reads, search, patch)"
11
18
  method_option :model, type: :string, desc: "Ollama model (default: OLLAMA_AGENT_MODEL or ollama-client default)"
12
19
  method_option :interactive, type: :boolean, aliases: "-i", desc: "Interactive REPL"
20
+ method_option :read_only, type: :boolean, default: false, aliases: "-R",
21
+ desc: "Read/search only (no edit_file, write_file, patches, or delegation)"
13
22
  method_option :yes, type: :boolean, aliases: "-y", desc: "Apply patches without confirmation"
14
23
  method_option :root, type: :string, desc: "Project root (default: OLLAMA_AGENT_ROOT or cwd)"
15
24
  method_option :timeout, type: :numeric, aliases: "-t", desc: "HTTP timeout seconds (default 120)"
16
25
  method_option :think, type: :string, desc: "Thinking mode: true|false|high|medium|low (see OLLAMA_AGENT_THINK)"
26
+ method_option :no_skills, type: :boolean, default: false,
27
+ desc: "Disable bundled prompt skills (same as OLLAMA_AGENT_SKILLS=0)"
28
+ method_option :skill_paths, type: :string,
29
+ desc: "Extra .md paths or dirs, colon-separated; merged with OLLAMA_AGENT_SKILL_PATHS"
30
+ method_option :stream, type: :boolean, default: false,
31
+ desc: "Stream tokens to terminal as they arrive (OLLAMA_AGENT_STREAM=1)"
32
+ method_option :audit, type: :boolean, default: false,
33
+ desc: "Enable structured audit log under .ollama_agent/logs/ (OLLAMA_AGENT_AUDIT=1)"
34
+ method_option :max_retries, type: :numeric,
35
+ desc: "HTTP retry attempts (0=disable, default 3)"
36
+ method_option :session, type: :string, desc: "Named session id (saves/resumes conversation)"
37
+ method_option :resume, type: :boolean, default: false,
38
+ desc: "Resume the named (or most recent) session"
39
+ method_option :max_tokens, type: :numeric,
40
+ desc: "Context window budget (OLLAMA_AGENT_MAX_TOKENS)"
41
+ method_option :context_summarize, type: :boolean, default: false,
42
+ desc: "Summarize dropped context vs sliding window"
17
43
  def ask(query = nil)
18
44
  agent = build_agent
19
45
 
20
46
  if options[:interactive]
21
47
  start_interactive(agent)
22
48
  elsif query
23
- agent.run(query)
49
+ run_single_shot_agent!(agent, query)
50
+ else
51
+ puts Console.error_line("Error: provide a QUERY or use --interactive")
52
+ exit 1
53
+ end
54
+ end
55
+
56
+ desc "orchestrate [QUERY]", "Like ask, with tools to list/delegate to external CLI agents (Claude, Gemini, …)"
57
+ method_option :model, type: :string, desc: "Ollama model (default: OLLAMA_AGENT_MODEL or ollama-client default)"
58
+ method_option :interactive, type: :boolean, aliases: "-i", desc: "Interactive REPL"
59
+ method_option :read_only, type: :boolean, default: false, aliases: "-R",
60
+ desc: "Read/search only (no edit_file, write_file, patches, or delegation)"
61
+ method_option :yes, type: :boolean, aliases: "-y", desc: "Apply patches and run delegations without confirmation"
62
+ method_option :root, type: :string, desc: "Project root (default: OLLAMA_AGENT_ROOT or cwd)"
63
+ method_option :timeout, type: :numeric, aliases: "-t", desc: "HTTP timeout seconds (default 120)"
64
+ method_option :think, type: :string, desc: "Thinking mode: true|false|high|medium|low (see OLLAMA_AGENT_THINK)"
65
+ method_option :no_skills, type: :boolean, default: false,
66
+ desc: "Disable bundled prompt skills (same as OLLAMA_AGENT_SKILLS=0)"
67
+ method_option :skill_paths, type: :string,
68
+ desc: "Extra .md paths or dirs, colon-separated; merged with OLLAMA_AGENT_SKILL_PATHS"
69
+ method_option :stream, type: :boolean, default: false,
70
+ desc: "Stream tokens to terminal as they arrive (OLLAMA_AGENT_STREAM=1)"
71
+ method_option :audit, type: :boolean, default: false,
72
+ desc: "Enable structured audit log under .ollama_agent/logs/ (OLLAMA_AGENT_AUDIT=1)"
73
+ method_option :max_retries, type: :numeric,
74
+ desc: "HTTP retry attempts (0=disable, default 3)"
75
+ method_option :max_tokens, type: :numeric,
76
+ desc: "Context window budget (OLLAMA_AGENT_MAX_TOKENS)"
77
+ method_option :context_summarize, type: :boolean, default: false,
78
+ desc: "Summarize dropped context vs sliding window"
79
+ def orchestrate(query = nil)
80
+ agent = build_orchestrator_agent
81
+
82
+ if options[:interactive]
83
+ start_interactive(agent)
84
+ elsif query
85
+ run_single_shot_agent!(agent, query)
24
86
  else
25
87
  puts Console.error_line("Error: provide a QUERY or use --interactive")
26
88
  exit 1
27
89
  end
28
90
  end
29
91
 
92
+ desc "sessions", "List saved sessions for the current project root"
93
+ method_option :root, type: :string, desc: "Project root (default: OLLAMA_AGENT_ROOT or cwd)"
94
+ def sessions
95
+ root = resolved_root_for_self_review
96
+ list = Session::Store.list(root: root)
97
+ if list.empty?
98
+ puts "No sessions found in #{root}"
99
+ return
100
+ end
101
+ puts "SESSION ID STARTED"
102
+ list.each { |s| puts format("%-30<id>s %<started>s", id: s.session_id, started: s.started_at) }
103
+ end
104
+
105
+ desc "agents", "List configured external CLI agents and whether they are on PATH"
106
+ def agents
107
+ reg = ExternalAgents::Registry.load
108
+ ExternalAgents::Probe.print_table(reg)
109
+ end
110
+
111
+ desc "doctor", "Alias for agents"
112
+ def doctor
113
+ agents
114
+ end
115
+
116
+ desc "self_review", "Self-review / improvement: --mode analysis | interactive | automated (see help)"
117
+ method_option :mode, type: :string, default: "analysis",
118
+ desc: "analysis (1)=read-only report; interactive (2)=confirm patches in tree; " \
119
+ "automated (3)=sandbox+RSpec+optional --apply"
120
+ long_desc <<~HELP
121
+ Modes:
122
+ analysis Read-only tools; prints a report; no writes (default).
123
+ interactive Full tools on --root; you confirm each patch (like `ask`); optional -y / --semi.
124
+ automated Temp sandbox, agent edits, `bundle exec rspec`; optional --apply to merge back.
125
+
126
+ Aliases: 1/2/3, readonly, fix, confirm, sandbox, full.
127
+ HELP
128
+ method_option :model, type: :string, desc: "Ollama model (default: OLLAMA_AGENT_MODEL or ollama-client default)"
129
+ method_option :root, type: :string, desc: "Project root (default: OLLAMA_AGENT_ROOT or cwd)"
130
+ method_option :timeout, type: :numeric, aliases: "-t", desc: "HTTP timeout seconds (default 120)"
131
+ method_option :think, type: :string, desc: "Thinking mode: true|false|high|medium|low (see OLLAMA_AGENT_THINK)"
132
+ method_option :yes, type: :boolean, aliases: "-y",
133
+ desc: "interactive/automated: apply patches without confirmation"
134
+ method_option :semi, type: :boolean, default: true,
135
+ desc: "interactive/automated: auto-approve obvious patches; prompt for risky (default: true)"
136
+ method_option :apply, type: :boolean, default: false,
137
+ desc: "automated only: after green tests, copy changed files from sandbox into --root"
138
+ method_option :no_skills, type: :boolean, default: false,
139
+ desc: "Disable bundled prompt skills (same as OLLAMA_AGENT_SKILLS=0)"
140
+ method_option :skill_paths, type: :string,
141
+ desc: "Extra .md paths or dirs, colon-separated; merged with OLLAMA_AGENT_SKILL_PATHS"
142
+ method_option :stream, type: :boolean, default: false,
143
+ desc: "Stream tokens to terminal as they arrive (OLLAMA_AGENT_STREAM=1)"
144
+ method_option :max_tokens, type: :numeric,
145
+ desc: "Context window budget (OLLAMA_AGENT_MAX_TOKENS)"
146
+ method_option :context_summarize, type: :boolean, default: false,
147
+ desc: "Summarize dropped context vs sliding window"
148
+ method_option :verify, type: :string,
149
+ desc: "automated only: comma-separated checks after agent (default: rspec or " \
150
+ "OLLAMA_AGENT_IMPROVE_VERIFY). Steps: syntax, rubocop, rspec"
151
+ method_option :no_ruby_mastery, type: :boolean, default: false,
152
+ desc: "Skip prepending ruby_mastery static analysis (see OLLAMA_AGENT_RUBY_MASTERY)"
153
+ def self_review
154
+ dispatch_self_review_mode(SelfImprovement::Modes.normalize(options[:mode]))
155
+ end
156
+
157
+ desc "improve", "Shortcut for: self_review --mode automated"
158
+ method_option :mode, type: :string, default: "automated",
159
+ desc: "Optional; must be automated (or 3, sandbox, full). Other modes: use self_review --mode"
160
+ method_option :model, type: :string, desc: "Ollama model (default: OLLAMA_AGENT_MODEL or ollama-client default)"
161
+ method_option :root, type: :string, desc: "Source tree to copy and test (default: OLLAMA_AGENT_ROOT or cwd)"
162
+ method_option :timeout, type: :numeric, aliases: "-t", desc: "HTTP timeout seconds (default 120)"
163
+ method_option :think, type: :string, desc: "Thinking mode: true|false|high|medium|low (see OLLAMA_AGENT_THINK)"
164
+ method_option :yes, type: :boolean, aliases: "-y", desc: "Apply all patches without confirmation"
165
+ method_option :semi, type: :boolean, default: true,
166
+ desc: "Without -y: auto-approve obvious patches; prompt for risky (default: true)"
167
+ method_option :apply, type: :boolean, default: false,
168
+ desc: "After green tests, copy changed files from sandbox into --root"
169
+ method_option :no_skills, type: :boolean, default: false,
170
+ desc: "Disable bundled prompt skills (same as OLLAMA_AGENT_SKILLS=0)"
171
+ method_option :skill_paths, type: :string,
172
+ desc: "Extra .md paths or dirs, colon-separated; merged with OLLAMA_AGENT_SKILL_PATHS"
173
+ method_option :stream, type: :boolean, default: false,
174
+ desc: "Stream tokens to terminal as they arrive (OLLAMA_AGENT_STREAM=1)"
175
+ method_option :max_tokens, type: :numeric,
176
+ desc: "Context window budget (OLLAMA_AGENT_MAX_TOKENS)"
177
+ method_option :context_summarize, type: :boolean, default: false,
178
+ desc: "Summarize dropped context vs sliding window"
179
+ method_option :verify, type: :string,
180
+ desc: "Comma-separated post-agent checks (default: rspec; env " \
181
+ "OLLAMA_AGENT_IMPROVE_VERIFY). Steps: syntax, rubocop, rspec"
182
+ method_option :no_ruby_mastery, type: :boolean, default: false,
183
+ desc: "Skip prepending ruby_mastery static analysis (see OLLAMA_AGENT_RUBY_MASTERY)"
184
+ def improve
185
+ ensure_improve_mode_only_automated!
186
+ dispatch_self_review_mode("automated")
187
+ end
188
+
30
189
  private
31
190
 
191
+ def ensure_improve_mode_only_automated!
192
+ m = SelfImprovement::Modes.normalize(options[:mode])
193
+ return if m == "automated"
194
+
195
+ warn Console.error_line(improve_mode_error_message(m))
196
+ exit 1
197
+ end
198
+
199
+ def improve_mode_error_message(normalized_mode)
200
+ return invalid_improve_mode_message unless SelfImprovement::Modes.valid?(normalized_mode)
201
+
202
+ "Command \"improve\" only runs automated (sandbox) mode. For other modes use: " \
203
+ "ollama_agent self_review --mode analysis | interactive"
204
+ end
205
+
206
+ def invalid_improve_mode_message
207
+ "Invalid --mode for improve: use automated (or 3, sandbox, full). Got: #{options[:mode].inspect}"
208
+ end
209
+
210
+ def dispatch_self_review_mode(mode)
211
+ validate_self_review_mode!(mode)
212
+ send(:"run_mode_#{mode}")
213
+ end
214
+
215
+ def validate_self_review_mode!(mode)
216
+ return if SelfImprovement::Modes.valid?(mode)
217
+
218
+ warn Console.error_line(
219
+ "Invalid --mode: use analysis, interactive, or automated (or 1, 2, 3). Got: #{options[:mode].inspect}"
220
+ )
221
+ exit 1
222
+ end
223
+
224
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize -- stream + context kwargs exceed limits
225
+ def run_mode_analysis
226
+ agent = Agent.new(
227
+ model: options[:model],
228
+ root: resolved_root_for_self_review,
229
+ read_only: true,
230
+ confirm_patches: false,
231
+ http_timeout: options[:timeout],
232
+ think: options[:think],
233
+ max_tokens: options[:max_tokens],
234
+ context_summarize: options[:context_summarize],
235
+ **skill_agent_options
236
+ )
237
+ attach_console_streamer(agent) if stream_enabled?
238
+ preamble = ruby_mastery_preamble(resolved_root_for_self_review)
239
+ SelfImprovement::Analyzer.new(agent).run(preamble: preamble)
240
+ end
241
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
242
+
243
+ def run_mode_interactive
244
+ agent = Agent.new(**interactive_agent_keywords)
245
+ attach_console_streamer(agent) if stream_enabled?
246
+ preamble = ruby_mastery_preamble(resolved_root_for_self_review)
247
+ SelfImprovement::Analyzer.new(agent).run(SelfImprovement::Analyzer::INTERACTIVE_PROMPT, preamble: preamble)
248
+ end
249
+
250
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize -- context kwargs push over limit
251
+ def interactive_agent_keywords
252
+ semi = options[:semi] != false
253
+ {
254
+ model: options[:model],
255
+ root: resolved_root_for_self_review,
256
+ read_only: false,
257
+ confirm_patches: !options[:yes],
258
+ patch_policy: semi ? PatchRisk.method(:assess).to_proc : nil,
259
+ http_timeout: options[:timeout],
260
+ think: options[:think],
261
+ max_tokens: options[:max_tokens],
262
+ context_summarize: options[:context_summarize]
263
+ }.merge(skill_agent_options)
264
+ end
265
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
266
+
267
+ def run_mode_automated
268
+ result = SelfImprovement::Improver.new.run(**improve_run_options)
269
+ report_improve_result(result)
270
+ end
271
+
272
+ def resolved_root_for_self_review
273
+ raw = options[:root] || ENV.fetch("OLLAMA_AGENT_ROOT", nil)
274
+ base = raw.to_s.strip.empty? ? Dir.pwd : raw
275
+ File.expand_path(base)
276
+ end
277
+
278
+ def ruby_mastery_enabled?
279
+ return false if options[:no_ruby_mastery]
280
+ return false if ENV.fetch("OLLAMA_AGENT_RUBY_MASTERY", "1") == "0"
281
+
282
+ true
283
+ end
284
+
285
+ def ruby_mastery_preamble(root)
286
+ return nil unless ruby_mastery_enabled?
287
+
288
+ SelfImprovement::RubyMasteryContext.markdown_section(root)
289
+ end
290
+
291
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- context kwargs push over limit
292
+ def improve_run_options
293
+ {
294
+ model: options[:model],
295
+ root: options[:root] || ENV.fetch("OLLAMA_AGENT_ROOT", nil),
296
+ yes: options[:yes],
297
+ semi: options[:semi] != false,
298
+ apply: options[:apply],
299
+ http_timeout: options[:timeout],
300
+ think: options[:think],
301
+ max_tokens: options[:max_tokens],
302
+ context_summarize: options[:context_summarize],
303
+ stream: stream_enabled?,
304
+ verify: options[:verify],
305
+ ruby_mastery: ruby_mastery_enabled?
306
+ }.merge(skill_agent_options)
307
+ end
308
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
309
+
310
+ def report_improve_result(result)
311
+ unless result[:success]
312
+ warn Console.error_line("Tests failed in sandbox.")
313
+ puts result[:test_output]
314
+ exit 1
315
+ end
316
+
317
+ root = result[:source_root]
318
+ puts "ollama_agent: tests passed in sandbox (#{root})"
319
+ copied = result[:copied_to_source]
320
+ report_improve_copied_files(copied, root)
321
+ report_improve_merge_outcome(copied, root)
322
+ end
323
+
324
+ def report_improve_merge_outcome(copied, root)
325
+ if options[:apply]
326
+ puts "ollama_agent: no changed files to copy from sandbox" if copied.empty?
327
+ return
328
+ end
329
+
330
+ puts "ollama_agent: sandbox was discarded — your project tree was not updated. " \
331
+ "Re-run with --apply to merge sandbox changes into #{root} after a green run."
332
+ end
333
+
334
+ def report_improve_copied_files(copied, root)
335
+ return if copied.empty?
336
+
337
+ puts "ollama_agent: copied #{copied.size} file(s) to #{root}"
338
+ copied.sort.each { |rel| puts " #{rel}" }
339
+ end
340
+
341
+ # Build an Agent for the `ask` command.
342
+ # Same root as `self_review` / interactive: cwd when unset (see README).
343
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize -- session + orchestrator + audit kwargs exceed limits
32
344
  def build_agent
33
- Agent.new(
345
+ orch = orchestrator_mode?
346
+ agent = Agent.new(
34
347
  model: options[:model],
35
- root: options[:root],
348
+ root: resolved_root_for_self_review,
349
+ read_only: options[:read_only],
36
350
  confirm_patches: !options[:yes],
37
351
  http_timeout: options[:timeout],
38
- think: options[:think]
352
+ think: options[:think],
353
+ orchestrator: orch,
354
+ confirm_delegation: orch ? !options[:yes] : true,
355
+ audit: options[:audit],
356
+ max_retries: options[:max_retries],
357
+ session_id: resolved_session_id,
358
+ resume: options[:resume] || false,
359
+ max_tokens: options[:max_tokens],
360
+ context_summarize: options[:context_summarize],
361
+ **skill_agent_options
39
362
  )
363
+ attach_console_streamer(agent) if stream_enabled?
364
+ agent
365
+ end
366
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
367
+
368
+ def resolved_session_id
369
+ return options[:session] if options[:session]
370
+ return nil unless options[:resume]
371
+
372
+ list = Session::Store.list(root: resolved_root_for_self_review)
373
+ list.first&.session_id
374
+ end
375
+
376
+ def orchestrator_mode?
377
+ return true if ENV.fetch("OLLAMA_AGENT_ORCHESTRATOR", "0").to_s == "1"
378
+
379
+ ENV.fetch("OLLAMA_AGENT_MODE", "").to_s.strip.casecmp("orchestrator").zero?
380
+ end
381
+
382
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize -- mirrors build_agent; stream attachment adds one line
383
+ def build_orchestrator_agent
384
+ agent = Agent.new(
385
+ model: options[:model],
386
+ root: resolved_root_for_self_review,
387
+ read_only: options[:read_only],
388
+ confirm_patches: !options[:yes],
389
+ http_timeout: options[:timeout],
390
+ think: options[:think],
391
+ orchestrator: true,
392
+ confirm_delegation: !options[:yes],
393
+ audit: options[:audit],
394
+ max_retries: options[:max_retries],
395
+ max_tokens: options[:max_tokens],
396
+ context_summarize: options[:context_summarize],
397
+ **skill_agent_options
398
+ )
399
+ attach_console_streamer(agent) if stream_enabled?
400
+ agent
401
+ end
402
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
403
+
404
+ def skill_agent_options
405
+ out = {}
406
+ out[:skills_enabled] = false if options[:no_skills]
407
+ paths = PromptSkills.split_paths(options[:skill_paths])
408
+ out[:skill_paths] = paths unless paths.empty?
409
+ out
410
+ end
411
+
412
+ def stream_enabled?
413
+ options[:stream] || ENV.fetch("OLLAMA_AGENT_STREAM", "0") == "1"
414
+ end
415
+
416
+ def attach_console_streamer(agent)
417
+ Streaming::ConsoleStreamer.new.attach(agent.hooks)
418
+ end
419
+
420
+ def run_single_shot_agent!(agent, query)
421
+ agent.run(query)
422
+ rescue Ollama::Error, OllamaAgent::Error => e
423
+ warn Console.error_line("#{e.class}: #{e.message}")
424
+ exit 1
425
+ rescue StandardError => e
426
+ warn Console.error_line("#{e.class}: #{e.message}")
427
+ warn e.full_message(order: :top, highlight: false) if ENV["OLLAMA_AGENT_DEBUG"] == "1"
428
+ exit 1
40
429
  end
41
430
 
42
431
  def start_interactive(agent)
@@ -70,4 +459,5 @@ module OllamaAgent
70
459
  end
71
460
  end
72
461
  end
462
+ # rubocop:enable Metrics/ClassLength
73
463
  end
@@ -3,6 +3,7 @@
3
3
  module OllamaAgent
4
4
  # ANSI styling for TTY output. Respects https://no-color.org/ via NO_COLOR.
5
5
  # Assistant replies use tty-markdown when enabled (headings, lists, bold, code blocks).
6
+ # rubocop:disable Metrics/ModuleLength -- single-responsibility output module; methods are all short
6
7
  module Console
7
8
  module_function
8
9
 
@@ -38,6 +39,116 @@ module OllamaAgent
38
39
  markdown_enabled? && ENV["OLLAMA_AGENT_THINKING_MARKDOWN"] == "1"
39
40
  end
40
41
 
42
+ # +compact+ (default): one "Thinking" label per agent run; later reasoning uses blank lines only (Cursor-like).
43
+ # +framed+: repeat the full banner + rulers on every assistant message (legacy).
44
+ def thinking_framed_style?
45
+ ENV.fetch("OLLAMA_AGENT_THINKING_STYLE", "compact").to_s.strip.downcase == "framed"
46
+ end
47
+
48
+ # @api private Reset at the start of each {Agent#run} so multi-turn tool loops share one thinking header.
49
+ def reset_thinking_session!
50
+ Thread.current[:ollama_agent_thinking_shown] = false
51
+ Thread.current[:ollama_agent_stream_thinking_open] = false
52
+ Thread.current[:ollama_agent_stream_had_thinking] = false
53
+ Thread.current[:ollama_agent_stream_thinking_buffer] = nil
54
+ end
55
+
56
+ def thinking_already_shown_in_session?
57
+ Thread.current[:ollama_agent_thinking_shown] == true
58
+ end
59
+
60
+ def mark_thinking_shown_in_session!
61
+ Thread.current[:ollama_agent_thinking_shown] = true
62
+ end
63
+
64
+ # --- Streaming (ollama-client passes thinking only via patched hooks[:on_thinking]) ---
65
+
66
+ # Print one dim "Thinking" label, then stream fragments in dim until content tokens arrive.
67
+ # Handles both cumulative thinking strings (common from Ollama) and plain deltas; sanitizes UTF-8.
68
+ def write_streaming_thinking_fragment(fragment)
69
+ text = utf8_for_stream(fragment)
70
+ return if text.empty?
71
+
72
+ open_streaming_thinking_section_if_needed
73
+ to_print = streaming_thinking_increment_to_print(text)
74
+ return if to_print.empty?
75
+
76
+ print to_print
77
+ $stdout.flush
78
+ end
79
+
80
+ def write_stream_token(fragment)
81
+ print utf8_for_stream(fragment)
82
+ $stdout.flush
83
+ end
84
+
85
+ # Call before the first streamed content token: closes dim reasoning, optional Assistant heading.
86
+ def finalize_streaming_thinking_before_content!
87
+ return unless Thread.current[:ollama_agent_stream_thinking_open]
88
+
89
+ Thread.current[:ollama_agent_stream_thinking_open] = false
90
+ had = Thread.current[:ollama_agent_stream_had_thinking]
91
+ Thread.current[:ollama_agent_stream_had_thinking] = false
92
+ Thread.current[:ollama_agent_stream_thinking_buffer] = nil
93
+ print "\e[0m" if color_enabled?
94
+ puts
95
+ puts assistant_reply_heading if had
96
+ end
97
+
98
+ # When the model returns only thinking (no content), close ANSI state on stream end.
99
+ def close_streaming_thinking_if_still_open!
100
+ return unless Thread.current[:ollama_agent_stream_thinking_open]
101
+
102
+ Thread.current[:ollama_agent_stream_thinking_open] = false
103
+ Thread.current[:ollama_agent_stream_had_thinking] = false
104
+ Thread.current[:ollama_agent_stream_thinking_buffer] = nil
105
+ print "\e[0m" if color_enabled?
106
+ puts
107
+ end
108
+
109
+ def open_streaming_thinking_section_if_needed
110
+ return if Thread.current[:ollama_agent_stream_thinking_open]
111
+
112
+ Thread.current[:ollama_agent_stream_thinking_open] = true
113
+ Thread.current[:ollama_agent_stream_had_thinking] = true
114
+ label = color_enabled? ? "#{dim("Thinking")}\n" : "Thinking\n"
115
+ print label
116
+ print "\e[2m" if color_enabled?
117
+ end
118
+ private_class_method :open_streaming_thinking_section_if_needed
119
+
120
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- cumulative vs delta stream shapes
121
+ def streaming_thinking_increment_to_print(next_utf8)
122
+ prev = Thread.current[:ollama_agent_stream_thinking_buffer].to_s
123
+ if prev.empty?
124
+ Thread.current[:ollama_agent_stream_thinking_buffer] = next_utf8
125
+ return next_utf8
126
+ end
127
+
128
+ if next_utf8.start_with?(prev) && next_utf8.bytesize >= prev.bytesize
129
+ slice = next_utf8.byteslice(prev.bytesize..)
130
+ Thread.current[:ollama_agent_stream_thinking_buffer] = next_utf8
131
+ return slice
132
+ end
133
+
134
+ return +"" if next_utf8 == prev
135
+
136
+ if next_utf8.bytesize < prev.bytesize
137
+ Thread.current[:ollama_agent_stream_thinking_buffer] = next_utf8
138
+ return next_utf8
139
+ end
140
+
141
+ Thread.current[:ollama_agent_stream_thinking_buffer] = prev + next_utf8
142
+ next_utf8
143
+ end
144
+ private_class_method :streaming_thinking_increment_to_print
145
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
146
+
147
+ def utf8_for_stream(fragment)
148
+ fragment.to_s.encode("UTF-8", invalid: :replace, undef: :replace)
149
+ end
150
+ private_class_method :utf8_for_stream
151
+
41
152
  def style(text, *codes)
42
153
  return text.to_s unless color_enabled?
43
154
 
@@ -85,6 +196,36 @@ module OllamaAgent
85
196
  "#{header}#{body}\n#{line}"
86
197
  end
87
198
 
199
+ def format_thinking_compact_open(text)
200
+ label = color_enabled? ? dim("Thinking") : "Thinking"
201
+ body = thinking_compact_body(text)
202
+ "#{label}\n#{body}"
203
+ end
204
+
205
+ # Later thinking in the same run (tool rounds, new chat chunks): same block, separated by a blank line.
206
+ def format_thinking_compact_merge(text)
207
+ "\n#{thinking_compact_body(text)}"
208
+ end
209
+
210
+ def thinking_compact_body(text)
211
+ if thinking_markdown_enabled?
212
+ parsed = markdown_parse(text, thinking: true)
213
+ return parsed if parsed
214
+
215
+ return dim_indent_body(text)
216
+ end
217
+
218
+ dim_indent_body(text)
219
+ end
220
+
221
+ def dim_indent_body(text)
222
+ s = text.to_s.rstrip
223
+ return "" if s.empty?
224
+
225
+ indent = " "
226
+ dim(s.lines.map { |l| "#{indent}#{l.rstrip}" }.join("\n"))
227
+ end
228
+
88
229
  def assistant_reply_heading
89
230
  bold(green("Assistant"))
90
231
  end
@@ -113,13 +254,33 @@ module OllamaAgent
113
254
 
114
255
  # Prints thinking (if any) then main content; duck-types #thinking and #content.
115
256
  def puts_assistant_message(message)
116
- t = message.thinking
117
- c = message.content
118
- thinking_present = t && !t.to_s.empty?
257
+ thinking_present = assistant_message_thinking_present?(message.thinking)
258
+ if thinking_present
259
+ puts thinking_output_chunk(message.thinking)
260
+ mark_thinking_shown_in_session! unless thinking_framed_style?
261
+ end
262
+ assistant_reply_if_present(message.content, thinking_present)
263
+ end
264
+
265
+ def assistant_message_thinking_present?(text)
266
+ text && !text.to_s.strip.empty?
267
+ end
268
+ private_class_method :assistant_message_thinking_present?
269
+
270
+ def assistant_reply_if_present(content, thinking_present)
271
+ return unless content && !content.to_s.strip.empty?
119
272
 
120
- puts format_thinking(t) if thinking_present
121
- write_assistant_reply(c, thinking_present) if c && !c.to_s.empty?
273
+ write_assistant_reply(content, thinking_present)
122
274
  end
275
+ private_class_method :assistant_reply_if_present
276
+
277
+ def thinking_output_chunk(text)
278
+ return format_thinking(text) if thinking_framed_style?
279
+ return format_thinking_compact_open(text) unless thinking_already_shown_in_session?
280
+
281
+ format_thinking_compact_merge(text)
282
+ end
283
+ private_class_method :thinking_output_chunk
123
284
 
124
285
  def patch_title(text)
125
286
  bold(yellow(text))
@@ -132,5 +293,16 @@ module OllamaAgent
132
293
  def error_line(text)
133
294
  red(text)
134
295
  end
296
+
297
+ def tool_call_line(name, args)
298
+ keys = args.is_a?(Hash) ? args.keys.first(2).join(", ") : ""
299
+ cyan("[tool→] #{name}(#{keys})")
300
+ end
301
+
302
+ def tool_result_line(name, result)
303
+ preview = result.to_s[0, 60].gsub(/\s+/, " ")
304
+ dim("[tool←] #{name}: #{preview}")
305
+ end
135
306
  end
307
+ # rubocop:enable Metrics/ModuleLength
136
308
  end