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
@@ -1,191 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "cgi"
4
-
5
- module Phronomy
6
- module LlmContextWindow
7
- # Assembler collects all four context regions and produces the final
8
- # {system:, messages:, tool_classes:} hash consumed by Agent::Base.
9
- #
10
- # Regions:
11
- # 1. Instruction — system prompt text set via #add_instruction
12
- # 2. Capability — tool classes registered via #add_capability
13
- # 3. Knowledge — external facts injected via #add_knowledge (generates XML tags)
14
- # 4. Conversation — historical messages added via #add_messages
15
- #
16
- # Token budgeting:
17
- # When a budget is given, conversation messages are trimmed from oldest to
18
- # newest until they fit. Capability token cost is estimated and deducted
19
- # from the budget before conversation trimming so the reserve is accurate.
20
- # Knowledge chunks are always included in full (they are assumed to be
21
- # pre-screened by the caller). When no budget is given all messages are
22
- # passed through unchanged.
23
- #
24
- # @example
25
- # assembler = Phronomy::LlmContextWindow::Assembler.new(budget: budget)
26
- # assembler.add_instruction("You are a helpful assistant.")
27
- # assembler.add_knowledge("The user lives in Tokyo.", type: :entity, trusted: false)
28
- # assembler.add_messages(manager.load(thread_id: "t1", query: user_input))
29
- # context = assembler.build
30
- # # => { system: "You are ...\n<context ...>...</context>", messages: [...] }
31
- class Assembler
32
- # Builds a single XML context tag string.
33
- # Exposed as a class method so callers (e.g. Agent::Base) can build
34
- # static knowledge XML tags independently of an Assembler instance.
35
- #
36
- # @param text [String]
37
- # @param type [Symbol, String]
38
- # @param trusted [Boolean]
39
- # @return [String]
40
- # @api private
41
- # mutant:disable - text.to_str and plain text (no to_s) are genuine equivalents when text is a String; type.to_str is genuine equivalent when type is a String
42
- def self.xml_tag(text, type:, trusted: false)
43
- "<context type=\"#{CGI.escapeHTML(type.to_s)}\" trusted=\"#{trusted}\">\n#{CGI.escapeHTML(text.to_s)}\n</context>"
44
- end
45
-
46
- # @param budget [Phronomy::LlmContextWindow::TokenBudget, nil]
47
- # when nil no token trimming is performed
48
- # @api private
49
- # mutant:disable - @instruction = nil deletion is a genuine equivalent (uninitialized Ruby instance variables return nil)
50
- def initialize(budget: nil)
51
- @budget = budget
52
- @instruction = nil
53
- @tool_classes = []
54
- @knowledge_chunks = []
55
- @messages = []
56
- end
57
-
58
- # Register tool classes (Region 2).
59
- # Estimates their token cost and deducts it from the budget so that
60
- # conversation trimming accounts for tool definition overhead.
61
- #
62
- # @param tool_classes [Array<Class, Object>] tool classes or instances
63
- # @return [self]
64
- # @api private
65
- def add_capability(tool_classes)
66
- @tool_classes = Array(tool_classes)
67
- self
68
- end
69
-
70
- # Set the system instruction text (Region 1).
71
- # Calling this multiple times replaces the previous value.
72
- #
73
- # @param text [String]
74
- # @return [self]
75
- # @api private
76
- # mutant:disable - text.to_str and plain text (no .to_s) are genuine equivalents when callers always pass a String
77
- def add_instruction(text)
78
- @instruction = text.to_s
79
- self
80
- end
81
-
82
- # Append a knowledge chunk (Region 3).
83
- # The chunk is wrapped in an XML context tag automatically.
84
- #
85
- # @param text [String]
86
- # @param type [Symbol, String] semantic label for the context tag (e.g. :entity, :rag, :static)
87
- # @param trusted [Boolean] false (default) indicates externally sourced data
88
- # @param source [String, nil] optional source label (e.g. filename); included in the
89
- # XML tag so the LLM can produce grounded citations. Omitted when nil.
90
- # @return [self]
91
- # @api private
92
- # mutant:disable - {text:} (shorthand, no .to_s) and text.to_str are genuine equivalents when text is a String; {type:} shorthand is genuine equivalent because xml_context_tag always calls .to_s on chunk[:type]
93
- def add_knowledge(text, type:, trusted: false, source: nil)
94
- @knowledge_chunks << {text: text.to_s, type: type.to_s, trusted: trusted, source: source}
95
- self
96
- end
97
-
98
- # Set conversation messages (Region 4). Replaces any previously set messages.
99
- #
100
- # @param messages [Array] message-like objects with #role and #content
101
- # @return [self]
102
- # @api private
103
- # mutant:disable - @messages = messages (no Array()) is a genuine equivalent when callers always pass an Array
104
- def add_messages(messages)
105
- @messages = Array(messages)
106
- self
107
- end
108
-
109
- # Returns the number of tokens available for conversation messages after
110
- # accounting for instruction, knowledge, and capability overhead.
111
- # Returns +nil+ when no budget is configured.
112
- #
113
- # @return [Integer, nil]
114
- # @api private
115
- def available_for_messages
116
- return nil unless @budget
117
- knowledge_text = @knowledge_chunks.map { |c| xml_context_tag(c) }.join("\n\n")
118
- system_parts = [@instruction, knowledge_text.empty? ? nil : knowledge_text].compact
119
- system_text = system_parts.join("\n\n")
120
- used = TokenEstimator.estimate(system_text) + estimate_capability_tokens
121
- @budget.available(used: used)
122
- end
123
-
124
- # Assemble the context.
125
- #
126
- # @return [Hash{Symbol => Object}]
127
- # :system [String, nil] combined system prompt (instruction + knowledge XML tags)
128
- # :messages [Array] conversation messages, trimmed to budget if set
129
- # :tool_classes [Array] tool classes/instances to register with the chat
130
- # @api private
131
- # Raises {Phronomy::ContextLengthError} when a budget is set and the
132
- # conversation messages do not fit within the remaining token allowance.
133
- # No automatic trimming is performed — callers must pre-process messages
134
- # (e.g. via Agent::Base#trim_messages or #compact_messages) before
135
- # passing them to the Assembler.
136
- #
137
- # mutant:disable - multiple genuine equivalent mutations: map{}.join("\n\n") → map{} is genuine; `unless knowledge_text.empty?` vs ternary is genuine; `{ system: unless system_text.empty? }` vs ternary is genuine; `messages:` shorthand vs `messages: messages` is genuine
138
- def build
139
- knowledge_text = @knowledge_chunks.map { |c| xml_context_tag(c) }.join("\n\n")
140
- system_parts = [@instruction, knowledge_text.empty? ? nil : knowledge_text].compact
141
- system_text = system_parts.join("\n\n")
142
-
143
- if @budget && @messages.any?
144
- capability_tokens = estimate_capability_tokens
145
- used = TokenEstimator.estimate(system_text) + capability_tokens
146
- remaining = @budget.available(used: used)
147
- msg_tokens = @messages.sum { |m| TokenEstimator.estimate(m.content.to_s) }
148
- if msg_tokens > remaining
149
- raise Phronomy::ContextLengthError,
150
- "Context exceeds token budget: messages require #{msg_tokens} tokens but " \
151
- "only #{remaining} available (context_window=#{@budget.context_window}, " \
152
- "used_by_system=#{used}). Override build_context to trim or compact messages."
153
- end
154
- end
155
-
156
- {
157
- system: system_text.empty? ? nil : system_text,
158
- messages: @messages,
159
- tool_classes: @tool_classes
160
- }
161
- end
162
-
163
- private
164
-
165
- # Estimates the token cost of all registered tool classes.
166
- # Uses each tool's description and parameter names as a proxy for its
167
- # JSON Schema size. This is a deliberate simplification — exact token
168
- # counts require provider-specific schema serialization which lives in
169
- # RubyLLM. The estimate errs on the side of being slightly conservative
170
- # so that the conversation budget is not over-allocated.
171
- def estimate_capability_tokens
172
- @tool_classes.sum do |tc|
173
- # Instantiated tool objects (e.g. Phronomy::Tools::Mcp instances) may not be a Class.
174
- next 0 unless tc.is_a?(Class) && tc.respond_to?(:description)
175
-
176
- text = [tc.description.to_s]
177
- if tc.respond_to?(:parameters)
178
- tc.parameters.each_key { |k| text << k.to_s }
179
- end
180
- TokenEstimator.estimate(text.join(" "))
181
- end
182
- end
183
-
184
- # mutant:disable - multiple genuine equivalent mutations: chunk.fetch(key) vs chunk[key] (key always present); chunk[:text] no .to_s / .to_str are genuine (stored as String); chunk[:type] no .to_s / .to_str are genuine (stored as String); chunk[:source] no .to_s / .to_str are genuine (truthy branch, always String); src_attr chunk.fetch(:source) is genuine (source key always present)
185
- def xml_context_tag(chunk)
186
- src_attr = chunk[:source] ? " source=\"#{CGI.escapeHTML(chunk[:source].to_s)}\"" : ""
187
- "<context type=\"#{CGI.escapeHTML(chunk[:type].to_s)}\"#{src_attr} trusted=\"#{chunk[:trusted]}\">\n#{CGI.escapeHTML(chunk[:text].to_s)}\n</context>"
188
- end
189
- end
190
- end
191
- end
@@ -1,52 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module LlmContextWindow
5
- # Caches the assembled static system prompt text keyed by a SHA-256
6
- # fingerprint of the agent's instructions + static knowledge content.
7
- # Each instance is owned by one thread (stored in +Thread.current+).
8
- class ContextVersionCache
9
- # @return [String, nil] last stored fingerprint
10
- attr_reader :fingerprint
11
-
12
- # @return [String, nil] cached system prompt text
13
- attr_reader :system_text
14
-
15
- # @return [Integer] estimated token count of #system_text
16
- attr_reader :system_tokens
17
-
18
- def initialize
19
- @fingerprint = nil
20
- @system_text = nil
21
- @system_tokens = 0
22
- end
23
-
24
- # Returns true when the given fingerprint matches the stored one.
25
- #
26
- # @param fingerprint [String] SHA-256 hex digest to compare
27
- # @return [Boolean]
28
- # @api private
29
- def valid?(fingerprint)
30
- !@fingerprint.nil? && !@system_text.nil? && @fingerprint == fingerprint
31
- end
32
-
33
- # Update the cache with a new fingerprint and system text.
34
- #
35
- # @param fingerprint [String] new SHA-256 hex digest
36
- # @param system_text [String] fully assembled system prompt text
37
- # @api private
38
- def update(fingerprint:, system_text:)
39
- @fingerprint = fingerprint
40
- @system_text = system_text.to_s
41
- @system_tokens = TokenEstimator.estimate(@system_text)
42
- end
43
-
44
- # Clear all cached values (used for testing and forced invalidation).
45
- def reset
46
- @fingerprint = nil
47
- @system_text = nil
48
- @system_tokens = 0
49
- end
50
- end
51
- end
52
- end