phronomy 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +35 -45
  3. data/benchmark/baseline.json +1 -1
  4. data/benchmark/bench_agent_invoke.rb +1 -1
  5. data/benchmark/bench_context_assembler.rb +11 -3
  6. data/benchmark/bench_regression.rb +11 -11
  7. data/benchmark/bench_token_estimator.rb +5 -5
  8. data/benchmark/bench_tool_schema.rb +2 -2
  9. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +224 -0
  10. data/lib/phronomy/agent/base.rb +268 -403
  11. data/lib/phronomy/agent/checkpoint.rb +118 -0
  12. data/lib/phronomy/agent/concerns/suspendable.rb +6 -6
  13. data/lib/phronomy/agent/context/capability/base.rb +689 -0
  14. data/lib/phronomy/agent/context/capability/scope_policy.rb +54 -0
  15. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  16. data/lib/phronomy/agent/context/knowledge/base.rb +58 -0
  17. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +102 -0
  18. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +58 -0
  19. data/lib/phronomy/agent/fsm.rb +1 -1
  20. data/lib/phronomy/agent/invocation_pipeline.rb +108 -0
  21. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  22. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  23. data/lib/phronomy/agent/react_agent.rb +43 -37
  24. data/lib/phronomy/agent/runner.rb +2 -2
  25. data/lib/phronomy/agent/shared_state.rb +2 -2
  26. data/lib/phronomy/agent/tool_executor.rb +108 -0
  27. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  28. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  29. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  30. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  31. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  32. data/lib/phronomy/concurrency/deadline.rb +65 -0
  33. data/lib/phronomy/{runtime → concurrency}/gate_registry.rb +1 -2
  34. data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
  35. data/lib/phronomy/configuration.rb +0 -6
  36. data/lib/phronomy/context.rb +2 -8
  37. data/lib/phronomy/eval/runner.rb +4 -0
  38. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  39. data/lib/phronomy/event_loop.rb +7 -7
  40. data/lib/phronomy/invocation_context.rb +3 -3
  41. data/lib/phronomy/knowledge_source.rb +0 -5
  42. data/lib/phronomy/llm_adapter/ruby_llm.rb +17 -11
  43. data/lib/phronomy/llm_context_window/assembler.rb +191 -0
  44. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  45. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  46. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  47. data/lib/phronomy/{agent → multi_agent}/handoff.rb +6 -6
  48. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +7 -7
  49. data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
  50. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +4 -4
  51. data/lib/phronomy/runtime/runtime_metrics.rb +0 -1
  52. data/lib/phronomy/runtime.rb +20 -6
  53. data/lib/phronomy/task_group.rb +1 -1
  54. data/lib/phronomy/tool.rb +3 -4
  55. data/lib/phronomy/{tool/agent_tool.rb → tools/agent.rb} +6 -6
  56. data/lib/phronomy/{tool/mcp_tool.rb → tools/mcp.rb} +9 -9
  57. data/lib/phronomy/tools/vector_search.rb +70 -0
  58. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  59. data/lib/phronomy/vector_store/async_backend.rb +4 -4
  60. data/lib/phronomy/vector_store/base.rb +2 -2
  61. data/lib/phronomy/vector_store/embeddings/base.rb +41 -0
  62. data/lib/phronomy/vector_store/embeddings/ruby_llm_embeddings.rb +47 -0
  63. data/lib/phronomy/vector_store/in_memory.rb +12 -2
  64. data/lib/phronomy/vector_store/loader/base.rb +27 -0
  65. data/lib/phronomy/vector_store/loader/csv_loader.rb +58 -0
  66. data/lib/phronomy/vector_store/loader/markdown_loader.rb +78 -0
  67. data/lib/phronomy/vector_store/loader/plain_text_loader.rb +24 -0
  68. data/lib/phronomy/vector_store/pgvector.rb +2 -2
  69. data/lib/phronomy/vector_store/redis_search.rb +2 -2
  70. data/lib/phronomy/vector_store/splitter/base.rb +49 -0
  71. data/lib/phronomy/vector_store/splitter/fixed_size_splitter.rb +53 -0
  72. data/lib/phronomy/vector_store/splitter/recursive_splitter.rb +107 -0
  73. data/lib/phronomy/vector_store.rb +14 -2
  74. data/lib/phronomy/version.rb +1 -1
  75. data/lib/phronomy/workflow_context.rb +8 -0
  76. data/lib/phronomy/workflow_runner.rb +11 -131
  77. data/lib/phronomy.rb +2 -0
  78. data/scripts/api_snapshot.rb +11 -9
  79. metadata +44 -46
  80. data/lib/phronomy/async_queue.rb +0 -155
  81. data/lib/phronomy/blocking_adapter_pool.rb +0 -435
  82. data/lib/phronomy/cancellation_scope.rb +0 -123
  83. data/lib/phronomy/cancellation_token.rb +0 -133
  84. data/lib/phronomy/concurrency_gate.rb +0 -155
  85. data/lib/phronomy/context/assembler.rb +0 -143
  86. data/lib/phronomy/context/compaction_context.rb +0 -111
  87. data/lib/phronomy/context/trigger_context.rb +0 -39
  88. data/lib/phronomy/context/trim_context.rb +0 -75
  89. data/lib/phronomy/deadline.rb +0 -63
  90. data/lib/phronomy/embeddings/base.rb +0 -39
  91. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  92. data/lib/phronomy/embeddings.rb +0 -11
  93. data/lib/phronomy/fsm_session.rb +0 -247
  94. data/lib/phronomy/knowledge_source/base.rb +0 -54
  95. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  96. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  97. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  98. data/lib/phronomy/loader/base.rb +0 -25
  99. data/lib/phronomy/loader/csv_loader.rb +0 -56
  100. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  101. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  102. data/lib/phronomy/loader.rb +0 -13
  103. data/lib/phronomy/prompt_template.rb +0 -96
  104. data/lib/phronomy/splitter/base.rb +0 -47
  105. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  106. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  107. data/lib/phronomy/splitter.rb +0 -12
  108. data/lib/phronomy/tool/base.rb +0 -644
  109. data/lib/phronomy/tool/scope_policy.rb +0 -50
  110. data/lib/phronomy/tool_executor.rb +0 -106
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Context
6
+ module Capability
7
+ # Evaluates whether a tool with a given scope may execute.
8
+ #
9
+ # A ScopePolicy is a callable that receives +(tool_class, scope, agent)+ and
10
+ # returns one of:
11
+ # +:allow+ — proceed immediately without an approval gate.
12
+ # +:reject+ — block execution; the tool returns a denial message.
13
+ # +:approve+ — delegate to the agent's approval handler (if registered);
14
+ # when no handler is registered the call is rejected.
15
+ #
16
+ # The {Default} instance is used automatically when no custom policy is
17
+ # configured on an agent.
18
+ #
19
+ # @example Custom policy that allows everything
20
+ # agent.scope_policy = ->(_tool_class, _scope, _agent) { :allow }
21
+ #
22
+ # @example Strict policy that rejects all write scopes
23
+ # agent.scope_policy = ->(_tc, scope, _agent) {
24
+ # scope == :write ? :reject : :allow
25
+ # }
26
+ class ScopePolicy
27
+ # Scopes that must go through an approval gate before execution.
28
+ APPROVAL_REQUIRED_SCOPES = %i[write admin external_network filesystem process external_process].freeze
29
+
30
+ # Scopes that are always permitted without approval.
31
+ ALWAYS_ALLOWED_SCOPES = %i[read_only].freeze
32
+
33
+ # Returns +:allow+ for always-allowed scopes, +:approve+ for high-risk
34
+ # scopes, and +:allow+ for anything else (including +nil+).
35
+ #
36
+ # @param _tool_class [Class]
37
+ # @param scope [Symbol, nil]
38
+ # @param _agent [Object]
39
+ # @return [:allow, :approve, :reject]
40
+ # @api private
41
+ def call(_tool_class, scope, _agent)
42
+ return :allow if scope.nil? || ALWAYS_ALLOWED_SCOPES.include?(scope)
43
+ return :approve if APPROVAL_REQUIRED_SCOPES.include?(scope)
44
+
45
+ :allow
46
+ end
47
+
48
+ # Shared singleton used when no custom policy is configured.
49
+ DEFAULT = new.freeze
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Context
6
+ module Instruction
7
+ # A prompt template that substitutes {{variable}} placeholders in a string.
8
+ #
9
+ # @example Simple human template
10
+ # t = Phronomy::Agent::Context::Instruction::PromptTemplate.new(template: "Translate to {{lang}}: {{text}}")
11
+ # t.format(lang: "French", text: "Hello")
12
+ # # => "Translate to French: Hello"
13
+ #
14
+ # @example With a system template
15
+ # t = Phronomy::Agent::Context::Instruction::PromptTemplate.new(
16
+ # template: "{{question}}",
17
+ # system_template: "You are a {{role}} assistant."
18
+ # )
19
+ # t.format_system(role: "helpful")
20
+ # # => "You are a helpful assistant."
21
+ #
22
+ # As a Runnable, #invoke accepts a Hash of variables and returns a Hash
23
+ # with :prompt (and optionally :system) keys.
24
+ class PromptTemplate
25
+ include Phronomy::Runnable
26
+
27
+ PLACEHOLDER = /\{\{(\w+)\}\}/
28
+
29
+ attr_reader :template, :system_template
30
+
31
+ # @param template [String] human message template with {{var}} placeholders
32
+ # @param system_template [String, nil] optional system message template
33
+ # @api public
34
+ def initialize(template:, system_template: nil)
35
+ @template = template
36
+ @system_template = system_template
37
+ end
38
+
39
+ # Substitute all {{var}} placeholders in the human template.
40
+ #
41
+ # @param variables [Hash{Symbol => String}]
42
+ # @return [String]
43
+ # @api public
44
+ def format(**variables)
45
+ substitute(@template, variables)
46
+ end
47
+
48
+ # Substitute all {{var}} placeholders in the system template.
49
+ # Returns nil when no system template was set.
50
+ #
51
+ # @param variables [Hash{Symbol => String}]
52
+ # @return [String, nil]
53
+ # @api public
54
+ def format_system(**variables)
55
+ @system_template && substitute(@system_template, variables)
56
+ end
57
+
58
+ # Runnable interface: accepts a Hash of variable values.
59
+ # Returns { prompt: String, system: String|nil }.
60
+ #
61
+ # @param input [Hash{Symbol => String}]
62
+ # @return [Hash]
63
+ # @api public
64
+ def invoke(input, config: {})
65
+ vars = normalize_input(input)
66
+ result = {prompt: format(**vars)}
67
+ sys = format_system(**vars)
68
+ result[:system] = sys if sys
69
+ result
70
+ end
71
+
72
+ # Returns the list of placeholder names found in both templates.
73
+ #
74
+ # @return [Array<Symbol>]
75
+ # @api public
76
+ def variables
77
+ names = @template.scan(PLACEHOLDER).flatten
78
+ names += @system_template.scan(PLACEHOLDER).flatten if @system_template
79
+ names.map(&:to_sym).uniq
80
+ end
81
+
82
+ private
83
+
84
+ def substitute(text, variables)
85
+ text.gsub(PLACEHOLDER) do |match|
86
+ key = Regexp.last_match(1).to_sym
87
+ variables.fetch(key) { raise KeyError, "Missing variable: {{#{key}}}" }
88
+ end
89
+ end
90
+
91
+ def normalize_input(input)
92
+ case input
93
+ when Hash then input
94
+ when String then {input: input}
95
+ else raise ArgumentError, "PromptTemplate#invoke expects a Hash of variables, got #{input.class}"
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Context
6
+ module Knowledge
7
+ # Abstract base class for all KnowledgeSource implementations.
8
+ #
9
+ # Subclasses must implement #fetch(query:) and return an Array of chunk Hashes.
10
+ # Each chunk Hash must contain:
11
+ # :content [String] the text to inject into the context
12
+ # :type [Symbol] semantic tag (e.g. :static, :rag, :entity)
13
+ class Base
14
+ # Retrieve knowledge chunks relevant to the given query.
15
+ #
16
+ # @param query [String, nil] the current user input used to select relevant chunks
17
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional token; raises CancellationError when cancelled
18
+ # @return [Array<Hash>] array of { content: String, type: Symbol }
19
+ # @api public
20
+ def fetch(query: nil, cancellation_token: nil)
21
+ cancellation_token&.raise_if_cancelled!
22
+ raise NotImplementedError, "#{self.class}#fetch is not implemented"
23
+ end
24
+
25
+ # Submits a {#fetch} call to {BlockingAdapterPool} and returns a
26
+ # {BlockingAdapterPool::PendingOperation}.
27
+ # Callers can fan out multiple fetches in parallel and await them all.
28
+ #
29
+ # @param query [String, nil]
30
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
31
+ # @param timeout [Numeric, nil] seconds before the operation is abandoned
32
+ # @return [BlockingAdapterPool::PendingOperation]
33
+ # @api public
34
+ def fetch_async(query: nil, cancellation_token: nil, timeout: nil)
35
+ Phronomy::Runtime.instance.blocking_io.submit(
36
+ timeout: timeout,
37
+ cancellation_token: cancellation_token
38
+ ) do
39
+ fetch(query: query, cancellation_token: cancellation_token)
40
+ end
41
+ end
42
+
43
+ # Returns true when this source's content is considered static (i.e. does
44
+ # not change between agent invocations). Static sources are eligible for
45
+ # fingerprint-based caching in ContextVersionCache.
46
+ #
47
+ # Override in subclasses that return fixed content.
48
+ #
49
+ # @return [Boolean]
50
+ # @api public
51
+ def static?
52
+ false
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Context
6
+ module Knowledge
7
+ # A KnowledgeSource that extracts named-entity facts from conversation history.
8
+ #
9
+ # This is the knowledge-injection counterpart of the old EntityMemory.
10
+ # It scans saved user messages with a regex heuristic (no LLM call) and
11
+ # returns the discovered facts as a single knowledge chunk tagged :entity.
12
+ #
13
+ # EntityKnowledge is stateful: it accumulates extracted facts via #update(messages:)
14
+ # which should be called each time new messages are saved.
15
+ #
16
+ # Supported extraction patterns (case-insensitive):
17
+ # "my name is Alice" → { name: "Alice" }
18
+ # "I am Alice" → { identity: "Alice" }
19
+ # "I'm a software engineer" → { occupation: "software engineer" }
20
+ # "I work at / for Acme" → { workplace: "Acme" }
21
+ # "I live in Tokyo" → { location: "Tokyo" }
22
+ # "I'm from Tokyo" → { location: "Tokyo" }
23
+ # "I like / love Ruby" → { preference: "Ruby" }
24
+ #
25
+ # @example
26
+ # ks = Phronomy::Agent::Context::Knowledge::EntityKnowledge.new
27
+ # ks.update(messages: chat_messages)
28
+ # agent = MyAgent.new
29
+ # agent.add_knowledge_source(ks)
30
+ # agent.invoke("What is my name?")
31
+ class EntityKnowledge < Base
32
+ PATTERNS = [
33
+ [:name, /\bmy name is\s+([A-Za-z][A-Za-z0-9 \-']*)/i],
34
+ [:identity, /\bI\s+am\s+([A-Z][A-Za-z0-9 \-']+)/],
35
+ [:occupation, /\bI(?:'m| am) a(?:n)?\s+([A-Za-z][A-Za-z0-9 \-']*)/i],
36
+ [:workplace, /\bI (?:work|worked) (?:at|for|in)\s+([A-Za-z0-9][A-Za-z0-9 \-'.&,]*)/i],
37
+ [:location, /\bI live in\s+([A-Za-z][A-Za-z0-9 \-']*)/i],
38
+ [:location, /\bI(?:'m| am) from\s+([A-Za-z][A-Za-z0-9 \-']*)/i],
39
+ [:preference, /\bI (?:like|love|enjoy)\s+([A-Za-z][A-Za-z0-9 \-']*)/i]
40
+ ].freeze
41
+
42
+ def initialize
43
+ @entities = {}
44
+ end
45
+
46
+ # Scan messages and accumulate entity facts.
47
+ # Call this after saving a new set of messages (e.g. from a ConversationManager save hook).
48
+ #
49
+ # @param messages [Array] message objects responding to #role and #content
50
+ # @api public
51
+ def update(messages:)
52
+ messages.each do |msg|
53
+ next unless msg.role.to_sym == :user
54
+
55
+ extract(msg.content.to_s).each { |key, value| @entities[key] = value }
56
+ end
57
+ end
58
+
59
+ # Returns a single chunk containing all known entity facts in XML context format.
60
+ # Returns an empty array when no entities have been discovered.
61
+ #
62
+ # @param query [String, nil] unused — entity knowledge is always fully injected
63
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; raises CancellationError when cancelled
64
+ # @return [Array<Hash>]
65
+ # @api public
66
+ def fetch(query: nil, cancellation_token: nil)
67
+ cancellation_token&.raise_if_cancelled!
68
+ return [] if @entities.empty?
69
+
70
+ lines = @entities.map { |key, value| "- #{key}: #{value}" }.join("\n")
71
+ content = <<~CONTENT.chomp
72
+ Known facts about the user:
73
+ #{lines}
74
+ CONTENT
75
+ [{content: content, type: :entity}]
76
+ end
77
+
78
+ # Returns the current entity store (primarily for testing).
79
+ #
80
+ # @return [Hash]
81
+ # @api public
82
+ def entities
83
+ @entities.dup
84
+ end
85
+
86
+ private
87
+
88
+ def extract(text)
89
+ found = {}
90
+ PATTERNS.each do |key, pattern|
91
+ if (match = text.match(pattern))
92
+ value = match[1].strip.sub(/[.!?]\s+.*$/, "").gsub(/[.,;!?]+$/, "")
93
+ found[key] = value unless value.empty?
94
+ end
95
+ end
96
+ found
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Context
6
+ module Knowledge
7
+ # A KnowledgeSource backed by fixed text provided at construction time.
8
+ #
9
+ # Useful for injecting static documents, policy files, or configuration
10
+ # knowledge that does not change per request.
11
+ #
12
+ # @example
13
+ # ks = Phronomy::Agent::Context::Knowledge::StaticKnowledge.new(
14
+ # "Our refund policy: ...",
15
+ # type: :policy
16
+ # )
17
+ # agent = MyAgent.new
18
+ # agent.add_knowledge_source(ks)
19
+ # agent.invoke("What is the refund policy?")
20
+ class StaticKnowledge < Base
21
+ # @param text [String] the static knowledge text to inject
22
+ # @param type [Symbol] semantic tag for the chunk (default :static)
23
+ # @param source [String, nil] label identifying where this knowledge came from
24
+ # (e.g. a filename). Included in the context XML tag and exposed to the LLM
25
+ # so that agents can produce grounded citations.
26
+ # @api public
27
+ def initialize(text, type: :static, source: nil)
28
+ @text = text.to_s
29
+ @type = type
30
+ @source = source
31
+ end
32
+
33
+ # Returns the fixed text as a single chunk, regardless of query.
34
+ #
35
+ # @param query [String, nil] ignored for static knowledge
36
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; raises CancellationError when cancelled
37
+ # @return [Array<Hash>]
38
+ # @api public
39
+ def fetch(query: nil, cancellation_token: nil)
40
+ cancellation_token&.raise_if_cancelled!
41
+ return [] if @text.empty?
42
+
43
+ chunk = {content: @text, type: @type}
44
+ chunk[:source] = @source if @source
45
+ [chunk]
46
+ end
47
+
48
+ # Static knowledge content never changes between invocations.
49
+ # @return [true]
50
+ # @api public
51
+ def static?
52
+ true
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -8,7 +8,7 @@ module Phronomy
8
8
  #
9
9
  # +AgentFSM+ implements the minimal interface expected by {Phronomy::EventLoop}
10
10
  # (+#id+, +#start+, +#handle+) so it can be managed alongside
11
- # {Phronomy::FSMSession} instances. It is *not* a traditional finite-state
11
+ # {Phronomy::Agent::Lifecycle::FSMSession} instances. It is *not* a traditional finite-state
12
12
  # machine; the name reflects its role in the EventLoop rather than internal
13
13
  # state transitions.
14
14
  #
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Encapsulates the core per-invocation LLM round-trip for {Agent::Base}.
6
+ #
7
+ # {Agent::Base#invoke_once} delegates the body of each LLM turn to this
8
+ # class, keeping the caller to a thin setup + trace frame (span≈2).
9
+ # The pipeline executes inside the agent's binding via +instance_exec+
10
+ # so that private concern methods (guardrails, hooks, cancellation) remain
11
+ # encapsulated in their original modules while the orchestration logic lives
12
+ # here.
13
+ #
14
+ # @api private
15
+ class InvocationPipeline
16
+ # @param agent [Agent::Base] the agent instance driving this invocation
17
+ # @api private
18
+ def initialize(agent)
19
+ @agent = agent
20
+ end
21
+
22
+ # Runs one LLM round-trip inside the agent's execution context.
23
+ #
24
+ # Calls private {Agent::Base} concern methods (guardrails, hooks,
25
+ # cancellation) via +instance_exec+ so that their encapsulation is
26
+ # preserved, then routes the LLM request through the configured adapter.
27
+ #
28
+ # @param input [String, Hash] the user input for this turn
29
+ # @param messages [Array] prior conversation messages
30
+ # @param thread_id [String, nil] persistence thread identifier
31
+ # @param config [Hash] per-invocation options
32
+ # @return [Array(Hash, Phronomy::TokenUsage, nil)]
33
+ # A two-element array: the result hash and the token usage (or nil on
34
+ # suspension).
35
+ # @api private
36
+ def run(input, messages:, thread_id:, config:)
37
+ @agent.instance_exec(input, messages, thread_id, config) do |inp, msgs, tid, cfg|
38
+ # Run input guardrails before touching the LLM.
39
+ run_input_guardrails!(inp)
40
+
41
+ user_message = extract_message(inp)
42
+ chat = build_chat
43
+
44
+ # Assemble context (system prompt + history). Override #build_context to
45
+ # inject custom context editing logic at the Agent subclass level.
46
+ context = build_context(
47
+ inp,
48
+ messages: msgs,
49
+ thread_id: tid,
50
+ config: cfg,
51
+ budget: build_token_budget,
52
+ instruction: build_instructions(inp),
53
+ tools: self.class.tools + _handoff_tools
54
+ )
55
+ apply_instructions(chat, context[:system]) if context[:system]
56
+ (context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
57
+ context[:messages].each { |msg| chat.messages << msg }
58
+
59
+ # Run before_completion hooks (global → class → instance) before the LLM call.
60
+ run_before_completion_hooks!(chat, cfg)
61
+
62
+ # Register suspension hook for approval-required tools (no-op when a
63
+ # synchronous on_approval_required handler is already registered).
64
+ _register_suspension_hook!(chat)
65
+
66
+ # Check for cancellation immediately before the LLM call.
67
+ check_cancellation!(cfg, "invocation cancelled before LLM call")
68
+
69
+ # Forward the cancellation token to ParallelToolChat explicitly
70
+ # via the chat instance so that tool dispatch batches can observe
71
+ # cancellation without needing Thread.current.
72
+ chat.cancellation_token = cfg[:cancellation_token] if chat.respond_to?(:cancellation_token=)
73
+
74
+ begin
75
+ # Route the LLM call through the configured LLMAdapter so that the
76
+ # blocking HTTP request runs inside BlockingAdapterPool and the
77
+ # adapter can be swapped without changing agent code.
78
+ adapter = Phronomy.configuration.llm_adapter
79
+ response = adapter.complete_async(chat, user_message, config: cfg).await
80
+ rescue SuspendSignal => signal
81
+ checkpoint = Checkpoint.new(
82
+ thread_id: tid,
83
+ original_input: inp,
84
+ messages: chat.messages.dup,
85
+ pending_tool_name: signal.tool_name,
86
+ pending_tool_args: signal.args,
87
+ pending_tool_call_id: signal.tool_call_id
88
+ )
89
+ suspended_result = {output: nil, suspended: true, checkpoint: checkpoint, messages: chat.messages}
90
+ next [suspended_result, nil]
91
+ ensure
92
+ # Clear the chat's cancellation token reference after each LLM call.
93
+ chat.cancellation_token = nil if chat.respond_to?(:cancellation_token=)
94
+ end
95
+
96
+ output = response.content
97
+ usage = Phronomy::TokenUsage.from_tokens(response.tokens)
98
+
99
+ # Run output guardrails before returning to the caller.
100
+ run_output_guardrails!(output)
101
+
102
+ result = {output: output, messages: chat.messages, usage: usage}
103
+ [result, usage]
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end