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.
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 in the Ruby scope. Creates the variable if it doesn't exist.",
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 available in the current scope. Use kwargs for keyword arguments.",
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
- def execute(prompt)
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
- # Ensure messages don't end with an unpaired tool_use (causes API 400 error)
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
- # Model returned text without calling any tools.
178
- # On the first iteration with no writes yet, nudge it to use tools.
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 (read_var, write_var, done) to complete this task. Do not just describe the answer in text." }
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
- # Otherwise, accept the text-only response and exit the loop
185
- break
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
- # --- 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}"
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
- # 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
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
- # 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
551
-
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
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
- # 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}>"
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