phronomy 0.31.0 → 0.32.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/README.md +7 -1
  4. data/docs/features.md +2 -0
  5. data/docs/runtime-and-concurrency.md +159 -0
  6. data/lib/phronomy/agent/approval_wait.rb +8 -0
  7. data/lib/phronomy/agent/async_event_api.rb +60 -0
  8. data/lib/phronomy/agent/base.rb +1 -1
  9. data/lib/phronomy/agent/execution/exact_execution.rb +4 -0
  10. data/lib/phronomy/agent/execution/execution_coordinator.rb +89 -2
  11. data/lib/phronomy/agent/journal/journal_projection.rb +24 -1
  12. data/lib/phronomy/agent/lifecycle/hosted_invocation.rb +70 -0
  13. data/lib/phronomy/agent/lifecycle/lifetime_api.rb +30 -0
  14. data/lib/phronomy/agent/lifecycle/operation_lease.rb +7 -0
  15. data/lib/phronomy/agent/lifecycle/operation_result.rb +12 -0
  16. data/lib/phronomy/agent/lifecycle/ownership_registry.rb +86 -14
  17. data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +1 -1
  18. data/lib/phronomy/agent/store.rb +52 -0
  19. data/lib/phronomy/context/context_policy_input_builder.rb +27 -19
  20. data/lib/phronomy/engine/fsm_session.rb +9 -3
  21. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +87 -5
  22. data/lib/phronomy/multi_agent/orchestrator.rb +30 -18
  23. data/lib/phronomy/version.rb +1 -1
  24. data/lib/phronomy/workflow/execution/workflow.rb +9 -0
  25. data/lib/phronomy/workflow/execution/workflow_runner.rb +62 -14
  26. data/scripts/api_snapshot.rb +2 -0
  27. data/sig/phronomy/agent/execution_contract.rbs +13 -0
  28. data/sig/phronomy/agent/store.rbs +1 -0
  29. data/sig/phronomy/agent.rbs +1 -0
  30. data/sig/phronomy/multi_agent.rbs +2 -0
  31. data/sig/phronomy/workflow.rbs +1 -0
  32. metadata +4 -2
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.31.0"
4
+ VERSION = "0.32.0"
5
5
  end
@@ -45,6 +45,15 @@ module Phronomy
45
45
  @runner.send_event(state: state, event: event, input: input)
46
46
  end
47
47
 
48
+ # Applies an explicitly named event to one revision of a saved wait.
49
+ # The host holds its exclusive claim for this Workflow across the call.
50
+ # @return [WorkflowContext] the next halted or completed context
51
+ # @api public
52
+ def send_saved_event(workflow_instance_id:, expected_revision:, event:, input: nil)
53
+ @runner.send_saved_event(workflow_instance_id: workflow_instance_id,
54
+ expected_revision: expected_revision, event: event, input: input)
55
+ end
56
+
48
57
  # Sends an event to an active Workflow execution without blocking.
49
58
  #
50
59
  # +workflow_instance_id+ is the logical/durable Workflow identity. EventLoop resolves it
@@ -54,6 +54,7 @@ module Phronomy
54
54
  :input,
55
55
  :event_name,
56
56
  :current_phase,
57
+ :expected_revision,
57
58
  :result_task,
58
59
  :workflow_instance_id,
59
60
  :owner_token
@@ -141,13 +142,28 @@ module Phronomy
141
142
  end
142
143
 
143
144
  start_resume_execution(
144
- state,
145
+ workflow_instance_id,
146
+ state: state,
145
147
  input: input,
146
148
  event_name: event_name,
147
149
  current_phase: current_phase
148
150
  ).wait_result
149
151
  end
150
152
 
153
+ def send_saved_event(workflow_instance_id:, expected_revision:, event:, input: nil)
154
+ if Phronomy::WaitPolicy.blocking_forbidden?
155
+ raise Phronomy::EventLoopReentrancyError, "send_saved_event requires a host thread"
156
+ end
157
+ unless workflow_instance_id.is_a?(String) && !workflow_instance_id.empty?
158
+ raise ArgumentError, "workflow_instance_id must be a nonempty String"
159
+ end
160
+ unless expected_revision.is_a?(Integer) && expected_revision.positive?
161
+ raise ArgumentError, "expected_revision must be a positive Integer"
162
+ end
163
+ start_resume_execution(workflow_instance_id.dup, expected_revision: expected_revision,
164
+ input: input, event_name: event).wait_result
165
+ end
166
+
151
167
  # Posts an application-defined event to the currently live Workflow owner.
