phronomy 0.32.0 → 0.33.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.
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  module Phronomy
4
6
  module MultiAgent
5
- # Implements peer coordination through an invocation-local KnowledgeStore.
7
+ # Coordinates members through default findings or application-defined shared data.
6
8
  # @api public
7
9
  class SharedState
8
10
  # Semantic revision of the framework-owned SharedState instrumentation
@@ -70,6 +72,29 @@ module Phronomy
70
72
  block ? @aggregator = block : @aggregator
71
73
  end
72
74
 
75
+ # Declare application-owned sharing for each invocation. The provider's
76
+ # open(input:, config:) method must synchronously yield one Binding.
77
+ # Its block scope owns any connection or other application resource.
78
+ # Increment version when the shared Tool schemas or meanings change.
79
+ # @param provider [#open]
80
+ # @param id [String] stable application sharing definition identity
81
+ # @param version [Integer] positive sharing definition revision
82
+ # @api public
83
+ def sharing(provider, id:, version:)
84
+ raise ArgumentError, "sharing provider must respond to open" unless provider.respond_to?(:open)
85
+ unless id.is_a?(String) && !id.strip.empty?
86
+ raise ArgumentError, "sharing id must be a nonempty String"
87
+ end
88
+ unless version.is_a?(Integer) && version.positive?
89
+ raise ArgumentError, "sharing version must be a positive Integer"
90
+ end
91
+
92
+ @sharing = {provider: provider, id: id.dup.freeze, version: version}.freeze
93
+ end
94
+
95
+ # @api private
96
+ def _sharing = @sharing
97
+
73
98
  def _members = Array(@members)
74
99
  def _coordination = @coordination
75
100
  def _max_cycles = @max_cycles
@@ -81,6 +106,8 @@ module Phronomy
81
106
  # @api public
82
107
  def invoke(input, config: {})
83
108
  validate_termination!
109
+ return invoke_with_sharing(input, config, self.class._sharing) if self.class._sharing
110
+
84
111
  store = KnowledgeStore.new
85
112
  cycles, terminated_by = coordinate_members(input, store)
86
113
 
@@ -97,7 +124,11 @@ module Phronomy
97
124
  cycle_limit = max_cycles || Float::INFINITY
98
125
 
99
126
  (1..cycle_limit).each do |cycle|
100
- invoke_members(input, store, cycle)
127
+ if block_given?
128
+ yield cycle
129
+ else
130
+ invoke_members(input, store, cycle)
131
+ end
101
132
  completed_cycles = cycle
102
133
 
103
134
  if self.class._terminate_when&.call(store)
@@ -137,6 +168,120 @@ module Phronomy
137
168
  "max_cycles or timeout must be configured before invoking SharedState"
138
169
  end
139
170
 
