Class: Legate::Planner
- Inherits:
-
Object
- Object
- Legate::Planner
- Defined in:
- lib/legate/planner.rb
Overview
Orchestrates the planning process using an LLM.
The Planner takes a user request and available tools, constructs a prompt, sends it through an LLM adapter (Gemini by default; any Legate::LLM::Adapter), and parses the response into a structured plan of execution. It handles multi-step planning, tool selection, and fallback strategies.
Constant Summary collapse
- PLAN_SCHEMA =
Structured-output schema for the multi-step plan (Gemini responseSchema). Tool params come back as a JSON string (‘tool_input_json`) because the provider schema can’t express per-tool free-form params; the parser normalizes it to ‘tool_input`.
{ type: 'OBJECT', properties: { thought_process: { type: 'STRING' }, plan: { type: 'ARRAY', items: { type: 'OBJECT', properties: { step: { type: 'INTEGER' }, type: { type: 'STRING' }, tool_name: { type: 'STRING' }, tool_input_json: { type: 'STRING', description: 'The tool parameters as a JSON object string, e.g. {"message":"hi"}' }, reason: { type: 'STRING' } }, required: %w[step type tool_name tool_input_json reason] } } }, required: %w[thought_process plan] }.freeze
Instance Attribute Summary collapse
-
#agent ⇒ Legate::Agent
readonly
The agent instance this planner belongs to.
-
#logger ⇒ Logger
readonly
The logger instance.
-
#model_name ⇒ String?
readonly
The model name being used.
Instance Method Summary collapse
-
#initialize(agent:, model_name: nil, **options) ⇒ Planner
constructor
Initializes a new Planner instance.
-
#plan(user_input, invocation_id = nil) ⇒ Hash
Generates a multi-step execution plan for the given user input.
-
#planning_failure_plan(message) ⇒ Object
A “plan” that carries no steps, only a terminal result.
-
#reason_next_action(user_input, observations = [], invocation_id = nil) ⇒ Legate::Agentic::Decision
Asks the LLM for the SINGLE next action given the request and the observations gathered so far.
-
#summarize_final(user_input, observations = [], invocation_id = nil) ⇒ String?
Best-effort final answer from the observations gathered so far, used when the agentic loop stops without the model having produced a ‘final` action (iteration cap or loop-breaker).
Constructor Details
#initialize(agent:, model_name: nil, **options) ⇒ Planner
Initializes a new Planner instance.
59 60 61 62 63 64 65 66 67 68 69 70 71 |
# File 'lib/legate/planner.rb', line 59 def initialize(agent:, model_name: nil, **) @agent = agent @logger = [:logger] || Legate.logger # Determine model to use: passed param > agent default > hardcoded default (fallback) @configured_model_name = model_name && !model_name.empty? ? model_name : Legate::Agent::DEFAULT_MODEL @adapter = [:llm_adapter] || Legate::LLM.build_adapter( model: @configured_model_name, api_key: [:api_key], logger: @logger ) @model_name = @adapter.model_name end |
Instance Attribute Details
#agent ⇒ Legate::Agent (readonly)
Returns The agent instance this planner belongs to.
45 46 47 |
# File 'lib/legate/planner.rb', line 45 def agent @agent end |
#logger ⇒ Logger (readonly)
Returns The logger instance.
47 48 49 |
# File 'lib/legate/planner.rb', line 47 def logger @logger end |
#model_name ⇒ String? (readonly)
Returns The model name being used.
49 50 51 |
# File 'lib/legate/planner.rb', line 49 def model_name @model_name end |
Instance Method Details
#plan(user_input, invocation_id = nil) ⇒ Hash
Generates a multi-step execution plan for the given user input.
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 |
# File 'lib/legate/planner.rb', line 85 def plan(user_input, invocation_id = nil) # Check if the LLM adapter is available, fallback if not unless @adapter.available? logger.warn() return planning_failure_plan('Planning failed: no LLM adapter is available. ' \ 'Set GOOGLE_API_KEY or configure Legate::LLM.default_adapter_factory.') end # Format tools for the prompt tools_description = format_tools_for_prompt # When the adapter supports it, constrain the plan JSON with a response # schema (guaranteed-valid JSON) and ask for params as a JSON string. structured = @adapter.respond_to?(:supports_structured_output?) && @adapter.supports_structured_output? # Build and send the planning prompt to the LLM prompt = build_multi_step_gemini_prompt(user_input, tools_description, structured: structured) modified_prompt = apply_before_model_callback(prompt, invocation_id) begin raw_response_text = @adapter.generate(modified_prompt, json: true, schema: structured ? PLAN_SCHEMA : nil) unless raw_response_text logger.warn('LLM response was empty or unparseable.') return planning_failure_plan('Planning failed: the LLM returned an empty response.') end # Execute after_model_callback if defined modified_response = raw_response_text if @agent.after_model_callback && invocation_id # Create callback context if not already created callback_context ||= Legate::Callbacks::CallbackContext.new( agent_name: @agent.name, invocation_id: invocation_id, session_id: nil, user_id: nil, app_name: nil, session_service: nil ) # Call the callback and get modified response if returned logger.debug { "Agent '#{@agent.name}': Executing after_model_callback for model output." } callback_result = begin @agent.after_model_callback.call(modified_response, callback_context) rescue StandardError => e logger.error("Error in after_model_callback: #{e.class}: #{e.}") logger.debug(e.backtrace.join("\n")) nil # Continue execution on error end # If the callback returned a string, use it as the modified response if callback_result.is_a?(String) modified_response = callback_result logger.debug { "Agent '#{@agent.name}': Response modified by after_model_callback." } end end # Extract and validate the plan validated_result = validate_and_format_multi_step_plan(modified_response) # Couldn't parse a structured plan — return the model's best-effort text # as a clean error result rather than depending on the echo tool. if validated_result[:error] logger.warn("Plan validation failed: #{validated_result[:error]}. Returning planning-error result.") = (modified_response, user_input) return planning_failure_plan() end # Return the formatted plan steps { thought_process: validated_result[:thought_process], steps: validated_result[:formatted_steps] } rescue StandardError => e logger.error("Error during planning: #{e.class}: #{e.}") planning_failure_plan("I encountered an error while processing your request: #{e.}") end end |
#planning_failure_plan(message) ⇒ Object
A “plan” that carries no steps, only a terminal result. The executor returns the direct_result as-is, so a planning failure always surfaces a clean error Event (with a real message) instead of an empty plan / a dependency on echo.
167 168 169 |
# File 'lib/legate/planner.rb', line 167 def planning_failure_plan() { thought_process: 'Planning failed', direct_result: { status: :error, error_message: } } end |
#reason_next_action(user_input, observations = [], invocation_id = nil) ⇒ Legate::Agentic::Decision
Asks the LLM for the SINGLE next action given the request and the observations gathered so far. Used by the agentic (:react) loop, which runs the chosen tool, feeds the result back, and calls this again. Unlike #plan (one upfront plan), this lets the model react to tool results.
179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 |
# File 'lib/legate/planner.rb', line 179 def reason_next_action(user_input, observations = [], invocation_id = nil) unless @adapter.available? logger.warn() return Legate::Agentic::Decision.final(answer: 'No LLM client available to reason about the next step.') end if @adapter.respond_to?(:supports_function_calling?) && @adapter.supports_function_calling? reason_with_function_calling(user_input, observations, invocation_id) else reason_with_json_prompt(user_input, observations, invocation_id) end rescue StandardError => e logger.error("Error during agentic reasoning: #{e.class}: #{e.}") Legate::Agentic::Decision.final(answer: "I encountered an error while reasoning: #{e.}") end |
#summarize_final(user_input, observations = [], invocation_id = nil) ⇒ String?
Best-effort final answer from the observations gathered so far, used when the agentic loop stops without the model having produced a ‘final` action (iteration cap or loop-breaker). One extra LLM call, plain text (not JSON).
199 200 201 202 203 204 205 206 207 208 209 |
# File 'lib/legate/planner.rb', line 199 def summarize_final(user_input, observations = [], invocation_id = nil) return nil unless @adapter.available? prompt = build_summary_prompt(user_input, observations) prompt = apply_before_model_callback(prompt, invocation_id) answer = @adapter.generate(prompt, json: false) answer&.strip rescue StandardError => e logger.error("Error during agentic summary: #{e.class}: #{e.}") nil end |