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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +159 -28
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +400 -143
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +7 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +3 -19
  10. data/benchmark/bench_tool_schema.rb +2 -34
  11. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  12. data/docs/decisions/010-cooperative-first-concurrency.md +7 -0
  13. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
  14. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
  15. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  16. data/lib/phronomy/agent/activation_registry.rb +28 -0
  17. data/lib/phronomy/agent/agent_execution.rb +97 -0
  18. data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
  19. data/lib/phronomy/agent/agent_invocation.rb +44 -46
  20. data/lib/phronomy/agent/agent_invocation_session_builder.rb +206 -104
  21. data/lib/phronomy/agent/agent_root.rb +66 -0
  22. data/lib/phronomy/agent/async_event_api.rb +55 -475
  23. data/lib/phronomy/agent/base.rb +351 -514
  24. data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
  25. data/lib/phronomy/agent/context/capability/base.rb +166 -297
  26. data/lib/phronomy/agent/context_assembler.rb +357 -0
  27. data/lib/phronomy/agent/context_candidate.rb +47 -0
  28. data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
  29. data/lib/phronomy/agent/context_importer.rb +217 -0
  30. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
  31. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
  32. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
  33. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +118 -0
  34. data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
  35. data/lib/phronomy/agent/context_plan.rb +25 -0
  36. data/lib/phronomy/agent/context_plan_validator.rb +134 -0
  37. data/lib/phronomy/agent/context_policies/default.rb +53 -0
  38. data/lib/phronomy/agent/context_policy.rb +15 -0
  39. data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
  40. data/lib/phronomy/agent/context_policy_registry.rb +46 -0
  41. data/lib/phronomy/agent/context_request.rb +35 -0
  42. data/lib/phronomy/agent/context_selection_unit.rb +38 -0
  43. data/lib/phronomy/agent/derived_content_spec.rb +34 -0
  44. data/lib/phronomy/agent/execution_coordinator.rb +1122 -0
  45. data/lib/phronomy/agent/immutable.rb +31 -0
  46. data/lib/phronomy/agent/journal_projection.rb +60 -0
  47. data/lib/phronomy/agent/journal_record.rb +67 -0
  48. data/lib/phronomy/agent/llm_call_record.rb +51 -0
  49. data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
  50. data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
  51. data/lib/phronomy/agent/llm_input_patch.rb +21 -0
  52. data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
  53. data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
  54. data/lib/phronomy/agent/ruby_llm_materializer.rb +189 -0
  55. data/lib/phronomy/agent/shared_state.rb +46 -138
  56. data/lib/phronomy/agent/token_budget_resolver.rb +70 -0
  57. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
  58. data/lib/phronomy/agent/tool_definition_set.rb +55 -0
  59. data/lib/phronomy/agent/tool_invocation.rb +108 -314
  60. data/lib/phronomy/agent.rb +10 -16
  61. data/lib/phronomy/agent_busy_error.rb +5 -0
  62. data/lib/phronomy/canonical_json.rb +136 -0
  63. data/lib/phronomy/configuration.rb +17 -155
  64. data/lib/phronomy/content_store/base.rb +51 -0
  65. data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
  66. data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
  67. data/lib/phronomy/engine/event_loop.rb +3 -0
  68. data/lib/phronomy/engine/runtime.rb +15 -230
  69. data/lib/phronomy/engine/task_group.rb +30 -102
  70. data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
  71. data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
  72. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  73. data/lib/phronomy/multi_agent/orchestrator.rb +153 -204
  74. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
  75. data/lib/phronomy/multi_agent/team_coordinator.rb +46 -133
  76. data/lib/phronomy/persistence/in_memory.rb +247 -0
  77. data/lib/phronomy/persistence.rb +39 -0
  78. data/lib/phronomy/tools/agent.rb +14 -36
  79. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  80. data/lib/phronomy/version.rb +1 -1
  81. data/lib/phronomy.rb +9 -115
  82. data/scripts/add_to_h_to_token_doubles.rb +33 -0
  83. data/scripts/add_to_h_unnamed_doubles.rb +27 -0
  84. data/scripts/api_snapshot.rb +1 -12
  85. data/scripts/migrate_spec_agent_definition.rb +108 -0
  86. data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
  87. data/scripts/migrate_spec_inline_pass3.rb +24 -0
  88. metadata +54 -13
  89. data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
  90. data/lib/phronomy/agent/before_completion_context.rb +0 -47
  91. data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
  92. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  93. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  94. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  95. data/lib/phronomy/knowledge_source.rb +0 -12
  96. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  97. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
