phronomy 0.15.0 → 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 +107 -18
- 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 -393
- data/lib/phronomy/agent/base.rb +301 -641
- 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 -47
- 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
|
@@ -40,6 +40,7 @@ class BenchStubChat
|
|
|
40
40
|
def with_cache_instructions(_) = self
|
|
41
41
|
def with_output_schema(_) = self
|
|
42
42
|
def on_tool_call(&) = self
|
|
43
|
+
def before_tool_call(&) = self
|
|
43
44
|
def last_message = @response
|
|
44
45
|
|
|
45
46
|
def ask(_)
|
|
@@ -71,12 +72,14 @@ BENCH_RESP = BenchAgentMessage.assistant("benchmark complete")
|
|
|
71
72
|
BENCH_RESP_CHAT = BenchStubChat.new(BENCH_RESP)
|
|
72
73
|
|
|
73
74
|
bench_minimal_class = Class.new(Phronomy::Agent::Base) do
|
|
75
|
+
agent_definition id: "bench-minimal", version: 1
|
|
74
76
|
model "stub-model"
|
|
75
77
|
|
|
76
78
|
define_method(:build_chat) { |*| BenchStubChat.new(BENCH_RESP) }
|
|
77
79
|
end
|
|
78
80
|
|
|
79
81
|
bench_tool_class = Class.new(Phronomy::Agent::Base) do
|
|
82
|
+
agent_definition id: "bench-tool", version: 1
|
|
80
83
|
model "stub-model"
|
|
81
84
|
tools BenchNullTool
|
|
82
85
|
|
|
@@ -88,6 +88,7 @@ end
|
|
|
88
88
|
# Target 4: Orchestrator#dispatch_parallel overhead (10 stub agents, no LLM)
|
|
89
89
|
# ---------------------------------------------------------------------------
|
|
90
90
|
stub_agent_class = Class.new(Phronomy::Agent::Base) do
|
|
91
|
+
agent_definition id: "bench-stub", version: 1
|
|
91
92
|
define_method(:invoke) do |_input, messages: [], thread_id: nil, config: {}|
|
|
92
93
|
{output: "stub", messages: []}
|
|
93
94
|
end
|
|
@@ -129,22 +130,6 @@ t6 = Benchmark.measure("CancellationToken#raise_if_cancelled! (no-op)") do
|
|
|
129
130
|
RAISE_ITERATIONS.times { RAISE_TOKEN.raise_if_cancelled! }
|
|
130
131
|
end
|
|
131
132
|
|
|
132
|
-
# ---------------------------------------------------------------------------
|
|
133
|
-
# Target 7: Agent::Base#trim_messages on a 2000-message history
|
|
134
|
-
# ---------------------------------------------------------------------------
|
|
135
|
-
BenchMsg = Struct.new(:content) unless defined?(BenchMsg)
|
|
136
|
-
|
|
137
|
-
TRIM_MESSAGES = Array.new(2_000) { |i| BenchMsg.new("msg #{i}") }
|
|
138
|
-
TRIM_ITERATIONS = 500
|
|
139
|
-
|
|
140
|
-
bench_trim_agent = Class.new(Phronomy::Agent::Base).new
|
|
141
|
-
|
|
142
|
-
t7 = Benchmark.measure("Agent::Base#trim_messages (2000-msg history)") do
|
|
143
|
-
TRIM_ITERATIONS.times do
|
|
144
|
-
bench_trim_agent.send(:trim_messages, TRIM_MESSAGES, keep: 1_800)
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
|
|
148
133
|
# ---------------------------------------------------------------------------
|
|
149
134
|
# Print results and store in REGRESSION_RESULTS
|
|
150
135
|
# ---------------------------------------------------------------------------
|
|
@@ -158,8 +143,7 @@ metrics = {
|
|
|
158
143
|
"tool_params_schema_definition" => [t3, REGRESSION_ITERATIONS],
|
|
159
144
|
"dispatch_parallel_10" => [t4, PARALLEL_ITERATIONS],
|
|
160
145
|
"cancellation_token_cancelled" => [t5, 8 * CANCEL_ITERATIONS],
|
|
161
|
-
"cancellation_token_raise_if_cancelled_noop" => [t6, RAISE_ITERATIONS]
|
|
162
|
-
"trim_messages_2000" => [t7, TRIM_ITERATIONS]
|
|
146
|
+
"cancellation_token_raise_if_cancelled_noop" => [t6, RAISE_ITERATIONS]
|
|
163
147
|
}
|
|
164
148
|
|
|
165
149
|
REGRESSION_RESULTS = {} # rubocop:disable Style/MutableConstant
|
|
@@ -54,6 +54,7 @@ class BenchKnowledgeSource < Phronomy::Agent::Context::Knowledge::Base
|
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
class BenchAgentWithKnowledge < Phronomy::Agent::Base
|
|
57
|
+
agent_definition id: "bench-knowledge", version: 1
|
|
57
58
|
model "gpt-4o-mini"
|
|
58
59
|
static_knowledge BenchKnowledgeSource.new
|
|
59
60
|
end
|
|
@@ -2,7 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
## Status
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Partially Superseded by ADR-012 — 2026-08-08
|
|
6
|
+
|
|
7
|
+
Originally proposed — 2026-05-31.
|
|
8
|
+
|
|
9
|
+
## Supersession Note
|
|
10
|
+
|
|
11
|
+
ADR-012, **Canonical Complete Execution Log and Context Policy**, supersedes the architectural parts of this ADR that treat the legacy `build_context` / `LlmContextWindow::Assembler` path as the long-term single authority for LLM input.
|
|
12
|
+
|
|
13
|
+
In particular, the following parts of this ADR are no longer normative for the stateful Agent architecture:
|
|
14
|
+
|
|
15
|
+
* **D1 — `build_context` is the single authority for all LLM input**
|
|
16
|
+
* **D2 — Assembler handles all four regions including Capability**
|
|
17
|
+
* **D3 — `build_context` includes all tools**
|
|
18
|
+
* **D5 — Previous context stored as an instance variable**
|
|
19
|
+
|
|
20
|
+
The replacement authority model is:
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
Canonical Journal
|
|
24
|
+
↓
|
|
25
|
+
Context Policy
|
|
26
|
+
↓
|
|
27
|
+
LLM Call Manifest
|
|
28
|
+
↓
|
|
29
|
+
Runtime Projection
|
|
30
|
+
↓
|
|
31
|
+
RubyLLM / Provider
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Under ADR-012:
|
|
35
|
+
|
|
36
|
+
* the **Journal** is authoritative for logical execution facts observed by Phronomy;
|
|
37
|
+
* the **Manifest** is authoritative for the logical input fixed for one specific LLM Call;
|
|
38
|
+
* Context Policy determines which canonical history is selected for that Manifest;
|
|
39
|
+
* selection, pruning, and compaction do not delete or rewrite the canonical Journal;
|
|
40
|
+
* Tool protocol dependencies are preserved independently from semantic Context-selection policy.
|
|
41
|
+
|
|
42
|
+
The problem statements and historical analysis in this ADR remain useful as design history. Decisions or implementation notes that do not conflict with ADR-012 may still describe valid constraints, but ADR-012 is authoritative whenever the two documents differ.
|
|
43
|
+
|
|
44
|
+
See: `012-canonical-execution-log-and-context-policy.md`
|
|
6
45
|
|
|
7
46
|
## Context
|
|
8
47
|
|
|
@@ -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
|