phronomy 0.5.4 → 0.6.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.
@@ -49,7 +49,7 @@ module Phronomy
49
49
  # Encoding:
50
50
  # :__end__ — workflow completed (or not yet started)
51
51
  # :awaiting_<name> — halted at a wait_state(:awaiting_<name>) declaration
52
- # :<node> — resuming at <node> (workflow paused before its execution)
52
+ # :<state> — resuming at <state> (workflow paused before its execution)
53
53
  # @return [Symbol]
54
54
  def phase
55
55
  @phase || :__end__
@@ -5,7 +5,7 @@ require "state_machines"
5
5
 
6
6
  module Phronomy
7
7
  # Execution engine for compiled workflows.
8
- # Manages node execution, phase transitions, halt/resume, and wait states.
8
+ # Manages state entry/exit action execution, phase transitions, halt/resume, and wait states.
9
9
  # Instantiated by Phronomy::Workflow and used internally.
10
10
  #
11
11
  # == Design principle
@@ -16,39 +16,41 @@ module Phronomy
16
16
  # the PhaseTracker itself. This ensures that "what happens next" is always
17
17
  # determined by the declared state machine topology, never by Phronomy internals.
18
18
  #
19
- # == Three transition categories registered in PhaseTracker
19
+ # Entry and exit actions are registered as state_machines +after_transition to:+
20
+ # and +before_transition from:+ callbacks respectively. The WorkflowContext is
21
+ # mutable; actions receive it and modify fields in place.
20
22
  #
21
- # 1. advance_<from> — automatic, unconditional after-transitions
22
- # fired when an action state's action completes
23
- # (declared with +after :foo, to: :bar+)
23
+ # The sole exception is the initial state: state_machines does not fire transition
24
+ # callbacks on initialization, so the entry action for the entry point is invoked
25
+ # directly by WorkflowRunner before the main execution loop begins.
24
26
  #
25
- # 2. route — a single event that carries all guarded transitions
26
- # (declared with +event :route, from: :foo, guard: ..., to: :bar+)
27
+ # == Two transition categories registered in PhaseTracker
28
+ #
29
+ # 1. state_completed — all auto-fire transitions (with or without guards).
30
+ # Fired when an action state's action completes.
27
31
  # Guards are evaluated in declaration order; first match wins.
28
- # An unguarded fallback, if declared, is evaluated last.
32
+ # (declared with +transition from: :foo, to: :bar+ or
33
+ # +transition from: :foo, guard: ..., to: :bar+)
29
34
  #
30
- # 3. <event_name> — external events triggered by human input, originating
35
+ # 2. <event_name> — external events triggered by human input, originating
31
36
  # from wait states
32
- # (declared with +event :approve, from: :awaiting, to: :run+)
37
+ # (declared with +transition from: :awaiting, on: :approve, to: :run+)
33
38
  class WorkflowRunner
34
39
  include Phronomy::Runnable
35
40
 
36
41
  # Sentinel value for the terminal state of a workflow.
37
42
  FINISH = :__end__
38
43
 
39
- def initialize(state_class:, nodes:, after_transitions:, route_transitions:,
40
- external_events:, entry_point:, wait_state_names: [],
41
- before_callbacks: {}, after_callbacks: {})
44
+ def initialize(state_class:, entry_actions:, declared_states:, auto_transitions:, external_events:, entry_point:, exit_actions: {}, wait_state_names: [])
42
45
  @state_class = state_class
43
- @nodes = nodes
44
- @after_transitions = after_transitions # { from => to }
45
- @route_transitions = route_transitions # { from => [{guard:, to:}, ...] }
46
+ @entry_actions = entry_actions # { state_name => [callable, ...] }
47
+ @declared_states = declared_states
48
+ # Lookup set: states with at least one auto-fire transition declared.
49
+ @auto_state_set = auto_transitions.each_with_object({}) { |t, h| h[t[:from]] = true }
46
50
  @external_events = external_events # { name => [{from:, to:, guard:}, ...] }
47
51
  @entry_point = entry_point
48
52
  @wait_state_names = wait_state_names
49
- @before_callbacks = before_callbacks.dup
50
- @after_callbacks = after_callbacks.dup
51
- @phase_machine_class = build_phase_machine_class
53
+ @phase_machine_class = build_phase_machine_class(auto_transitions, exit_actions)
52
54
  end
53
55
 
54
56
  # Executes the workflow from the initial state.
