Class: Phronomy::Agent::Base

Inherits:
Object
  • Object
show all
Includes:
Concerns::BeforeCompletion, Concerns::Guardrailable, Concerns::Retryable, Concerns::Suspendable, 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

Orchestrator, ReactAgent

Instance Attribute Summary

Attributes included from Concerns::BeforeCompletion

#before_completion

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Concerns::Suspendable

#on_approval_required, #resume

Methods included from Concerns::BeforeCompletion

included

Methods included from Concerns::Guardrailable

#add_input_guardrail, #add_output_guardrail

Methods included from Concerns::Retryable

included

Methods included from Runnable

#batch, #trace

Class Method Details

._on_compact_callbackProc?

Returns:

  • (Proc, nil)


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

def _on_compact_callback
  @on_compact_callback
end

._on_compaction_trigger_callbackProc?

Returns:

  • (Proc, nil)


240
241
242
# File 'lib/phronomy/agent/base.rb', line 240

def _on_compaction_trigger_callback
  @on_compaction_trigger_callback
end

._on_trim_callbackProc?

Returns:

  • (Proc, nil)


217
218
219
# File 'lib/phronomy/agent/base.rb', line 217

def _on_trim_callback
  @on_trim_callback
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


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

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


328
329
330
331
332
333
334
# File 'lib/phronomy/agent/base.rb', line 328

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


313
314
315
316
317
318
319
# File 'lib/phronomy/agent/base.rb', line 313

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 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:



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

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

.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)


169
170
171
172
173
174
175
# File 'lib/phronomy/agent/base.rb', line 169

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


296
297
298
299
300
301
302
# File 'lib/phronomy/agent/base.rb', line 296

def max_output_tokens(val = nil)
  if val.nil?
    @max_output_tokens
  else
    @max_output_tokens = val.to_i
  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

.on_compact {|ctx| ... } ⇒ Object

Registers a callback that performs the actual compaction when the +on_compaction_trigger+ callback fires. The block receives a Context::CompactionContext and should call +ctx.compact+ to specify which messages to summarise.

Examples:

Replace the first 4 messages with a short summary

on_compact do |ctx|
  ctx.compact(0..3) do |elements|
    texts = elements.map { |e| e[:message].content }.join(" | ")
    "Earlier conversation summary: #{texts}"
  end
end

Yields:

  • (ctx)

    Phronomy::Context::CompactionContext



257
258
259
# File 'lib/phronomy/agent/base.rb', line 257

def on_compact(&block)
  @on_compact_callback = block
end

.on_compaction_trigger {|ctx| ... } ⇒ Boolean

Registers a callback that decides whether compaction should run. Evaluated before every LLM call (after on_trim). If the block returns truthy AND an +on_compact+ callback is also registered, the compact pipeline is executed.

The block receives a read-only Context::TriggerContext.

Examples:

Trigger when messages exceed 70% of token budget

on_compaction_trigger do |ctx|
  limit = ctx.budget&.available(used: 0) || Float::INFINITY
  ctx.total_tokens > limit * 0.7
end

Yields:

  • (ctx)

    Phronomy::Context::TriggerContext

Returns:

  • (Boolean)

    truthy → run on_compact; falsy → skip



235
236
237
# File 'lib/phronomy/agent/base.rb', line 235

def on_compaction_trigger(&block)
  @on_compaction_trigger_callback = block
end

.on_trim {|ctx| ... } ⇒ Object

Registers a callback that is invoked before every LLM call so the application can remove stale or irrelevant messages from the conversation history.

The block receives a Context::TrimContext and may call +ctx.remove(seqs)+ to drop messages by seq number. Changes affect only the current invocation; the underlying memory store is unchanged.

Examples:

Drop the oldest message when over 80% of budget is used

on_trim do |ctx|
  limit = ctx.budget&.available(used: 0) || Float::INFINITY
  ctx.remove(ctx.message_elements.first[:seq]) if ctx.total_tokens > limit * 0.8
end

Yields:

  • (ctx)

    Phronomy::Context::TrimContext



212
213
214
# File 'lib/phronomy/agent/base.rb', line 212

def on_trim(&block)
  @on_trim_callback = block
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)


134
135
136
137
138
139
140
141
# File 'lib/phronomy/agent/base.rb', line 134

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 sources are fetched once per agent instance and their content is cached in ContextVersionCache keyed by a fingerprint of the instruction text + source content. The cache is invalidated automatically when the fingerprint changes (e.g. because a source was updated).

Examples:

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

Parameters:



188
189
190
# File 'lib/phronomy/agent/base.rb', line 188

def static_knowledge(*sources)
  @static_knowledge_sources = sources.flatten
end

.static_knowledge_sourcesArray<Phronomy::KnowledgeSource::Base>

Returns the registered static knowledge sources.



194
195
196
# File 'lib/phronomy/agent/base.rb', line 194

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)


