ruby-mana 0.5.8 → 0.5.10

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5431629c8418ec913228485f8874858561368c88430b246d0a463a4153a4714c
4
- data.tar.gz: cc3a8ec9b69aec797c5fc3ceea8db052a9d75726937cf39d5c00f7e4b1cec85a
3
+ metadata.gz: dec47003f35644ccba81fd005d200be2046c9e32c8a939ce1b4e74d90defc9cc
4
+ data.tar.gz: 2f7986b4125ca844517002630195c16fedd0ea182a753ae3836d3cd951721d20
5
5
  SHA512:
6
- metadata.gz: 20ee6065e53a175daadaee4d716c2355b15e312b8f4cf7de064fffbed2f1d8a4ab3a609896817fd9412ee2079a5a4583a0602c1bdae39f54ae56656ba10e5c9d
7
- data.tar.gz: 4ba20a60f760a827023e9d18e6f5195d9506a44251124e28d7279e531e08381fa9170bc11144e6c54425858a4e6f389113ca4a5253433328edf1e34f3aa709bb
6
+ metadata.gz: caa5b68af0f5658f1cc5ff0c053b4b15cb44e88872d07102454171abd4b5d53eacfe39db1d5b8622e76f87a8f45e44944a5e0dfd44055975dfc2324d3af24560
7
+ data.tar.gz: 202a6b979ecd66f3c8dd4c949d299479ce73ff47e4a6cbc98d06651d103bc1fd5a791bdd0719be9abdfc1bf2aa745fdc5ba73a557119f9c1452d8b37689212ae
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.10] - 2026-03-27
4
+
5
+ ### Added
6
+ - `Mana.chat` — interactive REPL mode with streaming output and colored prompts
7
+ - `think` tool — LLM can plan approach before acting on complex tasks
8
+ - Streaming support for Anthropic backend (`chat_stream` with SSE parsing)
9
+ - Agent behavior guidelines in system prompt (think → read → act → verify)
10
+
11
+ ## [0.5.9] - 2026-03-27
12
+
13
+ ### Added
14
+ - `error` tool — LLM can signal task failure, raised as `Mana::LLMError` to the Ruby caller
15
+ - Text-only LLM responses (after nudge) now raise `LLMError` instead of returning `nil`
16
+
17
+ ## [0.5.8] - 2026-03-27
18
+
19
+ ### Added
20
+ - `local_variables` support — LLM can call `local_variables` via `call_func` to discover variables in scope (binding-routed for correct scoping)
21
+
3
22
  ## [0.5.7] - 2026-03-27
4
23
 
5
24
  ### Security
data/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # ruby-mana 🔮
2
2
 
