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,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Phronomy
6
+ module LlmContextWindow
7
+ # Assembler collects all four context regions and produces the final
8
+ # {system:, messages:, tool_classes:} hash consumed by Agent::Base.
9
+ #
10
+ # Regions:
11
+ # 1. Instruction — system prompt text set via #add_instruction
12
+ # 2. Capability — tool classes registered via #add_capability
13
+ # 3. Knowledge — external facts injected via #add_knowledge (generates XML tags)
14
+ # 4. Conversation — historical messages added via #add_messages
15
+ #
16
+ # Token budgeting:
17
+ # When a budget is given, conversation messages are trimmed from oldest to
18
+ # newest until they fit. Capability token cost is estimated and deducted
19
+ # from the budget before conversation trimming so the reserve is accurate.
20
+ # Knowledge chunks are always included in full (they are assumed to be
21
+ # pre-screened by the caller). When no budget is given all messages are
22
+ # passed through unchanged.
23
+ #
24
+ # @example
25
+ # assembler = Phronomy::LlmContextWindow::Assembler.new(budget: budget)
26
+ # assembler.add_instruction("You are a helpful assistant.")
27
+ # assembler.add_knowledge("The user lives in Tokyo.", type: :entity, trusted: false)
28
+ # assembler.add_messages(manager.load(thread_id: "t1", query: user_input))
29
+ # context = assembler.build
30
+ # # => { system: "You are ...\n<context ...>...</context>", messages: [...] }
31
+ class Assembler
32
+ # Builds a single XML context tag string.
33
+ # Exposed as a class method so callers (e.g. Agent::Base) can build
34
+ # static knowledge XML tags independently of an Assembler instance.
35
+ #
36
+ # @param text [String]
37
+ # @param type [Symbol, String]
38
+ # @param trusted [Boolean]
39
+ # @return [String]
40
+ # @api private
41
+ # mutant:disable - text.to_str and plain text (no to_s) are genuine equivalents when text is a String; type.to_str is genuine equivalent when type is a String
42
+ def self.xml_tag(text, type:, trusted: false)
43
+ "<context type=\"#{CGI.escapeHTML(type.to_s)}\" trusted=\"#{trusted}\">\n#{CGI.escapeHTML(text.to_s)}\n</context>"
44
+ end
45
+
46
+ # @param budget [Phronomy::LlmContextWindow::TokenBudget, nil]
47
+ # when nil no token trimming is performed
48
+ # @api private
49
+ # mutant:disable - @instruction = nil deletion is a genuine equivalent (uninitialized Ruby instance variables return nil)
50
+ def initialize(budget: nil)
51
+ @budget = budget
52
+ @instruction = nil
53
+ @tool_classes = []
54
+ @knowledge_chunks = []
55
+ @messages = []
56
+ end
57
+
58
+ # Register tool classes (Region 2).
59
+ # Estimates their token cost and deducts it from the budget so that
60
+ # conversation trimming accounts for tool definition overhead.
61
+ #
62
+ # @param tool_classes [Array<Class, Object>] tool classes or instances
63
+ # @return [self]
64
+ # @api private
65
+ def add_capability(tool_classes)
66
+ @tool_classes = Array(tool_classes)
67
+ self
68
+ end
69
+
70
+ # Set the system instruction text (Region 1).
71
+ # Calling this multiple times replaces the previous value.
72
+ #
73
+ # @param text [String]
74
+ # @return [self]
75
+ # @api private
76
+ # mutant:disable - text.to_str and plain text (no .to_s) are genuine equivalents when callers always pass a String
77
+ def add_instruction(text)
78
+ @instruction = text.to_s
79
+ self
80
+ end
81
+
82
+ # Append a knowledge chunk (Region 3).
83
+ # The chunk is wrapped in an XML context tag automatically.
84
+ #
85
+ # @param text [String]
86
+ # @param type [Symbol, String] semantic label for the context tag (e.g. :entity, :rag, :static)
87
+ # @param trusted [Boolean] false (default) indicates externally sourced data
88
+ # @param source [String, nil] optional source label (e.g. filename); included in the
89
+ # XML tag so the LLM can produce grounded citations. Omitted when nil.
90
+ # @return [self]
91
+ # @api private
92
+ # mutant:disable - {text:} (shorthand, no .to_s) and text.to_str are genuine equivalents when text is a String; {type:} shorthand is genuine equivalent because xml_context_tag always calls .to_s on chunk[:type]
93
+ def add_knowledge(text, type:, trusted: false, source: nil)
94
+ @knowledge_chunks << {text: text.to_s, type: type.to_s, trusted: trusted, source: source}
95
+ self
96
+ end
97
+
98
+ # Set conversation messages (Region 4). Replaces any previously set messages.
99
+ #
100
+ # @param messages [Array] message-like objects with #role and #content
101
+ # @return [self]
102
+ # @api private
103
+ # mutant:disable - @messages = messages (no Array()) is a genuine equivalent when callers always pass an Array
104
+ def add_messages(messages)
105
+ @messages = Array(messages)
106
+ self
107
+ end
108
+
109
+ # Returns the number of tokens available for conversation messages after
110
+ # accounting for instruction, knowledge, and capability overhead.
111
+ # Returns +nil+ when no budget is configured.
112
+ #
113
+ # @return [Integer, nil]
114
+ # @api private
115
+ def available_for_messages
116
+ return nil unless @budget
117
+ knowledge_text = @knowledge_chunks.map { |c| xml_context_tag(c) }.join("\n\n")
118
+ system_parts = [@instruction, knowledge_text.empty? ? nil : knowledge_text].compact
119
+ system_text = system_parts.join("\n\n")
120
+ used = TokenEstimator.estimate(system_text) + estimate_capability_tokens
121
+ @budget.available(used: used)
122
+ end
123
+
124
+ # Assemble the context.
125
+ #
126
+ # @return [Hash{Symbol => Object}]
127
+ # :system [String, nil] combined system prompt (instruction + knowledge XML tags)
128
+ # :messages [Array] conversation messages, trimmed to budget if set
129
+ # :tool_classes [Array] tool classes/instances to register with the chat
130
+ # @api private
131
+ # Raises {Phronomy::ContextLengthError} when a budget is set and the
132
+ # conversation messages do not fit within the remaining token allowance.
133
+ # No automatic trimming is performed — callers must pre-process messages
134
+ # (e.g. via Agent::Base#trim_messages or #compact_messages) before
135
+ # passing them to the Assembler.
136
+ #
137
+ # mutant:disable - multiple genuine equivalent mutations: map{}.join("\n\n") → map{} is genuine; `unless knowledge_text.empty?` vs ternary is genuine; `{ system: unless system_text.empty? }` vs ternary is genuine; `messages:` shorthand vs `messages: messages` is genuine
138
+ def build
139
+ knowledge_text = @knowledge_chunks.map { |c| xml_context_tag(c) }.join("\n\n")
140
+ system_parts = [@instruction, knowledge_text.empty? ? nil : knowledge_text].compact
141
+ system_text = system_parts.join("\n\n")
142
+
143
+ if @budget && @messages.any?
144
+ capability_tokens = estimate_capability_tokens
145
+ used = TokenEstimator.estimate(system_text) + capability_tokens
146
+ remaining = @budget.available(used: used)
147
+ msg_tokens = @messages.sum { |m| TokenEstimator.estimate(m.content.to_s) }
148
+ if msg_tokens > remaining
149
+ raise Phronomy::ContextLengthError,
150
+ "Context exceeds token budget: messages require #{msg_tokens} tokens but " \
151
+ "only #{remaining} available (context_window=#{@budget.context_window}, " \
152
+ "used_by_system=#{used}). Override build_context to trim or compact messages."
153
+ end
154
+ end
155
+
156
+ {
157
+ system: system_text.empty? ? nil : system_text,
158
+ messages: @messages,
159
+ tool_classes: @tool_classes
160
+ }
161
+ end
162
+
163
+ private
164
+
165
+ # Estimates the token cost of all registered tool classes.
166
+ # Uses each tool's description and parameter names as a proxy for its
167
+ # JSON Schema size. This is a deliberate simplification — exact token
168
+ # counts require provider-specific schema serialization which lives in
169
+ # RubyLLM. The estimate errs on the side of being slightly conservative
170
+ # so that the conversation budget is not over-allocated.
171
+ def estimate_capability_tokens
172
+ @tool_classes.sum do |tc|
173
+ # Instantiated tool objects (e.g. Phronomy::Tools::Mcp instances) may not be a Class.
174
+ next 0 unless tc.is_a?(Class) && tc.respond_to?(:description)
175
+
176
+ text = [tc.description.to_s]
177
+ if tc.respond_to?(:parameters)
178
+ tc.parameters.each_key { |k| text << k.to_s }
179
+ end
180
+ TokenEstimator.estimate(text.join(" "))
181
+ end
182
+ end
183
+
184
+ # mutant:disable - multiple genuine equivalent mutations: chunk.fetch(key) vs chunk[key] (key always present); chunk[:text] no .to_s / .to_str are genuine (stored as String); chunk[:type] no .to_s / .to_str are genuine (stored as String); chunk[:source] no .to_s / .to_str are genuine (truthy branch, always String); src_attr chunk.fetch(:source) is genuine (source key always present)
185
+ def xml_context_tag(chunk)
186
+ src_attr = chunk[:source] ? " source=\"#{CGI.escapeHTML(chunk[:source].to_s)}\"" : ""
187
+ "<context type=\"#{CGI.escapeHTML(chunk[:type].to_s)}\"#{src_attr} trusted=\"#{chunk[:trusted]}\">\n#{CGI.escapeHTML(chunk[:text].to_s)}\n</context>"
188
+ end
189
+ end
190
+ end
191
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- module Context
4
+ module LlmContextWindow
5
5
  # Caches the assembled static system prompt text keyed by a SHA-256
