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.
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
- # Mock mode check (before anything else)
8
- if Mana.mock_active?
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
- # Detect language engine
13
- engine_class = detect_engine(prompt)
14
- Thread.current[:mana_last_engine] = engine_name(engine_class)
100
+ # Normal mode: execute via the LLM engine
101
+ new(caller_binding).execute(prompt)
102
+ end
15
103
 
16
- # Create engine and execute
17
- engine = engine_class.new(caller_binding)
18
- engine.execute(prompt)
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
- def detect_engine(code)
22
- Engines.detect(code, context: Thread.current[:mana_last_engine])
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
- def engine_name(klass)
26
- case klass.name
27
- when /JavaScript/ then "javascript"
28
- when /Python/ then "python"
29
- when /Ruby/ then "ruby"
30
- else "natural_language"
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
- # Delegate to LLM engine for backward compatibility
35
- def handler_stack
36
- Engines::LLM.handler_stack
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
- def with_handler(handler = nil, **opts, &block)
40
- Engines::LLM.with_handler(handler, **opts, &block)
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
- def all_tools
44
- Engines::LLM.all_tools
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
- # Backward compatibility: Engine.new(prompt, binding) delegates to Engines::LLM
49
- # Some tests instantiate Engine directly and call private methods
50
- def initialize(prompt, caller_binding)
51
- @delegate = Engines::LLM.new(caller_binding)
52
- @prompt = prompt
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
- def execute
56
- @delegate.execute(@prompt)
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
- private
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
- def method_missing(method, *args, **kwargs, &block)
62
- if @delegate.respond_to?(method, true)
63
- @delegate.send(method, *args, **kwargs, &block)
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
- super
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
- def respond_to_missing?(method, include_private = false)
70
- @delegate.respond_to?(method, include_private) || super
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