@@ -60,12 +62,16 @@ module Phronomy
60
62
  caller_meta[:user_id] = config[:user_id] if config[:user_id]
61
63
  caller_meta[:session_id] = config[:session_id] if config[:session_id]
62
64
 
63
- trace("graph.invoke", input: input.inspect, **caller_meta) do |_span|
65
+ trace("workflow.invoke", input: input.inspect, **caller_meta) do |_span|
64
66
  thread_id = config[:thread_id] || SecureRandom.uuid
65
67
  recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
66
68
  state = @state_class.new(**input)
67
69
  state.set_graph_metadata(thread_id: thread_id)
68
- result = run_graph(state, recursion_limit: recursion_limit)
70
+ result = if Phronomy.configuration.event_loop
71
+ run_via_event_loop(state, recursion_limit: recursion_limit)
72
+ else
73
+ run_workflow(state, recursion_limit: recursion_limit)
74
+ end
69
75
  [result, nil]
70
76
  end
71
77
  end
@@ -92,9 +98,6 @@ module Phronomy
92
98
  event = event.to_sym
93
99
  current_phase = state.phase
94
100
 
95
- tracker = new_phase_machine(current_phase)
96
- tracker.context = state
97
-
98
101
  ev_to_fire = if event == :resume
99
102
  # Find the first external event that can originate from the current wait state.
100
103
  name, = @external_events.find { |_, ts| ts.any? { |t| t[:from] == current_phase } }
@@ -111,14 +114,16 @@ module Phronomy
111
114
  event
112
115
  end
113
116
 
114
- fire_event!(tracker, ev_to_fire, current_phase)
115
-
116
- next_phase = tracker.phase.to_sym
117
- next_node = (next_phase == :__end__) ? FINISH : next_phase
118
- run_graph(state, from_node: next_node)
117
+ if Phronomy.configuration.event_loop
118
+ run_via_event_loop(state,
119
+ recursion_limit: Phronomy.configuration.recursion_limit,
120
+ resume_event: ev_to_fire, resume_phase: current_phase)
121
+ else
122
+ run_workflow(state, resume_event: ev_to_fire, resume_phase: current_phase)
123
+ end
119
124
  end
120
125
 
121
- # Streaming execution. Yields { node: Symbol, state: Object } after each node completes.
126
+ # Streaming execution. Yields { state: Symbol, context: Object } after each state action completes.
122
127
  # @param input [Hash]
123
128
  # @param config [Hash]
124
129
  # @yield [Hash]
@@ -128,145 +133,173 @@ module Phronomy
128
133
  recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
129
134
  state = @state_class.new(**input)
130
135
  state.set_graph_metadata(thread_id: thread_id)
131
- run_graph(state, recursion_limit: recursion_limit, &block)
136
+ run_workflow(state, recursion_limit: recursion_limit, &block)
132
137
  end
133
138
 
134
139
  private
135
140
 