@@ -4,6 +4,5 @@
4
4
  "tool_params_schema_definition": 19534379.159046534,
5
5
  "dispatch_parallel_10": 886.0,
6
6
  "cancellation_token_cancelled": 4335060.97443425,
7
- "cancellation_token_raise_if_cancelled_noop": 3566903.189098373,
8
- "trim_messages_2000": 2896552.0
9
- }
7
+ "cancellation_token_raise_if_cancelled_noop": 3566903.189098373
8
+ }
@@ -3,11 +3,11 @@
3
3
  # bench_agent_invoke.rb — Agent#invoke framework overhead benchmark.
4
4
  #
5
5
  # Measures the per-invoke cost of the Phronomy::Agent::Base framework path
6
- # (context assembly, guardrail checks, before_completion hooks, response
7
- # handling) with a fully stubbed LLM. No network calls are made.
6
+ # (context assembly, filter checks, before_llm_input hooks, response handling)
7
+ # with a fully stubbed LLM. No network calls are made.
8
8
  #
9
9
  # Scenarios:
10
- # 1. Minimal agent (no tools, no knowledge) — baseline framework overhead.
10
+ # 1. Minimal agent (no tools, no persistent Knowledge) — baseline framework overhead.
11
11
  # 2. Tool-aware agent with a registered stub Tool.
12
12
  # 3. Agent#stream setup latency (first-chunk time with stubbed stream).
13
13
 
@@ -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,14 +72,16 @@ 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
- tools BenchNullTool
84
+ tools(BenchNullTool => nil)
82
85
 
83
86
  define_method(:build_chat) { |*| BenchStubChat.new(BENCH_RESP) }
84
87
  end
@@ -1,54 +1,154 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Benchmark: Context::Assembler#build
3
+ # Benchmark: Manifest-first Context assembly and Default Context Policy.
4
4
  #
5
- # Tests context assembly performance for varying numbers of messages and
6
- # knowledge chunks. This path is exercised on every agent turn.
5
+ # Usage:
6
+ # ruby benchmark/bench_context_assembler.rb
7
+ #
8
+ # Measures:
9
+ # 1. ContextPolicies::Default selection cost for growing canonical candidate sets.
10
+ # 2. ContextAssembler#build_initial end-to-end Manifest construction.
11
+ #
12
+ # No provider call is performed.
7
13
 
8
14
  require "benchmark"
9
15
  require_relative "../lib/phronomy"
10
16
 
11
- BenchAsmMessage = Struct.new(:content)
17
+ module BenchContextAssembler
18
+ module_function
12
19
 
13
- def make_assembler(n_messages:, n_chunks:, with_budget: false)
14
- budget = if with_budget
15
- Phronomy::LlmContextWindow::TokenBudget.new(context_window: 4096, max_output_tokens: 512)
16
- end
17
- asm = Phronomy::LlmContextWindow::Assembler.new(budget: budget)
18
- asm.add_instruction("You are a helpful assistant. Answer the user's question.")
19
- n_chunks.times do |i|
20
- asm.add_knowledge("Fact #{i}: The capital of country #{i} is City #{i}.", type: :entity, trusted: true)
20
+ def candidate(index)
21
+ category, role = if (index % 10).zero?
22
+ [:knowledge, :user]
23
+ elsif index.even?
24
+ [:assistant_message, :assistant]
25
+ else
26
+ [:external_message, :user]
27
+ end
28
+
29
+ Phronomy::Agent::ContextCandidate.new(
30
+ candidate_id: "candidate-#{index}",
31
+ source_kind: :journal,
32
+ category: category,
33
+ role: role,
34
+ content_ref: "content-#{index}",
35
+ record_id: "record-#{index}",
36
+ agent_id: "bench-agent",
37
+ execution_id: "execution-#{index / 4}",
38
+ llm_call_id: nil,
39
+ tool_call_id: nil,
40
+ sequence: index,
41
+ requirement: :optional,
42
+ priority: 0,
43
+ metadata: {
44
+ "estimated_tokens" => 8,
45
+ "source_sequence" => index
46
+ }
47
+ )
21
48
  end
