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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "agent_prompt"
4
+ require_relative "prompt_skills"
4
5
  require_relative "console"
5
6
  require_relative "ollama_connection"
6
7
  require_relative "tools_schema"
@@ -8,52 +9,139 @@ require_relative "sandboxed_tools"
8
9
  require_relative "think_param"
9
10
  require_relative "timeout_param"
10
11
  require_relative "tool_content_parser"
12
+ require_relative "streaming/hooks"
13
+ require_relative "resilience/retry_middleware"
14
+ require_relative "resilience/audit_logger"
15
+ require_relative "context/manager"
16
+ require_relative "session/store"
17
+ require_relative "env_config"
18
+ require_relative "model_env"
19
+ require_relative "agent/agent_config"
20
+ require_relative "agent/client_wiring"
21
+ require_relative "agent/prompt_wiring"
22
+ require_relative "agent/session_wiring"
11
23
 
12
24
  module OllamaAgent
13
25
  # Runs a tool-calling loop against Ollama: read files, search, apply unified diffs.
26
+ # Public entry: {#run}. Other instance methods are internal to the agent loop.
27
+ # rubocop:disable Metrics/ClassLength -- facade coordinates includes and turn loop
14
28
  class Agent
15
29
  include SandboxedTools
30
+ include ClientWiring
31
+ include PromptWiring
32
+ include SessionWiring
16
33
 
17
34
  MAX_TURNS = 64
18
- # ollama-client defaults to 30s; multi-turn tool chats often need longer on local hardware.
19
35
  DEFAULT_HTTP_TIMEOUT = 120
20
36
 
21
- attr_reader :client, :root
22
-
23
- # rubocop:disable Metrics/ParameterLists -- CLI and tests pass explicit dependencies
24
- def initialize(client: nil, model: nil, root: nil, confirm_patches: true, http_timeout: nil, think: nil)
25
- @model = model || default_model
26
- @root = File.expand_path(root || ENV.fetch("OLLAMA_AGENT_ROOT", Dir.pwd))
27
- @confirm_patches = confirm_patches
28
- @http_timeout_override = http_timeout
29
- @think = think
37
+ attr_reader :client, :root, :hooks
38
+
39
+ # @param config [AgentConfig, nil] when set, keyword options are ignored (use {Runner} or build {AgentConfig}).
40
+ # rubocop:disable Metrics/ParameterLists
41
+ # rubocop:disable Metrics/MethodLength
42
+ def initialize(client: nil, config: nil, model: nil, root: nil, confirm_patches: true, http_timeout: nil,
43
+ think: nil,
44
+ read_only: false, patch_policy: nil,
45
+ skill_paths: nil, skills_enabled: nil, skills_include: nil, skills_exclude: nil,
46
+ external_skills_enabled: nil,
47
+ orchestrator: false, confirm_delegation: nil,
48
+ max_retries: nil, audit: nil,
49
+ session_id: nil, resume: false,
50
+ max_tokens: nil, context_summarize: nil,
51
+ stdin: $stdin, stdout: $stdout)
52
+ cfg = config || AgentConfig.new(
53
+ model: model, root: root, confirm_patches: confirm_patches, http_timeout: http_timeout, think: think,
54
+ read_only: read_only, patch_policy: patch_policy,
55
+ skill_paths: skill_paths, skills_enabled: skills_enabled, skills_include: skills_include,
56
+ skills_exclude: skills_exclude, external_skills_enabled: external_skills_enabled,
57
+ orchestrator: orchestrator, confirm_delegation: confirm_delegation,
58
+ max_retries: max_retries, audit: audit, session_id: session_id, resume: resume,
59
+ max_tokens: max_tokens, context_summarize: context_summarize, stdin: stdin, stdout: stdout
60
+ )
61
+ apply_agent_config(cfg)
62
+ @user_prompt = UserPrompt.new(stdin: cfg.stdin, stdout: cfg.stdout)
63
+ @context_manager = Context::Manager.new(max_tokens: @max_tokens, context_summarize: @context_summarize)
64
+ @hooks = Streaming::Hooks.new
65
+ attach_audit_logger if resolved_audit_enabled
30
66
  @client = client || build_default_client
