phronomy 0.11.0 → 0.12.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/README.md +17 -8
- data/benchmark/bench_agent_invoke.rb +1 -0
- data/lib/phronomy/agent/base.rb +197 -83
- data/lib/phronomy/agent/concerns/error_translation.rb +1 -1
- data/lib/phronomy/agent/concerns/retryable.rb +6 -2
- data/lib/phronomy/agent/invocation_context.rb +171 -0
- data/lib/phronomy/agent/invocation_session.rb +346 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +189 -0
- data/lib/phronomy/agent/suspended_session_registry.rb +54 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +26 -0
- data/lib/phronomy/configuration.rb +0 -6
- data/lib/phronomy/{concurrency → engine/concurrency}/async_queue.rb +68 -5
- data/lib/phronomy/{concurrency → engine/concurrency}/blocking_adapter_pool.rb +9 -3
- data/lib/phronomy/{event_loop.rb → engine/event_loop.rb} +71 -37
- data/lib/phronomy/engine/fsm_session.rb +248 -0
- data/lib/phronomy/{runtime → engine/runtime}/deterministic_scheduler.rb +28 -1
- data/lib/phronomy/{runtime → engine/runtime}/fake_scheduler.rb +1 -1
- data/lib/phronomy/{runtime.rb → engine/runtime.rb} +2 -4
- data/lib/phronomy/{task → engine/task}/backend.rb +2 -2
- data/lib/phronomy/engine/task/deferred_backend.rb +73 -0
- data/lib/phronomy/{task → engine/task}/fiber_backend.rb +1 -1
- data/lib/phronomy/{task → engine/task}/immediate_backend.rb +1 -1
- data/lib/phronomy/{task → engine/task}/mapped_backend.rb +15 -3
- data/lib/phronomy/{task → engine/task}/thread_backend.rb +1 -1
- data/lib/phronomy/{task.rb → engine/task.rb} +19 -7
- data/lib/phronomy/eval/scorer/llm_judge.rb +1 -1
- data/lib/phronomy/generator_verifier.rb +20 -11
- data/lib/phronomy/multi_agent/orchestrator.rb +6 -6
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +3 -3
- data/lib/phronomy/tools/mcp.rb +17 -10
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +24 -13
- data/lib/phronomy/workflow.rb +6 -3
- data/lib/phronomy/workflow_context.rb +7 -5
- data/lib/phronomy/workflow_runner.rb +77 -27
- data/lib/phronomy.rb +17 -9
- metadata +35 -35
- data/lib/phronomy/agent/checkpoint.rb +0 -208
- data/lib/phronomy/agent/checkpoint_store.rb +0 -97
- data/lib/phronomy/agent/concerns/suspendable.rb +0 -190
- data/lib/phronomy/agent/suspend_signal.rb +0 -37
- data/lib/phronomy/context.rb +0 -12
- data/lib/phronomy/tool.rb +0 -9
- data/lib/phronomy/workflow/fsm_session.rb +0 -249
- /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_scope.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_token.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/concurrency_gate.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/deadline.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/gate_registry.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/pool_registry.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/runtime_metrics.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/scheduler.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/scheduler_timer_adapter.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/task_registry.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/thread_scheduler.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/timer_queue.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/timer_service.rb +0 -0
- /data/lib/phronomy/{task_group.rb → engine/task_group.rb} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b5c5fc2f42107629541d051e1330e90e48298ccf19192440e7be77a30b6ca68
|
|
4
|
+
data.tar.gz: 78e4440ec72afb39b7021810c8cdfc8de44f94f15d97442f91000eff50aa967c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 79c98ed427ae1c55d245f871ba9248279199496b27535dfaca2b94d89998bfa95583c8b87a7c192f00f5143c755f6234a4f513ec37c86172ea2c286579e9b72a
|
|
7
|
+
data.tar.gz: 30d8c27a6a0a0d003da85438ee903fc73af648079c83ab42618ce53d98570b9cfaa39144a71c49e8cce77d94b0f679bb82b87b484e06e654eb8298379e8dc093
|
data/README.md
CHANGED
|
@@ -603,14 +603,16 @@ Understanding when to use each prevents scheduler stalls and hidden deadlocks.
|
|
|
603
603
|
| Context | Recommended API |
|
|
604
604
|
|---------|----------------|
|
|
605
605
|
| Top-level application code, Rails controller, background job | `agent.invoke(input)` — blocks the calling thread until done |
|
|
606
|
-
|
|
|
606
|
+
| Workflow action / EventLoop callback | `agent.invoke_async(input).map { |r| ctx.merge(output: r[:output]) }` — returns a Task and resumes by state transition |
|
|
607
|
+
| Top-level code that wants explicit async | `agent.invoke_async(input).wait_result` — blocks the calling thread until the Task completes |
|
|
607
608
|
|
|
608
609
|
### Why this matters
|
|
609
610
|
|
|
610
611
|
`invoke` is a synchronous wrapper that calls `invoke_async` and then _blocks_ the calling
|
|
611
|
-
thread until the task completes.
|
|
612
|
-
|
|
613
|
-
|
|
612
|
+
thread until the task completes. It is intended for top-level application threads such as
|
|
613
|
+
Rails controller actions, CLI scripts, or background jobs. Inside EventLoop-driven
|
|
614
|
+
workflow actions, return a Task and let `Task#map` / `Task#on_complete` drive the next
|
|
615
|
+
state transition instead of waiting inside the EventLoop thread.
|
|
614
616
|
|
|
615
617
|
### Runtime guard
|
|
616
618
|
|
|
@@ -630,14 +632,21 @@ You can also query the current context directly:
|
|
|
630
632
|
Phronomy::Runtime.in_scheduler_context? # => true if called from inside a task
|
|
631
633
|
```
|
|
632
634
|
|
|
633
|
-
### Migration:
|
|
635
|
+
### Migration: blocking wait → Task mapping
|
|
634
636
|
|
|
635
637
|
```ruby
|
|
636
|
-
#
|
|
638
|
+
# Top-level synchronous use
|
|
637
639
|
result = my_agent.invoke("Hello")
|
|
638
640
|
|
|
639
|
-
#
|
|
640
|
-
result = my_agent.invoke_async("Hello").
|
|
641
|
+
# Explicit async from top-level code
|
|
642
|
+
result = my_agent.invoke_async("Hello").wait_result
|
|
643
|
+
|
|
644
|
+
# Workflow action / EventLoop-safe use
|
|
645
|
+
NODE = ->(ctx) {
|
|
646
|
+
my_agent.invoke_async("Hello").map { |result|
|
|
647
|
+
ctx.merge(answer: result[:output])
|
|
648
|
+
}
|
|
649
|
+
}
|
|
641
650
|
```
|
|
642
651
|
|
|
643
652
|
### :immediate backend (synchronous / test mode)
|
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
-
require_relative "checkpoint_store"
|
|
5
4
|
require_relative "concerns/retryable"
|
|
6
5
|
require_relative "concerns/filterable"
|
|
7
6
|
require_relative "concerns/before_completion"
|
|
8
|
-
require_relative "concerns/suspendable"
|
|
9
7
|
require_relative "concerns/error_translation"
|
|
10
8
|
|
|
11
9
|
module Phronomy
|
|
@@ -36,7 +34,6 @@ module Phronomy
|
|
|
36
34
|
include Concerns::Retryable
|
|
37
35
|
include Concerns::Filterable
|
|
38
36
|
include Concerns::BeforeCompletion
|
|
39
|
-
include Concerns::Suspendable
|
|
40
37
|
include Concerns::ErrorTranslation
|
|
41
38
|
|
|
42
39
|
class << self
|
|
@@ -376,25 +373,21 @@ module Phronomy
|
|
|
376
373
|
end
|
|
377
374
|
end
|
|
378
375
|
|
|
379
|
-
#
|
|
380
|
-
# requiring the original agent instance to be kept in memory.
|
|
376
|
+
# Continues a suspended invocation identified by +session_id+.
|
|
381
377
|
#
|
|
382
|
-
#
|
|
383
|
-
#
|
|
378
|
+
# Instantiates a fresh agent and delegates to the instance-level #approve.
|
|
379
|
+
# When +approved: false+, the agent rejects the pending tool call and ends
|
|
380
|
+
# the invocation.
|
|
384
381
|
#
|
|
385
|
-
# @param
|
|
382
|
+
# @param session_id [String] the session_id from the suspended result hash
|
|
386
383
|
# @param approved [Boolean] +true+ to execute the pending tool; +false+ to deny
|
|
387
384
|
# @param config [Hash] same runtime options as {#invoke}
|
|
388
385
|
# @return [Hash] same shape as {#invoke} — may contain +suspended: true+ if
|
|
389
386
|
# another approval-required tool is encountered during continuation
|
|
390
|
-
# @raise [ArgumentError] when
|
|
387
|
+
# @raise [ArgumentError] when no suspended session matches +session_id+
|
|
391
388
|
# @api public
|
|
392
|
-
def
|
|
393
|
-
|
|
394
|
-
raise ArgumentError,
|
|
395
|
-
"checkpoint belongs to #{checkpoint.agent_class}, cannot resume with #{name}"
|
|
396
|
-
end
|
|
397
|
-
new.resume(checkpoint, approved: approved, config: config)
|
|
389
|
+
def approve(session_id, approved: true, config: {})
|
|
390
|
+
new.approve(session_id, approved: approved, config: config)
|
|
398
391
|
end
|
|
399
392
|
end
|
|
400
393
|
|
|
@@ -416,6 +409,35 @@ module Phronomy
|
|
|
416
409
|
@_handoff_tools || []
|
|
417
410
|
end
|
|
418
411
|
|
|
412
|
+
# Registers a synchronous approval callback that is invoked before
|
|
413
|
+
# executing any tool that has +requires_approval true+ set.
|
|
414
|
+
# The block receives the tool name (String) and the arguments Hash, and
|
|
415
|
+
# must return a truthy value to allow execution.
|
|
416
|
+
# Returning a falsy value causes the tool to return a denial message.
|
|
417
|
+
#
|
|
418
|
+
# When no handler is registered and a tool with +requires_approval+ is
|
|
419
|
+
# called, #invoke returns a suspended result hash containing a
|
|
420
|
+
# +session_id+. Call #approve to continue execution.
|
|
421
|
+
#
|
|
422
|
+
# @example
|
|
423
|
+
# agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
|
|
424
|
+
# @return [self]
|
|
425
|
+
# @api public
|
|
426
|
+
def on_approval_required(&block)
|
|
427
|
+
@approval_handler = block
|
|
428
|
+
self
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Registers a scope policy callable for this agent instance.
|
|
432
|
+
#
|
|
433
|
+
# The callable receives +(tool_class, scope, agent)+ and must return
|
|
434
|
+
# +:allow+, +:reject+, or +:approve+.
|
|
435
|
+
#
|
|
436
|
+
# @param policy [#call]
|
|
437
|
+
# @return [void]
|
|
438
|
+
# @api public
|
|
439
|
+
attr_writer :scope_policy
|
|
440
|
+
|
|
419
441
|
# Invokes the agent with the given input and returns a result Hash.
|
|
420
442
|
# Applies the retry policy configured via {.retry_policy} when transient
|
|
421
443
|
# errors occur. {Phronomy::FilterBlockError} is never retried.
|
|
@@ -468,7 +490,10 @@ module Phronomy
|
|
|
468
490
|
|
|
469
491
|
timeout_sec = self.class.invoke_timeout
|
|
470
492
|
unless timeout_sec
|
|
471
|
-
return
|
|
493
|
+
return trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
|
|
494
|
+
result = invoke_async(input, messages: messages, thread_id: thread_id, config: config).wait_result
|
|
495
|
+
[result, result[:usage]]
|
|
496
|
+
end
|
|
472
497
|
end
|
|
473
498
|
|
|
474
499
|
# invoke_timeout: create a CancellationScope with deadline, pass its token
|
|
@@ -482,7 +507,7 @@ module Phronomy
|
|
|
482
507
|
# Bridge the task result to an AsyncQueue so scope.pop_queue can observe the deadline.
|
|
483
508
|
completion_queue = Phronomy::Concurrency::AsyncQueue.new
|
|
484
509
|
Phronomy::Runtime.instance.spawn(name: "invoke-timeout-bridge:#{(self.class.name || "agent").downcase}") do
|
|
485
|
-
completion_queue.push(task.
|
|
510
|
+
completion_queue.push(task.wait_result)
|
|
486
511
|
rescue => e
|
|
487
512
|
completion_queue.push(e)
|
|
488
513
|
end
|
|
@@ -508,7 +533,7 @@ module Phronomy
|
|
|
508
533
|
#
|
|
509
534
|
# @example
|
|
510
535
|
# task = agent.invoke_async("Hello!")
|
|
511
|
-
# result = task.
|
|
536
|
+
# result = task.wait_result # => { output: "...", messages: [...], usage: ... }
|
|
512
537
|
#
|
|
513
538
|
# @param input [String, Hash]
|
|
514
539
|
# @param messages [Array]
|
|
@@ -524,11 +549,11 @@ module Phronomy
|
|
|
524
549
|
bp = Phronomy.configuration.backpressure
|
|
525
550
|
on_full = (bp == :raise) ? :reject : (bp || :wait)
|
|
526
551
|
bp_timeout = Phronomy.configuration.backpressure_timeout
|
|
552
|
+
result_task = Phronomy::Task.deferred(name: "agent-#{(self.class.name || "anonymous").downcase}-async")
|
|
527
553
|
gate = Phronomy::Runtime.instance.gate(:agent)
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
end
|
|
554
|
+
gate.acquire(on_full: on_full, timeout: bp_timeout) do
|
|
555
|
+
_start_invoke_attempt(result_task, input, messages: messages, thread_id: thread_id, config: config, attempt: 0)
|
|
556
|
+
return result_task
|
|
532
557
|
end
|
|
533
558
|
end
|
|
534
559
|
|
|
@@ -809,35 +834,154 @@ module Phronomy
|
|
|
809
834
|
end
|
|
810
835
|
protected :instance_knowledge_chunks
|
|
811
836
|
|
|
812
|
-
#
|
|
813
|
-
#
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
837
|
+
# Runs the agent invocation through the FSM-based execution engine.
|
|
838
|
+
# Called by Retryable#_invoke_impl (which wraps it in a retry loop).
|
|
839
|
+
# Returns the result hash: { output:, messages:, usage: } on success,
|
|
840
|
+
# or { suspended: true, session_id:, messages: } when awaiting approval.
|
|
841
|
+
# @api private
|
|
842
|
+
def _invoke_via_fsm(input, messages: [], thread_id: nil, config: {})
|
|
843
|
+
effective_config = thread_id ? config.merge(thread_id: thread_id) : config
|
|
844
|
+
# Fail fast when the token is already cancelled before any LLM call.
|
|
845
|
+
check_cancellation!(effective_config, "invocation cancelled")
|
|
846
|
+
# Ensure EventLoop is running. start is idempotent when already alive.
|
|
847
|
+
Phronomy::EventLoop.instance.start
|
|
848
|
+
trace("agent.invoke", input: input, **_build_caller_meta(effective_config)) do |_span|
|
|
849
|
+
session = Agent::InvocationSession.build(
|
|
850
|
+
agent: self,
|
|
851
|
+
input: input,
|
|
852
|
+
messages: messages,
|
|
853
|
+
config: effective_config
|
|
825
854
|
)
|
|
826
|
-
|
|
855
|
+
completion_queue = Phronomy::EventLoop.instance.register(session)
|
|
856
|
+
ctx = completion_queue.pop
|
|
857
|
+
raise ctx if ctx.is_a?(Exception)
|
|
858
|
+
result = _extract_invoke_result(ctx, session.id)
|
|
859
|
+
[result, result[:usage]]
|
|
860
|
+
end
|
|
861
|
+
end
|
|
827
862
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
863
|
+
# Starts a single invocation attempt and wires retry/translation onto result_task.
|
|
864
|
+
# Non-blocking: registers with EventLoop and returns immediately.
|
|
865
|
+
# On error, retries via timer_queue when policy allows; otherwise translates
|
|
866
|
+
# and resolves result_task as failed.
|
|
867
|
+
# @api private
|
|
868
|
+
def _start_invoke_attempt(result_task, input, messages:, thread_id:, config:, attempt:)
|
|
869
|
+
effective_config = thread_id ? config.merge(thread_id: thread_id) : config
|
|
870
|
+
check_cancellation!(effective_config, "invocation cancelled")
|
|
871
|
+
Phronomy::EventLoop.instance.start
|
|
872
|
+
session = Agent::InvocationSession.build(
|
|
873
|
+
agent: self,
|
|
874
|
+
input: input,
|
|
875
|
+
messages: messages,
|
|
876
|
+
config: effective_config
|
|
877
|
+
)
|
|
878
|
+
source_task = Phronomy::Task.deferred(name: "#{result_task.name}-attempt-#{attempt}")
|
|
879
|
+
Phronomy::EventLoop.instance.register(session, completion: source_task)
|
|
880
|
+
session_id = session.id
|
|
881
|
+
policy = self.class._retry_policy
|
|
882
|
+
|
|
883
|
+
source_task.on_complete do |ctx, error|
|
|
884
|
+
retriable = error &&
|
|
885
|
+
!error.is_a?(Phronomy::FilterBlockError) &&
|
|
886
|
+
!error.is_a?(Phronomy::CancellationError) &&
|
|
887
|
+
policy && attempt < policy[:times]
|
|
888
|
+
|
|
889
|
+
if retriable
|
|
890
|
+
wait = compute_agent_retry_wait(policy[:wait], policy[:base], attempt)
|
|
891
|
+
# Call _sleep_proc for instrumentation (test spy records the duration;
|
|
892
|
+
# in production this is a no-op since timer_queue handles the actual delay).
|
|
893
|
+
self.class._sleep_proc.call(wait) if wait > 0
|
|
894
|
+
do_retry = -> {
|
|
895
|
+
_start_invoke_attempt(
|
|
896
|
+
result_task, input,
|
|
897
|
+
messages: messages, thread_id: thread_id, config: config,
|
|
898
|
+
attempt: attempt + 1
|
|
899
|
+
)
|
|
900
|
+
}
|
|
901
|
+
if wait > 0
|
|
902
|
+
Phronomy::Runtime.instance.timer_queue.schedule(seconds: wait, &do_retry)
|
|
903
|
+
else
|
|
904
|
+
do_retry.call
|
|
905
|
+
end
|
|
906
|
+
elsif error
|
|
907
|
+
begin
|
|
908
|
+
translate_and_reraise!(error)
|
|
909
|
+
rescue => translated
|
|
910
|
+
result_task.backend.unblock(nil, translated)
|
|
911
|
+
result_task.transition!(:failed, error: translated)
|
|
912
|
+
end
|
|
913
|
+
else
|
|
914
|
+
begin
|
|
915
|
+
result = _extract_invoke_result(ctx, session_id)
|
|
916
|
+
result_task.backend.unblock(result, nil)
|
|
917
|
+
result_task.transition!(:completed, value: result)
|
|
918
|
+
rescue => e
|
|
919
|
+
result_task.backend.unblock(nil, e)
|
|
920
|
+
result_task.transition!(:failed, error: e)
|
|
921
|
+
end
|
|
922
|
+
end
|
|
923
|
+
end
|
|
924
|
+
rescue => e
|
|
925
|
+
result_task.backend.unblock(nil, e)
|
|
926
|
+
result_task.transition!(:failed, error: e)
|
|
927
|
+
end
|
|
831
928
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
929
|
+
# Continues a suspended invocation identified by +session_id+.
|
|
930
|
+
# When +approved: true+, executes the pending tool and continues.
|
|
931
|
+
# When +approved: false+, rejects the tool call and ends the invocation.
|
|
932
|
+
#
|
|
933
|
+
# @param session_id [String]
|
|
934
|
+
# @param approved [Boolean]
|
|
935
|
+
# @param config [Hash]
|
|
936
|
+
# @return [Hash]
|
|
937
|
+
# @api public
|
|
938
|
+
def approve(session_id, approved: true, config: {})
|
|
939
|
+
ctx = Agent::SuspendedSessionRegistry.fetch(session_id)
|
|
940
|
+
raise ArgumentError, "No suspended session found: #{session_id}" unless ctx
|
|
941
|
+
|
|
942
|
+
# Reset approval_required so executing_tool_action proceeds instead of
|
|
943
|
+
# re-suspending when called after the :approve FSM transition.
|
|
944
|
+
ctx.approval_required = false
|
|
945
|
+
ctx.approved = true if approved # signals executing_tool to run the tool
|
|
946
|
+
ctx.rejected = !approved # signals _extract_invoke_result for rejection
|
|
947
|
+
|
|
948
|
+
if approved
|
|
949
|
+
_resume_fsm(ctx, :approve)
|
|
950
|
+
else
|
|
951
|
+
_resume_fsm(ctx, :reject)
|
|
952
|
+
end
|
|
953
|
+
end
|
|
954
|
+
public :approve
|
|
837
955
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
956
|
+
# Builds and runs a resume FSMSession for the given context and event.
|
|
957
|
+
# @api private
|
|
958
|
+
def _resume_fsm(ctx, event)
|
|
959
|
+
session = Agent::InvocationSession.build_for_resume(
|
|
960
|
+
agent: self,
|
|
961
|
+
context: ctx,
|
|
962
|
+
resume_event: event,
|
|
963
|
+
resume_phase: :awaiting_approval
|
|
964
|
+
)
|
|
965
|
+
completion_queue = Phronomy::EventLoop.instance.register(session)
|
|
966
|
+
resumed_ctx = completion_queue.pop
|
|
967
|
+
raise resumed_ctx if resumed_ctx.is_a?(Exception)
|
|
968
|
+
_extract_invoke_result(resumed_ctx, session.id)
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
# Interprets the InvocationContext after FSM completion/halt and returns
|
|
972
|
+
# the appropriate result hash or raises the block error.
|
|
973
|
+
# @api private
|
|
974
|
+
def _extract_invoke_result(ctx, session_id)
|
|
975
|
+
if ctx.phase == :awaiting_approval
|
|
976
|
+
Agent::SuspendedSessionRegistry.store(session_id, ctx)
|
|
977
|
+
{suspended: true, session_id: session_id, messages: ctx.messages}
|
|
978
|
+
elsif ctx.input_blocked? || ctx.output_blocked?
|
|
979
|
+
raise ctx.block_error
|
|
980
|
+
elsif ctx.rejected
|
|
981
|
+
# Rejected path: :reject event → :blocked terminal
|
|
982
|
+
{rejected: true, messages: ctx.messages}
|
|
983
|
+
else
|
|
984
|
+
{output: ctx.output, messages: ctx.messages, usage: ctx.usage}
|
|
841
985
|
end
|
|
842
986
|
end
|
|
843
987
|
|
|
@@ -858,36 +1002,6 @@ module Phronomy
|
|
|
858
1002
|
context[:messages].each { |msg| chat.messages << msg }
|
|
859
1003
|
end
|
|
860
1004
|
|
|
861
|
-
# Submits the LLM call via LLMAdapter and handles SuspendSignal.
|
|
862
|
-
# Sets/clears the chat cancellation token around the call so that
|
|
863
|
-
# ParallelToolChat can observe cancellation without Thread.current.
|
|
864
|
-
# Returns [result_hash, usage_or_nil].
|
|
865
|
-
def _complete_with_suspension_guard(chat, user_message, config, thread_id:, original_input:)
|
|
866
|
-
chat.cancellation_token = config[:cancellation_token] if chat.respond_to?(:cancellation_token=)
|
|
867
|
-
begin
|
|
868
|
-
adapter = Phronomy.configuration.llm_adapter
|
|
869
|
-
response = adapter.complete_async(chat, user_message, config: config).await
|
|
870
|
-
rescue SuspendSignal => signal
|
|
871
|
-
checkpoint = Checkpoint.new(
|
|
872
|
-
checkpoint_id: SecureRandom.uuid,
|
|
873
|
-
agent_class: self.class.name,
|
|
874
|
-
requested_at: Time.now.utc,
|
|
875
|
-
thread_id: thread_id,
|
|
876
|
-
original_input: original_input,
|
|
877
|
-
messages: chat.messages.dup,
|
|
878
|
-
pending_tool_name: signal.tool_name,
|
|
879
|
-
pending_tool_args: signal.args,
|
|
880
|
-
pending_tool_call_id: signal.tool_call_id
|
|
881
|
-
)
|
|
882
|
-
return [{output: nil, suspended: true, checkpoint: checkpoint, messages: chat.messages}, nil]
|
|
883
|
-
ensure
|
|
884
|
-
chat.cancellation_token = nil if chat.respond_to?(:cancellation_token=)
|
|
885
|
-
end
|
|
886
|
-
output = response.content
|
|
887
|
-
usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
888
|
-
[{output: output, messages: chat.messages, usage: usage}, usage]
|
|
889
|
-
end
|
|
890
|
-
|
|
891
1005
|
def _drain_stream(chat, user_message, config, &block)
|
|
892
1006
|
adapter = Phronomy.configuration.llm_adapter
|
|
893
1007
|
chunk_queue = Phronomy::Concurrency::AsyncQueue.new(max_size: Phronomy.configuration.stream_queue_max_size)
|
|
@@ -900,7 +1014,7 @@ module Phronomy
|
|
|
900
1014
|
check_cancellation!(config, "invocation cancelled during streaming")
|
|
901
1015
|
end
|
|
902
1016
|
|
|
903
|
-
response = pending.
|
|
1017
|
+
response = pending.blocking_wait
|
|
904
1018
|
[response.content, Phronomy::TokenUsage.from_tokens(response.tokens)]
|
|
905
1019
|
end
|
|
906
1020
|
|
|
@@ -1059,7 +1173,7 @@ module Phronomy
|
|
|
1059
1173
|
effective_name = resolved.new.name
|
|
1060
1174
|
rejected_class = Class.new(resolved) do
|
|
1061
1175
|
tool_name effective_name
|
|
1062
|
-
define_method(:call) do |_args|
|
|
1176
|
+
define_method(:call) do |_args, **_kwargs|
|
|
1063
1177
|
"Tool execution denied: scope :#{scope} is not permitted."
|
|
1064
1178
|
end
|
|
1065
1179
|
end
|
|
@@ -1085,9 +1199,9 @@ module Phronomy
|
|
|
1085
1199
|
effective_name = resolved.new.name
|
|
1086
1200
|
resolved = Class.new(resolved) do
|
|
1087
1201
|
tool_name effective_name
|
|
1088
|
-
define_method(:call) do |args|
|
|
1202
|
+
define_method(:call) do |args, **kwargs|
|
|
1089
1203
|
if handler.call(name, args)
|
|
1090
|
-
super(args)
|
|
1204
|
+
super(args, **kwargs)
|
|
1091
1205
|
else
|
|
1092
1206
|
"Tool execution denied."
|
|
1093
1207
|
end
|
|
@@ -1102,8 +1216,8 @@ module Phronomy
|
|
|
1102
1216
|
effective_name4 = resolved.new.name
|
|
1103
1217
|
Class.new(resolved) do
|
|
1104
1218
|
tool_name effective_name4
|
|
1105
|
-
define_method(:call) do |args|
|
|
1106
|
-
result = super(args)
|
|
1219
|
+
define_method(:call) do |args, **kwargs|
|
|
1220
|
+
result = super(args, **kwargs)
|
|
1107
1221
|
result_filters.inject(result) { |val, f| f.call(val, tool_name: name, args: args) }
|
|
1108
1222
|
end
|
|
1109
1223
|
end
|
|
@@ -35,7 +35,11 @@ module Phronomy
|
|
|
35
35
|
# @return [Hash, nil]
|
|
36
36
|
attr_reader :_retry_policy
|
|
37
37
|
|
|
38
|
-
# Injectable sleep callable for testing
|
|
38
|
+
# Injectable sleep callable for testing.
|
|
39
|
+
# In the EventLoop-driven invoke path, actual delay is handled by
|
|
40
|
+
# timer_queue (non-blocking); _sleep_proc is called only as an
|
|
41
|
+
# instrumentation hook. The default is a no-op; tests override it
|
|
42
|
+
# with a spy lambda to record sleep durations.
|
|
39
43
|
# @return [#call]
|
|
40
44
|
# @api private
|
|
41
45
|
def _sleep_proc
|
|
@@ -59,7 +63,7 @@ module Phronomy
|
|
|
59
63
|
policy = self.class._retry_policy
|
|
60
64
|
attempt = 0
|
|
61
65
|
begin
|
|
62
|
-
|
|
66
|
+
_invoke_via_fsm(input, messages: messages, thread_id: thread_id, config: config)
|
|
63
67
|
rescue Phronomy::FilterBlockError
|
|
64
68
|
raise
|
|
65
69
|
rescue Phronomy::CancellationError
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# Holds the mutable state for a single Agent#invoke execution.
|
|
6
|
+
#
|
|
7
|
+
# An InvocationContext is created at the start of each invoke call and
|
|
8
|
+
# passed through every FSM state as the context object. It plays the same
|
|
9
|
+
# role as WorkflowContext does for Workflow executions.
|
|
10
|
+
#
|
|
11
|
+
# Fields:
|
|
12
|
+
# input — the (possibly filter-transformed) user input
|
|
13
|
+
# messages — conversation history (Array of RubyLLM::Message)
|
|
14
|
+
# chat — the RubyLLM::Chat object built during :building_context
|
|
15
|
+
# output — the final LLM text response
|
|
16
|
+
# usage — Phronomy::TokenUsage after completion
|
|
17
|
+
# tool_call_pending — whether the last LLM response contained a tool call
|
|
18
|
+
# approval_required — whether the pending tool requires human approval
|
|
19
|
+
# input_blocked — whether an input filter called block!
|
|
20
|
+
# output_blocked — whether an output filter called block!
|
|
21
|
+
# block_error — the FilterBlockError raised (for :blocked terminal)
|
|
22
|
+
# pending_tool_call — the intercepted tool_call object (name/arguments/id)
|
|
23
|
+
# user_message_sent — true after the first LLM call (continuation uses nil msg)
|
|
24
|
+
# sync_approval_handler — true when agent has an on_approval_required block
|
|
25
|
+
# session_id — FSM session id (set by set_graph_metadata)
|
|
26
|
+
# phase — terminal FSM phase (set by set_graph_metadata)
|
|
27
|
+
#
|
|
28
|
+
# @api private
|
|
29
|
+
class InvocationContext
|
|
30
|
+
# @return [String, Hash] user input (may be transformed by input filters)
|
|
31
|
+
attr_accessor :input
|
|
32
|
+
|
|
33
|
+
# @return [Array] conversation history
|
|
34
|
+
attr_accessor :messages
|
|
35
|
+
|
|
36
|
+
# @return [Object, nil] RubyLLM::Chat instance
|
|
37
|
+
attr_accessor :chat
|
|
38
|
+
|
|
39
|
+
# @return [String, nil] final LLM output text
|
|
40
|
+
attr_accessor :output
|
|
41
|
+
|
|
42
|
+
# @return [Phronomy::TokenUsage, nil]
|
|
43
|
+
attr_accessor :usage
|
|
44
|
+
|
|
45
|
+
# @return [Boolean] true when calling_llm returned a tool call
|
|
46
|
+
attr_accessor :tool_call_pending
|
|
47
|
+
|
|
48
|
+
# @return [Boolean] true when the pending tool requires human approval
|
|
49
|
+
attr_accessor :approval_required
|
|
50
|
+
|
|
51
|
+
# @return [Boolean] true when an input filter called block!
|
|
52
|
+
attr_accessor :input_blocked
|
|
53
|
+
|
|
54
|
+
# @return [Boolean] true when an output filter called block!
|
|
55
|
+
attr_accessor :output_blocked
|
|
56
|
+
|
|
57
|
+
# @return [Phronomy::FilterBlockError, nil]
|
|
58
|
+
attr_accessor :block_error
|
|
59
|
+
|
|
60
|
+
# @return [Object, nil] intercepted RubyLLM tool_call (name/arguments/id)
|
|
61
|
+
attr_accessor :pending_tool_call
|
|
62
|
+
|
|
63
|
+
# @return [Boolean] true after the first LLM call (continuation passes nil msg)
|
|
64
|
+
attr_accessor :user_message_sent
|
|
65
|
+
|
|
66
|
+
# @return [Boolean] true when Agent.approve was called (signals executing_tool
|
|
67
|
+
# to execute the pending tool rather than re-suspending)
|
|
68
|
+
attr_accessor :approved
|
|
69
|
+
|
|
70
|
+
# @return [Boolean] true when the pending tool call was rejected via
|
|
71
|
+
# Agent.approve(session_id, approved: false)
|
|
72
|
+
attr_accessor :rejected
|
|
73
|
+
|
|
74
|
+
# @return [String, nil] FSM session id (set by set_graph_metadata)
|
|
75
|
+
attr_reader :session_id
|
|
76
|
+
|
|
77
|
+
# @return [Symbol, nil] terminal FSM phase (set by set_graph_metadata)
|
|
78
|
+
attr_reader :phase
|
|
79
|
+
|
|
80
|
+
# @return [Boolean] true when agent has a synchronous on_approval_required handler
|
|
81
|
+
attr_reader :sync_approval_handler
|
|
82
|
+
|
|
83
|
+
# @return [Phronomy::Agent::Base] the agent instance driving this invocation
|
|
84
|
+
attr_reader :agent
|
|
85
|
+
|
|
86
|
+
# @return [Hash] the config hash passed to invoke
|
|
87
|
+
attr_reader :config
|
|
88
|
+
|
|
89
|
+
# @return [String, nil] thread_id from config
|
|
90
|
+
attr_reader :thread_id
|
|
91
|
+
|
|
92
|
+
# @param agent [Phronomy::Agent::Base]
|
|
93
|
+
# @param input [String, Hash]
|
|
94
|
+
# @param messages [Array]
|
|
95
|
+
# @param config [Hash]
|
|
96
|
+
# @api private
|
|
97
|
+
def initialize(agent:, input:, messages:, config:)
|
|
98
|
+
@agent = agent
|
|
99
|
+
@input = input
|
|
100
|
+
@messages = Array(messages)
|
|
101
|
+
@config = config
|
|
102
|
+
@thread_id = config[:thread_id]
|
|
103
|
+
@chat = nil
|
|
104
|
+
@output = nil
|
|
105
|
+
@usage = nil
|
|
106
|
+
@tool_call_pending = false
|
|
107
|
+
@approval_required = false
|
|
108
|
+
@input_blocked = false
|
|
109
|
+
@output_blocked = false
|
|
110
|
+
@block_error = nil
|
|
111
|
+
@pending_tool_call = nil
|
|
112
|
+
@user_message_sent = false
|
|
113
|
+
@approved = false
|
|
114
|
+
@rejected = false
|
|
115
|
+
@sync_approval_handler = !agent.instance_variable_get(:@approval_handler).nil?
|
|
116
|
+
@session_id = nil
|
|
117
|
+
@phase = nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Called by FSMSession#finish! and FSMSession#halt! to record the
|
|
121
|
+
# terminal/halted phase and the session id.
|
|
122
|
+
# Mirrors the WorkflowContext interface so FSMSession works with both.
|
|
123
|
+
# @param thread_id [String, nil]
|
|
124
|
+
# @param phase [Symbol, nil]
|
|
125
|
+
# @return [void]
|
|
126
|
+
# @api private
|
|
127
|
+
def set_graph_metadata(thread_id: nil, phase: nil)
|
|
128
|
+
@session_id = thread_id if thread_id
|
|
129
|
+
@phase = phase
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Guard helpers used by PhaseMachineBuilder transitions.
|
|
133
|
+
|
|
134
|
+
# @return [Boolean]
|
|
135
|
+
# @api private
|
|
136
|
+
def input_passed?
|
|
137
|
+
!@input_blocked
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [Boolean]
|
|
141
|
+
# @api private
|
|
142
|
+
def input_blocked?
|
|
143
|
+
@input_blocked
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @return [Boolean]
|
|
147
|
+
# @api private
|
|
148
|
+
def output_passed?
|
|
149
|
+
!@output_blocked
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @return [Boolean]
|
|
153
|
+
# @api private
|
|
154
|
+
def output_blocked?
|
|
155
|
+
@output_blocked
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @return [Boolean]
|
|
159
|
+
# @api private
|
|
160
|
+
def tool_call_pending?
|
|
161
|
+
@tool_call_pending
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# @return [Boolean]
|
|
165
|
+
# @api private
|
|
166
|
+
def approval_required?
|
|
167
|
+
@approval_required
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|