phronomy 0.28.0 → 0.30.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 +96 -1
- data/README.md +32 -6
- data/docs/application-recipes.md +229 -0
- data/docs/architecture/agent-configuration-and-tool-binding.md +8 -0
- data/docs/architecture/agent-iteration-budget.md +81 -0
- data/docs/architecture/dx05-agent-scope.md +126 -0
- data/docs/architecture/dx05-workflow-task-state.md +134 -0
- data/docs/architecture/error-api-review.md +245 -0
- data/docs/architecture/generator-verifier-lifecycle-result.md +126 -0
- data/docs/architecture/generator-verifier-ownership.md +18 -109
- data/docs/architecture/llm-continuation-metadata.md +88 -0
- data/docs/architecture/lm-studio-output-failures.md +111 -0
- data/docs/architecture/r8-unit15.md +3 -0
- data/docs/architecture/r8-unit7.md +2 -1
- data/docs/architecture/structured-output-and-scope.md +183 -0
- data/docs/architecture/tracing.md +22 -4
- data/docs/architecture/transport-error-boundaries.md +103 -0
- data/docs/architecture/trust-acceptance-followup.md +87 -0
- data/docs/failure-reason-codes.md +45 -0
- data/docs/features.md +14 -2
- data/docs/getting-started.md +44 -43
- data/docs/migrations/r8-unit15.md +3 -0
- data/docs/ordered-event-delivery.md +54 -0
- data/docs/workflow-completion.md +92 -0
- data/gemfiles/http_minimum.gemfile +10 -0
- data/lib/phronomy/agent/admission.rb +1 -1
- data/lib/phronomy/agent/agent_not_found_error.rb +16 -0
- data/lib/phronomy/agent/agent_unloaded_error.rb +14 -0
- data/lib/phronomy/agent/api/agent.rb +13 -0
- data/lib/phronomy/agent/async_event_api.rb +121 -88
- data/lib/phronomy/agent/base.rb +82 -44
- data/lib/phronomy/agent/before_llm_input.rb +7 -1
- data/lib/phronomy/agent/context_importer.rb +4 -0
- data/lib/phronomy/agent/execution/agent_invocation.rb +6 -2
- data/lib/phronomy/agent/execution/dispatch_preparation.rb +6 -1
- data/lib/phronomy/agent/execution/exact_execution.rb +20 -9
- data/lib/phronomy/agent/execution/execution_cancellation.rb +1 -1
- data/lib/phronomy/agent/execution/execution_coordinator.rb +79 -18
- data/lib/phronomy/agent/execution/execution_outcome_committer.rb +24 -4
- data/lib/phronomy/agent/execution/execution_registry.rb +5 -1
- data/lib/phronomy/agent/execution/initial_preparation.rb +11 -5
- data/lib/phronomy/agent/execution/invocation_actions.rb +1 -1
- data/lib/phronomy/agent/execution/invocation_transitions.rb +4 -4
- data/lib/phronomy/agent/execution/iteration_budget.rb +35 -0
- data/lib/phronomy/agent/execution/runtime_record_encoder.rb +4 -4
- data/lib/phronomy/agent/execution_observation.rb +5 -5
- data/lib/phronomy/agent/filterable.rb +21 -15
- data/lib/phronomy/agent/lifecycle/lifetime_api.rb +76 -0
- data/lib/phronomy/agent/lifecycle/operation_lease.rb +33 -0
- data/lib/phronomy/agent/lifecycle/operation_result.rb +53 -0
- data/lib/phronomy/agent/lifecycle/ownership_registry.rb +99 -8
- data/lib/phronomy/agent/lifecycle/scope.rb +208 -0
- data/lib/phronomy/agent/lifecycle/scope_cleanup_error.rb +21 -0
- data/lib/phronomy/agent/lifecycle/unload_transition.rb +78 -0
- data/lib/phronomy/agent/output_contract.rb +64 -0
- data/lib/phronomy/agent/output_error.rb +53 -0
- data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +6 -6
- data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +8 -7
- data/lib/phronomy/agent/recovery/recovery_coordinator/resolution.rb +14 -18
- data/lib/phronomy/agent/recovery/recovery_coordinator.rb +6 -10
- data/lib/phronomy/agent/recovery/recovery_support.rb +6 -8
- data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +2 -1
- data/lib/phronomy/agent/runtime_input.rb +15 -4
- data/lib/phronomy/agent/saved_context_reader.rb +2 -1
- data/lib/phronomy/agent/store.rb +10 -2
- data/lib/phronomy/agent/stream_callback_error.rb +2 -2
- data/lib/phronomy/common/error.rb +65 -1
- data/lib/phronomy/common/values/immutable.rb +33 -0
- data/lib/phronomy/content_store/base.rb +7 -2
- data/lib/phronomy/context/assembly.rb +18 -22
- data/lib/phronomy/embeddings/authentication_error.rb +11 -0
- data/lib/phronomy/embeddings/backends/ruby_llm_embeddings.rb +8 -2
- data/lib/phronomy/embeddings/rate_limit_error.rb +11 -0
- data/lib/phronomy/engine/fsm_session.rb +16 -1
- data/lib/phronomy/execution/execution_cancellation_error.rb +2 -2
- data/lib/phronomy/execution/execution_timeout_error.rb +2 -2
- data/lib/phronomy/execution/task_result.rb +7 -2
- data/lib/phronomy/filter/filter_block_error.rb +2 -2
- data/lib/phronomy/generation/generator_verifier/agent_operation.rb +28 -5
- data/lib/phronomy/generation/generator_verifier/agent_result_receiver.rb +14 -27
- data/lib/phronomy/generation/generator_verifier/approval.rb +30 -0
- data/lib/phronomy/generation/generator_verifier/cleanup_error.rb +15 -0
- data/lib/phronomy/generation/generator_verifier/invocation.rb +147 -0
- data/lib/phronomy/generation/generator_verifier/pipeline_state.rb +16 -0
- data/lib/phronomy/generation/generator_verifier/protocol.rb +55 -0
- data/lib/phronomy/generation/generator_verifier/result.rb +121 -0
- data/lib/phronomy/generation/generator_verifier/unaccepted_result_error.rb +14 -0
- data/lib/phronomy/generation/generator_verifier/workflow_builder.rb +13 -11
- data/lib/phronomy/generation/generator_verifier.rb +170 -85
- data/lib/phronomy/integrations/ordered_event_delivery.rb +208 -0
- data/lib/phronomy/llm_adapter/backends/lm_studio.rb +139 -0
- data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +134 -12
- data/lib/phronomy/llm_adapter/backends/structured_response_capture.rb +41 -0
- data/lib/phronomy/llm_adapter/message.rb +4 -0
- data/lib/phronomy/llm_adapter/request.rb +17 -2
- data/lib/phronomy/llm_adapter/response.rb +11 -4
- data/lib/phronomy/llm_adapter/structured_output_unsupported_error.rb +14 -0
- data/lib/phronomy/llm_adapter/transport_error.rb +4 -0
- data/lib/phronomy/multi_agent/persistence/team_execution_repository.rb +5 -5
- data/lib/phronomy/multi_agent/shared_state.rb +1 -0
- data/lib/phronomy/multi_agent/team_busy_error.rb +18 -0
- data/lib/phronomy/multi_agent/team_coordinator.rb +20 -7
- data/lib/phronomy/multi_agent/team_not_found_error.rb +18 -0
- data/lib/phronomy/output_parser/json_schema_parser.rb +113 -0
- data/lib/phronomy/output_parser/parse_error.rb +10 -1
- data/lib/phronomy/runtime_composition/agent_defaults.rb +4 -0
- data/lib/phronomy/storage/condition_failed_error.rb +2 -2
- data/lib/phronomy/storage/unique_constraint_error.rb +2 -2
- data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +1 -1
- data/lib/phronomy/tool/base.rb +25 -23
- data/lib/phronomy/tracing/automatic.rb +14 -8
- data/lib/phronomy/tracing/base.rb +37 -7
- data/lib/phronomy/tracing/langfuse_tracer.rb +3 -3
- data/lib/phronomy/tracing/observation.rb +2 -1
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +7 -32
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/execution/workflow.rb +30 -2
- data/lib/phronomy/workflow/execution/workflow_execution_environment.rb +1 -0
- data/lib/phronomy/workflow/execution/workflow_execution_registry.rb +74 -3
- data/lib/phronomy/workflow/execution/workflow_runner.rb +48 -14
- data/lib/phronomy/workflow/execution/workflow_task_execution.rb +153 -0
- data/lib/phronomy/workflow/execution/workflow_task_reception.rb +72 -0
- data/lib/phronomy/workflow/execution/workflow_task_resume_error.rb +14 -0
- data/lib/phronomy/workflow/runtime_binding/workflow_engine_environment.rb +26 -3
- data/lib/phronomy/workflow/runtime_binding/workflow_phase_machine_builder.rb +3 -1
- data/lib/phronomy/workflow/runtime_binding/workflow_task_session.rb +76 -0
- data/lib/phronomy.rb +7 -2
- data/scripts/api_snapshot.rb +15 -0
- data/scripts/check_readme_ruby.rb +24 -29
- data/scripts/check_readme_runnable.rb +92 -81
- data/scripts/migrate_generator_verifier_result.rb +18 -0
- data/sig/phronomy/agent/agent_not_found_error.rbs +6 -0
- data/sig/phronomy/agent/agent_unloaded_error.rbs +6 -0
- data/sig/phronomy/agent/execution_contract.rbs +4 -3
- data/sig/phronomy/agent/stream_callback_error.rbs +8 -0
- data/sig/phronomy/agent.rbs +36 -5
- data/sig/phronomy/embeddings/base.rbs +6 -0
- data/sig/phronomy/execution/execution.rbs +2 -2
- data/sig/phronomy/extensions.rbs +8 -2
- data/sig/phronomy/filter/filter_block_error.rbs +6 -0
- data/sig/phronomy/generation/generator_verifier.rbs +70 -0
- data/sig/phronomy/integrations.rbs +14 -0
- data/sig/phronomy/llm_adapter/backends/lm_studio.rbs +8 -0
- data/sig/phronomy/llm_adapter/backends/ruby_llm.rbs +1 -0
- data/sig/phronomy/llm_adapter/values.rbs +8 -2
- data/sig/phronomy/multi_agent/team_busy_error.rbs +8 -0
- data/sig/phronomy/multi_agent/team_not_found_error.rbs +8 -0
- data/sig/phronomy/storage/contracts.rbs +2 -2
- data/sig/phronomy/tracing/observation.rbs +1 -1
- data/sig/phronomy/workflow/execution/workflow_execution_environment.rbs +1 -0
- data/sig/phronomy/workflow.rbs +4 -0
- data/sig/phronomy.rbs +6 -0
- metadata +103 -5
- data/lib/phronomy/generation/generator_verifier/default_parser.rb +0 -27
- data/lib/phronomy/generation/low_confidence_error.rb +0 -14
- data/lib/phronomy/runtime_composition/generation_defaults.rb +0 -6
|
@@ -11,6 +11,39 @@ module Phronomy
|
|
|
11
11
|
module Immutable
|
|
12
12
|
module_function
|
|
13
13
|
|
|
14
|
+
# Normalize JSON object names without coercing values or losing duplicates.
|
|
15
|
+
# @api private
|
|
16
|
+
def json(value, ancestors: [])
|
|
17
|
+
if (value.is_a?(Hash) || value.is_a?(Array)) && ancestors.include?(value.object_id)
|
|
18
|
+
raise ArgumentError, "cyclic JSON value"
|
|
19
|
+
end
|
|
20
|
+
children = ancestors + [value.object_id]
|
|
21
|
+
normalized = case value
|
|
22
|
+
when Hash
|
|
23
|
+
value.each_with_object({}) do |(key, child), result|
|
|
24
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
25
|
+
raise ArgumentError, "JSON object keys must be Strings or Symbols"
|
|
26
|
+
end
|
|
27
|
+
name = key.to_s
|
|
28
|
+
raise ArgumentError, "duplicate JSON object key: #{name}" if result.key?(name)
|
|
29
|
+
result[name.dup.freeze] = json(child, ancestors: children)
|
|
30
|
+
end
|
|
31
|
+
when Array then value.map { |child| json(child, ancestors: children) }
|
|
32
|
+
when String
|
|
33
|
+
text = value.encode(Encoding::UTF_8)
|
|
34
|
+
raise ArgumentError, "invalid UTF-8 JSON string" unless text.valid_encoding?
|
|
35
|
+
text.dup
|
|
36
|
+
when Integer, TrueClass, FalseClass, NilClass then value
|
|
37
|
+
when Float
|
|
38
|
+
raise ArgumentError, "JSON numbers must be finite" unless value.finite?
|
|
39
|
+
value
|
|
40
|
+
else raise ArgumentError, "unsupported JSON value: #{value.class}"
|
|
41
|
+
end
|
|
42
|
+
normalized.freeze
|
|
43
|
+
rescue EncodingError
|
|
44
|
+
raise ArgumentError, "invalid UTF-8 JSON string"
|
|
45
|
+
end
|
|
46
|
+
|
|
14
47
|
# @api private
|
|
15
48
|
def copy(value)
|
|
16
49
|
case value
|
|
@@ -28,13 +28,18 @@ module Phronomy
|
|
|
28
28
|
def fetch_text(content_id)
|
|
29
29
|
value = fetch(content_id).force_encoding(Encoding::UTF_8)
|
|
30
30
|
unless value.valid_encoding?
|
|
31
|
-
raise IntegrityError
|
|
31
|
+
raise IntegrityError.new("content is not UTF-8: #{content_id}", code: "content.invalid_encoding")
|
|
32
32
|
end
|
|
33
33
|
value
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def fetch_json(content_id)
|
|
37
|
-
|
|
37
|
+
bytes = fetch(content_id)
|
|
38
|
+
begin
|
|
39
|
+
Phronomy::CanonicalJSON.load(bytes)
|
|
40
|
+
rescue JSON::ParserError => error
|
|
41
|
+
raise IntegrityError.new("content is not JSON: #{content_id}", code: "content.invalid_json"), cause: error
|
|
42
|
+
end
|
|
38
43
|
end
|
|
39
44
|
|
|
40
45
|
def fetch_many(content_ids)
|
|
@@ -34,7 +34,7 @@ module Phronomy
|
|
|
34
34
|
)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
def store(prepared, contents:)
|
|
37
|
+
def store(prepared, contents:, response_schema_ref: nil)
|
|
38
38
|
unless prepared.is_a?(Prepared)
|
|
39
39
|
raise ArgumentError, "Context::Assembly#store expected Context::Assembly::Prepared"
|
|
40
40
|
end
|
|
@@ -80,7 +80,7 @@ module Phronomy
|
|
|
80
80
|
).validate!(
|
|
81
81
|
token_budget: prepared.input.token_budget,
|
|
82
82
|
segments: segments,
|
|
83
|
-
extra_values: [selected_tool_definitions]
|
|
83
|
+
extra_values: [selected_tool_definitions, response_schema_ref && contents.fetch_json(response_schema_ref)].compact
|
|
84
84
|
)
|
|
85
85
|
|
|
86
86
|
store_manifest(
|
|
@@ -90,31 +90,25 @@ module Phronomy
|
|
|
90
90
|
segments: segments,
|
|
91
91
|
model_config_ref: contents.put_json(prepared.model_config),
|
|
92
92
|
tool_definitions_ref: contents.put_json(selected_tool_definitions),
|
|
93
|
-
adapter_identity: prepared.adapter_identity
|
|
93
|
+
adapter_identity: prepared.adapter_identity,
|
|
94
|
+
response_schema_ref: response_schema_ref
|
|
94
95
|
)
|
|
95
96
|
end
|
|
96
97
|
|
|
97
98
|
private
|
|
98
99
|
|
|
99
100
|
def invoke_policy(policy, policy_input)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
)
|
|
112
|
-
result = policy.call(policy_input)
|
|
113
|
-
tracer.finish_span(span)
|
|
114
|
-
result
|
|
115
|
-
rescue => error
|
|
116
|
-
tracer&.finish_span(span, error: error) if defined?(span) && span
|
|
117
|
-
raise
|
|
101
|
+
Phronomy::Tracing::Base.trace_with(
|
|
102
|
+
@tracer, "context_policy", record_output: false,
|
|
103
|
+
attributes: {agent_id: policy_input.agent_id,
|
|
104
|
+
execution_id: policy_input.execution_id,
|
|
105
|
+
call_sequence: policy_input.call_sequence,
|
|
106
|
+
policy_class: policy.class.name || policy.class.to_s,
|
|
107
|
+
instruction_count: policy_input.instruction.length,
|
|
108
|
+
knowledge_count: policy_input.knowledge.length,
|
|
109
|
+
tool_count: policy_input.tools.length,
|
|
110
|
+
conversation_group_count: policy_input.conversation.length}
|
|
111
|
+
) { [policy.call(policy_input), nil] }
|
|
118
112
|
end
|
|
119
113
|
|
|
120
114
|
def store_manifest(
|
|
@@ -124,7 +118,8 @@ module Phronomy
|
|
|
124
118
|
segments:,
|
|
125
119
|
model_config_ref:,
|
|
126
120
|
tool_definitions_ref:,
|
|
127
|
-
adapter_identity
|
|
121
|
+
adapter_identity:,
|
|
122
|
+
response_schema_ref:
|
|
128
123
|
)
|
|
129
124
|
positioned = segments.each_with_index.map do |value, position|
|
|
130
125
|
Phronomy::Context::LLMInputManifest::Segment.new(**value.merge(position: position))
|
|
@@ -135,6 +130,7 @@ module Phronomy
|
|
|
135
130
|
segments: positioned,
|
|
136
131
|
model_config_ref: model_config_ref,
|
|
137
132
|
tool_definitions_ref: tool_definitions_ref,
|
|
133
|
+
response_schema_ref: response_schema_ref,
|
|
138
134
|
assembly_policy_version: ASSEMBLY_POLICY_VERSION,
|
|
139
135
|
ruby_llm_version: adapter_identity["sdk_version"],
|
|
140
136
|
adapter_name: adapter_identity["adapter_name"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "transport_error"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Embeddings
|
|
7
|
+
# Provider refusal; this classification alone does not authorize retries.
|
|
8
|
+
# @api public
|
|
9
|
+
class AuthenticationError < TransportError; end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "faraday"
|
|
4
|
+
|
|
3
5
|
module Phronomy
|
|
4
6
|
module Embeddings
|
|
5
7
|
# Embeddings adapter backed by RubyLLM.
|
|
@@ -40,8 +42,12 @@ module Phronomy
|
|
|
40
42
|
opts[:provider] = @provider if @provider
|
|
41
43
|
opts[:assume_model_exists] = true if @assume_model_exists
|
|
42
44
|
::RubyLLM.embed(text, **opts).vectors
|
|
43
|
-
rescue ::RubyLLM::
|
|
44
|
-
raise
|
|
45
|
+
rescue ::RubyLLM::UnauthorizedError, ::RubyLLM::ForbiddenError => error
|
|
46
|
+
raise AuthenticationError.new(error.message, code: "embeddings.authentication_failed"), cause: error
|
|
47
|
+
rescue ::RubyLLM::RateLimitError => error
|
|
48
|
+
raise RateLimitError.new(error.message, code: "embeddings.rate_limited"), cause: error
|
|
49
|
+
rescue ::RubyLLM::Error, ::Faraday::TimeoutError, ::Faraday::ConnectionFailed => error
|
|
50
|
+
raise TransportError.new(error.message), cause: error
|
|
45
51
|
end
|
|
46
52
|
end
|
|
47
53
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "transport_error"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Embeddings
|
|
7
|
+
# Provider refusal; this classification alone does not authorize retries.
|
|
8
|
+
# @api public
|
|
9
|
+
class RateLimitError < TransportError; end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -93,6 +93,7 @@ module Phronomy
|
|
|
93
93
|
phase_machine_class:,
|
|
94
94
|
recursion_limit:,
|
|
95
95
|
event_loop:,
|
|
96
|
+
recursion_limit_scope: :session,
|
|
96
97
|
resume_event: nil,
|
|
97
98
|
resume_phase: nil,
|
|
98
99
|
stable_observer: nil,
|
|
@@ -123,6 +124,11 @@ module Phronomy
|
|
|
123
124
|
@external_events = external_events
|
|
124
125
|
@phase_machine_class = phase_machine_class
|
|
125
126
|
@recursion_limit = recursion_limit
|
|
127
|
+
unless %i[session automatic_chain].include?(recursion_limit_scope)
|
|
128
|
+
raise ArgumentError, "unknown recursion_limit_scope: #{recursion_limit_scope.inspect}"
|
|
129
|
+
end
|
|
130
|
+
@recursion_limit_scope = recursion_limit_scope
|
|
131
|
+
@automatic_steps = 0
|
|
126
132
|
@event_loop = event_loop
|
|
127
133
|
@resume_event = resume_event
|
|
128
134
|
@resume_phase = resume_phase
|
|
@@ -230,7 +236,13 @@ module Phronomy
|
|
|
230
236
|
end
|
|
231
237
|
|
|
232
238
|
def fire_and_advance!(event)
|
|
233
|
-
|
|
239
|
+
automatic = event.type == :state_completed
|
|
240
|
+
exhausted = if @recursion_limit_scope == :automatic_chain
|
|
241
|
+
automatic && @automatic_steps >= @recursion_limit
|
|
242
|
+
else
|
|
243
|
+
@step >= @recursion_limit
|
|
244
|
+
end
|
|
245
|
+
if exhausted
|
|
234
246
|
raise Phronomy::RecursionLimitError,
|
|
235
247
|
"Recursion limit (#{@recursion_limit}) exceeded"
|
|
236
248
|
end
|
|
@@ -246,6 +258,9 @@ module Phronomy
|
|
|
246
258
|
apply_context_metadata!
|
|
247
259
|
@current_state = @tracker.phase.to_sym
|
|
248
260
|
@step += 1
|
|
261
|
+
# Only an accepted external transition starts a fresh automatic chain.
|
|
262
|
+
# Consumed/stale events and events with no transition cannot reset it.
|
|
263
|
+
@automatic_steps = automatic ? @automatic_steps + 1 : 0
|
|
249
264
|
advance_or_halt
|
|
250
265
|
ensure
|
|
251
266
|
@tracker.current_event = nil if @tracker&.respond_to?(:current_event=)
|
|
@@ -6,9 +6,9 @@ module Phronomy
|
|
|
6
6
|
class ExecutionCancellationError < CancellationError
|
|
7
7
|
attr_reader :outcomes
|
|
8
8
|
|
|
9
|
-
def initialize(message = "Execution cancelled", outcomes:)
|
|
9
|
+
def initialize(message = "Execution cancelled", outcomes:, code: nil)
|
|
10
10
|
@outcomes = outcomes
|
|
11
|
-
super(message)
|
|
11
|
+
super(message, code: code)
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
end
|
|
@@ -6,9 +6,9 @@ module Phronomy
|
|
|
6
6
|
class ExecutionTimeoutError < TimeoutError
|
|
7
7
|
attr_reader :outcomes
|
|
8
8
|
|
|
9
|
-
def initialize(message = "Execution timed out", outcomes:)
|
|
9
|
+
def initialize(message = "Execution timed out", outcomes:, code: nil)
|
|
10
10
|
@outcomes = outcomes
|
|
11
|
-
super(message)
|
|
11
|
+
super(message, code: code)
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
end
|
|
@@ -308,8 +308,13 @@ module Phronomy
|
|
|
308
308
|
def deliver_completion_callback(callback, value, error)
|
|
309
309
|
callback.call(value, error)
|
|
310
310
|
rescue => callback_error
|
|
311
|
-
|
|
312
|
-
|
|
311
|
+
begin
|
|
312
|
+
Phronomy::RuntimeSettings.current.logger&.error do
|
|
313
|
+
"[TaskResult] on_complete callback raised #{callback_error.class}: #{callback_error.message}"
|
|
314
|
+
end
|
|
315
|
+
rescue
|
|
316
|
+
# Reporting must not interrupt delivery to the remaining observers.
|
|
317
|
+
nil
|
|
313
318
|
end
|
|
314
319
|
end
|
|
315
320
|
|
|
@@ -8,23 +8,27 @@ module Phronomy
|
|
|
8
8
|
# still pending. Generation owns the request-to-Workflow correlation.
|
|
9
9
|
# @api private
|
|
10
10
|
class AgentOperation
|
|
11
|
-
def initialize(agent_class)
|
|
11
|
+
def initialize(agent_class, output_schema:)
|
|
12
|
+
@output_schema = output_schema
|
|
12
13
|
@mutex = Mutex.new
|
|
13
14
|
@pending = []
|
|
14
15
|
@agent = agent_class.new(on_event: method(:receive_event))
|
|
15
16
|
end
|
|
16
17
|
|
|
17
|
-
def start(input, listener:)
|
|
18
|
+
def start(input, listener:, config: {})
|
|
18
19
|
request = Object.new
|
|
19
20
|
# Workflow entry calls are serial. Keep submitted requests until their
|
|
20
21
|
# own result settles: Agent admission itself may run after an earlier
|
|
21
22
|
# request completes, so a pending submission is not necessarily busy.
|
|
22
23
|
@mutex.synchronize { @pending << [request, listener] }
|
|
23
|
-
task = @agent.invoke_async(input)
|
|
24
|
+
task = @agent.invoke_async(input, config: config, output_schema: @output_schema)
|
|
24
25
|
task.on_complete do |result, error|
|
|
25
26
|
release(request)
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
remember_execution(result[:execution_id]) if result.is_a?(Hash)
|
|
28
|
+
if !error && result.is_a?(Hash) && result[:handoff_request]
|
|
29
|
+
error = Phronomy::Error.new("GeneratorVerifier does not coordinate Handoff",
|
|
30
|
+
code: "generation.unsupported_handoff")
|
|
31
|
+
end
|
|
28
32
|
|
|
29
33
|
event = if error
|
|
30
34
|
type = if error.is_a?(Phronomy::TimeoutError)
|
|
@@ -46,10 +50,29 @@ module Phronomy
|
|
|
46
50
|
raise
|
|
47
51
|
end
|
|
48
52
|
|
|
53
|
+
def approve_async(request, approved:)
|
|
54
|
+
@agent.approve_async(request.execution_id, approval_request_id: request.id, approved: approved)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def cancel_async
|
|
58
|
+
execution_id = @mutex.synchronize { @execution_id }
|
|
59
|
+
@agent.cancel_async(execution_id) if execution_id
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def unload!
|
|
63
|
+
@agent.unload!
|
|
64
|
+
end
|
|
65
|
+
|
|
49
66
|
private
|
|
50
67
|
|
|
68
|
+
def remember_execution(execution_id)
|
|
69
|
+
@mutex.synchronize { @execution_id = execution_id } if execution_id
|
|
70
|
+
end
|
|
71
|
+
|
|
51
72
|
def receive_event(event)
|
|
52
73
|
return unless event.type == :approval_required
|
|
74
|
+
request = event.payload[:request]
|
|
75
|
+
remember_execution(request.execution_id) if request.respond_to?(:execution_id)
|
|
53
76
|
|
|
54
77
|
listener = @mutex.synchronize { @pending.first&.last }
|
|
55
78
|
listener&.call(event)
|
|
@@ -5,11 +5,6 @@ module Phronomy
|
|
|
5
5
|
# Converts Agent events to correlated Workflow events. PipelineState owns
|
|
6
6
|
# stale-result rejection and all Workflow state mutation.
|
|
7
7
|
class AgentResultReceiver
|
|
8
|
-
def initialize(draft_result_parser:, review_result_parser:)
|
|
9
|
-
@draft_result_parser = draft_result_parser
|
|
10
|
-
@review_result_parser = review_result_parser
|
|
11
|
-
end
|
|
12
|
-
|
|
13
8
|
def draft_listener(workflow:, workflow_instance_id:, request_id:)
|
|
14
9
|
agent_listener(:draft, workflow, workflow_instance_id, request_id) do |output|
|
|
15
10
|
draft_payload(output)
|
|
@@ -25,20 +20,20 @@ module Phronomy
|
|
|
25
20
|
private
|
|
26
21
|
|
|
27
22
|
def draft_payload(output)
|
|
28
|
-
parsed =
|
|
23
|
+
parsed = output
|
|
29
24
|
{
|
|
30
|
-
draft: parsed
|
|
31
|
-
self_score:
|
|
32
|
-
citations:
|
|
25
|
+
draft: parsed.fetch("answer"),
|
|
26
|
+
self_score: parsed.fetch("confidence"),
|
|
27
|
+
citations: parsed.fetch("citations")
|
|
33
28
|
}
|
|
34
29
|
end
|
|
35
30
|
|
|
36
31
|
def review_payload(output)
|
|
37
|
-
parsed =
|
|
32
|
+
parsed = output
|
|
38
33
|
{
|
|
39
|
-
review_score:
|
|
40
|
-
approved: parsed
|
|
41
|
-
feedback: parsed
|
|
34
|
+
review_score: parsed.fetch("score"),
|
|
35
|
+
approved: parsed.fetch("approved"),
|
|
36
|
+
feedback: parsed.fetch("feedback")
|
|
42
37
|
}
|
|
43
38
|
end
|
|
44
39
|
|
|
@@ -47,6 +42,11 @@ module Phronomy
|
|
|
47
42
|
case agent_event.type
|
|
48
43
|
when :done
|
|
49
44
|
begin
|
|
45
|
+
if agent_event.payload[:rejected]
|
|
46
|
+
workflow.signal(workflow_instance_id: workflow_instance_id,
|
|
47
|
+
event: :"#{phase}_rejected", payload: {request_id: request_id})
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
50
|
payload = completion.call(agent_event.payload[:output])
|
|
51
51
|
workflow.signal(
|
|
52
52
|
workflow_instance_id: workflow_instance_id,
|
|
@@ -60,9 +60,6 @@ module Phronomy
|
|
|
60
60
|
error = agent_event.payload[:error] ||
|
|
61
61
|
Phronomy::Error.new("#{phase.to_s.capitalize} Agent ended with #{agent_event.type}")
|
|
62
62
|
signal_failure(workflow, workflow_instance_id, request_id, phase, error)
|
|
63
|
-
when :approval_required
|
|
64
|
-
error = Phronomy::Error.new("GeneratorVerifier #{phase} Agent suspended for approval")
|
|
65
|
-
signal_failure(workflow, workflow_instance_id, request_id, phase, error)
|
|
66
63
|
end
|
|
67
64
|
}
|
|
68
65
|
end
|
|
@@ -70,20 +67,10 @@ module Phronomy
|
|
|
70
67
|
def signal_failure(workflow, workflow_instance_id, request_id, phase, error)
|
|
71
68
|
workflow.signal(
|
|
72
69
|
workflow_instance_id: workflow_instance_id,
|
|
73
|
-
event: :"#{phase}_failed",
|
|
70
|
+
event: error.is_a?(Phronomy::Agent::OutputError) ? :"#{phase}_output_failed" : :"#{phase}_failed",
|
|
74
71
|
payload: {request_id: request_id, error: error}
|
|
75
72
|
)
|
|
76
73
|
end
|
|
77
|
-
|
|
78
|
-
def clamp(value)
|
|
79
|
-
value.to_f.clamp(0.0, 1.0)
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def normalize_citations(raw)
|
|
83
|
-
Array(raw).filter_map do |citation|
|
|
84
|
-
citation.is_a?(Hash) ? citation.transform_keys(&:to_sym) : nil
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
74
|
end
|
|
88
75
|
end
|
|
89
76
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class GeneratorVerifier
|
|
5
|
+
# One application-visible Tool approval notification, scoped to an invocation.
|
|
6
|
+
# The C04 request carries display-safe details. Generation only routes replies.
|
|
7
|
+
class Approval
|
|
8
|
+
attr_reader :invocation_id, :phase, :request
|
|
9
|
+
|
|
10
|
+
def initialize(invocation:, operation:, phase:, request:)
|
|
11
|
+
@invocation, @operation = invocation, operation
|
|
12
|
+
@invocation_id, @phase, @request = invocation.id, phase, request
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def respond_async(approved:)
|
|
17
|
+
unless approved == true || approved == false
|
|
18
|
+
return Phronomy::TaskResult.failed(ArgumentError.new("approved must be true or false"))
|
|
19
|
+
end
|
|
20
|
+
@invocation.respond(self) do
|
|
21
|
+
@operation.approve_async(@request, approved: approved)
|
|
22
|
+
end.map { nil }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
{invocation_id: @invocation_id, phase: @phase.to_s, request: @request.to_h}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class GeneratorVerifier
|
|
5
|
+
class CleanupError < Phronomy::Error
|
|
6
|
+
attr_reader :pipeline, :failures, :result, :primary_error
|
|
7
|
+
|
|
8
|
+
def initialize(pipeline:, failures:, result: nil, primary_error: nil, code: "generation.cleanup_failed")
|
|
9
|
+
@pipeline, @result, @primary_error = pipeline, result, primary_error
|
|
10
|
+
@failures = failures.map { |failure| failure.dup.freeze }.freeze
|
|
11
|
+
super("Could not release #{failures.length} GeneratorVerifier Agent(s)", code: code)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
class GeneratorVerifier
|
|
7
|
+
# Owns one run's subscriptions, child observations and application approval route.
|
|
8
|
+
# Agent owns durable execution, authorization and physical release safety.
|
|
9
|
+
# @api private
|
|
10
|
+
class Invocation
|
|
11
|
+
attr_reader :id, :result, :config
|
|
12
|
+
|
|
13
|
+
def initialize(config:, on_approval:, on_finish:)
|
|
14
|
+
@id = SecureRandom.uuid.freeze
|
|
15
|
+
@result = Phronomy::TaskResult.deferred(name: "generation:#{@id}")
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
@pending = 0
|
|
18
|
+
@workflow_done = false
|
|
19
|
+
@finished = false
|
|
20
|
+
@approvals = []
|
|
21
|
+
@on_approval, @on_finish = on_approval, on_finish
|
|
22
|
+
@token = Phronomy::Concurrency::CancellationToken.new
|
|
23
|
+
@subscriptions = Phronomy::Concurrency::Subscriptions.new
|
|
24
|
+
@config = config.merge(cancellation_token: @token).freeze
|
|
25
|
+
@agent_config = {cancellation_token: @token}
|
|
26
|
+
@agent_config[:invocation_context] = config[:invocation_context] if config.key?(:invocation_context)
|
|
27
|
+
@agent_config.freeze
|
|
28
|
+
@subscriptions.cancellation(config[:cancellation_token]) { @token.cancel! }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def approvals
|
|
32
|
+
@mutex.synchronize { @approvals.dup.freeze }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def cancel
|
|
36
|
+
@token.cancel!
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def start(operation, phase, input, listener:)
|
|
40
|
+
observe do
|
|
41
|
+
@token.raise_if_cancelled!
|
|
42
|
+
operation.start(input, config: @agent_config, listener: ->(event) {
|
|
43
|
+
if event.type == :approval_required
|
|
44
|
+
notify_approval(operation, phase, event.payload.fetch(:request))
|
|
45
|
+
else
|
|
46
|
+
listener.call(event)
|
|
47
|
+
end
|
|
48
|
+
})
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def respond(approval)
|
|
53
|
+
observe(check_approval: approval) { yield }
|
|
54
|
+
rescue => error
|
|
55
|
+
Phronomy::TaskResult.failed(error)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def follow(workflow_task, &build_result)
|
|
59
|
+
workflow_task.on_complete do |state, error|
|
|
60
|
+
begin
|
|
61
|
+
value = build_result.call(state) unless error
|
|
62
|
+
rescue => result_error
|
|
63
|
+
error = result_error
|
|
64
|
+
end
|
|
65
|
+
finish_workflow(value, error)
|
|
66
|
+
end
|
|
67
|
+
@result
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def fail_start(error)
|
|
71
|
+
finish_workflow(nil, error)
|
|
72
|
+
@result
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def notify_approval(operation, phase, request)
|
|
78
|
+
approval = Approval.new(invocation: self, operation: operation, phase: phase, request: request)
|
|
79
|
+
deliver = @mutex.synchronize do
|
|
80
|
+
next false if @workflow_done || @finished
|
|
81
|
+
@approvals << approval
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
@on_approval&.call(approval) if deliver
|
|
85
|
+
rescue => error
|
|
86
|
+
@mutex.synchronize { @primary_error ||= error }
|
|
87
|
+
cancel
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def observe(check_approval: nil)
|
|
91
|
+
reserved = false
|
|
92
|
+
observed = false
|
|
93
|
+
@mutex.synchronize do
|
|
94
|
+
if @finished || @workflow_done || (check_approval && !@approvals.include?(check_approval))
|
|
95
|
+
raise Phronomy::Error.new("Generation invocation is no longer accepting operations",
|
|
96
|
+
code: "generation.inactive")
|
|
97
|
+
end
|
|
98
|
+
@pending += 1
|
|
99
|
+
reserved = true
|
|
100
|
+
end
|
|
101
|
+
task = yield
|
|
102
|
+
unless task.is_a?(Phronomy::TaskResult)
|
|
103
|
+
raise TypeError, "Agent operation must return TaskResult"
|
|
104
|
+
end
|
|
105
|
+
task.on_complete { |_value, _error| child_finished }
|
|
106
|
+
observed = true
|
|
107
|
+
task
|
|
108
|
+
ensure
|
|
109
|
+
child_finished if reserved && !observed
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def child_finished
|
|
113
|
+
@mutex.synchronize { @pending -= 1 }
|
|
114
|
+
try_finish
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def finish_workflow(value, error)
|
|
118
|
+
@mutex.synchronize do
|
|
119
|
+
@workflow_done = true
|
|
120
|
+
@value, @error = value, error
|
|
121
|
+
end
|
|
122
|
+
cancel if error
|
|
123
|
+
try_finish
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def try_finish
|
|
127
|
+
completion = @mutex.synchronize do
|
|
128
|
+
next if @finished || !@workflow_done || !@pending.zero?
|
|
129
|
+
@finished = true
|
|
130
|
+
[@value, @primary_error || @error]
|
|
131
|
+
end
|
|
132
|
+
return unless completion
|
|
133
|
+
|
|
134
|
+
@subscriptions.close
|
|
135
|
+
@on_finish.call
|
|
136
|
+
value, error = completion
|
|
137
|
+
if error.is_a?(Phronomy::CancellationError)
|
|
138
|
+
@result.cancel!(error)
|
|
139
|
+
elsif error
|
|
140
|
+
@result.fail(error)
|
|
141
|
+
else
|
|
142
|
+
@result.complete(value)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -16,6 +16,7 @@ module Phronomy
|
|
|
16
16
|
field :output, type: :replace, default: -> { {} }
|
|
17
17
|
field :draft_request_id, type: :replace, default: nil
|
|
18
18
|
field :review_request_id, type: :replace, default: nil
|
|
19
|
+
field :interrupted_phase, type: :replace, default: nil
|
|
19
20
|
field :pipeline_error, type: :replace, default: nil
|
|
20
21
|
|
|
21
22
|
# Application-level event interpretation. Correlation IDs belong to this
|
|
@@ -39,6 +40,21 @@ module Phronomy
|
|
|
39
40
|
self.review_notes = review_notes + [event.payload[:feedback]]
|
|
40
41
|
self.review_request_id = nil
|
|
41
42
|
self.pipeline_error = nil
|
|
43
|
+
when :draft_rejected
|
|
44
|
+
return :consume unless event.payload[:request_id] == draft_request_id
|
|
45
|
+
self.interrupted_phase = :draft
|
|
46
|
+
self.draft_request_id = nil
|
|
47
|
+
when :review_rejected
|
|
48
|
+
return :consume unless event.payload[:request_id] == review_request_id
|
|
49
|
+
self.interrupted_phase = :review
|
|
50
|
+
self.review_request_id = nil
|
|
51
|
+
when :draft_output_failed, :review_output_failed
|
|
52
|
+
phase = (event.type == :draft_output_failed) ? :draft : :review
|
|
53
|
+
expected = (phase == :draft) ? draft_request_id : review_request_id
|
|
54
|
+
return :consume unless event.payload[:request_id] == expected
|
|
55
|
+
self.pipeline_error = event.payload.fetch(:error)
|
|
56
|
+
self.interrupted_phase = phase
|
|
57
|
+
(phase == :draft) ? self.draft_request_id = nil : self.review_request_id = nil
|
|
42
58
|
when :draft_failed
|
|
43
59
|
return :consume unless event.payload[:request_id] == draft_request_id
|
|
44
60
|
|