phronomy 0.7.1 → 0.8.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -16
  3. data/benchmark/bench_context_assembler.rb +2 -2
  4. data/benchmark/bench_regression.rb +5 -5
  5. data/benchmark/bench_token_estimator.rb +5 -5
  6. data/benchmark/bench_tool_schema.rb +1 -1
  7. data/benchmark/bench_vector_store.rb +1 -1
  8. data/lib/phronomy/agent/base.rb +86 -123
  9. data/lib/phronomy/agent/checkpoint.rb +118 -0
  10. data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
  11. data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
  12. data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
  13. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  14. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
  15. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
  16. data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
  17. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
  18. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
  19. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
  20. data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
  21. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
  22. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
  23. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
  24. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
  25. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
  26. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
  27. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
  28. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
  29. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
  30. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
  31. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
  32. data/lib/phronomy/agent/fsm.rb +1 -1
  33. data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
  34. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  35. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  36. data/lib/phronomy/agent/react_agent.rb +19 -14
  37. data/lib/phronomy/agent/runner.rb +2 -2
  38. data/lib/phronomy/agent/tool_executor.rb +108 -0
  39. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  40. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  41. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  42. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  43. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  44. data/lib/phronomy/concurrency/deadline.rb +65 -0
  45. data/lib/phronomy/{runtime → concurrency}/gate_registry.rb +1 -1
  46. data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
  47. data/lib/phronomy/context.rb +2 -8
  48. data/lib/phronomy/embeddings.rb +2 -2
  49. data/lib/phronomy/eval/runner.rb +4 -0
  50. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  51. data/lib/phronomy/event_loop.rb +7 -7
  52. data/lib/phronomy/invocation_context.rb +3 -3
  53. data/lib/phronomy/knowledge_source.rb +0 -5
  54. data/lib/phronomy/llm_adapter/ruby_llm.rb +17 -11
  55. data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
  56. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  57. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  58. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  59. data/lib/phronomy/loader.rb +4 -4
  60. data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
  61. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +6 -6
  62. data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
  63. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
  64. data/lib/phronomy/runtime.rb +19 -4
  65. data/lib/phronomy/splitter.rb +3 -3
  66. data/lib/phronomy/task_group.rb +1 -1
  67. data/lib/phronomy/tool/base.rb +50 -9
  68. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  69. data/lib/phronomy/vector_store.rb +2 -2
  70. data/lib/phronomy/version.rb +1 -1
  71. data/lib/phronomy/workflow_context.rb +8 -0
  72. data/lib/phronomy/workflow_runner.rb +11 -131
  73. data/lib/phronomy.rb +1 -0
  74. metadata +44 -42
  75. data/lib/phronomy/async_queue.rb +0 -155
  76. data/lib/phronomy/blocking_adapter_pool.rb +0 -435
  77. data/lib/phronomy/cancellation_scope.rb +0 -123
  78. data/lib/phronomy/cancellation_token.rb +0 -133
  79. data/lib/phronomy/concurrency_gate.rb +0 -155
  80. data/lib/phronomy/context/compaction_context.rb +0 -111
  81. data/lib/phronomy/context/trigger_context.rb +0 -39
  82. data/lib/phronomy/context/trim_context.rb +0 -75
  83. data/lib/phronomy/deadline.rb +0 -63
  84. data/lib/phronomy/embeddings/base.rb +0 -39
  85. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  86. data/lib/phronomy/fsm_session.rb +0 -247
  87. data/lib/phronomy/knowledge_source/base.rb +0 -54
  88. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  89. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  90. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  91. data/lib/phronomy/loader/base.rb +0 -25
  92. data/lib/phronomy/loader/csv_loader.rb +0 -56
  93. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  94. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  95. data/lib/phronomy/prompt_template.rb +0 -96
  96. data/lib/phronomy/splitter/base.rb +0 -47
  97. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  98. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  99. data/lib/phronomy/tool_executor.rb +0 -106
  100. data/lib/phronomy/vector_store/async_backend.rb +0 -110
  101. data/lib/phronomy/vector_store/base.rb +0 -89
  102. data/lib/phronomy/vector_store/in_memory.rb +0 -93
  103. data/lib/phronomy/vector_store/pgvector.rb +0 -127
  104. data/lib/phronomy/vector_store/redis_search.rb +0 -192