136
- def run_graph(state, from_node: nil, recursion_limit: 25, &event_block)
137
- current_node = from_node || @entry_point
138
- tracker = new_phase_machine(current_node)
139
- tracker.context = state
140
- # Event queue: decouple node execution from transition firing.
141
- # Events are enqueued after a node completes and processed at the top
141
+ # Builds an FSMSession for the given context. Used in EventLoop mode.
142
+ def build_session_for(context:, recursion_limit:, resume_event: nil, resume_phase: nil)
143
+ Phronomy::FSMSession.new(
144
+ id: context.thread_id,
145
+ context: context,
146
+ entry_point: @entry_point,
147
+ entry_actions: @entry_actions,
148
+ auto_state_set: @auto_state_set,
149
+ declared_states: @declared_states,
150
+ wait_state_names: @wait_state_names,
151
+ external_events: @external_events,
152
+ phase_machine_class: @phase_machine_class,
153
+ recursion_limit: recursion_limit,
154
+ resume_event: resume_event,
155
+ resume_phase: resume_phase
156
+ )
157
+ end
158
+
159
+ # Executes the workflow via the singleton EventLoop.
160
+ # Blocks the calling thread on a completion queue until the workflow
161
+ # finishes, halts at a wait state, or raises an error.
162
+ def run_via_event_loop(context, recursion_limit:, resume_event: nil, resume_phase: nil)
163
+ session = build_session_for(
164
+ context: context, recursion_limit: recursion_limit,
165
+ resume_event: resume_event, resume_phase: resume_phase
166
+ )
167
+ completion_queue = Phronomy::EventLoop.instance.register(session)
168
+ result = completion_queue.pop
169
+ raise result if result.is_a?(Exception)
170
+ result
171
+ end
172
+
173
+ def run_workflow(ctx, resume_event: nil, resume_phase: nil, recursion_limit: 25, &event_block)
174
+ if resume_event
175
+ # -- Resume from a wait state -------------------------------------------
176
+ # Fire the external event on a tracker positioned at the wait state.
177
+ # state_machines will invoke before_transition (exit) and after_transition
178
+ # (entry) callbacks as part of the transition, so both actions fire here.
179
+ current_state = resume_phase
180
+ tracker = new_phase_machine(current_state)
181
+ tracker.context = ctx
182
+ fire_event!(tracker, resume_event, current_state)
183
+ next_phase = tracker.phase.to_sym
184
+ current_state = (next_phase == current_state) ? FINISH : next_phase
185
+ else
186
+ # -- Fresh start --------------------------------------------------------
187
+ current_state = @entry_point
188
+ tracker = new_phase_machine(current_state)
189
+ tracker.context = ctx
190
+ # state_machines only fires after_transition callbacks on transitions.
191
+ # The entry point has no prior transition, so we invoke its entry actions directly.
192
+ @entry_actions[current_state]&.each { |c| c.call(ctx) }
193
+ end
194
+
195
+ # Event queue: decouple action execution from transition firing.
196
+ # Events are enqueued after visiting a state and processed at the top
142
197
  # of the next iteration so that guards always see the freshest context.
143
198
  event_queue = []
144
199
  step = 0
145
200
 
146
201
  loop do
147
- break if current_node == FINISH
202
+ break if current_state == FINISH
148
203
 
149
204
  # -- Process next pending event -----------------------------------------
150
205
  # Dequeue one event and fire it against the state machine. Guards are
151
- # evaluated here (at fire time) so they see the context written by the
152
- # node that enqueued the event.
206
+ # evaluated here (at fire time). Entry/exit callbacks fire inside fire_event!.
153
207
  if (event = event_queue.shift)
154
208
  if step >= recursion_limit
155
209
  raise Phronomy::RecursionLimitError,
156
210
  "Recursion limit (#{recursion_limit}) exceeded"
157
211
  end
158
212
 
159
- fire_event!(tracker, event, current_node)
213
+ fire_event!(tracker, event, current_state)
160
214
  next_phase = tracker.phase.to_sym
161
- # When next_phase == current_node no transition matched → terminal node.
162
- current_node = (next_phase == current_node) ? FINISH : next_phase
215
+ # When next_phase == current_state no transition matched → terminal state.
216
+ current_state = (next_phase == current_state) ? FINISH : next_phase
163
217
  step += 1
164
218
  next
165
219
  end
166
220
 
167
221
  # -- Queue empty: check for halt -----------------------------------------
168
222
  # Auto-halt at wait states: persist phase in context and return to caller.
169
- # The caller resumes via send_event, which starts a fresh run_graph call.
170
- if @wait_state_names.include?(current_node)
171
- state.set_graph_metadata(thread_id: state.thread_id, phase: current_node)
172
- return state
223
+ # The caller resumes via send_event.
224
+ if @wait_state_names.include?(current_state)
225
+ ctx.set_graph_metadata(thread_id: ctx.thread_id, phase: current_state)
226
+ return ctx
173
227
  end
174
228
 
175
- # -- Execute node action ------------------------------------------------
176
- node_fn = @nodes[current_node]
177
- raise ArgumentError, "Node #{current_node.inspect} is not defined" unless node_fn
178
-
179
- result = node_fn.call(state)
180
- state = case result
181
- when Hash then state.merge(result)
182
- when @state_class then result
183
- when nil then state
184
- else
185
- raise ArgumentError,
186
- "Node #{current_node} returned #{result.class}; " \
187
- "expected Hash, #{@state_class}, or nil"
229
+ # -- Validate state is known --------------------------------------------
230
+ unless @declared_states.include?(current_state)
231
+ raise ArgumentError, "State #{current_state.inspect} is not defined"
188
232
  end
189
233
 
