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)



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/ruby_pi/agent/loop.rb', line 56

def initialize(state:, emitter:, compaction: nil, execution_mode: :parallel, tool_timeout: 30)
  @state = state
  @emitter = emitter
  @compaction = compaction
  # Wire the loop's emitter into the compaction strategy so the
  # documented :compaction event actually reaches agent subscribers.
  # Compaction#emitter defaults to nil and nothing else ever sets it —
  # without this, `agent.on(:compaction)` never fires. An emitter that
  # was already assigned explicitly is left untouched.
  if @compaction.respond_to?(:emitter=) && @compaction.respond_to?(:emitter) && @compaction.emitter.nil?
    @compaction.emitter = emitter
  end
  @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:



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
# File 'lib/ruby_pi/agent/loop.rb', line 88

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