phronomy 0.32.0 → 0.34.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -0
  3. data/CONTRIBUTING.md +5 -5
  4. data/README.md +20 -1
  5. data/RELEASE_CHECKLIST.md +7 -1
  6. data/SECURITY.md +1 -1
  7. data/VERIFY.sh +1 -9
  8. data/docs/architecture/error-api-review.md +6 -2
  9. data/docs/architecture/structured-output-and-scope.md +8 -8
  10. data/docs/architecture/trust-acceptance-followup.md +15 -0
  11. data/docs/context-policy-budgeting.md +108 -0
  12. data/docs/decisions/016-semantic-multi-agent-handoff.md +6 -5
  13. data/docs/examples/shared_state_custom_sharing.rb +145 -0
  14. data/docs/features.md +4 -2
  15. data/docs/mcp-client.md +4 -2
  16. data/docs/migrations/cleanup-audit.md +89 -0
  17. data/docs/migrations/legacy-format-removal.md +55 -0
  18. data/docs/migrations/r8-unit6.md +4 -2
  19. data/docs/migrations/shared-state-multi-agent.md +5 -0
  20. data/docs/migrations/vector-async-clients.md +6 -1
  21. data/docs/runtime-and-concurrency.md +36 -0
  22. data/docs/shared-state-sharing.md +233 -0
  23. data/lib/phronomy/agent/base.rb +53 -0
  24. data/lib/phronomy/agent/execution/agent_invocation.rb +0 -4
  25. data/lib/phronomy/agent/execution/execution_coordinator.rb +11 -0
  26. data/lib/phronomy/agent/handoff/transfer_projection.rb +0 -9
  27. data/lib/phronomy/agent/journal/journal_record.rb +2 -2
  28. data/lib/phronomy/agent/lifecycle/hosted_invocation.rb +2 -5
  29. data/lib/phronomy/agent/recovery/recovery_rules.rb +0 -12
  30. data/lib/phronomy/agent/store.rb +3 -8
  31. data/lib/phronomy/agent/tool_execution/tool_approval_request.rb +3 -2
  32. data/lib/phronomy/agent/tool_execution/tool_invocation.rb +9 -36
  33. data/lib/phronomy/context/context_policy.rb +13 -8
  34. data/lib/phronomy/context/context_policy_input.rb +26 -0
  35. data/lib/phronomy/context/llm_input_manifest.rb +3 -3
  36. data/lib/phronomy/engine/runtime/shutdown_result.rb +0 -5
  37. data/lib/phronomy/execution/execution.rb +0 -11
  38. data/lib/phronomy/multi_agent/shared_state/binding.rb +44 -0
  39. data/lib/phronomy/multi_agent/shared_state/member_lifecycle_error.rb +32 -0
  40. data/lib/phronomy/multi_agent/shared_state.rb +152 -10
  41. data/lib/phronomy/tool/base.rb +69 -3
  42. data/lib/phronomy/tool/schema.rb +23 -11
  43. data/lib/phronomy/version.rb +1 -1
  44. data/scripts/api_snapshot.rb +6 -3
  45. data/sig/phronomy/agent.rbs +1 -0
  46. data/sig/phronomy/context.rbs +3 -0
  47. data/sig/phronomy/multi_agent.rbs +26 -2
  48. data/sig/phronomy/tool.rbs +1 -0
  49. metadata +9 -11
  50. data/lib/phronomy/execution/scheduler_reentrancy_error.rb +0 -9
  51. data/lib/phronomy/persistence/migration/initial_format_migration.rb +0 -226
  52. data/scripts/add_to_h_to_token_doubles.rb +0 -33
  53. data/scripts/add_to_h_unnamed_doubles.rb +0 -27
  54. data/scripts/migrate_generator_verifier_result.rb +0 -18
  55. data/scripts/migrate_spec_agent_definition.rb +0 -108
  56. data/scripts/migrate_spec_agent_definition_pass2.rb +0 -53
  57. data/scripts/migrate_spec_inline_pass3.rb +0 -24
  58. data/sig/phronomy/persistence/migration.rbs +0 -13