3
- Embed LLM as native Ruby. Write natural language, it just runs.
3
+ [![Gem Version](https://badge.fury.io/rb/ruby-mana.svg)](https://rubygems.org/gems/ruby-mana) · [Website](https://twokidscarl.github.io/ruby-mana/) · [RubyGems](https://rubygems.org/gems/ruby-mana) · [GitHub](https://github.com/twokidsCarl/ruby-mana)
4
+
5
+ Embed LLM as native Ruby. Write natural language, it just runs. Not an API wrapper — a language construct that weaves LLM into your code.
4
6
 
5
7
  ```ruby
6
8
  require "mana"
@@ -10,12 +12,6 @@ numbers = [1, "2", "three", "cuatro", "五"]
10
12
  puts result # => 3.0
11
13
  ```
12
14
 
13
- ## What is this?
14
-
15
- Mana turns LLM into a Ruby co-processor. Your natural language strings can read and write Ruby variables, call Ruby functions, manipulate objects, and control program flow — all from a single `~"..."`.
16
-
17
- Not an API wrapper. Not prompt formatting. Mana weaves LLM into your Ruby code as a first-class construct.
18
-
19
15
  ## Install
20
16
 
21
17
  ```bash
@@ -91,9 +87,11 @@ puts email.priority # => "high"
91
87
 
92
88
  ### Calling Ruby functions
93
89
 
94
- LLM can call functions in your scope:
90
+ LLM discovers and calls your Ruby functions automatically. Add YARD comments for better understanding:
95
91
 
96
92
  ```ruby
93
+ # Look up stock price by symbol
94
+ # @param symbol [String] ticker symbol
97
95
  def fetch_price(symbol)
98
96
  { "AAPL" => 189.5, "GOOG" => 141.2, "TSLA" => 248.9 }[symbol] || 0
99
97
  end
@@ -108,6 +106,46 @@ portfolio = ["AAPL", "GOOG", "TSLA", "MSFT"]
108
106
  puts total # => 579.6
109
107
  ```
110
108
 
109
+ The LLM sees your functions with descriptions and types:
110
+ ```
111
+ Available Ruby functions:
112
+ fetch_price(symbol) — Look up stock price by symbol
113
+ send_alert(msg)
114
+ ```
115
+
116
+ Both positional and keyword arguments are supported. Functions are discovered from the source file (via Prism AST) and from methods defined on `self`.
117
+
118
+ ### LLM-compiled methods
119
+
120
+ `mana def` lets LLM generate a method implementation on first call. The generated code is cached as a real `.rb` file — subsequent calls are pure Ruby with zero API overhead.
121
+
122
+ ```ruby
123
+ mana def fibonacci(n)
124
+ ~"return an array of the first n Fibonacci numbers"
125
+ end
126
+
127
+ fibonacci(10) # first call → LLM generates code → cached
128
+ fibonacci(20) # second call → loads from cache, no LLM, no waiting
129
+
130
+ # View the generated source
131
+ puts Mana.source(:fibonacci)
132
+
133
+ # Works in classes too
134
+ class Converter
135
+ include Mana::Mixin
136
+
137
+ mana def celsius_to_fahrenheit(c)
138
+ ~"convert Celsius to Fahrenheit"
139
+ end
140
+ end
141
+
142
+ puts Mana.source(:celsius_to_fahrenheit, owner: Converter)
143
+ ```
144
+
145
+ Generated files live in `.mana_cache/` (add to `.gitignore`, or commit them to skip LLM on CI).
146
+
147
+ ## Advanced
148
+
111
149
  ### Mixed control flow
112
150
 
113
151
  Ruby handles the structure, LLM handles the decisions:
@@ -150,39 +188,50 @@ lint = ->(code) { ~"check #{code} for style issues, store in <issues>" }
150
188
 
151
189
  Each nested call gets its own conversation context. The outer LLM only sees the function's return value, keeping its context clean.
152
190
 
153
- ### LLM-compiled methods
191
+ ### Memory
154
192
 
155
- `mana def` lets LLM generate a method implementation on first call. The generated code is cached as a real `.rb` file — subsequent calls are pure Ruby with zero API overhead.
193
+ Mana has two types of memory:
194
+
195
+ - **Short-term memory** — conversation history within the current process. Each `~"..."` call appends to it, so consecutive calls share context. Cleared when the process exits.
196
+ - **Long-term memory** — persistent facts stored on disk (`~/.mana/`). Survives across script executions. The LLM can save facts via the `remember` tool.
156
197
 
157
198
  ```ruby
158
- mana def fibonacci(n)
159
- ~"return an array of the first n Fibonacci numbers"
160
- end
199
+ ~"translate <text1> to Japanese, store in <result1>"
200
+ ~"translate <text2> to the same language, store in <result2>" # remembers "Japanese"
161
201
 
162
- fibonacci(10) # first call LLM generates code → cached → executed
163
- fibonacci(20) # pure Ruby from .mana_cache/
202
+ ~"remember that the user prefers concise output"
203
+ # persists to ~/.mana/ — available in future script runs
204
+ ```
164
205
 
165
- # View the generated source
166
- puts Mana.source(:fibonacci)
167
- # def fibonacci(n)
168
- # return [] if n <= 0
169
- # return [0] if n == 1
170
- # fib = [0, 1]
171
- # (2...n).each { |i| fib << fib[i-1] + fib[i-2] }
172
- # fib
173
- # end
206
+ ```ruby
207
+ Mana.memory.short_term # view conversation history
208
+ Mana.memory.long_term # view persisted facts
209
+ Mana.memory.forget(id: 2) # remove a specific fact
210
+ Mana.memory.clear! # clear everything
211
+ ```
174
212
 
175
- # Works in classes too
176
- class Converter
177
- include Mana::Mixin
213
+ #### Compaction
178
214
 
179
- mana def celsius_to_fahrenheit(c)
180
- ~"convert Celsius to Fahrenheit"
181
- end
215
+ When conversation history grows large, Mana automatically compacts old messages into summaries:
216
+
217
+ ```ruby
218
+ Mana.configure do |c|
219
+ c.memory_pressure = 0.7 # compact when tokens > 70% of context window
220
+ c.memory_keep_recent = 4 # keep last 4 rounds, summarize the rest
221
+ c.compact_model = nil # nil = use main model for summarization
222
+ c.on_compact = ->(summary) { puts "Compacted: #{summary}" }
182
223
  end
183
224
  ```
184
225
 
185
- Generated files live in `.mana_cache/` (add to `.gitignore`, or commit them to skip LLM on CI).
226
+ #### Incognito mode
227
+
228
+ Run without any memory — nothing is loaded or saved:
229
+
230
+ ```ruby
231
+ Mana.incognito do
232
+ ~"translate <text>" # no memory, no persistence
233
+ end
234
+ ```
186
235
 
187
236
  ## Configuration
188
237
 
@@ -196,7 +245,6 @@ export MANA_MODEL=claude-sonnet-4-6 # default model
196
245
  export MANA_VERBOSE=true # show LLM interactions
197
246
  export MANA_TIMEOUT=120 # HTTP timeout in seconds
198
247
  export MANA_BACKEND=anthropic # force backend (anthropic/openai)
199
- export MANA_SECURITY=standard # security level (0-4 or name)
200
248
  ```
201
249
 
202
250
  | Environment Variable | Config | Default | Description |
@@ -209,7 +257,6 @@ export MANA_SECURITY=standard # security level (0-4 or na
209
257
  | `MANA_VERBOSE` | `c.verbose` | `false` | Log LLM calls to stderr |
210
258
  | `MANA_TIMEOUT` | `c.timeout` | `120` | HTTP timeout (seconds) |
211
259
  | `MANA_BACKEND` | `c.backend` | auto-detect | Force `anthropic` or `openai` |
212
- | `MANA_SECURITY` | `c.security` | `:standard` (2) | Security level: `sandbox`, `strict`, `standard`, `permissive`, `danger` |
213
260
 
214
261
  Programmatic config (overrides env vars):
215
262
 
@@ -220,7 +267,6 @@ Mana.configure do |c|
220
267
  c.api_key = "sk-..."
221
268
  c.verbose = true
222
269
  c.timeout = 120
223
- c.security = :strict # security level (0-4 or symbol)
224
270
 
225
271
  # Memory settings
226
272
  c.namespace = "my-project" # nil = auto-detect from git/pwd
@@ -232,160 +278,7 @@ Mana.configure do |c|
232
278
  end
233
279
  ```
234
280
 
235
- ### Multiple LLM backends
236
-
237
- Mana supports Anthropic and OpenAI-compatible APIs (including Ollama, DeepSeek, Groq, etc.):
238
-
239
- ```ruby
240
- # Anthropic (default for claude-* models)
241
- Mana.configure do |c|
242
- c.api_key = ENV["ANTHROPIC_API_KEY"]
243
- c.model = "claude-sonnet-4-6"
244
- end
245
-
246
- # OpenAI
247
- Mana.configure do |c|
248
- c.api_key = ENV["OPENAI_API_KEY"]
249
- c.base_url = "https://api.openai.com"
250
- c.model = "gpt-4o"
251
- end
252
-
253
- # Ollama (local, no API key needed)
254
- Mana.configure do |c|
255
- c.api_key = "unused"
256
- c.base_url = "http://localhost:11434"
257
- c.model = "llama3"
258
- end
259
-
260
- # Explicit backend override
261
- Mana.configure do |c|
262
- c.backend = :openai # force OpenAI format
263
- c.base_url = "https://api.groq.com/openai"
264
- c.model = "llama-3.3-70b-versatile"
265
- end
266
- ```
267
-
268
- Backend is auto-detected from model name: `claude-*` → Anthropic, everything else → OpenAI.
269
-
270
- ### Security policy
271
-
272
- Mana restricts what the LLM can call via security levels (higher = more permissions):
273
-
274
- | Level | Name | What LLM Can Do | What's Blocked |
275
- |-------|------|-----------------|----------------|
276
- | 0 | `:sandbox` | Read/write variables, call user-defined functions only | Everything else |
277
- | 1 | `:strict` | + safe stdlib (`Time.now`, `Date.today`, `Math.*`) | Filesystem, network, system calls, eval |
278
- | **2** | **`:standard`** (default) | + read filesystem (`File.read`, `Dir.glob`) | Write/delete files, network, eval |
279
- | 3 | `:permissive` | + write files, network, require | eval, system/exec/fork |
280
- | 4 | `:danger` | No restrictions | Nothing |
281
-
282
- Default is **level 2 (`:standard`)**. Set via config or env var:
283
-
284
- ```ruby
285
- Mana.configure { |c| c.security = :standard }
286
- # or
287
- Mana.configure { |c| c.security = 2 }
288
- ```
289
-
290
- Fine-grained overrides:
291
-
292
- ```ruby
293
- Mana.configure do |c|
294
- c.security = :strict
295
- c.security.allow_receiver "File", only: %w[read exist?]
296
- c.security.block_method "puts"
297
- c.security.block_receiver "Net::HTTP"
298
- end
299
- ```
300
-
301
- ### Function discovery
302
-
303
- Mana automatically discovers your Ruby functions and makes them available to the LLM. Add comments above your functions for better LLM understanding:
304
-
305
- ```ruby
306
- # Query the database and return results
307
- # @param sql [String] the SQL query
308
- # @param limit [Integer] maximum rows to return
309
- def query_db(sql:, limit: 10)
310
- ActiveRecord::Base.connection.execute(sql).first(limit)
311
- end
312
-
313
- # Search the web for information
314
- # @param query [String] search keywords
315
- def search_web(query:)
316
- WebSearch.search(query)
317
- end
318
-
319
- ~"use query_db to find recent orders, store in <orders>"
320
- ~"search_web for 'ruby mana gem', store in <results>"
321
- ```
322
-
323
- The LLM sees:
324
- ```
325
- Available Ruby functions:
326
- query_db(sql:, limit: ...) — Query the database and return results
327
- search_web(query:) — Search the web for information
328
- ```
329
-
330
- Both positional and keyword arguments are supported. Functions are discovered from the source file (via Prism AST) and from methods defined on `self`.
331
-
332
- ### Memory
333
-
334
- Mana has two types of memory:
335
-
336
- - **Short-term memory** — conversation history within the current process. Each `~"..."` call appends to it, so consecutive calls share context. Cleared when the process exits.
337
- - **Long-term memory** — persistent facts stored on disk. Survives across script executions. The LLM can save facts via the `remember` tool.
338
-
339
- #### Short-term memory (conversation context)
340
-
341
- Consecutive `~"..."` calls automatically share context. No wrapper block needed:
342
-
343
- ```ruby
344
- ~"translate <text1> to Japanese, store in <result1>"
345
- ~"translate <text2> to the same language, store in <result2>" # remembers "Japanese"
346
- ~"which translation was harder? store in <analysis>" # can reference both
347
- ```
348
-
349
- Short-term memory is per-thread and auto-created on the first `~"..."` call.
350
-
351
- ```ruby
352
- Mana.memory.short_term # view conversation history
353
- Mana.memory.clear_short_term! # clear conversation history
354
- ```
355
-
356
- #### Long-term memory (persistent facts)
357
-
358
- The LLM has a `remember` tool that persists facts to disk. These survive across script executions:
359
-
360
- ```ruby
361
- # script_1.rb
362
- ~"remember that the user prefers concise output"
363
-
364
- # script_2.rb (later, separate execution)
365
- ~"translate <text>" # LLM sees "user prefers concise output" in long-term memory
366
- ```
367
-
368
- Identical content is automatically deduplicated.
369
-
370
- ```ruby
371
- Mana.memory.long_term # view all persisted facts
372
- Mana.memory.forget(id: 2) # remove a specific fact
373
- Mana.memory.clear_long_term! # clear all long-term memory
374
- Mana.memory.clear! # clear both short-term and long-term
375
- ```
376
-
377
- #### Incognito mode
378
-
379
- Run without any memory — nothing is loaded or saved:
380
-
381
- ```ruby
382
- Mana.incognito do
383
- ~"translate <text>" # no memory, no persistence
384
- end
385
- ```
386
-
387
-
388
- ### Testing
281
+ ## Testing
389
282
 
390
283
  Use `Mana.mock` to test code that uses `~"..."` without calling any API:
391
284
 
@@ -444,13 +337,39 @@ Unmatched prompts raise `Mana::MockError` with a helpful message suggesting the
444
337
 
445
338
  ## How it works
446
339
 
447
- 1. `~"..."` calls `String#~@`, which captures the caller's `Binding`
448
- 2. Mana parses `<var>` references and reads existing variables as context
449
- 3. Memory loads long-term facts and prior conversation into the system prompt
450
- 4. The prompt + context is sent to the LLM with tools: `read_var`, `write_var`, `read_attr`, `write_attr`, `call_func`, `remember`, `done`
451
- 5. LLM responds with tool calls → Mana executes them against the live Ruby binding → sends results back
452
- 6. Loop until LLM calls `done` or returns without tool calls
453
- 7. After completion, memory compaction runs in background if context is getting large
340
+ ```
341
+ Your Ruby code LLM (Claude/GPT/...)
342
+ ───────────── ────────────────────
343
+ numbers = [1, 2, 3]
344
+ ~"average of <numbers>, ──→ system prompt:
345
+ store in <result>" - rules + tools
346
+ - memory (short/long-term)
347
+ - variables: numbers = [1,2,3]
348
+ - available functions
349
+
350
+ ←── tool_call: read_var("numbers")
351
+ return [1, 2, 3] ──→
352
+
353
+ ←── tool_call: write_var("result", 2.0)
354
+ binding.local_variable_set ──→ ok
355
+
356
+ ←── tool_call: done(result: 2.0)
357
+ result == 2.0 ✓
358
+ ```
359
+
360
+ **Step by step:**
361
+
362
+ 1. **`~"..."` triggers `String#~@`** — captures the caller's `Binding` via `binding_of_caller`, giving Mana access to local variables, methods, and objects in scope.
363
+
364
+ 2. **Build context** — parses `<var>` references from the prompt, reads their current values, discovers available functions via Prism AST (with YARD descriptions if present).
365
+
366
+ 3. **Build system prompt** — assembles rules, memory (short-term conversation + long-term facts + compaction summaries), variable values, and function signatures into a single system prompt.
367
+
368
+ 4. **LLM tool-calling loop** — sends prompt to the LLM with built-in tools (`read_var`, `write_var`, `read_attr`, `write_attr`, `call_func`, `done`, `error`, `eval`, `think`, `knowledge`, `remember`). The LLM responds with tool calls, Mana executes them against the live Ruby binding, and sends results back. This loops until `done` is called or no more tool calls are returned.
369
+
370
+ 5. **Return value** — single `write_var` returns the value directly; multiple writes return a Hash. On Ruby 4.0+, a singleton method fallback ensures variables are accessible in the caller's scope.
371
+
372
+ 6. **Background compaction** — if short-term memory exceeds the token pressure threshold, old messages are summarized by the LLM in a background thread and replaced with a compact summary.
454
373
 
455
374
 
456
375
  ## License
data/exe/mana ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ begin
5
+ require "dotenv/load"
6
+ rescue LoadError
7
+ # dotenv not installed — skip
8
+ end
9
+
10
+ require "mana"
11
+
12
+ Mana::Chat.start(TOPLEVEL_BINDING)
@@ -7,6 +7,8 @@ module Mana
7
7
  # Sends requests directly to the Anthropic Messages API (/v1/messages).
8
8
  # No format conversion needed — Mana's internal format matches Anthropic's.
9
9
  class Anthropic < Base
10
+ # Non-streaming request. Returns content blocks directly since our internal
11
+ # format already matches Anthropic's — no normalization needed (unlike OpenAI).
10
12
  def chat(system:, messages:, tools:, model:, max_tokens: 4096)
11
13
  uri = URI("#{@config.effective_base_url}/v1/messages")
12
14
  parsed = http_post(uri, { model:, max_tokens:, system:, tools:, messages: }, {
@@ -15,6 +17,51 @@ module Mana
15
17
  })
16
18
  parsed[:content] || []
17
19
  end
20
+
21
+ # Streaming variant — yields {type: :text_delta, text: "..."} events.
22
+ # Returns the complete content blocks array (same format as chat).
23
+ def chat_stream(system:, messages:, tools:, model:, max_tokens: 4096, &on_event)
24
+ uri = URI("#{@config.effective_base_url}/v1/messages")
25
+ content_blocks = []
26
+ current_block = nil
27
+
28
+ # Anthropic streams SSE events that incrementally build content blocks.
29
+ # We reassemble them into the same format that chat() returns.
30
+ http_post_stream(uri, {
31
+ model:, max_tokens:, system:, tools:, messages:, stream: true
32
+ }, {
33
+ "x-api-key" => @config.api_key,
34
+ "anthropic-version" => "2023-06-01"
35
+ }) do |event|
36
+ case event[:type]
37
+ when "content_block_start"
38
+ current_block = event[:content_block].dup
39
+ # Tool input arrives as JSON fragments — accumulate as a string, parse on stop
40
+ current_block[:input] = +"" if current_block[:type] == "tool_use"
41
+ when "content_block_delta"
42
+ delta = event[:delta]
43
+ if delta[:type] == "text_delta"
44
+ current_block[:text] = (current_block[:text] || +"") << delta[:text]
45
+ on_event&.call(type: :text_delta, text: delta[:text])
46
+ elsif delta[:type] == "input_json_delta"
47
+ current_block[:input] << delta[:partial_json]
48
+ end
49
+ when "content_block_stop"
50
+ # Parse the accumulated JSON string into a Ruby hash for tool_use blocks
51
+ if current_block && current_block[:type] == "tool_use"
52
+ current_block[:input] = begin
53
+ JSON.parse(current_block[:input], symbolize_names: true)
54
+ rescue JSON::ParserError
55
+ {}
56
+ end
57
+ end
58
+ content_blocks << current_block if current_block
59
+ current_block = nil
60
+ end
61
+ end
62
+
63
+ content_blocks
64
+ end
18
65
  end
19
66
  end
20
67
  end
@@ -60,6 +60,47 @@ module Mana
60
60
  rescue Net::OpenTimeout, Net::ReadTimeout => e
61
61
  raise LLMError, "Request timed out: #{e.message}"
62
62
  end
63
+
64
+ # Streaming HTTP POST — yields parsed SSE events as hashes.
65
+ # Used by chat_stream for real-time output.
66
+ def http_post_stream(uri, body, headers = {})
67
+ http = Net::HTTP.new(uri.host, uri.port)
68
+ http.use_ssl = uri.scheme == "https"
69
+ http.open_timeout = @config.timeout
70
+ http.read_timeout = @config.timeout
71
+
72
+ req = Net::HTTP::Post.new(uri)
73
+ req["Content-Type"] = "application/json"
74
+ headers.each { |k, v| req[k] = v }
75
+ req.body = JSON.generate(body)
76
+
77
+ http.request(req) do |res|
78
+ raise LLMError, "HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
79
+
80
+ buffer = +""
81
+ res.read_body do |chunk|
82
+ buffer << chunk
83
+ while (idx = buffer.index("\n\n"))
84
+ line = buffer.slice!(0, idx + 2).strip
85
+ next if line.empty?
86
+
87
+ # Parse SSE: "event: type\ndata: {...}"
88
+ data_line = line.split("\n").find { |l| l.start_with?("data: ") }
89
+ next unless data_line
90
+
91
+ json_str = data_line.sub("data: ", "")
92
+ next if json_str == "[DONE]"
93
+
94
+ event = JSON.parse(json_str, symbolize_names: true)
95
+ yield event if block_given?
96
+ end
97
+ end
98
+ end
99
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
100
+ raise LLMError, "Request timed out: #{e.message}"
101
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
102
+ raise LLMError, "Connection failed: #{e.message}"
103
+ end
63
104
  end
64
105
  end
65
106
  end
@@ -9,6 +9,8 @@ module Mana
9
9
  # - tool calls: `tool_use`/`tool_result` blocks → `tool_calls` + `role: "tool"`
10
10
  # - response: `choices` → content blocks
11
11
  class OpenAI < Base
12
+ # Translates to OpenAI format, posts, then normalizes back to Anthropic format.
13
+ # Uses max_completion_tokens (not max_tokens) per OpenAI's newer API convention.
12
14
  def chat(system:, messages:, tools:, model:, max_tokens: 4096)
13
15
  uri = URI("#{@config.effective_base_url}/v1/chat/completions")
14
16
  parsed = http_post(uri, {
@@ -41,6 +43,11 @@ module Mana
41
43
  result
42
44
  end
43
45
 
46
+ # Handles three cases for user messages:
47
+ # 1. Plain string — pass through
48
+ # 2. Array of tool_result blocks — convert to OpenAI's "tool" role messages
49
+ # (OpenAI uses separate messages per tool result, not an array in one message)
50
+ # 3. Array of text blocks — merge into a single string
44
51
  def convert_user_message(msg)
45
52
  content = msg[:content]
46
53
 
@@ -64,6 +71,9 @@ module Mana
64
71
  { role: "user", content: content.to_s }
65
72
  end
66
73
 
74
+ # Splits Anthropic-style content blocks into OpenAI's separate fields:
75
+ # text goes into :content, tool_use blocks become :tool_calls with JSON-encoded args.
76
+ # OpenAI requires tool call arguments as JSON strings, not parsed objects.
67
77
  def convert_assistant_message(msg)
68
78
  content = msg[:content]
69
79
 
@@ -99,6 +109,8 @@ module Mana
99
109
  { role: "assistant", content: content.to_s }
100
110
  end
101
111
 
112
+ # Anthropic uses input_schema with optional $schema key; OpenAI uses parameters
113
+ # without it. Strip $schema to avoid OpenAI validation errors.
102
114
  def convert_tools(tools)
103
115
  tools.map do |tool|
104
116
  {
@@ -113,6 +125,8 @@ module Mana
113
125
  end
114
126
 
115
127
  # Convert OpenAI response to Anthropic-style content blocks.
128
+ # This normalization lets the rest of the engine work with a single format
129
+ # regardless of which backend was used.
116
130
  def normalize_response(parsed)
117
131
  choice = parsed.dig(:choices, 0, :message)
118
132
  return [] unless choice
@@ -123,6 +137,7 @@ module Mana
123
137
  blocks << { type: "text", text: choice[:content] }
124
138
  end
125
139
 
140
+ # Parse JSON argument strings back into Ruby hashes for tool_use blocks
126
141
  if choice[:tool_calls]
127
142
  choice[:tool_calls].each do |tc|
128
143
  func = tc[:function]