22
- msgs = Array.new(n_messages) { BenchAsmMessage.new("This is a conversation message.") }
23
- asm.add_messages(msgs)
24
- asm
25
- end
26
49
 
27
- BENCH_ASM_ITERATIONS = 1_000
50
+ def parts
51
+ {
52
+ unit_builder:
53
+ Phronomy::Agent::ContextParts::UnitBuilders::DependencyAwareUnitBuilder.new,
54
+ required_context_resolver:
55
+ Phronomy::Agent::ContextParts::Requirements::RequiredContextResolver.new,
56
+ recent_first_selector:
57
+ Phronomy::Agent::ContextParts::Selectors::RecentFirstSelector.new,
58
+ token_budget_packer:
59
+ Phronomy::Agent::ContextParts::Budget::TokenBudgetPacker.new
60
+ }.freeze
61
+ end
28
62
 
29
- puts "=== bench_context_assembler ==="
30
- Benchmark.bm(40) do |x|
31
- x.report("build(10 msgs, 0 chunks)") do
32
- BENCH_ASM_ITERATIONS.times { make_assembler(n_messages: 10, n_chunks: 0).build }
63
+ def request(candidate_count)
64
+ candidates = Array.new(candidate_count) { |i| candidate(i) }
65
+ Phronomy::Agent::ContextRequest.new(
66
+ agent_id: "bench-agent",
67
+ execution_id: "bench-execution",
68
+ call_sequence: 2,
69
+ call_mode: :complete,
70
+ candidates: candidates,
71
+ token_budget: Phronomy::LlmContextWindow::TokenBudget.new(
72
+ context_window: [candidate_count * 16, 4_096].max,
73
+ max_output_tokens: 512
74
+ ),
75
+ model_config: {},
76
+ previous_manifest: nil,
77
+ required_coverage: [],
78
+ parts: parts,
79
+ metadata: {"mandatory_token_estimate" => 32}
80
+ )
33
81
  end
34
82
 
35
- x.report("build(100 msgs, 5 chunks)") do
36
- BENCH_ASM_ITERATIONS.times { make_assembler(n_messages: 100, n_chunks: 5).build }
83
+ def assembler_fixture
84
+ persistence = Phronomy::Persistence::InMemory.new
85
+ agent_class = Class.new(Phronomy::Agent::Base) do
86
+ agent_definition id: "bench-manifest-context-assembler", version: 1
87
+ model "local-model"
88
+ context_window 16_384
89
+ max_output_tokens 1_024
90
+ instructions "Benchmark instruction"
91
+ end
92
+ agent = agent_class.new(
93
+ persistence: persistence,
94
+ knowledge: ["Persistent benchmark knowledge"]
95
+ )
96
+ root = agent.agent_root
97
+ input_ref = persistence.contents.put_text("benchmark input")
98
+ input_record = Phronomy::Agent::JournalRecord.new(
99
+ agent_id: agent.agent_id,
100
+ kind: :external_message,
101
+ channel: :external,
102
+ role: :user,
103
+ content_ref: input_ref,
104
+ context_generation: root.transcript_generation,
105
+ context_candidate: true
106
+ )
107
+ execution = Phronomy::Agent::AgentExecution.start(
108
+ agent_root: root,
109
+ input_record: input_record,
110
+ metadata: {
111
+ "current_input_ref" => input_ref,
112
+ "current_input_record_id" => input_record.record_id
113
+ }
114
+ ).with(
115
+ execution_revision: 0,
116
+ working_records: [input_record]
117
+ )
118
+
119
+ [
120
+ Phronomy::Agent::ContextAssembler.new(agent: agent, persistence: persistence),
121
+ root,
122
+ execution
123
+ ]
37
124
  end
