phronomy 0.15.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -9
- data/CHANGELOG.md +159 -28
- data/CONTRIBUTING.md +28 -16
- data/README.md +400 -143
- data/benchmark/baseline.json +2 -3
- data/benchmark/bench_agent_invoke.rb +7 -4
- data/benchmark/bench_context_assembler.rb +134 -34
- data/benchmark/bench_regression.rb +3 -19
- data/benchmark/bench_tool_schema.rb +2 -34
- data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
- data/docs/decisions/010-cooperative-first-concurrency.md +7 -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/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -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 +44 -46
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +206 -104
- data/lib/phronomy/agent/agent_root.rb +66 -0
- data/lib/phronomy/agent/async_event_api.rb +55 -475
- data/lib/phronomy/agent/base.rb +351 -514
- data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
- data/lib/phronomy/agent/context/capability/base.rb +166 -297
- data/lib/phronomy/agent/context_assembler.rb +357 -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 +118 -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 +134 -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 +1122 -0
- data/lib/phronomy/agent/immutable.rb +31 -0
- data/lib/phronomy/agent/journal_projection.rb +60 -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 +189 -0
- data/lib/phronomy/agent/shared_state.rb +46 -138
- data/lib/phronomy/agent/token_budget_resolver.rb +70 -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/tool_invocation.rb +108 -314
- data/lib/phronomy/agent.rb +10 -16
- data/lib/phronomy/agent_busy_error.rb +5 -0
- data/lib/phronomy/canonical_json.rb +136 -0
- data/lib/phronomy/configuration.rb +17 -155
- data/lib/phronomy/content_store/base.rb +51 -0
- data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
- data/lib/phronomy/engine/event_loop.rb +3 -0
- data/lib/phronomy/engine/runtime.rb +15 -230
- data/lib/phronomy/engine/task_group.rb +30 -102
- 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/token_budget.rb +8 -79
- data/lib/phronomy/multi_agent/orchestrator.rb +153 -204
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
- data/lib/phronomy/multi_agent/team_coordinator.rb +46 -133
- 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/vector_store/in_memory.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +9 -115
- data/scripts/add_to_h_to_token_doubles.rb +33 -0
- data/scripts/add_to_h_unnamed_doubles.rb +27 -0
- data/scripts/api_snapshot.rb +1 -12
- 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 -13
- 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
- data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
- data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
- data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
- data/lib/phronomy/knowledge_source.rb +0 -12
- data/lib/phronomy/llm_context_window/assembler.rb +0 -191
- data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
|
@@ -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,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
class JournalProjection
|
|
6
|
+
MESSAGE_KINDS = %i[
|
|
7
|
+
external_message
|
|
8
|
+
assistant_message
|
|
9
|
+
tool_message
|
|
10
|
+
].freeze
|
|
11
|
+
KNOWLEDGE_RESET_KINDS = %i[knowledge_cleared context_reset].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(persistence:, agent_root:)
|
|
14
|
+
@persistence = persistence
|
|
15
|
+
@agent_root = agent_root
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def transcript_records
|
|
19
|
+
generation = @agent_root.transcript_generation
|
|
20
|
+
records.select do |record|
|
|
21
|
+
record.context_candidate &&
|
|
22
|
+
record.context_generation == generation &&
|
|
23
|
+
MESSAGE_KINDS.include?(record.kind)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns all persistent records eligible for Context selection.
|
|
28
|
+
# Knowledge has an independent logical lifetime from the transcript:
|
|
29
|
+
# clear_transcript! does not remove Knowledge, while knowledge_cleared and
|
|
30
|
+
# context_reset invalidate earlier Knowledge records by Journal position.
|
|
31
|
+
def context_records
|
|
32
|
+
(transcript_records + active_knowledge_records)
|
|
33
|
+
.sort_by { |record| [record.sequence || 0, record.record_id] }
|
|
34
|
+
.freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def llm_call_records
|
|
38
|
+
records.select { |record| record.kind == :llm_call_recorded }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def records
|
|
42
|
+
@records ||= @persistence.journals.read(@agent_root.agent_id)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def active_knowledge_records
|
|
48
|
+
reset_sequence = records.filter_map do |record|
|
|
49
|
+
record.sequence if KNOWLEDGE_RESET_KINDS.include?(record.kind)
|
|
50
|
+
end.max.to_i
|
|
51
|
+
|
|
52
|
+
records.select do |record|
|
|
53
|
+
record.context_candidate &&
|
|
54
|
+
record.kind == :knowledge &&
|
|
55
|
+
record.sequence.to_i > reset_sequence
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
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
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
class RubyLLMMaterializer
|
|
6
|
+
RuntimeProjection = Data.define(
|
|
7
|
+
:system, :messages, :tool_classes, :ask_message,
|
|
8
|
+
:model_config, :manifest, :manifest_ref
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
def initialize(agent:, persistence:)
|
|
12
|
+
@agent = agent
|
|
13
|
+
@persistence = persistence
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def materialize(manifest:, manifest_ref:)
|
|
17
|
+
verify_manifest_identity!(manifest, manifest_ref)
|
|
18
|
+
verify_adapter!(manifest)
|
|
19
|
+
tool_set = verify_tool_definitions!(manifest)
|
|
20
|
+
model_config = fetch_json(manifest.model_config_ref)
|
|
21
|
+
system_parts = []
|
|
22
|
+
message_segments = []
|
|
23
|
+
ask_message = nil
|
|
24
|
+
|
|
25
|
+
manifest.segments.each do |segment|
|
|
26
|
+
case segment.delivery
|
|
27
|
+
when :ask_argument
|
|
28
|
+
ask_message = @persistence.contents.fetch_text(segment.content_ref)
|
|
29
|
+
when :chat_message
|
|
30
|
+
if segment.role == :system
|
|
31
|
+
system_parts << @persistence.contents.fetch_text(segment.content_ref)
|
|
32
|
+
else
|
|
33
|
+
message_segments << segment
|
|
34
|
+
end
|
|
35
|
+
else
|
|
36
|
+
raise ArgumentError, "unknown manifest delivery: #{segment.delivery.inspect}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
RuntimeProjection.new(
|
|
41
|
+
system: system_parts.empty? ? nil : system_parts.join("\n\n"),
|
|
42
|
+
messages: materialize_message_segments(message_segments).freeze,
|
|
43
|
+
tool_classes: tool_set.runtime_tools,
|
|
44
|
+
ask_message: ask_message,
|
|
45
|
+
model_config: Immutable.copy(model_config),
|
|
46
|
+
manifest: manifest,
|
|
47
|
+
manifest_ref: manifest_ref
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def materialize_journal_record(record)
|
|
52
|
+
materialize_segment(segment_from_record(record))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def materialize_journal_records(records)
|
|
56
|
+
segments = Array(records).each_with_index.map do |record, index|
|
|
57
|
+
segment_from_record(record, position: index)
|
|
58
|
+
end
|
|
59
|
+
materialize_message_segments(segments).freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def segment_from_record(record, position: 0)
|
|
65
|
+
LLMInputManifest::Segment.new(
|
|
66
|
+
position: position,
|
|
67
|
+
category: record.kind,
|
|
68
|
+
role: record.role,
|
|
69
|
+
content_ref: record.content_ref,
|
|
70
|
+
delivery: :chat_message,
|
|
71
|
+
tool_call_id: record.metadata["tool_call_id"] || record.metadata[:tool_call_id],
|
|
72
|
+
metadata: record.metadata.merge(
|
|
73
|
+
"journal_record_id" => record.record_id,
|
|
74
|
+
"journal_sequence" => record.sequence,
|
|
75
|
+
"llm_call_id" => record.llm_call_id
|
|
76
|
+
).compact
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def verify_manifest_identity!(manifest, manifest_ref)
|
|
81
|
+
expected = @persistence.contents.fetch(manifest_ref)
|
|
82
|
+
actual = Phronomy::CanonicalJSON.dump(manifest.to_h)
|
|
83
|
+
return if expected == actual
|
|
84
|
+
|
|
85
|
+
raise Phronomy::ContentStore::IntegrityError,
|
|
86
|
+
"manifest content does not match manifest_ref: #{manifest_ref}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def verify_adapter!(manifest)
|
|
90
|
+
actual = Phronomy.configuration.llm_adapter.class.name
|
|
91
|
+
return if manifest.adapter_name.nil? || manifest.adapter_name == actual
|
|
92
|
+
|
|
93
|
+
raise Phronomy::ConfigurationError,
|
|
94
|
+
"LLM adapter changed after manifest creation: #{manifest.adapter_name} -> #{actual}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def verify_tool_definitions!(manifest)
|
|
98
|
+
tool_set = ToolDefinitionSet.build(@agent)
|
|
99
|
+
return tool_set unless manifest.tool_definitions_ref
|
|
100
|
+
|
|
101
|
+
expected = @persistence.contents.fetch(manifest.tool_definitions_ref)
|
|
102
|
+
actual = Phronomy::CanonicalJSON.dump(tool_set.definitions)
|
|
103
|
+
unless expected == actual
|
|
104
|
+
raise Phronomy::ConfigurationError,
|
|
105
|
+
"Agent tool definitions changed after manifest creation"
|
|
106
|
+
end
|
|
107
|
+
tool_set
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def fetch_json(content_ref)
|
|
111
|
+
Phronomy::CanonicalJSON.load(@persistence.contents.fetch(content_ref))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def materialize_message_segments(segments)
|
|
115
|
+
segments.map { |segment| materialize_segment(segment) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def materialize_segment(segment)
|
|
119
|
+
case segment.category.to_sym
|
|
120
|
+
when :assistant_message
|
|
121
|
+
materialize_canonical_message(segment, expected_role: :assistant)
|
|
122
|
+
when :tool_message
|
|
123
|
+
materialize_canonical_message(segment, expected_role: :tool)
|
|
124
|
+
when :tool_result
|
|
125
|
+
raise ArgumentError, "raw Tool result is not an LLM message"
|
|
126
|
+
else
|
|
127
|
+
RubyLLM::Message.new(
|
|
128
|
+
role: segment.role,
|
|
129
|
+
content: @persistence.contents.fetch_text(segment.content_ref),
|
|
130
|
+
tool_call_id: segment.tool_call_id
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def materialize_canonical_message(segment, expected_role:)
|
|
136
|
+
payload = fetch_json(segment.content_ref)
|
|
137
|
+
role = payload.fetch("role").to_sym
|
|
138
|
+
unless role == expected_role
|
|
139
|
+
raise ArgumentError,
|
|
140
|
+
"canonical #{segment.category} role mismatch: #{role.inspect}"
|
|
141
|
+
end
|
|
142
|
+
if segment.role && segment.role.to_sym != role
|
|
143
|
+
raise ArgumentError,
|
|
144
|
+
"manifest role does not match canonical message: #{segment.role.inspect} != #{role.inspect}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
tool_calls = materialize_tool_calls(payload["tool_calls"])
|
|
148
|
+
message = RubyLLM::Message.new(
|
|
149
|
+
role: role,
|
|
150
|
+
content: initial_content_for(role, payload.fetch("content", nil), tool_calls),
|
|
151
|
+
tool_calls: tool_calls.empty? ? nil : tool_calls,
|
|
152
|
+
tool_call_id: payload["tool_call_id"],
|
|
153
|
+
model_id: payload["model_id"]
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# RubyLLM normalizes Hash content during Message initialization. A
|
|
157
|
+
# canonical message may legitimately contain structured assistant
|
|
158
|
+
# content, so restore the captured logical value after construction.
|
|
159
|
+
message.content = payload["content"] if payload.key?("content")
|
|
160
|
+
message
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def initial_content_for(role, content, tool_calls)
|
|
164
|
+
return "" if role == :assistant && content.nil? && !tool_calls.empty?
|
|
165
|
+
|
|
166
|
+
content.nil? ? "" : content
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def materialize_tool_calls(payloads)
|
|
170
|
+
Array(payloads).each_with_object({}) do |payload, result|
|
|
171
|
+
tool_call_id = payload.fetch("id").to_s
|
|
172
|
+
if result.key?(tool_call_id)
|
|
173
|
+
raise ArgumentError, "duplicate Tool Call in assistant message: #{tool_call_id}"
|
|
174
|
+
end
|
|
175
|
+
result[tool_call_id] = materialize_tool_call(payload)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def materialize_tool_call(payload)
|
|
180
|
+
RubyLLM::ToolCall.new(
|
|
181
|
+
id: payload.fetch("id").to_s,
|
|
182
|
+
name: payload.fetch("name"),
|
|
183
|
+
arguments: payload.fetch("arguments", {}),
|
|
184
|
+
thought_signature: payload["thought_signature"]
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|