6
6
  # fingerprint of the agent's instructions + static knowledge content.
7
7
  # Each instance is owned by one thread (stored in +Thread.current+).
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- module Context
4
+ module LlmContextWindow
5
5
  # Raised when a model name is not found in the RubyLLM model registry and
6
6
  # no explicit context_window was provided.
7
7
  class UnknownModelError < Phronomy::Error; end
@@ -17,16 +17,16 @@ module Phronomy
17
17
  # └─ effective_input_limit (available for memory + knowledge)
18
18
  #
19
19
  # @example Auto-derive from RubyLLM model registry
20
- # budget = Phronomy::Context::TokenBudget.new(model: "claude-3-5-sonnet-20241022")
20
+ # budget = Phronomy::LlmContextWindow::TokenBudget.new(model: "claude-3-5-sonnet-20241022")
21
21
  #
22
22
  # @example Explicit values (useful for local / unknown models)
23
- # budget = Phronomy::Context::TokenBudget.new(
23
+ # budget = Phronomy::LlmContextWindow::TokenBudget.new(
24
24
  # context_window: 32_768,
25
25
  # max_output_tokens: 4_096
26
26
  # )
27
27
  #
28
28
  # @example With overhead for instructions + tool definitions
29
- # budget = Phronomy::Context::TokenBudget.new(
29
+ # budget = Phronomy::LlmContextWindow::TokenBudget.new(
30
30
  # model: "gpt-4o",