125
+ end
126
+
127
+ puts "Manifest-first Context benchmark"
128
+ puts "Ruby #{RUBY_VERSION} on #{RUBY_PLATFORM}"
129
+ puts "=" * 72
38
130
 
39
- x.report("build(1000 msgs, 10 chunks, no budget)") do
40
- (BENCH_ASM_ITERATIONS / 10).times { make_assembler(n_messages: 1000, n_chunks: 10).build }
131
+ policy = Phronomy::Agent::ContextPolicies::Default.new
132
+ policy_requests = [10, 100, 1_000].to_h do |count|
133
+ [count, BenchContextAssembler.request(count)]
134
+ end
135
+
136
+ Benchmark.bm(46) do |x|
137
+ policy_requests.each do |count, request|
138
+ iterations = (count >= 1_000) ? 200 : 1_000
139
+ x.report("DefaultContextPolicy #{count} candidates x#{iterations}") do
140
+ iterations.times { policy.call(request) }
141
+ end
41
142
  end
42
143
 
43
- x.report("build(1000 msgs, 10 chunks, budgeted)") do
44
- (BENCH_ASM_ITERATIONS / 10).times do
45
- # Assembler raises ContextLengthError when messages exceed the budget;
46
- # callers (e.g. Agent::Base#build_context) are expected to pre-trim via
47
- # trim_to_budget before calling build. The rescue here keeps the benchmark
48
- # measuring build's fast path without triggering the error path.
49
- make_assembler(n_messages: 1000, n_chunks: 10, with_budget: true).build
50
- rescue Phronomy::ContextLengthError
51
- # expected — budget exceeded
144
+ assembler, root, execution = BenchContextAssembler.assembler_fixture
145
+ x.report("ContextAssembler#build_initial x500") do
146
+ 500.times do
147
+ assembler.build_initial(
148
+ input: "benchmark input",
149
+ agent_root: root,
150
+ execution: execution
151
+ )
52
152
  end
53
153
  end
54
154
  end
@@ -88,7 +88,8 @@ 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
- define_method(:invoke) do |_input, messages: [], thread_id: nil, config: {}|
91
+ agent_definition id: "bench-stub", version: 1
92
+ define_method(:invoke) do |_input, thread_id: nil, config: {}|
92
93
  {output: "stub", messages: []}
93
94
  end
94
95
  define_method(:invoke_async) { |input, **_kw| Phronomy::Runtime.instance.spawn(name: "bench-stub") { invoke(input) } }
@@ -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
@@ -1,16 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Benchmark: Tool::Base params_schema generation and static_knowledge_chunks cache
3
+ # Benchmark: Tool::Base params_schema generation.
4
4
  #
5
- # Tool schema generation happens once per tool class (lazily memoised).
6
- # static_knowledge_chunks is cached at the class level; cache-hit overhead
7
- # should be negligible compared to cache-miss (which calls the knowledge source).
5
+ # Tool schema generation happens once per tool class and is lazily memoized.
8
6
 
9
7
  require "benchmark"
10
8
  require_relative "../lib/phronomy"
11
9
 
12
- # --- Tool schema ---
13
-
14
10
  class BenchTool10Params < Phronomy::Agent::Context::Capability::Base
15
11
  description "A tool with 10 parameters for benchmarking purposes"
16
12
  param :param1, type: :string, desc: "First parameter"
@@ -29,7 +25,6 @@ class BenchTool10Params < Phronomy::Agent::Context::Capability::Base
29
25
  end
30
26
  end
31
27
 
32
- # Warm up memoisation
33
28
  BenchTool10Params.params_schema_definition
34
29
 
35
30
  BENCH_TOOL_ITERATIONS = 50_000
@@ -40,30 +35,3 @@ Benchmark.bm(35) do |x|
40
35
  BENCH_TOOL_ITERATIONS.times { BenchTool10Params.params_schema_definition }
41
36
  end
42
37
  end
