phronomy 0.7.0 → 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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -7
  3. data/CHANGELOG.md +151 -1
  4. data/README.md +170 -47
  5. data/Rakefile +33 -0
  6. data/benchmark/baseline.json +1 -1
  7. data/benchmark/bench_context_assembler.rb +2 -2
  8. data/benchmark/bench_regression.rb +6 -5
  9. data/benchmark/bench_token_estimator.rb +5 -5
  10. data/benchmark/bench_tool_schema.rb +1 -1
  11. data/benchmark/bench_vector_store.rb +1 -1
  12. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
  13. data/docs/decisions/006-no-built-in-guardrails.md +20 -2
  14. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  15. data/lib/phronomy/agent/base.rb +285 -137
  16. data/lib/phronomy/agent/checkpoint.rb +118 -0
  17. data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
  18. data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
  19. data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
  20. data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
  21. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  22. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
  23. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
  24. data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
  25. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
  26. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
  27. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
  28. data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
  29. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
  30. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
  31. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
  32. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
  33. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
  34. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
  35. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
  36. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
  37. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
  38. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
  39. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
  40. data/lib/phronomy/agent/fsm.rb +42 -65
  41. data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
  42. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  43. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  44. data/lib/phronomy/agent/react_agent.rb +27 -14
  45. data/lib/phronomy/agent/runner.rb +2 -2
  46. data/lib/phronomy/agent/tool_executor.rb +108 -0
  47. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  48. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  49. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  50. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  51. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  52. data/lib/phronomy/concurrency/deadline.rb +65 -0
  53. data/lib/phronomy/concurrency/gate_registry.rb +52 -0
  54. data/lib/phronomy/concurrency/pool_registry.rb +57 -0
  55. data/lib/phronomy/configuration.rb +142 -0
  56. data/lib/phronomy/context.rb +2 -8
  57. data/lib/phronomy/diagnostics.rb +62 -0
  58. data/lib/phronomy/embeddings.rb +2 -2
  59. data/lib/phronomy/eval/runner.rb +13 -9
  60. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  61. data/lib/phronomy/event_loop.rb +184 -46
  62. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  63. data/lib/phronomy/invocation_context.rb +152 -0
  64. data/lib/phronomy/knowledge_source.rb +0 -5
  65. data/lib/phronomy/llm_adapter/base.rb +104 -0
  66. data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
  67. data/lib/phronomy/llm_adapter.rb +20 -0
  68. data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
  69. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  70. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  71. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  72. data/lib/phronomy/loader.rb +4 -4
  73. data/lib/phronomy/metrics.rb +38 -0
  74. data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
  75. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
  76. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
  77. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
  78. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  79. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  80. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  81. data/lib/phronomy/runtime/scheduler.rb +98 -0
  82. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  83. data/lib/phronomy/runtime/task_registry.rb +48 -0
  84. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  85. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  86. data/lib/phronomy/runtime/timer_service.rb +42 -0
  87. data/lib/phronomy/runtime.rb +389 -0
  88. data/lib/phronomy/splitter.rb +3 -3
  89. data/lib/phronomy/task/backend.rb +80 -0
  90. data/lib/phronomy/task/fiber_backend.rb +157 -0
  91. data/lib/phronomy/task/immediate_backend.rb +89 -0
  92. data/lib/phronomy/task/thread_backend.rb +84 -0
  93. data/lib/phronomy/task.rb +275 -0
  94. data/lib/phronomy/task_group.rb +265 -0
  95. data/lib/phronomy/testing/fake_clock.rb +109 -0
  96. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  97. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  98. data/lib/phronomy/testing.rb +12 -0
  99. data/lib/phronomy/tool/base.rb +156 -7
  100. data/lib/phronomy/tool/mcp_tool.rb +47 -16
  101. data/lib/phronomy/tool/scope_policy.rb +50 -0
  102. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  103. data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
  104. data/lib/phronomy/vector_store.rb +2 -2
  105. data/lib/phronomy/version.rb +1 -1
  106. data/lib/phronomy/workflow.rb +52 -5
  107. data/lib/phronomy/workflow_context.rb +37 -2
  108. data/lib/phronomy/workflow_runner.rb +28 -77
  109. data/lib/phronomy.rb +43 -0
  110. metadata +73 -33
  111. data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
  112. data/lib/phronomy/cancellation_token.rb +0 -92
  113. data/lib/phronomy/context/compaction_context.rb +0 -111
  114. data/lib/phronomy/context/trigger_context.rb +0 -39
  115. data/lib/phronomy/context/trim_context.rb +0 -75
  116. data/lib/phronomy/embeddings/base.rb +0 -22
  117. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  118. data/lib/phronomy/fsm_session.rb +0 -201
  119. data/lib/phronomy/knowledge_source/base.rb +0 -36
  120. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  121. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  122. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  123. data/lib/phronomy/loader/base.rb +0 -25
  124. data/lib/phronomy/loader/csv_loader.rb +0 -56
  125. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  126. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  127. data/lib/phronomy/prompt_template.rb +0 -96
  128. data/lib/phronomy/splitter/base.rb +0 -47
  129. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  130. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  131. data/lib/phronomy/vector_store/base.rb +0 -82
  132. data/lib/phronomy/vector_store/in_memory.rb +0 -93
  133. data/lib/phronomy/vector_store/pgvector.rb +0 -127
  134. data/lib/phronomy/vector_store/redis_search.rb +0 -192
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.7.0
4
+ version: 0.8.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-23 00:00:00.000000000 Z
11
+ date: 2026-05-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -98,6 +98,7 @@ files:
98
98
  - docs/decisions/007-mcp-is-beta-stability.md