190
- # Update tracker so guards see the freshest context when the event fires.
191
- tracker.context = state
234
+ # -- Emit stream event and enqueue transition ---------------------------
235
+ # Entry action for current_state has already been invoked (either by the
236
+ # initial manual call above, or by the after_transition callback fired
237
+ # inside fire_event! on the previous iteration).
238
+ event_block&.call({state: current_state, context: ctx})
192
239
 
193
- event_block&.call({node: current_node, state: state})
194
-
195
- # -- Enqueue transition event -------------------------------------------
196
- # node_completed: generic event for all after-transitions (unconditional).
197
- # route event: user-named event carrying guarded conditional branches.
198
- # No enqueue: terminal node — next iteration exits via FINISH check.
199
- if @after_transitions.key?(current_node)
200
- event_queue << :node_completed
201
- elsif @route_transitions.key?(current_node)
202
- event_queue << @route_transitions[current_node][:event_name]
240
+ # state_completed: unified event for all auto-fire transitions.
241
+ # No enqueue: terminal state — next iteration exits via FINISH check.
242
+ if @auto_state_set.key?(current_state)
243
+ event_queue << :state_completed
203
244
  else
204
- current_node = FINISH
245
+ current_state = FINISH
205
246
  end
206
247
  end
207
248
 
208
- state.set_graph_metadata(thread_id: state.thread_id, phase: :__end__)
209
- state
249
+ ctx.set_graph_metadata(thread_id: ctx.thread_id, phase: :__end__)
250
+ ctx
210
251
  end
211
252
 
212
253
  # Fires +event_name+ on +tracker+, raising a descriptive error if no
213
254
  # transition matches. state_machines event methods return false when no
214
255
  # transition can be taken (invalid state or all guards fail).
215
- def fire_event!(tracker, event_name, from_node)
256
+ def fire_event!(tracker, event_name, from_state)
216
257
  return if tracker.send(event_name)
217
258
 
218
259
  raise ArgumentError,
219
- "Transition from #{from_node.inspect} via event #{event_name.inspect} failed. " \
260
+ "Transition from #{from_state.inspect} via event #{event_name.inspect} failed. " \
220
261
  "Ensure at least one guard matches or add a fallback (no-guard) transition."
221
262
  end
222
263
 
223
264
  # Builds the PhaseTracker class backed by state_machines.
224
265
  #
225
- # Three event types are registered:
226
- # advance_<from> unconditional after-transitions
227
- # route all guarded routing transitions (one event, multiple transitions)
228
- # <external_name>external events originating from wait states
266
+ # Four event/callback types are registered:
267
+ # state_completed all auto-fire transitions (guarded and unguarded)
268
+ # <external_name> external events originating from wait states
269
+ # after_transition to entry callbacks (invoked when entering a state)
270
+ # before_transition from — exit callbacks (invoked when leaving a state)
229
271
  #
230
272
  # Guard lambdas bridge the PhaseTracker and WorkflowContext via +m.context+.
231
- def build_phase_machine_class
273
+ def build_phase_machine_class(auto_transitions, exit_actions)
232
274
  entry = @entry_point
233
- all_states = (@nodes.keys + @wait_state_names + [:__end__]).uniq
234
- after_trans = @after_transitions # { from => to }
235
- route_trans = @route_transitions # { from => [{guard:, to:}, ...] }
236
- ext_events = @external_events # { name => [{from:, to:, guard:}, ...] }
275
+ all_states = (@declared_states + @wait_state_names + [:__end__]).uniq
276
+ auto_trans = auto_transitions # Array of { from:, to:, guard: }
277
+ ext_events = @external_events
278
+ entry_acts = @entry_actions
279
+ exit_acts = exit_actions
237
280
 
238
281
  Class.new do
239
- # Holds the current WorkflowContext so guards can read it.
282
+ # Holds the current WorkflowContext so guards and callbacks can read it.
240
283
  attr_accessor :context
241
284
 
242
285
  state_machine :phase, initial: entry do
243
286
  all_states.each { |s| state s }
244
287
 