31
31
  # overhead: 800
32
32
  # )
@@ -46,6 +46,7 @@ module Phronomy
46
46
  # and model is given, uses max_output_tokens
47
47
  # @param overhead [Integer] tokens reserved for instructions/tools
48
48
  # @api private
49
+ # mutant:disable - multiple genuine equivalent mutations: overhead/context_window/max_output_tokens .to_i vs .to_int vs Integer() vs omitted are equivalent for Integer inputs; (max_output_tokens||0).to_i vs (max_output_tokens).to_i and (||nil).to_i are genuine because nil.to_i==0; overhead:nil default is genuine because nil.to_i==0
49
50
  def initialize(model: nil, context_window: nil, max_output_tokens: nil, overhead: 0)
50
51
  @overhead = overhead.to_i
51
52
 
@@ -76,12 +77,14 @@ module Phronomy
76
77
  # @param used [Integer] tokens already committed (e.g. from knowledge injection)
77
78
  # @return [Integer] remaining tokens (always >= 0)
78
79
  # @api private
80
+ # mutant:disable - used.to_i vs used vs used.to_int vs Integer(used) are genuine equivalents when used is an Integer; used:nil default is genuine because nil.to_i==0==default 0
79
81
  def available(used: 0)
80
82
  [effective_input_limit - used.to_i, 0].max
