ruby-mana 0.4.0 → 0.5.1

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.
@@ -0,0 +1,467 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mana
6
+ module Engines
7
+ class LLM < Base
8
+ # LLM is a reasoning engine, not an execution engine.
9
+ # It understands natural language and uses tool-calling to interact with
10
+ # Ruby variables, but it cannot hold remote object references, maintain
11
+ # state, or be called back from other engines.
12
+ def execution_engine?
13
+ false
14
+ end
15
+
16
+ TOOLS = [
17
+ {
18
+ name: "read_var",
19
+ description: "Read a variable value from the Ruby scope.",
20
+ input_schema: {
21
+ type: "object",
22
+ properties: { name: { type: "string", description: "Variable name" } },
23
+ required: ["name"]
24
+ }
25
+ },
26
+ {
27
+ name: "write_var",
28
+ description: "Write a value to a variable in the Ruby scope. Creates the variable if it doesn't exist.",
29
+ input_schema: {
30
+ type: "object",
31
+ properties: {
32
+ name: { type: "string", description: "Variable name" },
33
+ value: { description: "Value to assign (any JSON type)" }
34
+ },
35
+ required: %w[name value]
36
+ }
37
+ },
38
+ {
39
+ name: "read_attr",
40
+ description: "Read an attribute from a Ruby object.",
41
+ input_schema: {
42
+ type: "object",
43
+ properties: {
44
+ obj: { type: "string", description: "Variable name holding the object" },
45
+ attr: { type: "string", description: "Attribute name to read" }
46
+ },
47
+ required: %w[obj attr]
48
+ }
49
+ },
50
+ {
51
+ name: "write_attr",
52
+ description: "Set an attribute on a Ruby object.",
53
+ input_schema: {
54
+ type: "object",
55
+ properties: {
56
+ obj: { type: "string", description: "Variable name holding the object" },
57
+ attr: { type: "string", description: "Attribute name to set" },
58
+ value: { description: "Value to assign" }
59
+ },
60
+ required: %w[obj attr value]
61
+ }
62
+ },
63
+ {
64
+ name: "call_func",
65
+ description: "Call a Ruby method/function available in the current scope.",
66
+ input_schema: {
67
+ type: "object",
68
+ properties: {
69
+ name: { type: "string", description: "Function/method name" },
70
+ args: { type: "array", description: "Arguments to pass", items: {} }
71
+ },
72
+ required: ["name"]
73
+ }
74
+ },
75
+ {
76
+ name: "done",
77
+ description: "Signal that the task is complete.",
78
+ input_schema: {
79
+ type: "object",
80
+ properties: {
81
+ result: { description: "Optional return value" }
82
+ }
83
+ }
84
+ }
85
+ ].freeze
86
+
87
+ REMEMBER_TOOL = {
88
+ name: "remember",
89
+ description: "Store a fact in long-term memory. This memory persists across script executions. Use when the user explicitly asks to remember something.",
90
+ input_schema: {
91
+ type: "object",
92
+ properties: { content: { type: "string", description: "The fact to remember" } },
93
+ required: ["content"]
94
+ }
95
+ }.freeze
96
+
97
+ class << self
98
+ def handler_stack
99
+ Thread.current[:mana_handlers] ||= []
100
+ end
101
+
102
+ def with_handler(handler = nil, **opts, &block)
103
+ handler_stack.push(handler)
104
+ block.call
105
+ ensure
106
+ handler_stack.pop
107
+ end
108
+
109
+ # Built-in tools + remember + any registered custom effects
110
+ def all_tools
111
+ tools = TOOLS.dup
112
+ tools << REMEMBER_TOOL unless Memory.incognito?
113
+ tools + Mana::EffectRegistry.tool_definitions
114
+ end
115
+ end
116
+
117
+ def initialize(caller_binding, config = Mana.config)
118
+ super
119
+ @caller_path = caller_source_path
120
+ @incognito = Memory.incognito?
121
+ end
122
+
123
+ def execute(prompt)
124
+ Thread.current[:mana_depth] ||= 0
125
+ Thread.current[:mana_depth] += 1
126
+ nested = Thread.current[:mana_depth] > 1
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
136
+ end
137
+
138
+ context = build_context(prompt)
139
+ system_prompt = build_system_prompt(context)
140
+
141
+ # Use memory's short_term messages (auto per-thread), or fresh if incognito
142
+ memory = @incognito ? nil : Memory.current
143
+ memory&.wait_for_compaction
144
+
145
+ messages = memory ? memory.short_term : []
146
+ messages << { role: "user", content: prompt }
147
+
148
+ iterations = 0
149
+ done_result = nil
150
+
151
+ loop do
152
+ iterations += 1
153
+ raise MaxIterationsError, "exceeded #{@config.max_iterations} iterations" if iterations > @config.max_iterations
154
+
155
+ response = llm_call(system_prompt, messages)
156
+ tool_uses = extract_tool_uses(response)
157
+
158
+ break if tool_uses.empty?
159
+
160
+ # Append assistant message
161
+ messages << { role: "assistant", content: response }
162
+
163
+ # Process each tool use
164
+ tool_results = tool_uses.map do |tu|
165
+ result = handle_effect(tu, memory)
166
+ done_result = (tu[:input][:result] || tu[:input]["result"]) if tu[:name] == "done"
167
+ { type: "tool_result", tool_use_id: tu[:id], content: result.to_s }
168
+ end
169
+
170
+ messages << { role: "user", content: tool_results }
171
+
172
+ break if tool_uses.any? { |t| t[:name] == "done" }
173
+ end
174
+
175
+ # Append a final assistant summary so LLM has full context next call
176
+ if memory && done_result
177
+ messages << { role: "assistant", content: [{ type: "text", text: "Done: #{done_result}" }] }
178
+ end
179
+
180
+ # Schedule compaction if needed (runs in background, skip for nested)
181
+ memory&.schedule_compaction unless nested
182
+
183
+ done_result
184
+ ensure
185
+ if nested && !@incognito
186
+ Thread.current[:mana_memory] = outer_memory
187
+ end
188
+ Thread.current[:mana_depth] -= 1 if Thread.current[:mana_depth]
189
+ end
190
+
191
+ # Mock handling — public so Engine dispatcher can call it
192
+ def handle_mock(prompt)
193
+ mock = Mana.current_mock
194
+ stub = mock.match(prompt)
195
+
196
+ unless stub
197
+ truncated = prompt.length > 60 ? "#{prompt[0..57]}..." : prompt
198
+ raise MockError, "No mock matched: \"#{truncated}\"\n Add: mock_prompt \"#{truncated}\", _return: \"...\""
199
+ end
200
+
201
+ values = if stub.block
202
+ stub.block.call(prompt)
203
+ else
204
+ stub.values.dup
205
+ end
206
+
207
+ return_value = values.delete(:_return)
208
+
209
+ values.each do |name, value|
210
+ write_local(name.to_s, value)
211
+ end
212
+
213
+ # Record in short-term memory if not incognito
214
+ if !@incognito
215
+ memory = Memory.current
216
+ if memory
217
+ memory.short_term << { role: "user", content: prompt }
218
+ memory.short_term << { role: "assistant", content: [{ type: "text", text: "Done: #{return_value || values.inspect}" }] }
219
+ end
220
+ end
221
+
222
+ return_value || values.values.first
223
+ end
224
+
225
+ private
226
+
227
+ # --- Context Building ---
228
+
229
+ def build_context(prompt)
230
+ var_names = prompt.scan(/<(\w+)>/).flatten.uniq
231
+ ctx = {}
232
+ var_names.each do |name|
233
+ val = resolve(name)
234
+ ctx[name] = serialize_value(val)
235
+ rescue NameError
236
+ # Variable doesn't exist yet — will be created by LLM
237
+ end
238
+ ctx
239
+ end
240
+
241
+ def build_system_prompt(context)
242
+ parts = [
243
+ "You are embedded inside a Ruby program. You interact with the program's live state using the provided tools.",
244
+ "",
245
+ "Rules:",
246
+ "- Use read_var / read_attr to inspect variables and objects.",
247
+ "- Use write_var to create or update variables in the Ruby scope.",
248
+ "- Use write_attr to set attributes on Ruby objects.",
249
+ "- Use call_func to call Ruby methods available in scope.",
250
+ "- Call done when the task is complete.",
251
+ "- When the user references <var>, that's a variable in scope.",
252
+ "- If a referenced variable doesn't exist yet, the user expects you to create it with write_var.",
253
+ "- Be precise with types: use numbers for numeric values, arrays for lists, strings for text."
254
+ ]
255
+
256
+ # Inject long-term memories or incognito notice
257
+ if @incognito
258
+ parts << ""
259
+ parts << "You are in incognito mode. The remember tool is disabled. No memories will be loaded or saved."
260
+ else
261
+ memory = Memory.current
262
+ if memory
263
+ # Inject summaries from compaction
264
+ unless memory.summaries.empty?
265
+ parts << ""
266
+ parts << "Previous conversation summary:"
267
+ memory.summaries.each { |s| parts << " #{s}" }
268
+ end
269
+
270
+ unless memory.long_term.empty?
271
+ parts << ""
272
+ parts << "Long-term memories (persistent across executions):"
273
+ memory.long_term.each { |m| parts << "- #{m[:content]}" }
274
+ end
275
+
276
+ unless memory.long_term.empty?
277
+ parts << ""
278
+ parts << "You have a `remember` tool to store new facts in long-term memory when the user asks."
279
+ end
280
+ end
281
+ end
282
+
283
+ unless context.empty?
284
+ parts << ""
285
+ parts << "Current variable values:"
286
+ context.each { |k, v| parts << " #{k} = #{v}" }
287
+ end
288
+
289
+ # Discover available functions from caller's source
290
+ methods = begin
291
+ Mana::Introspect.methods_from_file(@caller_path)
292
+ rescue => _e
293
+ []
294
+ end
295
+ unless methods.empty?
296
+ parts << ""
297
+ parts << Mana::Introspect.format_for_prompt(methods)
298
+ end
299
+
300
+ # List custom effects
301
+ custom_effects = Mana::EffectRegistry.tool_definitions
302
+ unless custom_effects.empty?
303
+ parts << ""
304
+ parts << "Custom tools available:"
305
+ custom_effects.each do |t|
306
+ params = (t[:input_schema][:properties] || {}).keys.join(", ")
307
+ parts << " #{t[:name]}(#{params}) — #{t[:description]}"
308
+ end
309
+ end
310
+
311
+ parts.join("\n")
312
+ end
313
+
314
+ # --- Effect Handling ---
315
+
316
+ def handle_effect(tool_use, memory = nil)
317
+ name = tool_use[:name]
318
+ input = tool_use[:input] || {}
319
+ # Normalize keys to strings for consistent access
320
+ input = input.transform_keys(&:to_s) if input.is_a?(Hash)
321
+
322
+ # Check handler stack first (legacy)
323
+ handler = self.class.handler_stack.last
324
+ return handler.call(name, input) if handler && handler.respond_to?(:call)
325
+
326
+ # Check custom effect registry
327
+ handled, result = Mana::EffectRegistry.handle(name, input)
328
+ return serialize_value(result) if handled
329
+
330
+ case name
331
+ when "read_var"
332
+ serialize_value(resolve(input["name"]))
333
+
334
+ when "write_var"
335
+ var_name = input["name"]
336
+ value = input["value"]
337
+ write_local(var_name, value)
338
+ "ok: #{var_name} = #{value.inspect}"
339
+
340
+ when "read_attr"
341
+ obj = resolve(input["obj"])
342
+ validate_name!(input["attr"])
343
+ serialize_value(obj.public_send(input["attr"]))
344
+
345
+ when "write_attr"
346
+ obj = resolve(input["obj"])
347
+ validate_name!(input["attr"])
348
+ obj.public_send("#{input['attr']}=", input["value"])
349
+ "ok: #{input['obj']}.#{input['attr']} = #{input['value'].inspect}"
350
+
351
+ when "call_func"
352
+ func = input["name"]
353
+ validate_name!(func)
354
+ args = input["args"] || []
355
+ # Try method first, then local variable (supports lambdas/procs)
356
+ callable = if @binding.receiver.respond_to?(func.to_sym, true)
357
+ @binding.receiver.method(func.to_sym)
358
+ elsif @binding.local_variables.include?(func.to_sym)
359
+ @binding.local_variable_get(func.to_sym)
360
+ else
361
+ @binding.receiver.method(func.to_sym) # raise NameError
362
+ end
363
+ result = callable.call(*args)
364
+ serialize_value(result)
365
+
366
+ when "remember"
367
+ if @incognito
368
+ "Memory not saved (incognito mode)"
369
+ elsif memory
370
+ entry = memory.remember(input["content"])
371
+ "Remembered (id=#{entry[:id]}): #{input['content']}"
372
+ else
373
+ "Memory not available"
374
+ end
375
+
376
+ when "done"
377
+ input["result"].to_s
378
+
379
+ else
380
+ "error: unknown tool #{name}"
381
+ end
382
+ rescue => e
383
+ "error: #{e.class}: #{e.message}"
384
+ end
385
+
386
+ # --- Binding Helpers ---
387
+
388
+ VALID_IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/
389
+
390
+ def validate_name!(name)
391
+ raise Mana::Error, "invalid identifier: #{name.inspect}" unless name.match?(VALID_IDENTIFIER)
392
+ end
393
+
394
+ def resolve(name)
395
+ validate_name!(name)
396
+ if @binding.local_variable_defined?(name.to_sym)
397
+ @binding.local_variable_get(name.to_sym)
398
+ elsif @binding.receiver.respond_to?(name.to_sym, true)
399
+ @binding.receiver.send(name.to_sym)
400
+ else
401
+ raise NameError, "undefined variable or method '#{name}'"
402
+ end
403
+ end
404
+
405
+ def write_local(name, value)
406
+ validate_name!(name)
407
+ @binding.local_variable_set(name.to_sym, value)
408
+ end
409
+
410
+ def caller_source_path
411
+ # Walk up the call stack to find the first non-mana source file
412
+ loc = @binding.source_location
413
+ return loc[0] if loc.is_a?(Array)
414
+
415
+ # Fallback: search caller_locations
416
+ caller_locations(4, 20)&.each do |frame|
417
+ path = frame.absolute_path || frame.path
418
+ next if path.nil? || path.include?("mana/")
419
+ return path
420
+ end
421
+ nil
422
+ end
423
+
424
+ def serialize_value(val)
425
+ case val
426
+ when String, Integer, Float, TrueClass, FalseClass, NilClass
427
+ val.inspect
428
+ when Symbol
429
+ val.to_s.inspect
430
+ when Array
431
+ "[#{val.map { |v| serialize_value(v) }.join(', ')}]"
432
+ when Hash
433
+ pairs = val.map { |k, v| "#{serialize_value(k)} => #{serialize_value(v)}" }
434
+ "{#{pairs.join(', ')}}"
435
+ else
436
+ ivars = val.instance_variables
437
+ obj_repr = ivars.map do |ivar|
438
+ attr_name = ivar.to_s.delete_prefix("@")
439
+ "#{attr_name}: #{val.instance_variable_get(ivar).inspect}" rescue nil
440
+ end.compact.join(", ")
441
+ "#<#{val.class} #{obj_repr}>"
442
+ end
443
+ end
444
+
445
+ # --- LLM Client ---
446
+
447
+ def llm_call(system, messages)
448
+ backend = Backends.for(@config)
449
+ backend.chat(
450
+ system: system,
451
+ messages: messages,
452
+ tools: self.class.all_tools,
453
+ model: @config.model,
454
+ max_tokens: 4096
455
+ )
456
+ end
457
+
458
+ def extract_tool_uses(content)
459
+ return [] unless content.is_a?(Array)
460
+
461
+ content
462
+ .select { |block| block[:type] == "tool_use" }
463
+ .map { |block| { id: block[:id], name: block[:name], input: block[:input] || {} } }
464
+ end
465
+ end
466
+ end
467
+ end