Class: RubyPi::Agent::Core

Inherits:
Object
  • Object
show all
Includes:
EventEmitter
Defined in:
lib/ruby_pi/agent/core.rb

Overview

The main agent class. Wraps State, Loop, and EventEmitter into a cohesive interface for running agentic LLM interactions with tool use, streaming, and lifecycle hooks.

Examples:

Full lifecycle

agent = RubyPi::Agent::Core.new(
  system_prompt: "You are Olli, an AI assistant.",
  model: RubyPi::LLM.model(:gemini, "gemini-2.0-flash"),
  tools: registry,
  max_iterations: 10,
  before_tool_call: ->(tc) { puts "Calling #{tc.name}" },
  after_tool_call: ->(tc, r) { puts "Done: #{r.success?}" }
)

agent.on(:text_delta) { |d| stream.write(d[:content]) }
agent.on(:agent_end) { |_| stream.close }

result = agent.run("Create a LinkedIn post")
result = agent.continue("Make it shorter")

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from EventEmitter

#emit, #off, #on

Constructor Details

#initialize(system_prompt:, model:, tools: nil, messages: [], max_iterations: 10, transform_context: nil, before_tool_call: nil, after_tool_call: nil, compaction: nil, user_data: {}, config: nil, execution_mode: :parallel, tool_timeout: 30) ⇒ Core

Creates a new Agent instance.

Parameters:

  • system_prompt (String)

    the system-level instruction prompt

  • model (RubyPi::LLM::BaseProvider)

    the LLM provider instance

  • tools (RubyPi::Tools::Registry, nil) (defaults to: nil)

    tool registry

  • messages (Array<Hash>) (defaults to: [])

    initial conversation history

  • max_iterations (Integer) (defaults to: 10)

    max think-act-observe cycles (default: 10)

  • transform_context (Proc, nil) (defaults to: nil)

    context transform hook

  • before_tool_call (Proc, nil) (defaults to: nil)

    pre-tool-execution hook

  • after_tool_call (Proc, nil) (defaults to: nil)

    post-tool-execution hook

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

    compaction strategy

  • user_data (Hash) (defaults to: {})

    arbitrary data bag for transforms/extensions

  • config (RubyPi::Configuration, nil) (defaults to: nil)

    informational handle to the per-agent configuration. Stored for inspection by transforms and extensions but does NOT override the model's provider config — the model is already constructed by the time it reaches the agent. To use a per-agent config for actual API calls, pass it to the model factory:

    RubyPi::LLM.model(:openai, "gpt-4o", config: custom_config)
  • 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)



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

def initialize(
  system_prompt:,
  model:,
  tools: nil,
  messages: [],
  max_iterations: 10,
  transform_context: nil,
  before_tool_call: nil,
  after_tool_call: nil,
  compaction: nil,
  user_data: {},
  config: nil,
  execution_mode: :parallel,
  tool_timeout: 30
)
  valid_timeout = tool_timeout.is_a?(Numeric) && !tool_timeout.is_a?(Complex) &&
                  tool_timeout.finite? && tool_timeout.positive?
  unless valid_timeout
    raise ArgumentError, "tool_timeout must be a finite positive number, got #{tool_timeout.inspect}"
  end

  @state = State.new(
    system_prompt: system_prompt,
    model: model,
    tools: tools,
    messages: messages,
    max_iterations: max_iterations,
    transform_context: transform_context,
    before_tool_call: before_tool_call,
    after_tool_call: after_tool_call,
    user_data: user_data
  )
  @compaction = compaction
  @extensions = []
  @config = config
  @execution_mode = execution_mode
  @tool_timeout = tool_timeout
end

Instance Attribute Details

#configRubyPi::Configuration? (readonly)

Returns per-agent configuration handle exposed for inspection only — see #config= caveats below. The actual provider config is resolved at model construction time:

custom = RubyPi::Configuration.new
custom.openai_api_key = "sk-..."
model = RubyPi::LLM.model(:openai, "gpt-4o", config: custom)
agent = RubyPi::Agent.new(model: model, config: custom, ...)