99
99
  - docs/decisions/008-orchestrator-uses-os-threads.md
100
100
  - docs/decisions/009-state-store-abstraction.md
101
+ - docs/decisions/010-cooperative-first-concurrency.md
101
102
  - lib/phronomy.rb
102
103
  - lib/phronomy/agent.rb
103
104
  - lib/phronomy/agent/base.rb
@@ -108,28 +109,49 @@ files:
108
109
  - lib/phronomy/agent/concerns/guardrailable.rb
109
110
  - lib/phronomy/agent/concerns/retryable.rb
110
111
  - lib/phronomy/agent/concerns/suspendable.rb
112
+ - lib/phronomy/agent/context/conversation/compaction_context.rb
113
+ - lib/phronomy/agent/context/conversation/trigger_context.rb
114
+ - lib/phronomy/agent/context/conversation/trim_context.rb
115
+ - lib/phronomy/agent/context/instruction/prompt_template.rb
116
+ - lib/phronomy/agent/context/knowledge/embeddings/base.rb
117
+ - lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb
118
+ - lib/phronomy/agent/context/knowledge/loader/base.rb
119
+ - lib/phronomy/agent/context/knowledge/loader/csv_loader.rb
120
+ - lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb
121
+ - lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb
122
+ - lib/phronomy/agent/context/knowledge/source/base.rb
123
+ - lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb
124
+ - lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb
125
+ - lib/phronomy/agent/context/knowledge/source/static_knowledge.rb
126
+ - lib/phronomy/agent/context/knowledge/splitter/base.rb
127
+ - lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb
128
+ - lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb
129
+ - lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb
130
+ - lib/phronomy/agent/context/knowledge/vector_store/base.rb
131
+ - lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb
132
+ - lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb
133
+ - lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb
111
134
  - lib/phronomy/agent/fsm.rb
112
- - lib/phronomy/agent/handoff.rb
113
- - lib/phronomy/agent/orchestrator.rb
114
- - lib/phronomy/agent/parallel_tool_chat.rb
135
+ - lib/phronomy/agent/invocation_pipeline.rb
136
+ - lib/phronomy/agent/lifecycle/fsm_session.rb
137
+ - lib/phronomy/agent/lifecycle/phase_machine_builder.rb
115
138
  - lib/phronomy/agent/react_agent.rb
