Class: Legate::Planner

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

Constructor Details

#initialize(agent:, model_name: nil, **options) ⇒ Planner

Initializes a new Planner instance.

Parameters:

  • agent (Legate::Agent)

    The agent that owns this planner.

  • model_name (String, nil) (defaults to: nil)

    The model to use (overrides the agent default).

  • options (Hash)

    Additional options.

Options Hash (**options):

  • :logger (Logger)

    Logger instance to use (defaults to Legate.logger).

  • :api_key (String)

    API key for the default Gemini adapter (defaults to ENV).

  • :llm_adapter (Legate::LLM::Adapter)

    An explicit LLM adapter to use instead of the default Gemini one.



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, **options)
  @agent = agent
  @logger = options[: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 = options[:llm_adapter] || Legate::LLM.build_adapter(
    model: @configured_model_name,
    api_key: options[:api_key],
    logger: @logger
  )
  @model_name = @adapter.model_name
end

Instance Attribute Details

#agentLegate::Agent (readonly)

Returns The agent instance this planner belongs to.

Returns:

  • (Legate::Agent)

    The agent instance this planner belongs to.



45
46
47
# File 'lib/legate/planner.rb', line 45

def agent
  @agent
end

#loggerLogger (readonly)

Returns The logger instance.

Returns:

  • (Logger)

    The logger instance.



47
48
49
# File 'lib/legate/planner.rb', line 47

def logger
  @logger
end

#model_nameString? (readonly)

Returns The model name being used.

Returns:

  • (String, nil)

    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.

Parameters:

  • user_input (String)

    The user’s request or task description.

  • invocation_id (String, nil) (defaults to: nil)

    The unique ID for this invocation (used for callbacks).

Returns:

  • (Hash)

    A hash containing the thought process and the list of steps.

    • :thought_process [String] The LLM’s reasoning.

    • :steps [Array<Hash>] The sequence of tool execution steps. Each step hash contains:

      • :tool [Symbol] The name of the tool to execute.

      • :params [Hash] The parameters for the tool.

      • :reason [String] The reason for this step.

    Returns a fallback plan structure on error.



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(llm_unavailable_message)
    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.message}")
        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.")
      fallback_message = extract_fallback_message(modified_response, user_input)
      return planning_failure_plan(fallback_message)
    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.message}")
    planning_failure_plan("I encountered an error while processing your request: #{e.message}")
  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(message)
  { thought_process: 'Planning failed', direct_result: { status: :error, error_message: 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.

Parameters:

  • user_input (String)

    the original user request

  • observations (Array<Hash>) (defaults to: [])
    { tool:, params:, result: } …

    so far

  • invocation_id (String, nil) (defaults to: nil)

Returns:



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(llm_unavailable_message)
    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.message}")
  Legate::Agentic::Decision.final(answer: "I encountered an error while reasoning: #{e.message}")
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).

Returns:

  • (String, nil)

    the summary, or nil if unavailable / on error



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.message}")
  nil
end