Class: Phronomy::Agent::Base

Inherits:
Object
  • Object
show all
Includes:
Concerns::BeforeCompletion, Concerns::ErrorTranslation, Concerns::Filterable, Concerns::Retryable, Runnable
Defined in:
lib/phronomy/agent/base.rb

Overview

Base class for all Phronomy agents.

Subclass this to create a conversational agent powered by an LLM. DSL class methods configure the model, instructions, tools, memory, and retry behaviour. Instance methods handle invocation.

Examples:

Minimal agent

class GreetingAgent < Phronomy::Agent::Base
  model "gpt-4o-mini"
  instructions "You are a friendly greeter."
end
result = GreetingAgent.new.invoke("Hello!")
puts result[:output]

Agent with tools

class ResearchAgent < Phronomy::Agent::Base
  model "gpt-4o"
  instructions "You are a research assistant."
  tools WebSearchTool, CalculatorTool
  max_iterations 15
end

Direct Known Subclasses

MultiAgent::Orchestrator

Instance Attribute Summary collapse

Attributes included from Concerns::BeforeCompletion

#before_completion

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Concerns::BeforeCompletion

included

Methods included from Concerns::Filterable

#add_input_filter, #add_output_filter, #add_tool_result_filter, included

Methods included from Concerns::Retryable

included

Methods included from Runnable

#batch, #trace

Instance Attribute Details

#scope_policy=(value) ⇒ void (writeonly)

This method returns an undefined value.

Registers a scope policy callable for this agent instance.

The callable receives (tool_class, scope, agent) and must return :allow, :reject, or :approve.

