phronomy 0.7.1 → 0.8.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 +16 -16
- data/benchmark/bench_context_assembler.rb +2 -2
- data/benchmark/bench_regression.rb +5 -5
- data/benchmark/bench_token_estimator.rb +5 -5
- data/benchmark/bench_tool_schema.rb +1 -1
- data/benchmark/bench_vector_store.rb +1 -1
- data/lib/phronomy/agent/base.rb +86 -123
- data/lib/phronomy/agent/checkpoint.rb +118 -0
- data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
- data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
- data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
- data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
- data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
- data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
- data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
- data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
- data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
- data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
- data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
- data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
- data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
- data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
- data/lib/phronomy/agent/fsm.rb +1 -1
- data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
- data/lib/phronomy/agent/react_agent.rb +19 -14
- data/lib/phronomy/agent/runner.rb +2 -2
- data/lib/phronomy/agent/tool_executor.rb +108 -0
- data/lib/phronomy/concurrency/async_queue.rb +157 -0
- data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
- data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
- data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
- data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
- data/lib/phronomy/concurrency/deadline.rb +65 -0
- data/lib/phronomy/{runtime → concurrency}/gate_registry.rb +1 -1
- data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
- data/lib/phronomy/context.rb +2 -8
- data/lib/phronomy/embeddings.rb +2 -2
- data/lib/phronomy/eval/runner.rb +4 -0
- data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
- data/lib/phronomy/event_loop.rb +7 -7
- data/lib/phronomy/invocation_context.rb +3 -3
- data/lib/phronomy/knowledge_source.rb +0 -5
- data/lib/phronomy/llm_adapter/ruby_llm.rb +17 -11
- data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
- data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
- data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
- data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
- data/lib/phronomy/loader.rb +4 -4
- data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
- data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +6 -6
- data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
- data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
- data/lib/phronomy/runtime.rb +19 -4
- data/lib/phronomy/splitter.rb +3 -3
- data/lib/phronomy/task_group.rb +1 -1
- data/lib/phronomy/tool/base.rb +50 -9
- data/lib/phronomy/tracing/null_tracer.rb +3 -1
- data/lib/phronomy/vector_store.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow_context.rb +8 -0
- data/lib/phronomy/workflow_runner.rb +11 -131
- data/lib/phronomy.rb +1 -0
- metadata +44 -42
- data/lib/phronomy/async_queue.rb +0 -155
- data/lib/phronomy/blocking_adapter_pool.rb +0 -435
- data/lib/phronomy/cancellation_scope.rb +0 -123
- data/lib/phronomy/cancellation_token.rb +0 -133
- data/lib/phronomy/concurrency_gate.rb +0 -155
- data/lib/phronomy/context/compaction_context.rb +0 -111
- data/lib/phronomy/context/trigger_context.rb +0 -39
- data/lib/phronomy/context/trim_context.rb +0 -75
- data/lib/phronomy/deadline.rb +0 -63
- data/lib/phronomy/embeddings/base.rb +0 -39
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
- data/lib/phronomy/fsm_session.rb +0 -247
- data/lib/phronomy/knowledge_source/base.rb +0 -54
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
- data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
- data/lib/phronomy/loader/base.rb +0 -25
- data/lib/phronomy/loader/csv_loader.rb +0 -56
- data/lib/phronomy/loader/markdown_loader.rb +0 -76
- data/lib/phronomy/loader/plain_text_loader.rb +0 -22
- data/lib/phronomy/prompt_template.rb +0 -96
- data/lib/phronomy/splitter/base.rb +0 -47
- data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
- data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
- data/lib/phronomy/tool_executor.rb +0 -106
- data/lib/phronomy/vector_store/async_backend.rb +0 -110
- data/lib/phronomy/vector_store/base.rb +0 -89
- data/lib/phronomy/vector_store/in_memory.rb +0 -93
- data/lib/phronomy/vector_store/pgvector.rb +0 -127
- data/lib/phronomy/vector_store/redis_search.rb +0 -192
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# Encapsulates the core per-invocation LLM round-trip for {Agent::Base}.
|
|
6
|
+
#
|
|
7
|
+
# {Agent::Base#invoke_once} delegates the body of each LLM turn to this
|
|
8
|
+
# class, keeping the caller to a thin setup + trace frame (span≈2).
|
|
9
|
+
# The pipeline executes inside the agent's binding via +instance_exec+
|
|
10
|
+
# so that private concern methods (guardrails, hooks, cancellation) remain
|
|
11
|
+
# encapsulated in their original modules while the orchestration logic lives
|
|
12
|
+
# here.
|
|
13
|
+
#
|
|
14
|
+
# @api private
|
|
15
|
+
class InvocationPipeline
|
|
16
|
+
# @param agent [Agent::Base] the agent instance driving this invocation
|
|
17
|
+
# @api private
|
|
18
|
+
def initialize(agent)
|
|
19
|
+
@agent = agent
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Runs one LLM round-trip inside the agent's execution context.
|
|
23
|
+
#
|
|
24
|
+
# Calls private {Agent::Base} concern methods (guardrails, hooks,
|
|
25
|
+
# cancellation) via +instance_exec+ so that their encapsulation is
|
|
26
|
+
# preserved, then routes the LLM request through the configured adapter.
|
|
27
|
+
#
|
|
28
|
+
# @param input [String, Hash] the user input for this turn
|
|
29
|
+
# @param messages [Array] prior conversation messages
|
|
30
|
+
# @param thread_id [String, nil] persistence thread identifier
|
|
31
|
+
# @param config [Hash] per-invocation options
|
|
32
|
+
# @return [Array(Hash, Phronomy::TokenUsage, nil)]
|
|
33
|
+
# A two-element array: the result hash and the token usage (or nil on
|
|
34
|
+
# suspension).
|
|
35
|
+
# @api private
|
|
36
|
+
def run(input, messages:, thread_id:, config:)
|
|
37
|
+
@agent.instance_exec(input, messages, thread_id, config) do |inp, msgs, tid, cfg|
|
|
38
|
+
# Run input guardrails before touching the LLM.
|
|
39
|
+
run_input_guardrails!(inp)
|
|
40
|
+
|
|
41
|
+
user_message = extract_message(inp)
|
|
42
|
+
chat = build_chat
|
|
43
|
+
|
|
44
|
+
# Assemble context (system prompt + history). Override #build_context to
|
|
45
|
+
# inject custom context editing logic at the Agent subclass level.
|
|
46
|
+
context = build_context(inp, messages: msgs, thread_id: tid, config: cfg)
|
|
47
|
+
apply_instructions(chat, context[:system]) if context[:system]
|
|
48
|
+
context[:messages].each { |msg| chat.messages << msg }
|
|
49
|
+
|
|
50
|
+
# Run before_completion hooks (global → class → instance) before the LLM call.
|
|
51
|
+
run_before_completion_hooks!(chat, cfg)
|
|
52
|
+
|
|
53
|
+
# Register suspension hook for approval-required tools (no-op when a
|
|
54
|
+
# synchronous on_approval_required handler is already registered).
|
|
55
|
+
_register_suspension_hook!(chat)
|
|
56
|
+
|
|
57
|
+
# Check for cancellation immediately before the LLM call.
|
|
58
|
+
check_cancellation!(cfg, "invocation cancelled before LLM call")
|
|
59
|
+
|
|
60
|
+
# Forward the cancellation token to ParallelToolChat explicitly
|
|
61
|
+
# via the chat instance so that tool dispatch batches can observe
|
|
62
|
+
# cancellation without needing Thread.current.
|
|
63
|
+
chat.cancellation_token = cfg[:cancellation_token] if chat.respond_to?(:cancellation_token=)
|
|
64
|
+
|
|
65
|
+
begin
|
|
66
|
+
# Route the LLM call through the configured LLMAdapter so that the
|
|
67
|
+
# blocking HTTP request runs inside BlockingAdapterPool and the
|
|
68
|
+
# adapter can be swapped without changing agent code.
|
|
69
|
+
adapter = Phronomy.configuration.llm_adapter
|
|
70
|
+
response = adapter.complete_async(chat, user_message, config: cfg).await
|
|
71
|
+
rescue SuspendSignal => signal
|
|
72
|
+
checkpoint = Checkpoint.new(
|
|
73
|
+
thread_id: tid,
|
|
74
|
+
original_input: inp,
|
|
75
|
+
messages: chat.messages.dup,
|
|
76
|
+
pending_tool_name: signal.tool_name,
|
|
77
|
+
pending_tool_args: signal.args,
|
|
78
|
+
pending_tool_call_id: signal.tool_call_id
|
|
79
|
+
)
|
|
80
|
+
suspended_result = {output: nil, suspended: true, checkpoint: checkpoint, messages: chat.messages}
|
|
81
|
+
next [suspended_result, nil]
|
|
82
|
+
ensure
|
|
83
|
+
# Clear the chat's cancellation token reference after each LLM call.
|
|
84
|
+
chat.cancellation_token = nil if chat.respond_to?(:cancellation_token=)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
output = response.content
|
|
88
|
+
usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
89
|
+
|
|
90
|
+
# Run output guardrails before returning to the caller.
|
|
91
|
+
run_output_guardrails!(output)
|
|
92
|
+
|
|
93
|
+
result = {output: output, messages: chat.messages, usage: usage}
|
|
94
|
+
[result, usage]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Lifecycle
|
|
6
|
+
# Event-driven execution wrapper for a single workflow run.
|
|
7
|
+
#
|
|
8
|
+
# Created by WorkflowRunner and registered with EventLoop. All public methods
|
|
9
|
+
# are called from the EventLoop thread — FSMSession is NOT thread-safe and must
|
|
10
|
+
# not be accessed concurrently from multiple threads.
|
|
11
|
+
#
|
|
12
|
+
# == Lifecycle
|
|
13
|
+
#
|
|
14
|
+
# register(session) → EventLoop posts :start → session.start
|
|
15
|
+
# ↓ (auto-transition present)
|
|
16
|
+
# EventLoop posts :state_completed → session.handle
|
|
17
|
+
# ↓ (repeat)
|
|
18
|
+
# session posts :finished or :halted
|
|
19
|
+
# ↓
|
|
20
|
+
# EventLoop pushes ctx to completion_queue → caller unblocks
|
|
21
|
+
#
|
|
22
|
+
# == Async IO pattern (EventLoop mode only)
|
|
23
|
+
#
|
|
24
|
+
# When a state has no auto-transition and is not a wait_state, but has an
|
|
25
|
+
# external event registered (e.g. +transition from: :fetching, on: :fetch_done+),
|
|
26
|
+
# the FSMSession stays registered in the EventLoop and waits for that event.
|
|
27
|
+
# The entry action is expected to spawn an IO thread that posts the event back:
|
|
28
|
+
#
|
|
29
|
+
# entry :fetching, ->(ctx) {
|
|
30
|
+
# Thread.new {
|
|
31
|
+
# ctx.result = http.get(ctx.url)
|
|
32
|
+
# Phronomy::EventLoop.instance.post(
|
|
33
|
+
# Phronomy::Event.new(type: :fetch_done, target_id: ctx.thread_id, payload: nil)
|
|
34
|
+
# )
|
|
35
|
+
# }
|
|
36
|
+
# }
|
|
37
|
+
# transition from: :fetching, on: :fetch_done, to: :process
|
|
38
|
+
class FSMSession
|
|
39
|
+
FINISH = WorkflowRunner::FINISH
|
|
40
|
+
|
|
41
|
+
# @return [String] workflow thread_id (matches WorkflowContext#thread_id)
|
|
42
|
+
attr_reader :id
|
|
43
|
+
|
|
44
|
+
# @param id [String]
|
|
45
|
+
# @param context [Object] includes Phronomy::WorkflowContext
|
|
46
|
+
# @param entry_point [Symbol] initial state name
|
|
47
|
+
# @param entry_actions [Hash] { state_name => [callable, ...] }
|
|
48
|
+
# @param auto_state_set [Hash] { state_name => true }
|
|
49
|
+
# @param declared_states [Array<Symbol>] all action state names
|
|
50
|
+
# @param wait_state_names [Array<Symbol>]
|
|
51
|
+
# @param external_events [Hash] { event_name => [{from:, to:, guard:}] }
|
|
52
|
+
# @param phase_machine_class [Class] state_machines-backed phase tracker class
|
|
53
|
+
# @param recursion_limit [Integer]
|
|
54
|
+
# @param action_timeouts [Hash] { state_name => seconds }
|
|
55
|
+
# @param resume_event [Symbol, nil] external event to fire when resuming
|
|
56
|
+
# @param resume_phase [Symbol, nil] wait state name to resume from
|
|
57
|
+
# @api private
|
|
58
|
+
def initialize(id:, context:, entry_point:, entry_actions:, auto_state_set:,
|
|
59
|
+
declared_states:, wait_state_names:, external_events:, phase_machine_class:,
|
|
60
|
+
recursion_limit:, action_timeouts: {}, resume_event: nil, resume_phase: nil)
|
|
61
|
+
@id = id
|
|
62
|
+
@ctx = context
|
|
63
|
+
@entry_point = entry_point
|
|
64
|
+
@entry_actions = entry_actions
|
|
65
|
+
@auto_state_set = auto_state_set
|
|
66
|
+
@declared_states = declared_states
|
|
67
|
+
@wait_state_names = wait_state_names
|
|
68
|
+
@external_events = external_events
|
|
69
|
+
@phase_machine_class = phase_machine_class
|
|
70
|
+
@recursion_limit = recursion_limit
|
|
71
|
+
@action_timeouts = action_timeouts
|
|
72
|
+
@resume_event = resume_event
|
|
73
|
+
@resume_phase = resume_phase
|
|
74
|
+
@step = 0
|
|
75
|
+
@done = false
|
|
76
|
+
@current_state = nil
|
|
77
|
+
@tracker = nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Begins workflow execution. Called by EventLoop on :start event.
|
|
81
|
+
def start
|
|
82
|
+
if @resume_event
|
|
83
|
+
# Resume from wait state: position tracker at the wait state, then fire the
|
|
84
|
+
# external event. state_machines fires before_transition (exit) and
|
|
85
|
+
# after_transition (entry) callbacks, so both actions execute here.
|
|
86
|
+
@current_state = @resume_phase
|
|
87
|
+
@tracker = build_tracker(@current_state)
|
|
88
|
+
@tracker.context = @ctx
|
|
89
|
+
fire_and_advance!(@resume_event)
|
|
90
|
+
else
|
|
91
|
+
# Fresh start: state_machines does not fire callbacks on initialization,
|
|
92
|
+
# so we invoke the entry action for the initial state manually.
|
|
93
|
+
@current_state = @entry_point
|
|
94
|
+
@tracker = build_tracker(@current_state)
|
|
95
|
+
@tracker.context = @ctx
|
|
96
|
+
(@entry_actions[@current_state] || []).each do |c|
|
|
97
|
+
result = c.call(@ctx)
|
|
98
|
+
if result.is_a?(Phronomy::Task)
|
|
99
|
+
# Awaitable action: spawn a task to await without blocking EventLoop.
|
|
100
|
+
@tracker.async_pending = true
|
|
101
|
+
session_id = @id
|
|
102
|
+
current_state_name = @current_state
|
|
103
|
+
timeout_secs = @action_timeouts[current_state_name]
|
|
104
|
+
Phronomy::Runtime.instance.spawn(name: "fsm-await-#{session_id}") do
|
|
105
|
+
if timeout_secs
|
|
106
|
+
if result.join(timeout_secs).nil?
|
|
107
|
+
result.cancel!
|
|
108
|
+
raise Phronomy::ActionTimeoutError,
|
|
109
|
+
"Action in state #{current_state_name.inspect} timed out after #{timeout_secs}s"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
task_result = result.await
|
|
113
|
+
if task_result.is_a?(Phronomy::WorkflowContext)
|
|
114
|
+
event_loop.post(Event.new(type: :action_completed, target_id: session_id, payload: task_result))
|
|
115
|
+
else
|
|
116
|
+
event_loop.post(Event.new(type: :state_completed, target_id: session_id, payload: nil))
|
|
117
|
+
end
|
|
118
|
+
rescue => e
|
|
119
|
+
event_loop.post(Event.new(type: :error, target_id: session_id, payload: e))
|
|
120
|
+
end
|
|
121
|
+
break # Only one async action at a time per state
|
|
122
|
+
elsif result.is_a?(Phronomy::WorkflowContext)
|
|
123
|
+
@ctx = result
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
@tracker.context = @ctx
|
|
127
|
+
advance_or_halt unless @tracker.async_pending
|
|
128
|
+
end
|
|
129
|
+
rescue => e
|
|
130
|
+
finish_with_error(e)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Processes an event dispatched from EventLoop.
|
|
134
|
+
# Called for :state_completed, :action_completed, and all user-defined external events.
|
|
135
|
+
#
|
|
136
|
+
# @param event [Phronomy::Event]
|
|
137
|
+
# @api private
|
|
138
|
+
def handle(event)
|
|
139
|
+
return if @done
|
|
140
|
+
|
|
141
|
+
if event.type == :action_completed
|
|
142
|
+
# An awaitable entry action completed: update context and advance.
|
|
143
|
+
@ctx = event.payload if event.payload.is_a?(Phronomy::WorkflowContext)
|
|
144
|
+
@tracker.context = @ctx
|
|
145
|
+
@tracker.async_pending = false # Reset flag set by start or fire_and_advance!
|
|
146
|
+
advance_or_halt
|
|
147
|
+
return
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
fire_and_advance!(event.type)
|
|
151
|
+
rescue => e
|
|
152
|
+
finish_with_error(e)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
# Fires event_name on the phase tracker, updates @current_state, then
|
|
158
|
+
# calls advance_or_halt to decide what to do next.
|
|
159
|
+
def fire_and_advance!(event_name)
|
|
160
|
+
if @step >= @recursion_limit
|
|
161
|
+
raise Phronomy::RecursionLimitError,
|
|
162
|
+
"Recursion limit (#{@recursion_limit}) exceeded"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
fire_event!(@tracker, event_name, @current_state)
|
|
166
|
+
@ctx = @tracker.context
|
|
167
|
+
next_phase = @tracker.phase.to_sym
|
|
168
|
+
# When next_phase == @current_state, no transition matched → treat as terminal.
|
|
169
|
+
@current_state = (next_phase == @current_state) ? FINISH : next_phase
|
|
170
|
+
@step += 1
|
|
171
|
+
|
|
172
|
+
# If an entry action returned a Task, the after_transition callback set
|
|
173
|
+
# async_pending = true and spawned a thread. Skip advance_or_halt — the
|
|
174
|
+
# background thread will post :action_completed or :state_completed.
|
|
175
|
+
if @tracker.async_pending
|
|
176
|
+
@tracker.async_pending = false
|
|
177
|
+
return
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
advance_or_halt
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Determines the next action after the FSM has entered @current_state.
|
|
184
|
+
def advance_or_halt
|
|
185
|
+
return finish! if @current_state == FINISH
|
|
186
|
+
|
|
187
|
+
if @wait_state_names.include?(@current_state)
|
|
188
|
+
return halt!
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
if @auto_state_set.key?(@current_state)
|
|
192
|
+
event_loop.post(Event.new(type: :state_completed, target_id: @id, payload: nil))
|
|
193
|
+
return
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
if has_external_event_from?(@current_state)
|
|
197
|
+
# Async IO pattern: the entry action spawned an IO thread that will post
|
|
198
|
+
# an external event back. Stay registered; do nothing here.
|
|
199
|
+
return
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# No transition declared — validate the state is known, then treat as terminal.
|
|
203
|
+
unless @declared_states.include?(@current_state)
|
|
204
|
+
raise ArgumentError, "State #{@current_state.inspect} is not defined"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
finish!
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def finish!
|
|
211
|
+
@done = true
|
|
212
|
+
@ctx.set_graph_metadata(thread_id: @id, phase: :__end__)
|
|
213
|
+
event_loop.post(Event.new(type: :finished, target_id: @id, payload: @ctx))
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def halt!
|
|
217
|
+
@done = true
|
|
218
|
+
@ctx.set_graph_metadata(thread_id: @id, phase: @current_state)
|
|
219
|
+
event_loop.post(Event.new(type: :halted, target_id: @id, payload: @ctx))
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def finish_with_error(err)
|
|
223
|
+
@done = true
|
|
224
|
+
event_loop.post(Event.new(type: :error, target_id: @id, payload: err))
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def fire_event!(tracker, event_name, from_state)
|
|
228
|
+
return if tracker.send(event_name)
|
|
229
|
+
|
|
230
|
+
raise ArgumentError,
|
|
231
|
+
"Transition from #{from_state.inspect} via event #{event_name.inspect} failed. " \
|
|
232
|
+
"Ensure at least one guard matches or add a fallback (no-guard) transition."
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def has_external_event_from?(state)
|
|
236
|
+
@external_events.any? { |_, transitions| transitions.any? { |t| t[:from] == state } }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def build_tracker(from_state)
|
|
240
|
+
machine = @phase_machine_class.new
|
|
241
|
+
machine.instance_variable_set(:@phase, from_state.to_s)
|
|
242
|
+
machine
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def event_loop
|
|
246
|
+
Phronomy::EventLoop.instance
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "state_machines"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Agent
|
|
7
|
+
module Lifecycle
|
|
8
|
+
# Builds the anonymous state-machine Class used by {WorkflowRunner} to track
|
|
9
|
+
# workflow phase transitions.
|
|
10
|
+
#
|
|
11
|
+
# Extracted from {WorkflowRunner#build_phase_machine_class} to reduce the
|
|
12
|
+
# span of WorkflowRunner's initializer and to give the FSM construction
|
|
13
|
+
# logic an explicit, testable home.
|
|
14
|
+
#
|
|
15
|
+
# Call {#build} to obtain the generated +Class+. The returned class responds
|
|
16
|
+
# to +#context+ / +#context=+ and +#async_pending+ / +#async_pending=+, and
|
|
17
|
+
# has a +state_machine :phase+ definition with all registered transitions and
|
|
18
|
+
# callbacks.
|
|
19
|
+
#
|
|
20
|
+
# @api private
|
|
21
|
+
class PhaseMachineBuilder
|
|
22
|
+
# @param entry_point [Symbol] initial state for the phase machine
|
|
23
|
+
# @param declared_states [Array<Symbol>] all states declared in the workflow
|
|
24
|
+
# @param wait_state_names [Array<Symbol>] states that wait for external events
|
|
25
|
+
# @param external_events [Hash{Symbol => Array<Hash>}]
|
|
26
|
+
# +{ event_name => [{from:, to:, guard:}, ...] }+
|
|
27
|
+
# @param entry_actions [Hash{Symbol => Array<#call>}]
|
|
28
|
+
# +{ state_name => [callable, ...] }+
|
|
29
|
+
# @param action_timeouts [Hash{Symbol => Numeric}]
|
|
30
|
+
# +{ state_name => seconds }+
|
|
31
|
+
# @param auto_transitions [Array<Hash>]
|
|
32
|
+
# +[{ from:, to:, guard: }, ...]+ — all auto-fire transitions
|
|
33
|
+
# @param exit_actions [Hash{Symbol => Array<#call>}]
|
|
34
|
+
# +{ state_name => [callable, ...] }+
|
|
35
|
+
# @api private
|
|
36
|
+
def initialize(
|
|
37
|
+
entry_point:,
|
|
38
|
+
declared_states:,
|
|
39
|
+
wait_state_names:,
|
|
40
|
+
external_events:,
|
|
41
|
+
entry_actions:,
|
|
42
|
+
action_timeouts:,
|
|
43
|
+
auto_transitions:,
|
|
44
|
+
exit_actions:
|
|
45
|
+
)
|
|
46
|
+
@entry_point = entry_point
|
|
47
|
+
@declared_states = declared_states
|
|
48
|
+
@wait_state_names = wait_state_names
|
|
49
|
+
@external_events = external_events
|
|
50
|
+
@entry_actions = entry_actions
|
|
51
|
+
@action_timeouts = action_timeouts
|
|
52
|
+
@auto_transitions = auto_transitions
|
|
53
|
+
@exit_actions = exit_actions
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Constructs and returns the anonymous phase-machine Class.
|
|
57
|
+
#
|
|
58
|
+
# @return [Class] an anonymous class with a +state_machine :phase+ definition
|
|
59
|
+
# @raise [ArgumentError] if state_machines raises during class construction
|
|
60
|
+
# @api private
|
|
61
|
+
def build
|
|
62
|
+
entry = @entry_point
|
|
63
|
+
all_states = (@declared_states + @wait_state_names + [:__end__]).uniq
|
|
64
|
+
auto_trans = @auto_transitions
|
|
65
|
+
ext_events = @external_events
|
|
66
|
+
entry_acts = @entry_actions
|
|
67
|
+
exit_acts = @exit_actions
|
|
68
|
+
act_timeouts = @action_timeouts
|
|
69
|
+
build_cb = method(:build_entry_callback)
|
|
70
|
+
|
|
71
|
+
Class.new do
|
|
72
|
+
# Holds the current WorkflowContext so guards and callbacks can read it.
|
|
73
|
+
attr_accessor :context
|
|
74
|
+
|
|
75
|
+
# Set to true by an entry action that returned an awaitable Task.
|
|
76
|
+
# When true, FSMSession skips the automatic advance_or_halt step and
|
|
77
|
+
# waits for the async worker thread to post a state_completed event back.
|
|
78
|
+
attr_accessor :async_pending
|
|
79
|
+
|
|
80
|
+
state_machine :phase, initial: entry do
|
|
81
|
+
all_states.each { |s| state s }
|
|
82
|
+
|
|
83
|
+
# Auto-fire transitions: all auto transitions unified under :state_completed.
|
|
84
|
+
# Includes unguarded (unconditional) and guarded (conditional) transitions.
|
|
85
|
+
# Declaration order is preserved; guards are evaluated before unguarded fallbacks.
|
|
86
|
+
event :state_completed do
|
|
87
|
+
auto_trans.each do |t|
|
|
88
|
+
if t[:guard]
|
|
89
|
+
guard_proc = t[:guard]
|
|
90
|
+
transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
|
|
91
|
+
else
|
|
92
|
+
transition t[:from] => t[:to]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# External events: human-in-the-loop triggers from wait states.
|
|
98
|
+
ext_events.each do |ev_name, transitions|
|
|
99
|
+
event ev_name do
|
|
100
|
+
transitions.each do |t|
|
|
101
|
+
if t[:guard]
|
|
102
|
+
guard_proc = t[:guard]
|
|
103
|
+
transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
|
|
104
|
+
else
|
|
105
|
+
transition t[:from] => t[:to]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Entry callbacks: fire after_transition into each state.
|
|
112
|
+
# Each callable is registered as a separate callback; state_machines
|
|
113
|
+
# accumulates them and fires in declaration order.
|
|
114
|
+
# If the callable returns a WorkflowContext (e.g. via s.merge(...)),
|
|
115
|
+
# the returned context replaces the current one on the tracker.
|
|
116
|
+
entry_acts.each do |state_name, callables|
|
|
117
|
+
callables.each do |callable|
|
|
118
|
+
cb = build_cb.call(callable, state_name, act_timeouts[state_name])
|
|
119
|
+
after_transition to: state_name, &cb
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Exit callbacks: fire before_transition out of each state.
|
|
124
|
+
# Each callable is registered as a separate callback; state_machines
|
|
125
|
+
# accumulates them and fires in declaration order.
|
|
126
|
+
exit_acts.each do |state_name, callables|
|
|
127
|
+
callables.each do |callable|
|
|
128
|
+
before_transition from: state_name do |machine|
|
|
129
|
+
callable.call(machine.context)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
rescue => e
|
|
136
|
+
raise ArgumentError, "Failed to build phase machine: #{e.message}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
# Returns a proc suitable for use as an +after_transition+ callback.
|
|
142
|
+
#
|
|
143
|
+
# The returned proc accepts a single argument (the phase machine instance),
|
|
144
|
+
# invokes the entry action callable with the current context, then delegates
|
|
145
|
+
# the result to {#handle_entry_action_result}. Capturing this in the
|
|
146
|
+
# builder's scope lets the anonymous +Class.new+ block stay slim.
|
|
147
|
+
#
|
|
148
|
+
# @param callable [#call] the entry action
|
|
149
|
+
# @param state_name [Symbol] name of the target state (for error messages)
|
|
150
|
+
# @param timeout_secs [Numeric, nil] seconds before ActionTimeoutError
|
|
151
|
+
# @return [Proc]
|
|
152
|
+
# @api private
|
|
153
|
+
def build_entry_callback(callable, state_name, timeout_secs)
|
|
154
|
+
handle = method(:handle_entry_action_result)
|
|
155
|
+
->(machine) {
|
|
156
|
+
result = callable.call(machine.context)
|
|
157
|
+
handle.call(machine, result, state_name, timeout_secs)
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Dispatches the return value of an entry action callable.
|
|
162
|
+
#
|
|
163
|
+
# - +Phronomy::Task+ → async or blocking task handling
|
|
164
|
+
# - +Phronomy::WorkflowContext+ → replaces the machine's context directly
|
|
165
|
+
# - anything else → ignored
|
|
166
|
+
#
|
|
167
|
+
# @param machine [Object] phase machine instance
|
|
168
|
+
# @param result [Object] return value of the entry callable
|
|
169
|
+
# @param state_name [Symbol] name of the entered state
|
|
170
|
+
# @param timeout_secs [Numeric, nil] optional timeout in seconds
|
|
171
|
+
# @api private
|
|
172
|
+
def handle_entry_action_result(machine, result, state_name, timeout_secs)
|
|
173
|
+
if result.is_a?(Phronomy::Task)
|
|
174
|
+
if Phronomy.configuration.event_loop
|
|
175
|
+
dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
|
|
176
|
+
else
|
|
177
|
+
await_task_blocking(machine, result, state_name, timeout_secs)
|
|
178
|
+
end
|
|
179
|
+
elsif result.is_a?(Phronomy::WorkflowContext)
|
|
180
|
+
machine.context = result
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Handles a +Phronomy::Task+ return value in EventLoop mode.
|
|
185
|
+
#
|
|
186
|
+
# Marks the machine as async-pending and spawns a cooperative background
|
|
187
|
+
# task that awaits the result, then posts the appropriate event back to
|
|
188
|
+
# the EventLoop. +FSMSession+ will skip the automatic +advance_or_halt+
|
|
189
|
+
# step while +async_pending+ is true.
|
|
190
|
+
#
|
|
191
|
+
# @param machine [Object] phase machine instance
|
|
192
|
+
# @param result [Phronomy::Task]
|
|
193
|
+
# @param state_name [Symbol]
|
|
194
|
+
# @param timeout_secs [Numeric, nil]
|
|
195
|
+
# @api private
|
|
196
|
+
def dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
|
|
197
|
+
machine.async_pending = true
|
|
198
|
+
thread_id = machine.context.thread_id
|
|
199
|
+
Phronomy::Runtime.instance.spawn(name: "wf-await-#{thread_id}") do
|
|
200
|
+
enforce_timeout!(result, state_name, timeout_secs)
|
|
201
|
+
task_result = result.await
|
|
202
|
+
ev = if task_result.is_a?(Phronomy::WorkflowContext)
|
|
203
|
+
Phronomy::Event.new(type: :action_completed, target_id: thread_id, payload: task_result)
|
|
204
|
+
else
|
|
205
|
+
Phronomy::Event.new(type: :state_completed, target_id: thread_id, payload: nil)
|
|
206
|
+
end
|
|
207
|
+
Phronomy::EventLoop.instance.post(ev)
|
|
208
|
+
rescue => e
|
|
209
|
+
Phronomy::EventLoop.instance.post(
|
|
210
|
+
Phronomy::Event.new(type: :error, target_id: thread_id, payload: e)
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Handles a +Phronomy::Task+ return value in non-EventLoop mode.
|
|
216
|
+
#
|
|
217
|
+
# Blocks the current execution context until the task completes or the
|
|
218
|
+
# optional timeout elapses.
|
|
219
|
+
#
|
|
220
|
+
# @param machine [Object] phase machine instance
|
|
221
|
+
# @param result [Phronomy::Task]
|
|
222
|
+
# @param state_name [Symbol]
|
|
223
|
+
# @param timeout_secs [Numeric, nil]
|
|
224
|
+
# @api private
|
|
225
|
+
def await_task_blocking(machine, result, state_name, timeout_secs)
|
|
226
|
+
enforce_timeout!(result, state_name, timeout_secs)
|
|
227
|
+
task_result = result.await
|
|
228
|
+
machine.context = task_result if task_result.is_a?(Phronomy::WorkflowContext)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Raises +ActionTimeoutError+ if the task does not complete within
|
|
232
|
+
# +timeout_secs+. No-op when +timeout_secs+ is +nil+.
|
|
233
|
+
#
|
|
234
|
+
# @param result [Phronomy::Task]
|
|
235
|
+
# @param state_name [Symbol]
|
|
236
|
+
# @param timeout_secs [Numeric, nil]
|
|
237
|
+
# @api private
|
|
238
|
+
def enforce_timeout!(result, state_name, timeout_secs)
|
|
239
|
+
return unless timeout_secs
|
|
240
|
+
return unless result.join(timeout_secs).nil?
|
|
241
|
+
|
|
242
|
+
result.cancel!
|
|
243
|
+
raise Phronomy::ActionTimeoutError,
|
|
244
|
+
"Action in state #{state_name.inspect} timed out after #{timeout_secs}s"
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -135,14 +135,14 @@ module Phronomy
|
|
|
135
135
|
# Run before_completion hooks before each LLM call in the ReAct loop.
|
|
136
136
|
run_before_completion_hooks!(chat, config)
|
|
137
137
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
138
|
+
# Route the LLM call through the configured LLMAdapter so that the
|
|
139
|
+
# blocking HTTP request runs inside BlockingAdapterPool and the
|
|
140
|
+
# adapter can be swapped without changing agent code.
|
|
141
|
+
# Passing nil as message signals the adapter to call chat.complete
|
|
142
|
+
# (no new user turn) for continuation iterations.
|
|
143
|
+
adapter = Phronomy.configuration.llm_adapter
|
|
144
|
+
message = user_asked ? nil : extract_message(initial_input)
|
|
145
|
+
response = adapter.complete_async(chat, message, config: config).await
|
|
146
146
|
|
|
147
147
|
usage = Phronomy::TokenUsage.from_tokens(response&.tokens)
|
|
148
148
|
tool_calls = chat.messages.last&.tool_calls
|
|
@@ -181,13 +181,18 @@ module Phronomy
|
|
|
181
181
|
# Run before_completion hooks before each LLM call in the streaming loop.
|
|
182
182
|
run_before_completion_hooks!(chat, config)
|
|
183
183
|
|
|
184
|
+
# Route the streaming LLM call through the configured LLMAdapter so that
|
|
185
|
+
# the blocking HTTP request runs inside BlockingAdapterPool.
|
|
186
|
+
# Passing nil as message signals the adapter to call chat.complete
|
|
187
|
+
# (no new user turn) for continuation iterations.
|
|
188
|
+
# Streaming chunks and tool event callbacks are delivered directly via the
|
|
189
|
+
# block on the pool worker thread; pending.await yields cooperatively until
|
|
190
|
+
# streaming is complete.
|
|
191
|
+
adapter = Phronomy.configuration.llm_adapter
|
|
192
|
+
message = user_asked ? nil : extract_message(initial_input)
|
|
184
193
|
streaming_block = proc { |chunk| block.call(StreamEvent.new(type: :token, payload: {content: chunk.content})) }
|
|
185
|
-
|
|
186
|
-
response =
|
|
187
|
-
chat.complete(&streaming_block)
|
|
188
|
-
else
|
|
189
|
-
chat.ask(extract_message(initial_input), &streaming_block)
|
|
190
|
-
end
|
|
194
|
+
pending = adapter.stream_async(chat, message, config: config, &streaming_block)
|
|
195
|
+
response = pending.await
|
|
191
196
|
|
|
192
197
|
usage = Phronomy::TokenUsage.from_tokens(response&.tokens)
|
|
193
198
|
tool_calls = chat.messages.last&.tool_calls
|
|
@@ -74,7 +74,7 @@ module Phronomy
|
|
|
74
74
|
def build_handoffs(routes)
|
|
75
75
|
routes.each do |source_agent, target_agents|
|
|
76
76
|
Array(target_agents).each do |target_agent|
|
|
77
|
-
handoff = Handoff.new(target_agent: target_agent)
|
|
77
|
+
handoff = Phronomy::MultiAgent::Handoff.new(target_agent: target_agent)
|
|
78
78
|
@sentinel_map[handoff.sentinel] = target_agent
|
|
79
79
|
source_agent._add_handoff_tool(handoff.to_tool_class)
|
|
80
80
|
end
|
|
@@ -86,7 +86,7 @@ module Phronomy
|
|
|
86
86
|
next unless msg.role.to_sym == :tool
|
|
87
87
|
|
|
88
88
|
content = msg.content.to_s
|
|
89
|
-
next unless content.start_with?(Handoff::SENTINEL_PREFIX)
|
|
89
|
+
next unless content.start_with?(Phronomy::MultiAgent::Handoff::SENTINEL_PREFIX)
|
|
90
90
|
|
|
91
91
|
return @sentinel_map[content]
|
|
92
92
|
end
|