31
67
  end
68
+ # rubocop:enable Metrics/MethodLength
32
69
  # rubocop:enable Metrics/ParameterLists
33
70
 
34
71
  def run(query)
35
- messages = [
36
- { role: "system", content: system_prompt },
37
- { role: "user", content: query }
38
- ]
39
-
72
+ Console.reset_thinking_session!
73
+ messages = build_messages_for_run(query)
40
74
  execute_agent_turns(messages)
41
75
  end
42
76
 
43
77
  private
44
78
 
79
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize -- maps AgentConfig to ivars + resolved max turns
80
+ def apply_agent_config(cfg)
81
+ @model = cfg.model || default_model
82
+ @root = File.expand_path(cfg.root || ENV.fetch("OLLAMA_AGENT_ROOT", Dir.pwd))
83
+ @confirm_patches = cfg.confirm_patches
84
+ @read_only = cfg.read_only
85
+ @patch_policy = cfg.patch_policy
86
+ @http_timeout_override = cfg.http_timeout
87
+ @think = cfg.think
88
+ @skill_paths = cfg.skill_paths
89
+ @skills_enabled = cfg.skills_enabled
90
+ @skills_include = cfg.skills_include
91
+ @skills_exclude = cfg.skills_exclude
92
+ @external_skills_enabled = cfg.external_skills_enabled
93
+ @orchestrator = cfg.orchestrator
94
+ @confirm_delegation = cfg.resolved_confirm_delegation
95
+ @max_retries = cfg.max_retries
96
+ @audit = cfg.audit
97
+ @session_id = cfg.session_id
98
+ @resume = cfg.resume
99
+ @max_tokens = cfg.max_tokens
100
+ @context_summarize = cfg.context_summarize
101
+ strict = EnvConfig.strict_env?
102
+ @max_turns = EnvConfig.fetch_int("OLLAMA_AGENT_MAX_TURNS", MAX_TURNS, strict: strict)
103
+ end
104
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
105
+
106
+ # rubocop:disable Metrics/MethodLength -- turn loop with early break
45
107
  def execute_agent_turns(messages)
46
- max_turns.times do
47
- message = chat_assistant_message(messages)
108
+ @current_turn = 0
109
+ @max_turns.times do
110
+ @current_turn += 1
111
+ trimmed = trimmed_messages_for_chat(messages)
112
+ message = chat_assistant_message(trimmed)
48
113
  tool_calls = tool_calls_from(message)
49
-
50
- messages << message.to_h
51
- return if tool_calls.empty?
114
+ persist_assistant_turn(messages, message)
115
+ break if tool_calls.empty?
52
116
 
53
117
  append_tool_results(messages, tool_calls)
54
118
  end
119
+ emit_turn_complete(messages)
120
+ warn_max_turns_if_needed
121
+ end
122
+ # rubocop:enable Metrics/MethodLength
55
123
 
56
- warn "ollama_agent: maximum tool rounds (#{max_turns}) reached" if ENV["OLLAMA_AGENT_DEBUG"] == "1"
124
+ def trimmed_messages_for_chat(messages)
125
+ @context_manager.trim(messages)
126
+ end
127
+
128
+ def persist_assistant_turn(messages, message)
129
+ messages << message.to_h
130
+ save_message_to_session(message.to_h)
131
+ end
132
+
133
+ def emit_turn_complete(messages)
134
+ @hooks.emit(:on_complete, { messages: messages, turns: @current_turn })
135
+ end
136
+
137
+ def warn_max_turns_if_needed
138
+ return unless ENV["OLLAMA_AGENT_DEBUG"] == "1" && @current_turn >= @max_turns
139
+
140
+ warn "ollama_agent: maximum tool rounds (#{@max_turns}) reached"
141
+ end
142
+
143
+ def current_turn
144
+ @current_turn || 0
57
145
  end
