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 +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +114 -195
- data/exe/mana +12 -0
- data/lib/mana/backends/anthropic.rb +47 -0
- data/lib/mana/backends/base.rb +41 -0
- data/lib/mana/backends/openai.rb +15 -0
- data/lib/mana/binding_helpers.rb +106 -0
- data/lib/mana/chat.rb +301 -0
- data/lib/mana/config.rb +0 -19
- data/lib/mana/engine.rb +102 -359
- data/lib/mana/knowledge.rb +203 -0
- data/lib/mana/logger.rb +10 -0
- data/lib/mana/prompt_builder.rb +157 -0
- data/lib/mana/tool_handler.rb +180 -0
- data/lib/mana/version.rb +1 -1
- data/lib/mana.rb +10 -1
- metadata +38 -4
- data/lib/mana/security_policy.rb +0 -195
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dec47003f35644ccba81fd005d200be2046c9e32c8a939ce1b4e74d90defc9cc
|
|
4
|
+
data.tar.gz: 2f7986b4125ca844517002630195c16fedd0ea182a753ae3836d3cd951721d20
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
3
|
+
[](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
|
|
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
|
-
###
|
|
191
|
+
### Memory
|
|
154
192
|
|
|
155
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
163
|
-
|
|
202
|
+
~"remember that the user prefers concise output"
|
|
203
|
+
# persists to ~/.mana/ — available in future script runs
|
|
204
|
+
```
|
|
164
205
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
#
|
|
168
|
-
#
|
|
169
|
-
#
|
|
170
|
-
|
|
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
|
-
|
|
176
|
-
class Converter
|
|
177
|
-
include Mana::Mixin
|
|
213
|
+
#### Compaction
|
|
178
214
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
@@ -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
|
data/lib/mana/backends/base.rb
CHANGED
|
@@ -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
|
data/lib/mana/backends/openai.rb
CHANGED
|
@@ -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]
|