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
data/lib/mana/engine.rb
CHANGED
|
@@ -8,6 +8,10 @@ module Mana
|
|
|
8
8
|
class Engine
|
|
9
9
|
attr_reader :config, :binding
|
|
10
10
|
|
|
11
|
+
include Mana::BindingHelpers
|
|
12
|
+
include Mana::PromptBuilder
|
|
13
|
+
include Mana::ToolHandler
|
|
14
|
+
|
|
11
15
|
TOOLS = [
|
|
12
16
|
{
|
|
13
17
|
name: "read_var",
|
|
@@ -20,7 +24,7 @@ module Mana
|
|
|
20
24
|
},
|
|
21
25
|
{
|
|
22
26
|
name: "write_var",
|
|
23
|
-
description: "Write a value to a variable
|
|
27
|
+
description: "Write a JSON-serializable value (string, number, boolean, array, hash, nil) to a variable. Cannot store lambdas, procs, or Ruby objects — use call_func with define_method for functions.",
|
|
24
28
|
input_schema: {
|
|
25
29
|
type: "object",
|
|
26
30
|
properties: {
|
|
@@ -57,13 +61,14 @@ module Mana
|
|
|
57
61
|
},
|
|
58
62
|
{
|
|
59
63
|
name: "call_func",
|
|
60
|
-
description: "Call a Ruby method/function
|
|
64
|
+
description: "Call a Ruby method/function. Use body to pass a block. To define new methods: call_func(name: 'define_method', args: ['method_name'], body: '|args| code').",
|
|
61
65
|
input_schema: {
|
|
62
66
|
type: "object",
|
|
63
67
|
properties: {
|
|
64
68
|
name: { type: "string", description: "Function/method name" },
|
|
65
69
|
args: { type: "array", description: "Positional arguments", items: {} },
|
|
66
|
-
kwargs: { type: "object", description: "Keyword arguments (e.g. {sql: '...', limit: 10})" }
|
|
70
|
+
kwargs: { type: "object", description: "Keyword arguments (e.g. {sql: '...', limit: 10})" },
|
|
71
|
+
body: { type: "string", description: "Ruby code block body, passed as &block. Use |params| syntax. Example: '|x| x * 2'" }
|
|
67
72
|
},
|
|
68
73
|
required: ["name"]
|
|
69
74
|
}
|
|
@@ -77,9 +82,43 @@ module Mana
|
|
|
77
82
|
result: { description: "The answer or result to return. Always provide this." }
|
|
78
83
|
}
|
|
79
84
|
}
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "error",
|
|
88
|
+
description: "Signal that the task cannot be completed. Call this when you encounter an unrecoverable problem. The message will be raised as an exception in the Ruby program.",
|
|
89
|
+
input_schema: {
|
|
90
|
+
type: "object",
|
|
91
|
+
properties: {
|
|
92
|
+
message: { type: "string", description: "Description of the error" }
|
|
93
|
+
},
|
|
94
|
+
required: ["message"]
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: "eval",
|
|
99
|
+
description: "Execute Ruby code directly in the caller's binding. Returns the result of the last expression. Use this for anything that's easier to express as Ruby code than as individual tool calls.",
|
|
100
|
+
input_schema: {
|
|
101
|
+
type: "object",
|
|
102
|
+
properties: {
|
|
103
|
+
code: { type: "string", description: "Ruby code to execute" }
|
|
104
|
+
},
|
|
105
|
+
required: ["code"]
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: "knowledge",
|
|
110
|
+
description: "Query the knowledge base. Covers ruby-mana internals, Ruby documentation (ri), and runtime introspection of classes/modules.",
|
|
111
|
+
input_schema: {
|
|
112
|
+
type: "object",
|
|
113
|
+
properties: {
|
|
114
|
+
topic: { type: "string", description: "Topic to look up. Examples: 'memory', 'tools', 'ruby', 'Array#map', 'Enumerable', 'Hash'" }
|
|
115
|
+
},
|
|
116
|
+
required: ["topic"]
|
|
117
|
+
}
|
|
80
118
|
}
|
|
81
119
|
].freeze
|
|
82
120
|
|
|
121
|
+
# Separated from TOOLS because it's conditionally excluded in incognito mode
|
|
83
122
|
REMEMBER_TOOL = {
|
|
84
123
|
name: "remember",
|
|
85
124
|
description: "Store a fact in long-term memory. This memory persists across script executions. Use when the user explicitly asks to remember something.",
|
|
@@ -90,6 +129,7 @@ module Mana
|
|
|
90
129
|
}
|
|
91
130
|
}.freeze
|
|
92
131
|
|
|
132
|
+
|
|
93
133
|
class << self
|
|
94
134
|
# Entry point for ~"..." prompts. Routes to mock handler or real LLM engine.
|
|
95
135
|
def run(prompt, caller_binding)
|
|
@@ -101,12 +141,17 @@ module Mana
|
|
|
101
141
|
new(caller_binding).execute(prompt)
|
|
102
142
|
end
|
|
103
143
|
|
|
104
|
-
# Built-in tools + remember
|
|
144
|
+
# Built-in tools + remember (conditional)
|
|
105
145
|
def all_tools
|
|
106
146
|
tools = TOOLS.dup
|
|
107
147
|
tools << REMEMBER_TOOL unless Memory.incognito?
|
|
108
148
|
tools
|
|
109
149
|
end
|
|
150
|
+
|
|
151
|
+
# Query the runtime knowledge base
|
|
152
|
+
def knowledge(topic)
|
|
153
|
+
Mana::Knowledge.query(topic)
|
|
154
|
+
end
|
|
110
155
|
end
|
|
111
156
|
|
|
112
157
|
# Capture the caller's binding, config, source path, and incognito state
|
|
@@ -117,8 +162,9 @@ module Mana
|
|
|
117
162
|
@incognito = Memory.incognito?
|
|
118
163
|
end
|
|
119
164
|
|
|
120
|
-
# Main execution loop: build context, call LLM, handle tool calls, iterate until done
|
|
121
|
-
|
|
165
|
+
# Main execution loop: build context, call LLM, handle tool calls, iterate until done.
|
|
166
|
+
# Optional &on_text block receives streaming text deltas for real-time display.
|
|
167
|
+
def execute(prompt, &on_text)
|
|
122
168
|
# Track nesting depth to isolate memory for nested ~"..." calls
|
|
123
169
|
Thread.current[:mana_depth] ||= 0
|
|
124
170
|
Thread.current[:mana_depth] += 1
|
|
@@ -145,7 +191,9 @@ module Mana
|
|
|
145
191
|
|
|
146
192
|
messages = memory ? memory.short_term : []
|
|
147
193
|
|
|
148
|
-
#
|
|
194
|
+
# Strip trailing unpaired tool_use messages from prior calls.
|
|
195
|
+
# Both Anthropic and OpenAI reject requests where the last assistant message
|
|
196
|
+
# has tool_use blocks without corresponding tool_result responses.
|
|
149
197
|
while messages.last && messages.last[:role] == "assistant" &&
|
|
150
198
|
messages.last[:content].is_a?(Array) &&
|
|
151
199
|
messages.last[:content].any? { |b| (b[:type] || b["type"]) == "tool_use" }
|
|
@@ -170,19 +218,26 @@ module Mana
|
|
|
170
218
|
@_iteration = iterations
|
|
171
219
|
raise MaxIterationsError, "exceeded #{@config.max_iterations} iterations" if iterations > @config.max_iterations
|
|
172
220
|
|
|
173
|
-
response = llm_call(system_prompt, messages)
|
|
221
|
+
response = llm_call(system_prompt, messages, &on_text)
|
|
174
222
|
tool_uses = extract_tool_uses(response)
|
|
175
223
|
|
|
176
224
|
if tool_uses.empty?
|
|
177
|
-
#
|
|
178
|
-
|
|
225
|
+
# In streaming/chat mode, text-only responses are fine — just accept them
|
|
226
|
+
if on_text
|
|
227
|
+
messages << { role: "assistant", content: response }
|
|
228
|
+
text = response.is_a?(Array) ? response.filter_map { |b| b[:text] || b["text"] }.join("\n") : response.to_s
|
|
229
|
+
done_result = text unless text.empty?
|
|
230
|
+
break
|
|
231
|
+
end
|
|
232
|
+
# In script mode (~"..."), nudge the LLM to use tools
|
|
179
233
|
if iterations == 1 && @written_vars.empty?
|
|
180
234
|
messages << { role: "assistant", content: response }
|
|
181
|
-
messages << { role: "user", content: "You must use the provided tools
|
|
235
|
+
messages << { role: "user", content: "You must use the provided tools to complete this task. Do not just describe the answer in text." }
|
|
182
236
|
next
|
|
183
237
|
end
|
|
184
|
-
#
|
|
185
|
-
|
|
238
|
+
# LLM refused to use tools after nudge — extract text and raise
|
|
239
|
+
text = response.is_a?(Array) ? response.filter_map { |b| b[:text] || b["text"] }.join("\n") : response.to_s
|
|
240
|
+
raise Mana::LLMError, "LLM did not use tools: #{text.slice(0, 200)}"
|
|
186
241
|
end
|
|
187
242
|
|
|
188
243
|
# Append assistant message with tool_use blocks
|
|
@@ -190,7 +245,18 @@ module Mana
|
|
|
190
245
|
|
|
191
246
|
# Process each tool use and collect results
|
|
192
247
|
tool_results = tool_uses.map do |tu|
|
|
248
|
+
if on_text
|
|
249
|
+
case tu[:name]
|
|
250
|
+
when "done", "error"
|
|
251
|
+
# handled separately
|
|
252
|
+
else
|
|
253
|
+
on_text.call(:tool_start, tu[:name], tu[:input])
|
|
254
|
+
end
|
|
255
|
+
end
|
|
193
256
|
result = handle_effect(tu, memory)
|
|
257
|
+
if on_text && !%w[done error].include?(tu[:name])
|
|
258
|
+
on_text.call(:tool_end, tu[:name], result)
|
|
259
|
+
end
|
|
194
260
|
done_result = (tu[:input][:result] || tu[:input]["result"]) if tu[:name] == "done"
|
|
195
261
|
{ type: "tool_result", tool_use_id: tu[:id], content: result.to_s }
|
|
196
262
|
end
|
|
@@ -276,358 +342,35 @@ module Mana
|
|
|
276
342
|
|
|
277
343
|
private
|
|
278
344
|
|
|
279
|
-
# ---
|
|
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}"
|
|
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}"
|
|
520
|
-
end
|
|
521
|
-
|
|
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)
|
|
529
|
-
end
|
|
345
|
+
# --- LLM Client ---
|
|
530
346
|
|
|
531
|
-
#
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
|
543
|
-
end
|
|
347
|
+
# Send a request to the LLM backend and log the response.
|
|
348
|
+
# When &on_text is provided and the backend supports streaming, streams text deltas.
|
|
349
|
+
def llm_call(system, messages, &on_text)
|
|
350
|
+
vlog("\n#{"─" * 60}")
|
|
351
|
+
vlog("🔄 LLM call ##{@_iteration} → #{@config.model}")
|
|
352
|
+
backend = Backends::Base.for(@config)
|
|
544
353
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
|
354
|
+
result = if on_text && backend.respond_to?(:chat_stream)
|
|
355
|
+
backend.chat_stream(
|
|
356
|
+
system: system,
|
|
357
|
+
messages: messages,
|
|
358
|
+
tools: self.class.all_tools,
|
|
359
|
+
model: @config.model,
|
|
360
|
+
max_tokens: 4096
|
|
361
|
+
) do |event|
|
|
362
|
+
on_text.call(:text, event[:text]) if event[:type] == :text_delta
|
|
566
363
|
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(', ')}}"
|
|
606
364
|
else
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
365
|
+
backend.chat(
|
|
366
|
+
system: system,
|
|
367
|
+
messages: messages,
|
|
368
|
+
tools: self.class.all_tools,
|
|
369
|
+
model: @config.model,
|
|
370
|
+
max_tokens: 4096
|
|
371
|
+
)
|
|
614
372
|
end
|
|
615
|
-
end
|
|
616
|
-
|
|
617
|
-
# --- LLM Client ---
|
|
618
373
|
|
|
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
374
|
result.each do |block|
|
|
632
375
|
type = block[:type] || block["type"]
|
|
633
376
|
case type
|