152
168
  # WorkflowExecutionRegistry resolves workflow_instance_id to the current concrete
153
169
  # fsm_session_id. The opaque admission owner token is deliberately not a
@@ -256,8 +272,8 @@ module Phronomy
256
272
  result_task || failed_task("workflow:preparation", error)
257
273
  end
258
274
 
259
- def start_resume_execution(state, input:, event_name:, current_phase:)
260
- workflow_instance_id = state.workflow_instance_id.to_s.freeze
275
+ def start_resume_execution(workflow_instance_id, input:, event_name:, state: nil, current_phase: nil, expected_revision: nil)
276
+ workflow_instance_id = workflow_instance_id.to_s.freeze
261
277
  result_task = Phronomy::TaskResult.deferred(name: "workflow-resume:#{workflow_instance_id}")
262
278
  Phronomy::Tracing::Automatic.observe_task(
263
279
  result_task,
@@ -272,7 +288,8 @@ module Phronomy
272
288
  state: state,
273
289
  input: input,
274
290
  event_name: event_name.to_sym,
275
- current_phase: current_phase.to_sym,
291
+ current_phase: current_phase,
292
+ expected_revision: expected_revision,
276
293
  result_task: result_task,
277
294
  workflow_instance_id: workflow_instance_id,
278
295
  owner_token: Object.new.freeze
@@ -345,6 +362,7 @@ module Phronomy
345
362
 
346
363
  repository = configured_repository
347
364
  unless repository
365
+ raise Phronomy::ConfigurationError, "send_saved_event requires Workflow persistence" if request.expected_revision
348
366
  context = request.input ? request.state.merge(request.input) : request.state
349
367
  execution = Execution.new(
350
368
  context: context,
@@ -445,12 +463,9 @@ module Phronomy
445
463
  return
446
464
  end
447
465
 
448
- expected_revision = validate_resume_snapshot!(
449
- request.state,
450
- ready.result.snapshot,
451
- ready.result.revision
452
- )
453
- context = request.input ? request.state.merge(request.input) : request.state
466
+ state = request.state || restore_saved_wait!(request, ready.result)
467
+ expected_revision = validate_resume_snapshot!(state, ready.result.snapshot, ready.result.revision)
468
+ context = request.input ? state.merge(request.input) : state
454
469
  execution = Execution.new(
455
470
  context: context,
456
471
  workflow_instance_id: request.workflow_instance_id,
@@ -465,12 +480,28 @@ module Phronomy
465
480
  execution,
466
481
  request.result_task,
467
482
  resume_event: request.event_name,
468
- resume_phase: request.current_phase
483
+ resume_phase: request.current_phase || state.phase,
484
+ require_resume_transition: !request.expected_revision.nil?
469
485
  )
470
486
  rescue => error
471
487
  release_and_fail(request, error)
472
488
  end
473
489
 
490
+ def restore_saved_wait!(request, loaded)
491
+ unless loaded.snapshot && loaded.revision == request.expected_revision
492
+ raise Phronomy::Persistence::StateConflictError,
493
+ "No saved Workflow at revision #{request.expected_revision} for #{request.workflow_instance_id.inspect}"
494
+ end
495
+ phase = record_value(loaded.snapshot, :phase)&.to_sym
496
+ unless @wait_state_names.include?(phase) && @external_events.fetch(request.event_name, []).any? { |edge| edge[:from] == phase }
497
+ raise ArgumentError, "No saved wait at #{phase.inspect} accepting #{request.event_name.inspect}"
498
+ end
499
+ fields = record_value(loaded.snapshot, :fields)
500
+ @state_class.new(**restore_context_fields(fields)).set_graph_metadata(
501
+ workflow_instance_id: request.workflow_instance_id, phase: phase
502
+ )
503
+ end
504
+
474
505
  def build_new_execution(
475
506
  input,
476
507
  workflow_instance_id:,
@@ -487,9 +518,7 @@ module Phronomy
487
518
  stored_fields = loaded_snapshot &&
488
519
  (loaded_snapshot[:fields] || loaded_snapshot["fields"])
489
520
  initial_fields = if stored_fields
490
- stored_fields
491
- .transform_keys(&:to_sym)
492
- .merge(input.transform_keys(&:to_sym))
521
+ restore_context_fields(stored_fields).merge(input.transform_keys(&:to_sym))
493
522
  else
494
523
  input
495
524
  end
@@ -509,6 +538,21 @@ module Phronomy
509
538
  )
510
539
  end
511
540
 
541
+ # The repository and the load message own immutable snapshots. Actions receive
542
+ # their own mutable field values, including strings nested in arrays or hashes.
543
+ def restore_context_fields(fields)
544
+ fields.to_h { |name, value| [name.to_sym, mutable_field_value(value)] }
545
+ end
546
+
547
+ def mutable_field_value(value)
548
+ case value
549
+ when Hash then value.transform_values { |child| mutable_field_value(child) }
550
+ when Array then value.map { |child| mutable_field_value(child) }
551
+ when String then value.dup
552
+ else value
553
+ end
554
+ end
555
+
512
556
  def validate_resume_snapshot!(state, durable_snapshot, durable_revision)
513
557
  return nil unless durable_snapshot
514
558
 
@@ -576,6 +620,7 @@ module Phronomy
576
620
  result_task,
577
621
  resume_event: nil,
578
622
  resume_phase: nil,
623
+ require_resume_transition: false,
579
624
  stable_observer: nil
580
625
  )
581
626
  assert_execution_owner!
@@ -588,6 +633,7 @@ module Phronomy
588
633
  execution: execution,
589
634
  resume_event: resume_event,
590
635
  resume_phase: resume_phase,
636
+ require_resume_transition: require_resume_transition,
591
637
  stable_observer: stable_observer,
592
638
  task_execution: task_execution
593
639
  )