152
153
154
155
156
157
158
# File 'lib/phronomy/agent/base.rb', line 152

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.

Returns:

  • (Hash{Class => String})


119
120
121
# File 'lib/phronomy/agent/base.rb', line 119

def tool_aliases
  @tool_aliases ||= {}
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
)


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

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

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

Parameters:

Returns:

  • (self)


341
342
343
344
345
# File 'lib/phronomy/agent/base.rb', line 341

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

#_handoff_toolsArray<Class>

Returns handoff tool classes registered on this instance by Runner.

Returns:

  • (Array<Class>)


349
350
351
# File 'lib/phronomy/agent/base.rb', line 349

def _handoff_tools
  @_handoff_tools || []
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.

Returns the Context::ContextVersionCache for the current thread.



491
492
493
# File 'lib/phronomy/agent/base.rb', line 491

def context_version_cache
  (Thread.current[:phronomy_context_version_caches] ||= {})[object_id]
end

#invoke(input, messages: [], thread_id: nil, config: {}) ⇒ 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. GuardrailError 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]

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: +:knowledge_sources+ (Array) — dynamic knowledge sources for this turn +:user_id+ (+String+, optional) — caller identity forwarded to the tracer +:session_id+ (+String+, optional) — session identity forwarded to the tracer

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:



385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
# File 'lib/phronomy/agent/base.rb', line 385

def invoke(input, messages: [], thread_id: nil, config: {})
  if Phronomy.configuration.event_loop
    # Protect against blocking the EventLoop thread itself.
    if Thread.current[:phronomy_event_loop_thread]
      raise Phronomy::Error,
        "Cannot call Agent#invoke (EventLoop mode) from within an EventLoop " \
        "entry action. Use agent.run_as_child(input, ctx: ctx) instead."
    end

    fsm = Agent::FSM.new(
      agent: self,
      input: input,
      messages: messages,
      thread_id: thread_id || SecureRandom.uuid,
      config: config
    )
    completion_queue = Phronomy::EventLoop.instance.register(fsm)
    result = completion_queue.pop
    raise result if result.is_a?(Exception)
    result
  else
    _invoke_impl(input, messages: messages, thread_id: thread_id, config: config)
  end
end

#run_as_child(input, ctx:, messages: [], config: {}) {|Hash| ... } ⇒ nil

Registers this agent as a child AgentFSM inside the given Workflow context.

Use this method from a Workflow entry action (running on the EventLoop thread) instead of #invoke, which would raise a deadlock error because +invoke+ blocks on a +Thread::Queue+ when EventLoop mode is active.

The agent runs asynchronously in a background IO thread. When it finishes, the parent FSMSession receives a +:child_completed+ event whose payload is the result hash +{ output:, messages:, usage: }+. Declare an +on: :child_completed+ transition in your Workflow to advance to the next state.

An optional block may be provided to write the result back into the parent WorkflowContext before the +:child_completed+ event is dispatched. +Thread::Queue+ provides the happens-before guarantee \u2014 no Mutex is needed.

Examples:

Without block (result available only as event payload)

entry :run_agent, ->(ctx) { MyAgent.new.run_as_child(ctx.query, ctx: ctx) }
transition from: :run_agent, on: :child_completed, to: :process_result

With block (writes result into context)

entry :run_agent, ->(ctx) {
  MyAgent.new.run_as_child(ctx.query, ctx: ctx) { |r| ctx.answer = r[:output] }
}
transition from: :run_agent, on: :child_completed, to: :process_result

Parameters:

  • input (String, Hash)

    user input passed to the agent

  • ctx (Object)

    a WorkflowContext that responds to +#thread_id+

  • messages (Array) (defaults to: [])

    prior conversation history

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

    invocation config (forwarded to +_invoke_impl+)

Yields:

  • (Hash)

    result hash +{ output:, messages:, usage: }+ — called from the agent IO thread before +:child_completed+ is posted

Returns:

  • (nil)

    the caller must not wait on any return value; the result arrives as a +:child_completed+ event

Raises:



444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
# File 'lib/phronomy/agent/base.rb', line 444

def run_as_child(input, ctx:, messages: [], config: {}, &result_writer)
  unless Phronomy.configuration.event_loop
    raise Phronomy::Error,
      "run_as_child requires EventLoop mode. " \
      "Enable with: Phronomy.configure { |c| c.event_loop = true }"
  end

  fsm = Agent::FSM.new(
    agent: self,
    input: input,
    messages: messages,
    thread_id: "#{ctx.thread_id}_agent_#{SecureRandom.uuid}",
    config: config,
    parent_id: ctx.thread_id,
    result_writer: result_writer
  )
  Phronomy::EventLoop.instance.enqueue_child(fsm)
  nil
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 (ReactAgent subclasses only) :tool_result — after a tool completes (ReactAgent subclasses only) :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



480
481
482
483
484
485
486
487
# File 'lib/phronomy/agent/base.rb', line 480

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