phronomy 0.15.1 → 0.16.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/CHANGELOG.md +105 -28
- data/README.md +300 -75
- data/benchmark/bench_agent_invoke.rb +3 -0
- data/benchmark/bench_regression.rb +2 -18
- data/benchmark/bench_tool_schema.rb +1 -0
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
- data/lib/phronomy/agent/activation_registry.rb +28 -0
- data/lib/phronomy/agent/agent_execution.rb +97 -0
- data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
- data/lib/phronomy/agent/agent_invocation.rb +42 -10
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +50 -11
- data/lib/phronomy/agent/agent_root.rb +67 -0
- data/lib/phronomy/agent/async_event_api.rb +55 -475
- data/lib/phronomy/agent/base.rb +301 -285
- data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
- data/lib/phronomy/agent/context_assembler.rb +321 -0
- data/lib/phronomy/agent/context_candidate.rb +47 -0
- data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
- data/lib/phronomy/agent/context_importer.rb +217 -0
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +188 -0
- data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
- data/lib/phronomy/agent/context_plan.rb +25 -0
- data/lib/phronomy/agent/context_plan_validator.rb +167 -0
- data/lib/phronomy/agent/context_policies/default.rb +53 -0
- data/lib/phronomy/agent/context_policy.rb +15 -0
- data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
- data/lib/phronomy/agent/context_policy_registry.rb +46 -0
- data/lib/phronomy/agent/context_request.rb +35 -0
- data/lib/phronomy/agent/context_selection_unit.rb +38 -0
- data/lib/phronomy/agent/derived_content_spec.rb +34 -0
- data/lib/phronomy/agent/execution_coordinator.rb +1123 -0
- data/lib/phronomy/agent/fsm_runtime_adapter.rb +210 -0
- data/lib/phronomy/agent/immutable.rb +31 -0
- data/lib/phronomy/agent/journal_projection.rb +34 -0
- data/lib/phronomy/agent/journal_record.rb +67 -0
- data/lib/phronomy/agent/llm_call_record.rb +51 -0
- data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
- data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
- data/lib/phronomy/agent/llm_input_patch.rb +21 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
- data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +298 -0
- data/lib/phronomy/agent/token_budget_resolver.rb +69 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
- data/lib/phronomy/agent/tool_definition_set.rb +55 -0
- data/lib/phronomy/agent.rb +14 -16
- data/lib/phronomy/agent_busy_error.rb +5 -0
- data/lib/phronomy/canonical_json.rb +136 -0
- data/lib/phronomy/configuration.rb +9 -4
- data/lib/phronomy/content_store/base.rb +51 -0
- data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
- data/lib/phronomy/engine/event_loop.rb +3 -0
- data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
- data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
- data/lib/phronomy/llm_context_window/assembler.rb +8 -8
- data/lib/phronomy/multi_agent/orchestrator.rb +1 -0
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
- data/lib/phronomy/multi_agent/team_coordinator.rb +6 -2
- data/lib/phronomy/persistence/in_memory.rb +247 -0
- data/lib/phronomy/persistence.rb +39 -0
- data/lib/phronomy/tools/agent.rb +14 -36
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +11 -0
- data/scripts/add_to_h_to_token_doubles.rb +33 -0
- data/scripts/add_to_h_unnamed_doubles.rb +27 -0
- data/scripts/migrate_spec_agent_definition.rb +108 -0
- data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
- data/scripts/migrate_spec_inline_pass3.rb +24 -0
- metadata +54 -7
- data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
- data/lib/phronomy/agent/before_completion_context.rb +0 -47
- data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# 012 — Canonical Complete Execution Log and Context Policy
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
Accepted for the stateful Agent refactor. This decision supersedes the parts of ADR-011 that made the legacy `build_context`/Assembler path the long-term LLM-input authority.
|
|
6
|
+
|
|
7
|
+
## Decision
|
|
8
|
+
|
|
9
|
+
Phronomy records the logical execution facts it observes as an append-only Canonical Complete Execution Log. Context selection, pruning and compaction do not rewrite or delete those raw facts. They decide only which representation is materialized into one LLM Call Manifest.
|
|
10
|
+
|
|
11
|
+
Journal and Manifest are separate authorities:
|
|
12
|
+
|
|
13
|
+
- **Journal** is the authoritative record of logical execution facts that Phronomy observed.
|
|
14
|
+
- **Manifest** is the authoritative record of the logical input fixed for one particular LLM Call.
|
|
15
|
+
- Runtime Projection is derived from the Manifest and must not add semantic content that the Manifest did not select.
|
|
16
|
+
|
|
17
|
+
The persistent identity axes have narrow responsibilities:
|
|
18
|
+
|
|
19
|
+
- `agent_id` identifies the owning Agent.
|
|
20
|
+
- `execution_id` identifies one AgentExecution. It is provenance, not a Context-selection atom.
|
|
21
|
+
- `llm_call_id` identifies one runtime Provider LLM Call. It is allocated before transport starts and correlates that call's outcome. It is provenance, not a semantic-compaction boundary.
|
|
22
|
+
- `tool_call_id` links an assistant message's Tool Call with the corresponding Tool execution/message.
|
|
23
|
+
- Journal `sequence` is canonical chronology.
|
|
24
|
+
|
|
25
|
+
No `message_group_id`, import-only source provenance ID, or synthetic imported `execution_id` / `llm_call_id` is introduced.
|
|
26
|
+
|
|
27
|
+
### Message preservation
|
|
28
|
+
|
|
29
|
+
A logical message that Phronomy receives is not flattened merely to make later Context assembly convenient.
|
|
30
|
+
|
|
31
|
+
- A Provider assistant response is captured as one complete assistant message containing its observable `content` and all Tool Calls.
|
|
32
|
+
- An imported assistant message is journaled as one assistant message with the structure supplied by the Import contract.
|
|
33
|
+
- A Tool value returned by Phronomy Tool execution is an execution fact (`tool_result`).
|
|
34
|
+
- The Tool-role message actually appended to the LLM conversation is a separate logical fact (`tool_message`).
|
|
35
|
+
- Imported Tool-role messages are journaled directly as `tool_message` records; Phronomy does not invent a separate raw Tool execution result for an execution it did not observe.
|
|
36
|
+
|
|
37
|
+
The Journal therefore does not need to infer or reconstruct a source message boundary that Phronomy already observed. Context Policy can inspect Tool Call IDs contained in an assistant message and form protocol-safe selection units with the corresponding Tool messages.
|
|
38
|
+
|
|
39
|
+
### Import boundary
|
|
40
|
+
|
|
41
|
+
The application supplying imported history is responsible for satisfying Phronomy's Import contract. Phronomy interprets valid input according to that contract and rejects only data that is invalid under the contract, such as unsupported roles, missing Tool Call IDs, orphan/duplicate Tool results, unresolved Tool calls, or malformed message structure.
|
|
42
|
+
|
|
43
|
+
External resource acquisition is not a `ContextImporter` responsibility. Files, URLs and similar resources are obtained and interpreted by the Application or Tool that owns that capability; Phronomy journals the logical content/results it actually receives. `ContextImporter` therefore does not introduce RubyLLM-specific attachment handling or an attachment-specific reject path.
|
|
44
|
+
|
|
45
|
+
Phronomy must not reject an otherwise valid input merely because an internal flattened representation would lose information. In particular, two separately supplied assistant messages remain two separate Journal messages.
|
|
46
|
+
|
|
47
|
+
### Manifest boundary
|
|
48
|
+
|
|
49
|
+
The Manifest fixes what one LLM Call will actually receive after Context Policy selection. A historical raw Tool return value and the Tool message produced from it are not interchangeable: the raw result belongs to the execution log, while the message selected for an LLM Call belongs to the Manifest input path.
|
|
50
|
+
|
|
51
|
+
One Provider response is captured as a Phronomy-owned `ProviderCallOutcome` before Agent-owned Tool execution starts. The canonical assistant-message record is produced from that outcome, not from Application callback delivery.
|
|
52
|
+
|
|
53
|
+
Context selection is expressed separately through `ContextCandidate`, dependency-aware `ContextSelectionUnit`, `ContextRequest`, `ContextPolicy`, validated `ContextPlan`, and final token-budget validation. An assistant message containing Tool Calls and the corresponding Tool messages form an atomic protocol unit. Ordinary messages in the same `execution_id` remain independently selectable.
|
|
54
|
+
|
|
55
|
+
## RubyLLM boundary
|
|
56
|
+
|
|
57
|
+
Agent-owned Tool execution requires RubyLLM's additive callback contract introduced in RubyLLM 1.15. Phronomy therefore requires `ruby_llm >= 1.15, < 2`.
|
|
58
|
+
|
|
59
|
+
RubyLLM 1.15 adds the complete assistant message to `Chat#messages` before `before_tool_call` callbacks run. Phronomy captures the immutable Provider outcome at that boundary and raises `ToolCallIntercepted` only as an internal control transfer so approval, suspension, parallel dispatch and durable state remain Phronomy-owned.
|
|
60
|
+
|
|
61
|
+
## Consequences
|
|
62
|
+
|
|
63
|
+
- Canonical execution history is independent of the current Context budget or policy.
|
|
64
|
+
- Import and runtime histories converge on the same canonical assistant/tool-message model without synthetic grouping identity.
|
|
65
|
+
- Old optional working history may be excluded from a follow-up Manifest without being deleted.
|
|
66
|
+
- Raw Tool results remain available as execution facts even when the corresponding Tool message is omitted from a later Manifest.
|
|
67
|
+
- Tool protocol dependencies are validated independently from semantic selection policy.
|
|
68
|
+
- Context Policy can later introduce deterministic derived/compacted records without replacing their raw sources.
|
|
69
|
+
- Public custom Context Policy APIs, transaction-boundary restructuring, deterministic compaction, Manifest v2/tool subsets, and legacy Assembler removal remain later phases.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
class ActivationRegistry
|
|
6
|
+
def initialize
|
|
7
|
+
@mutex = Mutex.new
|
|
8
|
+
@records = {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def register(activation)
|
|
12
|
+
@mutex.synchronize do
|
|
13
|
+
raise ArgumentError, "activation already registered: #{activation.execution_id}" if @records.key?(activation.execution_id)
|
|
14
|
+
@records[activation.execution_id] = activation
|
|
15
|
+
end
|
|
16
|
+
activation
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def fetch(execution_id)
|
|
20
|
+
@mutex.synchronize { @records[execution_id.to_s] }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def delete(execution_id)
|
|
24
|
+
@mutex.synchronize { @records.delete(execution_id.to_s) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Phronomy
|
|
7
|
+
module Agent
|
|
8
|
+
class AgentExecution
|
|
9
|
+
ACTIVE_STATUSES = %i[preparing active suspended].freeze
|
|
10
|
+
TERMINAL_STATUSES = %i[completed failed cancelled rejected blocked].freeze
|
|
11
|
+
TRANSITIONS = {
|
|
12
|
+
preparing: %i[preparing active failed cancelled blocked],
|
|
13
|
+
active: %i[active suspended completed failed cancelled rejected blocked],
|
|
14
|
+
suspended: %i[suspended active failed cancelled],
|
|
15
|
+
completed: %i[completed],
|
|
16
|
+
failed: %i[failed],
|
|
17
|
+
cancelled: %i[cancelled],
|
|
18
|
+
rejected: %i[rejected],
|
|
19
|
+
blocked: %i[blocked]
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
ATTRIBUTES = %i[
|
|
23
|
+
execution_id agent_id execution_revision status phase
|
|
24
|
+
base_agent_revision base_context_revision base_journal_position
|
|
25
|
+
working_records llm_calls approval_request result_ref error_ref
|
|
26
|
+
created_at updated_at terminal_reason metadata
|
|
27
|
+
].freeze
|
|
28
|
+
attr_reader(*ATTRIBUTES)
|
|
29
|
+
|
|
30
|
+
def self.start(agent_root:, input_record:, metadata: {})
|
|
31
|
+
now = Time.now.utc.iso8601(6)
|
|
32
|
+
new(
|
|
33
|
+
execution_id: SecureRandom.uuid,
|
|
34
|
+
agent_id: agent_root.agent_id,
|
|
35
|
+
execution_revision: 0,
|
|
36
|
+
status: :preparing,
|
|
37
|
+
phase: :preparing,
|
|
38
|
+
base_agent_revision: agent_root.agent_revision,
|
|
39
|
+
base_context_revision: agent_root.context_revision,
|
|
40
|
+
base_journal_position: agent_root.journal_position,
|
|
41
|
+
working_records: [input_record],
|
|
42
|
+
llm_calls: [],
|
|
43
|
+
approval_request: nil,
|
|
44
|
+
result_ref: nil,
|
|
45
|
+
error_ref: nil,
|
|
46
|
+
created_at: now,
|
|
47
|
+
updated_at: now,
|
|
48
|
+
terminal_reason: nil,
|
|
49
|
+
metadata: metadata
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def initialize(**attributes)
|
|
54
|
+
ATTRIBUTES.each do |name|
|
|
55
|
+
value = attributes.fetch(name)
|
|
56
|
+
value = value.to_sym if %i[status phase].include?(name)
|
|
57
|
+
instance_variable_set("@#{name}", Immutable.copy(value))
|
|
58
|
+
end
|
|
59
|
+
raise ArgumentError, "unknown execution status: #{status.inspect}" unless TRANSITIONS.key?(status)
|
|
60
|
+
raise ArgumentError, "execution_revision must be non-negative" if execution_revision.negative?
|
|
61
|
+
Immutable.validate_canonical_json!(metadata, label: "Execution metadata")
|
|
62
|
+
if approval_request
|
|
63
|
+
Immutable.validate_canonical_json!(approval_request, label: "Approval request")
|
|
64
|
+
end
|
|
65
|
+
freeze
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def active?
|
|
69
|
+
ACTIVE_STATUSES.include?(status)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def terminal?
|
|
73
|
+
TERMINAL_STATUSES.include?(status)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def with(**changes)
|
|
77
|
+
next_status = changes.fetch(:status, status).to_sym
|
|
78
|
+
unless TRANSITIONS.fetch(status).include?(next_status)
|
|
79
|
+
raise ArgumentError, "illegal AgentExecution transition: #{status} -> #{next_status}"
|
|
80
|
+
end
|
|
81
|
+
values = ATTRIBUTES.to_h { |name| [name, public_send(name)] }.merge(changes)
|
|
82
|
+
values[:execution_revision] = execution_revision + 1 unless changes.key?(:execution_revision)
|
|
83
|
+
values[:updated_at] = Time.now.utc.iso8601(6) unless changes.key?(:updated_at)
|
|
84
|
+
self.class.new(**values)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def to_h
|
|
88
|
+
ATTRIBUTES.to_h do |name|
|
|
89
|
+
value = public_send(name)
|
|
90
|
+
value = value.map(&:to_h) if name == :working_records
|
|
91
|
+
value = value.map(&:to_h) if name == :llm_calls
|
|
92
|
+
[name.to_s, value]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Phronomy
|
|
7
|
+
module Agent
|
|
8
|
+
class AgentExecutionActivation
|
|
9
|
+
attr_reader :execution_id, :agent, :application_listener, :coordinator,
|
|
10
|
+
:base_manifest
|
|
11
|
+
attr_accessor :invocation, :session
|
|
12
|
+
|
|
13
|
+
def initialize(
|
|
14
|
+
execution:,
|
|
15
|
+
agent:,
|
|
16
|
+
runtime_projection:,
|
|
17
|
+
coordinator:,
|
|
18
|
+
application_listener: nil
|
|
19
|
+
)
|
|
20
|
+
@execution_id = execution.execution_id
|
|
21
|
+
@execution = execution
|
|
22
|
+
@agent = agent
|
|
23
|
+
@runtime_projection = runtime_projection
|
|
24
|
+
@base_manifest = runtime_projection.manifest
|
|
25
|
+
@coordinator = coordinator
|
|
26
|
+
@application_listener = application_listener
|
|
27
|
+
@mutex = Mutex.new
|
|
28
|
+
@active_call = nil
|
|
29
|
+
@llm_results = []
|
|
30
|
+
@runtime_events = []
|
|
31
|
+
@callback_errors = []
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def execution
|
|
35
|
+
@mutex.synchronize { @execution }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def replace_execution(value)
|
|
39
|
+
@mutex.synchronize { @execution = value }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def runtime_projection
|
|
43
|
+
@mutex.synchronize { @runtime_projection }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def replace_runtime_projection(value)
|
|
47
|
+
@mutex.synchronize { @runtime_projection = value }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Allocates Provider Call identity before transport begins. This identity is
|
|
51
|
+
# provenance only; Context selection must not use it as a semantic boundary.
|
|
52
|
+
def begin_llm_call(projection)
|
|
53
|
+
call_context = {
|
|
54
|
+
llm_call_id: SecureRandom.uuid,
|
|
55
|
+
manifest_ref: projection.manifest_ref,
|
|
56
|
+
started_at: Time.now.utc.iso8601(6)
|
|
57
|
+
}.freeze
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
if @active_call
|
|
60
|
+
raise Phronomy::Error,
|
|
61
|
+
"cannot start a Provider Call while another Provider Call is active"
|
|
62
|
+
end
|
|
63
|
+
@runtime_projection = projection
|
|
64
|
+
@active_call = call_context
|
|
65
|
+
end
|
|
66
|
+
call_context
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def record_llm_result(response:, error:, streaming:)
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
active_call = @active_call
|
|
72
|
+
unless active_call
|
|
73
|
+
raise Phronomy::Error, "LLM result arrived without an active Provider Call"
|
|
74
|
+
end
|
|
75
|
+
@llm_results << {
|
|
76
|
+
llm_call_id: active_call.fetch(:llm_call_id),
|
|
77
|
+
response: response,
|
|
78
|
+
error: error,
|
|
79
|
+
streaming: streaming,
|
|
80
|
+
manifest_ref: active_call.fetch(:manifest_ref),
|
|
81
|
+
started_at: active_call.fetch(:started_at)
|
|
82
|
+
}
|
|
83
|
+
@active_call = nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def runtime_snapshot
|
|
88
|
+
@mutex.synchronize do
|
|
89
|
+
{
|
|
90
|
+
llm_results: @llm_results.dup,
|
|
91
|
+
runtime_events: @runtime_events.dup,
|
|
92
|
+
active_call: @active_call&.dup
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def acknowledge_runtime_snapshot(snapshot)
|
|
98
|
+
@mutex.synchronize do
|
|
99
|
+
@llm_results.shift(snapshot.fetch(:llm_results).length)
|
|
100
|
+
@runtime_events.shift(snapshot.fetch(:runtime_events).length)
|
|
101
|
+
if snapshot[:active_call] && @active_call == snapshot[:active_call]
|
|
102
|
+
@active_call = nil
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
ApplicationCallbackFailure = Data.define(:event_type, :error) do
|
|
108
|
+
def to_stream_callback_error
|
|
109
|
+
wrapped = Phronomy::StreamCallbackError.new(
|
|
110
|
+
event_type: event_type, original_error: error, result: nil
|
|
111
|
+
)
|
|
112
|
+
begin
|
|
113
|
+
raise wrapped, cause: error
|
|
114
|
+
rescue Phronomy::StreamCallbackError => caught
|
|
115
|
+
caught.set_backtrace(error.backtrace)
|
|
116
|
+
caught
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
attr_reader :callback_failure
|
|
122
|
+
|
|
123
|
+
def callback_failed?
|
|
124
|
+
@mutex.synchronize { !@callback_failure.nil? }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Canonical runtime recording is independent of Application callback health.
|
|
128
|
+
# Once an event is observed it is appended even after a listener has failed.
|
|
129
|
+
def record_event(event)
|
|
130
|
+
listener = @mutex.synchronize do
|
|
131
|
+
@runtime_events << event
|
|
132
|
+
@callback_failure ? nil : @application_listener
|
|
133
|
+
end
|
|
134
|
+
return unless listener
|
|
135
|
+
|
|
136
|
+
listener.call(event)
|
|
137
|
+
rescue => callback_error
|
|
138
|
+
failure = ApplicationCallbackFailure.new(
|
|
139
|
+
event_type: event.type, error: callback_error
|
|
140
|
+
)
|
|
141
|
+
@mutex.synchronize do
|
|
142
|
+
@callback_failure ||= failure
|
|
143
|
+
@application_listener = nil
|
|
144
|
+
end
|
|
145
|
+
notify_callback_failure(failure)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def notify_callback_failure(failure)
|
|
151
|
+
invocation = @mutex.synchronize { @invocation }
|
|
152
|
+
if invocation
|
|
153
|
+
accepted = Phronomy::Runtime.instance.event_loop.post_to_session(
|
|
154
|
+
Phronomy::Event.new(
|
|
155
|
+
type: :application_callback_failed,
|
|
156
|
+
target_id: invocation.id,
|
|
157
|
+
payload: {failure: failure}
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
unless accepted
|
|
161
|
+
Phronomy.configuration.logger&.warn(
|
|
162
|
+
"[Phronomy] Callback failure recorded but could not notify FSM: execution_id=#{@execution_id}"
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
Phronomy.configuration.logger&.warn(
|
|
167
|
+
"[Phronomy] Application event listener failed: #{failure.error.class}: #{failure.error.message}"
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -26,6 +26,8 @@ module Phronomy
|
|
|
26
26
|
llm_failed
|
|
27
27
|
].freeze
|
|
28
28
|
|
|
29
|
+
CALLBACK_FAILED_EVENTS = %i[application_callback_failed].freeze
|
|
30
|
+
|
|
29
31
|
attr_accessor :input,
|
|
30
32
|
:messages,
|
|
31
33
|
:chat,
|
|
@@ -50,7 +52,9 @@ module Phronomy
|
|
|
50
52
|
:tool_invocations,
|
|
51
53
|
:session_id,
|
|
52
54
|
:phase,
|
|
53
|
-
:mode
|
|
55
|
+
:mode,
|
|
56
|
+
:current_llm_call_id,
|
|
57
|
+
:tool_batch_llm_call_id
|
|
54
58
|
|
|
55
59
|
def initialize(
|
|
56
60
|
agent:,
|
|
@@ -96,9 +100,10 @@ module Phronomy
|
|
|
96
100
|
@error = nil
|
|
97
101
|
@session_id = nil
|
|
98
102
|
@phase = nil
|
|
103
|
+
@current_llm_call_id = nil
|
|
104
|
+
@tool_batch_llm_call_id = nil
|
|
99
105
|
end
|
|
100
106
|
|
|
101
|
-
# Compatibility aliases for existing internal callers.
|
|
102
107
|
def stream_listener
|
|
103
108
|
@event_listener
|
|
104
109
|
end
|
|
@@ -116,6 +121,11 @@ module Phronomy
|
|
|
116
121
|
@phase = phase
|
|
117
122
|
end
|
|
118
123
|
|
|
124
|
+
def begin_llm_call!(llm_call_id)
|
|
125
|
+
@current_llm_call_id = llm_call_id.to_s
|
|
126
|
+
self
|
|
127
|
+
end
|
|
128
|
+
|
|
119
129
|
def pending_tool_calls=(calls)
|
|
120
130
|
@pending_tool_calls = Array(calls)
|
|
121
131
|
end
|
|
@@ -128,6 +138,7 @@ module Phronomy
|
|
|
128
138
|
@pending_tool_calls = []
|
|
129
139
|
@tool_invocations = []
|
|
130
140
|
@approval_request = nil
|
|
141
|
+
@tool_batch_llm_call_id = nil
|
|
131
142
|
end
|
|
132
143
|
|
|
133
144
|
def handle_fsm_event(event)
|
|
@@ -146,6 +157,11 @@ module Phronomy
|
|
|
146
157
|
return true
|
|
147
158
|
end
|
|
148
159
|
|
|
160
|
+
if CALLBACK_FAILED_EVENTS.include?(event.type)
|
|
161
|
+
@error ||= event.payload.fetch(:failure).to_stream_callback_error
|
|
162
|
+
return true
|
|
163
|
+
end
|
|
164
|
+
|
|
149
165
|
return false unless TOOL_EVENT_TYPES.include?(event.type)
|
|
150
166
|
|
|
151
167
|
invocation = tool_invocation(
|
|
@@ -164,8 +180,6 @@ module Phronomy
|
|
|
164
180
|
true
|
|
165
181
|
end
|
|
166
182
|
|
|
167
|
-
# Deprecated internal compatibility hook. FSMSession no longer calls
|
|
168
|
-
# this method; asynchronous results enter through explicit events.
|
|
169
183
|
def apply_fsm_action_result(result)
|
|
170
184
|
event_type =
|
|
171
185
|
if result.respond_to?(:error) &&
|
|
@@ -185,15 +199,20 @@ module Phronomy
|
|
|
185
199
|
self
|
|
186
200
|
end
|
|
187
201
|
|
|
188
|
-
def accept_tool_calls!(tool_calls)
|
|
202
|
+
def accept_tool_calls!(tool_calls, llm_call_id: nil)
|
|
189
203
|
@user_message_sent = true
|
|
190
204
|
@pending_tool_calls = Array(tool_calls)
|
|
191
205
|
@messages = @chat.messages
|
|
206
|
+
@tool_batch_llm_call_id = (llm_call_id || @current_llm_call_id)&.to_s
|
|
207
|
+
@current_llm_call_id = nil
|
|
192
208
|
@pending_tool_calls.each do |tool_call|
|
|
193
209
|
deliver_event(
|
|
194
210
|
StreamEvent.new(
|
|
195
211
|
type: :tool_call,
|
|
196
|
-
payload: {
|
|
212
|
+
payload: {
|
|
213
|
+
tool_call: tool_call,
|
|
214
|
+
llm_call_id: @tool_batch_llm_call_id
|
|
215
|
+
}.compact
|
|
197
216
|
)
|
|
198
217
|
)
|
|
199
218
|
end
|
|
@@ -210,6 +229,7 @@ module Phronomy
|
|
|
210
229
|
@usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
211
230
|
@messages = @chat.messages
|
|
212
231
|
@pending_tool_calls = []
|
|
232
|
+
@current_llm_call_id = nil
|
|
213
233
|
self
|
|
214
234
|
end
|
|
215
235
|
|
|
@@ -255,9 +275,10 @@ module Phronomy
|
|
|
255
275
|
|
|
256
276
|
def record_tool_results!
|
|
257
277
|
@tool_invocations.each do |invocation|
|
|
278
|
+
tool_content = invocation.result.to_s
|
|
258
279
|
@chat.add_message(
|
|
259
280
|
role: :tool,
|
|
260
|
-
content:
|
|
281
|
+
content: tool_content,
|
|
261
282
|
tool_call_id: invocation.tool_call_id
|
|
262
283
|
)
|
|
263
284
|
deliver_event(
|
|
@@ -266,8 +287,14 @@ module Phronomy
|
|
|
266
287
|
payload: {
|
|
267
288
|
tool_call_id: invocation.tool_call_id,
|
|
268
289
|
tool_name: invocation.tool_name,
|
|
269
|
-
tool_result: invocation.result
|
|
270
|
-
|
|
290
|
+
tool_result: invocation.result,
|
|
291
|
+
tool_message: {
|
|
292
|
+
"role" => "tool",
|
|
293
|
+
"content" => tool_content,
|
|
294
|
+
"tool_call_id" => invocation.tool_call_id.to_s
|
|
295
|
+
},
|
|
296
|
+
llm_call_id: @tool_batch_llm_call_id
|
|
297
|
+
}.compact
|
|
271
298
|
)
|
|
272
299
|
)
|
|
273
300
|
end
|
|
@@ -361,6 +388,7 @@ module Phronomy
|
|
|
361
388
|
end
|
|
362
389
|
|
|
363
390
|
if event.type == :llm_failed
|
|
391
|
+
@current_llm_call_id = nil
|
|
364
392
|
@error = result.error ||
|
|
365
393
|
Phronomy::Error.new("LLM operation failed without an error")
|
|
366
394
|
return
|
|
@@ -368,8 +396,12 @@ module Phronomy
|
|
|
368
396
|
|
|
369
397
|
if result.error
|
|
370
398
|
if result.error.is_a?(ToolCallIntercepted)
|
|
371
|
-
accept_tool_calls!(
|
|
399
|
+
accept_tool_calls!(
|
|
400
|
+
result.error.tool_calls,
|
|
401
|
+
llm_call_id: result.error.llm_call_id
|
|
402
|
+
)
|
|
372
403
|
else
|
|
404
|
+
@current_llm_call_id = nil
|
|
373
405
|
@error = result.error
|
|
374
406
|
end
|
|
375
407
|
else
|
|
@@ -132,6 +132,17 @@ module Phronomy
|
|
|
132
132
|
],
|
|
133
133
|
resume: [
|
|
134
134
|
{from: :suspended, to: :waiting_for_tools, guard: nil}
|
|
135
|
+
],
|
|
136
|
+
application_callback_failed: [
|
|
137
|
+
{from: :filtering_input, to: :failed, guard: nil},
|
|
138
|
+
{from: :building_context, to: :failed, guard: nil},
|
|
139
|
+
{from: :calling_llm, to: :failed, guard: nil},
|
|
140
|
+
{from: :starting_tools, to: :failed, guard: nil},
|
|
141
|
+
{from: :evaluating_tools, to: :failed, guard: nil},
|
|
142
|
+
{from: :waiting_for_tools, to: :failed, guard: nil},
|
|
143
|
+
{from: :dispatching_tools, to: :failed, guard: nil},
|
|
144
|
+
{from: :recording_tool_results, to: :failed, guard: nil},
|
|
145
|
+
{from: :output_filtering, to: :failed, guard: nil}
|
|
135
146
|
]
|
|
136
147
|
)
|
|
137
148
|
end
|
|
@@ -206,30 +217,58 @@ module Phronomy
|
|
|
206
217
|
invocation.chat,
|
|
207
218
|
invocation.config
|
|
208
219
|
)
|
|
209
|
-
install_tool_interceptors(invocation.chat)
|
|
220
|
+
install_tool_interceptors(invocation.chat, invocation)
|
|
210
221
|
invocation
|
|
211
222
|
end
|
|
212
223
|
private_class_method :building_context_action
|
|
213
224
|
|
|
214
|
-
|
|
225
|
+
# RubyLLM >= 1.15 adds the complete assistant Message to chat.messages
|
|
226
|
+
# before before_tool_call is fired. Capture that Message at the control
|
|
227
|
+
# boundary instead of trying to recover it after the exception unwinds.
|
|
228
|
+
def self.install_tool_interceptors(chat, invocation = nil)
|
|
229
|
+
unless chat.respond_to?(:before_tool_call)
|
|
230
|
+
raise Phronomy::ConfigurationError,
|
|
231
|
+
"Agent-owned Tool execution requires RubyLLM >= 1.15 (before_tool_call callback)"
|
|
232
|
+
end
|
|
233
|
+
|
|
215
234
|
if chat.respond_to?(:on_tool_call_batch)
|
|
216
235
|
chat.on_tool_call_batch do |tool_calls|
|
|
217
|
-
raise
|
|
236
|
+
raise build_tool_interception(chat, tool_calls, invocation)
|
|
218
237
|
end
|
|
219
238
|
end
|
|
220
239
|
|
|
221
|
-
|
|
222
|
-
chat
|
|
223
|
-
raise Phronomy::Agent::ToolCallIntercepted.new(tool_call)
|
|
224
|
-
end
|
|
225
|
-
else
|
|
226
|
-
chat.on_tool_call do |tool_call|
|
|
227
|
-
raise Phronomy::Agent::ToolCallIntercepted.new(tool_call)
|
|
228
|
-
end
|
|
240
|
+
chat.before_tool_call do |tool_call|
|
|
241
|
+
raise build_tool_interception(chat, [tool_call], invocation)
|
|
229
242
|
end
|
|
230
243
|
end
|
|
231
244
|
private_class_method :install_tool_interceptors
|
|
232
245
|
|
|
246
|
+
def self.build_tool_interception(chat, fallback_tool_calls, invocation)
|
|
247
|
+
assistant_message = chat.messages.last
|
|
248
|
+
unless assistant_message&.respond_to?(:role) &&
|
|
249
|
+
assistant_message.role.to_sym == :assistant &&
|
|
250
|
+
assistant_message.respond_to?(:tool_calls)
|
|
251
|
+
raise Phronomy::Error,
|
|
252
|
+
"RubyLLM Tool callback fired before the complete assistant message was observable"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
message_tool_calls = assistant_message.tool_calls
|
|
256
|
+
tool_calls = if message_tool_calls.respond_to?(:values)
|
|
257
|
+
message_tool_calls.values
|
|
258
|
+
else
|
|
259
|
+
Array(message_tool_calls)
|
|
260
|
+
end
|
|
261
|
+
tool_calls = Array(fallback_tool_calls) if tool_calls.empty?
|
|
262
|
+
|
|
263
|
+
ToolCallIntercepted.new(
|
|
264
|
+
tool_calls,
|
|
265
|
+
assistant_message: assistant_message,
|
|
266
|
+
assistant_outcome: ProviderCallOutcome.capture(assistant_message),
|
|
267
|
+
llm_call_id: invocation&.current_llm_call_id
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
private_class_method :build_tool_interception
|
|
271
|
+
|
|
233
272
|
def self.calling_llm_action(agent, runtime, invocation)
|
|
234
273
|
user_message = invocation.user_message_sent ?
|
|
235
274
|
nil :
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Agent
|
|
7
|
+
class AgentRoot
|
|
8
|
+
LIFECYCLE_STATUSES = %i[idle active suspended closed invalidated].freeze
|
|
9
|
+
|
|
10
|
+
ATTRIBUTES = %i[
|
|
11
|
+
agent_id agent_definition_id definition_version agent_revision
|
|
12
|
+
context_revision journal_position lifecycle_status transcript_generation memory_generation
|
|
13
|
+
created_at updated_at metadata
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader(*ATTRIBUTES)
|
|
17
|
+
|
|
18
|
+
def self.create(agent_id:, agent_definition_id:, definition_version:, metadata: {})
|
|
19
|
+
now = Time.now.utc.iso8601(6)
|
|
20
|
+
new(
|
|
21
|
+
agent_id: agent_id,
|
|
22
|
+
agent_definition_id: agent_definition_id,
|
|
23
|
+
definition_version: definition_version,
|
|
24
|
+
agent_revision: 0,
|
|
25
|
+
context_revision: 0,
|
|
26
|
+
journal_position: 0,
|
|
27
|
+
lifecycle_status: :idle,
|
|
28
|
+
transcript_generation: 0,
|
|
29
|
+
memory_generation: 0,
|
|
30
|
+
created_at: now,
|
|
31
|
+
updated_at: now,
|
|
32
|
+
metadata: metadata
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(**attributes)
|
|
37
|
+
ATTRIBUTES.each do |name|
|
|
38
|
+
value = attributes.fetch(name)
|
|
39
|
+
value = value.to_sym if name == :lifecycle_status
|
|
40
|
+
instance_variable_set("@#{name}", Immutable.copy(value))
|
|
41
|
+
end
|
|
42
|
+
unless LIFECYCLE_STATUSES.include?(lifecycle_status)
|
|
43
|
+
raise ArgumentError, "unknown Agent lifecycle status: #{lifecycle_status.inspect}"
|
|
44
|
+
end
|
|
45
|
+
raise ArgumentError, "agent_revision must be non-negative" if agent_revision.negative?
|
|
46
|
+
raise ArgumentError, "context_revision must be non-negative" if context_revision.negative?
|
|
47
|
+
raise ArgumentError, "journal_position must be non-negative" if journal_position.negative?
|
|
48
|
+
Immutable.validate_canonical_json!(metadata, label: "Agent metadata")
|
|
49
|
+
freeze
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def with(**changes)
|
|
53
|
+
values = to_h.transform_keys(&:to_sym).merge(changes)
|
|
54
|
+
values[:updated_at] = Time.now.utc.iso8601(6) unless changes.key?(:updated_at)
|
|
55
|
+
self.class.new(**values)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def to_h
|
|
59
|
+
ATTRIBUTES.to_h { |name| [name.to_s, public_send(name)] }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.from_h(hash)
|
|
63
|
+
new(**ATTRIBUTES.to_h { |name| [name, hash.fetch(name.to_s)] })
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|