@@ -37,15 +37,12 @@ module Phronomy
37
37
  unless wait && wait.approval_request_id == approval_request_id
38
38
  raise Phronomy::AgentBusyError, "No matching hosted approval wait"
39
39
  end
40
- completion = Phronomy::TaskResult.deferred(name: "detach-hosted-approval")
41
40
  owner = @environment.registry.agent_execution_owner(execution_id)
42
41
  unless owner && (child ? owner.agent.persistence.equal?(@agent.persistence) : owner.agent.equal?(@agent))
43
42
  raise Phronomy::AgentBusyError, "Hosted execution has no matching local owner"
44
43
  end
45
- command = ExecutionCoordinator::DetachHostedWait.new(coordinator: owner.coordinator, wait: wait,
46
- task: (child && !@resumed_from) ? nil : @task, parent: child ? @agent : nil, completion: completion)
47
- accepted = @environment.registry.post(command, completion: completion)
48
- raise Phronomy::RuntimeShutdownError, "EventLoop rejected hosted detach" unless accepted
44
+ completion = owner.coordinator.request_hosted_detach(wait,
45
+ task: (child && !@resumed_from) ? nil : @task, parent: child ? @agent : nil)
49
46
  completion.wait_result
50
47
  end
51
48
 
@@ -88,18 +88,6 @@ module Phronomy
88
88
  normalized.freeze
89
89
  end
90
90
 
91
- def subject_key(subject)
92
- normalized = normalize_subject(subject)
93
- case normalized.fetch(:type)
94
- when :llm_call
95
- "llm_call:#{normalized.fetch(:llm_call_id)}"
96
- when :tool_invocation
97
- "tool_invocation:#{normalized.fetch(:tool_invocation_id)}"
98
- when :persistence_operation
99
- "persistence_operation:#{normalized.fetch(:entity)}"
100
- end
101
- end
102
-
103
91
  def subject_equal?(left, right)
104
92
  normalize_subject(left) == normalize_subject(right)
105
93
  rescue ArgumentError, KeyError
@@ -48,6 +48,7 @@ module Phronomy
48
48
 
49
49
  # Validate the exact requested call and project its authorized batch in
50
50
  # Provider order from one execution snapshot in the caller's transaction.
51
+ # Only the validated dispatch snapshot is authorization evidence.
51
52
  # The receiving domain owns application and replay of these operations.
52
53
  def authorized_operations(scope, agent_id:, execution_id:, invocation_id:, name:, arguments:, names:)
53
54
  participate(scope) do |records|
@@ -58,7 +59,7 @@ module Phronomy
58
59
  batch = Array(execution.metadata[ExecutionMetadata::TOOL_BATCH_METADATA_KEY])
59
60
  requested = batch.find { |entry| entry.fetch("tool_invocation_id") == invocation_id }
60
61
  unless names.include?(name) && requested && requested.fetch("status") == "authorized" &&
61
- requested.fetch("tool_name") == name && authorized_argument_values(requested) == arguments
62
+ requested.fetch("tool_name") == name && requested.fetch("validated_arguments") == arguments
62
63
  raise Phronomy::Persistence::StateConflictError, "Operation #{invocation_id} is not the authorized call"
63
64
  end
64
65
  batch.filter_map do |entry|
@@ -66,7 +67,7 @@ module Phronomy
66
67
  AuthorizedOperation.new(
67
68
  invocation_id: Phronomy::Values::Immutable.copy(entry.fetch("tool_invocation_id")),
68
69
  name: Phronomy::Values::Immutable.copy(entry.fetch("tool_name")),
69
- arguments: Phronomy::Values::Immutable.copy(authorized_argument_values(entry))
70
+ arguments: Phronomy::Values::Immutable.copy(entry.fetch("validated_arguments"))
70
71
  )
71
72
  end.freeze
72
73
  end
@@ -286,12 +287,6 @@ module Phronomy
286
287
  Phronomy::Values::Immutable.json(contents.fetch_json(execution.result_ref)) : contents.fetch_text(execution.result_ref)
287
288
  end
