Class: AgentLoop

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

Overview

Note:

Not thread-safe. Callers must serialize concurrent calls to #process (e.g. TUI uses a loading flag, future callers should use session-level 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.process("What files are in the current directory?")
loop.finalize

With dependency injection (testing)

loop = AgentLoop.new(session: session, client: mock_client, registry: mock_registry)
loop.process("hello")

Background job usage (retry-safe)

loop = AgentLoop.new(session: session)
loop.run  # processes persisted session messages without emitting UserMessage
loop.finalize

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 #process call if not provided

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

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



36
37
38
39
40
41
# File 'lib/agent_loop.rb', line 36

def initialize(session:, shell_session: nil, client: nil, registry: nil)
  @session = session
  @shell_session = shell_session || ShellSession.new(session_id: session.id)
  @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



27
28
29
# File 'lib/agent_loop.rb', line 27

def session
  @session
end

Instance Method Details

#finalizeObject

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



83
84
85
# File 'lib/agent_loop.rb', line 83

def finalize
  @shell_session&.finalize
end

#process(input) ⇒ String?

Runs the agent loop for a single user input.

Emits Events::UserMessage immediately, then delegates to #run. On error emits Events::AgentMessage with the error text.

Parameters:

  • input (String)

    raw user input

Returns:

  • (String, nil)

    the agent’s response text, or nil for blank input



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

def process(input)
  text = input.to_s.strip
  return if text.empty?

  Events::Bus.emit(Events::UserMessage.new(content: text, session_id: @session.id))
  run
rescue => error
  error_message = "#{error.class}: #{error.message}"
  Events::Bus.emit(Events::AgentMessage.new(content: error_message, session_id: @session.id))
  error_message
end

#runString

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

Unlike #process, does not emit Events::UserMessage and lets errors propagate — designed for callers like AgentRequestJob that handle retries and need errors to bubble up.

Returns:

  • (String)

    the agent’s response text

Raises:



71
72
73
74
75
76
77
78
79
# File 'lib/agent_loop.rb', line 71

def run
  @client ||= LLM::Client.new
  @registry ||= build_tool_registry

  messages = @session.messages_for_llm
  response = @client.chat_with_tools(messages, registry: @registry, session_id: @session.id)
  Events::Bus.emit(Events::AgentMessage.new(content: response, session_id: @session.id))
  response
end