81
83
  end
82
84
 
83
85
  private
84
86
 
87
+ # mutant:disable - raise(UnknownModelError) and raise(UnknownModelError,nil) and raise(UnknownModelError,"Model '#{nil}' not found") in both branches are genuine equivalents (spec checks exception class only, not message text)
85
88
  def lookup_model!(model_name)
86
89
  found = RubyLLM.models.find(model_name)
87
90
  raise UnknownModelError, "Model '#{model_name}' not found in RubyLLM registry" unless found
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- module Context
4
+ module LlmContextWindow
5
5
  # Central, stateless token estimation utility.
6
6
  #
7
7
  # All token counting in the framework passes through this module so that the
@@ -21,10 +21,10 @@ module Phronomy
21
21
  # @example Use tiktoken_ruby for accurate GPT token counts
22
22
  # require "tiktoken_ruby"
23
23
  # enc = Tiktoken.encoding_for_model("gpt-4o")
24
- # Phronomy::Context::TokenEstimator.tokenizer = ->(text) { enc.encode(text).length }
24
+ # Phronomy::LlmContextWindow::TokenEstimator.tokenizer = ->(text) { enc.encode(text).length }
25
25
  #
26
26
  # @example Reset to built-in heuristic
27
- # Phronomy::Context::TokenEstimator.tokenizer = nil
27
+ # Phronomy::LlmContextWindow::TokenEstimator.tokenizer = nil
28
28
  module TokenEstimator
29
29
  @tokenizer = nil
30
30
  @tokenizer_mutex = Mutex.new
@@ -3,16 +3,16 @@
3
3
  require "securerandom"
4
4
 
5
5
  module Phronomy
6
- module Agent
6
+ module MultiAgent
7
7
  # Represents a transfer edge from one agent to another.
8
- # Creates an anonymous Phronomy::Tool::Base subclass that the source agent
8
+ # Creates an anonymous Phronomy::Agent::Context::Capability::Base subclass that the source agent
9
9
  # exposes to the LLM as a +transfer_to_<name>+ function.
10
10
  # The tool's execute method returns a sentinel string that Runner uses to
11
11
  # detect which target agent to route to next.
12
12
  #
13
13
  # @example
14
14
  # billing = BillingAgent.new
15
- # handoff = Phronomy::Agent::Handoff.new(target_agent: billing)
15
+ # handoff = Phronomy::MultiAgent::Handoff.new(target_agent: billing)
16
16
  # tool_class = handoff.to_tool_class
17
17
  class Handoff
18
18
  # Prefix embedded in tool results so Runner can detect handoffs.
@@ -32,14 +32,14 @@ module Phronomy
32
32
  @description = description || "Transfer the conversation to #{klass_name}."
33
33
  end
34
34
 
35
- # Builds an anonymous Phronomy::Tool::Base subclass for this handoff.
36
- # @return [Class<Phronomy::Tool::Base>]
35
+ # Builds an anonymous Phronomy::Agent::Context::Capability::Base subclass for this handoff.
36
+ # @return [Class<Phronomy::Agent::Context::Capability::Base>]
37
37
  # @api public
38
38
  def to_tool_class
39
39
  sentinel_value = sentinel
40
40
  tn = tool_name
41
41
  desc = description
42
- Class.new(Phronomy::Tool::Base) do
42
+ Class.new(Phronomy::Agent::Context::Capability::Base) do
43
43
  tool_name tn
44
44
  description desc
45
45
  define_method(:execute) { sentinel_value }
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- module Agent
4
+ module MultiAgent
5
5
  # Base class for orchestrator agents that coordinate multiple subagents.
6
6
  # Implements the Orchestrator-Subagent multi-agent coordination pattern