43
-
44
- # --- static_knowledge_chunks cache ---
45
-
46
- class BenchKnowledgeSource < Phronomy::Agent::Context::Knowledge::Base
47
- def fetch(query: nil)
48
- [{content: "Cached knowledge fact.", type: :static}]
49
- end
50
-
51
- def static?
52
- true
53
- end
54
- end
55
-
56
- class BenchAgentWithKnowledge < Phronomy::Agent::Base
57
- model "gpt-4o-mini"
58
- static_knowledge BenchKnowledgeSource.new
59
- end
60
-
61
- # Warm up cache
62
- BenchAgentWithKnowledge.static_knowledge_chunks
63
-
64
- puts "\n=== bench_static_knowledge_cache ==="
65
- Benchmark.bm(35) do |x|
66
- x.report("static_knowledge_chunks (hit)") do
67
- BENCH_TOOL_ITERATIONS.times { BenchAgentWithKnowledge.static_knowledge_chunks }
68
- end
69
- end
@@ -2,7 +2,12 @@
2
2
 
3
3
  ## Status
4
4
 
5
- Accepted
5
+ Superseded by ADR-013.
6
+
7
+ This ADR records the historical decision for the former KnowledgeSource-based
8
+ architecture. `static_knowledge`, `KnowledgeSource`, `StaticKnowledge`,
9
+ `EntityKnowledge` and the class-level Knowledge cache are no longer part of the
10
+ active design.
6
11
 
7
12
  ## Context
8
13
 
@@ -43,3 +48,9 @@ re-fetched on each invocation because their content depends on runtime state.
43
48
  non-static knowledge source.
44
49
  - In tests, the cache must be cleared between examples. `Phronomy.reset_runtime!`
45
50
  handles this.
51
+
52
+ ## Supersession
53
+
54
+ ADR-013 replaces the source-object/cache model with Journal-backed persistent
55
+ Knowledge selected through Context Policy. The historical rationale above is
56
+ retained only to explain the superseded architecture.
@@ -5,6 +5,13 @@
5
5
  Accepted — updated 2026-05-25 to document current scheduler landscape and
