ruby-mana 0.3.0 → 0.4.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.
- checksums.yaml +4 -4
- data/README.md +96 -1
- data/lib/mana/backends/anthropic.rb +38 -0
- data/lib/mana/backends/base.rb +18 -0
- data/lib/mana/backends/openai.rb +167 -0
- data/lib/mana/backends/registry.rb +23 -0
- data/lib/mana/config.rb +2 -0
- data/lib/mana/engine.rb +74 -27
- data/lib/mana/mock.rb +53 -0
- data/lib/mana/string_ext.rb +1 -8
- data/lib/mana/test.rb +18 -0
- data/lib/mana/version.rb +1 -1
- data/lib/mana.rb +8 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c5998b08056a2657d73e7ab5382fd9ad3a96a53c9cbaa3268a98f6798c7290da
|
|
4
|
+
data.tar.gz: 82326fc68f9c15694f70dcbea19b43b321674f766497864c824ee574023a7f1c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a612ba5f45d2bd5abf09fd68e7225945e5d3defa70f0bd5298e55215010bfe71dd1a7f53adf415e126fe3a97c654fa209812e8db8b3720d8258355054767eb7d
|
|
7
|
+
data.tar.gz: 3649cc64a401d6d8e92b16ae8cc05999b9a6e5813a16d25a62c7661573827e3086b30e49f9d62e0c57c48a2ea6d081307a053d64d53e5de4d565db607dc76574
|
data/README.md
CHANGED
|
@@ -28,10 +28,12 @@ Or in your Gemfile:
|
|
|
28
28
|
gem "ruby-mana"
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
Requires Ruby 3.3+ and an Anthropic
|
|
31
|
+
Requires Ruby 3.3+ and an API key (Anthropic, OpenAI, or compatible):
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
34
|
export ANTHROPIC_API_KEY=your_key_here
|
|
35
|
+
# or
|
|
36
|
+
export OPENAI_API_KEY=your_key_here
|
|
35
37
|
```
|
|
36
38
|
|
|
37
39
|
## Usage
|
|
@@ -146,6 +148,41 @@ Mana.configure do |c|
|
|
|
146
148
|
end
|
|
147
149
|
```
|
|
148
150
|
|
|
151
|
+
### Multiple LLM backends
|
|
152
|
+
|
|
153
|
+
Mana supports Anthropic and OpenAI-compatible APIs (including Ollama, DeepSeek, Groq, etc.):
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
# Anthropic (default for claude-* models)
|
|
157
|
+
Mana.configure do |c|
|
|
158
|
+
c.api_key = ENV["ANTHROPIC_API_KEY"]
|
|
159
|
+
c.model = "claude-sonnet-4-20250514"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# OpenAI
|
|
163
|
+
Mana.configure do |c|
|
|
164
|
+
c.api_key = ENV["OPENAI_API_KEY"]
|
|
165
|
+
c.base_url = "https://api.openai.com"
|
|
166
|
+
c.model = "gpt-4o"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Ollama (local, no API key needed)
|
|
170
|
+
Mana.configure do |c|
|
|
171
|
+
c.api_key = "unused"
|
|
172
|
+
c.base_url = "http://localhost:11434"
|
|
173
|
+
c.model = "llama3"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Explicit backend override
|
|
177
|
+
Mana.configure do |c|
|
|
178
|
+
c.backend = :openai # force OpenAI format
|
|
179
|
+
c.base_url = "https://api.groq.com/openai"
|
|
180
|
+
c.model = "llama-3.3-70b-versatile"
|
|
181
|
+
end
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Backend is auto-detected from model name: `claude-*` → Anthropic, everything else → OpenAI.
|
|
185
|
+
|
|
149
186
|
### Custom effect handlers
|
|
150
187
|
|
|
151
188
|
Define your own tools that the LLM can call. Each effect becomes an LLM tool automatically — the block's keyword parameters define the tool's input schema.
|
|
@@ -217,6 +254,64 @@ Mana.incognito do
|
|
|
217
254
|
end
|
|
218
255
|
```
|
|
219
256
|
|
|
257
|
+
### Testing
|
|
258
|
+
|
|
259
|
+
Use `Mana.mock` to test code that uses `~"..."` without calling any API:
|
|
260
|
+
|
|
261
|
+
```ruby
|
|
262
|
+
require "mana/test"
|
|
263
|
+
|
|
264
|
+
RSpec.describe MyApp do
|
|
265
|
+
include Mana::TestHelpers
|
|
266
|
+
|
|
267
|
+
it "analyzes code" do
|
|
268
|
+
mock_prompt "analyze", bugs: ["XSS"], score: 8.5
|
|
269
|
+
|
|
270
|
+
result = MyApp.analyze("user_input")
|
|
271
|
+
expect(result[:bugs]).to include("XSS")
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
it "translates with dynamic response" do
|
|
275
|
+
mock_prompt(/translate.*to\s+\w+/) do |prompt|
|
|
276
|
+
{ output: prompt.include?("Chinese") ? "你好" : "hello" }
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
expect(MyApp.translate("hi", "Chinese")).to eq("你好")
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Block mode for inline tests:
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
Mana.mock do
|
|
288
|
+
prompt "summarize", summary: "A brief overview"
|
|
289
|
+
|
|
290
|
+
text = "Long article..."
|
|
291
|
+
~"summarize <text> and store in <summary>"
|
|
292
|
+
puts summary # => "A brief overview"
|
|
293
|
+
end
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Unmatched prompts raise `Mana::MockError` with a helpful message suggesting the stub to add.
|
|
297
|
+
|
|
298
|
+
### Nested prompts
|
|
299
|
+
|
|
300
|
+
Functions called by LLM can themselves contain `~"..."` prompts:
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
lint = ->(code) { ~"check #{code} for style issues, store in <issues>" }
|
|
304
|
+
# Equivalent to:
|
|
305
|
+
# def lint(code)
|
|
306
|
+
# ~"check #{code} for style issues, store in <issues>"
|
|
307
|
+
# issues
|
|
308
|
+
# end
|
|
309
|
+
|
|
310
|
+
~"review <codebase>, call lint for each file, store report in <report>"
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Each nested call gets its own conversation context. The outer LLM only sees the function's return value, keeping its context clean.
|
|
314
|
+
|
|
220
315
|
### LLM-compiled methods
|
|
221
316
|
|
|
222
317
|
`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.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Mana
|
|
8
|
+
module Backends
|
|
9
|
+
class Anthropic < Base
|
|
10
|
+
def chat(system:, messages:, tools:, model:, max_tokens: 4096)
|
|
11
|
+
uri = URI("#{@config.base_url}/v1/messages")
|
|
12
|
+
body = {
|
|
13
|
+
model: model,
|
|
14
|
+
max_tokens: max_tokens,
|
|
15
|
+
system: system,
|
|
16
|
+
tools: tools,
|
|
17
|
+
messages: messages
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
21
|
+
http.use_ssl = uri.scheme == "https"
|
|
22
|
+
http.read_timeout = 120
|
|
23
|
+
|
|
24
|
+
req = Net::HTTP::Post.new(uri)
|
|
25
|
+
req["Content-Type"] = "application/json"
|
|
26
|
+
req["x-api-key"] = @config.api_key
|
|
27
|
+
req["anthropic-version"] = "2023-06-01"
|
|
28
|
+
req.body = JSON.generate(body)
|
|
29
|
+
|
|
30
|
+
res = http.request(req)
|
|
31
|
+
raise LLMError, "HTTP #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)
|
|
32
|
+
|
|
33
|
+
parsed = JSON.parse(res.body, symbolize_names: true)
|
|
34
|
+
parsed[:content] || []
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mana
|
|
4
|
+
module Backends
|
|
5
|
+
class Base
|
|
6
|
+
def initialize(config)
|
|
7
|
+
@config = config
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Send a chat request, return array of content blocks in Anthropic format
|
|
11
|
+
# (normalized). Each backend converts its native response to this format.
|
|
12
|
+
# Returns: [{ type: "text", text: "..." }, { type: "tool_use", id: "...", name: "...", input: {...} }]
|
|
13
|
+
def chat(system:, messages:, tools:, model:, max_tokens: 4096)
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Mana
|
|
8
|
+
module Backends
|
|
9
|
+
class OpenAI < Base
|
|
10
|
+
def chat(system:, messages:, tools:, model:, max_tokens: 4096)
|
|
11
|
+
uri = URI("#{@config.base_url}/v1/chat/completions")
|
|
12
|
+
body = {
|
|
13
|
+
model: model,
|
|
14
|
+
max_completion_tokens: max_tokens,
|
|
15
|
+
messages: convert_messages(system, messages),
|
|
16
|
+
tools: convert_tools(tools)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
20
|
+
http.use_ssl = uri.scheme == "https"
|
|
21
|
+
http.read_timeout = 120
|
|
22
|
+
|
|
23
|
+
req = Net::HTTP::Post.new(uri)
|
|
24
|
+
req["Content-Type"] = "application/json"
|
|
25
|
+
req["Authorization"] = "Bearer #{@config.api_key}"
|
|
26
|
+
req.body = JSON.generate(body)
|
|
27
|
+
|
|
28
|
+
res = http.request(req)
|
|
29
|
+
raise LLMError, "HTTP #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)
|
|
30
|
+
|
|
31
|
+
parsed = JSON.parse(res.body, symbolize_names: true)
|
|
32
|
+
normalize_response(parsed)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Convert Anthropic-style messages to OpenAI format
|
|
38
|
+
def convert_messages(system, messages)
|
|
39
|
+
result = [{ role: "system", content: system }]
|
|
40
|
+
|
|
41
|
+
messages.each do |msg|
|
|
42
|
+
case msg[:role]
|
|
43
|
+
when "user"
|
|
44
|
+
converted = convert_user_message(msg)
|
|
45
|
+
if converted.is_a?(Array)
|
|
46
|
+
result.concat(converted)
|
|
47
|
+
else
|
|
48
|
+
result << converted
|
|
49
|
+
end
|
|
50
|
+
when "assistant"
|
|
51
|
+
result << convert_assistant_message(msg)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
result
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def convert_user_message(msg)
|
|
59
|
+
content = msg[:content]
|
|
60
|
+
|
|
61
|
+
# Plain text user message
|
|
62
|
+
return { role: "user", content: content } if content.is_a?(String)
|
|
63
|
+
|
|
64
|
+
# Array of content blocks — may contain tool_result blocks
|
|
65
|
+
if content.is_a?(Array) && content.all? { |b| b[:type] == "tool_result" || b["type"] == "tool_result" }
|
|
66
|
+
# Convert each tool_result to an OpenAI tool message
|
|
67
|
+
return content.map do |block|
|
|
68
|
+
{
|
|
69
|
+
role: "tool",
|
|
70
|
+
tool_call_id: block[:tool_use_id] || block["tool_use_id"],
|
|
71
|
+
content: (block[:content] || block["content"]).to_s
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Other array content (e.g. text blocks) — join as string
|
|
77
|
+
if content.is_a?(Array)
|
|
78
|
+
texts = content.map { |b| b[:text] || b["text"] }.compact
|
|
79
|
+
return { role: "user", content: texts.join("\n") }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
{ role: "user", content: content.to_s }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def convert_assistant_message(msg)
|
|
86
|
+
content = msg[:content]
|
|
87
|
+
|
|
88
|
+
# Simple text response
|
|
89
|
+
if content.is_a?(String)
|
|
90
|
+
return { role: "assistant", content: content }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Array of content blocks — may contain tool_use
|
|
94
|
+
if content.is_a?(Array)
|
|
95
|
+
text_parts = []
|
|
96
|
+
tool_calls = []
|
|
97
|
+
|
|
98
|
+
content.each do |block|
|
|
99
|
+
type = block[:type] || block["type"]
|
|
100
|
+
case type
|
|
101
|
+
when "text"
|
|
102
|
+
text_parts << (block[:text] || block["text"])
|
|
103
|
+
when "tool_use"
|
|
104
|
+
tool_calls << {
|
|
105
|
+
id: block[:id] || block["id"],
|
|
106
|
+
type: "function",
|
|
107
|
+
function: {
|
|
108
|
+
name: block[:name] || block["name"],
|
|
109
|
+
arguments: JSON.generate(block[:input] || block["input"] || {})
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
msg_hash = { role: "assistant" }
|
|
116
|
+
msg_hash[:content] = text_parts.join("\n") unless text_parts.empty?
|
|
117
|
+
msg_hash[:tool_calls] = tool_calls unless tool_calls.empty?
|
|
118
|
+
return msg_hash
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
{ role: "assistant", content: content.to_s }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Convert Anthropic tool definitions to OpenAI function calling format
|
|
125
|
+
def convert_tools(tools)
|
|
126
|
+
tools.map do |tool|
|
|
127
|
+
{
|
|
128
|
+
type: "function",
|
|
129
|
+
function: {
|
|
130
|
+
name: tool[:name],
|
|
131
|
+
description: tool[:description] || "",
|
|
132
|
+
parameters: tool[:input_schema] || {}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Convert OpenAI response back to Anthropic-style content blocks
|
|
139
|
+
def normalize_response(parsed)
|
|
140
|
+
choice = parsed.dig(:choices, 0, :message)
|
|
141
|
+
return [] unless choice
|
|
142
|
+
|
|
143
|
+
blocks = []
|
|
144
|
+
|
|
145
|
+
# Text content
|
|
146
|
+
if choice[:content] && !choice[:content].empty?
|
|
147
|
+
blocks << { type: "text", text: choice[:content] }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Tool calls
|
|
151
|
+
if choice[:tool_calls]
|
|
152
|
+
choice[:tool_calls].each do |tc|
|
|
153
|
+
func = tc[:function]
|
|
154
|
+
blocks << {
|
|
155
|
+
type: "tool_use",
|
|
156
|
+
id: tc[:id],
|
|
157
|
+
name: func[:name],
|
|
158
|
+
input: JSON.parse(func[:arguments], symbolize_names: true)
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
blocks
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mana
|
|
4
|
+
module Backends
|
|
5
|
+
ANTHROPIC_PATTERNS = /^(claude-)/i
|
|
6
|
+
OPENAI_PATTERNS = /^(gpt-|o1-|o3-|chatgpt-|dall-e|tts-|whisper-)/i
|
|
7
|
+
|
|
8
|
+
def self.for(config)
|
|
9
|
+
return config.backend if config.backend.is_a?(Base)
|
|
10
|
+
|
|
11
|
+
case config.backend&.to_s
|
|
12
|
+
when "openai" then OpenAI.new(config)
|
|
13
|
+
when "anthropic" then Anthropic.new(config)
|
|
14
|
+
else
|
|
15
|
+
# Auto-detect from model name
|
|
16
|
+
case config.model
|
|
17
|
+
when ANTHROPIC_PATTERNS then Anthropic.new(config)
|
|
18
|
+
else OpenAI.new(config) # Default to OpenAI (most compatible)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/mana/config.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
module Mana
|
|
4
4
|
class Config
|
|
5
5
|
attr_accessor :model, :temperature, :api_key, :max_iterations, :base_url,
|
|
6
|
+
:backend,
|
|
6
7
|
:namespace, :memory_store, :memory_path,
|
|
7
8
|
:context_window, :memory_pressure, :memory_keep_recent,
|
|
8
9
|
:compact_model, :on_compact
|
|
@@ -13,6 +14,7 @@ module Mana
|
|
|
13
14
|
@api_key = ENV["ANTHROPIC_API_KEY"]
|
|
14
15
|
@max_iterations = 50
|
|
15
16
|
@base_url = "https://api.anthropic.com"
|
|
17
|
+
@backend = nil
|
|
16
18
|
@namespace = nil
|
|
17
19
|
@memory_store = nil
|
|
18
20
|
@memory_path = nil
|
data/lib/mana/engine.rb
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require "net/http"
|
|
5
|
-
require "uri"
|
|
6
4
|
|
|
7
5
|
module Mana
|
|
8
6
|
class Engine
|
|
@@ -120,6 +118,23 @@ module Mana
|
|
|
120
118
|
end
|
|
121
119
|
|
|
122
120
|
def execute
|
|
121
|
+
# Mock mode: match prompt and return stubbed values, no API calls
|
|
122
|
+
return handle_mock(@prompt) if Mana.mock_active?
|
|
123
|
+
|
|
124
|
+
Thread.current[:mana_depth] ||= 0
|
|
125
|
+
Thread.current[:mana_depth] += 1
|
|
126
|
+
nested = Thread.current[:mana_depth] > 1
|
|
127
|
+
|
|
128
|
+
# Nested calls get fresh short-term memory but share long-term
|
|
129
|
+
if nested && !@incognito
|
|
130
|
+
outer_memory = Thread.current[:mana_memory]
|
|
131
|
+
inner_memory = Mana::Memory.new
|
|
132
|
+
long_term = outer_memory&.long_term || []
|
|
133
|
+
inner_memory.instance_variable_set(:@long_term, long_term)
|
|
134
|
+
inner_memory.instance_variable_set(:@next_id, (long_term.map { |m| m[:id] }.max || 0) + 1)
|
|
135
|
+
Thread.current[:mana_memory] = inner_memory
|
|
136
|
+
end
|
|
137
|
+
|
|
123
138
|
context = build_context(@prompt)
|
|
124
139
|
system_prompt = build_system_prompt(context)
|
|
125
140
|
|
|
@@ -162,14 +177,54 @@ module Mana
|
|
|
162
177
|
messages << { role: "assistant", content: [{ type: "text", text: "Done: #{done_result}" }] }
|
|
163
178
|
end
|
|
164
179
|
|
|
165
|
-
# Schedule compaction if needed (runs in background)
|
|
166
|
-
memory&.schedule_compaction
|
|
180
|
+
# Schedule compaction if needed (runs in background, skip for nested)
|
|
181
|
+
memory&.schedule_compaction unless nested
|
|
167
182
|
|
|
168
183
|
done_result
|
|
184
|
+
ensure
|
|
185
|
+
if nested && !@incognito
|
|
186
|
+
Thread.current[:mana_memory] = outer_memory
|
|
187
|
+
end
|
|
188
|
+
Thread.current[:mana_depth] -= 1 if Thread.current[:mana_depth]
|
|
169
189
|
end
|
|
170
190
|
|
|
171
191
|
private
|
|
172
192
|
|
|
193
|
+
# --- Mock Handling ---
|
|
194
|
+
|
|
195
|
+
def handle_mock(prompt)
|
|
196
|
+
mock = Mana.current_mock
|
|
197
|
+
stub = mock.match(prompt)
|
|
198
|
+
|
|
199
|
+
unless stub
|
|
200
|
+
truncated = prompt.length > 60 ? "#{prompt[0..57]}..." : prompt
|
|
201
|
+
raise MockError, "No mock matched: \"#{truncated}\"\n Add: mock_prompt \"#{truncated}\", _return: \"...\""
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
values = if stub.block
|
|
205
|
+
stub.block.call(prompt)
|
|
206
|
+
else
|
|
207
|
+
stub.values.dup
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
return_value = values.delete(:_return)
|
|
211
|
+
|
|
212
|
+
values.each do |name, value|
|
|
213
|
+
write_local(name.to_s, value)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Record in short-term memory if not incognito
|
|
217
|
+
if !@incognito
|
|
218
|
+
memory = Memory.current
|
|
219
|
+
if memory
|
|
220
|
+
memory.short_term << { role: "user", content: prompt }
|
|
221
|
+
memory.short_term << { role: "assistant", content: [{ type: "text", text: "Done: #{return_value || values.inspect}" }] }
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
return_value || values.values.first
|
|
226
|
+
end
|
|
227
|
+
|
|
173
228
|
# --- Context Building ---
|
|
174
229
|
|
|
175
230
|
def build_context(prompt)
|
|
@@ -298,7 +353,15 @@ module Mana
|
|
|
298
353
|
func = input["name"]
|
|
299
354
|
validate_name!(func)
|
|
300
355
|
args = input["args"] || []
|
|
301
|
-
|
|
356
|
+
# Try method first, then local variable (supports lambdas/procs)
|
|
357
|
+
callable = if @binding.receiver.respond_to?(func.to_sym, true)
|
|
358
|
+
@binding.receiver.method(func.to_sym)
|
|
359
|
+
elsif @binding.local_variables.include?(func.to_sym)
|
|
360
|
+
@binding.local_variable_get(func.to_sym)
|
|
361
|
+
else
|
|
362
|
+
@binding.receiver.method(func.to_sym) # raise NameError
|
|
363
|
+
end
|
|
364
|
+
result = callable.call(*args)
|
|
302
365
|
serialize_value(result)
|
|
303
366
|
|
|
304
367
|
when "remember"
|
|
@@ -383,30 +446,14 @@ module Mana
|
|
|
383
446
|
# --- LLM Client ---
|
|
384
447
|
|
|
385
448
|
def llm_call(system, messages)
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
model: @config.model,
|
|
389
|
-
max_tokens: 4096,
|
|
449
|
+
backend = Backends.for(@config)
|
|
450
|
+
backend.chat(
|
|
390
451
|
system: system,
|
|
452
|
+
messages: messages,
|
|
391
453
|
tools: self.class.all_tools,
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
396
|
-
http.use_ssl = uri.scheme == "https"
|
|
397
|
-
http.read_timeout = 120
|
|
398
|
-
|
|
399
|
-
req = Net::HTTP::Post.new(uri)
|
|
400
|
-
req["Content-Type"] = "application/json"
|
|
401
|
-
req["x-api-key"] = @config.api_key
|
|
402
|
-
req["anthropic-version"] = "2023-06-01"
|
|
403
|
-
req.body = JSON.generate(body)
|
|
404
|
-
|
|
405
|
-
res = http.request(req)
|
|
406
|
-
raise LLMError, "HTTP #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)
|
|
407
|
-
|
|
408
|
-
parsed = JSON.parse(res.body, symbolize_names: true)
|
|
409
|
-
parsed[:content] || []
|
|
454
|
+
model: @config.model,
|
|
455
|
+
max_tokens: 4096
|
|
456
|
+
)
|
|
410
457
|
end
|
|
411
458
|
|
|
412
459
|
def extract_tool_uses(content)
|
data/lib/mana/mock.rb
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mana
|
|
4
|
+
class Mock
|
|
5
|
+
Stub = Struct.new(:pattern, :values, :block, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
attr_reader :stubs
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@stubs = []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def prompt(pattern, **values, &block)
|
|
14
|
+
@stubs << Stub.new(pattern: pattern, values: values, block: block)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def match(prompt_text)
|
|
18
|
+
@stubs.find do |stub|
|
|
19
|
+
case stub.pattern
|
|
20
|
+
when Regexp then prompt_text.match?(stub.pattern)
|
|
21
|
+
when String then prompt_text.include?(stub.pattern)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def mock(&block)
|
|
29
|
+
old_mock = Thread.current[:mana_mock]
|
|
30
|
+
m = Mock.new
|
|
31
|
+
Thread.current[:mana_mock] = m
|
|
32
|
+
m.instance_eval(&block)
|
|
33
|
+
ensure
|
|
34
|
+
Thread.current[:mana_mock] = old_mock
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def mock!
|
|
38
|
+
Thread.current[:mana_mock] = Mock.new
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def unmock!
|
|
42
|
+
Thread.current[:mana_mock] = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def mock_active?
|
|
46
|
+
!Thread.current[:mana_mock].nil?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def current_mock
|
|
50
|
+
Thread.current[:mana_mock]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/mana/string_ext.rb
CHANGED
|
@@ -5,13 +5,6 @@ require "binding_of_caller"
|
|
|
5
5
|
class String
|
|
6
6
|
# ~"natural language prompt" → execute via Mana engine
|
|
7
7
|
def ~@
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
Thread.current[:mana_running] = true
|
|
11
|
-
begin
|
|
12
|
-
Mana::Engine.run(self, binding.of_caller(1))
|
|
13
|
-
ensure
|
|
14
|
-
Thread.current[:mana_running] = false
|
|
15
|
-
end
|
|
8
|
+
Mana::Engine.run(self, binding.of_caller(1))
|
|
16
9
|
end
|
|
17
10
|
end
|
data/lib/mana/test.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mana"
|
|
4
|
+
|
|
5
|
+
module Mana
|
|
6
|
+
module TestHelpers
|
|
7
|
+
def self.included(base)
|
|
8
|
+
base.before { Mana.mock! }
|
|
9
|
+
base.after { Mana.unmock! }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def mock_prompt(pattern, **values, &block)
|
|
13
|
+
raise Mana::MockError, "Mana mock mode not active. Call Mana.mock! first" unless Mana.mock_active?
|
|
14
|
+
|
|
15
|
+
Mana.current_mock.prompt(pattern, **values, &block)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/mana/version.rb
CHANGED
data/lib/mana.rb
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "mana/version"
|
|
4
4
|
require_relative "mana/config"
|
|
5
|
+
require_relative "mana/backends/base"
|
|
6
|
+
require_relative "mana/backends/anthropic"
|
|
7
|
+
require_relative "mana/backends/openai"
|
|
8
|
+
require_relative "mana/backends/registry"
|
|
5
9
|
require_relative "mana/effect_registry"
|
|
6
10
|
require_relative "mana/namespace"
|
|
7
11
|
require_relative "mana/memory_store"
|
|
@@ -17,6 +21,7 @@ module Mana
|
|
|
17
21
|
class Error < StandardError; end
|
|
18
22
|
class MaxIterationsError < Error; end
|
|
19
23
|
class LLMError < Error; end
|
|
24
|
+
class MockError < Error; end
|
|
20
25
|
|
|
21
26
|
class << self
|
|
22
27
|
def config
|
|
@@ -40,6 +45,7 @@ module Mana
|
|
|
40
45
|
@config = Config.new
|
|
41
46
|
EffectRegistry.clear!
|
|
42
47
|
Thread.current[:mana_memory] = nil
|
|
48
|
+
Thread.current[:mana_mock] = nil
|
|
43
49
|
end
|
|
44
50
|
|
|
45
51
|
# Define a custom effect that becomes an LLM tool
|
|
@@ -73,3 +79,5 @@ module Mana
|
|
|
73
79
|
end
|
|
74
80
|
end
|
|
75
81
|
end
|
|
82
|
+
|
|
83
|
+
require_relative "mana/mock"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby-mana
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Carl
|
|
@@ -37,6 +37,10 @@ files:
|
|
|
37
37
|
- LICENSE
|
|
38
38
|
- README.md
|
|
39
39
|
- lib/mana.rb
|
|
40
|
+
- lib/mana/backends/anthropic.rb
|
|
41
|
+
- lib/mana/backends/base.rb
|
|
42
|
+
- lib/mana/backends/openai.rb
|
|
43
|
+
- lib/mana/backends/registry.rb
|
|
40
44
|
- lib/mana/compiler.rb
|
|
41
45
|
- lib/mana/config.rb
|
|
42
46
|
- lib/mana/context_window.rb
|
|
@@ -46,8 +50,10 @@ files:
|
|
|
46
50
|
- lib/mana/memory.rb
|
|
47
51
|
- lib/mana/memory_store.rb
|
|
48
52
|
- lib/mana/mixin.rb
|
|
53
|
+
- lib/mana/mock.rb
|
|
49
54
|
- lib/mana/namespace.rb
|
|
50
55
|
- lib/mana/string_ext.rb
|
|
56
|
+
- lib/mana/test.rb
|
|
51
57
|
- lib/mana/version.rb
|
|
52
58
|
homepage: https://github.com/carlnoah6/ruby-mana
|
|
53
59
|
licenses:
|