@@ -56,6 +56,124 @@ module Phronomy
56
56
  @pending_tool_args = pending_tool_args
57
57
  @pending_tool_call_id = pending_tool_call_id
58
58
  end
59
+
60
+ # Converts this checkpoint to a plain Hash suitable for JSON / Marshal serialization.
61
+ #
62
+ # All values are plain Ruby objects (String, Symbol, Hash, Array, Numeric,
63
+ # nil). +RubyLLM::Message+ objects in +:messages+ are deep-converted so that
64
+ # any embedded +RubyLLM::ToolCall+ objects are also serialized as plain hashes.
65
+ #
66
+ # @example Round-trip via JSON
67
+ # json = JSON.generate(checkpoint.to_h)
68
+ # checkpoint2 = Phronomy::Agent::Checkpoint.from_h(JSON.parse(json))
69
+ #
70
+ # @return [Hash]
71
+ # @api public
72
+ def to_h
73
+ {
74
+ thread_id: @thread_id,
75
+ original_input: @original_input,
76
+ messages: @messages.map { |m| serialize_message(m) },
77
+ pending_tool_name: @pending_tool_name,
78
+ pending_tool_args: @pending_tool_args,
79
+ pending_tool_call_id: @pending_tool_call_id
80
+ }
81
+ end
82
+
83
+ # Reconstructs a +Checkpoint+ from a plain Hash (e.g. produced by {#to_h}
84
+ # and deserialized from JSON or Marshal).
85
+ #
86
+ # Hash keys may be either Symbols or Strings; both are accepted.
87
+ # +RubyLLM::ToolCall+ objects inside message +:tool_calls+ arrays are
88
+ # reconstructed from their hash representations.
89
+ #
90
+ # @param h [Hash] a hash previously produced by {#to_h}
91
+ # @return [Checkpoint]
92
+ # @api public
93
+ def self.from_h(h)
94
+ h = h.transform_keys { |k|
95
+ begin
96
+ k.to_sym
97
+ rescue
98
+ k
99
+ end
100
+ }
101
+ messages = Array(h[:messages]).map { |m| deserialize_message(m) }
102
+ new(
103
+ thread_id: h[:thread_id],
104
+ original_input: h[:original_input],
105
+ messages: messages,
106
+ pending_tool_name: h[:pending_tool_name]&.to_s,
107
+ pending_tool_args: h[:pending_tool_args] ? h[:pending_tool_args].transform_keys { |k|
108
+ begin
109
+ k.to_sym
110
+ rescue
111
+ k
112
+ end
113
+ } : {},
114
+ pending_tool_call_id: h[:pending_tool_call_id]&.to_s
115
+ )
116
+ end
117
+
118
+ private
119
+
120
+ # Converts a +RubyLLM::Message+ to a plain Hash, ensuring that any
121
+ # embedded +RubyLLM::ToolCall+ objects in +:tool_calls+ are also converted.
122
+ #
123
+ # @param msg [RubyLLM::Message]
124
+ # @return [Hash]
125
+ # @api private
126
+ def serialize_message(msg)
127
+ h = msg.to_h
128
+ return h unless h[:tool_calls]
129
+
130
+ h.merge(tool_calls: h[:tool_calls].map { |tc|
131
+ tc.respond_to?(:to_h) ? tc.to_h : tc
132
+ })
133
+ end
134
+
135
+ # Reconstructs a +RubyLLM::Message+ from a plain Hash.
136
+ # +RubyLLM::ToolCall+ entries in +:tool_calls+ are re-instantiated.
137
+ #
138
+ # @param h [Hash]
139
+ # @return [RubyLLM::Message]
140
+ # @api private
141
+ def self.deserialize_message(h)
142
+ h = h.transform_keys { |k|
143
+ begin
144
+ k.to_sym
145
+ rescue
146
+ k
147
+ end
148
+ }
149
+ if h[:tool_calls]
150
+ h = h.merge(tool_calls: Array(h[:tool_calls]).map { |tc|
151
+ next tc if tc.is_a?(RubyLLM::ToolCall)
152
+
153
+ tc = tc.transform_keys { |k|
154
+ begin
155
+ k.to_sym
156
+ rescue
157
+ k
158
+ end
159
+ }
160
+ RubyLLM::ToolCall.new(
161
+ id: tc[:id].to_s,
162
+ name: tc[:name].to_s,
163
+ arguments: (tc[:arguments] || {}).transform_keys { |k|
164
+ begin
165
+ k.to_sym
166
+ rescue
167
+ k
168
+ end
169
+ },
170
+ thought_signature: tc[:thought_signature]
171
+ )
172
+ })
173
+ end
174
+ RubyLLM::Message.new(h)
175
+ end
176
+ private_class_method :deserialize_message
59
177
  end