58
146
 
59
147
  def tool_calls_from(message)
@@ -63,15 +151,16 @@ module OllamaAgent
63
151
  ToolContentParser.synthetic_calls(message.content)
64
152
  end
65
153
 
66
- def max_turns
67
- Integer(ENV.fetch("OLLAMA_AGENT_MAX_TURNS", MAX_TURNS.to_s))
68
- rescue ArgumentError, TypeError
69
- MAX_TURNS
154
+ def chat_assistant_message(messages)
155
+ if @hooks.subscribed?(:on_token)
156
+ stream_assistant_message(messages)
157
+ else
158
+ block_assistant_message(messages)
159
+ end
70
160
  end
71
161
 
72
- def chat_assistant_message(messages)
162
+ def block_assistant_message(messages)
73
163
  response = @client.chat(**chat_request_args(messages))
74
-
75
164
  message = response.message
76
165
  raise Error, "Empty assistant message" if message.nil?
77
166
 
@@ -79,16 +168,41 @@ module OllamaAgent
79
168
  message
80
169
  end
81
170
 
171
+ def stream_assistant_message(messages)
172
+ response = @client.chat(**chat_request_args(messages), hooks: ollama_stream_hooks)
173
+ message = response.message
174
+ raise Error, "Empty assistant message" if message.nil?
175
+
176
+ message
177
+ end
178
+
82
179
  def chat_request_args(messages)
83
- args = {
180
+ base_chat_request_args(messages).tap do |args|
181
+ th = resolve_think
182
+ args[:think] = th unless th.nil?
183
+ end
184
+ end
185
+
186
+ def base_chat_request_args(messages)
187
+ {
84
188
  messages: messages,
85
- tools: TOOLS,
189
+ tools: OllamaAgent.tools_for(read_only: @read_only, orchestrator: @orchestrator),
86
190
  model: @model,
87
191
  options: { temperature: 0.2 }
88
192
  }
89
- th = resolve_think
90
- args[:think] = th unless th.nil?
91
- args
193
+ end
194
+
195
+ def ollama_stream_hooks
196
+ {
197
+ on_thinking: ->(fragment) { @hooks.emit(:on_thinking, { token: fragment.to_s, turn: current_turn }) },
198
+ on_token: lambda do |*args|
199
+ token = args[0]
200
+ logprobs = args[1]
201
+ payload = { token: token, turn: current_turn }
202
+ payload[:logprobs] = logprobs unless logprobs.nil?
203
+ @hooks.emit(:on_token, payload)
204
+ end
205
+ }
92
206
  end
93
207
 
94
208
  def announce_assistant_content(message)
@@ -100,47 +214,8 @@ module OllamaAgent
100
214
  end
101
215
 
102
216
  def default_model
103
- ENV["OLLAMA_AGENT_MODEL"] || Ollama::Config.new.model
104
- end
105
-
106
- def build_default_client
107
- config = Ollama::Config.new
108
- @http_timeout_seconds = resolved_http_timeout_seconds
109
- config.timeout = @http_timeout_seconds
110
- OllamaConnection.apply_env_to_config(config)
111
- Ollama::Client.new(config: config)
112
- end
113
-
114
- def resolved_http_timeout_seconds
115
- parsed = TimeoutParam.parse_positive(@http_timeout_override)
116
- return parsed if parsed
117
-
118
- parsed = TimeoutParam.parse_positive(ENV.fetch("OLLAMA_AGENT_TIMEOUT", nil))
119
- return parsed if parsed
120
-
121
- DEFAULT_HTTP_TIMEOUT
122
- end
123
-
124
- def system_prompt
125
- AgentPrompt.text
126
- end
127
-
128
- def append_tool_results(messages, tool_calls)
129
- tool_calls.each do |tool_call|
130
- result = execute_tool(tool_call.name, tool_call.arguments || {})
131
- messages << tool_message(tool_call, result)
132
- end
133
- end
134
-
135
- def tool_message(tool_call, result)
136
- msg = {
137
- role: "tool",
138
- name: tool_call.name,
139
- content: result.to_s
140
- }
141
- id = tool_call.id
142
- msg[:tool_call_id] = id if id && !id.to_s.empty?
143
- msg
217
+ ModelEnv.default_chat_model
144
218
  end
145
219
  end
220
+ # rubocop:enable Metrics/ClassLength
146
221
  end
@@ -5,7 +5,7 @@ module OllamaAgent
5
5
  module AgentPrompt
6
6
  def self.text
7
7
  <<~PROMPT
8
- You are a coding assistant with tools: list_files, read_file, search_code, edit_file.
8
+ You are a coding assistant with tools: list_files, read_file, search_code, edit_file, write_file.
9
9
  Work only under the project root. Briefly state your plan, then use tools.
10
10
 
11
11
  Large Ruby codebases: use search_code with mode "method", "class", "module", or "constant" to locate definitions
@@ -15,6 +15,9 @@ module OllamaAgent
15
15
  Do not paste JSON tool calls or {"name": ...} blocks in your reply text. Tools run only when the host
16
16
  receives native tool calls from the model API—not from prose. Never put commas after --- or +++ file lines.
17
17
 
18
+ Use write_file to create a new file or fully replace an existing file with complete content.
19
+ Prefer edit_file for surgical changes to existing files; reserve write_file for new files or full rewrites.
20
+
18
21
  For README or documentation updates that should reflect the codebase:
19
22
  1) list_files on "." or "lib" (and read ollama_agent.gemspec if present) to see structure.