116
139
  - lib/phronomy/agent/runner.rb
117
140
  - lib/phronomy/agent/shared_state.rb
118
141
  - lib/phronomy/agent/suspend_signal.rb
119
- - lib/phronomy/agent/team_coordinator.rb
120
- - lib/phronomy/cancellation_token.rb
142
+ - lib/phronomy/agent/tool_executor.rb
143
+ - lib/phronomy/concurrency/async_queue.rb
144
+ - lib/phronomy/concurrency/blocking_adapter_pool.rb
145
+ - lib/phronomy/concurrency/cancellation_scope.rb
146
+ - lib/phronomy/concurrency/cancellation_token.rb
147
+ - lib/phronomy/concurrency/concurrency_gate.rb
148
+ - lib/phronomy/concurrency/deadline.rb
149
+ - lib/phronomy/concurrency/gate_registry.rb
150
+ - lib/phronomy/concurrency/pool_registry.rb
121
151
  - lib/phronomy/configuration.rb
122
152
  - lib/phronomy/context.rb
123
- - lib/phronomy/context/assembler.rb
124
- - lib/phronomy/context/compaction_context.rb
125
- - lib/phronomy/context/context_version_cache.rb
126
- - lib/phronomy/context/token_budget.rb
127
- - lib/phronomy/context/token_estimator.rb
128
- - lib/phronomy/context/trigger_context.rb
129
- - lib/phronomy/context/trim_context.rb
153
+ - lib/phronomy/diagnostics.rb
130
154
  - lib/phronomy/embeddings.rb
131
- - lib/phronomy/embeddings/base.rb
132
- - lib/phronomy/embeddings/ruby_llm_embeddings.rb
133
155
  - lib/phronomy/eval.rb
134
156
  - lib/phronomy/eval/comparison.rb
135
157
  - lib/phronomy/eval/dataset.rb
@@ -144,50 +166,68 @@ files:
144
166
  - lib/phronomy/eval/scorer/llm_judge.rb
145
167
  - lib/phronomy/event.rb
146
168
  - lib/phronomy/event_loop.rb
147
- - lib/phronomy/fsm_session.rb
148
169
  - lib/phronomy/generator_verifier.rb
149
170
  - lib/phronomy/guardrail.rb
150
171
  - lib/phronomy/guardrail/base.rb
151
172
  - lib/phronomy/guardrail/input_guardrail.rb
152
173
  - lib/phronomy/guardrail/output_guardrail.rb
174
+ - lib/phronomy/guardrail/prompt_injection_guardrail.rb
175
+ - lib/phronomy/invocation_context.rb
153
176
  - lib/phronomy/knowledge_source.rb
154
- - lib/phronomy/knowledge_source/base.rb
155
- - lib/phronomy/knowledge_source/entity_knowledge.rb
156
- - lib/phronomy/knowledge_source/rag_knowledge.rb
157
- - lib/phronomy/knowledge_source/static_knowledge.rb
177
+ - lib/phronomy/llm_adapter.rb
178
+ - lib/phronomy/llm_adapter/base.rb
179
+ - lib/phronomy/llm_adapter/ruby_llm.rb
180
+ - lib/phronomy/llm_context_window/assembler.rb
181
+ - lib/phronomy/llm_context_window/context_version_cache.rb
182
+ - lib/phronomy/llm_context_window/token_budget.rb
183
+ - lib/phronomy/llm_context_window/token_estimator.rb
158
184
  - lib/phronomy/loader.rb
159
- - lib/phronomy/loader/base.rb
160
- - lib/phronomy/loader/csv_loader.rb
161
- - lib/phronomy/loader/markdown_loader.rb
162
- - lib/phronomy/loader/plain_text_loader.rb
185
+ - lib/phronomy/metrics.rb
186
+ - lib/phronomy/multi_agent/handoff.rb
187
+ - lib/phronomy/multi_agent/orchestrator.rb
188
+ - lib/phronomy/multi_agent/parallel_tool_chat.rb
189
+ - lib/phronomy/multi_agent/team_coordinator.rb
163
190
  - lib/phronomy/output_parser.rb