245
- # 1. After-transitions: one generic :node_completed event covers all
246
- # unconditional transitions. This keeps event names independent of
247
- # source state names and matches standard state machine semantics.
248
- event :node_completed do
249
- after_trans.each do |from, to|
250
- transition from => to
251
- end
252
- end
253
-
254
- # 2. Route events: one named event per from-state (name may vary).
255
- # Declaration order is preserved; guards first, unguarded fallback last.
256
- route_trans.each do |from, routing|
257
- event routing[:event_name] do
258
- routing[:entries].each do |t|
259
- if t[:guard]
260
- guard_proc = t[:guard]
261
- transition from => t[:to], :if => ->(m) { guard_proc.call(m.context) }
262
- else
263
- transition from => t[:to]
264
- end
288
+ # Auto-fire transitions: all auto transitions unified under :state_completed.
289
+ # Includes unguarded (unconditional) and guarded (conditional) transitions.
290
+ # Declaration order is preserved; guards are evaluated before unguarded fallbacks.
291
+ event :state_completed do
292
+ auto_trans.each do |t|
293
+ if t[:guard]
294
+ guard_proc = t[:guard]
295
+ transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
296
+ else
297
+ transition t[:from] => t[:to]
265
298
  end
266
299
  end
267
300
  end
268
301
 
269
- # 3. External events: human-in-the-loop triggers from wait states.
302
+ # External events: human-in-the-loop triggers from wait states.
270
303
  ext_events.each do |ev_name, transitions|
271
304
  event ev_name do
272
305
  transitions.each do |t|
@@ -279,18 +312,40 @@ module Phronomy
279
312
  end
280
313
  end
281
314
  end
315
+
316
+ # Entry callbacks: fire after_transition into each state.
317
+ # Each callable is registered as a separate callback; state_machines
318
+ # accumulates them and fires in declaration order.
319
+ entry_acts.each do |state_name, callables|
320
+ callables.each do |callable|
321
+ after_transition to: state_name do |machine|
322
+ callable.call(machine.context)
323
+ end
324
+ end
325
+ end
326
+
327
+ # Exit callbacks: fire before_transition out of each state.
328
+ # Each callable is registered as a separate callback; state_machines
329
+ # accumulates them and fires in declaration order.
330
+ exit_acts.each do |state_name, callables|
331
+ callables.each do |callable|
332
+ before_transition from: state_name do |machine|
333
+ callable.call(machine.context)
334
+ end
335
+ end
336
+ end
282
337
  end
283
338
  end
284
339
  rescue => e
285
340
  raise ArgumentError, "Failed to build phase machine: #{e.message}"
286
341
  end
287
342
 
288
- # Creates a PhaseTracker instance initialized to +from_node+.
289
- def new_phase_machine(from_node)
343
+ # Creates a PhaseTracker instance initialized to +from_state+.
344
+ def new_phase_machine(from_state)
290
345
  machine = @phase_machine_class.new
291
346
  # Override the initial state set by state_machine's initializer so we can
292
- # resume from an arbitrary node (e.g. after a wait state).
293
- machine.instance_variable_set(:@phase, from_node.to_s)
347
+ # resume from an arbitrary state (e.g. after a wait state).
348
+ machine.instance_variable_set(:@phase, from_state.to_s)
294
349
  machine
295
350
  end
296
351
  end
data/lib/phronomy.rb CHANGED
@@ -8,6 +8,10 @@ loader = Zeitwerk::Loader.for_gem
8
8
  # Teach Zeitwerk that "llm" maps to "LLM" so that file names such as
9
9
  # ruby_llm_embeddings.rb resolve to RubyLLMEmbeddings (not RubyLlmEmbeddings).
10
10
  loader.inflector.inflect("ruby_llm_embeddings" => "RubyLLMEmbeddings")
11
+ # FSMSession: Zeitwerk would infer "FsmSession" — override to "FSMSession".
12
+ loader.inflector.inflect("fsm_session" => "FSMSession")
13
+ # AgentFSM: Zeitwerk would infer "Fsm" — override to "FSM".
14
+ loader.inflector.inflect("fsm" => "FSM")
11
15
  loader.setup
12
16
 
13
17
  require_relative "phronomy/version"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phronomy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.4
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raizo T.C.S
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-20 00:00:00.000000000 Z
11
+ date: 2026-05-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -73,8 +73,10 @@ files:
73
73
  - lib/phronomy/agent/concerns/guardrailable.rb
74
74
  - lib/phronomy/agent/concerns/retryable.rb
75
75
  - lib/phronomy/agent/concerns/suspendable.rb
76
+ - lib/phronomy/agent/fsm.rb
76
77
  - lib/phronomy/agent/handoff.rb
77
78
  - lib/phronomy/agent/orchestrator.rb
79
+ - lib/phronomy/agent/parallel_tool_chat.rb
78
80
  - lib/phronomy/agent/react_agent.rb