60
178
  end
61
179
  end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Context
6
+ module Conversation
7
+ # Context object passed to the +on_compact+ callback registered on an agent.
8
+ #
9
+ # The callback calls #compact one or more times to specify which ranges of
10
+ # messages to replace with a summary. Each call:
11
+ # 1. Yields the selected message elements to the block.
12
+ # 2. Receives the block's return value as the summary text.
13
+ # 3. Persists a compaction record to the memory store (if available).
14
+ # 4. Updates #result_messages so that the compacted range is replaced
15
+ # by a single +:system+ summary message.
16
+ #
17
+ # The agent reads #result_messages after the callback returns and uses it
18
+ # as the new message list for this invocation.
19
+ #
20
+ # @example Summarise the oldest half of the conversation
21
+ # on_compact do |ctx|
22
+ # half = ctx.message_elements.length / 2
23
+ # ctx.compact(0...half) do |elements|
24
+ # texts = elements.map { |e| "#{e[:role]}: #{e[:message].content}" }.join("\n")
25
+ # "Summary of earlier conversation:\n#{texts}"
26
+ # end
27
+ # end
28
+ class CompactionContext
29
+ # @return [Array<Hash>] message elements at compaction time
30
+ attr_reader :message_elements
31
+
32
+ # @return [Phronomy::LlmContextWindow::TokenBudget, nil]
33
+ attr_reader :budget
34
+
35
+ # @return [Integer] total estimated token count before compaction
36
+ attr_reader :total_tokens
37
+
38
+ # The current message list to be used after all compact calls have been made.
39
+ # Updated by each call to #compact.
40
+ #
41
+ # @return [Array]
42
+ attr_reader :result_messages
43
+
44
+ # @param message_elements [Array<Hash>]
45
+ # each element: { seq: Integer, message: Object, tokens: Integer, role: Symbol }
46
+ # @param budget [Phronomy::LlmContextWindow::TokenBudget, nil]
47
+ # @param thread_id [String, nil] used when saving compaction records
48
+ # @param memory [Object, nil] memory object; must respond to #save_compaction
49
+ # for compaction records to be persisted
50
+ # @api private
51
+ # mutant:disable - e[:tokens] vs e.fetch(:tokens) and e[:message] vs e.fetch(:message) are genuine equivalent mutations: elements always carry both keys
52
+ def initialize(message_elements:, budget:, thread_id: nil, memory: nil)
53
+ @message_elements = message_elements.dup
54
+ @budget = budget
55
+ @total_tokens = message_elements.sum { |e| e[:tokens] }
56
+ @thread_id = thread_id
57
+ @memory = memory
58
+ @result_messages = @message_elements.map { |e| e[:message] }
59
+ end
60
+
61
+ # Replace a range of messages with a summary produced by the block.
62
+ #
63
+ # The block receives the selected Array<Hash> elements and must return a
64
+ # String that serves as the summary text. After the call, #result_messages
65
+ # reflects the replacement.
66
+ #
67
+ # If the memory object responds to #save_compaction, a compaction record
68
+ # { start_seq:, end_seq:, summary_text: } is persisted for auditability.
69
+ #
70
+ # @param range [Range, Integer] index range into message_elements (0-based)
71
+ # @yieldparam elements [Array<Hash>] the selected message elements
72
+ # @yieldreturn [String] summary text to replace the selected messages
73
+ # @return [Array] the updated result_messages array
74
+ # @api private
75
+ # mutant:disable - multiple genuine equivalent mutations: is_a? vs instance_of? (Array/Range have no subclasses), yield.to_s vs yield (block always returns String), [:seq]/[:message] vs .fetch(:seq)/.fetch(:message) (keys always present), range.to_i vs range/to_int/Integer() (Integer is already integer), || [] vs nothing (Array#[] never returns nil for slice), RubyLLM::Message vs Message (killfork inherits Message=Struct from integration specs, both expose identical role/content interface)
76
+ def compact(range)
77
+ # Normalise: Integer index → single-element Array; Range → Array slice.
78
+ raw = @message_elements[range]
79
+ elements = if raw.is_a?(Array)
80
+ raw
81
+ elsif raw.nil?
82
+ []
83
+ else
84
+ [raw]
85
+ end
86
+ return @result_messages if elements.empty?
87
+
88
+ summary_text = yield(elements).to_s
89
+
90
+ start_seq = elements.first[:seq]
91
+ end_seq = elements.last[:seq]
92
+
93
+ if @memory && @thread_id && @memory.respond_to?(:save_compaction)
94
+ @memory.save_compaction(
95
+ thread_id: @thread_id,
96
+ start_seq: start_seq,
97
+ end_seq: end_seq,
98
+ summary_text: summary_text
99
+ )
100
+ end
101
+
102
+ # Compute the last included index in the original @message_elements array.
103
+ last_idx = if range.is_a?(Range)
104
+ range.exclude_end? ? range.last - 1 : range.last
105
+ else
106
+ range.to_i
107
+ end
108
+
109
+ remaining = (@message_elements[(last_idx + 1)..] || []).map { |e| e[:message] }
110
+ summary_msg = RubyLLM::Message.new(role: :system, content: summary_text)
111
+ @result_messages = [summary_msg] + remaining
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Context
6
+ module Conversation
7
+ # Read-only context passed to the +on_compaction_trigger+ callback.
8
+ #
9
+ # The callback inspects the current message list and budget, then returns
10
+ # a truthy value to trigger compaction or a falsy value to skip it.
11
+ #
12
+ # No mutations are allowed through this object; use CompactionContext
13
+ # (passed to +on_compact+) for actual modifications.
14
+ #
15
+ # @example Trigger compaction when messages exceed 80% of the input budget
16
+ # on_compaction_trigger do |ctx|
17
+ # limit = ctx.budget&.available(used: 0) || Float::INFINITY
18
+ # ctx.total_tokens > limit * 0.8
19
+ # end
20
+ class TriggerContext
21
+ # @return [Array<Hash>] frozen snapshot of message elements
22
+ # each element: { seq: Integer, message: Object, tokens: Integer, role: Symbol }
23
+ attr_reader :message_elements
24
+
25
+ # @return [Phronomy::LlmContextWindow::TokenBudget, nil] token budget for this invocation
26
+ attr_reader :budget
27
+
28
+ # @return [Integer] total estimated token count of all message elements
29
+ attr_reader :total_tokens
30
+
31
+ # @param message_elements [Array<Hash>]
32
+ # @param budget [Phronomy::LlmContextWindow::TokenBudget, nil]
33
+ # @api private
34
+ def initialize(message_elements:, budget:)
35
+ @message_elements = message_elements.dup.freeze
36
+ @budget = budget
37
+ @total_tokens = message_elements.sum { |e| e[:tokens] }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Context
6
+ module Conversation
7
+ # Context object passed to the +on_trim+ callback registered on an agent class.
8
+ #
9
+ # The callback receives a TrimContext and may call #remove to drop specific
10
+ # messages from the conversation before the LLM is called. Changes affect
11
+ # only the current invocation; the underlying memory store is not modified.
12
+ #
13
+ # Message elements are identified by a +:seq+ integer that is assigned
14
+ # sequentially (0-based) when messages are loaded from memory each turn.
15
+ #
16
+ # @example Remove the oldest two messages when the budget is tight
17
+ # on_trim do |ctx|
18
+ # if ctx.total_tokens > ctx.budget.available(used: 0) * 0.9
19
+ # seqs_to_drop = ctx.message_elements.first(2).map { |e| e[:seq] }
20
+ # ctx.remove(seqs_to_drop)
21
+ # end
22
+ # end
23
+ class TrimContext
24
+ # @return [Phronomy::LlmContextWindow::TokenBudget, nil] token budget for this invocation
25
+ attr_reader :budget
26
+
27
+ # @return [Integer] total estimated token count of all current message elements
28
+ attr_reader :total_tokens
29
+
30
+ # @param message_elements [Array<Hash>]
31
+ # each element: { seq: Integer, message: Object, tokens: Integer, role: Symbol }
32
+ # @param budget [Phronomy::LlmContextWindow::TokenBudget, nil]
33
+ # @api private
34
+ def initialize(message_elements:, budget:)
35
+ @message_elements = message_elements.dup
36
+ @budget = budget
37
+ recalculate!
38
+ end
39
+
40
+ # Returns a snapshot of the current message elements (defensive copy).
41
+ # Each element is a Hash with +:seq+, +:message+, +:tokens+, and +:role+.
42
+ #
43
+ # @return [Array<Hash>]
44
+ # @api private
45
+ def message_elements
46
+ @message_elements.dup
47
+ end
48
+
49
+ # Remove messages identified by seq numbers.
50
+ # Calling this multiple times accumulates removals.
51
+ #
52
+ # @param seqs [Integer, Array<Integer>] seq number(s) to remove
53
+ # @return [self]
54
+ # @api private
55
+ # mutant:disable - Array(seqs).to_set vs Array(seqs) and e[:seq] vs e.fetch(:seq) are genuine equivalent: Array#include? returns identical results for both
56
+ def remove(seqs)
57
+ seqs_set = Array(seqs).to_set
58
+ @message_elements.reject! { |e| seqs_set.include?(e[:seq]) }
59
+ recalculate!
60
+ self
61
+ end
62
+
63
+ # Convenience: returns the plain message objects (without element metadata).
64
+ #
65
+ # @return [Array]
66
+ # @api private
67
+ # mutant:disable - e[:message] vs e.fetch(:message) is a genuine equivalent mutation: elements always carry :message
68
+ def messages
69
+ @message_elements.map { |e| e[:message] }
70
+ end
71
+
72
+ private
73
+
74
+ # mutant:disable - e[:tokens] vs e.fetch(:tokens) is a genuine equivalent mutation: elements always carry :tokens
75
+ def recalculate!
76
+ @total_tokens = @message_elements.sum { |e| e[:tokens] }
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Context
6
+ module Instruction
7
+ # A prompt template that substitutes {{variable}} placeholders in a string.
8
+ #
9
+ # @example Simple human template
10
+ # t = Phronomy::Agent::Context::Instruction::PromptTemplate.new(template: "Translate to {{lang}}: {{text}}")
11
+ # t.format(lang: "French", text: "Hello")
12
+ # # => "Translate to French: Hello"
13
+ #
14
+ # @example With a system template
15
+ # t = Phronomy::Agent::Context::Instruction::PromptTemplate.new(
16
+ # template: "{{question}}",
17
+ # system_template: "You are a {{role}} assistant."
18
+ # )
19
+ # t.format_system(role: "helpful")
20
+ # # => "You are a helpful assistant."
21
+ #
22
+ # As a Runnable, #invoke accepts a Hash of variables and returns a Hash
23
+ # with :prompt (and optionally :system) keys.
24
+ class PromptTemplate
25
+ include Phronomy::Runnable
26
+
27
+ PLACEHOLDER = /\{\{(\w+)\}\}/
28
+
29
+ attr_reader :template, :system_template
30
+
31
+ # @param template [String] human message template with {{var}} placeholders
32
+ # @param system_template [String, nil] optional system message template
33
+ # @api public
34
+ def initialize(template:, system_template: nil)
35
+ @template = template
36
+ @system_template = system_template
37
+ end
38
+
39
+ # Substitute all {{var}} placeholders in the human template.
40
+ #
41
+ # @param variables [Hash{Symbol => String}]
42
+ # @return [String]
43
+ # @api public
44
+ def format(**variables)
45
+ substitute(@template, variables)
46
+ end
47
+
48
+ # Substitute all {{var}} placeholders in the system template.
49
+ # Returns nil when no system template was set.
50
+ #
51
+ # @param variables [Hash{Symbol => String}]
52
+ # @return [String, nil]
53
+ # @api public
54
+ def format_system(**variables)
55
+ @system_template && substitute(@system_template, variables)
56
+ end
57
+
58
+ # Runnable interface: accepts a Hash of variable values.
59
+ # Returns { prompt: String, system: String|nil }.
60
+ #
61
+ # @param input [Hash{Symbol => String}]
62
+ # @return [Hash]
63
+ # @api public
64
+ def invoke(input, config: {})
65
+ vars = normalize_input(input)
66
+ result = {prompt: format(**vars)}
67
+ sys = format_system(**vars)
68
+ result[:system] = sys if sys
69
+ result
70
+ end
71
+
72
+ # Returns the list of placeholder names found in both templates.
73
+ #
74
+ # @return [Array<Symbol>]
75
+ # @api public
76
+ def variables
77
+ names = @template.scan(PLACEHOLDER).flatten
78
+ names += @system_template.scan(PLACEHOLDER).flatten if @system_template
79
+ names.map(&:to_sym).uniq
80
+ end
81
+
82
+ private
83
+
84
+ def substitute(text, variables)
85
+ text.gsub(PLACEHOLDER) do |match|
86
+ key = Regexp.last_match(1).to_sym
87
+ variables.fetch(key) { raise KeyError, "Missing variable: {{#{key}}}" }
88
+ end
89
+ end
90
+
91
+ def normalize_input(input)
92
+ case input
93
+ when Hash then input
94
+ when String then {input: input}
95
+ else raise ArgumentError, "PromptTemplate#invoke expects a Hash of variables, got #{input.class}"
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Context
6
+ module Knowledge
7
+ module Embeddings
8
+ # Abstract interface for embedding adapters.
9
+ #
10
+ # Concrete implementations must override {#embed} and return a vector
11
+ # as an +Array<Float>+.
12
+ class Base
13
+ # Embed the given text and return a vector representation.
14
+ #
15
+ # @param text [String] the text to embed
16
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; raises CancellationError when cancelled
17
+ # @return [Array<Float>] the embedding vector
18
+ # @api public
19
+ def embed(text, cancellation_token = nil)
20
+ cancellation_token&.raise_if_cancelled!
21
+ raise NotImplementedError, "#{self.class}#embed is not implemented"
22
+ end
23
+
24
+ # Submits an {#embed} call to {BlockingAdapterPool} and returns a
25
+ # {BlockingAdapterPool::PendingOperation}.
26
+ #
27
+ # @param text [String]
28
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
29
+ # @param timeout [Numeric, nil] seconds before the operation is abandoned
30
+ # @return [BlockingAdapterPool::PendingOperation]
31
+ # @api public
32
+ def embed_async(text, cancellation_token = nil, timeout: nil)
33
+ Phronomy::Runtime.instance.blocking_io.submit(
34
+ timeout: timeout,
35
+ cancellation_token: cancellation_token
36
+ ) do
37
+ embed(text, cancellation_token)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Context
6
+ module Knowledge
7
+ module Embeddings
8
+ # Embeddings adapter backed by RubyLLM.
9
+ #
10
+ # Delegates to +RubyLLM.embed+ and returns the resulting vector as an
11
+ # +Array<Float>+.
12
+ #
13
+ # @example Default model
14
+ # embeddings = Phronomy::Agent::Context::Knowledge::Embeddings::RubyLLMEmbeddings.new
15
+ # vector = embeddings.embed("Hello, world!")
16
+ #
17
+ # @example Explicit model
18
+ # embeddings = Phronomy::Agent::Context::Knowledge::Embeddings::RubyLLMEmbeddings.new(model: "text-embedding-3-small")
19
+ # vector = embeddings.embed("Hello, world!")
20
+ class RubyLLMEmbeddings < Base
21
+ # @param model [String, nil] embedding model identifier; nil uses the RubyLLM default
22
+ # @param provider [Symbol, nil] provider override (e.g. :openai); nil uses the RubyLLM default
23
+ # @param assume_model_exists [Boolean] when true, skips RubyLLM model-registry validation
24
+ # (useful for locally hosted models not in the registry)
25
+ # @api public
26
+ def initialize(model: nil, provider: nil, assume_model_exists: false)
27
+ @model = model
28
+ @provider = provider
29
+ @assume_model_exists = assume_model_exists
30
+ end
31
+
32
+ # Embed text via RubyLLM.
33
+ #
34
+ # @param text [String]
35
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; raises CancellationError when cancelled
36
+ # @return [Array<Float>]
37
+ # @api public
38
+ def embed(text, cancellation_token = nil)
39
+ cancellation_token&.raise_if_cancelled!
40
+ opts = {}
41
+ opts[:model] = @model if @model
42
+ opts[:provider] = @provider if @provider
43
+ opts[:assume_model_exists] = true if @assume_model_exists
44
+ RubyLLM.embed(text, **opts).vectors
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Context
6
+ module Knowledge
7
+ module Loader
8
+ # Abstract base class for document loaders.
9
+ #
10
+ # A loader converts an external source (file path, URL, etc.) into an
11
+ # Array of document hashes understood by the rest of the pipeline:
12
+ #
13
+ # [{ text: String, metadata: Hash }, ...]
14
+ #
15
+ # Subclasses must implement {#load}.
16
+ class Base
17
+ # Load documents from +source+ and return an array of document hashes.
18
+ #
19
+ # @param source [String] file path, URL, or other source identifier
20
+ # @return [Array<Hash>] array of <tt>{ text: String, metadata: Hash }</tt>
21
+ # @raise [NotImplementedError] when not overridden by a subclass
22
+ # @api public
23
+ def load(source)
24
+ raise NotImplementedError, "#{self.class}#load is not implemented"
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ module Context
8
+ module Knowledge
9
+ module Loader
10
+ # Loads a CSV file, converting each row into a separate document.
11
+ #
12
+ # By default the first row is treated as a header and column names are
13
+ # available in the document metadata. The full row is serialised to
14
+ # a human-readable "key: value" string for embedding.
15
+ #
16
+ # @example
17
+ # loader = Phronomy::Agent::Context::Knowledge::Loader::CsvLoader.new
18
+ # docs = loader.load("products.csv")
19
+ # # => [
20
+ # # { text: "name: Widget\nprice: 9.99", metadata: { source: "...", row: 1, name: "Widget", price: "9.99" } },
21
+ # # ...
22
+ # # ]
23
+ class CsvLoader < Base
24
+ # @param headers [Boolean] treat the first row as headers (default: true)
25
+ # @param text_column [String, nil] if set, use only this column as the document text
26
+ # @api public
27
+ def initialize(headers: true, text_column: nil)
28
+ @headers = headers
29
+ @text_column = text_column
30
+ end
31
+
32
+ # @param source [String] path to a CSV file
33
+ # @return [Array<Hash>]
34
+ # @raise [Errno::ENOENT] if the file does not exist
35
+ # @api public
36
+ def load(source)
37
+ rows = CSV.read(source, headers: @headers, encoding: "UTF-8")
38
+
39
+ if @headers
40
+ rows.each_with_index.map do |row, idx|
41
+ row_hash = row.to_h
42
+ text = if @text_column
43
+ row_hash[@text_column].to_s
44
+ else
45
+ row_hash.map { |k, v| "#{k}: #{v}" }.join("\n")
46
+ end
47
+ metadata = row_hash.transform_keys(&:to_sym).merge(source: source, row: idx + 1)
48
+ {text: text, metadata: metadata}
49
+ end
50
+ else
51
+ rows.each_with_index.map do |row, idx|
52
+ text = row.join(", ")
53
+ {text: text, metadata: {source: source, row: idx + 1}}
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end