164
191
  - lib/phronomy/output_parser/base.rb
165
192
  - lib/phronomy/output_parser/json_parser.rb
166
193
  - lib/phronomy/output_parser/structured_parser.rb
167
- - lib/phronomy/prompt_template.rb
168
194
  - lib/phronomy/ruby_llm_patches.rb
169
195
  - lib/phronomy/runnable.rb
196
+ - lib/phronomy/runtime.rb
197
+ - lib/phronomy/runtime/deterministic_scheduler.rb
198
+ - lib/phronomy/runtime/fake_scheduler.rb
199
+ - lib/phronomy/runtime/runtime_metrics.rb
200
+ - lib/phronomy/runtime/scheduler.rb
201
+ - lib/phronomy/runtime/scheduler_timer_adapter.rb
202
+ - lib/phronomy/runtime/task_registry.rb
203
+ - lib/phronomy/runtime/thread_scheduler.rb
204
+ - lib/phronomy/runtime/timer_queue.rb
205
+ - lib/phronomy/runtime/timer_service.rb
170
206
  - lib/phronomy/splitter.rb
171
- - lib/phronomy/splitter/base.rb
172
- - lib/phronomy/splitter/fixed_size_splitter.rb
173
- - lib/phronomy/splitter/recursive_splitter.rb
174
207
  - lib/phronomy/state_store/base.rb
175
208
  - lib/phronomy/state_store/in_memory.rb
209
+ - lib/phronomy/task.rb
210
+ - lib/phronomy/task/backend.rb
211
+ - lib/phronomy/task/fiber_backend.rb
212
+ - lib/phronomy/task/immediate_backend.rb
213
+ - lib/phronomy/task/thread_backend.rb
214
+ - lib/phronomy/task_group.rb
215
+ - lib/phronomy/testing.rb
216
+ - lib/phronomy/testing/fake_clock.rb
217
+ - lib/phronomy/testing/fake_scheduler.rb
218
+ - lib/phronomy/testing/scheduler_helpers.rb
176
219
  - lib/phronomy/token_usage.rb
177
220
  - lib/phronomy/tool.rb
178
221
  - lib/phronomy/tool/agent_tool.rb
179
222
  - lib/phronomy/tool/base.rb
180
223
  - lib/phronomy/tool/mcp_tool.rb
224
+ - lib/phronomy/tool/scope_policy.rb
181
225
  - lib/phronomy/tracing.rb
182
226
  - lib/phronomy/tracing/base.rb
183
227
  - lib/phronomy/tracing/langfuse_tracer.rb
184
228
  - lib/phronomy/tracing/null_tracer.rb
185
229
  - lib/phronomy/tracing/open_telemetry_tracer.rb
186
230
  - lib/phronomy/vector_store.rb
187
- - lib/phronomy/vector_store/base.rb
188
- - lib/phronomy/vector_store/in_memory.rb
189
- - lib/phronomy/vector_store/pgvector.rb
190
- - lib/phronomy/vector_store/redis_search.rb
191
231
  - lib/phronomy/version.rb
192
232
  - lib/phronomy/workflow.rb
193
233
  - lib/phronomy/workflow_context.rb