171
+ def invoke_with_sharing(input, config, sharing)
172
+ unless self.class._aggregator
173
+ raise ArgumentError, "aggregate must be configured when sharing is configured"
174
+ end
175
+
176
+ open_sharing(sharing, input, config) do |binding|
177
+ cycles, terminated_by = coordinate_members(input, binding.store) do |cycle|
178
+ invoke_shared_members(input, binding, cycle, sharing)
179
+ end
180
+ {output: aggregate_findings(binding.store), cycles: cycles, terminated_by: terminated_by}
181
+ end
182
+ end
183
+
184
+ def open_sharing(sharing, input, config)
185
+ opening_thread = Thread.current
186
+ active = true
187
+ entered = completed = false
188
+ result = callback_error = nil
189
+
190
+ sharing.fetch(:provider).open(input: input, config: config) do |binding|
191
+ unless active && Thread.current.equal?(opening_thread)
192
+ raise ArgumentError, "sharing provider must yield synchronously within open"
193
+ end
194
+ raise ArgumentError, "sharing provider must yield exactly once" if entered
195
+
196
+ entered = true
197
+ unless binding.is_a?(Binding)
198
+ raise ArgumentError, "sharing provider must yield a SharedState::Binding"
199
+ end
200
+
201
+ result = yield binding
202
+ completed = true
203
+ rescue => error
204
+ callback_error = error
205
+ raise
206
+ end
207
+
208
+ raise callback_error if callback_error
209
+ unless entered && completed
210
+ raise ArgumentError, "sharing provider must yield exactly once and complete the block"
211
+ end
212
+
213
+ result
214
+ ensure
215
+ active = false
216
+ end
217
+
218
+ def invoke_shared_members(input, binding, cycle, sharing)
219
+ self.class._members.each do |member_config|
220
+ member = member_config.fetch(:klass)
221
+ definitions = shared_tool_definitions(member, binding, cycle)
222
+ instrumented = build_shared_researcher(member, definitions, sharing)
223
+ prompt = shared_prompt(input, member_config[:instruction])
224
+ invoke_shared_researcher(instrumented, prompt)
225
+ end
226
+ end
227
+
228
+ def invoke_shared_researcher(instrumented, prompt)
229
+ agent = instrumented.new
230
+ suspended = false
231
+ result = agent.invoke(prompt)
232
+ if result.is_a?(Hash) && result[:suspended]
233
+ suspended = true
234
+ raise MemberLifecycleError.new(agent: agent, result: result, reason: :suspended)
235
+ end
236
+ result
237
+ ensure
238
+ release_shared_member(agent, result, $!) if agent && !suspended
239
+ end
240
+
241
+ def release_shared_member(agent, result, primary_error)
242
+ agent.unload!
243
+ rescue => release_error
244
+ error = MemberLifecycleError.new(agent: agent, result: result, reason: :release_failed,
245
+ primary_error: primary_error, release_error: release_error)
246
+ raise error, cause: primary_error || release_error
247
+ end
248
+
249
+ def shared_tool_definitions(member, binding, cycle)
250
+ definitions = member.tools.to_h { |tool| [tool, member.tool_aliases[tool]] }
251
+ added = binding.tool_definitions(member: member, cycle: cycle)
252
+ unless (definitions.keys & added.keys).empty?
253
+ raise ArgumentError, "sharing Tool classes must not replace member Tools"
254
+ end
255
+
256
+ definitions = definitions.merge(added)
257
+ names = definitions.map { |tool, alias_name| (alias_name || tool.tool_name).to_s }
258
+ if names.any?(&:empty?) || names.uniq.length != names.length
259
+ raise ArgumentError, "sharing and member Tools must have distinct nonempty names or aliases"
260
+ end
261
+ definitions
262
+ end
263
+
264
+ def build_shared_researcher(member, definitions, sharing)
265
+ definition = shared_definition_for(member, sharing)
266
+ member.with_tools(definitions, id: definition.fetch(:id), version: definition.fetch(:version))
267
+ end
268
+
269
+ def shared_definition_for(member, sharing)
270
+ parent = member.agent_definition
271
+ identity = [parent.fetch(:id), parent.fetch(:version), sharing.fetch(:id), sharing.fetch(:version)]
272
+ digest = Digest::SHA256.hexdigest(Phronomy::CanonicalJSON.dump(identity))
273
+ {id: "Phronomy::MultiAgent::SharedState::Application/#{digest}", version: 1}
274
+ end
275
+
276
+ def shared_prompt(input, instruction)
277
+ guide = self.class._coordination ||
278
+ "Use the provided Tools to collaborate through the application's shared data."
279
+ parts = [guide]
280
+ parts << "\nYour specific focus for this session: #{instruction}" if instruction
281
+ parts << "\n\nTask: #{input}"
282
+ parts.join
283
+ end
284
+
140
285
  def invoke_researcher(
141
286
  researcher_class,
142
287
  store,
@@ -363,10 +363,17 @@ module Phronomy
363
363
  # @api public
364
364
  def validate_arguments(args)
365
365
  schema = parameters_schema
366
- if !@validation_schema || @validation_schema.json_schema != schema
367
- @validation_schema = Schema.new(schema)
366
+ validator = @validation_schema
367
+ if !validator || validator.json_schema != schema
368
+ declaration = self.class.parameters_schema_definition
369
+ validator = if Schema === declaration && declaration.instance_of?(Schema) && declaration.json_schema.equal?(schema)
370
+ declaration.for_tool_instance
371
+ else
372
+ Schema.new(schema)
373
+ end
374
+ @validation_schema = validator
368
375
  end
369
- values, error = @validation_schema.validate(args || {}, coerce: self.class.on_schema_error == :coerce)
376
+ values, error = validator.validate(args || {}, coerce: self.class.on_schema_error == :coerce)
370
377
  if values && !self.class.parameters_schema_definition
371
378
  # Legacy optional param nil means omission, preserving Ruby defaults.
372
379
  self.class.declared_parameters.each do |name, parameter|
@@ -442,6 +449,24 @@ module Phronomy
442
449
  {}
443
450
  end
444
451
 
452
+ # Selects the arguments and policy facts that may appear in a human approval
453
+ # request. Arguments may be raw or validated. This does not change either input.
454
+ # @api public
455
+ def approval_display(arguments:, facts:)
456
+ sensitive = arguments.transform_keys(&:to_sym).slice(*self.class.redact_params).values
457
+ indexed = {}
458
+ sensitive.each do |value|
459
+ if Hash === value || Array === value
460
+ approval_comparison_key(value) { |key| indexed[key] = true }
461
+ end
462
+ end
463
+ display_facts = redact_approval_fact(facts) do |value|
464
+ sensitive_fact_value?(value, sensitive) || (!indexed.empty? && indexed.key?(approval_comparison_key(value)))
465
+ end
466
+ # If the entire facts Hash matches a sensitive argument, hide all of its keys.
467
+ {arguments: redacted_args(arguments), facts: display_facts.is_a?(Hash) ? display_facts : {}}
468
+ end
469
+
445
470
  # @api public
446
471
  def execute(**_args)
447
472
  raise NotImplementedError, "#{self.class}#execute is not implemented"
@@ -536,6 +561,47 @@ module Phronomy
536
561
  end
537
562
  end
538
563
 
564
+ # Index each sensitive subtree and scalar once. Object member names describe
565
+ # structure; its member values and array elements can be copied into facts.
566
+ def approval_comparison_key(value, &index)
567
+ key = case value
568
+ when Hash then value.transform_values { |item| approval_comparison_key(item, &index) }
569
+ when Array then value.map { |item| approval_comparison_key(item, &index) }
570
+ when Symbol then value.to_s
571
+ when Float then (value.finite? && value == value.to_i) ? value.to_i : value
572
+ else value
573
+ end
574
+ index&.call(key)
575
+ key
576
+ end
577
+
578
+ def sensitive_fact_value?(value, sensitive_values)
579
+ sensitive_values.any? do |sensitive|
580
+ sensitive == value ||
581
+ (Symbol === value && value.to_s == sensitive) ||
582
+ (Symbol === sensitive && sensitive.to_s == value)
583
+ end
584
+ end
585
+
586
+ def redact_approval_fact(value, &sensitive)
587
+ return "[REDACTED]" if sensitive.call(value)
588
+
589
+ case value
590
+ when Hash
591
+ value.each_with_object({}) do |(key, item), result|
592
+ next if sensitive.call(key)
593
+
594
+ result[key] = redact_approval_fact(item, &sensitive)
595
+ end.freeze
596
+ when Array
597
+ value.map { |item| redact_approval_fact(item, &sensitive) }.freeze
598
+ when String
599
+ "[REDACTED]"
600
+ else
601
+ value
602
+ end
603
+ end
604
+
539
605
  def nested_schema_to_json_schema(nested)
540
606
  nested.each_with_object({}) do |(prop_name, spec), result|
541
607
  type = spec[:type].to_s
@@ -43,19 +43,17 @@ module Phronomy
43
43
  unless @json_schema.is_a?(Hash) && @json_schema["type"] == "object"
44
44
  raise ArgumentError, "Tool schema must declare an object"
45
45
  end
46
+ @json_schema = Phronomy::Values::Immutable.copy(@json_schema)
46
47
  @references = []
47
48
  validate_keywords!(@json_schema)
48
- options = {ref_resolver: ->(uri) { raise ArgumentError, "external Tool schema reference: #{uri}" }}
49
- unless JSONSchemer.valid_schema?(@json_schema, **options)
50
- raise ArgumentError, "invalid Tool JSON Schema"
51
- end
52
- @json_schema = Phronomy::Values::Immutable.copy(@json_schema)
53
- @validator = JSONSchemer.schema(@json_schema, **options)
54
- @references.each { |reference| @validator.ref(reference) }
55
- @references = nil
56
- freeze
57
- rescue JSONSchemer::InvalidRefPointer, JSONSchemer::InvalidRefResolution => error
58
- raise ArgumentError, "invalid local Tool schema reference: #{error.message}"
49
+ @references.freeze
50
+ compile_validator!
51
+ end
52
+
53
+ # Build an independent validator from this immutable Tool definition.
54
+ # @api private
55
+ def for_tool_instance
56
+ dup.compile_validator!
59
57
  end
60
58
 
61
59
  # @api public
@@ -99,6 +97,20 @@ module Phronomy
99
97
  end
100
98
  end
101
99
 
100
+ protected
101
+
102
+ def compile_validator!
103
+ options = {ref_resolver: ->(uri) { raise ArgumentError, "external Tool schema reference: #{uri}" }}
104
+ unless JSONSchemer.valid_schema?(@json_schema, **options)
105
+ raise ArgumentError, "invalid Tool JSON Schema"
106
+ end
107
+ @validator = JSONSchemer.schema(@json_schema, **options)
108
+ @references.each { |reference| @validator.ref(reference) }
109
+ freeze
110
+ rescue JSONSchemer::InvalidRefPointer, JSONSchemer::InvalidRefResolution => error
111
+ raise ArgumentError, "invalid local Tool schema reference: #{error.message}"
112
+ end
113
+
102
114
  private
103
115
 
104
116
  def error_message(error)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.32.0"
4
+ VERSION = "0.33.0"
5
5
  end
@@ -106,9 +106,12 @@ def snapshot_entry(klass)
106
106
  "public_class_methods" => (klass.public_methods(false) - BASELINE_CLASS_METHODS).sort
107
107
  }
