ruby-mana 0.5.1 → 0.5.7
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 +70 -17
- data/LICENSE +1 -1
- data/README.md +189 -166
- data/lib/mana/backends/anthropic.rb +9 -27
- data/lib/mana/backends/base.rb +51 -4
- data/lib/mana/backends/openai.rb +17 -42
- data/lib/mana/compiler.rb +162 -46
- data/lib/mana/config.rb +94 -6
- data/lib/mana/engine.rb +628 -38
- data/lib/mana/introspect.rb +58 -19
- data/lib/mana/logger.rb +99 -0
- data/lib/mana/memory.rb +132 -39
- data/lib/mana/memory_store.rb +18 -8
- data/lib/mana/mixin.rb +2 -2
- data/lib/mana/mock.rb +40 -0
- data/lib/mana/security_policy.rb +195 -0
- data/lib/mana/version.rb +1 -1
- data/lib/mana.rb +7 -30
- metadata +12 -38
- data/data/lang-rules.yml +0 -196
- data/lib/mana/backends/registry.rb +0 -23
- data/lib/mana/context_window.rb +0 -28
- data/lib/mana/effect_registry.rb +0 -155
- data/lib/mana/engines/base.rb +0 -79
- data/lib/mana/engines/detect.rb +0 -93
- data/lib/mana/engines/javascript.rb +0 -314
- data/lib/mana/engines/llm.rb +0 -467
- data/lib/mana/engines/python.rb +0 -314
- data/lib/mana/engines/ruby_eval.rb +0 -11
- data/lib/mana/namespace.rb +0 -39
- data/lib/mana/object_registry.rb +0 -89
- data/lib/mana/remote_ref.rb +0 -85
- data/lib/mana/test.rb +0 -18
data/lib/mana/engine.rb
CHANGED
|
@@ -1,73 +1,663 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module Mana
|
|
6
|
+
# The Engine handles ~"..." prompts by calling an LLM with tool-calling
|
|
7
|
+
# to interact with Ruby variables in the caller's binding.
|
|
4
8
|
class Engine
|
|
9
|
+
attr_reader :config, :binding
|
|
10
|
+
|
|
11
|
+
TOOLS = [
|
|
12
|
+
{
|
|
13
|
+
name: "read_var",
|
|
14
|
+
description: "Read a variable value from the Ruby scope.",
|
|
15
|
+
input_schema: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: { name: { type: "string", description: "Variable name" } },
|
|
18
|
+
required: ["name"]
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: "write_var",
|
|
23
|
+
description: "Write a value to a variable in the Ruby scope. Creates the variable if it doesn't exist.",
|
|
24
|
+
input_schema: {
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: {
|
|
27
|
+
name: { type: "string", description: "Variable name" },
|
|
28
|
+
value: { description: "Value to assign (any JSON type)" }
|
|
29
|
+
},
|
|
30
|
+
required: %w[name value]
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "read_attr",
|
|
35
|
+
description: "Read an attribute from a Ruby object.",
|
|
36
|
+
input_schema: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
obj: { type: "string", description: "Variable name holding the object" },
|
|
40
|
+
attr: { type: "string", description: "Attribute name to read" }
|
|
41
|
+
},
|
|
42
|
+
required: %w[obj attr]
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "write_attr",
|
|
47
|
+
description: "Set an attribute on a Ruby object.",
|
|
48
|
+
input_schema: {
|
|
49
|
+
type: "object",
|
|
50
|
+
properties: {
|
|
51
|
+
obj: { type: "string", description: "Variable name holding the object" },
|
|
52
|
+
attr: { type: "string", description: "Attribute name to set" },
|
|
53
|
+
value: { description: "Value to assign" }
|
|
54
|
+
},
|
|
55
|
+
required: %w[obj attr value]
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "call_func",
|
|
60
|
+
description: "Call a Ruby method/function available in the current scope. Use kwargs for keyword arguments.",
|
|
61
|
+
input_schema: {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: {
|
|
64
|
+
name: { type: "string", description: "Function/method name" },
|
|
65
|
+
args: { type: "array", description: "Positional arguments", items: {} },
|
|
66
|
+
kwargs: { type: "object", description: "Keyword arguments (e.g. {sql: '...', limit: 10})" }
|
|
67
|
+
},
|
|
68
|
+
required: ["name"]
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "done",
|
|
73
|
+
description: "Signal that the task is complete. Always include the result — this is the value returned to the Ruby program.",
|
|
74
|
+
input_schema: {
|
|
75
|
+
type: "object",
|
|
76
|
+
properties: {
|
|
77
|
+
result: { description: "The answer or result to return. Always provide this." }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
].freeze
|
|
82
|
+
|
|
83
|
+
REMEMBER_TOOL = {
|
|
84
|
+
name: "remember",
|
|
85
|
+
description: "Store a fact in long-term memory. This memory persists across script executions. Use when the user explicitly asks to remember something.",
|
|
86
|
+
input_schema: {
|
|
87
|
+
type: "object",
|
|
88
|
+
properties: { content: { type: "string", description: "The fact to remember" } },
|
|
89
|
+
required: ["content"]
|
|
90
|
+
}
|
|
91
|
+
}.freeze
|
|
92
|
+
|
|
5
93
|
class << self
|
|
94
|
+
# Entry point for ~"..." prompts. Routes to mock handler or real LLM engine.
|
|
6
95
|
def run(prompt, caller_binding)
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
return Engines::LLM.new(caller_binding).handle_mock(prompt)
|
|
96
|
+
if Mana.current_mock
|
|
97
|
+
return new(caller_binding).handle_mock(prompt)
|
|
10
98
|
end
|
|
11
99
|
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
|
|
100
|
+
# Normal mode: execute via the LLM engine
|
|
101
|
+
new(caller_binding).execute(prompt)
|
|
102
|
+
end
|
|
15
103
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
104
|
+
# Built-in tools + remember
|
|
105
|
+
def all_tools
|
|
106
|
+
tools = TOOLS.dup
|
|
107
|
+
tools << REMEMBER_TOOL unless Memory.incognito?
|
|
108
|
+
tools
|
|
19
109
|
end
|
|
110
|
+
end
|
|
20
111
|
|
|
21
|
-
|
|
22
|
-
|
|
112
|
+
# Capture the caller's binding, config, source path, and incognito state
|
|
113
|
+
def initialize(caller_binding, config = Mana.config)
|
|
114
|
+
@binding = caller_binding
|
|
115
|
+
@config = config
|
|
116
|
+
@caller_path = caller_source_path
|
|
117
|
+
@incognito = Memory.incognito?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Main execution loop: build context, call LLM, handle tool calls, iterate until done
|
|
121
|
+
def execute(prompt)
|
|
122
|
+
# Track nesting depth to isolate memory for nested ~"..." calls
|
|
123
|
+
Thread.current[:mana_depth] ||= 0
|
|
124
|
+
Thread.current[:mana_depth] += 1
|
|
125
|
+
nested = Thread.current[:mana_depth] > 1
|
|
126
|
+
outer_memory = nil # defined here so ensure block always has access
|
|
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
|
|
23
136
|
end
|
|
24
137
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
138
|
+
# Extract <var> references from the prompt and read their current values
|
|
139
|
+
context = build_context(prompt)
|
|
140
|
+
system_prompt = build_system_prompt(context)
|
|
141
|
+
|
|
142
|
+
memory = @incognito ? nil : Memory.current
|
|
143
|
+
# Wait for any in-progress background compaction before reading messages
|
|
144
|
+
memory&.wait_for_compaction
|
|
145
|
+
|
|
146
|
+
messages = memory ? memory.short_term : []
|
|
147
|
+
|
|
148
|
+
# Ensure messages don't end with an unpaired tool_use (causes API 400 error)
|
|
149
|
+
while messages.last && messages.last[:role] == "assistant" &&
|
|
150
|
+
messages.last[:content].is_a?(Array) &&
|
|
151
|
+
messages.last[:content].any? { |b| (b[:type] || b["type"]) == "tool_use" }
|
|
152
|
+
messages.pop
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Track where we started in messages — rollback on failure
|
|
156
|
+
messages_start_size = messages.size
|
|
157
|
+
messages << { role: "user", content: prompt }
|
|
158
|
+
|
|
159
|
+
iterations = 0
|
|
160
|
+
done_result = nil
|
|
161
|
+
@written_vars = {} # Track write_var calls for return value
|
|
162
|
+
|
|
163
|
+
vlog("═" * 60)
|
|
164
|
+
vlog("🚀 Prompt: #{prompt}")
|
|
165
|
+
vlog("📡 Backend: #{@config.effective_base_url} / #{@config.model}")
|
|
166
|
+
|
|
167
|
+
# --- Main tool-calling loop ---
|
|
168
|
+
loop do
|
|
169
|
+
iterations += 1
|
|
170
|
+
@_iteration = iterations
|
|
171
|
+
raise MaxIterationsError, "exceeded #{@config.max_iterations} iterations" if iterations > @config.max_iterations
|
|
172
|
+
|
|
173
|
+
response = llm_call(system_prompt, messages)
|
|
174
|
+
tool_uses = extract_tool_uses(response)
|
|
175
|
+
|
|
176
|
+
if tool_uses.empty?
|
|
177
|
+
# Model returned text without calling any tools.
|
|
178
|
+
# On the first iteration with no writes yet, nudge it to use tools.
|
|
179
|
+
if iterations == 1 && @written_vars.empty?
|
|
180
|
+
messages << { role: "assistant", content: response }
|
|
181
|
+
messages << { role: "user", content: "You must use the provided tools (read_var, write_var, done) to complete this task. Do not just describe the answer in text." }
|
|
182
|
+
next
|
|
183
|
+
end
|
|
184
|
+
# Otherwise, accept the text-only response and exit the loop
|
|
185
|
+
break
|
|
31
186
|
end
|
|
187
|
+
|
|
188
|
+
# Append assistant message with tool_use blocks
|
|
189
|
+
messages << { role: "assistant", content: response }
|
|
190
|
+
|
|
191
|
+
# Process each tool use and collect results
|
|
192
|
+
tool_results = tool_uses.map do |tu|
|
|
193
|
+
result = handle_effect(tu, memory)
|
|
194
|
+
done_result = (tu[:input][:result] || tu[:input]["result"]) if tu[:name] == "done"
|
|
195
|
+
{ type: "tool_result", tool_use_id: tu[:id], content: result.to_s }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Send tool results back to the LLM as a user message
|
|
199
|
+
messages << { role: "user", content: tool_results }
|
|
200
|
+
# Exit loop when the LLM signals completion via the "done" tool
|
|
201
|
+
break if tool_uses.any? { |t| t[:name] == "done" }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Append a final assistant summary so LLM has full context next call
|
|
205
|
+
if memory && done_result
|
|
206
|
+
messages << { role: "assistant", content: [{ type: "text", text: "Done: #{done_result}" }] }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Schedule compaction if needed (runs in background, skip for nested)
|
|
210
|
+
memory&.schedule_compaction unless nested
|
|
211
|
+
|
|
212
|
+
# Return written variables so Ruby 4.0+ users can capture them:
|
|
213
|
+
# result = ~"compute average and store in <result>"
|
|
214
|
+
# Single write -> return the value directly; multiple -> return Hash.
|
|
215
|
+
if @written_vars.size == 1
|
|
216
|
+
@written_vars.values.first
|
|
217
|
+
elsif @written_vars.size > 1
|
|
218
|
+
@written_vars.transform_keys(&:to_sym)
|
|
219
|
+
else
|
|
220
|
+
# No writes — return the done() result
|
|
221
|
+
done_result
|
|
222
|
+
end
|
|
223
|
+
rescue => e
|
|
224
|
+
# Rollback: remove messages added during this failed call so they don't
|
|
225
|
+
# pollute short-term memory for subsequent prompts
|
|
226
|
+
if memory && messages.size > messages_start_size
|
|
227
|
+
messages.slice!(messages_start_size..)
|
|
228
|
+
end
|
|
229
|
+
raise e
|
|
230
|
+
ensure
|
|
231
|
+
# Restore outer memory when exiting a nested call
|
|
232
|
+
if nested && !@incognito
|
|
233
|
+
Thread.current[:mana_memory] = outer_memory
|
|
234
|
+
end
|
|
235
|
+
Thread.current[:mana_depth] -= 1 if Thread.current[:mana_depth]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Mock handling — finds a matching stub and writes its values into the caller's binding.
|
|
239
|
+
def handle_mock(prompt)
|
|
240
|
+
mock = Mana.current_mock
|
|
241
|
+
stub = mock.match(prompt)
|
|
242
|
+
|
|
243
|
+
# No matching stub found — raise with a helpful hint
|
|
244
|
+
unless stub
|
|
245
|
+
truncated = prompt.length > 60 ? "#{prompt[0..57]}..." : prompt
|
|
246
|
+
raise MockError, "No mock matched: \"#{truncated}\"\n Add: mock_prompt \"#{truncated}\", _return: \"...\""
|
|
32
247
|
end
|
|
33
248
|
|
|
34
|
-
#
|
|
35
|
-
|
|
36
|
-
|
|
249
|
+
# Evaluate stub: block-based stubs receive the prompt, hash-based return a copy
|
|
250
|
+
values = if stub.block
|
|
251
|
+
stub.block.call(prompt)
|
|
252
|
+
else
|
|
253
|
+
stub.values.dup
|
|
37
254
|
end
|
|
38
255
|
|
|
39
|
-
|
|
40
|
-
|
|
256
|
+
# Extract the special _return key (the value returned to the caller)
|
|
257
|
+
return_value = values.delete(:_return)
|
|
258
|
+
|
|
259
|
+
# Write remaining key-value pairs as local variables in the caller's scope
|
|
260
|
+
values.each do |name, value|
|
|
261
|
+
write_local(name.to_s, value)
|
|
41
262
|
end
|
|
42
263
|
|
|
43
|
-
|
|
44
|
-
|
|
264
|
+
# Record in short-term memory if not incognito
|
|
265
|
+
if !@incognito
|
|
266
|
+
memory = Memory.current
|
|
267
|
+
if memory
|
|
268
|
+
memory.short_term << { role: "user", content: prompt }
|
|
269
|
+
memory.short_term << { role: "assistant", content: [{ type: "text", text: "Done: #{return_value || values.inspect}" }] }
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Return _return value if set, otherwise the first written value
|
|
274
|
+
return_value || values.values.first
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
private
|
|
278
|
+
|
|
279
|
+
# --- Context Building ---
|
|
280
|
+
|
|
281
|
+
# Extract <var> references from the prompt and read their current values.
|
|
282
|
+
# Variables that don't exist yet are silently skipped (LLM will create them).
|
|
283
|
+
def build_context(prompt)
|
|
284
|
+
var_names = prompt.scan(/<(\w+)>/).flatten.uniq
|
|
285
|
+
ctx = {}
|
|
286
|
+
var_names.each do |name|
|
|
287
|
+
val = resolve(name)
|
|
288
|
+
ctx[name] = serialize_value(val)
|
|
289
|
+
rescue NameError
|
|
290
|
+
# Variable doesn't exist yet — will be created by LLM
|
|
291
|
+
end
|
|
292
|
+
ctx
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Assemble the system prompt with rules, memory, variables, available functions, and custom effects
|
|
296
|
+
def build_system_prompt(context)
|
|
297
|
+
parts = [
|
|
298
|
+
"You are embedded inside a Ruby program. You interact with the program's live state using the provided tools.",
|
|
299
|
+
"",
|
|
300
|
+
"Rules:",
|
|
301
|
+
"- Use read_var / read_attr to inspect variables and objects.",
|
|
302
|
+
"- Use write_var to create or update variables in the Ruby scope.",
|
|
303
|
+
"- Use write_attr to set attributes on Ruby objects.",
|
|
304
|
+
"- Use call_func to call Ruby methods listed below. Only call functions that are explicitly listed — do NOT guess or try to discover functions by calling methods like `methods`, `local_variables`, etc.",
|
|
305
|
+
"- Call done(result: ...) when the task is complete. ALWAYS put the answer in the result field — it is the return value of ~\"...\". If no <var> is referenced, the done result is the only way to return a value.",
|
|
306
|
+
"- When the user references <var>, that's a variable in scope.",
|
|
307
|
+
"- If a referenced variable doesn't exist yet, the user expects you to create it with write_var.",
|
|
308
|
+
"- Be precise with types: use numbers for numeric values, arrays for lists, strings for text.",
|
|
309
|
+
"- Respond in the same language as the user's prompt unless explicitly told otherwise.",
|
|
310
|
+
"- PRIORITY: The user's current prompt ALWAYS overrides any prior context, conversation history, or long-term memories. Treat it like Ruby's inner scope shadowing outer scope."
|
|
311
|
+
]
|
|
312
|
+
|
|
313
|
+
if @incognito
|
|
314
|
+
parts << ""
|
|
315
|
+
parts << "You are in incognito mode. The remember tool is disabled. No memories will be loaded or saved."
|
|
316
|
+
else
|
|
317
|
+
memory = Memory.current
|
|
318
|
+
# Inject memory context when available
|
|
319
|
+
if memory
|
|
320
|
+
# Add compaction summaries from prior conversations
|
|
321
|
+
unless memory.summaries.empty?
|
|
322
|
+
parts << ""
|
|
323
|
+
parts << "Previous conversation summary:"
|
|
324
|
+
memory.summaries.each { |s| parts << " #{s}" }
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Add persistent long-term facts
|
|
328
|
+
unless memory.long_term.empty?
|
|
329
|
+
parts << ""
|
|
330
|
+
parts << "Long-term memories (persistent background context):"
|
|
331
|
+
memory.long_term.each { |m| parts << "- #{m[:content]}" }
|
|
332
|
+
parts << "NOTE: Long-term memories are background defaults. The user's current prompt ALWAYS takes priority. If the prompt conflicts with a memory (e.g. memory says Japanese but prompt says Chinese), follow the prompt."
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
unless memory.long_term.empty?
|
|
336
|
+
parts << ""
|
|
337
|
+
parts << "You have a `remember` tool to store new facts in long-term memory when the user asks."
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Inject current variable values referenced in the prompt
|
|
343
|
+
unless context.empty?
|
|
344
|
+
parts << ""
|
|
345
|
+
parts << "Current variable values:"
|
|
346
|
+
context.each { |k, v| parts << " #{k} = #{v}" }
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Discover available functions from two sources:
|
|
350
|
+
# 1. AST scan of the caller's source file (gets parameter signatures)
|
|
351
|
+
# 2. Receiver's methods minus Ruby builtins (catches require'd functions)
|
|
352
|
+
file_methods = begin
|
|
353
|
+
Mana::Introspect.methods_from_file(@caller_path)
|
|
354
|
+
rescue => _e
|
|
355
|
+
[]
|
|
356
|
+
end
|
|
357
|
+
file_method_names = file_methods.map { |m| m[:name] }
|
|
358
|
+
|
|
359
|
+
# Methods on the receiver not from Object/Kernel (user-defined or require'd)
|
|
360
|
+
receiver = @binding.receiver
|
|
361
|
+
receiver_methods = (receiver.methods - Object.methods - Kernel.methods - [:~@, :mana])
|
|
362
|
+
.select { |m| receiver.method(m).owner != Object && receiver.method(m).owner != Kernel }
|
|
363
|
+
.reject { |m| file_method_names.include?(m.to_s) } # avoid duplicates with AST scan
|
|
364
|
+
.map { |m|
|
|
365
|
+
meth = receiver.method(m)
|
|
366
|
+
params = meth.parameters.map { |(type, name)|
|
|
367
|
+
case type
|
|
368
|
+
when :req then name.to_s
|
|
369
|
+
when :opt then "#{name}=..."
|
|
370
|
+
when :rest then "*#{name}"
|
|
371
|
+
when :keyreq then "#{name}:"
|
|
372
|
+
when :key then "#{name}: ..."
|
|
373
|
+
when :keyrest then "**#{name}"
|
|
374
|
+
when :block then "&#{name}"
|
|
375
|
+
else name.to_s
|
|
376
|
+
end
|
|
377
|
+
}
|
|
378
|
+
{ name: m.to_s, params: params }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
all_methods = file_methods + receiver_methods
|
|
382
|
+
# Append available function signatures so the LLM knows what it can call
|
|
383
|
+
unless all_methods.empty?
|
|
384
|
+
parts << ""
|
|
385
|
+
parts << Mana::Introspect.format_for_prompt(all_methods)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
parts.join("\n")
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# --- Effect Handling ---
|
|
392
|
+
|
|
393
|
+
# Dispatch a single tool call from the LLM.
|
|
394
|
+
# Handle built-in tool calls from the LLM.
|
|
395
|
+
def handle_effect(tool_use, memory = nil)
|
|
396
|
+
name = tool_use[:name]
|
|
397
|
+
input = tool_use[:input] || {}
|
|
398
|
+
# Normalize keys to strings for consistent access
|
|
399
|
+
input = input.transform_keys(&:to_s) if input.is_a?(Hash)
|
|
400
|
+
|
|
401
|
+
case name
|
|
402
|
+
when "read_var"
|
|
403
|
+
# Read a variable from the caller's binding and return its serialized value
|
|
404
|
+
val = serialize_value(resolve(input["name"]))
|
|
405
|
+
vlog_value(" ↩ #{input['name']} =", val)
|
|
406
|
+
val
|
|
407
|
+
|
|
408
|
+
when "write_var"
|
|
409
|
+
# Write a value to the caller's binding and track it for the return value
|
|
410
|
+
var_name = input["name"]
|
|
411
|
+
value = input["value"]
|
|
412
|
+
write_local(var_name, value)
|
|
413
|
+
@written_vars[var_name] = value
|
|
414
|
+
vlog_value(" ✅ #{var_name} =", value)
|
|
415
|
+
"ok: #{var_name} = #{value.inspect}"
|
|
416
|
+
|
|
417
|
+
when "read_attr"
|
|
418
|
+
# Read an attribute (public method) from a Ruby object in scope
|
|
419
|
+
obj = resolve(input["obj"])
|
|
420
|
+
validate_name!(input["attr"])
|
|
421
|
+
serialize_value(obj.public_send(input["attr"]))
|
|
422
|
+
|
|
423
|
+
when "write_attr"
|
|
424
|
+
# Set an attribute (public setter) on a Ruby object in scope
|
|
425
|
+
obj = resolve(input["obj"])
|
|
426
|
+
validate_name!(input["attr"])
|
|
427
|
+
obj.public_send("#{input['attr']}=", input["value"])
|
|
428
|
+
"ok: #{input['obj']}.#{input['attr']} = #{input['value'].inspect}"
|
|
429
|
+
|
|
430
|
+
when "call_func"
|
|
431
|
+
func = input["name"]
|
|
432
|
+
args = input["args"] || []
|
|
433
|
+
kwargs = (input["kwargs"] || {}).transform_keys(&:to_sym)
|
|
434
|
+
policy = @config.security
|
|
435
|
+
|
|
436
|
+
# Handle chained calls (e.g. Time.now, Time.now.to_s, File.read)
|
|
437
|
+
if func.include?(".")
|
|
438
|
+
# Split into receiver constant and method chain for security check
|
|
439
|
+
first_dot = func.index(".")
|
|
440
|
+
receiver_name = func[0...first_dot]
|
|
441
|
+
rest = func[(first_dot + 1)..]
|
|
442
|
+
methods_chain = rest.split(".")
|
|
443
|
+
first_method = methods_chain.first
|
|
444
|
+
|
|
445
|
+
# Enforce security policy on the receiver+method pair
|
|
446
|
+
if policy.receiver_call_blocked?(receiver_name, first_method)
|
|
447
|
+
raise NameError, "'#{receiver_name}.#{first_method}' is blocked by security policy (level #{policy.level}: #{policy.preset})"
|
|
448
|
+
end
|
|
449
|
+
if policy.method_blocked?(first_method)
|
|
450
|
+
raise NameError, "'#{first_method}' is blocked by security policy"
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Validate receiver is a simple constant name (e.g. "Time", "File", "Math")
|
|
454
|
+
# NOT an expression like "ENV['HOME']" which could bypass security policy
|
|
455
|
+
unless receiver_name.match?(/\A[A-Z][A-Za-z0-9_]*(::[A-Z][A-Za-z0-9_]*)*\z/)
|
|
456
|
+
raise NameError, "'#{receiver_name}' is not a valid constant name"
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
begin
|
|
460
|
+
receiver = @binding.eval(receiver_name)
|
|
461
|
+
rescue => e
|
|
462
|
+
raise NameError, "cannot resolve '#{receiver_name}': #{e.message}"
|
|
463
|
+
end
|
|
464
|
+
result = receiver.public_send(first_method.to_sym, *args)
|
|
465
|
+
|
|
466
|
+
# Chain remaining methods without args (e.g. .to_s, .strftime)
|
|
467
|
+
methods_chain[1..].each do |m|
|
|
468
|
+
result = result.public_send(m.to_sym)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
vlog_value(" ↩ #{func}(#{args.map(&:inspect).join(', ')}) →", result)
|
|
472
|
+
return serialize_value(result)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Simple (non-chained) function call
|
|
476
|
+
validate_name!(func)
|
|
477
|
+
if policy.method_blocked?(func)
|
|
478
|
+
raise NameError, "'#{func}' is blocked by security policy (level #{policy.level}: #{policy.preset})"
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Try local variable (lambdas/procs) first, then receiver methods
|
|
482
|
+
callable = if @binding.local_variables.include?(func.to_sym)
|
|
483
|
+
# Local lambda/proc takes priority
|
|
484
|
+
@binding.local_variable_get(func.to_sym)
|
|
485
|
+
elsif @binding.receiver.respond_to?(func.to_sym, true)
|
|
486
|
+
# Fall back to method defined on the receiver (self)
|
|
487
|
+
@binding.receiver.method(func.to_sym)
|
|
488
|
+
else
|
|
489
|
+
raise NameError, "undefined function '#{func}'"
|
|
490
|
+
end
|
|
491
|
+
result = kwargs.empty? ? callable.call(*args) : callable.call(*args, **kwargs)
|
|
492
|
+
call_desc = args.map(&:inspect).concat(kwargs.map { |k, v| "#{k}: #{v.inspect}" }).join(", ")
|
|
493
|
+
vlog_value(" ↩ #{func}(#{call_desc}) →", result)
|
|
494
|
+
serialize_value(result)
|
|
495
|
+
|
|
496
|
+
when "remember"
|
|
497
|
+
# Store a fact in long-term memory (persistent across executions)
|
|
498
|
+
if @incognito
|
|
499
|
+
"Memory not saved (incognito mode)"
|
|
500
|
+
elsif memory
|
|
501
|
+
entry = memory.remember(input["content"])
|
|
502
|
+
"Remembered (id=#{entry[:id]}): #{input['content']}"
|
|
503
|
+
else
|
|
504
|
+
"Memory not available"
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
when "done"
|
|
508
|
+
# Signal task completion; the result becomes the return value
|
|
509
|
+
done_val = input["result"]
|
|
510
|
+
vlog_value("🏁 Done:", done_val)
|
|
511
|
+
vlog("═" * 60)
|
|
512
|
+
input["result"].to_s
|
|
513
|
+
|
|
514
|
+
else
|
|
515
|
+
"error: unknown tool #{name}"
|
|
45
516
|
end
|
|
517
|
+
rescue => e
|
|
518
|
+
# Return errors as strings so the LLM can see and react to them
|
|
519
|
+
"error: #{e.class}: #{e.message}"
|
|
46
520
|
end
|
|
47
521
|
|
|
48
|
-
#
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
522
|
+
# --- Binding Helpers ---
|
|
523
|
+
|
|
524
|
+
VALID_IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
525
|
+
|
|
526
|
+
# Ensure a name is a valid Ruby identifier (prevents injection)
|
|
527
|
+
def validate_name!(name)
|
|
528
|
+
raise Mana::Error, "invalid identifier: #{name.inspect}" unless name.match?(VALID_IDENTIFIER)
|
|
53
529
|
end
|
|
54
530
|
|
|
55
|
-
|
|
56
|
-
|
|
531
|
+
# Resolve a name to a value: try local variable first, then receiver method
|
|
532
|
+
def resolve(name)
|
|
533
|
+
validate_name!(name)
|
|
534
|
+
if @binding.local_variable_defined?(name.to_sym)
|
|
535
|
+
# Found as a local variable in the caller's binding
|
|
536
|
+
@binding.local_variable_get(name.to_sym)
|
|
537
|
+
elsif @binding.receiver.respond_to?(name.to_sym)
|
|
538
|
+
# Found as a public method on the caller's self
|
|
539
|
+
@binding.receiver.public_send(name.to_sym)
|
|
540
|
+
else
|
|
541
|
+
raise NameError, "undefined variable or method '#{name}'"
|
|
542
|
+
end
|
|
57
543
|
end
|
|
58
544
|
|
|
59
|
-
|
|
545
|
+
# Write a value into the caller's binding, with Ruby 4.0+ singleton method fallback.
|
|
546
|
+
# Only defines a singleton method when the variable doesn't already exist as a local
|
|
547
|
+
# AND the receiver doesn't already have a real method with that name.
|
|
548
|
+
def write_local(name, value)
|
|
549
|
+
validate_name!(name)
|
|
550
|
+
sym = name.to_sym
|
|
60
551
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
552
|
+
# Check if the variable already exists before setting
|
|
553
|
+
existed = @binding.local_variable_defined?(sym)
|
|
554
|
+
@binding.local_variable_set(sym, value)
|
|
555
|
+
|
|
556
|
+
# Ruby 4.0+: local_variable_set can no longer create new locals visible
|
|
557
|
+
# in the caller's scope. Define a singleton method ONLY for new variables
|
|
558
|
+
# that don't conflict with existing methods on the receiver.
|
|
559
|
+
unless existed
|
|
560
|
+
receiver = @binding.eval("self")
|
|
561
|
+
# Don't overwrite real instance methods — only add if no method exists
|
|
562
|
+
unless receiver.class.method_defined?(sym) || receiver.class.private_method_defined?(sym)
|
|
563
|
+
old_verbose, $VERBOSE = $VERBOSE, nil
|
|
564
|
+
receiver.define_singleton_method(sym) { value }
|
|
565
|
+
$VERBOSE = old_verbose
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Find the user's source file by walking up the call stack.
|
|
571
|
+
# Used for introspecting available methods in the caller's code.
|
|
572
|
+
def caller_source_path
|
|
573
|
+
# Try binding's source_location first (most direct)
|
|
574
|
+
loc = @binding.source_location
|
|
575
|
+
return loc[0] if loc.is_a?(Array)
|
|
576
|
+
|
|
577
|
+
# Fallback: scan caller_locations, skip frames inside the mana gem itself
|
|
578
|
+
caller_locations(4, 20)&.each do |frame|
|
|
579
|
+
path = frame.absolute_path || frame.path
|
|
580
|
+
next if path.nil? || path.include?("mana/")
|
|
581
|
+
return path
|
|
582
|
+
end
|
|
583
|
+
nil
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# Serialize a Ruby value to a string representation the LLM can understand.
|
|
587
|
+
# Handles primitives, collections, and arbitrary objects (via ivar inspection).
|
|
588
|
+
def serialize_value(val)
|
|
589
|
+
case val
|
|
590
|
+
when Time
|
|
591
|
+
# Format Time as a human-readable timestamp string
|
|
592
|
+
val.strftime("%Y-%m-%d %H:%M:%S %z").inspect
|
|
593
|
+
when String, Integer, Float, TrueClass, FalseClass, NilClass
|
|
594
|
+
# Primitives: use Ruby's built-in inspect
|
|
595
|
+
val.inspect
|
|
596
|
+
when Symbol
|
|
597
|
+
# Convert symbol to string for LLM readability
|
|
598
|
+
val.to_s.inspect
|
|
599
|
+
when Array
|
|
600
|
+
# Recursively serialize each element
|
|
601
|
+
"[#{val.map { |v| serialize_value(v) }.join(', ')}]"
|
|
602
|
+
when Hash
|
|
603
|
+
# Recursively serialize key-value pairs
|
|
604
|
+
pairs = val.map { |k, v| "#{serialize_value(k)} => #{serialize_value(v)}" }
|
|
605
|
+
"{#{pairs.join(', ')}}"
|
|
64
606
|
else
|
|
65
|
-
|
|
607
|
+
# Arbitrary object: show class name and instance variables
|
|
608
|
+
ivars = val.instance_variables
|
|
609
|
+
obj_repr = ivars.map do |ivar|
|
|
610
|
+
attr_name = ivar.to_s.delete_prefix("@")
|
|
611
|
+
"#{attr_name}: #{val.instance_variable_get(ivar).inspect}" rescue nil
|
|
612
|
+
end.compact.join(", ")
|
|
613
|
+
"#<#{val.class} #{obj_repr}>"
|
|
66
614
|
end
|
|
67
615
|
end
|
|
68
616
|
|
|
69
|
-
|
|
70
|
-
|
|
617
|
+
# --- LLM Client ---
|
|
618
|
+
|
|
619
|
+
# Send a request to the LLM backend and log the response
|
|
620
|
+
def llm_call(system, messages)
|
|
621
|
+
vlog("\n#{"─" * 60}")
|
|
622
|
+
vlog("🔄 LLM call ##{@_iteration} → #{@config.model}")
|
|
623
|
+
backend = Backends::Base.for(@config)
|
|
624
|
+
result = backend.chat(
|
|
625
|
+
system: system,
|
|
626
|
+
messages: messages,
|
|
627
|
+
tools: self.class.all_tools,
|
|
628
|
+
model: @config.model,
|
|
629
|
+
max_tokens: 4096
|
|
630
|
+
)
|
|
631
|
+
result.each do |block|
|
|
632
|
+
type = block[:type] || block["type"]
|
|
633
|
+
case type
|
|
634
|
+
when "text"
|
|
635
|
+
text = block[:text] || block["text"]
|
|
636
|
+
vlog("💬 #{text}") if text
|
|
637
|
+
when "tool_use"
|
|
638
|
+
name = block[:name] || block["name"]
|
|
639
|
+
input = block[:input] || block["input"]
|
|
640
|
+
vlog("🔧 #{name}(#{summarize_input(input)})")
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
result
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
include Mana::Logger
|
|
647
|
+
|
|
648
|
+
# Extract tool_use blocks from the LLM response content array
|
|
649
|
+
def extract_tool_uses(content)
|
|
650
|
+
return [] unless content.is_a?(Array)
|
|
651
|
+
|
|
652
|
+
content
|
|
653
|
+
.select { |block| (block[:type] || block["type"]) == "tool_use" }
|
|
654
|
+
.map { |block|
|
|
655
|
+
{
|
|
656
|
+
id: block[:id] || block["id"],
|
|
657
|
+
name: block[:name] || block["name"],
|
|
658
|
+
input: block[:input] || block["input"] || {}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
71
661
|
end
|
|
72
662
|
end
|
|
73
663
|
end
|