@@ -1,92 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Agent
5
- # RubyLLM::Chat subclass that executes multiple tool calls concurrently.
6
- #
7
- # When the LLM returns more than one tool call in a single response, each
8
- # tool is dispatched in a dedicated IO thread and all results are collected
9
- # before being appended to the message history. This preserves a
10
- # deterministic message order while reducing wall-clock latency when tools
11
- # are IO-bound (HTTP calls, DB queries, etc.).
12
- #
13
- # Single-tool responses fall through to the standard sequential path via
14
- # +super+, preserving all existing edge-case behaviour (Tool::Halt,
15
- # forced_tool_choice, streaming, SuspendSignal, etc.).
16
- #
17
- # This class is used automatically when the agent is running inside an
18
- # {AgentFSM} IO thread (i.e. when the +:phronomy_agent_parallel_tools+
19
- # thread-local flag is +true+). It is not used for direct synchronous
20
- # +invoke+ calls so that the streaming callback state remains single-threaded.
21
- # @api private
22
- class ParallelToolChat < RubyLLM::Chat
23
- private
24
-
25
- # Overrides RubyLLM::Chat#handle_tool_calls to parallelise execution
26
- # when multiple tool calls are present in a single LLM response.
27
- #
28
- # The method preserves the three-phase protocol of the original:
29
- # 1. Pre-execution callbacks (+on_new_message+, +on_tool_call+) —
30
- # sequential so that the Suspendable concern's approval hook can
31
- # raise +SuspendSignal+ before any tool is executed.
32
- # 2. Parallel tool execution — one IO thread per tool call.
33
- # 3. Post-execution callbacks and message recording — sequential,
34
- # in the original tool-call order.
35
- #
36
- # @param response [RubyLLM::Message] the LLM response containing tool calls
37
- # @yield streaming block forwarded to +complete+
38
- # @api private
39
- def handle_tool_calls(response, &block)
40
- tool_calls = response.tool_calls.values
41
-
42
- # Single tool: delegate to the parent implementation to preserve every
43
- # edge case (forced_tool_choice, streaming, Halt, SuspendSignal…).
44
- return super if tool_calls.size <= 1
45
-
46
- # Phase 1 — pre-execution callbacks (sequential, original order).
47
- # The SuspendSignal approval hook is registered via on_tool_call, so it
48
- # MUST fire before execution begins.
49
- tool_calls.each do |tool_call|
50
- @on[:new_message]&.call
51
- @on[:tool_call]&.call(tool_call)
52
- end
53
-
54
- # Phase 2 — parallel tool execution.
55
- # Honour the per-agent concurrency cap (max_parallel_tools DSL).
56
- # Tool calls are processed in batches of at most `max` threads;
57
- # batches run sequentially so the total in-flight thread count never
58
- # exceeds the limit.
59
- #
60
- # Check for cancellation before dispatching each batch so that
61
- # already-cancelled tokens do not start new LLM/tool-round-trips.
62
- ct = Thread.current[:phronomy_cancellation_token]
63
- max = Thread.current[:phronomy_max_parallel_tools] || 10
64
- thread_results = tool_calls.each_slice(max).flat_map do |batch|
65
- if ct&.cancelled?
66
- raise Phronomy::CancellationError, "invocation cancelled before tool execution"
67
- end
68
-
69
- threads = batch.map do |tool_call|
70
- Thread.new { {tool_call: tool_call, result: execute_tool(tool_call)} }
71
- end
72
- threads.map(&:value)
73
- end
74
-
75
- # Phase 3 — post-execution callbacks and message recording (sequential).
76
- halt_result = nil
77
- thread_results.each do |item|
78
- result = item[:result]
79
- @on[:tool_result]&.call(result)
80
- tool_payload = result.is_a?(RubyLLM::Tool::Halt) ? result.content : result
81
- content = content_like?(tool_payload) ? tool_payload : tool_payload.to_s
82
- message = add_message(role: :tool, content: content, tool_call_id: item[:tool_call].id)
83
- @on[:end_message]&.call(message)
84
- halt_result = result if result.is_a?(RubyLLM::Tool::Halt)
85
- end
86
-
87
- reset_tool_choice if forced_tool_choice?
88
- halt_result || complete(&block)
89
- end
90
- end
91
- end
92
- end
@@ -1,92 +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
- end
56
-
57
- # @return [Time, nil] the wall-clock deadline passed to {#initialize}, or +nil+.
58
- attr_reader :deadline
59
-
60
- # Mark the token as cancelled. Thread-safe; may be called from any thread.
61
- # @return [self]
62
- # @api public
63
- def cancel!
64
- @mutex.synchronize { @cancelled = true }
65
- self
66
- end
67
-
68
- # Returns +true+ when the token has been explicitly cancelled via {#cancel!},
69
- # when the wall-clock deadline has passed, or when the monotonic deadline
70
- # (set by {.timeout_after}) has elapsed. Thread-safe.
71
- # @return [Boolean]
72
- # @api public
73
- def cancelled?
74
- return true if @mutex.synchronize { @cancelled }
75
- return true if !@deadline.nil? && Time.now >= @deadline
76
- !@monotonic_deadline.nil? &&
77
- Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @monotonic_deadline
78
- end
79
-
80
- # Raises {Phronomy::CancellationError} if the token is cancelled.
81
- # A convenience method for cooperative cancellation checks inside tools,
82
- # RAG loaders, and hooks, replacing the +if cancelled? then raise+ pattern.
83
- #
84
- # @param message [String] optional error message
85
- # @return [nil] when the token is not cancelled
86
- # @raise [Phronomy::CancellationError] when the token is cancelled
87
- # @api public
88
- def raise_if_cancelled!(message = "invocation cancelled")
89
- raise Phronomy::CancellationError, message if cancelled?
90
- end
91
- end
92
- 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
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Context
5
- # Read-only context passed to the +on_compaction_trigger+ callback.
6
- #
7
- # The callback inspects the current message list and budget, then returns
8
- # a truthy value to trigger compaction or a falsy value to skip it.
9
- #
10
- # No mutations are allowed through this object; use CompactionContext
11
- # (passed to +on_compact+) for actual modifications.
12
- #
13
- # @example Trigger compaction when messages exceed 80% of the input budget
14
- # on_compaction_trigger do |ctx|
15
- # limit = ctx.budget&.available(used: 0) || Float::INFINITY
16
- # ctx.total_tokens > limit * 0.8
17
- # end
18
- class TriggerContext
19
- # @return [Array<Hash>] frozen snapshot of message elements
20
- # each element: { seq: Integer, message: Object, tokens: Integer, role: Symbol }
21
- attr_reader :message_elements
22
-
23
- # @return [Phronomy::Context::TokenBudget, nil] token budget for this invocation
24
- attr_reader :budget
25
-
26
- # @return [Integer] total estimated token count of all message elements
27
- attr_reader :total_tokens
28
-
29
- # @param message_elements [Array<Hash>]
30
- # @param budget [Phronomy::Context::TokenBudget, nil]
31
- # @api private
32
- def initialize(message_elements:, budget:)
33
- @message_elements = message_elements.dup.freeze
34
- @budget = budget
35
- @total_tokens = message_elements.sum { |e| e[:tokens] }
36
- end
37
- end
38
- end
39
- end
@@ -1,75 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Context
5
- # Context object passed to the +on_trim+ callback registered on an agent class.
6
- #
7
- # The callback receives a TrimContext and may call #remove to drop specific
8
- # messages from the conversation before the LLM is called. Changes affect
9
- # only the current invocation; the underlying memory store is not modified.
10
- #
11
- # Message elements are identified by a +:seq+ integer that is assigned
12
- # sequentially (0-based) when messages are loaded from memory each turn.
13
- #
14
- # @example Remove the oldest two messages when the budget is tight
15
- # on_trim do |ctx|
16
- # if ctx.total_tokens > ctx.budget.available(used: 0) * 0.9
17
- # seqs_to_drop = ctx.message_elements.first(2).map { |e| e[:seq] }
18
- # ctx.remove(seqs_to_drop)
19
- # end
20
- # end
21
- class TrimContext
22
- # @return [Phronomy::Context::TokenBudget, nil] token budget for this invocation
23
- attr_reader :budget
24
-
25
- # @return [Integer] total estimated token count of all current message elements
26
- attr_reader :total_tokens
27
-
28
- # @param message_elements [Array<Hash>]
29
- # each element: { seq: Integer, message: Object, tokens: Integer, role: Symbol }
30
- # @param budget [Phronomy::Context::TokenBudget, nil]
31
- # @api private
32
- def initialize(message_elements:, budget:)
33
- @message_elements = message_elements.dup
34
- @budget = budget
35
- recalculate!
36
- end
37
-
38
- # Returns a snapshot of the current message elements (defensive copy).
39
- # Each element is a Hash with +:seq+, +:message+, +:tokens+, and +:role+.
40
- #
41
- # @return [Array<Hash>]
42
- # @api private
43
- def message_elements
44
- @message_elements.dup
45
- end
46
-
47
- # Remove messages identified by seq numbers.
48
- # Calling this multiple times accumulates removals.
49
- #
50
- # @param seqs [Integer, Array<Integer>] seq number(s) to remove
51
- # @return [self]
52
- # @api private
53
- def remove(seqs)
54
- seqs_set = Array(seqs).to_set
55
- @message_elements.reject! { |e| seqs_set.include?(e[:seq]) }
56
- recalculate!
57
- self
58
- end
59
-
60
- # Convenience: returns the plain message objects (without element metadata).
61
- #
62
- # @return [Array]
63
- # @api private
64
- def messages
65
- @message_elements.map { |e| e[:message] }
66
- end
67
-
68
- private
69
-
70
- def recalculate!
71
- @total_tokens = @message_elements.sum { |e| e[:tokens] }
72
- end
73
- end
74
- end
75
- end
@@ -1,22 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Embeddings
5
- # Abstract interface for embedding adapters.
6
- #
7
- # Concrete implementations must override {#embed} and return a vector
8
- # as an +Array<Float>+.
9
- class Base
10
- # Embed the given text and return a vector representation.
11
- #
12
- # @param text [String] the text to embed
13
- # @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
14
- # @return [Array<Float>] the embedding vector
15
- # @api public
16
- def embed(text, cancellation_token = nil)
17
- cancellation_token&.raise_if_cancelled!
18
- raise NotImplementedError, "#{self.class}#embed is not implemented"
19
- end
20
- end
21
- end
22
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Embeddings
5
- # Embeddings adapter backed by RubyLLM.
6
- #
7
- # Delegates to +RubyLLM.embed+ and returns the resulting vector as an
8
- # +Array<Float>+.
9
- #
10
- # @example Default model
11
- # embeddings = Phronomy::Embeddings::RubyLLMEmbeddings.new
12
- # vector = embeddings.embed("Hello, world!")
13
- #
14
- # @example Explicit model
15
- # embeddings = Phronomy::Embeddings::RubyLLMEmbeddings.new(model: "text-embedding-3-small")
16
- # vector = embeddings.embed("Hello, world!")
17
- class RubyLLMEmbeddings < Base
18
- # @param model [String, nil] embedding model identifier; nil uses the RubyLLM default
19
- # @param provider [Symbol, nil] provider override (e.g. :openai); nil uses the RubyLLM default
20
- # @param assume_model_exists [Boolean] when true, skips RubyLLM model-registry validation
21
- # (useful for locally hosted models not in the registry)
22
- # @api public
23
- def initialize(model: nil, provider: nil, assume_model_exists: false)
24
- @model = model
25
- @provider = provider
26
- @assume_model_exists = assume_model_exists
27
- end
28
-
29
- # Embed text via RubyLLM.
30
- #
31
- # @param text [String]
32
- # @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
33
- # @return [Array<Float>]
34
- # @api public
35
- def embed(text, cancellation_token = nil)
36
- cancellation_token&.raise_if_cancelled!
37
- opts = {}
38
- opts[:model] = @model if @model
39
- opts[:provider] = @provider if @provider
40
- opts[:assume_model_exists] = true if @assume_model_exists
41
- RubyLLM.embed(text, **opts).vectors
42
- end
43
- end
44
- end
45
- end