108
108
  else
109
- internal_context_methods = (klass == Phronomy::InvocationContext) ?
110
- %i[__bind_execution __execution_scope] : []
111
- instance_methods = (klass.public_instance_methods - BASELINE_INSTANCE_METHODS - internal_context_methods).sort
109
+ internal_methods = case klass.name
110
+ when "Phronomy::InvocationContext" then %i[__bind_execution __execution_scope]
111
+ when "Phronomy::Tool::Schema" then %i[for_tool_instance]
112
+ else []
113
+ end
114
+ instance_methods = (klass.public_instance_methods - BASELINE_INSTANCE_METHODS - internal_methods).sort
112
115
  internal_class_methods = (klass == Phronomy::Storage::AsyncClient) ? %i[submit] : []
113
116
  class_methods = (klass.public_methods(false) - BASELINE_CLASS_METHODS - internal_class_methods).sort
114
117
  {
@@ -83,6 +83,7 @@ module Phronomy
83
83
  def self.instructions: (?untyped text) { (untyped) -> untyped } -> untyped
84
84
  | (?untyped text) -> untyped
85
85
  def self.tools: (?Hash[Class, String?] definitions) -> untyped
86
+ def self.with_tools: (Hash[Class, String?] definitions, id: String, version: Integer) -> singleton(Base)
86
87
  def self.tool_aliases: () -> Hash[Class, String]
87
88
  def self.provider: (?Symbol name) -> Symbol?
88
89
  def self.temperature: (?Float value) -> Float?
@@ -110,6 +110,8 @@ module Phronomy
110
110
  attr_reader previous_manifest: LLMInputManifest?
111
111
  attr_reader metadata: Hash[untyped, untyped]
112
112
  def initialize: (agent_id: String, ?execution_id: String?, call_sequence: Integer, call_mode: Symbol, ?instruction: Array[InstructionItem], ?knowledge: Array[KnowledgeItem], ?tools: Array[ToolItem], ?conversation: Array[Array[ConversationItem]], token_budget: untyped, ?model_config: Hash[untyped, untyped], previous_manifest: LLMInputManifest?, ?metadata: Hash[untyped, untyped]) -> void
113
+ def input_token_limit: () -> Integer?
114
+ def with_input_token_limit: (Integer limit) -> ContextPolicyInput
113
115
  end
114
116
 
115
117
  class ContextPlan
@@ -124,6 +126,7 @@ module Phronomy
124
126
  class ContextPolicy
125
127
  def call: (ContextPolicyInput input) -> ContextPlan
126
128
 
129
+ def estimate_tokens: (untyped content) -> Integer
127
130
  def instruction_item: (content: untyped, ?role: Symbol, ?kind: Symbol, ?content_format: Symbol?, ?required: bool, ?metadata: Hash[untyped, untyped]) -> ContextPolicyInput::InstructionItem
128
131
  def knowledge_item: (content: untyped, ?role: Symbol, ?kind: Symbol, ?content_format: Symbol?, ?required: bool, ?metadata: Hash[untyped, untyped]) -> ContextPolicyInput::KnowledgeItem
129
132
  def conversation_item: (content: untyped, role: Symbol, ?kind: Symbol, ?content_format: Symbol?, ?sequence: Integer?, ?required: bool, ?tool_call_id: String?, ?tool_call_ids: Array[String], ?delivery: Symbol, ?metadata: Hash[untyped, untyped]) -> ContextPolicyInput::ConversationItem
@@ -1,6 +1,29 @@
1
1
  module Phronomy
2
2
  module MultiAgent
3
3
  class SharedState
4
+ interface _SharingTools
5
+ def call: (member: singleton(::Phronomy::Agent::Base), cycle: Integer) -> Hash[singleton(::Phronomy::Tool::Base), untyped]
6
+ end
7
+
8
+ interface _SharingProvider
9
+ def open: (input: untyped, config: Hash[Symbol, untyped]) { (Binding) -> untyped } -> untyped
10
+ end
11
+
12
+ class Binding
13
+ def self.new: (store: untyped, tools: _SharingTools) -> instance
14
+ attr_reader store: untyped
15
+ def tool_definitions: (member: singleton(::Phronomy::Agent::Base), cycle: Integer) -> Hash[singleton(::Phronomy::Tool::Base), String?]
16
+ end
17
+
18
+ class MemberLifecycleError < ::Phronomy::Error
19
+ attr_reader agent: ::Phronomy::Agent::Base
20
+ attr_reader result: untyped
21
+ attr_reader reason: Symbol
22
+ attr_reader primary_error: Exception?
23
+ attr_reader release_error: Exception?
24
+ def self.new: (agent: ::Phronomy::Agent::Base, result: untyped, reason: Symbol, ?primary_error: Exception?, ?release_error: Exception?) -> instance
25
+ end
26
+
4
27
  class KnowledgeStore
5
28
  def self.new: () -> instance
6
29
  def read_all: () -> Array[Hash[Symbol, untyped]]
@@ -13,8 +36,9 @@ module Phronomy
13
36
  def self.coordination: (?untyped text) -> untyped
14
37
  def self.max_cycles: (?untyped value) -> Integer?
15
38
  def self.timeout: (?untyped value) -> Float?
16
- def self.terminate_when: () ?{ (KnowledgeStore) -> untyped } -> untyped
17
- def self.aggregate: () ?{ (KnowledgeStore) -> untyped } -> untyped
39
+ def self.terminate_when: () ?{ (untyped) -> untyped } -> untyped
40
+ def self.aggregate: () ?{ (untyped) -> untyped } -> untyped
41
+ def self.sharing: (_SharingProvider provider, id: String, version: Integer) -> Hash[Symbol, untyped]
18
42
  def invoke: (untyped input, ?config: Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
19
43
  end
20
44
 
@@ -55,6 +55,7 @@ module Phronomy
55
55
  def requires_approval?: () -> untyped
56
56
  def tool_origin: () -> Symbol
57
57
  def approval_metadata: () -> Hash[untyped, untyped]
58
+ def approval_display: (arguments: Hash[String | Symbol, untyped], facts: Hash[untyped, untyped]) -> { arguments: Hash[String | Symbol, untyped], facts: Hash[untyped, untyped] }
58
59
  def execute: (**untyped args) -> untyped
59
60
 
60
61
  private
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phronomy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.32.0
4
+ version: 0.33.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raizo T.C.S
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-11-03 00:00:00.000000000 Z
11
+ date: 2026-11-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -257,6 +257,7 @@ files:
257
257
  - docs/archive/design/historical/06_design_decisions.md
258
258
  - docs/async-composition.md
259
259
  - docs/changelog/0.14-and-earlier.md
260
+ - docs/context-policy-budgeting.md
260
261
  - docs/decisions/001-rubyllm-as-provider-layer.md
261
262
  - docs/decisions/002-workflow-context-immutability.md
262
263
  - docs/decisions/003-event-loop-singleton.md
@@ -333,6 +334,7 @@ files:
333
334
  - docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md
334
335
  - docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md
335
336
  - docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md
337
+ - docs/examples/shared_state_custom_sharing.rb
336
338
  - docs/execution-boundaries-preview.md
337
339
  - docs/failure-reason-codes.md
338
340
  - docs/features.md
@@ -376,6 +378,7 @@ files:
376
378
  - docs/ordered-event-delivery.md
377
379
  - docs/persistence-backends.md
378
380
  - docs/runtime-and-concurrency.md
381
+ - docs/shared-state-sharing.md
379
382
  - docs/workflow-completion.md
380
383
  - examples/README.md
381
384
  - gemfiles/http_minimum.gemfile
@@ -616,6 +619,8 @@ files:
616
619
  - lib/phronomy/multi_agent/reserved_child_admission.rb
617
620
  - lib/phronomy/multi_agent/runtime_binding/engine_environment.rb
618
621
  - lib/phronomy/multi_agent/shared_state.rb
622
+ - lib/phronomy/multi_agent/shared_state/binding.rb
623
+ - lib/phronomy/multi_agent/shared_state/member_lifecycle_error.rb
619
624
  - lib/phronomy/multi_agent/store.rb
620
625
  - lib/phronomy/multi_agent/team_busy_error.rb
621
626
  - lib/phronomy/multi_agent/team_coordinator.rb