Passing config: to Agent.new does NOT retroactively change the model's behavior — it is informational, intended for inspection by transforms and extensions. To override provider config you must pass config: to the model factory as shown above.

Returns:

  • (RubyPi::Configuration, nil)

    per-agent configuration handle exposed for inspection only — see #config= caveats below. The actual provider config is resolved at model construction time:

    custom = RubyPi::Configuration.new
    custom.openai_api_key = "sk-..."
    model = RubyPi::LLM.model(:openai, "gpt-4o", config: custom)
    agent = RubyPi::Agent.new(model: model, config: custom, ...)
    

    Passing config: to Agent.new does NOT retroactively change the model's behavior — it is informational, intended for inspection by transforms and extensions. To override provider config you must pass config: to the model factory as shown above.



64
65
66
# File 'lib/ruby_pi/agent/core.rb', line 64

def config
  @config
end

#extensionsArray<Class> (readonly)

Returns registered extension classes for introspection.

Returns:

  • (Array<Class>)

    registered extension classes for introspection



49
50
51
# File 'lib/ruby_pi/agent/core.rb', line 49

def extensions
  @extensions
end

#stateRubyPi::Agent::State (readonly)

Returns the agent's mutable state.

Returns:



46
47
48
# File 'lib/ruby_pi/agent/core.rb', line 46

def state
  @state
end

Instance Method Details

#continue(prompt) ⇒ RubyPi::Agent::Result

Continues the conversation with a follow-up user message. Preserves the existing conversation history and appends the new prompt before resuming the loop.

NOTE on Result accounting: each run/continue builds a fresh Loop, so the returned Result's usage, tool_calls_made, and turns cover ONLY this invocation — while messages is cumulative across the whole conversation. Sum the per-call Results if you need session totals.

Issue #16: Uses the encapsulated reset_iteration! method instead of the old approach that bypassed encapsulation and was fragile.

Parameters:

  • prompt (String)

    the follow-up user message

Returns:

  • (RubyPi::Agent::Result)

    the outcome of the continued run (usage/tool_calls_made/turns are per-invocation; messages cumulative)



161
162
163
164
165
# File 'lib/ruby_pi/agent/core.rb', line 161

def continue(prompt)
  @state.reset_iteration!
  @state.add_message(role: :user, content: prompt)
  execute_loop
end

#effective_configRubyPi::Configuration

Returns the effective configuration for this agent. If a per-agent config was provided, returns that; otherwise falls back to the global RubyPi.configuration.

Returns:



196
197
198
# File 'lib/ruby_pi/agent/core.rb', line 196

def effective_config
  @config || RubyPi.configuration
end

#run(prompt) ⇒ RubyPi::Agent::Result

Runs the agent with an initial user prompt. Adds the prompt to the conversation history, executes the think-act-observe loop, emits :agent_end when done, and returns the result.

Issue #16: Resets the iteration counter at the start of each run() call using the encapsulated reset_iteration! method. Previously, the counter was never reset on run(), so a second call to run() on the same agent instance could immediately trip max_iterations_reached?.

Parameters:

  • prompt (String)

    the user's initial message

Returns:



139
140
141
142
143
# File 'lib/ruby_pi/agent/core.rb', line 139

def run(prompt)
  @state.reset_iteration!
  @state.add_message(role: :user, content: prompt)
  execute_loop
end

#use(extension_class) ⇒ void

This method returns an undefined value.

Registers an extension with this agent. The extension's hooks are automatically subscribed to the agent's events.

Parameters:

  • extension_class (Class)

    a subclass of RubyPi::Extensions::Base

Raises:

  • (ArgumentError)

    if the argument is not a valid extension class



173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/ruby_pi/agent/core.rb', line 173

def use(extension_class)
  unless extension_class.respond_to?(:hooks)
    raise ArgumentError,
          "Expected an extension class with a .hooks method, got #{extension_class.inspect}"
  end

  # Subscribe each hook to the corresponding event
  extension_class.hooks.each do |event, handlers|
    handlers.each do |handler|
      on(event) do |data|
        handler.call(data, self)
      end
    end
  end

  @extensions << extension_class
end