Parameters:

  • policy (#call)


439
440
441
# File 'lib/phronomy/agent/base.rb', line 439

def scope_policy=(value)
  @scope_policy = value
end

Class Method Details

.approve(session_id, approved: true, config: {}) ⇒ Hash

Continues a suspended invocation identified by session_id.

Instantiates a fresh agent and delegates to the instance-level #approve. When approved: false, the agent rejects the pending tool call and ends the invocation.

Parameters:

  • session_id (String)

    the session_id from the suspended result hash

  • approved (Boolean) (defaults to: true)

    true to execute the pending tool; false to deny

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

    same runtime options as #invoke

Returns:

  • (Hash)

    same shape as #invoke — may contain suspended: true if another approval-required tool is encountered during continuation

Raises:

  • (ArgumentError)

    when no suspended session matches session_id



389
390
391
# File 'lib/phronomy/agent/base.rb', line 389

def approve(session_id, approved: true, config: {})
  new.approve(session_id, approved: approved, config: config)
end

.cache_instructions(enabled = nil) ⇒ Object

When enabled, attaches Anthropic prompt-cache markers to the system message so that the fixed instructions are served from cache on subsequent turns, reducing input-token costs.

Only has an effect when the agent also declares provider :anthropic. The cache_control field is provider-specific (the format differs between Anthropic direct, Bedrock, etc.), so the agent must explicitly declare its provider via the DSL rather than having it inferred from the model name.

Examples:

class MyAgent < Phronomy::Agent::Base
  provider :anthropic
  cache_instructions true
end


318
319
320
321
322
323
324
# File 'lib/phronomy/agent/base.rb', line 318

def cache_instructions(enabled = nil)
  if enabled.nil?
    @cache_instructions
  else
    @cache_instructions = enabled
  end
end

.context_overhead(val = nil) ⇒ Object

Tokens reserved for the system prompt + tool definitions overhead. Subtract this from the context window before computing the memory budget.

Examples:

class MyAgent < Phronomy::Agent::Base
  context_overhead 500
end


368
369
370
371
372
373
374
# File 'lib/phronomy/agent/base.rb', line 368

def context_overhead(val = nil)
  if val.nil?
    @context_overhead || 0
  else
    @context_overhead = val.to_i
  end
end

.context_window(val = nil) ⇒ Object

Overrides the context window size used for token budget calculations. When set, this value takes precedence over the RubyLLM model registry, which is useful for locally-hosted models (e.g. LM Studio) where the actually-loaded context length may differ from the catalogue value.

Examples:

class MyAgent < Phronomy::Agent::Base
  context_window 4096
end


352
353
354
355
356
357
358
# File 'lib/phronomy/agent/base.rb', line 352

def context_window(val = nil)
  if val.nil?
    @context_window
  else
    @context_window = val.to_i
  end
end

.instructions(text = nil) { ... } ⇒ String, ...

Sets or reads the system instructions for this agent. Accepts a String, a Context::Instruction::PromptTemplate, or a block (Proc). When used as a reader (no argument, no block), returns the stored value.

Examples:

String instructions

class MyAgent < Phronomy::Agent::Base
  instructions "You are a helpful assistant."
end

Block instructions

class MyAgent < Phronomy::Agent::Base
  instructions { |input| "Answer in #{input[:lang]}." }
end

Parameters:

Yields:

  • optionally provide instructions as a block

Returns:



75
76
77
78
79
80
81
82
# File 'lib/phronomy/agent/base.rb', line 75

def instructions(text = nil, &block)
  if text || block_given?
    @instructions = text || block
  else
    return @instructions if instance_variable_defined?(:@instructions)
    superclass.respond_to?(:instructions) ? superclass.instructions : nil
  end
end

.invoke_timeout(val = nil) ⇒ Numeric?

Sets or reads the per-invocation timeout (in seconds) for EventLoop-mode agent calls. When set, invoke raises TimeoutError if the agent does not finish within the given number of seconds.

Has no effect when EventLoop mode is disabled (direct invoke path). Defaults to nil (no timeout). Inherited by subclasses; the most-specific definition wins.

When the timeout fires, a Concurrency::CancellationScope is cancelled and its token is propagated to the FSM config so that in-flight LLM, tool, and RAG calls observe cancellation via their cancellation_token: keyword argument. Phronomy::TimeoutError is raised to the caller.

Examples:

class MyAgent < Phronomy::Agent::Base
  invoke_timeout 30
end

Parameters:

  • val (Numeric, nil) (defaults to: nil)

Returns:

  • (Numeric, nil)


237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/phronomy/agent/base.rb', line 237

def invoke_timeout(val = nil)
  if val.nil?
    return @invoke_timeout if defined?(@invoke_timeout)
    superclass.respond_to?(:invoke_timeout) ? superclass.invoke_timeout : nil
  else
    unless val.is_a?(Numeric) && val > 0
      raise ArgumentError,
        "invoke_timeout must be a positive number, got #{val.inspect}"
    end
    @invoke_timeout = val
  end
end

.max_iterations(val = nil) ⇒ Integer

Sets or reads the maximum number of LLM call cycles for ReAct agents. Each tool call and follow-up counts as one iteration. Defaults to 10.

Examples:

class MyAgent < Phronomy::Agent::Base
  max_iterations 5
end

Parameters:

  • val (Integer, nil) (defaults to: nil)

Returns:

  • (Integer)


182
183
184
185
186
187
188
# File 'lib/phronomy/agent/base.rb', line 182

def max_iterations(val = nil)
  if val
    @max_iterations = val
  else
    @max_iterations || 10
  end
end

.max_output_tokens(val = nil) ⇒ Object

Tokens to reserve for the model's output. When nil, the model's max_output_tokens from the registry is used.

Examples:

class MyAgent < Phronomy::Agent::Base
  max_output_tokens 4096
end


334
335
336
337
338
339
340
# File 'lib/phronomy/agent/base.rb', line 334

def max_output_tokens(val = nil)
  if val.nil?
    @max_output_tokens
  else
    @max_output_tokens = val.to_i
  end
end

.max_parallel_tools(val = nil) ⇒ Integer

Sets or reads the maximum number of tool calls executed concurrently when the LLM returns multiple tool calls in a single response (ParallelToolChat mode, active inside an AgentFSM IO thread).

Defaults to 10. Set to 1 to force sequential execution. Inherited by subclasses; the most-specific definition wins.

Examples:

class MyAgent < Phronomy::Agent::Base
  max_parallel_tools 4
end

Parameters:

  • val (Integer, nil) (defaults to: nil)

Returns:

  • (Integer)


204
205
206
207
208
209
210
211
212
213
214
215
# File 'lib/phronomy/agent/base.rb', line 204

def max_parallel_tools(val = nil)
  if val.nil?
    @max_parallel_tools ||
      (superclass.respond_to?(:max_parallel_tools) ? superclass.max_parallel_tools : 10)
  else
    unless val.is_a?(Integer) && val >= 1
      raise ArgumentError,
        "max_parallel_tools must be a positive Integer (>= 1), got #{val.inspect}"
    end
    @max_parallel_tools = val
  end
end

.model(name = nil) ⇒ String?

Sets or reads the LLM model identifier for this agent. When called without an argument, returns the stored model or the global default from Phronomy.configuration.

Examples:

class MyAgent < Phronomy::Agent::Base
  model "gpt-4o"
end

Parameters:

  • name (String, nil) (defaults to: nil)

    model identifier (e.g. "gpt-4o", "claude-3-5-sonnet")

Returns:

  • (String, nil)

    the model name when used as a reader



51
52
53
54
55
56
57
# File 'lib/phronomy/agent/base.rb', line 51

def model(name = nil)
  if name
    @model = name
  else
    @model || Phronomy.configuration.default_model
  end
end

.provider(name = nil) ⇒ Symbol?

Sets or reads the LLM provider for this agent. Required when using a model not registered in RubyLLM's model registry (e.g. locally-hosted models via LM Studio or Ollama).

Examples:

class MyAgent < Phronomy::Agent::Base
  model "openai/gpt-oss-20b"
  provider :openai
end

Parameters:

  • name (Symbol, nil) (defaults to: nil)

    e.g. :openai, :anthropic, :ollama

Returns:

  • (Symbol, nil)


145
146
147
148
149
150
151
152
# File 'lib/phronomy/agent/base.rb', line 145

def provider(name = nil)
  if name
    @provider = name
  else
    return @provider if instance_variable_defined?(:@provider)
    superclass.respond_to?(:provider) ? superclass.provider : nil
  end
end

.static_knowledge(*sources) ⇒ Object

Registers one or more static knowledge sources on the agent class. Static source content is fetched and memoized at the class level the first time invoke is called. The cache persists for the lifetime of the process; call static_knowledge_refresh! to force a reload.

Examples:

class PolicyAgent < Phronomy::Agent::Base
  static_knowledge Phronomy::Agent::Context::Knowledge::StaticKnowledge.new(POLICY_TEXT)
end

Parameters:



261
262
263
264
265
266
# File 'lib/phronomy/agent/base.rb', line 261

def static_knowledge(*sources)
  @static_knowledge_sources = sources.flatten
  # Invalidate the cached chunks so the new sources are fetched on
  # the next call to static_knowledge_chunks.
  @static_knowledge_chunks = nil
end

.static_knowledge_chunksArray<Hash>

Returns the fetched content from all static knowledge sources. Results are cached at the class level so that each source is fetched only once regardless of how many times the agent is invoked.

Returns:

  • (Array<Hash>)


280
281
282
283
284
# File 'lib/phronomy/agent/base.rb', line 280

def static_knowledge_chunks
  @static_knowledge_chunks ||= static_knowledge_sources.flat_map { |ks|
    ks.fetch(query: nil)
  }
end

.static_knowledge_refresh!nil

Clears the class-level knowledge cache so that the next invoke call re-fetches content from all registered static knowledge sources.

Call this method when the underlying knowledge source has been updated at runtime (e.g. a file was rewritten, a DB record changed) and you want the agent to pick up the new content without restarting the process.

Examples:

Refresh after updating a knowledge file

MyAgent.static_knowledge_refresh!

Returns:

  • (nil)


298
299
300
# File 'lib/phronomy/agent/base.rb', line 298

def static_knowledge_refresh!
  @static_knowledge_chunks = nil
end

.static_knowledge_sourcesArray<Phronomy::Agent::Context::Knowledge::Base>

Returns the registered static knowledge sources.



271
272
273
# File 'lib/phronomy/agent/base.rb', line 271

def static_knowledge_sources
  @static_knowledge_sources || []
end

.temperature(val = nil) ⇒ Float?

Sets or reads the sampling temperature sent to the LLM. When nil, the provider's default is used.

Examples:

class MyAgent < Phronomy::Agent::Base
  temperature 0.2
end

Parameters:

  • val (Float, nil) (defaults to: nil)

    temperature (0.0 to 2.0 depending on provider)

Returns:

  • (Float, nil)


164
165
166
167
168
169
170
# File 'lib/phronomy/agent/base.rb', line 164

def temperature(val = nil)
  if val
    @temperature = val
  else
    @temperature
  end
end

.tool_aliasesHash{Class => String}

Returns the alias map registered via the hash form of .tools. Merges parent class aliases so subclasses inherit their parent's mappings. Subclass-specific aliases take precedence over parent aliases.

Returns:

  • (Hash{Class => String})


124
125
126
127
128
129
130
131
# File 'lib/phronomy/agent/base.rb', line 124

def tool_aliases
  own = @tool_aliases || {}
  if superclass.respond_to?(:tool_aliases)
    superclass.tool_aliases.merge(own)
  else
    own
  end
end

.tools(*args) ⇒ Object

Registers tool classes for this agent.

Accepts either a splat of classes (backward-compatible) or a Hash mapping each class to an explicit alias name (String) or nil (use tool's own name). The alias form is useful when two tools share the same auto-generated name (e.g. two SearchTool classes from different modules).

Examples:

Splat form (no alias)

tools WeatherTool, TimeTool

Hash form (with optional per-tool alias)

tools(
  Weather::SearchTool => "weather_search",
  Places::SearchTool  => "places_search",
  CurrentTimeTool     => nil
)


101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/phronomy/agent/base.rb', line 101

def tools(*args)
  if args.empty?
    if instance_variable_defined?(:@tools)
      return @tools
    end
    return superclass.respond_to?(:tools) ? superclass.tools : []
  end

  if args.length == 1 && args.first.is_a?(Hash)
    hash = args.first
    @tools = hash.keys
    @tool_aliases = hash.transform_values { |v| v&.to_s }.reject { |_, v| v.nil? }
  else
    @tools = args
    @tool_aliases = {}
  end
end

Instance Method Details

#_add_handoff_tool(tool_class) ⇒ self

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Registers an anonymous handoff tool class on this agent instance. Called by Runner during construction when routes are configured.

Parameters:

Returns:

  • (self)


399
400
401
402
403
# File 'lib/phronomy/agent/base.rb', line 399

def _add_handoff_tool(tool_class)
  @_handoff_tools ||= []
  @_handoff_tools << tool_class
  self
end

#_handoff_toolsArray<Class>

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns handoff tool classes registered on this instance by Runner.

Returns:

  • (Array<Class>)


408
409
410
# File 'lib/phronomy/agent/base.rb', line 408

def _handoff_tools
  @_handoff_tools || []
end

#approve(session_id, approved: true, config: {}) ⇒ Hash

Continues a suspended invocation identified by session_id. When approved: true, executes the pending tool and continues. When approved: false, rejects the tool call and ends the invocation.

Parameters:

  • session_id (String)
  • approved (Boolean) (defaults to: true)
  • config (Hash) (defaults to: {})

Returns:

  • (Hash)

Raises:

  • (ArgumentError)


938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
# File 'lib/phronomy/agent/base.rb', line 938

def approve(session_id, approved: true, config: {})
  ctx = Agent::SuspendedSessionRegistry.fetch(session_id)
  raise ArgumentError, "No suspended session found: #{session_id}" unless ctx

  # Reset approval_required so executing_tool_action proceeds instead of
  # re-suspending when called after the :approve FSM transition.
  ctx.approval_required = false
  ctx.approved = true if approved  # signals executing_tool to run the tool
  ctx.rejected = !approved  # signals _extract_invoke_result for rejection

  if approved
    _resume_fsm(ctx, :approve)
  else
    _resume_fsm(ctx, :reject)
  end
end

#context_version_cacheObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Deprecated.

The context version cache has been removed. Returns nil. Retained for backward compatibility with callers using safe navigation (+&.reset+).



589
590
591
# File 'lib/phronomy/agent/base.rb', line 589

def context_version_cache
  nil
end

#invoke(input, messages: [], thread_id: nil, config: {}, invocation_context: nil) ⇒ Hash

Invokes the agent with the given input and returns a result Hash. Applies the retry policy configured via retry_policy when transient errors occur. FilterBlockError is never retried.

Examples:

Normal invocation

result = MyAgent.new.invoke("What is Ruby?")
puts result[:output]

Multi-turn conversation

result1 = agent.invoke("Hi, I'm Alice.")
result2 = agent.invoke("What's my name?", messages: result1[:messages])

Suspend / resume flow

result = agent.invoke("Perform task X")
if result[:suspended]
  result = agent.resume(result[:checkpoint], approved: true)
end
puts result[:output]

With InvocationContext (deadline-based timeout)

ctx = Phronomy::InvocationContext.new(
  thread_id: "conv-123",
  deadline: Phronomy::Concurrency::Deadline.in(30),
  task_id: SecureRandom.uuid
)
result = MyAgent.new.invoke("Hello", invocation_context: ctx)

Parameters:

  • input (String, Hash)

    the user message; a Hash may supply :message, :query, or :user as the text key, plus any template variables consumed by the configured instructions template.

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

    conversation history from a previous invocation. The application owns and persists this array; pass it on every turn to maintain multi-turn context.

  • thread_id (String, nil) (defaults to: nil)

    conversation thread identifier, forwarded to the compaction context when on_compact is configured.

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

    additional runtime options: :user_id (+String+, optional) — caller identity forwarded to the tracer :session_id (+String+, optional) — session identity forwarded to the tracer

  • invocation_context (Phronomy::InvocationContext, nil) (defaults to: nil)

    optional first-class context object. When present, thread_id, cancellation_token, and deadline are derived from it (existing config: keys take precedence as backward-compat aliases). The object is also stored in config[:invocation_context] so that task_id / parent_task_id appear in trace spans automatically.

Returns:

  • (Hash)

    { output: String, messages: Array, usage: Phronomy::TokenUsage }, or +{ output: nil, suspended: true, checkpoint: Phronomy::Agent::Checkpoint, messages: Array }+ when the invocation was suspended awaiting tool approval.

Raises:



485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
# File 'lib/phronomy/agent/base.rb', line 485

def invoke(input, messages: [], thread_id: nil, config: {}, invocation_context: nil)
  if invocation_context
    thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
  end
  _check_scheduler_reentrancy

  timeout_sec = self.class.invoke_timeout
  unless timeout_sec
    return trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
      result = invoke_async(input, messages: messages, thread_id: thread_id, config: config).wait_result
      [result, result[:usage]]
    end
  end

  # invoke_timeout: create a CancellationScope with deadline, pass its token
  # to the async invocation, and use scope.pop_queue so the calling thread
  # unblocks as soon as either the result arrives or the deadline fires.
  scope = Phronomy::Concurrency::CancellationScope.new(parent_token: config[:cancellation_token])
  scope.deadline_in(timeout_sec)
  effective_config = config.merge(cancellation_token: scope.token)
  task = invoke_async(input, messages: messages, thread_id: thread_id, config: effective_config)

  # Bridge the task result to an AsyncQueue so scope.pop_queue can observe the deadline.
  completion_queue = Phronomy::Concurrency::AsyncQueue.new
  Phronomy::Runtime.instance.spawn(name: "invoke-timeout-bridge:#{(self.class.name || "agent").downcase}") do
    completion_queue.push(task.wait_result)
  rescue => e
    completion_queue.push(e)
  end

  result = scope.pop_queue(completion_queue) do
    raise Phronomy::TimeoutError,
      "Agent #{self.class.name} invoke timed out after #{timeout_sec}s"
  end
  raise result if result.is_a?(Exception)
  result
end

#invoke_async(input, messages: [], thread_id: nil, config: {}, invocation_context: nil) ⇒ Phronomy::Task

Invokes this agent asynchronously and returns a Task.

This is the primary async entry point. #invoke is a synchronous wrapper that calls this method and blocks the caller until the task completes. Calling #invoke from inside an active scheduler task raises SchedulerReentrancyError; use invoke_async directly in that context.

The task is registered with the Runtime task registry so Runtime#shutdown drains in-flight invocations before process exit.

Examples:

task = agent.invoke_async("Hello!")
result = task.wait_result   # => { output: "...", messages: [...], usage: ... }

Parameters:

  • input (String, Hash)
  • messages (Array) (defaults to: [])
  • thread_id (String, nil) (defaults to: nil)
  • config (Hash) (defaults to: {})
  • invocation_context (Phronomy::InvocationContext, nil) (defaults to: nil)

Returns:



545
546
547
548
549
550
551
552
553
554
555
556
557
558
# File 'lib/phronomy/agent/base.rb', line 545

def invoke_async(input, messages: [], thread_id: nil, config: {}, invocation_context: nil)
  if invocation_context
    thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
  end
  bp = Phronomy.configuration.backpressure
  on_full = (bp == :raise) ? :reject : (bp || :wait)
  bp_timeout = Phronomy.configuration.backpressure_timeout
  result_task = Phronomy::Task.deferred(name: "agent-#{(self.class.name || "anonymous").downcase}-async")
  gate = Phronomy::Runtime.instance.gate(:agent)
  gate.acquire(on_full: on_full, timeout: bp_timeout) do
    _start_invoke_attempt(result_task, input, messages: messages, thread_id: thread_id, config: config, attempt: 0)
    return result_task
  end
end

#on_approval_required(&block) ⇒ self

Registers a synchronous approval callback that is invoked before executing any tool that has requires_approval true set. The block receives the tool name (String) and the arguments Hash, and must return a truthy value to allow execution. Returning a falsy value causes the tool to return a denial message.

When no handler is registered and a tool with requires_approval is called, #invoke returns a suspended result hash containing a session_id. Call #approve to continue execution.

Examples:

agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }

Returns:

  • (self)


426
427
428
429
# File 'lib/phronomy/agent/base.rb', line 426

def on_approval_required(&block)
  @approval_handler = block
  self
end

#stream(input, messages: [], thread_id: nil, config: {}) {|Phronomy::Agent::StreamEvent| ... } ⇒ Hash

Streaming version of #invoke. Yields StreamEvent objects as they are produced by the underlying LLM.

Events emitted (in order):

:token       — each content delta from the LLM
:tool_call   — when the LLM requests a tool
:tool_result — after a tool completes
:done        — final event carrying output, messages, and usage
:error       — if an unrecoverable error occurs

Parameters:

  • input (String, Hash)

    same as #invoke

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

    same as #invoke

  • thread_id (String, nil) (defaults to: nil)

    same as #invoke

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

    same as #invoke

Yields:

Returns:

  • (Hash)

    { output:, messages:, usage: } — same as #invoke



577
578
579
580
581
582
583
584
# File 'lib/phronomy/agent/base.rb', line 577

def stream(input, messages: [], thread_id: nil, config: {}, &block)
  return invoke(input, messages: messages, thread_id: thread_id, config: config) unless block

  _stream_impl(input, messages: messages, thread_id: thread_id, config: config, &block)
rescue => e
  block&.call(StreamEvent.new(type: :error, payload: {error: e}))
  raise
end