phronomy 0.7.1 → 0.9.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +35 -45
  3. data/benchmark/baseline.json +1 -1
  4. data/benchmark/bench_agent_invoke.rb +1 -1
  5. data/benchmark/bench_context_assembler.rb +11 -3
  6. data/benchmark/bench_regression.rb +11 -11
  7. data/benchmark/bench_token_estimator.rb +5 -5
  8. data/benchmark/bench_tool_schema.rb +2 -2
  9. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +224 -0
  10. data/lib/phronomy/agent/base.rb +268 -403
  11. data/lib/phronomy/agent/checkpoint.rb +118 -0
  12. data/lib/phronomy/agent/concerns/suspendable.rb +6 -6
  13. data/lib/phronomy/agent/context/capability/base.rb +689 -0
  14. data/lib/phronomy/agent/context/capability/scope_policy.rb +54 -0
  15. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  16. data/lib/phronomy/agent/context/knowledge/base.rb +58 -0
  17. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +102 -0
  18. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +58 -0
  19. data/lib/phronomy/agent/fsm.rb +1 -1
  20. data/lib/phronomy/agent/invocation_pipeline.rb +108 -0
  21. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  22. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  23. data/lib/phronomy/agent/react_agent.rb +43 -37
  24. data/lib/phronomy/agent/runner.rb +2 -2
  25. data/lib/phronomy/agent/shared_state.rb +2 -2
  26. data/lib/phronomy/agent/tool_executor.rb +108 -0
  27. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  28. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  29. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  30. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  31. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  32. data/lib/phronomy/concurrency/deadline.rb +65 -0
  33. data/lib/phronomy/{runtime → concurrency}/gate_registry.rb +1 -2
  34. data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
  35. data/lib/phronomy/configuration.rb +0 -6
  36. data/lib/phronomy/context.rb +2 -8
  37. data/lib/phronomy/eval/runner.rb +4 -0
  38. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  39. data/lib/phronomy/event_loop.rb +7 -7
  40. data/lib/phronomy/invocation_context.rb +3 -3
  41. data/lib/phronomy/knowledge_source.rb +0 -5
  42. data/lib/phronomy/llm_adapter/ruby_llm.rb +17 -11
  43. data/lib/phronomy/llm_context_window/assembler.rb +191 -0
  44. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  45. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  46. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  47. data/lib/phronomy/{agent → multi_agent}/handoff.rb +6 -6
  48. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +7 -7
  49. data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
  50. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +4 -4
  51. data/lib/phronomy/runtime/runtime_metrics.rb +0 -1
  52. data/lib/phronomy/runtime.rb +20 -6
  53. data/lib/phronomy/task_group.rb +1 -1
  54. data/lib/phronomy/tool.rb +3 -4
  55. data/lib/phronomy/{tool/agent_tool.rb → tools/agent.rb} +6 -6
  56. data/lib/phronomy/{tool/mcp_tool.rb → tools/mcp.rb} +9 -9
  57. data/lib/phronomy/tools/vector_search.rb +70 -0
  58. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  59. data/lib/phronomy/vector_store/async_backend.rb +4 -4
  60. data/lib/phronomy/vector_store/base.rb +2 -2
  61. data/lib/phronomy/vector_store/embeddings/base.rb +41 -0
  62. data/lib/phronomy/vector_store/embeddings/ruby_llm_embeddings.rb +47 -0
  63. data/lib/phronomy/vector_store/in_memory.rb +12 -2
  64. data/lib/phronomy/vector_store/loader/base.rb +27 -0
  65. data/lib/phronomy/vector_store/loader/csv_loader.rb +58 -0
  66. data/lib/phronomy/vector_store/loader/markdown_loader.rb +78 -0
  67. data/lib/phronomy/vector_store/loader/plain_text_loader.rb +24 -0
  68. data/lib/phronomy/vector_store/pgvector.rb +2 -2
  69. data/lib/phronomy/vector_store/redis_search.rb +2 -2
  70. data/lib/phronomy/vector_store/splitter/base.rb +49 -0
  71. data/lib/phronomy/vector_store/splitter/fixed_size_splitter.rb +53 -0
  72. data/lib/phronomy/vector_store/splitter/recursive_splitter.rb +107 -0
  73. data/lib/phronomy/vector_store.rb +14 -2
  74. data/lib/phronomy/version.rb +1 -1
  75. data/lib/phronomy/workflow_context.rb +8 -0
  76. data/lib/phronomy/workflow_runner.rb +11 -131
  77. data/lib/phronomy.rb +2 -0
  78. data/scripts/api_snapshot.rb +11 -9
  79. metadata +44 -46
  80. data/lib/phronomy/async_queue.rb +0 -155
  81. data/lib/phronomy/blocking_adapter_pool.rb +0 -435
  82. data/lib/phronomy/cancellation_scope.rb +0 -123
  83. data/lib/phronomy/cancellation_token.rb +0 -133
  84. data/lib/phronomy/concurrency_gate.rb +0 -155
  85. data/lib/phronomy/context/assembler.rb +0 -143
  86. data/lib/phronomy/context/compaction_context.rb +0 -111
  87. data/lib/phronomy/context/trigger_context.rb +0 -39
  88. data/lib/phronomy/context/trim_context.rb +0 -75
  89. data/lib/phronomy/deadline.rb +0 -63
  90. data/lib/phronomy/embeddings/base.rb +0 -39
  91. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  92. data/lib/phronomy/embeddings.rb +0 -11
  93. data/lib/phronomy/fsm_session.rb +0 -247
  94. data/lib/phronomy/knowledge_source/base.rb +0 -54
  95. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  96. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  97. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  98. data/lib/phronomy/loader/base.rb +0 -25
  99. data/lib/phronomy/loader/csv_loader.rb +0 -56
  100. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  101. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  102. data/lib/phronomy/loader.rb +0 -13
  103. data/lib/phronomy/prompt_template.rb +0 -96
  104. data/lib/phronomy/splitter/base.rb +0 -47
  105. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  106. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  107. data/lib/phronomy/splitter.rb +0 -12
  108. data/lib/phronomy/tool/base.rb +0 -644
  109. data/lib/phronomy/tool/scope_policy.rb +0 -50
  110. data/lib/phronomy/tool_executor.rb +0 -106
