phronomy 0.22.0 → 0.24.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 +215 -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 +147 -0
- data/docs/architecture/persistence.md +186 -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 +89 -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 +114 -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/028-preparing-recovery-replay-contract.md +106 -0
- data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +220 -0
- data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +235 -0
- data/docs/decisions/031-durable-multi-agent-coordination.md +301 -0
- data/docs/decisions/README.md +176 -0
- data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +33 -0
- data/docs/design/durable-semantic-coordination/CONTINUATION_DECISION_REFACTOR.md +191 -0
- data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +862 -0
- data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +106 -0
- data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +179 -0
- data/docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md +302 -0
- data/docs/features.md +65 -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/migrations/durable-semantic-coordination-v2.md +65 -0
- data/docs/persistence-backends.md +129 -40
- data/docs/runtime-and-concurrency.md +227 -33
- data/examples/README.md +13 -0
- data/lib/phronomy/agent/agent_execution.rb +21 -17
- 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 +162 -72
- data/lib/phronomy/agent/base.rb +416 -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/exact_execution.rb +153 -0
- data/lib/phronomy/agent/execution_cancellation.rb +25 -0
- data/lib/phronomy/agent/execution_coordinator.rb +2417 -572
- data/lib/phronomy/agent/handoff.rb +43 -0
- data/lib/phronomy/agent/handoff_capability_factory.rb +45 -0
- data/lib/phronomy/agent/handoff_context.rb +120 -0
- data/lib/phronomy/agent/handoff_execution_coordinator.rb +143 -0
- data/lib/phronomy/agent/handoff_policy.rb +143 -0
- data/lib/phronomy/agent/handoff_projection.rb +207 -0
- data/lib/phronomy/agent/handoff_request.rb +45 -0
- data/lib/phronomy/agent/handoff_runner.rb +178 -0
- data/lib/phronomy/agent/handoff_state.rb +43 -0
- 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 +174 -0
- data/lib/phronomy/agent/recovery_coordinator/installation.rb +381 -0
- data/lib/phronomy/agent/recovery_coordinator/resolution.rb +511 -0
- data/lib/phronomy/agent/recovery_coordinator.rb +217 -0
- data/lib/phronomy/agent/recovery_support.rb +504 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +16 -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 +339 -103
- 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/team_ownership_registry.rb +77 -0
- data/lib/phronomy/engine/runtime.rb +92 -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/durable_subagent_coordinator.rb +134 -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/orchestrator.rb +71 -26
- data/lib/phronomy/multi_agent/team_coordinator.rb +473 -125
- data/lib/phronomy/multi_agent/team_execution.rb +44 -0
- data/lib/phronomy/multi_agent/team_root.rb +41 -0
- data/lib/phronomy/persistence/durable_codec.rb +706 -0
- data/lib/phronomy/persistence/durable_record.rb +117 -0
- data/lib/phronomy/persistence/in_memory.rb +471 -133
- data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
- data/lib/phronomy/persistence/repository_facades.rb +535 -0
- data/lib/phronomy/persistence.rb +174 -40
- 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 +2 -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/testing/persistence_contract/coordination_repositories.rb +137 -0
- data/lib/phronomy/testing/persistence_contract.rb +5 -0
- data/lib/phronomy/tools/agent.rb +1 -1
- 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/handoff.rbs +41 -0
- data/sig/phronomy/multi_agent.rbs +35 -0
- data/sig/phronomy/persistence.rbs +123 -4
- data/sig/phronomy/runtime.rbs +1 -4
- data/sig/phronomy/workflow.rbs +2 -2
- data/sig/phronomy.rbs +10 -0
- metadata +84 -18
- 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
- data/lib/phronomy/multi_agent/handoff.rb +0 -64
|
@@ -2,19 +2,98 @@
|
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
module Agent
|
|
5
|
+
# ContentStore-backed durable codec boundary for one Provider input manifest.
|
|
6
|
+
# It is intentionally separate from Persistence::DurableRecord.
|
|
5
7
|
class LLMInputManifest
|
|
6
|
-
VERSION = 1
|
|
8
|
+
VERSION = "0.1"
|
|
7
9
|
CALL_MODES = %i[ask complete].freeze
|
|
10
|
+
SEGMENT_DELIVERIES = %w[ask_argument chat_message].freeze
|
|
8
11
|
|
|
9
12
|
Segment = Data.define(
|
|
10
13
|
:position, :category, :role, :content_ref, :delivery,
|
|
11
14
|
:tool_call_id, :metadata
|
|
12
15
|
) do
|
|
13
16
|
def initialize(**values)
|
|
17
|
+
delivery = values[:delivery]
|
|
18
|
+
if delivery.nil? || !SEGMENT_DELIVERIES.include?(delivery.to_s)
|
|
19
|
+
raise ArgumentError, "unknown Segment delivery: #{delivery.inspect}"
|
|
20
|
+
end
|
|
14
21
|
super(**values.merge(metadata: Immutable.copy(values[:metadata] || {})))
|
|
15
22
|
freeze
|
|
16
23
|
end
|
|
17
24
|
|
|
25
|
+
def self.from_h(hash)
|
|
26
|
+
source = LLMInputManifest.send(
|
|
27
|
+
:strict_source!,
|
|
28
|
+
hash,
|
|
29
|
+
required: %w[position category content_ref delivery metadata],
|
|
30
|
+
optional: %w[role tool_call_id],
|
|
31
|
+
label: "LLMInputManifest segment"
|
|
32
|
+
)
|
|
33
|
+
label = "LLMInputManifest segment"
|
|
34
|
+
position = LLMInputManifest.send(
|
|
35
|
+
:require_nonnegative_integer_field!,
|
|
36
|
+
source,
|
|
37
|
+
"position",
|
|
38
|
+
label: label
|
|
39
|
+
)
|
|
40
|
+
category = LLMInputManifest.send(
|
|
41
|
+
:require_nonempty_string_field!,
|
|
42
|
+
source,
|
|
43
|
+
"category",
|
|
44
|
+
label: label
|
|
45
|
+
)
|
|
46
|
+
role = LLMInputManifest.send(
|
|
47
|
+
:require_optional_string_field!,
|
|
48
|
+
source,
|
|
49
|
+
"role",
|
|
50
|
+
label: label
|
|
51
|
+
)
|
|
52
|
+
content_ref = LLMInputManifest.send(
|
|
53
|
+
:require_nonempty_string_field!,
|
|
54
|
+
source,
|
|
55
|
+
"content_ref",
|
|
56
|
+
label: label
|
|
57
|
+
)
|
|
58
|
+
delivery = LLMInputManifest.send(
|
|
59
|
+
:require_nonempty_string_field!,
|
|
60
|
+
source,
|
|
61
|
+
"delivery",
|
|
62
|
+
label: label
|
|
63
|
+
)
|
|
64
|
+
unless SEGMENT_DELIVERIES.include?(delivery)
|
|
65
|
+
raise Phronomy::Persistence::SerializationError,
|
|
66
|
+
"LLMInputManifest segment delivery must be one of #{SEGMENT_DELIVERIES.inspect}, got #{delivery.inspect}"
|
|
67
|
+
end
|
|
68
|
+
tool_call_id = LLMInputManifest.send(
|
|
69
|
+
:require_optional_string_field!,
|
|
70
|
+
source,
|
|
71
|
+
"tool_call_id",
|
|
72
|
+
label: label
|
|
73
|
+
)
|
|
74
|
+
metadata = LLMInputManifest.send(
|
|
75
|
+
:require_hash_field!,
|
|
76
|
+
source,
|
|
77
|
+
"metadata",
|
|
78
|
+
label: label
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
new(
|
|
82
|
+
position: position,
|
|
83
|
+
category: category.to_sym,
|
|
84
|
+
role: role&.to_sym,
|
|
85
|
+
content_ref: content_ref,
|
|
86
|
+
delivery: delivery.to_sym,
|
|
87
|
+
tool_call_id: tool_call_id,
|
|
88
|
+
metadata: metadata
|
|
89
|
+
)
|
|
90
|
+
rescue Phronomy::Persistence::SerializationError
|
|
91
|
+
raise
|
|
92
|
+
rescue => error
|
|
93
|
+
raise Phronomy::Persistence::SerializationError,
|
|
94
|
+
"invalid LLMInputManifest segment: #{error.class}: #{error.message}"
|
|
95
|
+
end
|
|
96
|
+
|
|
18
97
|
def to_h
|
|
19
98
|
{
|
|
20
99
|
"position" => position,
|
|
@@ -28,6 +107,15 @@ module Phronomy
|
|
|
28
107
|
end
|
|
29
108
|
end
|
|
30
109
|
|
|
110
|
+
REQUIRED_KEYS = %w[
|
|
111
|
+
version call_sequence call_mode assembly_policy_version segments
|
|
112
|
+
model_config_ref
|
|
113
|
+
].freeze
|
|
114
|
+
OPTIONAL_KEYS = %w[
|
|
115
|
+
tool_definitions_ref response_schema_ref ruby_llm_version
|
|
116
|
+
adapter_name adapter_version
|
|
117
|
+
].freeze
|
|
118
|
+
|
|
31
119
|
attr_reader :version, :call_sequence, :call_mode,
|
|
32
120
|
:assembly_policy_version, :segments,
|
|
33
121
|
:model_config_ref, :tool_definitions_ref, :response_schema_ref,
|
|
@@ -46,7 +134,7 @@ module Phronomy
|
|
|
46
134
|
adapter_version: nil,
|
|
47
135
|
version: VERSION
|
|
48
136
|
)
|
|
49
|
-
@version =
|
|
137
|
+
@version = String(version).freeze
|
|
50
138
|
@call_sequence = Integer(call_sequence)
|
|
51
139
|
@call_mode = call_mode.to_sym
|
|
52
140
|
@assembly_policy_version = Integer(assembly_policy_version)
|
|
@@ -61,6 +149,93 @@ module Phronomy
|
|
|
61
149
|
freeze
|
|
62
150
|
end
|
|
63
151
|
|
|
152
|
+
# Current-format-only durable decoder. Historical format conversion is an
|
|
153
|
+
# explicit migration operation and is never attempted here. Unlike the
|
|
154
|
+
# Ruby construction API, this decoder never coerces durable field types.
|
|
155
|
+
def self.from_h(hash)
|
|
156
|
+
source = strict_source!(
|
|
157
|
+
hash,
|
|
158
|
+
required: REQUIRED_KEYS,
|
|
159
|
+
optional: OPTIONAL_KEYS,
|
|
160
|
+
label: "LLMInputManifest"
|
|
161
|
+
)
|
|
162
|
+
label = "LLMInputManifest"
|
|
163
|
+
version = source.fetch("version")
|
|
164
|
+
unless version.is_a?(String) && version == VERSION
|
|
165
|
+
raise Phronomy::Persistence::SerializationError,
|
|
166
|
+
"unsupported LLMInputManifest version: #{version.inspect}; " \
|
|
167
|
+
"current version is #{VERSION.inspect}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
call_sequence = require_positive_integer_field!(
|
|
171
|
+
source,
|
|
172
|
+
"call_sequence",
|
|
173
|
+
label: label
|
|
174
|
+
)
|
|
175
|
+
call_mode = require_enum_string_field!(
|
|
176
|
+
source,
|
|
177
|
+
"call_mode",
|
|
178
|
+
CALL_MODES.map(&:to_s),
|
|
179
|
+
label: label
|
|
180
|
+
)
|
|
181
|
+
assembly_policy_version = require_integer_field!(
|
|
182
|
+
source,
|
|
183
|
+
"assembly_policy_version",
|
|
184
|
+
label: label
|
|
185
|
+
)
|
|
186
|
+
segments = require_array_field!(source, "segments", label: label)
|
|
187
|
+
.map { |segment| Segment.from_h(segment) }
|
|
188
|
+
model_config_ref = require_nonempty_string_field!(
|
|
189
|
+
source,
|
|
190
|
+
"model_config_ref",
|
|
191
|
+
label: label
|
|
192
|
+
)
|
|
193
|
+
tool_definitions_ref = require_optional_string_field!(
|
|
194
|
+
source,
|
|
195
|
+
"tool_definitions_ref",
|
|
196
|
+
label: label
|
|
197
|
+
)
|
|
198
|
+
response_schema_ref = require_optional_string_field!(
|
|
199
|
+
source,
|
|
200
|
+
"response_schema_ref",
|
|
201
|
+
label: label
|
|
202
|
+
)
|
|
203
|
+
ruby_llm_version = require_optional_string_field!(
|
|
204
|
+
source,
|
|
205
|
+
"ruby_llm_version",
|
|
206
|
+
label: label
|
|
207
|
+
)
|
|
208
|
+
adapter_name = require_optional_string_field!(
|
|
209
|
+
source,
|
|
210
|
+
"adapter_name",
|
|
211
|
+
label: label
|
|
212
|
+
)
|
|
213
|
+
adapter_version = require_optional_string_field!(
|
|
214
|
+
source,
|
|
215
|
+
"adapter_version",
|
|
216
|
+
label: label
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
new(
|
|
220
|
+
version: version,
|
|
221
|
+
call_sequence: call_sequence,
|
|
222
|
+
call_mode: call_mode.to_sym,
|
|
223
|
+
assembly_policy_version: assembly_policy_version,
|
|
224
|
+
segments: segments,
|
|
225
|
+
model_config_ref: model_config_ref,
|
|
226
|
+
tool_definitions_ref: tool_definitions_ref,
|
|
227
|
+
response_schema_ref: response_schema_ref,
|
|
228
|
+
ruby_llm_version: ruby_llm_version,
|
|
229
|
+
adapter_name: adapter_name,
|
|
230
|
+
adapter_version: adapter_version
|
|
231
|
+
)
|
|
232
|
+
rescue Phronomy::Persistence::SerializationError
|
|
233
|
+
raise
|
|
234
|
+
rescue => error
|
|
235
|
+
raise Phronomy::Persistence::SerializationError,
|
|
236
|
+
"invalid LLMInputManifest: #{error.class}: #{error.message}"
|
|
237
|
+
end
|
|
238
|
+
|
|
64
239
|
def referenced_content_refs
|
|
65
240
|
([model_config_ref, tool_definitions_ref, response_schema_ref] +
|
|
66
241
|
segments.map(&:content_ref)).compact.uniq.freeze
|
|
@@ -82,9 +257,109 @@ module Phronomy
|
|
|
82
257
|
}.compact
|
|
83
258
|
end
|
|
84
259
|
|
|
260
|
+
class << self
|
|
261
|
+
private
|
|
262
|
+
|
|
263
|
+
def strict_source!(hash, required:, optional:, label:)
|
|
264
|
+
unless hash.is_a?(Hash)
|
|
265
|
+
raise Phronomy::Persistence::SerializationError,
|
|
266
|
+
"#{label} must be a Hash"
|
|
267
|
+
end
|
|
268
|
+
unless hash.keys.all? { |key| key.is_a?(String) }
|
|
269
|
+
raise Phronomy::Persistence::SerializationError,
|
|
270
|
+
"#{label} keys must all be String"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
actual = hash.keys
|
|
274
|
+
missing = required - actual
|
|
275
|
+
unknown = actual - (required + optional)
|
|
276
|
+
unless missing.empty? && unknown.empty?
|
|
277
|
+
details = []
|
|
278
|
+
details << "missing=#{missing.inspect}" unless missing.empty?
|
|
279
|
+
details << "unknown=#{unknown.inspect}" unless unknown.empty?
|
|
280
|
+
raise Phronomy::Persistence::SerializationError,
|
|
281
|
+
"#{label} schema mismatch (#{details.join(", ")})"
|
|
282
|
+
end
|
|
283
|
+
Phronomy::CanonicalJSON.dump(hash)
|
|
284
|
+
hash
|
|
285
|
+
rescue ArgumentError => error
|
|
286
|
+
raise Phronomy::Persistence::SerializationError,
|
|
287
|
+
"#{label} is not canonical JSON compatible: #{error.message}"
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def require_integer_field!(hash, key, label:)
|
|
291
|
+
value = hash.fetch(key)
|
|
292
|
+
return value if value.is_a?(Integer)
|
|
293
|
+
|
|
294
|
+
raise Phronomy::Persistence::SerializationError,
|
|
295
|
+
"#{label} #{key} must be an Integer"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def require_positive_integer_field!(hash, key, label:)
|
|
299
|
+
value = hash.fetch(key)
|
|
300
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
301
|
+
|
|
302
|
+
raise Phronomy::Persistence::SerializationError,
|
|
303
|
+
"#{label} #{key} must be a positive Integer"
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def require_nonnegative_integer_field!(hash, key, label:)
|
|
307
|
+
value = hash.fetch(key)
|
|
308
|
+
return value if value.is_a?(Integer) && value >= 0
|
|
309
|
+
|
|
310
|
+
raise Phronomy::Persistence::SerializationError,
|
|
311
|
+
"#{label} #{key} must be a non-negative Integer"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def require_nonempty_string_field!(hash, key, label:)
|
|
315
|
+
value = hash.fetch(key)
|
|
316
|
+
return value if value.is_a?(String) && !value.empty?
|
|
317
|
+
|
|
318
|
+
raise Phronomy::Persistence::SerializationError,
|
|
319
|
+
"#{label} #{key} must be a non-empty String"
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def require_optional_string_field!(hash, key, label:)
|
|
323
|
+
value = hash[key]
|
|
324
|
+
return value if value.nil? || value.is_a?(String)
|
|
325
|
+
|
|
326
|
+
raise Phronomy::Persistence::SerializationError,
|
|
327
|
+
"#{label} #{key} must be a String or nil"
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def require_enum_string_field!(hash, key, allowed, label:)
|
|
331
|
+
value = hash.fetch(key)
|
|
332
|
+
return value if value.is_a?(String) && allowed.include?(value)
|
|
333
|
+
|
|
334
|
+
raise Phronomy::Persistence::SerializationError,
|
|
335
|
+
"#{label} #{key} must be one of #{allowed.inspect}"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def require_array_field!(hash, key, label:)
|
|
339
|
+
value = hash.fetch(key)
|
|
340
|
+
return value if value.is_a?(Array)
|
|
341
|
+
|
|
342
|
+
raise Phronomy::Persistence::SerializationError,
|
|
343
|
+
"#{label} #{key} must be an Array"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def require_hash_field!(hash, key, label:)
|
|
347
|
+
value = hash.fetch(key)
|
|
348
|
+
return value if value.is_a?(Hash)
|
|
349
|
+
|
|
350
|
+
raise Phronomy::Persistence::SerializationError,
|
|
351
|
+
"#{label} #{key} must be a Hash"
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
85
355
|
private
|
|
86
356
|
|
|
87
357
|
def validate!
|
|
358
|
+
unless version == VERSION
|
|
359
|
+
raise Phronomy::ConfigurationError,
|
|
360
|
+
"unsupported LLMInputManifest version: #{version.inspect}; " \
|
|
361
|
+
"current version is #{VERSION.inspect}"
|
|
362
|
+
end
|
|
88
363
|
raise ArgumentError, "invalid manifest call mode: #{call_mode.inspect}" unless CALL_MODES.include?(call_mode)
|
|
89
364
|
raise ArgumentError, "call_sequence must be positive" unless call_sequence.positive?
|
|
90
365
|
expected = (0...segments.length).to_a
|
|
@@ -2,20 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
module Agent
|
|
5
|
-
# Immutable carrier for one
|
|
5
|
+
# Immutable carrier for one Provider operation outcome.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# The Provider Call identity is allocated on EventLoop before transport starts.
|
|
8
|
+
# Worker completion carries that semantic identity back to the owning
|
|
9
|
+
# FSMSession, which validates it against the currently active call before
|
|
10
|
+
# applying any live state.
|
|
10
11
|
#
|
|
11
12
|
# @api private
|
|
12
13
|
class LLMOperationResult
|
|
13
|
-
attr_reader :response, :error, :streaming
|
|
14
|
+
attr_reader :llm_call_id, :response, :error, :streaming
|
|
14
15
|
|
|
15
|
-
def initialize(response: nil, error: nil, streaming: false)
|
|
16
|
+
def initialize(llm_call_id:, response: nil, error: nil, streaming: false)
|
|
17
|
+
id = llm_call_id&.to_s
|
|
18
|
+
raise ArgumentError, "LLMOperationResult requires llm_call_id" if id.nil? || id.empty?
|
|
19
|
+
|
|
20
|
+
@llm_call_id = id.freeze
|
|
16
21
|
@response = response
|
|
17
22
|
@error = error
|
|
18
|
-
@streaming = streaming
|
|
23
|
+
@streaming = !!streaming
|
|
19
24
|
freeze
|
|
20
25
|
end
|
|
21
26
|
end
|
|
@@ -4,12 +4,6 @@ require "state_machines"
|
|
|
4
4
|
|
|
5
5
|
module Phronomy
|
|
6
6
|
module Agent
|
|
7
|
-
# Compiles AgentInvocation phase topology.
|
|
8
|
-
#
|
|
9
|
-
# Async completion is represented by explicit FSM events. This builder does
|
|
10
|
-
# not await Tasks or register Task callbacks.
|
|
11
|
-
#
|
|
12
|
-
# @api private
|
|
13
7
|
class PhaseMachineBuilder
|
|
14
8
|
TOOL_EVENTS = %i[
|
|
15
9
|
tool_authorized
|
|
@@ -43,6 +37,7 @@ module Phronomy
|
|
|
43
37
|
state :recording_tool_results
|
|
44
38
|
state :suspended
|
|
45
39
|
state :output_filtering
|
|
40
|
+
state :handed_off
|
|
46
41
|
state :completed
|
|
47
42
|
state :blocked
|
|
48
43
|
state :failed
|
|
@@ -70,7 +65,6 @@ module Phronomy
|
|
|
70
65
|
if: ->(machine) { machine.context&.ready_to_dispatch? }
|
|
71
66
|
transition evaluating_tools: :waiting_for_tools
|
|
72
67
|
|
|
73
|
-
transition dispatching_tools: :evaluating_tools
|
|
74
68
|
transition recording_tool_results: :calling_llm
|
|
75
69
|
|
|
76
70
|
transition output_filtering: :completed,
|
|
@@ -80,6 +74,12 @@ module Phronomy
|
|
|
80
74
|
end
|
|
81
75
|
|
|
82
76
|
event :llm_completed do
|
|
77
|
+
transition calling_llm: :failed,
|
|
78
|
+
if: ->(machine) { machine.context&.callback_failed? }
|
|
79
|
+
transition calling_llm: :failed,
|
|
80
|
+
if: ->(machine) { machine.context&.handoff_failed? }
|
|
81
|
+
transition calling_llm: :handed_off,
|
|
82
|
+
if: ->(machine) { machine.context&.handoff_requested? }
|
|
83
83
|
transition calling_llm: :starting_tools,
|
|
84
84
|
if: ->(machine) { machine.context&.tool_call_pending? }
|
|
85
85
|
transition calling_llm: :output_filtering
|
|
@@ -89,6 +89,18 @@ module Phronomy
|
|
|
89
89
|
transition calling_llm: :failed
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
+
event :llm_setup_failed do
|
|
93
|
+
transition calling_llm: :failed
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
event :tool_setup_failed do
|
|
97
|
+
transition dispatching_tools: :failed
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
event :tool_dispatch_prepared do
|
|
101
|
+
transition dispatching_tools: :evaluating_tools
|
|
102
|
+
end
|
|
103
|
+
|
|
92
104
|
TOOL_EVENTS.each do |event_name|
|
|
93
105
|
event event_name do
|
|
94
106
|
transition waiting_for_tools: :evaluating_tools
|
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
module Agent
|
|
5
5
|
# Immutable, Phronomy-owned snapshot of one completed Provider assistant output.
|
|
6
|
-
# It is captured before Agent-owned Tool execution begins, so durable logging
|
|
7
|
-
# never depends on RubyLLM's later control flow or Application callbacks.
|
|
8
6
|
ProviderCallOutcome = Data.define(:role, :content, :tool_calls, :usage, :metadata) do
|
|
9
7
|
def self.capture(message)
|
|
10
8
|
return if message.nil?
|
|
9
|
+
return message if message.is_a?(self)
|
|
11
10
|
|
|
12
11
|
calls = if message.respond_to?(:tool_calls) && message.tool_calls
|
|
13
12
|
source = message.tool_calls.respond_to?(:values) ? message.tool_calls.values : Array(message.tool_calls)
|
|
@@ -32,6 +31,17 @@ module Phronomy
|
|
|
32
31
|
)
|
|
33
32
|
end
|
|
34
33
|
|
|
34
|
+
def self.from_h(hash)
|
|
35
|
+
source = hash.to_h { |key, value| [key.to_s, value] }
|
|
36
|
+
new(
|
|
37
|
+
role: source.fetch("role", "assistant"),
|
|
38
|
+
content: source["content"],
|
|
39
|
+
tool_calls: source.fetch("tool_calls", []),
|
|
40
|
+
usage: source.fetch("usage", {}),
|
|
41
|
+
metadata: source.fetch("metadata", {})
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
35
45
|
def self.tool_call_hash(call)
|
|
36
46
|
return call.to_h if call.respond_to?(:to_h)
|
|
37
47
|
|
|
@@ -54,11 +64,7 @@ module Phronomy
|
|
|
54
64
|
when String, Integer, Float, TrueClass, FalseClass, NilClass
|
|
55
65
|
value
|
|
56
66
|
else
|
|
57
|
-
|
|
58
|
-
normalize(value.to_h)
|
|
59
|
-
else
|
|
60
|
-
value.to_s
|
|
61
|
-
end
|
|
67
|
+
value.respond_to?(:to_h) ? normalize(value.to_h) : value.to_s
|
|
62
68
|
end
|
|
63
69
|
end
|
|
64
70
|
private_class_method :normalize
|
|
@@ -78,6 +84,16 @@ module Phronomy
|
|
|
78
84
|
freeze
|
|
79
85
|
end
|
|
80
86
|
|
|
87
|
+
def to_h
|
|
88
|
+
{
|
|
89
|
+
"role" => role&.to_s,
|
|
90
|
+
"content" => content,
|
|
91
|
+
"tool_calls" => tool_calls,
|
|
92
|
+
"usage" => usage,
|
|
93
|
+
"metadata" => metadata
|
|
94
|
+
}.compact
|
|
95
|
+
end
|
|
96
|
+
|
|
81
97
|
def content_present?
|
|
82
98
|
!content.nil? && !(content.respond_to?(:empty?) && content.empty?)
|
|
83
99
|
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
class RecoveryCoordinator
|
|
6
|
+
# Both resolution apply and restart use this interpretation of saved facts.
|
|
7
|
+
# Runtime tasks and sessions are projections, never continuation authority.
|
|
8
|
+
# @api private
|
|
9
|
+
module Continuation
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def recovery_action(execution)
|
|
13
|
+
return :framework_tools if framework_batch?(execution)
|
|
14
|
+
|
|
15
|
+
case execution.phase.to_sym
|
|
16
|
+
when :recovery_provider_completed
|
|
17
|
+
execution.metadata["framework_calls_pending"] ? :framework_calls : :output
|
|
18
|
+
when :recovery_tools_completed
|
|
19
|
+
execution.metadata["framework_calls_pending"] ? :framework_calls : :followup
|
|
20
|
+
when :recovery_resolved_failed
|
|
21
|
+
:failed_terminal
|
|
22
|
+
else
|
|
23
|
+
:resolution_required
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Read durable inputs before entering EventLoop. The returned messages are
|
|
28
|
+
# operation-local projections transferred to the new invocation, never a
|
|
29
|
+
# shared cache or a second source of continuation decisions.
|
|
30
|
+
def prepare_recovery_material(execution, projection: nil)
|
|
31
|
+
action = recovery_action(execution)
|
|
32
|
+
suspended = execution.status == :suspended || execution.phase.to_sym == :resuming
|
|
33
|
+
return if !suspended && %i[resolution_required failed_terminal].include?(action)
|
|
34
|
+
|
|
35
|
+
unless projection
|
|
36
|
+
_manifest, projection = RecoverySupport.materialize_projection(agent, execution.metadata.fetch("manifest_ref"))
|
|
37
|
+
end
|
|
38
|
+
records = execution.working_records.select { |record| %i[assistant_message tool_message].include?(record.kind.to_sym) }
|
|
39
|
+
materializer = RubyLLMMaterializer.new(agent: agent, persistence: agent.persistence)
|
|
40
|
+
messages = records.map { |record| materializer.materialize_journal_record(record) }.freeze
|
|
41
|
+
assistant = messages.reverse.find { |message| message.role.to_sym == :assistant }
|
|
42
|
+
output, usage = RecoverySupport.provider_output_and_usage(agent, execution) if execution.phase.to_sym == :recovery_provider_completed
|
|
43
|
+
RecoveryMaterial.new(projection: projection, messages: messages,
|
|
44
|
+
assistant_message: assistant, output: output, usage: usage)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def continue_recovery_on_event_loop(execution, completion, material:)
|
|
48
|
+
event_loop = @runtime.event_loop
|
|
49
|
+
action = recovery_action(execution)
|
|
50
|
+
if action == :resolution_required
|
|
51
|
+
event_loop.mark_agent_execution_admission(agent.agent_id,
|
|
52
|
+
execution_id: execution.execution_id, state: :recovery_required)
|
|
53
|
+
deliver_resolution_required(execution, coordination_recovery_descriptor(execution))
|
|
54
|
+
completion.complete({execution_id: execution.execution_id,
|
|
55
|
+
execution_revision: execution.execution_revision,
|
|
56
|
+
recovery: :resolution_required}.freeze)
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
observe_recovery_execution(completion, execution)
|
|
61
|
+
main = agent.send(:execution_coordinator_for, agent.__coordination_config)
|
|
62
|
+
projection = event_loop.agent_execution_state(execution.execution_id).runtime_projection
|
|
63
|
+
if action == :failed_terminal
|
|
64
|
+
invocation = build_failed_recovery_invocation(execution, main)
|
|
65
|
+
else
|
|
66
|
+
projection = material.projection
|
|
67
|
+
invocation = if action == :framework_tools
|
|
68
|
+
RecoverySupport.build_invocation_for_suspended(agent, execution, projection, main,
|
|
69
|
+
agent.send(:_phronomy_event_listener), assistant_message: material.assistant_message)
|
|
70
|
+
else
|
|
71
|
+
RecoverySupport.build_chat_for_recovery(agent, execution, projection, main,
|
|
72
|
+
agent.send(:_phronomy_event_listener), messages: material.messages)
|
|
73
|
+
end
|
|
74
|
+
if execution.phase.to_sym == :recovery_provider_completed
|
|
75
|
+
invocation.output, invocation.usage = material.output, material.usage
|
|
76
|
+
end
|
|
77
|
+
prepare_saved_provider_calls(execution, invocation, material.assistant_message) if action == :framework_calls
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
event_loop.replace_agent_execution(execution.execution_id, execution: execution,
|
|
81
|
+
runtime_projection: projection, invocation: invocation, fsm_session_id: nil)
|
|
82
|
+
if action == :failed_terminal
|
|
83
|
+
failure = execution.metadata.dig(RecoverySupport::RECOVERY_METADATA_KEY, "failure") ||
|
|
84
|
+
{"class" => "Phronomy::Error", "message" => "Recovery-resolved failure"}
|
|
85
|
+
main.send(:begin_terminal_commit_on_event_loop,
|
|
86
|
+
event_loop.agent_execution_state(execution.execution_id), completion, invocation,
|
|
87
|
+
RecoverySupport.error_from_failure(failure), fsm_session_id: nil)
|
|
88
|
+
return
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
event_loop.mark_agent_execution_admission(agent.agent_id,
|
|
92
|
+
execution_id: execution.execution_id, state: :executing)
|
|
93
|
+
case action
|
|
94
|
+
when :framework_tools
|
|
95
|
+
event_loop.register_agent_completion_waiter(execution.execution_id, completion)
|
|
96
|
+
main.send(:start_framework_tools_on_event_loop, execution.execution_id, completion)
|
|
97
|
+
when :framework_calls, :output
|
|
98
|
+
# Re-enter the ordinary FSM before output filtering so its failures
|
|
99
|
+
# settle through the same durable terminal barrier as a live call.
|
|
100
|
+
start_recovery_session(event_loop, main, execution, invocation, completion,
|
|
101
|
+
resume_event: :llm_completed, resume_phase: :calling_llm)
|
|
102
|
+
when :followup
|
|
103
|
+
start_recovery_session(event_loop, main, execution, invocation, completion,
|
|
104
|
+
resume_event: :state_completed, resume_phase: :recording_tool_results)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def prepare_saved_provider_calls(execution, invocation, message)
|
|
109
|
+
record = RecoverySupport.latest_assistant_record(execution)
|
|
110
|
+
calls = message.tool_calls.respond_to?(:values) ? message.tool_calls.values : Array(message.tool_calls)
|
|
111
|
+
framework_calls = calls.select { |call| agent.__framework_call?(call.name) }
|
|
112
|
+
if framework_calls.empty?
|
|
113
|
+
raise Phronomy::ExecutionRehydrationRequiredError, "Saved framework call wiring is missing"
|
|
114
|
+
end
|
|
115
|
+
invocation.accept_tool_calls!(framework_calls, llm_call_id: record.llm_call_id)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build_failed_recovery_invocation(execution, main)
|
|
119
|
+
Phronomy::Agent::AgentInvocation.new(agent: agent, input: nil,
|
|
120
|
+
config: {execution_id: execution.execution_id, phronomy_execution_coordinator: main},
|
|
121
|
+
event_listener: agent.send(:_phronomy_event_listener),
|
|
122
|
+
mode: (execution.metadata[RecoverySupport::INVOCATION_MODE_KEY] || "invoke").to_sym,
|
|
123
|
+
execution_id: execution.execution_id)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def start_recovery_session(event_loop, main, execution, invocation, completion, resume_event:, resume_phase:)
|
|
127
|
+
session = AgentInvocationSessionBuilder.build_for_resume(agent_invocation: invocation,
|
|
128
|
+
resume_event: resume_event, resume_phase: resume_phase, runtime: @runtime)
|
|
129
|
+
event_loop.replace_agent_execution(execution.execution_id, invocation: invocation, fsm_session_id: session.id)
|
|
130
|
+
event_loop.register_agent_completion_waiter(execution.execution_id, completion)
|
|
131
|
+
source = Phronomy::Task.deferred(name: "#{completion.name}-source")
|
|
132
|
+
source.on_complete do |completed, error|
|
|
133
|
+
main.send(:finish_on_event_loop, execution.execution_id, completion,
|
|
134
|
+
completed || session.context, error, fsm_session_id: session.id)
|
|
135
|
+
end
|
|
136
|
+
event_loop.register(session, completion: source)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def deliver_resolution_required(execution, descriptor)
|
|
140
|
+
unless descriptor
|
|
141
|
+
raise Phronomy::ExecutionRehydrationRequiredError,
|
|
142
|
+
"Recovery state has no current unresolved subject"
|
|
143
|
+
end
|
|
144
|
+
listener = agent.send(:_phronomy_event_listener)
|
|
145
|
+
unless listener
|
|
146
|
+
raise Phronomy::ConfigurationError,
|
|
147
|
+
"Recovery resolution requires an Agent on_event listener"
|
|
148
|
+
end
|
|
149
|
+
payload = RecoverySupport.event_payload(
|
|
150
|
+
execution,
|
|
151
|
+
descriptor
|
|
152
|
+
)
|
|
153
|
+
callback_error = agent.send(
|
|
154
|
+
:_deliver_stream_event,
|
|
155
|
+
listener,
|
|
156
|
+
StreamEvent.new(
|
|
157
|
+
type: :recovery_resolution_required,
|
|
158
|
+
payload: payload
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
if callback_error
|
|
162
|
+
raise agent.send(
|
|
163
|
+
:_build_stream_callback_error,
|
|
164
|
+
event_type: :recovery_resolution_required,
|
|
165
|
+
callback_error: callback_error,
|
|
166
|
+
result: payload
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|