Class: RubyPi::Agent::Loop

Inherits:
Object
  • Object
show all
Defined in:
lib/ruby_pi/agent/loop.rb

Overview

Executes the think-act-observe cycle against a given State, emitting events through the provided EventEmitter-compatible emitter. Returns an Agent::Result when the cycle terminates.

The cycle:

1. THINK — Call the LLM with current messages and tools. Apply
   transform_context if present. Emit :turn_start and stream
   :text_delta events.
2. ACT — If the LLM returned tool calls, execute them via
   Tools::Executor. Fire before_tool_call / after_tool_call hooks
   and emit :tool_execution_start / :tool_execution_end events.
3. OBSERVE — Append tool results to messages and loop back to THINK.
4. DONE — Return when finish_reason == "stop" (no more tool calls)
   or max_iterations is reached.

Examples:

Running the loop directly

loop = RubyPi::Agent::Loop.new(state: state, emitter: agent)
result = loop.run

Constant Summary collapse

PROGRAMMING_ERRORS =

Issue #18: Programming errors that should NOT be rescued by the loop. These indicate bugs in the calling code, not LLM/provider/tool failures. Rescuing them would silently swallow real bugs like typos or type mismatches.

[
  NoMethodError,
  NameError,
  ArgumentError,
  TypeError
].freeze

Instance Method Summary collapse

Constructor Details

#initialize(state:, emitter:, compaction: nil, execution_mode: :parallel, tool_timeout: 30) ⇒ Loop

Creates a new Loop bound to the given state and event emitter.

Parameters:

  • state (RubyPi::Agent::State)

    mutable agent state

  • emitter (#emit)

    object that responds to ‘emit(event, data)`

  • compaction (RubyPi::Context::Compaction, nil) (defaults to: nil)

    optional compaction strategy for managing context window size

  • execution_mode (Symbol) (defaults to: :parallel)

    tool execution mode (:parallel or :sequential, default: :parallel)

  • tool_timeout (Numeric) (defaults to: 30)

    per-tool execution timeout in seconds (default: 30)



54
55
56
57
58
59
60
61
62
# File 'lib/ruby_pi/agent/loop.rb', line 54

def initialize(state:, emitter:, compaction: nil, execution_mode: :parallel, tool_timeout: 30)
  @state = state
  @emitter = emitter
  @compaction = compaction
  @execution_mode = execution_mode
  @tool_timeout = tool_timeout
  @tool_calls_made = []
  @total_usage = { input_tokens: 0, output_tokens: 0 }
end

Instance Method Details

#runRubyPi::Agent::Result

Runs the think-act-observe cycle until completion or max iterations. Returns an Agent::Result capturing the final content, messages, tool calls, usage, and turn count.

Issue #18: Re-raises programming errors (NoMethodError, NameError, ArgumentError, TypeError) instead of swallowing them. Only LLM/provider/ tool errors are caught and wrapped in a failed Result.

Issue #19: When max_iterations is reached, the Result now includes stop_reason: :max_iterations and success? returns false. Previously, the Result had no error set, so success? returned true even though the agent was forcibly stopped.

Returns:



78
79
80
81
82
83
84
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
# File 'lib/ruby_pi/agent/loop.rb', line 78

def run
  loop do
    # Check iteration limit before starting a new turn
    if @state.max_iterations_reached?
      return build_result(
        content: last_assistant_content,
        stop_reason: :max_iterations
      )
    end

    # Apply context compaction if configured and needed
    compact_if_needed!

    # THINK: Call the LLM
    response = think

    # Track usage from this turn
    accumulate_usage(response.usage)

    # Increment iteration counter
    @state.increment_iteration!

    if response.tool_calls?
      # ACT: Execute tool calls
      act(response)

      # OBSERVE: Tool results have been added to messages; loop continues
      @emitter.emit(:turn_end, turn: @state.iteration, has_tool_calls: true)
    else
      # No tool calls — the LLM is done
      @emitter.emit(:turn_end, turn: @state.iteration, has_tool_calls: false)
      return build_result(content: response.content, stop_reason: :complete)
    end
  end
rescue *PROGRAMMING_ERRORS
  # Issue #18: Re-raise programming errors immediately. These are bugs
  # in the calling code (typos, wrong argument counts, type mismatches)
  # that should never be silently swallowed.
  raise
rescue StandardError => e
  @emitter.emit(:error, error: e, source: :agent_loop)
  Result.new(
    content: nil,
    messages: @state.messages,
    tool_calls_made: @tool_calls_made,
    usage: @total_usage,
    turns: @state.iteration,
    error: e,
    stop_reason: :error
  )
end