Class: Mana::Engine
- Inherits:
-
Object
- Object
- Mana::Engine
- Includes:
- BindingHelpers, Logger, PromptBuilder, ToolHandler
- Defined in:
- lib/mana/engine.rb
Overview
The Engine handles ~"..." prompts by calling an LLM with tool-calling to interact with Ruby variables in the caller's binding.
Constant Summary collapse
- TOOLS =
[ { name: "read_var", description: "Read a variable value from the Ruby scope.", input_schema: { type: "object", properties: { name: { type: "string", description: "Variable name" } }, required: ["name"] } }, { name: "write_var", 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.", input_schema: { type: "object", properties: { name: { type: "string", description: "Variable name" }, value: { description: "Value to assign (any JSON type)" } }, required: %w[name value] } }, { name: "read_attr", description: "Read an attribute from a Ruby object.", input_schema: { type: "object", properties: { obj: { type: "string", description: "Variable name holding the object" }, attr: { type: "string", description: "Attribute name to read" } }, required: %w[obj attr] } }, { name: "write_attr", description: "Set an attribute on a Ruby object.", input_schema: { type: "object", properties: { obj: { type: "string", description: "Variable name holding the object" }, attr: { type: "string", description: "Attribute name to set" }, value: { description: "Value to assign" } }, required: %w[obj attr value] } }, { name: "call_func", 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').", input_schema: { type: "object", properties: { name: { type: "string", description: "Function/method name" }, args: { type: "array", description: "Positional arguments", items: {} }, kwargs: { type: "object", description: "Keyword arguments (e.g. {sql: '...', limit: 10})" }, body: { type: "string", description: "Ruby code block body, passed as &block. Use |params| syntax. Example: '|x| x * 2'" } }, required: ["name"] } }, { name: "done", description: "Signal that the task is complete. Always include the result — this is the value returned to the Ruby program.", input_schema: { type: "object", properties: { result: { description: "The answer or result to return. Always provide this." } } } }, { name: "error", 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.", input_schema: { type: "object", properties: { message: { type: "string", description: "Description of the error" } }, required: ["message"] } }, { name: "eval", description: "Define new methods, classes, or require libraries — use this to create new things in the runtime. For reading/writing variables and calling existing functions, use the other tools.", input_schema: { type: "object", properties: { code: { type: "string", description: "Ruby code to execute" } }, required: ["code"] } }, { name: "knowledge", description: "Query the knowledge base. Covers ruby-mana internals, Ruby documentation (ri), and runtime introspection of classes/modules.", input_schema: { type: "object", properties: { topic: { type: "string", description: "Topic to look up. Examples: 'memory', 'tools', 'ruby', 'Array#map', 'Enumerable', 'Hash'" } }, required: ["topic"] } } ].freeze
Constants included from ToolHandler
Constants included from BindingHelpers
BindingHelpers::VALID_IDENTIFIER
Instance Attribute Summary collapse
-
#binding ⇒ Object
readonly
Returns the value of attribute binding.
-
#config ⇒ Object
readonly
Returns the value of attribute config.
-
#trace_data ⇒ Object
readonly
Returns the value of attribute trace_data.
Class Method Summary collapse
-
.all_tools ⇒ Object
Built-in tools + registered tools (e.g. remember from claw).
-
.knowledge(topic) ⇒ Object
Query the runtime knowledge base.
-
.run(prompt, caller_binding) ⇒ Object
Entry point for ~"..." prompts.
Instance Method Summary collapse
-
#execute(prompt, &on_text) ⇒ Object
Main execution loop: build context, call LLM, handle tool calls, iterate until done.
-
#handle_mock(prompt) ⇒ Object
Mock handling — finds a matching stub and writes its values into the caller's binding.
-
#initialize(caller_binding, config = Mana.config) ⇒ Engine
constructor
Capture the caller's binding, config, and source path.
Constructor Details
#initialize(caller_binding, config = Mana.config) ⇒ Engine
Capture the caller's binding, config, and source path
146 147 148 149 150 |
# File 'lib/mana/engine.rb', line 146 def initialize(caller_binding, config = Mana.config) @binding = caller_binding @config = config @caller_path = caller_source_path end |
Instance Attribute Details
#binding ⇒ Object (readonly)
Returns the value of attribute binding.
9 10 11 |
# File 'lib/mana/engine.rb', line 9 def binding @binding end |
#config ⇒ Object (readonly)
Returns the value of attribute config.
9 10 11 |
# File 'lib/mana/engine.rb', line 9 def config @config end |
#trace_data ⇒ Object (readonly)
Returns the value of attribute trace_data.
9 10 11 |
# File 'lib/mana/engine.rb', line 9 def trace_data @trace_data end |
Class Method Details
.all_tools ⇒ Object
Built-in tools + registered tools (e.g. remember from claw)
133 134 135 |
# File 'lib/mana/engine.rb', line 133 def all_tools TOOLS.dup + Mana.registered_tools end |
.knowledge(topic) ⇒ Object
Query the runtime knowledge base. Uses config.knowledge_provider if set, otherwise Mana::Knowledge.
139 140 141 142 |
# File 'lib/mana/engine.rb', line 139 def knowledge(topic) provider = Mana.config.knowledge_provider || Mana::Knowledge provider.query(topic) end |
.run(prompt, caller_binding) ⇒ Object
Entry point for ~"..." prompts. Routes to mock handler or real LLM engine.
123 124 125 126 127 128 129 130 |
# File 'lib/mana/engine.rb', line 123 def run(prompt, caller_binding) if Mana.current_mock return new(caller_binding).handle_mock(prompt) end # Normal mode: execute via the LLM engine new(caller_binding).execute(prompt) end |
Instance Method Details
#execute(prompt, &on_text) ⇒ Object
Main execution loop: build context, call LLM, handle tool calls, iterate until done. Optional &on_text block receives streaming text deltas for real-time display.
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 |
# File 'lib/mana/engine.rb', line 154 def execute(prompt, &on_text) # Track nesting depth to isolate context for nested ~"..." calls Thread.current[:mana_depth] ||= 0 Thread.current[:mana_depth] += 1 nested = Thread.current[:mana_depth] > 1 outer_context = nil # defined here so ensure block always has access # Nested calls get fresh short-term context if nested outer_context = Thread.current[:mana_context] Thread.current[:mana_context] = Mana::Context.new end # Extract <var> references from the prompt and read their current values context = build_context(prompt) system_prompt = build_system_prompt(context) memory = Context.current = memory. # Strip trailing unpaired tool_use messages from prior calls. # Both Anthropic and OpenAI reject requests where the last assistant message # has tool_use blocks without corresponding tool_result responses. while .last && .last[:role] == "assistant" && .last[:content].is_a?(Array) && .last[:content].any? { |b| (b[:type] || b["type"]) == "tool_use" } .pop end # Track where we started in messages — rollback on failure = .size << { role: "user", content: prompt } iterations = 0 done_result = nil @written_vars = {} # Track write_var calls for return value @_steps = [] # Trace data: per-iteration usage + timing + tool calls vlog("═" * 60) vlog("🚀 Prompt: #{prompt}") vlog("📡 Backend: #{@config.effective_base_url} / #{@config.model}") # --- Main tool-calling loop --- loop do iterations += 1 @_iteration = iterations raise MaxIterationsError, "exceeded #{@config.max_iterations} iterations" if iterations > @config.max_iterations response = llm_call(system_prompt, , &on_text) tool_uses = extract_tool_uses(response) if tool_uses.empty? # In streaming/chat mode, text-only responses are fine — just accept them if on_text << { role: "assistant", content: response } text = response.is_a?(Array) ? response.filter_map { |b| b[:text] || b["text"] }.join("\n") : response.to_s done_result = text unless text.empty? break end # In script mode (~"..."), nudge the LLM to use tools if iterations == 1 && @written_vars.empty? << { role: "assistant", content: response } << { role: "user", content: "You must use the provided tools to complete this task. Do not just describe the answer in text." } next end # LLM refused to use tools after nudge — extract text and raise text = response.is_a?(Array) ? response.filter_map { |b| b[:text] || b["text"] }.join("\n") : response.to_s raise Mana::LLMError, "LLM did not use tools: #{text.slice(0, 200)}" end # Append assistant message with tool_use blocks << { role: "assistant", content: response } # Process each tool use and collect results tool_results = tool_uses.map do |tu| if on_text case tu[:name] when "done", "error" # handled separately else on_text.call(:tool_start, tu[:name], tu[:input]) end end result = handle_effect(tu) if on_text && !%w[done error].include?(tu[:name]) on_text.call(:tool_end, tu[:name], result) end done_result = (tu[:input][:result] || tu[:input]["result"]) if tu[:name] == "done" { type: "tool_result", tool_use_id: tu[:id], content: result.to_s } end # Record tool calls in trace step if @_steps.last @_steps.last[:tool_calls] = tool_uses.zip(tool_results).map { |tu, tr| { name: tu[:name], input: tu[:input], result: tr[:content] } } end # Send tool results back to the LLM as a user message << { role: "user", content: tool_results } # Exit loop when the LLM signals completion via the "done" tool break if tool_uses.any? { |t| t[:name] == "done" } end # Append a final assistant summary so LLM has full context next call if done_result << { role: "assistant", content: [{ type: "text", text: "Done: #{done_result}" }] } end # Build trace data for external consumers (e.g. Claw::Trace) @trace_data = { prompt: prompt, model: @config.model, steps: @_steps, total_iterations: iterations, timestamp: Time.now.iso8601 } # Return written variables so Ruby 4.0+ users can capture them: # result = ~"compute average and store in <result>" # Single write -> return the value directly; multiple -> return Hash. if @written_vars.size == 1 @written_vars.values.first elsif @written_vars.size > 1 @written_vars.transform_keys(&:to_sym) else # No writes — return the done() result done_result end rescue => e # Rollback: remove messages added during this failed call so they don't # pollute short-term memory for subsequent prompts if .size > .slice!(..) end raise e ensure # Restore outer context when exiting a nested call if nested Thread.current[:mana_context] = outer_context end Thread.current[:mana_depth] -= 1 if Thread.current[:mana_depth] end |
#handle_mock(prompt) ⇒ Object
Mock handling — finds a matching stub and writes its values into the caller's binding.
299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 |
# File 'lib/mana/engine.rb', line 299 def handle_mock(prompt) mock = Mana.current_mock stub = mock.match(prompt) # No matching stub found — raise with a helpful hint unless stub truncated = prompt.length > 60 ? "#{prompt[0..57]}..." : prompt raise MockError, "No mock matched: \"#{truncated}\"\n Add: mock_prompt \"#{truncated}\", _return: \"...\"" end # Evaluate stub: block-based stubs receive the prompt, hash-based return a copy values = if stub.block stub.block.call(prompt) else stub.values.dup end # Extract the special _return key (the value returned to the caller) return_value = values.delete(:_return) # Write remaining key-value pairs as local variables in the caller's scope values.each do |name, value| write_local(name.to_s, value) end # Record in context memory = Context.current if memory memory. << { role: "user", content: prompt } memory. << { role: "assistant", content: [{ type: "text", text: "Done: #{return_value || values.inspect}" }] } end # Return _return value if set, otherwise the first written value return_value || values.values.first end |