288
289
 
289
- def authorized_argument_values(entry)
290
- # Unit 6 persists the exact validated snapshot. Older records encoded
291
- # legacy raw params, whose explicit optional nil meant omission.
292
- entry.key?("validated_arguments") ? entry.fetch("validated_arguments") : entry.fetch("arguments").compact
293
- end
294
-
295
290
  def with_scope(scope, &block)
296
291
  assert_observation_thread!
297
292
  scope ? participate(scope, &block) : transaction(&block)
@@ -101,12 +101,13 @@ module Phronomy
101
101
  end
102
102
 
103
103
  def self.build_item(invocation)
104
+ display = invocation.approval_display
104
105
  Item.new(
105
106
  tool_invocation_id: invocation.id,
106
107
  tool_call_id: invocation.tool_call_id,
107
108
  tool_name: invocation.tool_name,
108
- arguments: invocation.display_arguments,
109
- facts: invocation.display_facts,
109
+ arguments: display.fetch(:arguments),
110
+ facts: display.fetch(:facts),
110
111
  reason: invocation.authorization_reason,
111
112
  origin: invocation.origin,
112
113
  metadata: invocation.metadata
@@ -333,12 +333,16 @@ module Phronomy
333
333
  @tool&.respond_to?(:parameters_schema) ? @tool.parameters_schema : {}
334
334
  end
335
335
 
336
- def display_arguments
337
- redact_for_display(@arguments || @raw_arguments)
338
- end
336
+ # The approval Item snapshots these fields at its own construction boundary.
337
+ # @api private
338
+ def approval_display
339
+ return {arguments: {}.freeze, facts: {}.freeze}.freeze unless @tool&.respond_to?(:approval_display)
339
340
 
340
- def display_facts
341
- redact_value(@facts, sensitive_argument_values)
341
+ display = @tool.approval_display(arguments: @arguments || @raw_arguments, facts: @facts)
342
+ unless Hash === display && Hash === display.fetch(:arguments, nil) && Hash === display.fetch(:facts, nil)
343
+ raise Phronomy::ConfigurationError, "Tool approval_display must return :arguments and :facts Hashes"
344
+ end
345
+ display
342
346
  end
343
347
 
344
348
  private
@@ -544,37 +548,6 @@ module Phronomy
544
548
  def immutable_copy(value)
545
549
  Phronomy::Values::Immutable.copy(value)
546
550
  end
547
-
548
- def redact_for_display(value)
549
- if @tool&.respond_to?(:redacted_args, true)
550
- immutable_copy(@tool.send(:redacted_args, value || {}))
551
- else
552
- immutable_copy(value || {})
553
- end
554
- end
555
-
556
- def sensitive_argument_values
557
- return [] unless @tool&.class&.respond_to?(:redact_params)
558
- normalized = (@arguments || @raw_arguments || {}).transform_keys(&:to_sym)
559
- @tool.class.redact_params.filter_map { |name| normalized[name] }
560
- end
561
-
562
- def redact_value(value, sensitive_values)
563
- return "[REDACTED]" if sensitive_values.any? { |sensitive| sensitive == value }
564
-
565
- case value
566
- when Hash
567
- value.each_with_object({}) do |(key, item), result|
568
- result[key] = redact_value(item, sensitive_values)
569
- end.freeze
570
- when Array
571
- value.map { |item| redact_value(item, sensitive_values) }.freeze
572
- when String
573
- "[REDACTED]"
574
- else
575
- value
576
- end
577
- end
578
551
  end
579
552
  end
580
553
  end
@@ -11,6 +11,16 @@ module Phronomy
11
11
 
12
12
  protected
13
13
 
14
+ # Uses the same canonical representation and estimator as generated Policy
15
+ # items. Estimates are planning values, not a model tokenizer guarantee.
16
+ # @param content [Object] text or JSON-serializable content
17
+ # @return [Integer] estimated token count
18
+ # @api public
19
+ def estimate_tokens(content)
20
+ bytes = content.is_a?(String) ? content : Phronomy::CanonicalJSON.dump(content)
21
+ Phronomy::LlmContextWindow::TokenEstimator.estimate(bytes)
22
+ end
23
+
14
24
  def instruction_item(
15
25
  content:,
16
26
  role: :system,
@@ -25,7 +35,7 @@ module Phronomy
25
35
  role: role,
26
36
  content: content,
27
37
  content_format: content_format || inferred_content_format(content),
28
- estimated_tokens: estimate_content(content),
38
+ estimated_tokens: estimate_tokens(content),
29
39
  required: required,
30
40
  provenance: generated_provenance,
31
41
  metadata: metadata
@@ -46,7 +56,7 @@ module Phronomy
46
56
  role: role,
47
57
  content: content,
48
58
  content_format: content_format || inferred_content_format(content),
49
- estimated_tokens: estimate_content(content),
59
+ estimated_tokens: estimate_tokens(content),
50
60
  required: required,
51
61
  provenance: generated_provenance,
52
62
  metadata: metadata
@@ -72,7 +82,7 @@ module Phronomy
72
82
  content: content,
73
83
  content_format: content_format || inferred_content_format(content),
74
84
  sequence: sequence,
75
- estimated_tokens: estimate_content(content),
85
+ estimated_tokens: estimate_tokens(content),
76
86
  required: required,
77
87
  provenance: generated_provenance,
78
88
  tool_call_id: tool_call_id,
@@ -111,11 +121,6 @@ module Phronomy
111
121
  def inferred_content_format(content)
112
122
  content.is_a?(String) ? :text : :json
113
123
  end
114
-
115
- def estimate_content(content)
116
- bytes = content.is_a?(String) ? content : Phronomy::CanonicalJSON.dump(content)
117
- Phronomy::LlmContextWindow::TokenEstimator.estimate(bytes)
118
- end
119
124
  end
120
125
  end
121
126
  end
@@ -56,6 +56,32 @@ module Phronomy
56
56
  freeze
57
57
  end
58
58
 
59
+ # Reports the input limit supplied to this Policy without exposing the
60
+ # framework's budget representation. Unknown capability remains nil.
61
+ # @return [Integer, nil]
62
+ # @api public
63
+ def input_token_limit
64
+ token_budget&.effective_input_limit
65
+ end
66
+
67
+ # Returns a selection input with an application limit. A known limit may
68
+ # only be retained or reduced. An unknown limit accepts an explicit cap
69
+ # without asserting that the model can accept that many tokens.
70
+ # @param limit [Integer] positive selection limit
71
+ # @return [ContextPolicyInput]
72
+ # @raise [ArgumentError] for an invalid limit or a known-limit increase
73
+ # @api public
74
+ def with_input_token_limit(limit)
75
+ unless limit.is_a?(Integer) && limit.positive?
76
+ raise ArgumentError, "input token limit must be a positive Integer"
77
+ end
78
+ if input_token_limit && limit > input_token_limit
79
+ raise ArgumentError, "input token limit must not exceed the current limit"
80
+ end
81
+
82
+ with(token_budget: Phronomy::LlmContextWindow::TokenBudget.new(max_input_tokens: limit))
83
+ end
84
+
59
85
  private
60
86
 
61
87
  def validate_items!(items, expected_class, label)
@@ -149,9 +149,9 @@ module Phronomy
149
149
  freeze
150
150
  end
151
151
 
152
- # Current-format-only durable decoder. Historical format conversion is an
153
- # explicit migration operation and is never attempted here. Unlike the
154
- # Ruby construction API, this decoder never coerces durable field types.
152
+ # Current-format-only durable decoder. Historical versions are rejected.
153
+ # Unlike the Ruby construction API, this decoder never coerces durable
154
+ # field types.
155
155
  def self.from_h(hash)
156
156
  source = strict_source!(
157
157
  hash,
@@ -52,11 +52,6 @@ module Phronomy
52
52
  %i[not_started terminated].include?(@event_loop_status) &&
53
53
  @task_registry_status == :empty
54
54
  end
55
-
56
- # Runtime reset is safe when cleanup completed, even if execution failed.
57
- def success?
58
- cleanup_complete?
59
- end
60
55
  end
61
56
  end
62
57
  end
@@ -127,22 +127,11 @@ module Phronomy
127
127
  observed
128
128
  end
129
129
 
130
- # Scope/admission hooks shared with framework-owned operations.
131
- # @api private
132
- def __while_open(&block)
133
- @scope.__while_open(&block)
134
- end
135
-
136
130
  # @api private
137
131
  def __open?
138
132
  @scope.__open?
139
133
  end
140
134
 
141
- # @api private
142
- def __cancellation_token
143
- @token
144
- end
145
-
146
135
  # @api private
147
136
  def __cancellation_error
148
137
  @scope.__cancellation_error
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module MultiAgent
5
+ class SharedState
6
+ # Connects one application's shared data to the Tools offered to members.
7
+ # The application owns the store's shape, operations and resource lifetime.
8
+ # @api public
9
+ class Binding
10
+ # @param store [Object] opaque application data passed to coordination callbacks
11
+ # @param tools [#call] builder accepting member: (Agent class) and cycle:
12
+ # @api public
13
+ def initialize(store:, tools:)
14
+ raise ArgumentError, "sharing tools must respond to call" unless tools.respond_to?(:call)
15
+
16
+ @store = store
17
+ @tools = tools
18
+ freeze
19
+ end
20
+
21
+ # @api public
22
+ attr_reader :store
23
+
24
+ # Build invocation-bound Tools using the existing Agent.tools declaration shape.
25
+ # The builder receives the registered Agent class, not an Agent instance.
26
+ # @return [Hash<Class, String, nil>] Tool classes mapped to aliases or nil
27
+ # @api public
28
+ def tool_definitions(member:, cycle:)
29
+ definitions = @tools.call(member: member, cycle: cycle)
30
+ unless definitions.is_a?(Hash)
31
+ raise ArgumentError, "sharing tools must return a Hash of ToolClass => alias_or_nil"
32
+ end
33
+
34
+ definitions.to_h do |tool_class, alias_name|
35
+ unless tool_class.is_a?(Class) && tool_class <= Phronomy::Tool::Base
36
+ raise ArgumentError, "sharing tools must contain Phronomy::Tool::Base classes"
37
+ end
38
+ [tool_class, alias_name&.to_s&.dup&.freeze]
39
+ end.freeze
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module MultiAgent
5
+ class SharedState
6
+ # Keeps the member handle when invocation-scoped sharing cannot finish safely.
7
+ # Callers choose approval, cancellation or release through existing Agent APIs.
8
+ # @api public
9
+ class MemberLifecycleError < Phronomy::Error
10
+ # @api public
11
+ attr_reader :agent, :result, :reason, :primary_error, :release_error
12
+
13
+ # The live Agent and application resources are process-local, not a
14
+ # durable recipe for rebuilding the sharing provider or its Tools.
15
+ # @api public
16
+ def initialize(agent:, result:, reason:, primary_error: nil, release_error: nil)
17
+ @agent = agent
18
+ @result = result
19
+ @reason = reason
20
+ @primary_error = primary_error
21
+ @release_error = release_error
22
+ message = if reason == :suspended
23
+ "SharedState sharing cannot complete while a member is suspended"
24
+ else
25
+ "SharedState sharing could not release a member: #{release_error&.message}"
26
+ end
27
+ super(message)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -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,
@@ -178,14 +323,11 @@ module Phronomy
178
323
  # application's definition-version namespace independent from framework
179
324
  # instrumentation.
180
325
  instrumented_def = instrumented_definition_for(researcher_class)
181
- Class.new(researcher_class) do
182
- model researcher_class.model
183
- agent_definition(
184
- id: instrumented_def.fetch(:id),
185
- version: instrumented_def.fetch(:version)
186
- )
187
- tools(definitions)
188
- end
326
+ researcher_class.with_tools(
327
+ definitions,
328
+ id: instrumented_def.fetch(:id),
329
+ version: instrumented_def.fetch(:version)
330
+ )
189
331
  end
190
332
 
191
333
  def build_read_tool(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