phronomy 0.22.0 → 0.23.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/.mutant.yml +3 -4
- data/CHANGELOG.md +200 -10
- data/CONTRIBUTING.md +81 -9
- data/README.md +15 -6
- data/VERIFY.sh +587 -0
- data/benchmark/bench_agent_invoke.rb +2 -2
- data/benchmark/bench_context_assembler.rb +39 -68
- data/benchmark/bench_regression.rb +2 -2
- data/docs/architecture/agent-context.md +174 -0
- data/docs/architecture/before-llm-input.md +78 -0
- data/docs/architecture/context-management.md +232 -0
- data/docs/architecture/knowledge-and-rag.md +130 -0
- data/docs/architecture/multi-agent-handoff.md +152 -0
- data/docs/architecture/persistence.md +175 -0
- data/docs/architecture/removed/agent-context.md +72 -0
- data/docs/architecture/security-boundaries.md +173 -0
- data/docs/architecture/tracing.md +194 -0
- data/docs/architecture.md +82 -0
- data/docs/archive/design/archived/04_api_design.md +507 -0
- data/docs/archive/design/archived/09_guardrails.md +186 -0
- data/docs/archive/design/archived/17_rails_integration.md +175 -0
- data/docs/archive/design/historical/00_design_philosophy.md +122 -0
- data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
- data/docs/archive/design/historical/06_design_decisions.md +143 -0
- data/docs/changelog/0.14-and-earlier.md +1 -1
- data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
- data/docs/decisions/002-workflow-context-immutability.md +26 -1
- data/docs/decisions/006-no-built-in-guardrails.md +2 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
- data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
- data/docs/decisions/016-semantic-multi-agent-handoff.md +112 -0
- data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
- data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
- data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
- data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
- data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
- data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
- data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
- data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
- data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
- data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
- data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
- data/docs/decisions/README.md +172 -0
- data/docs/features.md +31 -11
- data/docs/getting-started.md +77 -45
- data/docs/migrations/0.19.md +14 -7
- data/docs/migrations/0.22.md +390 -0
- data/docs/persistence-backends.md +88 -38
- data/docs/runtime-and-concurrency.md +227 -33
- data/examples/README.md +13 -0
- data/lib/phronomy/agent/agent_execution.rb +19 -15
- data/lib/phronomy/agent/agent_invocation.rb +288 -93
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
- data/lib/phronomy/agent/agent_root.rb +3 -3
- data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
- data/lib/phronomy/agent/async_event_api.rb +145 -72
- data/lib/phronomy/agent/base.rb +388 -181
- data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
- data/lib/phronomy/agent/context_assembler.rb +437 -178
- data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
- data/lib/phronomy/agent/context_plan.rb +18 -13
- data/lib/phronomy/agent/context_plan_validator.rb +246 -88
- data/lib/phronomy/agent/context_policies/default.rb +123 -34
- data/lib/phronomy/agent/context_policy.rb +109 -3
- data/lib/phronomy/agent/context_policy_input.rb +244 -0
- data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
- data/lib/phronomy/agent/execution_coordinator.rb +1975 -587
- data/lib/phronomy/agent/journal_record.rb +17 -4
- data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
- data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
- data/lib/phronomy/agent/llm_operation_result.rb +12 -7
- data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
- data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
- data/lib/phronomy/agent/recovery_coordinator/continuation.rb +271 -0
- data/lib/phronomy/agent/recovery_coordinator/installation.rb +427 -0
- data/lib/phronomy/agent/recovery_coordinator/resolution.rb +635 -0
- data/lib/phronomy/agent/recovery_coordinator.rb +211 -0
- data/lib/phronomy/agent/recovery_support.rb +512 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +22 -13
- data/lib/phronomy/agent/selection/candidate.rb +53 -0
- data/lib/phronomy/agent/selection/constraint.rb +49 -0
- data/lib/phronomy/agent/shared_state.rb +38 -1
- data/lib/phronomy/agent/tool_approval_request.rb +33 -5
- data/lib/phronomy/agent/tool_definition_set.rb +49 -3
- data/lib/phronomy/agent/tool_invocation.rb +336 -102
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
- data/lib/phronomy/agent.rb +20 -2
- data/lib/phronomy/agent_already_exists_error.rb +5 -0
- data/lib/phronomy/agent_purged_error.rb +5 -0
- data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
- data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
- data/lib/phronomy/engine/event_loop.rb +622 -63
- data/lib/phronomy/engine/fsm_session.rb +194 -21
- data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
- data/lib/phronomy/engine/runtime.rb +77 -20
- data/lib/phronomy/generator_verifier.rb +12 -14
- data/lib/phronomy/invocation_context.rb +9 -29
- data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
- data/lib/phronomy/multi_agent/coordination_state.rb +18 -0
- data/lib/phronomy/multi_agent/coordinator.rb +154 -0
- data/lib/phronomy/multi_agent/execution_coordinator.rb +116 -0
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
- data/lib/phronomy/multi_agent/handoff.rb +24 -45
- data/lib/phronomy/multi_agent/handoff_capability_factory.rb +87 -0
- data/lib/phronomy/multi_agent/handoff_context.rb +95 -0
- data/lib/phronomy/multi_agent/handoff_policy.rb +137 -0
- data/lib/phronomy/multi_agent/handoff_projection.rb +191 -0
- data/lib/phronomy/multi_agent/handoff_request.rb +45 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +12 -15
- data/lib/phronomy/multi_agent/runner.rb +98 -0
- data/lib/phronomy/persistence/durable_codec.rb +646 -0
- data/lib/phronomy/persistence/durable_record.rb +117 -0
- data/lib/phronomy/persistence/in_memory.rb +210 -134
- data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
- data/lib/phronomy/persistence/repository_facades.rb +316 -0
- data/lib/phronomy/persistence.rb +81 -41
- data/lib/phronomy/recovery.rb +186 -0
- data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -1
- data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
- data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
- data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
- data/lib/phronomy/tracing/automatic.rb +176 -0
- data/lib/phronomy/tracing/base.rb +11 -2
- data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +3 -6
- data/lib/phronomy/workflow_context.rb +14 -5
- data/lib/phronomy/workflow_recovery.rb +123 -0
- data/lib/phronomy/workflow_runner.rb +468 -256
- data/lib/phronomy.rb +6 -0
- data/scripts/api_snapshot.rb +12 -0
- data/sig/phronomy/agent.rbs +209 -7
- data/sig/phronomy/multi_agent.rbs +39 -0
- data/sig/phronomy/persistence.rbs +62 -4
- data/sig/phronomy/runtime.rbs +1 -4
- data/sig/phronomy/workflow.rbs +2 -2
- data/sig/phronomy.rbs +10 -0
- metadata +65 -17
- data/examples/workflows/agent_event_mapping.rb +0 -101
- data/examples/workflows/generic_task_event_mapping.rb +0 -66
- data/lib/phronomy/agent/activation_registry.rb +0 -28
- data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
- data/lib/phronomy/agent/context_candidate.rb +0 -47
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
- data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
- data/lib/phronomy/agent/context_policy_registry.rb +0 -46
- data/lib/phronomy/agent/context_request.rb +0 -35
- data/lib/phronomy/agent/context_selection_unit.rb +0 -38
- data/lib/phronomy/agent/derived_content_spec.rb +0 -34
- data/lib/phronomy/agent/runner.rb +0 -97
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module MultiAgent
|
|
5
|
+
# Handoff-aware specialization of the normal Agent execution coordinator.
|
|
6
|
+
# It changes only the durable terminal semantics of an invocation that
|
|
7
|
+
# produced a typed HandoffRequest. EventLoop ownership/apply remains entirely
|
|
8
|
+
# in Agent::ExecutionCoordinator.
|
|
9
|
+
class ExecutionCoordinator < Phronomy::Agent::ExecutionCoordinator
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def compute_terminal(operation)
|
|
13
|
+
view = operation.terminal_view
|
|
14
|
+
if view.callback_failure
|
|
15
|
+
return commit_failed_outcome(
|
|
16
|
+
operation,
|
|
17
|
+
view.callback_failure.to_stream_callback_error
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
return commit_failed_outcome(operation, view.source_error) if view.source_error
|
|
21
|
+
return commit_suspended(operation) if view.phase == :suspended
|
|
22
|
+
|
|
23
|
+
raise view.block_error if view.input_blocked || view.output_blocked
|
|
24
|
+
raise view.invocation_error if view.invocation_error
|
|
25
|
+
|
|
26
|
+
return commit_handed_off(operation) if view.handoff
|
|
27
|
+
|
|
28
|
+
commit_completed(operation)
|
|
29
|
+
rescue => caught
|
|
30
|
+
commit_failed_outcome(operation, caught)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def commit_handed_off(operation)
|
|
34
|
+
current = operation.execution
|
|
35
|
+
root = operation.root
|
|
36
|
+
runtime_snapshot = operation.runtime_snapshot
|
|
37
|
+
request = operation.terminal_view.handoff
|
|
38
|
+
handed_off = next_root = appended = nil
|
|
39
|
+
|
|
40
|
+
@agent.persistence.transaction do |tx|
|
|
41
|
+
encoded_records, call_records = encode_runtime_records(
|
|
42
|
+
current,
|
|
43
|
+
tx: tx,
|
|
44
|
+
snapshot: runtime_snapshot,
|
|
45
|
+
context_candidate: false,
|
|
46
|
+
agent_root: root
|
|
47
|
+
)
|
|
48
|
+
audit_ref = tx.contents.put_json(
|
|
49
|
+
"target_agent_id" => request.target_agent_id,
|
|
50
|
+
"responsibility" => request.responsibility,
|
|
51
|
+
"selection_intent" => request.selection_intent.to_h do |category, included|
|
|
52
|
+
[category.to_s, included]
|
|
53
|
+
end
|
|
54
|
+
)
|
|
55
|
+
audit_record = Phronomy::Agent::JournalRecord.new(
|
|
56
|
+
agent_id: @agent.agent_id,
|
|
57
|
+
execution_id: current.execution_id,
|
|
58
|
+
llm_call_id: request.llm_call_id,
|
|
59
|
+
kind: :execution_handed_off,
|
|
60
|
+
channel: :audit,
|
|
61
|
+
content_ref: audit_ref,
|
|
62
|
+
context_generation: root.transcript_generation,
|
|
63
|
+
context_candidate: false,
|
|
64
|
+
metadata: {
|
|
65
|
+
"target_agent_id" => request.target_agent_id,
|
|
66
|
+
"handoff_tool_call_id" => request.tool_call_id
|
|
67
|
+
}.compact
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
all_records = current.working_records + encoded_records + [audit_record]
|
|
71
|
+
appended = tx.journals.append(
|
|
72
|
+
root.agent_id,
|
|
73
|
+
expected_position: root.journal_position,
|
|
74
|
+
records: all_records
|
|
75
|
+
)
|
|
76
|
+
handed_off = current.with(
|
|
77
|
+
status: :handed_off,
|
|
78
|
+
phase: :handed_off,
|
|
79
|
+
working_records: [],
|
|
80
|
+
llm_calls: current.llm_calls + call_records,
|
|
81
|
+
approval_request: nil,
|
|
82
|
+
terminal_reason: "handed_off"
|
|
83
|
+
)
|
|
84
|
+
tx.executions.save(
|
|
85
|
+
current.execution_id,
|
|
86
|
+
expected_revision: current.execution_revision,
|
|
87
|
+
execution: handed_off
|
|
88
|
+
)
|
|
89
|
+
context_changed = appended.any?(&:context_candidate)
|
|
90
|
+
next_root = root.with(
|
|
91
|
+
agent_revision: root.agent_revision + 1,
|
|
92
|
+
context_revision: root.context_revision + (context_changed ? 1 : 0),
|
|
93
|
+
journal_position: root.journal_position + appended.length,
|
|
94
|
+
lifecycle_status: :idle
|
|
95
|
+
)
|
|
96
|
+
tx.agents.save(
|
|
97
|
+
root.agent_id,
|
|
98
|
+
expected_revision: root.agent_revision,
|
|
99
|
+
root: next_root
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
result = result_base(handed_off, next_root)
|
|
104
|
+
TerminalOutcome.new(
|
|
105
|
+
type: :handed_off,
|
|
106
|
+
execution: handed_off,
|
|
107
|
+
root: next_root,
|
|
108
|
+
appended_records: Array(appended).freeze,
|
|
109
|
+
result: result.freeze,
|
|
110
|
+
error: nil,
|
|
111
|
+
approval_request: nil
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "securerandom"
|
|
4
|
-
|
|
5
3
|
module Phronomy
|
|
6
4
|
module MultiAgent
|
|
7
5
|
# Mutable FSM context for one fan-out operation.
|
|
8
6
|
class FanOutInvocation
|
|
9
|
-
Child = Data.define(:index, :agent, :input, :config
|
|
7
|
+
Child = Data.define(:index, :agent, :input, :config)
|
|
10
8
|
|
|
11
|
-
attr_reader :
|
|
9
|
+
attr_reader :phase, :results
|
|
12
10
|
|
|
13
11
|
def initialize(children:, max_concurrency:, on_error:)
|
|
14
|
-
@id = SecureRandom.uuid.to_s
|
|
15
12
|
@phase = nil
|
|
16
13
|
@children = children
|
|
17
14
|
@max_concurrency = max_concurrency || children.length
|
|
@@ -19,15 +16,13 @@ module Phronomy
|
|
|
19
16
|
@pending = children.dup
|
|
20
17
|
@active = {}
|
|
21
18
|
@results = Array.new(children.length)
|
|
22
|
-
@child_errors = Array.new(children.length)
|
|
23
|
-
@fatal_error = nil
|
|
19
|
+
@child_errors = Array.new(children.length)
|
|
20
|
+
@fatal_error = nil
|
|
24
21
|
@cancelled = false
|
|
25
22
|
@timed_out = false
|
|
26
|
-
@session_id = nil
|
|
27
23
|
end
|
|
28
24
|
|
|
29
|
-
def set_graph_metadata(
|
|
30
|
-
@session_id = thread_id if thread_id
|
|
25
|
+
def set_graph_metadata(phase: nil)
|
|
31
26
|
@phase = phase
|
|
32
27
|
end
|
|
33
28
|
|
|
@@ -41,9 +36,6 @@ module Phronomy
|
|
|
41
36
|
if child_error
|
|
42
37
|
if @on_error == :raise
|
|
43
38
|
@child_errors[index] = child_error
|
|
44
|
-
# Signal cancellation so other children can stop early, but continue
|
|
45
|
-
# waiting for all active children to respond so we can return the
|
|
46
|
-
# first error in INPUT ORDER (not arrival order).
|
|
47
39
|
cancel_active_children! unless @child_errors.any?(&:itself)
|
|
48
40
|
end
|
|
49
41
|
else
|
|
@@ -69,27 +61,28 @@ module Phronomy
|
|
|
69
61
|
end
|
|
70
62
|
end
|
|
71
63
|
|
|
72
|
-
def start_available!(runtime)
|
|
64
|
+
def start_available!(runtime, event_sink:)
|
|
73
65
|
return self if @fatal_error
|
|
74
66
|
|
|
75
67
|
while @active.length < @max_concurrency && (child = @pending.shift)
|
|
76
68
|
child_config = build_child_config(child.config)
|
|
77
69
|
handle = child.agent.invoke_async(
|
|
78
70
|
child.input,
|
|
79
|
-
config: child_config
|
|
80
|
-
thread_id: child.thread_id
|
|
71
|
+
config: child_config
|
|
81
72
|
)
|
|
82
|
-
@active[child.index] = {
|
|
83
|
-
|
|
84
|
-
|
|
73
|
+
@active[child.index] = {
|
|
74
|
+
handle: handle,
|
|
75
|
+
token: child_config[:cancellation_token]
|
|
76
|
+
}
|
|
85
77
|
[child.index].each do |captured_index|
|
|
86
78
|
handle.on_complete do |result, error|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
79
|
+
event_sink.post(
|
|
80
|
+
:child_completed,
|
|
81
|
+
{
|
|
82
|
+
index: captured_index,
|
|
83
|
+
result: result,
|
|
84
|
+
error: error
|
|
85
|
+
}
|
|
93
86
|
)
|
|
94
87
|
end
|
|
95
88
|
end
|
|
@@ -98,13 +91,7 @@ module Phronomy
|
|
|
98
91
|
rescue => caught
|
|
99
92
|
@fatal_error ||= caught
|
|
100
93
|
cancel_active_children!
|
|
101
|
-
|
|
102
|
-
Phronomy::Event.new(
|
|
103
|
-
type: :driver_failed,
|
|
104
|
-
target_id: @id,
|
|
105
|
-
payload: {error: caught}
|
|
106
|
-
)
|
|
107
|
-
)
|
|
94
|
+
event_sink.post(:driver_failed, {error: caught})
|
|
108
95
|
self
|
|
109
96
|
end
|
|
110
97
|
|
|
@@ -114,7 +101,11 @@ module Phronomy
|
|
|
114
101
|
|
|
115
102
|
def failed?
|
|
116
103
|
return true if @fatal_error
|
|
117
|
-
|
|
104
|
+
|
|
105
|
+
@on_error == :raise &&
|
|
106
|
+
@pending.empty? &&
|
|
107
|
+
@active.empty? &&
|
|
108
|
+
@child_errors.any?(&:itself)
|
|
118
109
|
end
|
|
119
110
|
|
|
120
111
|
def error
|
|
@@ -16,14 +16,15 @@ module Phronomy
|
|
|
16
16
|
cancellation_token: nil,
|
|
17
17
|
runtime: Phronomy::Runtime.instance
|
|
18
18
|
)
|
|
19
|
-
result = Phronomy::Task.deferred(name: "fan-out
|
|
19
|
+
result = Phronomy::Task.deferred(name: "fan-out")
|
|
20
20
|
|
|
21
21
|
if cancellation_token&.cancelled?
|
|
22
22
|
result.fail(Phronomy::CancellationError.new("fan-out cancelled"))
|
|
23
23
|
return result
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
session = build(invocation: invocation, runtime: runtime)
|
|
27
|
+
source = Phronomy::Task.deferred(name: "fan-out-source:#{session.id}")
|
|
27
28
|
source.on_complete do |context, error|
|
|
28
29
|
if error
|
|
29
30
|
result.fail(error)
|
|
@@ -34,27 +35,18 @@ module Phronomy
|
|
|
34
35
|
end
|
|
35
36
|
end
|
|
36
37
|
|
|
37
|
-
session = build(invocation: invocation, runtime: runtime)
|
|
38
38
|
runtime.event_loop.register(session, completion: source)
|
|
39
|
-
|
|
40
39
|
if timeout
|
|
41
40
|
runtime.timer_queue.schedule(seconds: timeout) do
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
target_id: invocation.id,
|
|
46
|
-
payload: {message: "dispatch_parallel timed out after #{timeout}s"}
|
|
47
|
-
)
|
|
41
|
+
session.event_sink.post(
|
|
42
|
+
:timeout,
|
|
43
|
+
{message: "dispatch_parallel timed out after #{timeout}s"}
|
|
48
44
|
)
|
|
49
45
|
end
|
|
50
46
|
end
|
|
51
|
-
|
|
52
47
|
cancellation_token&.on_cancel do
|
|
53
|
-
|
|
54
|
-
Phronomy::Event.new(type: :cancel, target_id: invocation.id, payload: nil)
|
|
55
|
-
)
|
|
48
|
+
session.event_sink.post(:cancel, nil)
|
|
56
49
|
end
|
|
57
|
-
|
|
58
50
|
result
|
|
59
51
|
rescue => error
|
|
60
52
|
result ||= Phronomy::Task.deferred(name: "fan-out:registration")
|
|
@@ -63,7 +55,8 @@ module Phronomy
|
|
|
63
55
|
end
|
|
64
56
|
|
|
65
57
|
def self.build(invocation:, runtime:)
|
|
66
|
-
|
|
58
|
+
event_sink = Phronomy::FSMSession::EventSink.new(event_loop: runtime.event_loop)
|
|
59
|
+
phase_machine = build_phase_machine(runtime, event_sink)
|
|
67
60
|
external_events = {
|
|
68
61
|
child_completed: [{from: :running, to: :running, guard: nil}],
|
|
69
62
|
driver_failed: [{from: :running, to: :failed, guard: nil}],
|
|
@@ -72,8 +65,8 @@ module Phronomy
|
|
|
72
65
|
}
|
|
73
66
|
|
|
74
67
|
Phronomy::FSMSession.new(
|
|
75
|
-
id: invocation.id,
|
|
76
68
|
context: invocation,
|
|
69
|
+
event_sink: event_sink,
|
|
77
70
|
entry_point: :idle,
|
|
78
71
|
entry_actions: {},
|
|
79
72
|
auto_state_set: AUTO_STATE_SET,
|
|
@@ -87,7 +80,7 @@ module Phronomy
|
|
|
87
80
|
end
|
|
88
81
|
private_class_method :build
|
|
89
82
|
|
|
90
|
-
def self.build_phase_machine(runtime)
|
|
83
|
+
def self.build_phase_machine(runtime, event_sink)
|
|
91
84
|
Class.new do
|
|
92
85
|
attr_accessor :context, :current_event
|
|
93
86
|
|
|
@@ -114,7 +107,7 @@ module Phronomy
|
|
|
114
107
|
event(:cancel) { transition running: :cancelled }
|
|
115
108
|
|
|
116
109
|
after_transition to: :running do |machine|
|
|
117
|
-
machine.context.start_available!(runtime)
|
|
110
|
+
machine.context.start_available!(runtime, event_sink: event_sink)
|
|
118
111
|
end
|
|
119
112
|
end
|
|
120
113
|
end
|
|
@@ -4,60 +4,39 @@ require "securerandom"
|
|
|
4
4
|
|
|
5
5
|
module Phronomy
|
|
6
6
|
module MultiAgent
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# exposes to the LLM as a +transfer_to_<name>+ function.
|
|
10
|
-
# The tool's execute method returns a sentinel string that Runner uses to
|
|
11
|
-
# detect which target agent to route to next.
|
|
12
|
-
#
|
|
13
|
-
# @example
|
|
14
|
-
# billing = BillingAgent.new
|
|
15
|
-
# handoff = Phronomy::MultiAgent::Handoff.new(target_agent: billing)
|
|
16
|
-
# tool_class = handoff.to_tool_class
|
|
7
|
+
# Application-defined semantic edge for transferring active responsibility
|
|
8
|
+
# from one live Agent instance to another.
|
|
17
9
|
class Handoff
|
|
18
|
-
|
|
19
|
-
SENTINEL_PREFIX = "__PHRONOMY_HANDOFF__"
|
|
10
|
+
attr_reader :source_agent, :target_agent, :policy, :description
|
|
20
11
|
|
|
21
|
-
attr_reader :target_agent, :tool_name, :description
|
|
22
|
-
|
|
23
|
-
# @param target_agent [Phronomy::Agent::Base] the agent to hand off to
|
|
24
|
-
# @param description [String, nil] overrides the auto-generated tool description
|
|
25
|
-
# @api public
|
|
26
|
-
def initialize(target_agent:, description: nil)
|
|
27
|
-
@target_agent = target_agent
|
|
28
|
-
klass_name = target_agent.class.name&.split("::")&.last || "Agent"
|
|
29
|
-
# Use a UUID so that two handoffs targeting the same class remain distinct.
|
|
30
|
-
@uuid = SecureRandom.uuid
|
|
31
|
-
@tool_name = "transfer_to_#{snake_case(klass_name)}_#{@uuid.delete("-")[0, 8]}"
|
|
32
|
-
@description = description || "Transfer the conversation to #{klass_name}."
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# Builds an anonymous Phronomy::Agent::Context::Capability::Base subclass for this handoff.
|
|
36
|
-
# @return [Class<Phronomy::Agent::Context::Capability::Base>]
|
|
37
12
|
# @api public
|
|
38
|
-
def
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
13
|
+
def initialize(source_agent:, target_agent:, policy: HandoffPolicy.default, description: nil)
|
|
14
|
+
unless source_agent.is_a?(Phronomy::Agent::Base) &&
|
|
15
|
+
target_agent.is_a?(Phronomy::Agent::Base)
|
|
16
|
+
raise ArgumentError, "source_agent and target_agent must be Agent::Base instances"
|
|
17
|
+
end
|
|
18
|
+
if source_agent.equal?(target_agent)
|
|
19
|
+
raise ArgumentError, "Handoff source_agent and target_agent must be different instances"
|
|
20
|
+
end
|
|
21
|
+
unless policy.is_a?(HandoffPolicy)
|
|
22
|
+
raise ArgumentError, "policy must be a Phronomy::MultiAgent::HandoffPolicy"
|
|
47
23
|
end
|
|
48
|
-
end
|
|
49
24
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
25
|
+
@source_agent = source_agent
|
|
26
|
+
@target_agent = target_agent
|
|
27
|
+
@policy = policy
|
|
28
|
+
@description = (description || default_description).to_s.freeze
|
|
29
|
+
@transport_key = SecureRandom.hex(8).freeze
|
|
30
|
+
freeze
|
|
55
31
|
end
|
|
56
32
|
|
|
57
33
|
private
|
|
58
34
|
|
|
59
|
-
|
|
60
|
-
|
|
35
|
+
attr_reader :transport_key
|
|
36
|
+
|
|
37
|
+
def default_description
|
|
38
|
+
target_name = target_agent.class.name || "target Agent"
|
|
39
|
+
"Transfer active responsibility to #{target_name}."
|
|
61
40
|
end
|
|
62
41
|
end
|
|
63
42
|
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "weakref"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module MultiAgent
|
|
7
|
+
class HandoffCapabilityFactory
|
|
8
|
+
REGISTRY_MUTEX = Mutex.new
|
|
9
|
+
private_constant :REGISTRY_MUTEX
|
|
10
|
+
|
|
11
|
+
Binding = Data.define(:handoff, :tool_class, :tool_name) do
|
|
12
|
+
def initialize(handoff:, tool_class:, tool_name:)
|
|
13
|
+
super(handoff: handoff, tool_class: tool_class, tool_name: tool_name.to_s.freeze)
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.build(handoff)
|
|
19
|
+
key = handoff.send(:transport_key)
|
|
20
|
+
source_name = class_slug(handoff.source_agent.class)
|
|
21
|
+
target_name = class_slug(handoff.target_agent.class)
|
|
22
|
+
tool_name = "phronomy_handoff_#{source_name}_to_#{target_name}_#{key}"
|
|
23
|
+
description = handoff.description
|
|
24
|
+
policy = handoff.policy
|
|
25
|
+
|
|
26
|
+
klass = Class.new(Phronomy::Agent::Context::Capability::Base) do
|
|
27
|
+
self.tool_name(tool_name)
|
|
28
|
+
self.description(description)
|
|
29
|
+
execution_mode :cooperative
|
|
30
|
+
param :responsibility,
|
|
31
|
+
type: :string,
|
|
32
|
+
required: true,
|
|
33
|
+
desc: "The concrete responsibility the target Agent must continue."
|
|
34
|
+
|
|
35
|
+
policy.selectable_categories.each do |category|
|
|
36
|
+
param :"include_#{category}",
|
|
37
|
+
type: :boolean,
|
|
38
|
+
required: false,
|
|
39
|
+
desc: "Whether to transfer selectable #{category} Context."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
define_method(:execute) do |**_args|
|
|
43
|
+
raise Phronomy::HandoffError,
|
|
44
|
+
"Handoff capabilities are control-plane operations and must not execute as Tools"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
binding = Binding.new(handoff: handoff, tool_class: klass, tool_name: tool_name)
|
|
49
|
+
registry_mutex.synchronize { registry[tool_name] = WeakRef.new(binding) }
|
|
50
|
+
binding
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.lookup(tool_name)
|
|
54
|
+
key = tool_name.to_s
|
|
55
|
+
registry_mutex.synchronize do
|
|
56
|
+
reference = registry[key]
|
|
57
|
+
return nil unless reference
|
|
58
|
+
|
|
59
|
+
begin
|
|
60
|
+
reference.__getobj__
|
|
61
|
+
rescue WeakRef::RefError
|
|
62
|
+
registry.delete(key)
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.registry
|
|
69
|
+
@registry ||= {}
|
|
70
|
+
end
|
|
71
|
+
private_class_method :registry
|
|
72
|
+
|
|
73
|
+
def self.registry_mutex
|
|
74
|
+
REGISTRY_MUTEX
|
|
75
|
+
end
|
|
76
|
+
private_class_method :registry_mutex
|
|
77
|
+
|
|
78
|
+
def self.class_slug(klass)
|
|
79
|
+
raw = (klass.name || "agent").gsub("::", "_")
|
|
80
|
+
raw.gsub(/([a-z\d])([A-Z])/, '\\1_\\2')
|
|
81
|
+
.gsub(/[^a-zA-Z0-9_]/, "_")
|
|
82
|
+
.downcase
|
|
83
|
+
end
|
|
84
|
+
private_class_method :class_slug
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module MultiAgent
|
|
5
|
+
class HandoffContext
|
|
6
|
+
Provenance = Data.define(
|
|
7
|
+
:origin_agent_id,
|
|
8
|
+
:origin_record_id,
|
|
9
|
+
:origin_execution_id,
|
|
10
|
+
:origin_llm_call_id,
|
|
11
|
+
:origin_tool_call_id,
|
|
12
|
+
:transfer_path
|
|
13
|
+
) do
|
|
14
|
+
def initialize(**values)
|
|
15
|
+
super(**values.merge(
|
|
16
|
+
origin_agent_id: values[:origin_agent_id]&.to_s&.freeze,
|
|
17
|
+
origin_record_id: values[:origin_record_id]&.to_s&.freeze,
|
|
18
|
+
origin_execution_id: values[:origin_execution_id]&.to_s&.freeze,
|
|
19
|
+
origin_llm_call_id: values[:origin_llm_call_id]&.to_s&.freeze,
|
|
20
|
+
origin_tool_call_id: values[:origin_tool_call_id]&.to_s&.freeze,
|
|
21
|
+
transfer_path: Array(values[:transfer_path]).map(&:to_s).freeze
|
|
22
|
+
))
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def forwarded_to(agent_id)
|
|
27
|
+
self.class.new(
|
|
28
|
+
origin_agent_id: origin_agent_id,
|
|
29
|
+
origin_record_id: origin_record_id,
|
|
30
|
+
origin_execution_id: origin_execution_id,
|
|
31
|
+
origin_llm_call_id: origin_llm_call_id,
|
|
32
|
+
origin_tool_call_id: origin_tool_call_id,
|
|
33
|
+
transfer_path: transfer_path + [agent_id.to_s]
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_h
|
|
38
|
+
{
|
|
39
|
+
"origin_agent_id" => origin_agent_id,
|
|
40
|
+
"origin_record_id" => origin_record_id,
|
|
41
|
+
"origin_execution_id" => origin_execution_id,
|
|
42
|
+
"origin_llm_call_id" => origin_llm_call_id,
|
|
43
|
+
"origin_tool_call_id" => origin_tool_call_id,
|
|
44
|
+
"transfer_path" => transfer_path
|
|
45
|
+
}.compact.freeze
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
Item = Data.define(
|
|
50
|
+
:candidate_category,
|
|
51
|
+
:policy_category,
|
|
52
|
+
:role,
|
|
53
|
+
:content,
|
|
54
|
+
:content_format,
|
|
55
|
+
:tool_call_id,
|
|
56
|
+
:provenance,
|
|
57
|
+
:metadata
|
|
58
|
+
) do
|
|
59
|
+
def initialize(**values)
|
|
60
|
+
provenance = values.fetch(:provenance)
|
|
61
|
+
unless provenance.is_a?(Provenance)
|
|
62
|
+
raise ArgumentError, "HandoffContext::Item provenance must be Provenance"
|
|
63
|
+
end
|
|
64
|
+
format = (values[:content_format] || :text).to_sym
|
|
65
|
+
unless %i[text json].include?(format)
|
|
66
|
+
raise ArgumentError, "unsupported Handoff Context content format: #{format.inspect}"
|
|
67
|
+
end
|
|
68
|
+
super(**values.merge(
|
|
69
|
+
candidate_category: values.fetch(:candidate_category).to_sym,
|
|
70
|
+
policy_category: values.fetch(:policy_category).to_sym,
|
|
71
|
+
role: values[:role]&.to_sym,
|
|
72
|
+
content: Phronomy::Agent::Immutable.copy(values.fetch(:content)),
|
|
73
|
+
content_format: format,
|
|
74
|
+
tool_call_id: values[:tool_call_id]&.to_s&.freeze,
|
|
75
|
+
provenance: provenance,
|
|
76
|
+
metadata: Phronomy::Agent::Immutable.copy(values[:metadata] || {})
|
|
77
|
+
))
|
|
78
|
+
freeze
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
attr_reader :responsibility, :items
|
|
83
|
+
|
|
84
|
+
def initialize(responsibility:, items: [])
|
|
85
|
+
@responsibility = responsibility.to_s.freeze
|
|
86
|
+
@items = Array(items).freeze
|
|
87
|
+
raise ArgumentError, "Handoff Context responsibility must not be empty" if @responsibility.strip.empty?
|
|
88
|
+
unless @items.all? { |item| item.is_a?(Item) }
|
|
89
|
+
raise ArgumentError, "Handoff Context items must be HandoffContext::Item values"
|
|
90
|
+
end
|
|
91
|
+
freeze
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|