79
81
  - lib/phronomy/agent/runner.rb
80
82
  - lib/phronomy/agent/shared_state.rb
@@ -83,7 +85,6 @@ files:
83
85
  - lib/phronomy/configuration.rb
84
86
  - lib/phronomy/context.rb
85
87
  - lib/phronomy/context/assembler.rb
86
- - lib/phronomy/context/builder.rb
87
88
  - lib/phronomy/context/compaction_context.rb
88
89
  - lib/phronomy/context/context_version_cache.rb
89
90
  - lib/phronomy/context/token_budget.rb
@@ -105,12 +106,12 @@ files:
105
106
  - lib/phronomy/eval/scorer/exact_match.rb
106
107
  - lib/phronomy/eval/scorer/includes_scorer.rb
107
108
  - lib/phronomy/eval/scorer/llm_judge.rb
109
+ - lib/phronomy/event.rb
110
+ - lib/phronomy/event_loop.rb
111
+ - lib/phronomy/fsm_session.rb
108
112
  - lib/phronomy/generator_verifier.rb
109
113
  - lib/phronomy/guardrail.rb
110
114
  - lib/phronomy/guardrail/base.rb
111
- - lib/phronomy/guardrail/builtin.rb
112
- - lib/phronomy/guardrail/builtin/pii_pattern_detector.rb
113
- - lib/phronomy/guardrail/builtin/prompt_injection_detector.rb
114
115
  - lib/phronomy/guardrail/input_guardrail.rb
115
116
  - lib/phronomy/guardrail/output_guardrail.rb
116
117
  - lib/phronomy/knowledge_source.rb
@@ -1,92 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Context
5
- # Assembles ordered context sections (system prompt, knowledge, conversation
6
- # history) within a given token budget.
7
- #
8
- # Usage:
9
- # builder = Phronomy::Context::Builder.new(budget: budget)
10
- # builder.add_system(instructions_text)
11
- # builder.add_knowledge(knowledge_text)
12
- # builder.add_messages(messages)
13
- # messages_to_send = builder.build
14
- #
15
- # Sections are added in priority order. When the budget is exceeded the
16
- # lower-priority tail of each section is truncated.
17
- class Builder
18
- # @param budget [Phronomy::Context::TokenBudget]
19
- def initialize(budget:)
20
- @budget = budget
21
- @system = nil
22
- @knowledge = []
23
- @messages = []
24
- end
25
-
26
- # Set the system instructions text (highest priority).
27
- # @param text [String]
28
- def add_system(text)
29
- @system = text.to_s
30
- self
31
- end
32
-
33
- # Append knowledge/RAG text (medium priority).
34
- # @param text [String]
35
- def add_knowledge(text)
36
- @knowledge << text.to_s
37
- self
38
- end
39
-
40
- # Set conversation messages (lowest priority — oldest are dropped first).
41
- # @param messages [Array] list of message-like objects with #role and #content
42
- def add_messages(messages)
43
- @messages = Array(messages)
44
- self
45
- end
46
-
47
- # Assemble the context respecting the token budget.
48
- #
49
- # Returns a hash with:
50
- # :system [String, nil] system prompt (instructions + knowledge)
51
- # :messages [Array] conversation messages that fit within the budget
52
- #
53
- # @return [Hash]
54
- def build
55
- used = 0
56
-
57
- # System prompt is always included (budget enforcement is informational only).
58
- system_text = [@system, *@knowledge].compact.join("\n\n")
59
- used += TokenEstimator.estimate(system_text)
60
-
61
- # Conversation messages — keep as many recent messages as fit.
62
- remaining = @budget.available(used: used)
63
- kept = fit_messages_to_budget(@messages, remaining)
64
-
65
- {
66
- system: system_text.empty? ? nil : system_text,
67
- messages: kept
68
- }
69
- end
70
-
71
- private
72
-
73
- # Greedily accumulate messages from newest to oldest, stop when budget runs out.
74
- def fit_messages_to_budget(messages, token_limit)
75
- return messages if token_limit <= 0 && messages.empty?
76
-
77
- accumulated = 0
78
- result = []
79
-
80
- messages.reverse_each do |msg|
81
- tokens = TokenEstimator.estimate(msg.content.to_s)
82
- break if accumulated + tokens > token_limit
83
-
84
- accumulated += tokens
85
- result.unshift(msg)
86
- end
87
-
88
- result
89
- end
90
- end
91
- end
92
- end