@@ -769,6 +815,7 @@ module Phronomy
769
815
  execution:,
770
816
  resume_event: nil,
771
817
  resume_phase: nil,
818
+ require_resume_transition: false,
772
819
  stable_observer: nil,
773
820
  task_execution: nil
774
821
  )
@@ -787,6 +834,7 @@ module Phronomy
787
834
  recursion_limit: execution.recursion_limit,
788
835
  resume_event: resume_event,
789
836
  resume_phase: resume_phase,
837
+ require_resume_transition: require_resume_transition,
790
838
  stable_observer: stable_observer,
791
839
  persist: terminal_persistence_callback(execution),
792
840
  task_execution: task_execution
@@ -30,6 +30,8 @@ PUBLIC_API_ENTRIES = [
30
30
  Phronomy::Embeddings::AuthenticationError,
31
31
  Phronomy::Embeddings::RateLimitError,
32
32
  Phronomy::Agent::Base,
33
+ Phronomy::Agent::HostedInvocation,
34
+ Phronomy::Agent::ApprovalWait,
33
35
  Phronomy::Agent::ReservedExecution,
34
36
  Phronomy::Agent::ExecutionObservation,
35
37
  Phronomy::Agent::KnowledgeItem,
@@ -4,6 +4,19 @@ module Phronomy
4
4
  type execution_wiring = { ?phronomy_execution_participant: _ExecutionParticipant, ?phronomy_control_bindings: Array[_ControlCapability], ?phronomy_transfer_context: TransferContext, ?phronomy_admission: _AdmissionOwner, ?phronomy_reservation: Hash[String, json_value] }
5
5
  type execution_result = { agent_id: String, execution_id: String, status: Symbol, result: json_value, output: json_value, ?raw_output: String?, error: Hash[String, json_value]? }
6
6
 
7
+ class ApprovalWait < Data
8
+ attr_reader execution_id: String
9
+ attr_reader approval_request_id: String
10
+ attr_reader execution_revision: Integer
11
+ def self.new: (execution_id: String, approval_request_id: String, execution_revision: Integer) -> instance
12
+ end
13
+
14
+ class HostedInvocation
15
+ def on_complete: () { (untyped, StandardError?) -> untyped } -> self
16
+ def detach!: (approval_request_id: String, ?execution_id: String) -> ApprovalWait
17
+ def wait_result: (?timeout: Numeric?) -> (execution_result | ApprovalWait)
18
+ end
19
+
7
20
  class ReservedExecution < Data
8
21
  attr_reader agent_id: String
9
22
  attr_reader execution_id: String
@@ -54,6 +54,7 @@ module Phronomy
54
54
  def authorized_operations: (::Phronomy::Persistence::Transaction scope, agent_id: String, execution_id: String, invocation_id: String, name: String, arguments: Hash[String, untyped], names: Array[String]) -> Array[AuthorizedOperation]
55
55
  def result: (String execution_id) -> Hash[Symbol, untyped]
56
56
  def runs: (String owner_id, ?after: String?, ?limit: Integer) -> Array[untyped]
57
+ def standalone_approval_wait: (agent_id: String, execution_id: String, ?scope: ::Phronomy::Persistence::Transaction?) -> ApprovalWait?
57
58
  def observe_execution: (agent_id: String, execution_id: String, ?scope: ::Phronomy::Persistence::Transaction?) -> ExecutionObservation
58
59
  def active_executions: (agent_id: String, ?scope: ::Phronomy::Persistence::Transaction?) -> Array[ExecutionObservation]
59
60
  def definition: (agent_id: String, ?scope: ::Phronomy::Persistence::Transaction?) -> { id: String, version: Integer }
@@ -108,6 +108,7 @@ module Phronomy
108
108
  attr_reader persistence: Store
109
109
  def knowledge_snapshot: () -> Array[KnowledgeItem]
110
110
  def event_listener: () -> (^(Object) -> void)?
111
+ def start_hosted: (reservation: ReservedExecution, input: String, ?config: Hash[Symbol, Object], ?output_schema: Hash[String | Symbol, untyped]?, ?schema_delivery: (:native | :none)?) -> HostedInvocation
111
112
  def start_reserved_async: (reservation: ReservedExecution, input: String, ?config: Hash[Symbol, Object], ?output_schema: Hash[String | Symbol, untyped]?, ?schema_delivery: (:native | :none)?) -> TaskResult[execution_result]
112
113
  def resume_async: (String execution_id, ?config: Hash[Symbol, Object]) -> TaskResult[execution_result]
113
114
  def resume: (String execution_id, ?config: Hash[Symbol, Object]) -> execution_result
@@ -23,6 +23,8 @@ module Phronomy
23
23
  def self.create: (?coordination_store: Store?, **Object options) ?{ (Object) -> void } -> instance
24
24
  def self.load: (String agent_id, persistence: Agent::Store, coordination_store: Store, **Object options) ?{ (Object) -> void } -> instance
25
25
  def self.subagent: (Symbol | String name, singleton(::Phronomy::Agent::Base) agent_class, ?on_error: Symbol, ?inherit_knowledge: bool) -> untyped
26
+ def unload_with_completed_child!: (String parent_execution_id, execution_id: String) -> bool
27
+ def approve_hosted_child: (String parent_execution_id, execution_id: String, approval_request_id: String, ?approved: bool, ?config: Hash[Symbol, untyped]) -> ::Phronomy::Agent::HostedInvocation
26
28
  def self.registered_subagents: () -> Hash[Symbol | String, untyped]
27
29
  def resume: (String execution_id, ?config: Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
28
30
  def dispatch_parallel: (*Hash[Symbol, untyped] tasks, ?max_concurrency: Integer?, ?on_error: Symbol, ?timeout: Numeric?, ?cancellation_token: untyped, ?invocation_context: untyped, ?inherit_knowledge: bool) -> Array[untyped]
@@ -18,6 +18,7 @@ module Phronomy
18
18
  def stream: (untyped input, ?config: Hash[untyped, untyped], ?invocation_context: untyped) { (untyped) -> void } -> untyped
19
19
  def resume: (state: untyped, ?input: untyped) -> untyped
20
20
  def send_event: (state: untyped, event: Symbol, ?input: untyped) -> untyped
21
+ def send_saved_event: (workflow_instance_id: String, expected_revision: Integer, event: Symbol, ?input: untyped) -> untyped
21
22
  def signal: (workflow_instance_id: String, event: Symbol, ?payload: untyped) -> bool
22
23
 
23
24
  class Builder
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phronomy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.31.0
4
+ version: 0.32.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raizo T.C.S
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-25 00:00:00.000000000 Z
11
+ date: 2026-11-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -389,6 +389,7 @@ files:
389
389
  - lib/phronomy/agent/agent_unloaded_error.rb
390
390
  - lib/phronomy/agent/api/agent.rb
391
391
  - lib/phronomy/agent/api/execution_rehydration_required_error.rb
392
+ - lib/phronomy/agent/approval_wait.rb
392
393
  - lib/phronomy/agent/async_event_api.rb
393
394
  - lib/phronomy/agent/base.rb
394
395
  - lib/phronomy/agent/before_llm_input.rb
@@ -429,6 +430,7 @@ files:
429
430
  - lib/phronomy/agent/knowledge_item.rb
430
431
  - lib/phronomy/agent/lifecycle/agent_root.rb
431
432
  - lib/phronomy/agent/lifecycle/default_persistence.rb
433
+ - lib/phronomy/agent/lifecycle/hosted_invocation.rb
432
434
  - lib/phronomy/agent/lifecycle/lifetime_api.rb
433
435
  - lib/phronomy/agent/lifecycle/operation_lease.rb
434
436
  - lib/phronomy/agent/lifecycle/operation_result.rb