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.
Files changed (157) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -1
  3. data/README.md +32 -6
  4. data/docs/application-recipes.md +229 -0
  5. data/docs/architecture/agent-configuration-and-tool-binding.md +8 -0
  6. data/docs/architecture/agent-iteration-budget.md +81 -0
  7. data/docs/architecture/dx05-agent-scope.md +126 -0
  8. data/docs/architecture/dx05-workflow-task-state.md +134 -0
  9. data/docs/architecture/error-api-review.md +245 -0
  10. data/docs/architecture/generator-verifier-lifecycle-result.md +126 -0
  11. data/docs/architecture/generator-verifier-ownership.md +18 -109
  12. data/docs/architecture/llm-continuation-metadata.md +88 -0
  13. data/docs/architecture/lm-studio-output-failures.md +111 -0
  14. data/docs/architecture/r8-unit15.md +3 -0
  15. data/docs/architecture/r8-unit7.md +2 -1
  16. data/docs/architecture/structured-output-and-scope.md +183 -0
  17. data/docs/architecture/tracing.md +22 -4
  18. data/docs/architecture/transport-error-boundaries.md +103 -0
  19. data/docs/architecture/trust-acceptance-followup.md +87 -0
  20. data/docs/failure-reason-codes.md +45 -0
  21. data/docs/features.md +14 -2
  22. data/docs/getting-started.md +44 -43
  23. data/docs/migrations/r8-unit15.md +3 -0
  24. data/docs/ordered-event-delivery.md +54 -0
  25. data/docs/workflow-completion.md +92 -0
  26. data/gemfiles/http_minimum.gemfile +10 -0
  27. data/lib/phronomy/agent/admission.rb +1 -1
  28. data/lib/phronomy/agent/agent_not_found_error.rb +16 -0
  29. data/lib/phronomy/agent/agent_unloaded_error.rb +14 -0
  30. data/lib/phronomy/agent/api/agent.rb +13 -0
  31. data/lib/phronomy/agent/async_event_api.rb +121 -88
  32. data/lib/phronomy/agent/base.rb +82 -44
  33. data/lib/phronomy/agent/before_llm_input.rb +7 -1
  34. data/lib/phronomy/agent/context_importer.rb +4 -0
  35. data/lib/phronomy/agent/execution/agent_invocation.rb +6 -2
  36. data/lib/phronomy/agent/execution/dispatch_preparation.rb +6 -1
  37. data/lib/phronomy/agent/execution/exact_execution.rb +20 -9
  38. data/lib/phronomy/agent/execution/execution_cancellation.rb +1 -1
  39. data/lib/phronomy/agent/execution/execution_coordinator.rb +79 -18
  40. data/lib/phronomy/agent/execution/execution_outcome_committer.rb +24 -4
  41. data/lib/phronomy/agent/execution/execution_registry.rb +5 -1
  42. data/lib/phronomy/agent/execution/initial_preparation.rb +11 -5
  43. data/lib/phronomy/agent/execution/invocation_actions.rb +1 -1
  44. data/lib/phronomy/agent/execution/invocation_transitions.rb +4 -4
  45. data/lib/phronomy/agent/execution/iteration_budget.rb +35 -0
  46. data/lib/phronomy/agent/execution/runtime_record_encoder.rb +4 -4
  47. data/lib/phronomy/agent/execution_observation.rb +5 -5
  48. data/lib/phronomy/agent/filterable.rb +21 -15
  49. data/lib/phronomy/agent/lifecycle/lifetime_api.rb +76 -0
  50. data/lib/phronomy/agent/lifecycle/operation_lease.rb +33 -0
  51. data/lib/phronomy/agent/lifecycle/operation_result.rb +53 -0
  52. data/lib/phronomy/agent/lifecycle/ownership_registry.rb +99 -8
  53. data/lib/phronomy/agent/lifecycle/scope.rb +208 -0
  54. data/lib/phronomy/agent/lifecycle/scope_cleanup_error.rb +21 -0
  55. data/lib/phronomy/agent/lifecycle/unload_transition.rb +78 -0
  56. data/lib/phronomy/agent/output_contract.rb +64 -0
  57. data/lib/phronomy/agent/output_error.rb +53 -0
  58. data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +6 -6
  59. data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +8 -7
  60. data/lib/phronomy/agent/recovery/recovery_coordinator/resolution.rb +14 -18
  61. data/lib/phronomy/agent/recovery/recovery_coordinator.rb +6 -10
  62. data/lib/phronomy/agent/recovery/recovery_support.rb +6 -8
  63. data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +2 -1
  64. data/lib/phronomy/agent/runtime_input.rb +15 -4
  65. data/lib/phronomy/agent/saved_context_reader.rb +2 -1
  66. data/lib/phronomy/agent/store.rb +10 -2
  67. data/lib/phronomy/agent/stream_callback_error.rb +2 -2
  68. data/lib/phronomy/common/error.rb +65 -1
  69. data/lib/phronomy/common/values/immutable.rb +33 -0
  70. data/lib/phronomy/content_store/base.rb +7 -2
  71. data/lib/phronomy/context/assembly.rb +18 -22
  72. data/lib/phronomy/embeddings/authentication_error.rb +11 -0
  73. data/lib/phronomy/embeddings/backends/ruby_llm_embeddings.rb +8 -2
  74. data/lib/phronomy/embeddings/rate_limit_error.rb +11 -0
  75. data/lib/phronomy/engine/fsm_session.rb +16 -1
  76. data/lib/phronomy/execution/execution_cancellation_error.rb +2 -2
  77. data/lib/phronomy/execution/execution_timeout_error.rb +2 -2
  78. data/lib/phronomy/execution/task_result.rb +7 -2
  79. data/lib/phronomy/filter/filter_block_error.rb +2 -2
  80. data/lib/phronomy/generation/generator_verifier/agent_operation.rb +28 -5
  81. data/lib/phronomy/generation/generator_verifier/agent_result_receiver.rb +14 -27
  82. data/lib/phronomy/generation/generator_verifier/approval.rb +30 -0
  83. data/lib/phronomy/generation/generator_verifier/cleanup_error.rb +15 -0
  84. data/lib/phronomy/generation/generator_verifier/invocation.rb +147 -0
  85. data/lib/phronomy/generation/generator_verifier/pipeline_state.rb +16 -0
  86. data/lib/phronomy/generation/generator_verifier/protocol.rb +55 -0
  87. data/lib/phronomy/generation/generator_verifier/result.rb +121 -0
  88. data/lib/phronomy/generation/generator_verifier/unaccepted_result_error.rb +14 -0
  89. data/lib/phronomy/generation/generator_verifier/workflow_builder.rb +13 -11
  90. data/lib/phronomy/generation/generator_verifier.rb +170 -85
  91. data/lib/phronomy/integrations/ordered_event_delivery.rb +208 -0
  92. data/lib/phronomy/llm_adapter/backends/lm_studio.rb +139 -0
  93. data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +134 -12
  94. data/lib/phronomy/llm_adapter/backends/structured_response_capture.rb +41 -0
  95. data/lib/phronomy/llm_adapter/message.rb +4 -0
  96. data/lib/phronomy/llm_adapter/request.rb +17 -2
  97. data/lib/phronomy/llm_adapter/response.rb +11 -4
  98. data/lib/phronomy/llm_adapter/structured_output_unsupported_error.rb +14 -0
  99. data/lib/phronomy/llm_adapter/transport_error.rb +4 -0
  100. data/lib/phronomy/multi_agent/persistence/team_execution_repository.rb +5 -5
  101. data/lib/phronomy/multi_agent/shared_state.rb +1 -0
  102. data/lib/phronomy/multi_agent/team_busy_error.rb +18 -0
  103. data/lib/phronomy/multi_agent/team_coordinator.rb +20 -7
  104. data/lib/phronomy/multi_agent/team_not_found_error.rb +18 -0
  105. data/lib/phronomy/output_parser/json_schema_parser.rb +113 -0
  106. data/lib/phronomy/output_parser/parse_error.rb +10 -1
  107. data/lib/phronomy/runtime_composition/agent_defaults.rb +4 -0
  108. data/lib/phronomy/storage/condition_failed_error.rb +2 -2
  109. data/lib/phronomy/storage/unique_constraint_error.rb +2 -2
  110. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +1 -1
  111. data/lib/phronomy/tool/base.rb +25 -23
  112. data/lib/phronomy/tracing/automatic.rb +14 -8
  113. data/lib/phronomy/tracing/base.rb +37 -7
  114. data/lib/phronomy/tracing/langfuse_tracer.rb +3 -3
  115. data/lib/phronomy/tracing/observation.rb +2 -1
  116. data/lib/phronomy/tracing/open_telemetry_tracer.rb +7 -32
  117. data/lib/phronomy/version.rb +1 -1
  118. data/lib/phronomy/workflow/execution/workflow.rb +30 -2
  119. data/lib/phronomy/workflow/execution/workflow_execution_environment.rb +1 -0
  120. data/lib/phronomy/workflow/execution/workflow_execution_registry.rb +74 -3
  121. data/lib/phronomy/workflow/execution/workflow_runner.rb +48 -14
  122. data/lib/phronomy/workflow/execution/workflow_task_execution.rb +153 -0
  123. data/lib/phronomy/workflow/execution/workflow_task_reception.rb +72 -0
  124. data/lib/phronomy/workflow/execution/workflow_task_resume_error.rb +14 -0
  125. data/lib/phronomy/workflow/runtime_binding/workflow_engine_environment.rb +26 -3
  126. data/lib/phronomy/workflow/runtime_binding/workflow_phase_machine_builder.rb +3 -1
  127. data/lib/phronomy/workflow/runtime_binding/workflow_task_session.rb +76 -0
  128. data/lib/phronomy.rb +7 -2
  129. data/scripts/api_snapshot.rb +15 -0
  130. data/scripts/check_readme_ruby.rb +24 -29
  131. data/scripts/check_readme_runnable.rb +92 -81
  132. data/scripts/migrate_generator_verifier_result.rb +18 -0
  133. data/sig/phronomy/agent/agent_not_found_error.rbs +6 -0
  134. data/sig/phronomy/agent/agent_unloaded_error.rbs +6 -0
  135. data/sig/phronomy/agent/execution_contract.rbs +4 -3
  136. data/sig/phronomy/agent/stream_callback_error.rbs +8 -0
  137. data/sig/phronomy/agent.rbs +36 -5
  138. data/sig/phronomy/embeddings/base.rbs +6 -0
  139. data/sig/phronomy/execution/execution.rbs +2 -2
  140. data/sig/phronomy/extensions.rbs +8 -2
  141. data/sig/phronomy/filter/filter_block_error.rbs +6 -0
  142. data/sig/phronomy/generation/generator_verifier.rbs +70 -0
  143. data/sig/phronomy/integrations.rbs +14 -0
  144. data/sig/phronomy/llm_adapter/backends/lm_studio.rbs +8 -0
  145. data/sig/phronomy/llm_adapter/backends/ruby_llm.rbs +1 -0
  146. data/sig/phronomy/llm_adapter/values.rbs +8 -2
  147. data/sig/phronomy/multi_agent/team_busy_error.rbs +8 -0
  148. data/sig/phronomy/multi_agent/team_not_found_error.rbs +8 -0
  149. data/sig/phronomy/storage/contracts.rbs +2 -2
  150. data/sig/phronomy/tracing/observation.rbs +1 -1
  151. data/sig/phronomy/workflow/execution/workflow_execution_environment.rbs +1 -0
  152. data/sig/phronomy/workflow.rbs +4 -0
  153. data/sig/phronomy.rbs +6 -0
  154. metadata +103 -5
  155. data/lib/phronomy/generation/generator_verifier/default_parser.rb +0 -27
  156. data/lib/phronomy/generation/low_confidence_error.rb +0 -14
  157. 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, "content is not UTF-8: #{content_id}"
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
- Phronomy::CanonicalJSON.load(fetch(content_id))
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
- tracer = @tracer
101
- span = tracer.start_span(
102
- "context_policy",
103
- 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
- )
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::Error => error
44
- raise TransportError, error.message
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
- if @step >= @recursion_limit
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
- Phronomy::RuntimeSettings.current.logger&.error do
312
- "[TaskResult] on_complete callback raised #{callback_error.class}: #{callback_error.message}"
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
 
@@ -6,8 +6,8 @@ module Phronomy
6
6
  class FilterBlockError < Error
7
7
  attr_reader :filter
8
8
 
9
- def initialize(message, filter: nil)
10
- super(message)
9
+ def initialize(message, filter: nil, code: nil)
10
+ super(message, code: code)
11
11
  @filter = filter
12
12
  end
13
13
  end
@@ -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
- # Handoff was not a generation completion in the event-based path.
27
- next if !error && result.is_a?(Hash) && result[:handoff_request]
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 = @draft_result_parser.call(output)
23
+ parsed = output
29
24
  {
30
- draft: parsed[:answer].to_s,
31
- self_score: clamp(parsed[:confidence]),
32
- citations: normalize_citations(parsed[: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 = @review_result_parser.call(output)
32
+ parsed = output
38
33
  {
39
- review_score: clamp(parsed[:score]),
40
- approved: parsed[:approved] == true,
41
- feedback: parsed[:feedback].to_s
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