ruby-mana 0.1.0 → 0.2.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 +63 -2
- data/lib/mana/compiler.rb +158 -0
- data/lib/mana/effect_registry.rb +155 -0
- data/lib/mana/engine.rb +49 -3
- data/lib/mana/introspect.rb +117 -0
- data/lib/mana/mixin.rb +23 -4
- data/lib/mana/string_ext.rb +8 -1
- data/lib/mana/version.rb +1 -1
- data/lib/mana.rb +24 -1
- metadata +5 -5
- data/lib/mana/effects.rb +0 -12
- data/lib/mana/llm/anthropic.rb +0 -69
- data/lib/mana/llm/base.rb +0 -19
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa9b6637652accc3c6223e613713b06f3ef24654324c3498deac36067fbca29f
|
|
4
|
+
data.tar.gz: dc9722c916a0bcc6d599d37e24d68fc40870a6c35b0736d211266a7b4085c725
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 911c263d7ad2e854c0268878ac19ac7e353d5d293e6683ae8453963c7edcb2443526ffdfdd019d24bbe024f729138ea3bad3234c96c16e968b6b997f81d298cb
|
|
7
|
+
data.tar.gz: b20334705ffd23d95249e5f3411e54e7e74da25442ebe059745de41ea1dbe88496377b410c3964979a86d2f64cab6e0755291bd5e860d5a116af6cfe535e50a9
|
data/README.md
CHANGED
|
@@ -136,11 +136,72 @@ Mana.configure do |c|
|
|
|
136
136
|
c.api_key = ENV["ANTHROPIC_API_KEY"]
|
|
137
137
|
c.max_iterations = 50
|
|
138
138
|
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Custom effect handlers
|
|
142
|
+
|
|
143
|
+
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.
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
# No params
|
|
147
|
+
Mana.define_effect :get_time do
|
|
148
|
+
Time.now.to_s
|
|
149
|
+
end
|
|
139
150
|
|
|
140
|
-
#
|
|
141
|
-
Mana.
|
|
151
|
+
# With params — keyword args become tool parameters
|
|
152
|
+
Mana.define_effect :query_db do |sql:|
|
|
153
|
+
ActiveRecord::Base.connection.execute(sql).to_a
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# With description (optional, recommended)
|
|
157
|
+
Mana.define_effect :search_web,
|
|
158
|
+
description: "Search the web for information" do |query:, max_results: 5|
|
|
159
|
+
WebSearch.search(query, limit: max_results)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Use in prompts
|
|
163
|
+
~"get the current time and store in <now>"
|
|
164
|
+
~"find recent orders using query_db, store in <orders>"
|
|
142
165
|
```
|
|
143
166
|
|
|
167
|
+
Built-in effects (`read_var`, `write_var`, `read_attr`, `write_attr`, `call_func`, `done`) are reserved and cannot be overridden.
|
|
168
|
+
|
|
169
|
+
### LLM-compiled methods
|
|
170
|
+
|
|
171
|
+
`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.
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
mana def fizzbuzz(n)
|
|
175
|
+
~"return an array of FizzBuzz results from 1 to n"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
fizzbuzz(15) # first call → LLM generates code → cached → executed
|
|
179
|
+
fizzbuzz(20) # pure Ruby from .mana_cache/fizzbuzz.rb
|
|
180
|
+
|
|
181
|
+
# View the generated source
|
|
182
|
+
puts Mana.source(:fizzbuzz)
|
|
183
|
+
# def fizzbuzz(n)
|
|
184
|
+
# (1..n).map do |i|
|
|
185
|
+
# if i % 15 == 0 then "FizzBuzz"
|
|
186
|
+
# elsif i % 3 == 0 then "Fizz"
|
|
187
|
+
# elsif i % 5 == 0 then "Buzz"
|
|
188
|
+
# else i.to_s
|
|
189
|
+
# end
|
|
190
|
+
# end
|
|
191
|
+
# end
|
|
192
|
+
|
|
193
|
+
# Works in classes too
|
|
194
|
+
class Converter
|
|
195
|
+
include Mana::Mixin
|
|
196
|
+
|
|
197
|
+
mana def celsius_to_fahrenheit(c)
|
|
198
|
+
~"convert Celsius to Fahrenheit"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Generated files live in `.mana_cache/` (add to `.gitignore`, or commit them to skip LLM on CI).
|
|
204
|
+
|
|
144
205
|
## How it works
|
|
145
206
|
|
|
146
207
|
1. `~"..."` calls `String#~@`, which captures the caller's `Binding`
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Mana
|
|
6
|
+
# Compiler for `mana def` — LLM generates method implementations on first call,
|
|
7
|
+
# caches them as real .rb files, and replaces the method with native Ruby.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# mana def fizzbuzz(n)
|
|
11
|
+
# ~"return an array of FizzBuzz results from 1 to n"
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# fizzbuzz(15) # first call → LLM generates code → cached → executed
|
|
15
|
+
# fizzbuzz(20) # subsequent calls → pure Ruby, zero API overhead
|
|
16
|
+
#
|
|
17
|
+
# Mana.source(:fizzbuzz) # view generated source
|
|
18
|
+
module Compiler
|
|
19
|
+
class << self
|
|
20
|
+
# Registry of compiled method sources: { "ClassName#method" => source_code }
|
|
21
|
+
def registry
|
|
22
|
+
@registry ||= {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Cache directory for generated .rb files
|
|
26
|
+
def cache_dir
|
|
27
|
+
@cache_dir || ".mana_cache"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attr_writer :cache_dir
|
|
31
|
+
|
|
32
|
+
# Get the generated source for a compiled method
|
|
33
|
+
def source(method_name, owner: nil)
|
|
34
|
+
key = registry_key(method_name, owner)
|
|
35
|
+
registry[key]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Compile a method: wrap it so first invocation triggers LLM code generation
|
|
39
|
+
def compile(owner, method_name)
|
|
40
|
+
original = owner.instance_method(method_name)
|
|
41
|
+
compiler = self
|
|
42
|
+
key = registry_key(method_name, owner)
|
|
43
|
+
|
|
44
|
+
# Read the prompt from the original method body
|
|
45
|
+
prompt = extract_prompt(original)
|
|
46
|
+
|
|
47
|
+
# Build parameter signature for the generated method
|
|
48
|
+
params_desc = describe_params(original)
|
|
49
|
+
|
|
50
|
+
owner.define_method(method_name) do |*args, **kwargs, &blk|
|
|
51
|
+
# Generate implementation via LLM
|
|
52
|
+
generated = compiler.generate(method_name, params_desc, prompt)
|
|
53
|
+
|
|
54
|
+
# Write to cache file
|
|
55
|
+
cache_path = compiler.write_cache(method_name, generated, owner)
|
|
56
|
+
|
|
57
|
+
# Store in registry
|
|
58
|
+
compiler.registry[key] = generated
|
|
59
|
+
|
|
60
|
+
# Define the method on the correct owner (not Object) via class_eval
|
|
61
|
+
target_owner = owner
|
|
62
|
+
target_owner.class_eval(generated, cache_path, 1)
|
|
63
|
+
|
|
64
|
+
# Call the now-native method
|
|
65
|
+
send(method_name, *args, **kwargs, &blk)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Generate Ruby method source via LLM
|
|
70
|
+
def generate(method_name, params_desc, prompt)
|
|
71
|
+
code = nil # pre-declare so binding captures it
|
|
72
|
+
b = binding
|
|
73
|
+
engine_prompt = "Write a Ruby method definition `def #{method_name}(#{params_desc})` that: #{prompt}. " \
|
|
74
|
+
"Return ONLY the complete method definition (def...end), no explanation. " \
|
|
75
|
+
"Store the code as a string in <code>"
|
|
76
|
+
|
|
77
|
+
Mana::Engine.run(engine_prompt, b)
|
|
78
|
+
code
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Write generated code to a cache file, return the path
|
|
82
|
+
def write_cache(method_name, source, owner = nil)
|
|
83
|
+
FileUtils.mkdir_p(cache_dir)
|
|
84
|
+
prefix = owner && owner != Object ? "#{underscore(owner.name)}_" : ""
|
|
85
|
+
path = File.join(cache_dir, "#{prefix}#{method_name}.rb")
|
|
86
|
+
File.write(path, "# Auto-generated by ruby-mana\n# frozen_string_literal: true\n\n#{source}\n")
|
|
87
|
+
path
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Clear all cached files and registry
|
|
91
|
+
def clear!
|
|
92
|
+
FileUtils.rm_rf(cache_dir) if Dir.exist?(cache_dir)
|
|
93
|
+
@registry = {}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def registry_key(method_name, owner = nil)
|
|
99
|
+
if owner && owner != Object
|
|
100
|
+
"#{owner}##{method_name}"
|
|
101
|
+
else
|
|
102
|
+
method_name.to_s
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Extract the prompt string from the original method.
|
|
107
|
+
# The method body should be a single ~"..." expression.
|
|
108
|
+
def extract_prompt(unbound_method)
|
|
109
|
+
source_loc = unbound_method.source_location
|
|
110
|
+
return nil unless source_loc
|
|
111
|
+
|
|
112
|
+
file, line = source_loc
|
|
113
|
+
return nil unless file && File.exist?(file)
|
|
114
|
+
|
|
115
|
+
lines = File.readlines(file)
|
|
116
|
+
# Scan from the def line to find the prompt string
|
|
117
|
+
body_lines = []
|
|
118
|
+
depth = 0
|
|
119
|
+
(line - 1...lines.length).each do |i|
|
|
120
|
+
l = lines[i]
|
|
121
|
+
depth += l.scan(/\bdef\b|\bdo\b|\bclass\b|\bmodule\b|\bif\b|\bunless\b|\bcase\b|\bwhile\b|\buntil\b|\bbegin\b/).length
|
|
122
|
+
depth -= l.scan(/\bend\b/).length
|
|
123
|
+
body_lines << l
|
|
124
|
+
break if depth <= 0
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Extract string content from ~"..." pattern
|
|
128
|
+
body = body_lines.join
|
|
129
|
+
match = body.match(/~"([^"]*)"/) || body.match(/~'([^']*)'/)
|
|
130
|
+
match ? match[1] : body_lines[1...-1].join.strip
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def describe_params(unbound_method)
|
|
134
|
+
unbound_method.parameters.map do |(type, name)|
|
|
135
|
+
case type
|
|
136
|
+
when :req then name.to_s
|
|
137
|
+
when :opt then "#{name}=nil"
|
|
138
|
+
when :rest then "*#{name}"
|
|
139
|
+
when :keyreq then "#{name}:"
|
|
140
|
+
when :key then "#{name}: nil"
|
|
141
|
+
when :keyrest then "**#{name}"
|
|
142
|
+
when :block then "&#{name}"
|
|
143
|
+
else name.to_s
|
|
144
|
+
end
|
|
145
|
+
end.join(", ")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def underscore(str)
|
|
149
|
+
return "anonymous" if str.nil? || str.empty?
|
|
150
|
+
|
|
151
|
+
str.gsub("::", "_")
|
|
152
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
153
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
154
|
+
.downcase
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mana
|
|
4
|
+
# Registry for custom effect handlers.
|
|
5
|
+
#
|
|
6
|
+
# Users define effects that become LLM tools automatically:
|
|
7
|
+
#
|
|
8
|
+
# Mana.define_effect :query_db,
|
|
9
|
+
# description: "Execute a SQL query" do |sql:|
|
|
10
|
+
# DB.execute(sql)
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# The block's keyword parameters become the tool's input schema.
|
|
14
|
+
# The block's return value is serialized and sent back to the LLM.
|
|
15
|
+
module EffectRegistry
|
|
16
|
+
class EffectDefinition
|
|
17
|
+
attr_reader :name, :description, :handler, :params
|
|
18
|
+
|
|
19
|
+
def initialize(name, description: nil, &handler)
|
|
20
|
+
@name = name.to_s
|
|
21
|
+
@description = description || @name
|
|
22
|
+
@handler = handler
|
|
23
|
+
@params = extract_params(handler)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Convert to LLM tool definition
|
|
27
|
+
def to_tool
|
|
28
|
+
properties = {}
|
|
29
|
+
required = []
|
|
30
|
+
|
|
31
|
+
@params.each do |param|
|
|
32
|
+
properties[param[:name]] = {
|
|
33
|
+
type: infer_type(param[:default]),
|
|
34
|
+
description: param[:name]
|
|
35
|
+
}
|
|
36
|
+
required << param[:name] if param[:required]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
tool = {
|
|
40
|
+
name: @name,
|
|
41
|
+
description: @description,
|
|
42
|
+
input_schema: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: properties
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
tool[:input_schema][:required] = required unless required.empty?
|
|
48
|
+
tool
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Call the handler with LLM-provided input
|
|
52
|
+
def call(input)
|
|
53
|
+
kwargs = {}
|
|
54
|
+
@params.each do |param|
|
|
55
|
+
key = param[:name]
|
|
56
|
+
if input.key?(key)
|
|
57
|
+
kwargs[key.to_sym] = input[key]
|
|
58
|
+
elsif param[:default] != :__mana_no_default__
|
|
59
|
+
# Use block's default
|
|
60
|
+
elsif param[:required]
|
|
61
|
+
raise Mana::Error, "missing required parameter: #{key}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if kwargs.empty? && @params.empty?
|
|
66
|
+
@handler.call
|
|
67
|
+
else
|
|
68
|
+
@handler.call(**kwargs)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def extract_params(block)
|
|
75
|
+
return [] unless block
|
|
76
|
+
|
|
77
|
+
block.parameters.map do |(type, name)|
|
|
78
|
+
case type
|
|
79
|
+
when :keyreq
|
|
80
|
+
{ name: name.to_s, required: true, default: :__mana_no_default__ }
|
|
81
|
+
when :key
|
|
82
|
+
# Try to get default value — not possible via reflection,
|
|
83
|
+
# so we mark it as optional with unknown default
|
|
84
|
+
{ name: name.to_s, required: false, default: nil }
|
|
85
|
+
when :keyrest
|
|
86
|
+
# **kwargs — skip, can't generate schema
|
|
87
|
+
nil
|
|
88
|
+
else
|
|
89
|
+
# Positional args — treat as required string params
|
|
90
|
+
{ name: name.to_s, required: true, default: :__mana_no_default__ } if name
|
|
91
|
+
end
|
|
92
|
+
end.compact
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def infer_type(default)
|
|
96
|
+
case default
|
|
97
|
+
when Integer then "integer"
|
|
98
|
+
when Float then "number"
|
|
99
|
+
when TrueClass, FalseClass then "boolean"
|
|
100
|
+
when Array then "array"
|
|
101
|
+
when Hash then "object"
|
|
102
|
+
else "string"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
RESERVED_EFFECTS = %w[read_var write_var read_attr write_attr call_func done].freeze
|
|
108
|
+
|
|
109
|
+
class << self
|
|
110
|
+
def registry
|
|
111
|
+
@registry ||= {}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def define(name, description: nil, &handler)
|
|
115
|
+
name_s = name.to_s
|
|
116
|
+
if RESERVED_EFFECTS.include?(name_s)
|
|
117
|
+
raise Mana::Error, "cannot override built-in effect: #{name_s}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
registry[name_s] = EffectDefinition.new(name, description: description, &handler)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def undefine(name)
|
|
124
|
+
registry.delete(name.to_s)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def defined?(name)
|
|
128
|
+
registry.key?(name.to_s)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def get(name)
|
|
132
|
+
registry[name.to_s]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Generate tool definitions for all registered effects
|
|
136
|
+
def tool_definitions
|
|
137
|
+
registry.values.map(&:to_tool)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def clear!
|
|
141
|
+
@registry = {}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Handle a tool call if it matches a registered effect
|
|
145
|
+
# Returns [handled, result] — handled is true if we processed it
|
|
146
|
+
def handle(name, input)
|
|
147
|
+
effect = get(name)
|
|
148
|
+
return [false, nil] unless effect
|
|
149
|
+
|
|
150
|
+
result = effect.call(input)
|
|
151
|
+
[true, result]
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
data/lib/mana/engine.rb
CHANGED
|
@@ -92,12 +92,18 @@ module Mana
|
|
|
92
92
|
ensure
|
|
93
93
|
handler_stack.pop
|
|
94
94
|
end
|
|
95
|
+
|
|
96
|
+
# Built-in tools + any registered custom effects
|
|
97
|
+
def all_tools
|
|
98
|
+
TOOLS + Mana::EffectRegistry.tool_definitions
|
|
99
|
+
end
|
|
95
100
|
end
|
|
96
101
|
|
|
97
102
|
def initialize(prompt, caller_binding)
|
|
98
103
|
@prompt = prompt
|
|
99
104
|
@binding = caller_binding
|
|
100
105
|
@config = Mana.config
|
|
106
|
+
@caller_path = caller_source_path
|
|
101
107
|
end
|
|
102
108
|
|
|
103
109
|
def execute
|
|
@@ -123,7 +129,7 @@ module Mana
|
|
|
123
129
|
# Process each tool use
|
|
124
130
|
tool_results = tool_uses.map do |tu|
|
|
125
131
|
result = handle_effect(tu)
|
|
126
|
-
done_result = tu[:input]["result"] if tu[:name] == "done"
|
|
132
|
+
done_result = (tu[:input][:result] || tu[:input]["result"]) if tu[:name] == "done"
|
|
127
133
|
{ type: "tool_result", tool_use_id: tu[:id], content: result.to_s }
|
|
128
134
|
end
|
|
129
135
|
|
|
@@ -172,6 +178,28 @@ module Mana
|
|
|
172
178
|
context.each { |k, v| parts << " #{k} = #{v}" }
|
|
173
179
|
end
|
|
174
180
|
|
|
181
|
+
# Discover available functions from caller's source
|
|
182
|
+
methods = begin
|
|
183
|
+
Mana::Introspect.methods_from_file(@caller_path)
|
|
184
|
+
rescue => _e
|
|
185
|
+
[]
|
|
186
|
+
end
|
|
187
|
+
unless methods.empty?
|
|
188
|
+
parts << ""
|
|
189
|
+
parts << Mana::Introspect.format_for_prompt(methods)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# List custom effects
|
|
193
|
+
custom_effects = Mana::EffectRegistry.tool_definitions
|
|
194
|
+
unless custom_effects.empty?
|
|
195
|
+
parts << ""
|
|
196
|
+
parts << "Custom tools available:"
|
|
197
|
+
custom_effects.each do |t|
|
|
198
|
+
params = (t[:input_schema][:properties] || {}).keys.join(", ")
|
|
199
|
+
parts << " #{t[:name]}(#{params}) — #{t[:description]}"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
175
203
|
parts.join("\n")
|
|
176
204
|
end
|
|
177
205
|
|
|
@@ -183,10 +211,14 @@ module Mana
|
|
|
183
211
|
# Normalize keys to strings for consistent access
|
|
184
212
|
input = input.transform_keys(&:to_s) if input.is_a?(Hash)
|
|
185
213
|
|
|
186
|
-
# Check handler stack first
|
|
214
|
+
# Check handler stack first (legacy)
|
|
187
215
|
handler = self.class.handler_stack.last
|
|
188
216
|
return handler.call(name, input) if handler && handler.respond_to?(:call)
|
|
189
217
|
|
|
218
|
+
# Check custom effect registry
|
|
219
|
+
handled, result = Mana::EffectRegistry.handle(name, input)
|
|
220
|
+
return serialize_value(result) if handled
|
|
221
|
+
|
|
190
222
|
case name
|
|
191
223
|
when "read_var"
|
|
192
224
|
serialize_value(resolve(input["name"]))
|
|
@@ -249,6 +281,20 @@ module Mana
|
|
|
249
281
|
@binding.local_variable_set(name.to_sym, value)
|
|
250
282
|
end
|
|
251
283
|
|
|
284
|
+
def caller_source_path
|
|
285
|
+
# Walk up the call stack to find the first non-mana source file
|
|
286
|
+
loc = @binding.source_location
|
|
287
|
+
return loc[0] if loc.is_a?(Array)
|
|
288
|
+
|
|
289
|
+
# Fallback: search caller_locations
|
|
290
|
+
caller_locations(4, 20)&.each do |frame|
|
|
291
|
+
path = frame.absolute_path || frame.path
|
|
292
|
+
next if path.nil? || path.include?("mana/")
|
|
293
|
+
return path
|
|
294
|
+
end
|
|
295
|
+
nil
|
|
296
|
+
end
|
|
297
|
+
|
|
252
298
|
def serialize_value(val)
|
|
253
299
|
case val
|
|
254
300
|
when String, Integer, Float, TrueClass, FalseClass, NilClass
|
|
@@ -278,7 +324,7 @@ module Mana
|
|
|
278
324
|
model: @config.model,
|
|
279
325
|
max_tokens: 4096,
|
|
280
326
|
system: system,
|
|
281
|
-
tools:
|
|
327
|
+
tools: self.class.all_tools,
|
|
282
328
|
messages: messages
|
|
283
329
|
}
|
|
284
330
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Mana
|
|
6
|
+
# Introspects the caller's source file to discover user-defined methods.
|
|
7
|
+
# Uses Prism AST to extract `def` nodes with their parameter signatures.
|
|
8
|
+
module Introspect
|
|
9
|
+
class << self
|
|
10
|
+
# Extract method definitions from a Ruby source file.
|
|
11
|
+
# Returns an array of { name:, params: } hashes.
|
|
12
|
+
#
|
|
13
|
+
# @param path [String] path to the Ruby source file
|
|
14
|
+
# @return [Array<Hash>] method definitions found
|
|
15
|
+
def methods_from_file(path)
|
|
16
|
+
return [] unless path && File.exist?(path)
|
|
17
|
+
|
|
18
|
+
source = File.read(path)
|
|
19
|
+
result = Prism.parse(source)
|
|
20
|
+
methods = []
|
|
21
|
+
|
|
22
|
+
walk(result.value) do |node|
|
|
23
|
+
next unless node.is_a?(Prism::DefNode)
|
|
24
|
+
|
|
25
|
+
params = extract_params(node)
|
|
26
|
+
methods << { name: node.name.to_s, params: params }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
methods
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Format discovered methods as a string for the system prompt.
|
|
33
|
+
#
|
|
34
|
+
# @param methods [Array<Hash>] from methods_from_file
|
|
35
|
+
# @return [String] formatted method list
|
|
36
|
+
def format_for_prompt(methods)
|
|
37
|
+
return "" if methods.empty?
|
|
38
|
+
|
|
39
|
+
lines = methods.map do |m|
|
|
40
|
+
sig = m[:params].empty? ? m[:name] : "#{m[:name]}(#{m[:params].join(', ')})"
|
|
41
|
+
" #{sig}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
"Available Ruby functions:\n#{lines.join("\n")}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def walk(node, &block)
|
|
50
|
+
queue = [node]
|
|
51
|
+
while (current = queue.shift)
|
|
52
|
+
next unless current.respond_to?(:compact_child_nodes)
|
|
53
|
+
|
|
54
|
+
block.call(current)
|
|
55
|
+
queue.concat(current.compact_child_nodes)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def extract_params(def_node)
|
|
60
|
+
params_node = def_node.parameters
|
|
61
|
+
return [] unless params_node
|
|
62
|
+
|
|
63
|
+
result = []
|
|
64
|
+
|
|
65
|
+
# Required parameters
|
|
66
|
+
(params_node.requireds || []).each do |p|
|
|
67
|
+
result << param_name(p)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Optional parameters
|
|
71
|
+
(params_node.optionals || []).each do |p|
|
|
72
|
+
result << "#{param_name(p)}=..."
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Rest parameter
|
|
76
|
+
if params_node.rest && !params_node.rest.is_a?(Prism::ImplicitRestNode)
|
|
77
|
+
name = params_node.rest.name
|
|
78
|
+
result << "*#{name || ''}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Keyword parameters
|
|
82
|
+
(params_node.keywords || []).each do |p|
|
|
83
|
+
case p
|
|
84
|
+
when Prism::RequiredKeywordParameterNode
|
|
85
|
+
result << "#{p.name}:"
|
|
86
|
+
when Prism::OptionalKeywordParameterNode
|
|
87
|
+
result << "#{p.name}: ..."
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Keyword rest
|
|
92
|
+
if params_node.keyword_rest.is_a?(Prism::KeywordRestParameterNode)
|
|
93
|
+
name = params_node.keyword_rest.name
|
|
94
|
+
result << "**#{name || ''}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Block parameter
|
|
98
|
+
if params_node.block
|
|
99
|
+
result << "&#{params_node.block.name || ''}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
result
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def param_name(node)
|
|
106
|
+
case node
|
|
107
|
+
when Prism::RequiredParameterNode
|
|
108
|
+
node.name.to_s
|
|
109
|
+
when Prism::OptionalParameterNode
|
|
110
|
+
node.name.to_s
|
|
111
|
+
else
|
|
112
|
+
node.respond_to?(:name) ? node.name.to_s : "_"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
data/lib/mana/mixin.rb
CHANGED
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Mana
|
|
4
|
-
# Include in classes to use ~"..." in instance methods
|
|
5
|
-
#
|
|
6
|
-
# is mainly a semantic marker + future extension point.
|
|
4
|
+
# Include in classes to use ~"..." in instance methods
|
|
5
|
+
# and `mana def` for LLM-compiled methods.
|
|
7
6
|
module Mixin
|
|
8
7
|
def self.included(base)
|
|
9
|
-
|
|
8
|
+
base.extend(ClassMethods)
|
|
10
9
|
end
|
|
10
|
+
|
|
11
|
+
module ClassMethods
|
|
12
|
+
# Mark a method for LLM compilation.
|
|
13
|
+
# Usage:
|
|
14
|
+
# mana def fizzbuzz(n)
|
|
15
|
+
# ~"return FizzBuzz array from 1 to n"
|
|
16
|
+
# end
|
|
17
|
+
def mana(method_name)
|
|
18
|
+
Mana::Compiler.compile(self, method_name)
|
|
19
|
+
method_name
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Make `mana def` available at the top level (main object)
|
|
26
|
+
class << self
|
|
27
|
+
def mana(method_name)
|
|
28
|
+
Mana::Compiler.compile(Object, method_name)
|
|
29
|
+
method_name
|
|
11
30
|
end
|
|
12
31
|
end
|
data/lib/mana/string_ext.rb
CHANGED
|
@@ -5,6 +5,13 @@ require "binding_of_caller"
|
|
|
5
5
|
class String
|
|
6
6
|
# ~"natural language prompt" → execute via Mana engine
|
|
7
7
|
def ~@
|
|
8
|
-
|
|
8
|
+
return self if Thread.current[:mana_running]
|
|
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
|
|
9
16
|
end
|
|
10
17
|
end
|
data/lib/mana/version.rb
CHANGED
data/lib/mana.rb
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "mana/version"
|
|
4
4
|
require_relative "mana/config"
|
|
5
|
-
require_relative "mana/
|
|
5
|
+
require_relative "mana/effect_registry"
|
|
6
6
|
require_relative "mana/engine"
|
|
7
|
+
require_relative "mana/introspect"
|
|
8
|
+
require_relative "mana/compiler"
|
|
7
9
|
require_relative "mana/string_ext"
|
|
8
10
|
require_relative "mana/mixin"
|
|
9
11
|
|
|
@@ -32,6 +34,27 @@ module Mana
|
|
|
32
34
|
|
|
33
35
|
def reset!
|
|
34
36
|
@config = Config.new
|
|
37
|
+
EffectRegistry.clear!
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Define a custom effect that becomes an LLM tool
|
|
41
|
+
def define_effect(name, description: nil, &handler)
|
|
42
|
+
EffectRegistry.define(name, description: description, &handler)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Remove a custom effect
|
|
46
|
+
def undefine_effect(name)
|
|
47
|
+
EffectRegistry.undefine(name)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# View generated source for a mana-compiled method
|
|
51
|
+
def source(method_name, owner: nil)
|
|
52
|
+
Compiler.source(method_name, owner: owner)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Cache directory for compiled methods
|
|
56
|
+
def cache_dir=(dir)
|
|
57
|
+
Compiler.cache_dir = dir
|
|
35
58
|
end
|
|
36
59
|
end
|
|
37
60
|
end
|
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.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Carl
|
|
@@ -37,11 +37,11 @@ files:
|
|
|
37
37
|
- LICENSE
|
|
38
38
|
- README.md
|
|
39
39
|
- lib/mana.rb
|
|
40
|
+
- lib/mana/compiler.rb
|
|
40
41
|
- lib/mana/config.rb
|
|
41
|
-
- lib/mana/
|
|
42
|
+
- lib/mana/effect_registry.rb
|
|
42
43
|
- lib/mana/engine.rb
|
|
43
|
-
- lib/mana/
|
|
44
|
-
- lib/mana/llm/base.rb
|
|
44
|
+
- lib/mana/introspect.rb
|
|
45
45
|
- lib/mana/mixin.rb
|
|
46
46
|
- lib/mana/string_ext.rb
|
|
47
47
|
- lib/mana/version.rb
|
|
@@ -67,7 +67,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
67
67
|
- !ruby/object:Gem::Version
|
|
68
68
|
version: '0'
|
|
69
69
|
requirements: []
|
|
70
|
-
rubygems_version: 3.
|
|
70
|
+
rubygems_version: 3.5.22
|
|
71
71
|
signing_key:
|
|
72
72
|
specification_version: 4
|
|
73
73
|
summary: Embed LLM as native Ruby — write natural language, it just runs
|
data/lib/mana/effects.rb
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Mana
|
|
4
|
-
module Effects
|
|
5
|
-
ReadVar = Struct.new(:name)
|
|
6
|
-
WriteVar = Struct.new(:name, :value)
|
|
7
|
-
ReadAttr = Struct.new(:obj_name, :attr)
|
|
8
|
-
WriteAttr = Struct.new(:obj_name, :attr, :value)
|
|
9
|
-
CallFunc = Struct.new(:name, :args)
|
|
10
|
-
Done = Struct.new(:result)
|
|
11
|
-
end
|
|
12
|
-
end
|
data/lib/mana/llm/anthropic.rb
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "net/http"
|
|
4
|
-
require "uri"
|
|
5
|
-
require "json"
|
|
6
|
-
|
|
7
|
-
module Mana
|
|
8
|
-
module LLM
|
|
9
|
-
class Anthropic < Base
|
|
10
|
-
API_URL = "https://api.anthropic.com/v1/messages"
|
|
11
|
-
API_VERSION = "2023-06-01"
|
|
12
|
-
|
|
13
|
-
def initialize(config = Mana.config)
|
|
14
|
-
super(config)
|
|
15
|
-
@api_key = config.api_key
|
|
16
|
-
@model = config.model
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def chat(system:, messages:, tools:)
|
|
20
|
-
raise Mana::Error, "Anthropic API key not set" unless @api_key
|
|
21
|
-
|
|
22
|
-
body = {
|
|
23
|
-
model: @model,
|
|
24
|
-
max_tokens: 4096,
|
|
25
|
-
temperature: @config.temperature,
|
|
26
|
-
system: system,
|
|
27
|
-
messages: messages,
|
|
28
|
-
tools: tools
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
response = post(body)
|
|
32
|
-
|
|
33
|
-
unless response.is_a?(Net::HTTPSuccess)
|
|
34
|
-
parsed = JSON.parse(response.body) rescue nil
|
|
35
|
-
error_msg = parsed&.dig("error", "message") || response.body
|
|
36
|
-
raise Mana::Error, "Anthropic API error (#{response.code}): #{error_msg}"
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
parsed = JSON.parse(response.body)
|
|
40
|
-
parsed["content"].map { |block| symbolize_keys(block) }
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
private
|
|
44
|
-
|
|
45
|
-
def post(body)
|
|
46
|
-
uri = URI(API_URL)
|
|
47
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
48
|
-
http.use_ssl = true
|
|
49
|
-
http.open_timeout = 30
|
|
50
|
-
http.read_timeout = 120
|
|
51
|
-
http.write_timeout = 30
|
|
52
|
-
|
|
53
|
-
request = Net::HTTP::Post.new(uri)
|
|
54
|
-
request["x-api-key"] = @api_key
|
|
55
|
-
request["anthropic-version"] = API_VERSION
|
|
56
|
-
request["content-type"] = "application/json"
|
|
57
|
-
request.body = JSON.generate(body)
|
|
58
|
-
|
|
59
|
-
http.request(request)
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def symbolize_keys(hash)
|
|
63
|
-
hash.each_with_object({}) do |(k, v), acc|
|
|
64
|
-
acc[k.to_sym] = v.is_a?(Hash) ? symbolize_keys(v) : v
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
data/lib/mana/llm/base.rb
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Mana
|
|
4
|
-
module LLM
|
|
5
|
-
# Base interface for LLM clients.
|
|
6
|
-
# Subclass and implement #chat to add new providers.
|
|
7
|
-
class Base
|
|
8
|
-
def initialize(config)
|
|
9
|
-
@config = config
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
# Send a chat request with tools.
|
|
13
|
-
# Returns an array of content blocks (tool_use / text).
|
|
14
|
-
def chat(system:, messages:, tools:)
|
|
15
|
-
raise NotImplementedError, "#{self.class}#chat not implemented"
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
end
|