@@ -1,123 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- # Represents a bounded execution scope that owns a {CancellationToken} and
5
- # optionally a {Deadline}.
6
- #
7
- # +CancellationScope+ replaces ad-hoc +Timeout.timeout+ calls in agent and
8
- # tool code. All work performed within a scope should observe the scope's
9
- # token; when the scope is cancelled (explicitly or by deadline expiry) the
10
- # token is cancelled and all child tasks that check it will stop.
11
- #
12
- # @example Time-bounded invocation
13
- # scope = Phronomy::CancellationScope.new.deadline_in(30)
14
- # result = scope.pop_queue(completion_queue) do
15
- # raise Phronomy::TimeoutError, "timed out"
16
- # end
17
- #
18
- # @example Explicit cancellation
19
- # scope = Phronomy::CancellationScope.new
20
- # Phronomy::Runtime.instance.spawn(name: "worker") do
21
- # scope.token.raise_if_cancelled!
22
- # # ... do work ...
23
- # end
24
- # scope.cancel! if some_condition
25
- class CancellationScope
26
- # @return [CancellationToken] the token owned by this scope
27
- attr_reader :token
28
-
29
- # @return [Deadline, nil] the deadline attached to this scope, if any
30
- attr_reader :deadline
31
-
32
- # @param parent_token [CancellationToken, nil] when provided, cancellation of
33
- # the parent token is propagated to this scope's token via a callback
34
- # (for explicit cancel) and/or the Runtime timer queue (for monotonic
35
- # deadline expiry). No polling thread is spawned.
36
- # @api private
37
- def initialize(parent_token: nil)
38
- @token = Phronomy::CancellationToken.new
39
- @deadline = nil
40
-
41
- if parent_token
42
- # Propagate explicit cancel() from parent to child via callback.
43
- parent_token.on_cancel { @token.cancel! }
44
-
45
- # Propagate monotonic-deadline expiry from parent to child via the
46
- # timer queue (avoids a polling thread).
47
- remaining = parent_token.remaining_monotonic_seconds
48
- if !remaining.nil?
49
- if remaining <= 0
50
- @token.cancel!
51
- else
52
- Phronomy::Runtime.instance.timer_queue.schedule(seconds: remaining) do
53
- @token.cancel!
54
- end
55
- end
56
- end
57
- end
58
- end
59
-
60
- # Attaches a deadline that will cancel this scope after +seconds+.
61
- #
62
- # @param seconds [Numeric] timeout duration
63
- # @return [self]
64
- # @api private
65
- def deadline_in(seconds)
66
- @deadline = Phronomy::Deadline.in(seconds)
67
- @deadline.attach_to(@token)
68
- self
69
- end
70
-
71
- # Cancels this scope immediately.
72
- # @return [void]
73
- # @api private
74
- def cancel!
75
- @token.cancel!
76
- end
77
-
78
- # Returns +true+ if this scope has been cancelled.
79
- # @return [Boolean]
80
- # @api private
81
- def cancelled?
82
- @token.cancelled?
83
- end
84
-
85
- # Returns the remaining time in seconds before the deadline expires,
86
- # or +nil+ when no deadline is set.
87
- # @return [Float, nil]
88
- # @api private
89
- def remaining_seconds
90
- @deadline&.remaining_seconds
91
- end
92
-
93
- # Pops from +queue+ with a timeout derived from the attached deadline (or
94
- # +fallback_timeout+ seconds when no deadline is set). If the pop times out,
95
- # the scope is cancelled and the block is called (or a {TimeoutError} raised).
96
- #
97
- # @param queue [Phronomy::AsyncQueue] the queue to pop from
98
- # @param fallback_timeout [Numeric, nil] used when no deadline is attached
99
- # @yield called when the operation times out
100
- # @raise [Phronomy::TimeoutError] when no block is given and a timeout occurs
101
- # @return [Object] the popped value
102
- # @api private
103
- def pop_queue(queue, fallback_timeout: nil)
104
- timeout = @deadline&.remaining_seconds || fallback_timeout
105
- result = if timeout
106
- queue.pop(timeout: timeout)
107
- else
108
- queue.pop
109
- end
110
-
111
- if result.nil?
112
- cancel!
113
- if block_given?
114
- yield
115
- else
116
- raise Phronomy::TimeoutError, "CancellationScope timed out"
117
- end
118
- end
119
-
120
- result
121
- end
122
- end
123
- end
@@ -1,133 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- # Provides cooperative cancellation for agent invocations.
5
- #
6
- # Pass a token to an agent via +config: { cancellation_token: token }+.
7
- # The agent checks the token before each LLM call and raises
8
- # {Phronomy::CancellationError} when the token is cancelled or the
9
- # optional deadline has passed.
10
- #
11
- # A token may be shared across multiple agent invocations and across threads;
12
- # all access to internal state is protected by a Mutex.
13
- #
14
- # @example Explicit cancel from another thread
15
- # token = Phronomy::CancellationToken.new
16
- # Thread.new { sleep 5; token.cancel! }
17
- # result = agent.invoke("...", config: { cancellation_token: token })
18
- #
19
- # @example Hard deadline via monotonic clock (recommended)
20
- # token = Phronomy::CancellationToken.timeout_after(30)
21
- # result = agent.invoke("...", config: { cancellation_token: token })
22
- #
23
- # @example Hard deadline via wall-clock (legacy)
24
- # token = Phronomy::CancellationToken.new(deadline: Time.now + 30)
25
- # result = agent.invoke("...", config: { cancellation_token: token })
26
- #
27
- # @example Propagate to parallel workers
28
- # token = Phronomy::CancellationToken.new
29
- # orchestrator.dispatch_parallel(task1, task2, cancellation_token: token)
30
- class CancellationToken
31
- # Returns a new token that will expire after +seconds+ seconds, measured
32
- # with the monotonic clock (+Process::CLOCK_MONOTONIC+). Unlike constructing
33
- # a token with +deadline: Time.now + seconds+, this factory is immune to NTP
34
- # adjustments and DST transitions.
35
- #
36
- # @param seconds [Numeric] duration in seconds until the token expires.
37
- # @return [CancellationToken]
38
- # @api public
39
- def self.timeout_after(seconds)
40
- monotonic_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
41
- new(monotonic_deadline: monotonic_deadline)
42
- end
43
-
44
- # @param deadline [Time, nil] optional wall-clock deadline; the token reports
45
- # +cancelled?+ as +true+ once +Time.now >= deadline+. Prefer
46
- # {.timeout_after} for duration-based cancellation.
47
- # @param monotonic_deadline [Float, nil] internal monotonic timestamp set by
48
- # {.timeout_after}; prefer that factory method over passing this directly.
49
- # @api public
50
- def initialize(deadline: nil, monotonic_deadline: nil)
51
- @cancelled = false
52
- @deadline = deadline
53
- @monotonic_deadline = monotonic_deadline
54
- @mutex = Mutex.new
55
- @cancel_callbacks = []
56
- end
57
-
58
- # @return [Time, nil] the wall-clock deadline passed to {#initialize}, or +nil+.
59
- attr_reader :deadline
60
-
61
- # Returns the remaining seconds until the monotonic deadline fires, or +nil+
62
- # when no monotonic deadline is set. Returns 0.0 if already past.
63
- # @return [Float, nil]
64
- # @api public
65
- def remaining_monotonic_seconds
66
- return nil if @monotonic_deadline.nil?
67
- remaining = @monotonic_deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
68
- [remaining, 0.0].max
69
- end
70
-
71
- # Registers a one-shot callback invoked when this token is explicitly
72
- # cancelled via {#cancel!}. If the token is already cancelled, the block
73
- # is called immediately (still within the caller's thread).
74
- #
75
- # Callbacks are NOT fired for deadline-based cancellation (i.e. when
76
- # {#cancelled?} returns +true+ due to +@monotonic_deadline+ expiry). Use
77
- # {Runtime#timer_queue} to schedule deadline callbacks.
78
- #
79
- # @yield called with no arguments when (or if) the token is cancelled
80
- # @return [self]
81
- # @api public
82
- def on_cancel(&block)
83
- already_cancelled = @mutex.synchronize do
84
- if @cancelled
85
- true
86
- else
87
- @cancel_callbacks << block
88
- false
89
- end
90
- end
91
- block.call if already_cancelled
92
- self
93
- end
94
-
95
- # Mark the token as cancelled and fire any registered {#on_cancel} callbacks.
96
- # Thread-safe; idempotent — calling multiple times has no additional effect.
97
- # @return [self]
98
- # @api public
99
- def cancel!
100
- callbacks = @mutex.synchronize do
101
- return self if @cancelled
102
- @cancelled = true
103
- @cancel_callbacks.dup
104
- end
105
- callbacks.each(&:call)
106
- self
107
- end
108
-
109
- # Returns +true+ when the token has been explicitly cancelled via {#cancel!},
110
- # when the wall-clock deadline has passed, or when the monotonic deadline
111
- # (set by {.timeout_after}) has elapsed. Thread-safe.
112
- # @return [Boolean]
113
- # @api public
114
- def cancelled?
115
- return true if @mutex.synchronize { @cancelled }
116
- return true if !@deadline.nil? && Time.now >= @deadline
117
- !@monotonic_deadline.nil? &&
118
- Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @monotonic_deadline
119
- end
120
-
121
- # Raises {Phronomy::CancellationError} if the token is cancelled.
122
- # A convenience method for cooperative cancellation checks inside tools,
123
- # RAG loaders, and hooks, replacing the +if cancelled? then raise+ pattern.
124
- #
125
- # @param message [String] optional error message
126
- # @return [nil] when the token is not cancelled
127
- # @raise [Phronomy::CancellationError] when the token is cancelled
128
- # @api public
129
- def raise_if_cancelled!(message = "invocation cancelled")
130
- raise Phronomy::CancellationError, message if cancelled?
131
- end
132
- end
133
- end
@@ -1,155 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- # A counting semaphore that enforces a concurrency cap across a named
5
- # resource category (e.g. agent tasks, tool tasks, LLM calls).
6
- #
7
- # When +max_concurrent+ is +nil+ the gate is a no-op and all callers
8
- # pass through immediately without acquiring a slot.
9
- #
10
- # Backpressure behaviour when the gate is full is controlled by the
11
- # +on_full:+ keyword:
12
- # +:reject+ — raise {Phronomy::BackpressureError} immediately
13
- # +:wait+ — block the calling fiber/thread until a slot is free
14
- # +:timeout+ — like +:wait+ but raises {Phronomy::BackpressureError}
15
- # after +timeout:+ seconds if no slot becomes available
16
- #
17
- # @example
18
- # gate = Phronomy::ConcurrencyGate.new(max_concurrent: 5, name: :agent)
19
- # gate.acquire(on_full: :reject) do
20
- # run_agent_task
21
- # end
22
- class ConcurrencyGate
23
- # @param max_concurrent [Integer, nil] concurrency cap; nil = unlimited
24
- # @param name [Symbol, String, nil] human-readable label used in error messages
25
- # @api private
26
- def initialize(max_concurrent:, name: nil)
27
- @max = max_concurrent
28
- @name = name
29
- @mutex = Mutex.new
30
- @cond = ConditionVariable.new
31
- @count = 0
32
- end
33
-
34
- # Returns the configured cap (or nil when unlimited).
35
- attr_reader :max
36
-
37
- # Returns the name label.
38
- attr_reader :name
39
-
40
- # Returns the number of slots currently in use.
41
- def current_count
42
- @mutex.synchronize { @count }
43
- end
44
-
45
- # Acquires a slot, executes +block+, then releases the slot.
46
- # When the gate is unlimited (max is nil) the block runs directly.
47
- #
48
- # @param on_full [:reject, :wait, :timeout] backpressure strategy
49
- # @param timeout [Numeric, nil] seconds before +:timeout+ gives up
50
- # @yield
51
- # @return block return value
52
- # @raise [Phronomy::BackpressureError] when +:reject+ or +:timeout+ fires
53
- # @api private
54
- def acquire(on_full: :wait, timeout: nil, &block)
55
- return block.call if @max.nil?
56
-
57
- _acquire_slot(on_full: on_full, timeout: timeout)
58
- begin
59
- block.call
60
- ensure
61
- _release_slot
62
- end
63
- end
64
-
65
- private
66
-
67
- def _acquire_slot(on_full:, timeout:)
68
- scheduler = Phronomy::Runtime::Scheduler.current
69
- if scheduler
70
- _acquire_slot_coop(scheduler, on_full: on_full, timeout: timeout)
71
- else
72
- _acquire_slot_threaded(on_full: on_full, timeout: timeout)
73
- end
74
- end
75
-
76
- def _acquire_slot_coop(scheduler, on_full:, timeout:)
77
- # In cooperative mode all tasks run on the same thread, so no mutex needed.
78
- deadline = timeout ? (scheduler.virtual_time + timeout) : nil
79
- @coop_signal ||= scheduler.new_signal
80
-
81
- loop do
82
- if @count < @max
83
- @count += 1
84
- return
85
- end
86
-
87
- case on_full
88
- when :reject
89
- raise Phronomy::BackpressureError,
90
- "ConcurrencyGate[#{@name}] at capacity (#{@max}); " \
91
- "increase max_concurrent_#{@name}_tasks or retry later"
92
- when :timeout
93
- if deadline && scheduler.virtual_time >= deadline
94
- raise Phronomy::BackpressureError,
95
- "ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
96
- end
97
- scheduler.wait_for_signal(@coop_signal)
98
- if deadline && scheduler.virtual_time >= deadline
99
- raise Phronomy::BackpressureError,
100
- "ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
101
- end
102
- else # :wait
103
- scheduler.wait_for_signal(@coop_signal)
104
- end
105
- end
106
- end
107
-
108
- def _acquire_slot_threaded(on_full:, timeout:)
109
- deadline = timeout ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout) : nil
110
-
111
- @mutex.synchronize do
112
- loop do
113
- if @count < @max
114
- @count += 1
115
- return
116
- end
117
-
118
- case on_full
119
- when :reject
120
- raise Phronomy::BackpressureError,
121
- "ConcurrencyGate[#{@name}] at capacity (#{@max}); " \
122
- "increase max_concurrent_#{@name}_tasks or retry later"
123
- when :timeout
124
- remaining = deadline ? (deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)) : nil
125
- if remaining && remaining <= 0
126
- raise Phronomy::BackpressureError,
127
- "ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
128
- end
129
- @cond.wait(@mutex, remaining || nil)
130
- # re-check deadline after wakeup
131
- if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
132
- raise Phronomy::BackpressureError,
133
- "ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
134
- end
135
- else # :wait
136
- @cond.wait(@mutex)
137
- end
138
- end
139
- end
140
- end
141
-
142
- def _release_slot
143
- scheduler = Phronomy::Runtime::Scheduler.current
144
- if scheduler && @coop_signal
145
- @count -= 1
146
- scheduler.raise_signal(@coop_signal)
147
- else
148
- @mutex.synchronize do
149
- @count -= 1
150
- @cond.signal
151
- end
152
- end
153
- end
154
- end
155
- end
@@ -1,143 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "cgi"
4
-
5
- module Phronomy
6
- module Context
7
- # Assembler collects all four context regions and produces the final
8
- # {system:, messages:} hash consumed by Agent::Base.
9
- #
10
- # Regions:
11
- # 1. Instruction — system prompt text set via #add_instruction
12
- # 2. Capability — tool definitions (handled by RubyLLM, not here)
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. Knowledge chunks are always included in full (they
19
- # are assumed to be pre-screened by the caller). When no budget is given all
20
- # messages are passed through unchanged.
21
- #
22
- # @example
23
- # assembler = Phronomy::Context::Assembler.new(budget: budget)
24
- # assembler.add_instruction("You are a helpful assistant.")
25
- # assembler.add_knowledge("The user lives in Tokyo.", type: :entity, trusted: false)
26
- # assembler.add_messages(manager.load(thread_id: "t1", query: user_input))
27
- # context = assembler.build
28
- # # => { system: "You are ...\n<context ...>...</context>", messages: [...] }
29
- class Assembler
30
- # Builds a single XML context tag string.
31
- # Exposed as a class method so callers (e.g. Agent::Base) can build
32
- # static knowledge XML tags independently of an Assembler instance.
33
- #
34
- # @param text [String]
35
- # @param type [Symbol, String]
36
- # @param trusted [Boolean]
37
- # @return [String]
38
- # @api private
39
- def self.xml_tag(text, type:, trusted: false)
40
- "<context type=\"#{CGI.escapeHTML(type.to_s)}\" trusted=\"#{trusted}\">\n#{CGI.escapeHTML(text.to_s)}\n</context>"
41
- end
42
-
43
- # @param budget [Phronomy::Context::TokenBudget, nil]
44
- # when nil no token trimming is performed
45
- # @api private
46
- def initialize(budget: nil)
47
- @budget = budget
48
- @instruction = nil
49
- @knowledge_chunks = []
50
- @messages = []
51
- end
52
-
53
- # Set the system instruction text (Region 1).
54
- # Calling this multiple times replaces the previous value.
55
- #
56
- # @param text [String]
57
- # @return [self]
58
- # @api private
59
- def add_instruction(text)
60
- @instruction = text.to_s
61
- self
62
- end
63
-
64
- # Append a knowledge chunk (Region 3).
65
- # The chunk is wrapped in an XML context tag automatically.
66
- #
67
- # @param text [String]
68
- # @param type [Symbol, String] semantic label for the context tag (e.g. :entity, :rag, :static)
69
- # @param trusted [Boolean] false (default) indicates externally sourced data
70
- # @param source [String, nil] optional source label (e.g. filename); included in the
71
- # XML tag so the LLM can produce grounded citations. Omitted when nil.
72
- # @return [self]
73
- # @api private
74
- def add_knowledge(text, type:, trusted: false, source: nil)
75
- @knowledge_chunks << {text: text.to_s, type: type.to_s, trusted: trusted, source: source}
76
- self
77
- end
78
-
79
- # Set conversation messages (Region 4). Replaces any previously set messages.
80
- #
81
- # @param messages [Array] message-like objects with #role and #content
82
- # @return [self]
83
- # @api private
84
- def add_messages(messages)
85
- @messages = Array(messages)
86
- self
87
- end
88
-
89
- # Assemble the context.
90
- #
91
- # @return [Hash{Symbol => Object}]
92
- # :system [String, nil] combined system prompt (instruction + knowledge XML tags)
93
- # :messages [Array] conversation messages, trimmed to budget if set
94
- # @api private
95
- def build
96
- knowledge_text = @knowledge_chunks.map { |c| xml_context_tag(c) }.join("\n\n")
97
- system_parts = [@instruction, knowledge_text.empty? ? nil : knowledge_text].compact
98
- system_text = system_parts.join("\n\n")
99
-
100
- messages = if @budget
101
- trim_messages_to_budget(@messages, system_text)
102
- else
103
- @messages
104
- end
105
-
106
- {
107
- system: system_text.empty? ? nil : system_text,
108
- messages: messages
109
- }
110
- end
111
-
112
- private
113
-
114
- def xml_context_tag(chunk)
115
- src_attr = chunk[:source] ? " source=\"#{CGI.escapeHTML(chunk[:source].to_s)}\"" : ""
116
- "<context type=\"#{CGI.escapeHTML(chunk[:type].to_s)}\"#{src_attr} trusted=\"#{chunk[:trusted]}\">\n#{CGI.escapeHTML(chunk[:text].to_s)}\n</context>"
117
- end
118
-
119
- def trim_messages_to_budget(messages, system_text)
120
- used = TokenEstimator.estimate(system_text)
121
- remaining = @budget.available(used: used)
122
- return messages if remaining <= 0 && messages.empty?
123
-
124
- accumulated = 0
125
- result = []
126
- messages.reverse_each do |msg|
127
- tokens = TokenEstimator.estimate(msg.content.to_s)
128
- break if accumulated + tokens > remaining
129
-
130
- accumulated += tokens
131
- result.push(msg)
132
- end
133
-
134
- if result.empty? && messages.any?
135
- warn "[Phronomy::Assembler] All #{messages.length} conversation message(s) dropped: " \
136
- "token budget exhausted by system context (budget=#{@budget.context_window}, used_by_system=#{used})"
137
- end
138
-
139
- result.reverse
140
- end
141
- end
142
- end
143
- end
@@ -1,111 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Context
5
- # Context object passed to the +on_compact+ callback registered on an agent.
6
- #
7
- # The callback calls #compact one or more times to specify which ranges of
8
- # messages to replace with a summary. Each call:
9
- # 1. Yields the selected message elements to the block.
10
- # 2. Receives the block's return value as the summary text.
11
- # 3. Persists a compaction record to the memory store (if available).
12
- # 4. Updates #result_messages so that the compacted range is replaced
13
- # by a single +:system+ summary message.
14
- #
15
- # The agent reads #result_messages after the callback returns and uses it
16
- # as the new message list for this invocation.
17
- #
18
- # @example Summarise the oldest half of the conversation
19
- # on_compact do |ctx|
20
- # half = ctx.message_elements.length / 2
21
- # ctx.compact(0...half) do |elements|
22
- # texts = elements.map { |e| "#{e[:role]}: #{e[:message].content}" }.join("\n")
23
- # "Summary of earlier conversation:\n#{texts}"
24
- # end
25
- # end
26
- class CompactionContext
27
- # @return [Array<Hash>] message elements at compaction time
28
- attr_reader :message_elements
29
-
30
- # @return [Phronomy::Context::TokenBudget, nil]
31
- attr_reader :budget
32
-
33
- # @return [Integer] total estimated token count before compaction
34
- attr_reader :total_tokens
35
-
36
- # The current message list to be used after all compact calls have been made.
37
- # Updated by each call to #compact.
38
- #
39
- # @return [Array]
40
- attr_reader :result_messages
41
-
42
- # @param message_elements [Array<Hash>]
43
- # each element: { seq: Integer, message: Object, tokens: Integer, role: Symbol }
44
- # @param budget [Phronomy::Context::TokenBudget, nil]
45
- # @param thread_id [String, nil] used when saving compaction records
46
- # @param memory [Object, nil] memory object; must respond to #save_compaction
47
- # for compaction records to be persisted
48
- # @api private
49
- def initialize(message_elements:, budget:, thread_id: nil, memory: nil)
50
- @message_elements = message_elements.dup
51
- @budget = budget
52
- @total_tokens = message_elements.sum { |e| e[:tokens] }
53
- @thread_id = thread_id
54
- @memory = memory
55
- @result_messages = @message_elements.map { |e| e[:message] }
56
- end
57
-
58
- # Replace a range of messages with a summary produced by the block.
59
- #
60
- # The block receives the selected Array<Hash> elements and must return a
61
- # String that serves as the summary text. After the call, #result_messages
62
- # reflects the replacement.
63
- #
64
- # If the memory object responds to #save_compaction, a compaction record
65
- # { start_seq:, end_seq:, summary_text: } is persisted for auditability.
66
- #
67
- # @param range [Range, Integer] index range into message_elements (0-based)
68
- # @yieldparam elements [Array<Hash>] the selected message elements
69
- # @yieldreturn [String] summary text to replace the selected messages
70
- # @return [Array] the updated result_messages array
71
- # @api private
72
- def compact(range)
73
- # Normalise: Integer index → single-element Array; Range → Array slice.
74
- raw = @message_elements[range]
75
- elements = if raw.is_a?(Array)
76
- raw
77
- elsif raw.nil?
78
- []
79
- else
80
- [raw]
81
- end
82
- return @result_messages if elements.empty?
83
-
84
- summary_text = yield(elements).to_s
85
-
86
- start_seq = elements.first[:seq]
87
- end_seq = elements.last[:seq]
88
-
89
- if @memory && @thread_id && @memory.respond_to?(:save_compaction)
90
- @memory.save_compaction(
91
- thread_id: @thread_id,
92
- start_seq: start_seq,
93
- end_seq: end_seq,
94
- summary_text: summary_text
95
- )
96
- end
97
-
98
- # Compute the last included index in the original @message_elements array.
99
- last_idx = if range.is_a?(Range)
100
- range.exclude_end? ? range.last - 1 : range.last
101
- else
102
- range.to_i
103
- end
104
-
105
- remaining = (@message_elements[(last_idx + 1)..] || []).map { |e| e[:message] }
106
- summary_msg = RubyLLM::Message.new(role: :system, content: summary_text)
107
- @result_messages = [summary_msg] + remaining
108
- end
109
- end
110
- end
111
- end