7
7
  # (Anthropic blog, Pattern 2).
@@ -16,7 +16,7 @@ module Phronomy
16
16
  # - +fan_out+ for parallel invocation of the same agent across multiple inputs.
17
17
  #
18
18
  # @example Declarative DSL
19
- # class ResearchOrchestrator < Phronomy::Agent::Orchestrator
19
+ # class ResearchOrchestrator < Phronomy::MultiAgent::Orchestrator
20
20
  # model "gpt-4o"
21
21
  # instructions "You coordinate research tasks."
22
22
  # subagent :searcher, SearchAgent
@@ -26,7 +26,7 @@ module Phronomy
26
26
  # result = ResearchOrchestrator.new.invoke("Research the latest AI news.")
27
27
  #
28
28
  # @example Programmatic parallel dispatch
29
- # class MyOrchestrator < Phronomy::Agent::Orchestrator
29
+ # class MyOrchestrator < Phronomy::MultiAgent::Orchestrator
30
30
  # model "gpt-4o"
31
31
  # instructions "Dispatch tasks in parallel."
32
32
  #
@@ -41,7 +41,7 @@ module Phronomy
41
41
  #
42
42
  # @example Fan-out (same agent, multiple inputs)
43
43
  # results = fan_out(agent: TranslationAgent, inputs: ["Hello", "World"])
44
- class Orchestrator < Base
44
+ class Orchestrator < Agent::Base
45
45
  # Declares a named subagent and registers it as a tool accessible to the
46
46
  # LLM during an +invoke+ call.
47
47
  #
@@ -57,7 +57,7 @@ module Phronomy
57
57
  # proceed
58
58
  # @api public
59
59
  def self.subagent(name, agent_class, on_error: :raise)
60
- tool_class = Class.new(Phronomy::Tool::Base) do
60
+ tool_class = Class.new(Phronomy::Agent::Context::Capability::Base) do
61
61
  tool_name "dispatch_to_#{name}"
62
62
  description "Dispatch work to the #{name} subagent (#{agent_class.name})"
63
63
  param :input, type: :string, desc: "The task or question for the subagent"
@@ -142,7 +142,7 @@ module Phronomy
142
142
  # nil means wait indefinitely. When the deadline is exceeded,
143
143
  # {Phronomy::TimeoutError} is raised and all surviving tasks are cancelled
144
144
  # cooperatively.
145
- # @param cancellation_token [Phronomy::CancellationToken, nil] when provided, the
145
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] when provided, the
146
146
  # token is merged into each task's config (unless the task already sets one) so
147
147
  # that every child agent checks it before making LLM calls.
148
148
  # @param invocation_context [Phronomy::InvocationContext, nil] when provided,
@@ -313,7 +313,7 @@ module Phronomy
313
313
  end
314
314
 
315
315
  if timeout
316
- deadline = Phronomy::Deadline.in(timeout)
316
+ deadline = Phronomy::Concurrency::Deadline.in(timeout)
317
317
  spawned.each { |t| t.join([deadline.remaining_seconds, 0].max) }
318
318
 
319
319
  alive = spawned.select(&:alive?)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- module Agent
4
+ module MultiAgent
5
5
  # RubyLLM::Chat subclass that executes multiple tool calls concurrently.
6
6
  #
7
7
  # When the LLM returns more than one tool call in a single response, each
@@ -25,7 +25,7 @@ module Phronomy
25
25
  # @api private
26
26
  class ParallelToolChat < RubyLLM::Chat
27
27
  # @param max_parallel_tools [Integer] maximum simultaneous tool executions
28
- # @param cancellation_token [Phronomy::CancellationToken, nil] token observed before each batch
28
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] token observed before each batch
29
29
  # @param opts [Hash] remaining kwargs forwarded to RubyLLM::Chat
30
30
  # @api private
31
31
  def initialize(max_parallel_tools: 10, cancellation_token: nil, **opts)
@@ -95,7 +95,7 @@ module Phronomy
95
95
  }}
96
96
  end
97
97
 