20
23
  2) read_file every file you will change before editing (e.g. README.md, lib/ollama_agent.rb).
@@ -40,5 +43,32 @@ module OllamaAgent
40
43
  them as the assistant message.
41
44
  PROMPT
42
45
  end
46
+
47
+ def self.self_review_text
48
+ <<~PROMPT
49
+ You are reviewing the ollama_agent Ruby gem. Tools available: list_files, read_file, search_code only.
50
+ Do not call edit_file and do not output unified diffs—this run is analysis-only.
51
+
52
+ The user message may begin with a "## Static analysis (ruby_mastery)" section from automated tooling; weigh it
53
+ against what you verify in the tree.
54
+
55
+ Work only under the project root. Briefly state your plan, then use tools.
56
+
57
+ Large Ruby trees: use search_code with mode "method", "class", "module", or "constant" to locate definitions
58
+ via the Prism index, then read_file with start_line/end_line for only the lines you need.
59
+
60
+ Final reply: strengths, risks, and concrete suggestions with file paths (and line numbers when clear).
61
+ Do not paste JSON tool calls in prose; tools run only via native tool calls from the API.
62
+ PROMPT
63
+ end
64
+
65
+ def self.orchestrator_addon
66
+ <<~PROMPT
67
+ Orchestrator mode: you may call list_external_agents to see which external CLI tools are installed,
68
+ then delegate_to_agent with a valid agent_id from that list. Gather context with read_file and
69
+ search_code first; keep task and context_summary short. Do not invent agent_id values.
70
+ External runs use non-interactive argv only; cwd is the project root.
71
+ PROMPT
72
+ end
43
73
  end
