Class: AgentLoop

Inherits:
Object
  • Object
show all
Defined in:
lib/agent_loop.rb

Overview

Note:

Not thread-safe. Callers must serialize concurrent access (e.g. AgentRequestJob uses session-level processing locks).

Orchestrates the LLM agent loop: accepts user input, runs the tool-use cycle via LLM::Client, and emits events through Events::Bus.

Extracted from TUI::Screens::Chat so the same agent logic can run from the TUI, a background job, or an Action Cable channel.

Examples:

Basic usage

loop = AgentLoop.new(session: session)
loop.run
loop.finalize

With dependency injection (testing)

loop = AgentLoop.new(session: session, client: mock_client, registry: mock_registry)
loop.run

Constant Summary collapse

STANDARD_TOOLS =

Tool classes available to all sessions by default.

Returns:

[Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think, Tools::Remember, Tools::Recall].freeze
ALWAYS_GRANTED_TOOLS =

Tools that bypass Session#granted_tools filtering. The agent’s reasoning depends on these regardless of task scope.

Returns:

[Tools::Think].freeze
STANDARD_TOOLS_BY_NAME =

Name-to-class mapping for tool restriction validation and registry building.

Returns:

STANDARD_TOOLS.index_by(&:tool_name).freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(session:, shell_session: nil, client: nil, registry: nil) ⇒ AgentLoop

Returns a new instance of AgentLoop.

Parameters:

  • session (Session)

    the conversation session

  • shell_session (ShellSession, nil) (defaults to: nil)

    injectable persistent shell; created automatically if not provided

  • client (LLM::Client, nil) (defaults to: nil)

    injectable LLM client; created lazily on first #run call if not provided

  • registry (Tools::Registry, nil) (defaults to: nil)

    injectable tool registry; built lazily on first #run call if not provided



33
34
35
36
37
38
39
# File 'lib/agent_loop.rb', line 33

def initialize(session:, shell_session: nil, client: nil, registry: nil)
  @session = session
  @shell_session = shell_session || ShellSession.new(session_id: session.id)
  restore_initial_cwd
  @client = client
  @registry = registry
end

Instance Attribute Details

#sessionSession (readonly)

Returns the conversation session this loop operates on.

Returns:

  • (Session)

    the conversation session this loop operates on



24
25
26
# File 'lib/agent_loop.rb', line 24

def session
  @session
end

Instance Method Details

#deliver!void

This method returns an undefined value.

Makes the first LLM API call to verify delivery. Called inside the Bounce Back transaction — if this raises, the user event rolls back.

Caches the first response so the subsequent #run call can continue from it without duplicating the API call.

Raises:



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# File 'lib/agent_loop.rb', line 49

def deliver!
  @client ||= build_client
  @registry ||= build_tool_registry

  messages = @session.messages_for_llm
  options = build_llm_options

  @first_response = @client.provider.create_message(
    model: @client.model,
    messages: messages,
    max_tokens: @client.max_tokens,
    tools: @registry.schemas,
    include_metrics: true,
    **options
  )
end

#finalizeObject

Clean up the underlying ShellSession PTY and resources. Safe to call multiple times — subsequent calls are no-ops.



107
108
109
# File 'lib/agent_loop.rb', line 107

def finalize
  @shell_session&.finalize
end

#runString?

Runs the LLM tool-use loop on persisted session messages.

When a cached first response exists (from #deliver!), continues from that response without a redundant API call. Otherwise makes a fresh call — used for pending message processing and the standard path.

Lets errors propagate — designed for callers like AgentRequestJob that handle retries and need errors to bubble up.

Returns:

  • (String, nil)

    the agent’s response text, or nil when interrupted

Raises:



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
# File 'lib/agent_loop.rb', line 79

def run
  @client ||= build_client
  @registry ||= build_tool_registry

  messages = @session.messages_for_llm
  options = build_llm_options

  first_resp = @first_response
  @first_response = nil

  between_rounds = -> { @session.promote_pending_messages! }

  result = @client.chat_with_tools(
    messages, registry: @registry, session_id: @session.id,
    first_response: first_resp, between_rounds: between_rounds, **options
  )
  return unless result

  Events::Bus.emit(Events::AgentMessage.new(
    content: result[:text],
    session_id: @session.id,
    api_metrics: result[:api_metrics]
  ))
  result[:text]
end