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
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# Adapts the current AgentInvocation FSM to the ideal manifest-first model.
|
|
6
|
+
# Every LLM Call receives a freshly fixed Manifest. Persistence and
|
|
7
|
+
# projection preparation run on BlockingAdapterPool, never on EventLoop.
|
|
8
|
+
module FsmRuntimeAdapter
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def filtering_input_action(_agent, invocation)
|
|
12
|
+
invocation.input = invocation.config.fetch(:phronomy_filtered_input)
|
|
13
|
+
invocation
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def building_context_action(agent, invocation)
|
|
17
|
+
projection = invocation.config.fetch(:phronomy_runtime_projection)
|
|
18
|
+
invocation.chat = agent.send(:build_chat, model_config: projection.model_config)
|
|
19
|
+
agent.send(
|
|
20
|
+
:_apply_context_to_chat,
|
|
21
|
+
invocation.chat,
|
|
22
|
+
{
|
|
23
|
+
system: projection.system,
|
|
24
|
+
messages: projection.messages,
|
|
25
|
+
tool_classes: projection.tool_classes,
|
|
26
|
+
model_config: projection.model_config
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
install_tool_interceptors(invocation.chat, invocation)
|
|
30
|
+
invocation
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def calling_llm_action(agent, runtime, invocation)
|
|
34
|
+
prepare_and_start_llm_call(
|
|
35
|
+
agent,
|
|
36
|
+
runtime,
|
|
37
|
+
invocation,
|
|
38
|
+
streaming: false
|
|
39
|
+
)
|
|
40
|
+
invocation
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def calling_llm_stream_action(agent, runtime, invocation)
|
|
44
|
+
prepare_and_start_llm_call(
|
|
45
|
+
agent,
|
|
46
|
+
runtime,
|
|
47
|
+
invocation,
|
|
48
|
+
streaming: true
|
|
49
|
+
)
|
|
50
|
+
invocation
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def prepare_and_start_llm_call(agent, runtime, invocation, streaming:)
|
|
54
|
+
activation = invocation.config.fetch(:phronomy_activation)
|
|
55
|
+
if invocation.user_message_sent
|
|
56
|
+
preparation = runtime.blocking_io.submit do
|
|
57
|
+
activation.coordinator.prepare_next_llm_call(activation)
|
|
58
|
+
end
|
|
59
|
+
preparation.on_complete do |projection, error|
|
|
60
|
+
if error
|
|
61
|
+
post_preparation_failure(runtime, invocation, error, streaming: streaming)
|
|
62
|
+
else
|
|
63
|
+
start_provider_call(
|
|
64
|
+
agent,
|
|
65
|
+
runtime,
|
|
66
|
+
invocation,
|
|
67
|
+
activation,
|
|
68
|
+
projection,
|
|
69
|
+
streaming: streaming,
|
|
70
|
+
replace_messages: true
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
else
|
|
75
|
+
start_provider_call(
|
|
76
|
+
agent,
|
|
77
|
+
runtime,
|
|
78
|
+
invocation,
|
|
79
|
+
activation,
|
|
80
|
+
activation.runtime_projection,
|
|
81
|
+
streaming: streaming,
|
|
82
|
+
replace_messages: false
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def start_provider_call(
|
|
88
|
+
agent,
|
|
89
|
+
runtime,
|
|
90
|
+
invocation,
|
|
91
|
+
activation,
|
|
92
|
+
projection,
|
|
93
|
+
streaming:,
|
|
94
|
+
replace_messages:
|
|
95
|
+
)
|
|
96
|
+
call_started = false
|
|
97
|
+
agent.send(
|
|
98
|
+
:check_cancellation!,
|
|
99
|
+
invocation.config,
|
|
100
|
+
"invocation cancelled before LLM call"
|
|
101
|
+
)
|
|
102
|
+
if replace_messages
|
|
103
|
+
invocation.chat = agent.send(:build_chat, model_config: projection.model_config)
|
|
104
|
+
agent.send(
|
|
105
|
+
:_apply_context_to_chat,
|
|
106
|
+
invocation.chat,
|
|
107
|
+
{
|
|
108
|
+
system: projection.system,
|
|
109
|
+
messages: projection.messages,
|
|
110
|
+
tool_classes: projection.tool_classes,
|
|
111
|
+
model_config: projection.model_config
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
install_tool_interceptors(invocation.chat, invocation)
|
|
115
|
+
invocation.config[:phronomy_runtime_projection] = projection
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
call_context = activation.begin_llm_call(projection)
|
|
119
|
+
call_started = true
|
|
120
|
+
invocation.begin_llm_call!(call_context.fetch(:llm_call_id))
|
|
121
|
+
message = projection.ask_message
|
|
122
|
+
|
|
123
|
+
operation = if streaming
|
|
124
|
+
Phronomy.configuration.llm_adapter.stream_async(
|
|
125
|
+
invocation.chat,
|
|
126
|
+
message,
|
|
127
|
+
config: invocation.config
|
|
128
|
+
) do |chunk|
|
|
129
|
+
agent.send(
|
|
130
|
+
:check_cancellation!,
|
|
131
|
+
invocation.config,
|
|
132
|
+
"invocation cancelled during streaming"
|
|
133
|
+
)
|
|
134
|
+
post_to_invocation!(
|
|
135
|
+
runtime,
|
|
136
|
+
invocation.id,
|
|
137
|
+
:llm_stream_chunk,
|
|
138
|
+
{content: chunk.content}
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
else
|
|
142
|
+
Phronomy.configuration.llm_adapter.complete_async(
|
|
143
|
+
invocation.chat,
|
|
144
|
+
message,
|
|
145
|
+
config: invocation.config
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
observe_manifest_call(
|
|
149
|
+
operation,
|
|
150
|
+
activation,
|
|
151
|
+
invocation,
|
|
152
|
+
runtime: runtime,
|
|
153
|
+
streaming: streaming
|
|
154
|
+
)
|
|
155
|
+
rescue => error
|
|
156
|
+
if call_started
|
|
157
|
+
activation.record_llm_result(
|
|
158
|
+
response: canonical_response_for(nil, error),
|
|
159
|
+
error: error,
|
|
160
|
+
streaming: streaming
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
post_llm_result(runtime, invocation, nil, error, streaming: streaming)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def observe_manifest_call(operation, activation, invocation, runtime:, streaming:)
|
|
167
|
+
operation.on_complete do |response, error|
|
|
168
|
+
activation.record_llm_result(
|
|
169
|
+
response: canonical_response_for(response, error),
|
|
170
|
+
error: error,
|
|
171
|
+
streaming: streaming
|
|
172
|
+
)
|
|
173
|
+
post_llm_result(
|
|
174
|
+
runtime,
|
|
175
|
+
invocation,
|
|
176
|
+
response,
|
|
177
|
+
error,
|
|
178
|
+
streaming: streaming
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def canonical_response_for(response, error)
|
|
184
|
+
if error.is_a?(ToolCallIntercepted)
|
|
185
|
+
error.assistant_outcome || ProviderCallOutcome.capture(response)
|
|
186
|
+
else
|
|
187
|
+
ProviderCallOutcome.capture(response)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def post_preparation_failure(runtime, invocation, error, streaming:)
|
|
192
|
+
post_llm_result(runtime, invocation, nil, error, streaming: streaming)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def post_llm_result(runtime, invocation, response, error, streaming:)
|
|
196
|
+
result = LLMOperationResult.new(
|
|
197
|
+
response: response,
|
|
198
|
+
error: error,
|
|
199
|
+
streaming: streaming
|
|
200
|
+
)
|
|
201
|
+
event_type = if error && !error.is_a?(ToolCallIntercepted)
|
|
202
|
+
:llm_failed
|
|
203
|
+
else
|
|
204
|
+
:llm_completed
|
|
205
|
+
end
|
|
206
|
+
post_to_invocation!(runtime, invocation.id, event_type, result)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Immutable
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def copy(value)
|
|
9
|
+
case value
|
|
10
|
+
when Hash
|
|
11
|
+
value.each_with_object({}) do |(key, child), result|
|
|
12
|
+
result[copy(key)] = copy(child)
|
|
13
|
+
end.freeze
|
|
14
|
+
when Array
|
|
15
|
+
value.map { |child| copy(child) }.freeze
|
|
16
|
+
when String
|
|
17
|
+
value.dup.freeze
|
|
18
|
+
else
|
|
19
|
+
value
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def validate_canonical_json!(value, label: "value")
|
|
24
|
+
Phronomy::CanonicalJSON.dump(value)
|
|
25
|
+
true
|
|
26
|
+
rescue ArgumentError => error
|
|
27
|
+
raise ArgumentError, "#{label} is not canonical JSON compatible: #{error.message}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
class JournalProjection
|
|
6
|
+
MESSAGE_KINDS = %i[
|
|
7
|
+
external_message assistant_message tool_message
|
|
8
|
+
llm_message tool_call tool_result
|
|
9
|
+
].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(persistence:, agent_root:)
|
|
12
|
+
@persistence = persistence
|
|
13
|
+
@agent_root = agent_root
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def transcript_records
|
|
17
|
+
generation = @agent_root.transcript_generation
|
|
18
|
+
records.select do |record|
|
|
19
|
+
record.context_candidate &&
|
|
20
|
+
record.context_generation == generation &&
|
|
21
|
+
MESSAGE_KINDS.include?(record.kind)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def llm_call_records
|
|
26
|
+
records.select { |record| record.kind == :llm_call_recorded }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def records
|
|
30
|
+
@records ||= @persistence.journals.read(@agent_root.agent_id)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Phronomy
|
|
7
|
+
module Agent
|
|
8
|
+
# One immutable entry in the Agent's append-only Canonical Complete Execution Log.
|
|
9
|
+
#
|
|
10
|
+
# Raw logical execution facts are never rewritten for Context selection or
|
|
11
|
+
# compaction. Derived Context artifacts must be appended separately and keep
|
|
12
|
+
# stable provenance back to their raw source records.
|
|
13
|
+
class JournalRecord
|
|
14
|
+
ATTRIBUTES = %i[
|
|
15
|
+
record_id agent_id sequence execution_id llm_call_id kind channel role content_ref
|
|
16
|
+
parent_id causation_id correlation_id visibility context_generation
|
|
17
|
+
context_candidate occurred_at metadata
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
attr_reader(*ATTRIBUTES)
|
|
21
|
+
|
|
22
|
+
def initialize(
|
|
23
|
+
agent_id:,
|
|
24
|
+
kind:,
|
|
25
|
+
channel:,
|
|
26
|
+
record_id: SecureRandom.uuid,
|
|
27
|
+
sequence: nil,
|
|
28
|
+
execution_id: nil,
|
|
29
|
+
llm_call_id: nil,
|
|
30
|
+
role: nil,
|
|
31
|
+
content_ref: nil,
|
|
32
|
+
parent_id: nil,
|
|
33
|
+
causation_id: nil,
|
|
34
|
+
correlation_id: nil,
|
|
35
|
+
visibility: :agent,
|
|
36
|
+
context_generation: 0,
|
|
37
|
+
context_candidate: false,
|
|
38
|
+
occurred_at: Time.now.utc.iso8601(6),
|
|
39
|
+
metadata: {}
|
|
40
|
+
)
|
|
41
|
+
values = binding.local_variables.to_h { |name| [name, binding.local_variable_get(name)] }
|
|
42
|
+
ATTRIBUTES.each do |name|
|
|
43
|
+
value = values.fetch(name)
|
|
44
|
+
value = value.to_sym if %i[kind channel role visibility].include?(name) && value
|
|
45
|
+
instance_variable_set("@#{name}", Immutable.copy(value))
|
|
46
|
+
end
|
|
47
|
+
Immutable.validate_canonical_json!(metadata, label: "Journal metadata")
|
|
48
|
+
freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def with_sequence(sequence)
|
|
52
|
+
self.class.from_h(to_h.merge("sequence" => sequence))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def to_h
|
|
56
|
+
ATTRIBUTES.to_h { |name| [name.to_s, public_send(name)] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.from_h(hash)
|
|
60
|
+
new(**ATTRIBUTES.to_h do |name|
|
|
61
|
+
key = hash.key?(name.to_s) ? name.to_s : name
|
|
62
|
+
[name, hash[key]]
|
|
63
|
+
end)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Phronomy
|
|
7
|
+
module Agent
|
|
8
|
+
class LLMCallRecord
|
|
9
|
+
STATUSES = %i[completed failed cancelled].freeze
|
|
10
|
+
|
|
11
|
+
ATTRIBUTES = %i[
|
|
12
|
+
llm_call_id execution_id sequence status manifest_ref output_ref
|
|
13
|
+
error_ref usage_ref started_at completed_at metadata
|
|
14
|
+
].freeze
|
|
15
|
+
attr_reader(*ATTRIBUTES)
|
|
16
|
+
|
|
17
|
+
def initialize(
|
|
18
|
+
execution_id:,
|
|
19
|
+
sequence:,
|
|
20
|
+
status:,
|
|
21
|
+
manifest_ref:,
|
|
22
|
+
llm_call_id: SecureRandom.uuid,
|
|
23
|
+
output_ref: nil,
|
|
24
|
+
error_ref: nil,
|
|
25
|
+
usage_ref: nil,
|
|
26
|
+
started_at: Time.now.utc.iso8601(6),
|
|
27
|
+
completed_at: nil,
|
|
28
|
+
metadata: {}
|
|
29
|
+
)
|
|
30
|
+
values = binding.local_variables.to_h { |name| [name, binding.local_variable_get(name)] }
|
|
31
|
+
ATTRIBUTES.each do |name|
|
|
32
|
+
value = values.fetch(name)
|
|
33
|
+
value = value.to_sym if name == :status
|
|
34
|
+
instance_variable_set("@#{name}", Immutable.copy(value))
|
|
35
|
+
end
|
|
36
|
+
raise ArgumentError, "unknown LLM Call status: #{status.inspect}" unless STATUSES.include?(status)
|
|
37
|
+
raise ArgumentError, "LLM Call sequence must be positive" unless sequence.positive?
|
|
38
|
+
Immutable.validate_canonical_json!(metadata, label: "LLM Call metadata")
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_h
|
|
43
|
+
ATTRIBUTES.to_h do |name|
|
|
44
|
+
value = public_send(name)
|
|
45
|
+
value = value.to_s if name == :status
|
|
46
|
+
[name.to_s, value]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# Immutable metadata passed to before_llm_input hooks. Runtime objects such
|
|
6
|
+
# as RubyLLM::Chat, messages and the mutable Agent instance are not exposed.
|
|
7
|
+
LLMInputBuildContext = Data.define(
|
|
8
|
+
:agent_id, :agent_definition_id, :definition_version,
|
|
9
|
+
:config, :call_sequence
|
|
10
|
+
) do
|
|
11
|
+
def initialize(**values)
|
|
12
|
+
super(**values.merge(config: Immutable.copy(values.fetch(:config, {}))))
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
class LLMInputManifest
|
|
6
|
+
VERSION = 1
|
|
7
|
+
CALL_MODES = %i[ask complete].freeze
|
|
8
|
+
|
|
9
|
+
Segment = Data.define(
|
|
10
|
+
:position, :category, :role, :content_ref, :delivery,
|
|
11
|
+
:tool_call_id, :metadata
|
|
12
|
+
) do
|
|
13
|
+
def initialize(**values)
|
|
14
|
+
super(**values.merge(metadata: Immutable.copy(values[:metadata] || {})))
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_h
|
|
19
|
+
{
|
|
20
|
+
"position" => position,
|
|
21
|
+
"category" => category.to_s,
|
|
22
|
+
"role" => role&.to_s,
|
|
23
|
+
"content_ref" => content_ref,
|
|
24
|
+
"delivery" => delivery.to_s,
|
|
25
|
+
"tool_call_id" => tool_call_id,
|
|
26
|
+
"metadata" => metadata
|
|
27
|
+
}.compact
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader :version, :call_sequence, :call_mode,
|
|
32
|
+
:assembly_policy_version, :segments,
|
|
33
|
+
:model_config_ref, :tool_definitions_ref, :response_schema_ref,
|
|
34
|
+
:ruby_llm_version, :adapter_name, :adapter_version
|
|
35
|
+
|
|
36
|
+
def initialize(
|
|
37
|
+
call_sequence:,
|
|
38
|
+
call_mode:,
|
|
39
|
+
segments:,
|
|
40
|
+
model_config_ref:,
|
|
41
|
+
tool_definitions_ref: nil,
|
|
42
|
+
response_schema_ref: nil,
|
|
43
|
+
assembly_policy_version: 1,
|
|
44
|
+
ruby_llm_version: nil,
|
|
45
|
+
adapter_name: nil,
|
|
46
|
+
adapter_version: nil,
|
|
47
|
+
version: VERSION
|
|
48
|
+
)
|
|
49
|
+
@version = Integer(version)
|
|
50
|
+
@call_sequence = Integer(call_sequence)
|
|
51
|
+
@call_mode = call_mode.to_sym
|
|
52
|
+
@assembly_policy_version = Integer(assembly_policy_version)
|
|
53
|
+
@segments = Array(segments).sort_by(&:position).freeze
|
|
54
|
+
@model_config_ref = model_config_ref.to_s.freeze
|
|
55
|
+
@tool_definitions_ref = tool_definitions_ref&.to_s&.freeze
|
|
56
|
+
@response_schema_ref = response_schema_ref&.to_s&.freeze
|
|
57
|
+
@ruby_llm_version = ruby_llm_version&.to_s&.freeze
|
|
58
|
+
@adapter_name = adapter_name&.to_s&.freeze
|
|
59
|
+
@adapter_version = adapter_version&.to_s&.freeze
|
|
60
|
+
validate!
|
|
61
|
+
freeze
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def referenced_content_refs
|
|
65
|
+
([model_config_ref, tool_definitions_ref, response_schema_ref] +
|
|
66
|
+
segments.map(&:content_ref)).compact.uniq.freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def to_h
|
|
70
|
+
{
|
|
71
|
+
"version" => version,
|
|
72
|
+
"call_sequence" => call_sequence,
|
|
73
|
+
"call_mode" => call_mode.to_s,
|
|
74
|
+
"assembly_policy_version" => assembly_policy_version,
|
|
75
|
+
"segments" => segments.map(&:to_h),
|
|
76
|
+
"model_config_ref" => model_config_ref,
|
|
77
|
+
"tool_definitions_ref" => tool_definitions_ref,
|
|
78
|
+
"response_schema_ref" => response_schema_ref,
|
|
79
|
+
"ruby_llm_version" => ruby_llm_version,
|
|
80
|
+
"adapter_name" => adapter_name,
|
|
81
|
+
"adapter_version" => adapter_version
|
|
82
|
+
}.compact
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def validate!
|
|
88
|
+
raise ArgumentError, "invalid manifest call mode: #{call_mode.inspect}" unless CALL_MODES.include?(call_mode)
|
|
89
|
+
raise ArgumentError, "call_sequence must be positive" unless call_sequence.positive?
|
|
90
|
+
expected = (0...segments.length).to_a
|
|
91
|
+
actual = segments.map(&:position)
|
|
92
|
+
raise ArgumentError, "manifest positions must be contiguous: #{actual.inspect}" unless actual == expected
|
|
93
|
+
|
|
94
|
+
ask_count = segments.count { |segment| segment.delivery == :ask_argument }
|
|
95
|
+
expected_ask_count = (call_mode == :ask) ? 1 : 0
|
|
96
|
+
unless ask_count == expected_ask_count
|
|
97
|
+
raise ArgumentError,
|
|
98
|
+
"#{call_mode} manifest requires #{expected_ask_count} ask_argument segment(s), got #{ask_count}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# Typed, canonicalizable input customization returned by before_llm_input.
|
|
6
|
+
# v1 deliberately exposes only fields that are fully applied to every call.
|
|
7
|
+
LLMInputPatch = Data.define(:model_config_patch, :segment_candidates) do
|
|
8
|
+
def initialize(model_config_patch: nil, segment_candidates: nil)
|
|
9
|
+
super(
|
|
10
|
+
model_config_patch: model_config_patch && Immutable.copy(model_config_patch),
|
|
11
|
+
segment_candidates: segment_candidates && Immutable.copy(Array(segment_candidates))
|
|
12
|
+
)
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.empty
|
|
17
|
+
new
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -99,6 +99,18 @@ module Phronomy
|
|
|
99
99
|
transition suspended: :waiting_for_tools
|
|
100
100
|
end
|
|
101
101
|
|
|
102
|
+
event :application_callback_failed do
|
|
103
|
+
transition filtering_input: :failed
|
|
104
|
+
transition building_context: :failed
|
|
105
|
+
transition calling_llm: :failed
|
|
106
|
+
transition starting_tools: :failed
|
|
107
|
+
transition evaluating_tools: :failed
|
|
108
|
+
transition waiting_for_tools: :failed
|
|
109
|
+
transition dispatching_tools: :failed
|
|
110
|
+
transition recording_tool_results: :failed
|
|
111
|
+
transition output_filtering: :failed
|
|
112
|
+
end
|
|
113
|
+
|
|
102
114
|
entry_actions.each do |state_name, callables|
|
|
103
115
|
callables.each do |callable|
|
|
104
116
|
after_transition(
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
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
|
+
ProviderCallOutcome = Data.define(:role, :content, :tool_calls, :usage, :metadata) do
|
|
9
|
+
def self.capture(message)
|
|
10
|
+
return if message.nil?
|
|
11
|
+
|
|
12
|
+
calls = if message.respond_to?(:tool_calls) && message.tool_calls
|
|
13
|
+
source = message.tool_calls.respond_to?(:values) ? message.tool_calls.values : Array(message.tool_calls)
|
|
14
|
+
source.map { |call| normalize(tool_call_hash(call)) }
|
|
15
|
+
else
|
|
16
|
+
[]
|
|
17
|
+
end
|
|
18
|
+
usage = if message.respond_to?(:tokens) && message.tokens
|
|
19
|
+
normalize(message.tokens.respond_to?(:to_h) ? message.tokens.to_h : message.tokens)
|
|
20
|
+
else
|
|
21
|
+
{}
|
|
22
|
+
end
|
|
23
|
+
metadata = {}
|
|
24
|
+
metadata["model_id"] = message.model_id.to_s if message.respond_to?(:model_id) && message.model_id
|
|
25
|
+
|
|
26
|
+
new(
|
|
27
|
+
role: message.respond_to?(:role) ? message.role : :assistant,
|
|
28
|
+
content: normalize(message.respond_to?(:content) ? message.content : nil),
|
|
29
|
+
tool_calls: calls,
|
|
30
|
+
usage: usage,
|
|
31
|
+
metadata: metadata
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.tool_call_hash(call)
|
|
36
|
+
return call.to_h if call.respond_to?(:to_h)
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
"id" => call.respond_to?(:id) ? call.id : nil,
|
|
40
|
+
"name" => call.respond_to?(:name) ? call.name : nil,
|
|
41
|
+
"arguments" => call.respond_to?(:arguments) ? call.arguments : {}
|
|
42
|
+
}.compact
|
|
43
|
+
end
|
|
44
|
+
private_class_method :tool_call_hash
|
|
45
|
+
|
|
46
|
+
def self.normalize(value)
|
|
47
|
+
case value
|
|
48
|
+
when Hash
|
|
49
|
+
value.to_h { |key, child| [key.to_s, normalize(child)] }
|
|
50
|
+
when Array
|
|
51
|
+
value.map { |child| normalize(child) }
|
|
52
|
+
when Symbol
|
|
53
|
+
value.to_s
|
|
54
|
+
when String, Integer, Float, TrueClass, FalseClass, NilClass
|
|
55
|
+
value
|
|
56
|
+
else
|
|
57
|
+
if value.respond_to?(:to_h)
|
|
58
|
+
normalize(value.to_h)
|
|
59
|
+
else
|
|
60
|
+
value.to_s
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
private_class_method :normalize
|
|
65
|
+
|
|
66
|
+
def initialize(role:, content:, tool_calls:, usage: {}, metadata: {})
|
|
67
|
+
super(
|
|
68
|
+
role: role&.to_sym,
|
|
69
|
+
content: Immutable.copy(content),
|
|
70
|
+
tool_calls: Immutable.copy(Array(tool_calls)),
|
|
71
|
+
usage: Immutable.copy(usage || {}),
|
|
72
|
+
metadata: Immutable.copy(metadata || {})
|
|
73
|
+
)
|
|
74
|
+
Immutable.validate_canonical_json!(content, label: "Provider assistant content")
|
|
75
|
+
Immutable.validate_canonical_json!(tool_calls, label: "Provider Tool Calls")
|
|
76
|
+
Immutable.validate_canonical_json!(usage, label: "Provider usage")
|
|
77
|
+
Immutable.validate_canonical_json!(metadata, label: "Provider outcome metadata")
|
|
78
|
+
freeze
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def content_present?
|
|
82
|
+
!content.nil? && !(content.respond_to?(:empty?) && content.empty?)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def tool_call?
|
|
86
|
+
!tool_calls.empty?
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|