44
74
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ # ollama-client 1.1.0 +process_chat_stream_chunk+ returns the *previous* +last_data+ for every
5
+ # non-+done+ line, so +message.tool_calls+ seen on an intermediate NDJSON row are dropped when the
6
+ # final +done+ row omits them. Carry forward merged state and copy +tool_calls+ onto the +done+ row.
7
+ module ChatStreamCarry
8
+ module_function
9
+
10
+ def next_last_data(prev, obj)
11
+ return json_dup(obj) if prev.nil? && !truthy_done?(obj)
12
+ return prev if truthy_done?(obj)
13
+
14
+ merge_carry(prev, obj)
15
+ end
16
+
17
+ def stitch_done_message_tool_calls!(done_obj, prev_carry)
18
+ prev_tc = tool_calls_from_carry(prev_carry)
19
+ return if prev_tc.nil? || !truthy_done?(done_obj)
20
+
21
+ apply_tool_calls_to_done!(done_obj, prev_tc)
22
+ end
23
+
24
+ def merge_carry(prev, obj)
25
+ merged = json_dup(prev)
26
+ chunk_msg = obj["message"]
27
+ return merged if chunk_msg.nil? || !chunk_msg.is_a?(Hash)
28
+
29
+ merge_message_fields!(merged, chunk_msg)
30
+ merged
31
+ end
32
+
33
+ def truthy_done?(obj)
34
+ obj.is_a?(Hash) && (obj["done"] == true || obj[:done] == true)
35
+ end
36
+
37
+ def json_dup(payload)
38
+ return payload if payload.nil?
39
+
40
+ JSON.parse(JSON.generate(payload))
41
+ rescue JSON::GeneratorError, JSON::ParserError, TypeError
42
+ dup_via_marshal(payload)
43
+ end
44
+
45
+ def tool_calls_from_carry(prev_carry)
46
+ return unless prev_carry.is_a?(Hash)
47
+
48
+ prev_msg = prev_carry["message"]
49
+ return unless prev_msg.is_a?(Hash)
50
+
51
+ tc = prev_msg["tool_calls"]
52
+ return tc if tc.is_a?(Array) && !tc.empty?
53
+
54
+ nil
55
+ end
56
+ private_class_method :tool_calls_from_carry
57
+
58
+ def apply_tool_calls_to_done!(done_obj, prev_tc)
59
+ done_msg = done_obj["message"]
60
+ unless done_msg.is_a?(Hash)
61
+ done_obj["message"] = { "role" => "assistant", "tool_calls" => prev_tc }
62
+ return
63
+ end
64
+
65
+ done_tc = done_msg["tool_calls"]
66
+ return if done_tc.is_a?(Array) && !done_tc.empty?
67
+
68
+ done_msg["tool_calls"] = prev_tc
69
+ end
70
+ private_class_method :apply_tool_calls_to_done!
71
+
72
+ def merge_message_fields!(merged, chunk_msg)
73
+ mm = (merged["message"] ||= {})
74
+ tc = chunk_msg["tool_calls"]
75
+ mm["tool_calls"] = tc if tc.is_a?(Array) && !tc.empty?
76
+ role = chunk_msg["role"]
77
+ mm["role"] = role if role && !role.to_s.strip.empty?
78
+ end
79
+ private_class_method :merge_message_fields!
80
+
81
+ def dup_via_marshal(payload)
82
+ Marshal.load(Marshal.dump(payload))
83
+ rescue ArgumentError, TypeError
84
+ payload.dup
85
+ end
86
+ private_class_method :dup_via_marshal
87
+ end
88
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ # Coerces streamed +message.thinking+ payloads to a String before ollama-client appends
5
+ # with +full_thinking << thinking+ (which raises TypeError on Hash/Array for some models/APIs).
6
+ module ChatStreamThinkingFormat
7
+ module_function
8
+
9
+ def normalize_message_thinking!(message_hash)
10
+ return unless message_hash.is_a?(Hash)
11
+
12
+ raw = message_hash["thinking"]
13
+ return if raw.nil? || raw.is_a?(String)
14
+
15
+ message_hash["thinking"] = coerce_thinking_to_string(raw)
16
+ end
17
+
18
+ def coerce_thinking_to_string(raw)
19
+ case raw
20
+ when String then raw
21
+ when Array then raw.map { |elem| coerce_thinking_to_string(elem) }.join
22
+ else
23
+ JSON.generate(raw)
24
+ end
25
+ rescue JSON::GeneratorError, TypeError
26
+ raw.to_s
27
+ end
28
+ end
29
+ end