6
6
  production-cooperative roadmap (Issues #331, #332, #334).
7
7
 
8
+ > **Historical implementation note (2026-08-09):** The implementation-detail
9
+ > inventory below is intentionally preserved as decision history. Compatibility
10
+ > APIs subsequently removed from the active contract — including the
11
+ > `runtime_backend :cooperative` alias and direct Runtime singleton replacement —
12
+ > must not be read as current API guidance. The cooperative-first layering
13
+ > principle remains the decision; use README/current source for current symbols.
14
+
8
15
  ## Context
9
16
 
10
17
  Phronomy provides its own concurrency primitives:
@@ -2,7 +2,46 @@
2
2
 
3
3
  ## Status
4
4
 
5
- Proposed — 2026-05-31
5
+ 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 this ADR as an active architecture contract. This document is retained as historical design analysis; references below to `build_context`, `context_overhead`, and `LlmContextWindow::Assembler` are non-normative.
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,122 @@
1
+ # ADR-013: Journal-backed Knowledge as Context Candidates
2
+
3
+ ## Status
4
+
5
+ Accepted.
6
+
7
+ ## Context
8
+
9
+ The Manifest-first Agent refactor established the Journal as the canonical
10
+ append-only record of Agent state and `LLMInputManifest` as the authority for
11
+ one provider call. The older Knowledge design remained outside that model:
12
+ `StaticKnowledge` and other `KnowledgeSource` objects were fetched separately,
13
+ then concatenated into the mandatory system prompt. That created several
14
+ problems:
15
+
16
+ - static/entity/RAG distinctions described acquisition strategy rather than
17
+ Context semantics;
18
+ - class-level and instance-level Knowledge followed different storage paths;
19
+ - persistent Knowledge did not share Agent persistence/reload semantics;
20
+ - Knowledge bypassed Context Policy and was always mandatory once configured;
21
+ - `static?`, `source`, fetch/caching APIs and EntityKnowledge behavior existed
22
+ primarily to support the obsolete source abstraction.
23
+
24
+ Applications still need two different lifetimes:
25
+
26
+ 1. information that becomes durable Agent Knowledge at creation time or later;
27
+ 2. request-scoped information used for only one LLM call.
28
+
29
+ ## Decision
30
+
31
+ Phronomy has one Knowledge Context category.
32
+
33
+ ### Persistent Knowledge
34
+
35
+ Persistent Knowledge is stored as ordinary append-only Journal records with
36
+ `kind: :knowledge`, `channel: :context`, `role: :user` and
37
+ `context_candidate: true`. Content lives in ContentStore and the Journal holds
38
+ its content reference and application metadata.
39
+
40
+ Agent instances accept Knowledge at creation and after creation:
41
+
42
+ ```ruby
43
+ agent = MyAgent.new(knowledge: ["Policy: ..."])
44
+ agent.add_knowledge("Customer locale: ja-JP")
45
+ ```
46
+
47
+ `Agent.load` requires no separate Knowledge source reconstruction because the
48
+ records are already persisted with the Agent.
49
+
50
+ ### Selection
51
+
52
+ Knowledge is not part of the public conversation transcript.
53
+ `JournalProjection` exposes active Knowledge together with active transcript
54
+ records for Context selection. `ContextCandidateResolver` and Context Policy
55
+ therefore handle persistent Knowledge through the same selection pipeline as
56
+ other optional Context.
57
+
58
+ Knowledge is optional by default. The fact that content is Knowledge does not
59
+ make it mandatory.
60
+
61
+ Selected Knowledge is materialized before ordinary conversation-history
62
+ segments so that persistent background Context is not interleaved into the
63
+ middle of dialogue chronology.
64
+
65
+ ### Reset semantics
66
+
67
+ `clear_knowledge!` appends a `knowledge_cleared` marker. Earlier Knowledge
68
+ records remain in the Journal but are excluded from later Context projections.
69
+ No Knowledge-generation counter is required.
70
+
71
+ `clear_transcript!` affects conversation history only. `reset_context!` resets
72
+ both transcript eligibility and Knowledge eligibility while retaining raw
73
+ Journal records.
74
+
75
+ ### Per-call Context
76
+
77
+ `before_llm_input` continues to accept `LLMInputPatch#segment_candidates`.
78
+ Those candidates are not persisted. They enter the same Context Policy request
79
+ as Journal-backed candidates and may be omitted when optional and over budget.
80
+
81
+ ### Acquisition responsibility
82
+
83
+ Phronomy core does not model `StaticKnowledge`, `EntityKnowledge`,
84
+ `RAGKnowledge` or `KnowledgeSource` subclasses. File loading, retrieval, entity
85
+ extraction and other acquisition strategies belong to applications or Tools.
86
+ Once an application chooses to retain the resulting information, it registers
87
+ plain logical Knowledge with the Agent.
88
+
89
+ ## Consequences
90
+
91
+ ### Positive
92
+
93
+ - one persistent representation and one Context-selection path;
94
+ - creation-time and post-creation Knowledge behave identically;
95
+ - durable Knowledge naturally survives Agent reload;
96
+ - token-budget selection can omit optional Knowledge without rewriting state;
97
+ - RAG/entity extraction can evolve independently of Agent Context persistence;
98
+ - no class-level static cache or `static?` distinction is needed.
99
+
100
+ ### Tradeoffs
101
+
102
+ - applications that previously declared class-level static Knowledge must pass
103
+ common Knowledge when constructing each Agent instance;
104
+ - source-specific refresh behavior is no longer a framework abstraction;
105
+ - provenance that matters to an application must be stored explicitly in
106
+ metadata rather than through a dedicated `source:` API.
107
+
108
+ ## Removed contracts
109
+
110
+ This decision removes the active contracts for:
111
+
112
+ - `Phronomy::KnowledgeSource`;
113
+ - `Agent::Context::Knowledge::Base`;
114
+ - `StaticKnowledge`;
115
+ - `EntityKnowledge`;
116
+ - `static_knowledge`, `static_knowledge_sources`, `static_knowledge_chunks`,
117
+ `static_knowledge_refresh!`;
118
+ - `add_knowledge_source`, `instance_knowledge_chunks`;
119
+ - `clear_memory!` / `memory_generation` as obsolete Agent Context concepts.
120
+
121
+ ADR-005 is superseded by this decision. ADR-012 remains the authority for the
122
+ Journal/Manifest separation and Context Policy model.
@@ -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