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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +139 -0
- data/CONTRIBUTING.md +5 -5
- data/README.md +20 -1
- data/RELEASE_CHECKLIST.md +7 -1
- data/SECURITY.md +1 -1
- data/VERIFY.sh +1 -9
- data/docs/architecture/error-api-review.md +6 -2
- data/docs/architecture/structured-output-and-scope.md +8 -8
- data/docs/architecture/trust-acceptance-followup.md +15 -0
- data/docs/context-policy-budgeting.md +108 -0
- data/docs/decisions/016-semantic-multi-agent-handoff.md +6 -5
- data/docs/examples/shared_state_custom_sharing.rb +145 -0
- data/docs/features.md +4 -2
- data/docs/mcp-client.md +4 -2
- data/docs/migrations/cleanup-audit.md +89 -0
- data/docs/migrations/legacy-format-removal.md +55 -0
- data/docs/migrations/r8-unit6.md +4 -2
- data/docs/migrations/shared-state-multi-agent.md +5 -0
- data/docs/migrations/vector-async-clients.md +6 -1
- data/docs/runtime-and-concurrency.md +36 -0
- data/docs/shared-state-sharing.md +233 -0
- data/lib/phronomy/agent/base.rb +53 -0
- data/lib/phronomy/agent/execution/agent_invocation.rb +0 -4
- data/lib/phronomy/agent/execution/execution_coordinator.rb +11 -0
- data/lib/phronomy/agent/handoff/transfer_projection.rb +0 -9
- data/lib/phronomy/agent/journal/journal_record.rb +2 -2
- data/lib/phronomy/agent/lifecycle/hosted_invocation.rb +2 -5
- data/lib/phronomy/agent/recovery/recovery_rules.rb +0 -12
- data/lib/phronomy/agent/store.rb +3 -8
- data/lib/phronomy/agent/tool_execution/tool_approval_request.rb +3 -2
- data/lib/phronomy/agent/tool_execution/tool_invocation.rb +9 -36
- data/lib/phronomy/context/context_policy.rb +13 -8
- data/lib/phronomy/context/context_policy_input.rb +26 -0
- data/lib/phronomy/context/llm_input_manifest.rb +3 -3
- data/lib/phronomy/engine/runtime/shutdown_result.rb +0 -5
- data/lib/phronomy/execution/execution.rb +0 -11
- data/lib/phronomy/multi_agent/shared_state/binding.rb +44 -0
- data/lib/phronomy/multi_agent/shared_state/member_lifecycle_error.rb +32 -0
- data/lib/phronomy/multi_agent/shared_state.rb +152 -10
- data/lib/phronomy/tool/base.rb +69 -3
- data/lib/phronomy/tool/schema.rb +23 -11
- data/lib/phronomy/version.rb +1 -1
- data/scripts/api_snapshot.rb +6 -3
- data/sig/phronomy/agent.rbs +1 -0
- data/sig/phronomy/context.rbs +3 -0
- data/sig/phronomy/multi_agent.rbs +26 -2
- data/sig/phronomy/tool.rbs +1 -0
- metadata +9 -11
- data/lib/phronomy/execution/scheduler_reentrancy_error.rb +0 -9
- data/lib/phronomy/persistence/migration/initial_format_migration.rb +0 -226
- data/scripts/add_to_h_to_token_doubles.rb +0 -33
- data/scripts/add_to_h_unnamed_doubles.rb +0 -27
- data/scripts/migrate_generator_verifier_result.rb +0 -18
- data/scripts/migrate_spec_agent_definition.rb +0 -108
- data/scripts/migrate_spec_agent_definition_pass2.rb +0 -53
- data/scripts/migrate_spec_inline_pass3.rb +0 -24
- 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
|
-
|
|
46
|
-
task: (child && !@resumed_from) ? nil : @task, parent: child ? @agent : nil
|
|
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
|
data/lib/phronomy/agent/store.rb
CHANGED
|
@@ -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 &&
|
|
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(
|
|
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:
|
|
109
|
-
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
341
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
153
|
-
#
|
|
154
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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)
|
data/lib/phronomy/tool/base.rb
CHANGED
|
@@ -363,10 +363,17 @@ module Phronomy
|
|
|
363
363
|
# @api public
|
|
364
364
|
def validate_arguments(args)
|
|
365
365
|
schema = parameters_schema
|
|
366
|
-
|
|
367
|
-
|
|
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 =
|
|
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
|