98
- awaitable = Phronomy::ToolExecutor.call_async(
98
+ awaitable = Phronomy::Agent::ToolExecutor.call_async(
99
99
  tool: tool,
100
100
  args: tc.arguments,
101
101
  cancellation_token: ct
@@ -138,7 +138,7 @@ module Phronomy
138
138
  }
139
139
  end
140
140
 
141
- Phronomy::ToolExecutor.call_async(
141
+ Phronomy::Agent::ToolExecutor.call_async(
142
142
  tool: tool,
143
143
  args: tool_call.arguments,
144
144
  cancellation_token: @cancellation_token
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- module Agent
4
+ module MultiAgent
5
5
  # Implements the "Agent teams" coordination pattern (Anthropic blog, Pattern 3).
6
6
  #
7
7
  # @see https://claude.com/blog/multi-agent-coordination-patterns
@@ -24,7 +24,7 @@ module Phronomy
24
24
  # +invoke+ call, so the LLM retains context across multiple task assignments.
25
25
  #
26
26
  # @example Basic usage
27
- # class MigrationTeam < Phronomy::Agent::TeamCoordinator
27
+ # class MigrationTeam < Phronomy::MultiAgent::TeamCoordinator
28
28
  # coordinator_model "claude-3-5-sonnet-20241022"
29
29
  # coordinator_instructions <<~INST
30
30
  # Analyze the request and enqueue one migration task per service.
@@ -265,7 +265,7 @@ module Phronomy
265
265
 
266
266
  # Builds the +enqueue_task+ tool. Each call appends a task Hash to task_queue.
267
267
  def build_enqueue_tool(task_queue)
268
- Class.new(Phronomy::Tool::Base) do
268
+ Class.new(Phronomy::Agent::Context::Capability::Base) do
269
269
  tool_name "enqueue_task"
270
270
  description "Add a task to the worker queue."
271
271
  param :description, type: :string, desc: "What the worker agent should do"
@@ -282,7 +282,7 @@ module Phronomy
282
282
  # Builds the +finalize+ tool. Signals to the coordinator LLM that all tasks
283
283
  # have been enqueued; returns a confirmation string.
284
284
  def build_finalize_tool(task_queue)
285
- Class.new(Phronomy::Tool::Base) do
285
+ Class.new(Phronomy::Agent::Context::Capability::Base) do
286
286
  tool_name "finalize"
287
287
  description "Signal that task generation is complete. Call this after all tasks have been enqueued."
288
288
  param :summary, type: :string, desc: "Brief summary of what was enqueued", required: false
@@ -90,7 +90,6 @@ module Phronomy
90
90
  active_agent_tasks: active[:agent].to_i,
91
91
  active_tool_tasks: active[:tool].to_i,
92
92
  active_workflow_tasks: active[:workflow].to_i,
93
- active_rag_tasks: active[:rag].to_i,
94
93
  active_llm_tasks: active[:llm].to_i,
95
94
  task_wait_time_p50_ms: _percentile(wait, 50),
96
95
  task_wait_time_p95_ms: _percentile(wait, 95),
@@ -8,8 +8,6 @@ require_relative "runtime/timer_queue"
8
8
  require_relative "runtime/scheduler_timer_adapter"
9
9
  require_relative "runtime/task_registry"
10
10
  require_relative "runtime/runtime_metrics"
11
- require_relative "runtime/gate_registry"
12
- require_relative "runtime/pool_registry"
13
11
  require_relative "runtime/timer_service"
14
12
 
15
13
  module Phronomy
@@ -99,6 +97,23 @@ module Phronomy
99
97
  !Task.current.nil?
100
98
  end
101
99
 
100
+ # Executes +block+ and returns +[result, elapsed_ms]+ where +elapsed_ms+
101
+ # is the wall-clock duration in milliseconds (Integer, rounded).
102
+ #
103
+ # Isolates all direct references to +Process.clock_gettime+ /
104
+ # +Process::CLOCK_MONOTONIC+ in one place so that callers stay at the
105
+ # framework abstraction level.
106
+ #
107
+ # @yield block to time
108
+ # @return [Array(Object, Integer)] +[block_return_value, elapsed_ms]+
109
+ # @api private
110
+ def self.measure_ms
111
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
112
+ result = yield
113
+ elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
114
+ [result, elapsed_ms]
115
+ end
116
+
102
117
  # The scheduler backing this runtime instance.
103
118
  # @return [Scheduler]
104
119
  attr_reader :scheduler
@@ -109,8 +124,8 @@ module Phronomy
109
124
  @scheduler = scheduler
110
125
  @task_registry = TaskRegistry.new
111
126
  @metrics = RuntimeMetrics.new
112
- @gate_registry = GateRegistry.new
113
- @pool_registry = PoolRegistry.new
127
+ @gate_registry = Phronomy::Concurrency::GateRegistry.new
128
+ @pool_registry = Phronomy::Concurrency::PoolRegistry.new
114
129
  @timer_service = TimerService.new(scheduler)
115
130
  end
116
131
 
@@ -120,7 +135,7 @@ module Phronomy
120
135
  # is first accessed; subsequent calls return the cached gate. To change the
121
136
  # cap at runtime, call {#reset_gate} first.
122
137
  #
123
- # @param name [:agent, :tool, :workflow, :llm, :rag, :vector] resource name
138
+ # @param name [:agent, :tool, :workflow, :llm, :vector] resource name
124
139
  # @return [ConcurrencyGate]
125
140
  # @api private
126
141
  def gate(name)
@@ -264,7 +279,6 @@ module Phronomy
264
279
  # | `active_agent_tasks` | currently running agent spawns |
265
280
  # | `active_tool_tasks` | currently running tool spawns |
266
281
  # | `active_workflow_tasks` | currently running workflow spawns |
267
- # | `active_rag_tasks` | currently running RAG fetches |
268
282
  # | `active_llm_tasks` | currently running LLM calls |
269
283
  # | `task_wait_time_p50_ms` | p50 spawn-to-start latency (ms) |
270
284
  # | `task_wait_time_p95_ms` | p95 spawn-to-start latency (ms) |
@@ -108,7 +108,7 @@ module Phronomy
108
108
  # @param tasks [Array<Task>]
109
109
  # @return [Array]
110
110
  def _await_all_cooperative(tasks)
111
- completion_q = AsyncQueue.new
111
+ completion_q = Phronomy::Concurrency::AsyncQueue.new
112
112
  tasks.each_with_index do |task, idx|
113
113
  task.on_complete do |value, error|
114
114
  completion_q.push({index: idx, value: value, error: error})
data/lib/phronomy/tool.rb CHANGED
@@ -1,9 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "tool/base"
4
- require_relative "tool/mcp_tool"
5
- require_relative "tool/agent_tool"
6
-
3
+ # This file is intentionally empty.
4
+ # Tool definitions have moved to Phronomy::Agent::Context::Capability.
5
+ # See lib/phronomy/agent/context/capability/.
7
6
  module Phronomy
8
7
  module Tool
9
8
  end
@@ -1,16 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- module Tool
4
+ module Tools
5
5
  # Wraps a Phronomy::Agent::Base subclass as a callable tool so that a parent
6
6
  # ReactAgent (or any agent that supports tools) can delegate sub-tasks to a
7
7
  # fully-capable agent.
8
8
  #
9
- # Use AgentTool.from_agent to generate a concrete tool class. The generated
9
+ # Use Agent.from_agent to generate a concrete tool class. The generated
10
10
  # class is anonymous; assign it to a constant when you need a stable name.
11
11
  #
12
12
  # @example Wrap an existing agent
13
- # SummarizerTool = Phronomy::Tool::AgentTool.from_agent(
13
+ # SummarizerTool = Phronomy::Tools::Agent.from_agent(
14
14
  # SummarizerAgent,
15
15
  # tool_name: "summarize",
16
16
  # description: "Summarizes a long text and returns a brief summary"
@@ -21,12 +21,12 @@ module Phronomy
21
21
  # instructions "You are an orchestrator that delegates to specialist agents."
22
22
  # tools SummarizerTool
23
23
  # end
24
- class AgentTool < Phronomy::Tool::Base
24
+ class Agent < Phronomy::Agent::Context::Capability::Base
25
25
  description "Wraps an agent as a tool"
26
26
  param :input, type: :string, desc: "The input to forward to the wrapped agent"
27
27
 
28
28
  class << self
29
- # Generates a Phronomy::Tool::AgentTool subclass that delegates #execute to
29
+ # Generates a Phronomy::Tools::Agent subclass that delegates #execute to
30
30
  # an instance of +agent_class+.
31
31
  #
32
32
  # @param agent_class [Class] a Phronomy::Agent::Base subclass
@@ -34,7 +34,7 @@ module Phronomy
34
34
  # defaults to a snake_case derivation of the agent class name
35
35
  # @param description [String, nil] description exposed to the LLM;
36
36
  # defaults to "Delegates to <AgentClassName>"
37
- # @return [Class] an anonymous Phronomy::Tool::AgentTool subclass
37
+ # @return [Class] an anonymous Phronomy::Tools::Agent subclass
38
38
  # @api public
39
39
  def from_agent(agent_class, tool_name: nil, description: nil)
40
40
  raise ArgumentError, "agent_class must be a Class" unless agent_class.is_a?(Class)
@@ -8,8 +8,8 @@ require "shellwords"
8
8
  require "uri"
9
9
 
10
10
  module Phronomy
11
- module Tool
12
- # A Phronomy::Tool::Base subclass that wraps a tool exposed by an external
11
+ module Tools
12
+ # A Phronomy::Agent::Context::Capability::Base subclass that wraps a tool exposed by an external
13
13
  # MCP (Model Context Protocol) server.
14
14
  #
15
15
  # Supports two transport schemes:
@@ -19,15 +19,15 @@ module Phronomy
19
19
  # HTTP/SSE MCP server using +net/http+.
20
20
  #
21
21
  # @example
22
- # web_search = Phronomy::Tool::McpTool.from_server(
22
+ # web_search = Phronomy::Tools::Mcp.from_server(
23
23
  # "stdio://./mcp-server",
24
24
  # tool_name: "search_web"
25
25
  # )
26
26
  # agent = MyAgent.new
27
27
  # agent_class.tools(web_search)
28
- class McpTool < Base
28
+ class Mcp < Phronomy::Agent::Context::Capability::Base
29
29
  class << self
30
- # Build a McpTool instance by querying a running MCP server for the
30
+ # Build a Mcp instance by querying a running MCP server for the
31
31
  # tool definition identified by +tool_name+.
32
32
  #
33
33
  # @param server_uri [String] URI of the MCP server.
@@ -35,11 +35,11 @@ module Phronomy
35
35
  # - "stdio://<command>" — spawn a child process
36
36
  # - "http://<url>" / "https://<url>" — connect to an HTTP/SSE server
37
37
  # @param tool_name [String] the tool name as registered in the MCP server
38
- # @return [McpTool] a configured subclass instance ready for use with an Agent
38
+ # @return [Mcp] a configured subclass instance ready for use with an Agent
39
39
  # @api public
40
40
  def from_server(server_uri, tool_name:)
41
41
  # Use a short-lived transport only to query the tool definition,
42
- # then close it. Each McpTool instance creates its own transport
42
+ # then close it. Each Mcp instance creates its own transport
43
43
  # so that concurrent callers never share IO streams.
44
44
  transport = build_transport(server_uri)
45
45
  begin
@@ -65,7 +65,7 @@ module Phronomy
65
65
  end
66
66
 
67
67
  def build_tool_class(tool_name, server_uri, tool_def)
68
- klass = Class.new(McpTool)
68
+ klass = Class.new(Mcp)
69
69
  klass.tool_name(tool_name)
70
70
  klass.instance_variable_set(:@mcp_server_uri, server_uri)
71
71
 
@@ -289,7 +289,7 @@ module Phronomy
289
289
  # both the 2024-11-05 and 2025-03-26 MCP HTTP transport specifications.
290
290
  #
291
291
  # @example
292
- # tool = Phronomy::Tool::McpTool.from_server(
292
+ # tool = Phronomy::Tools::Mcp.from_server(
293
293
  # "http://localhost:8080/mcp",
294
294
  # tool_name: "weather_lookup"
295
295
  # )