phronomy 0.15.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -9
- data/CHANGELOG.md +159 -28
- data/CONTRIBUTING.md +28 -16
- data/README.md +400 -143
- data/benchmark/baseline.json +2 -3
- data/benchmark/bench_agent_invoke.rb +7 -4
- data/benchmark/bench_context_assembler.rb +134 -34
- data/benchmark/bench_regression.rb +3 -19
- data/benchmark/bench_tool_schema.rb +2 -34
- data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
- data/docs/decisions/010-cooperative-first-concurrency.md +7 -0
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
- data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
- data/lib/phronomy/agent/activation_registry.rb +28 -0
- data/lib/phronomy/agent/agent_execution.rb +97 -0
- data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
- data/lib/phronomy/agent/agent_invocation.rb +44 -46
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +206 -104
- data/lib/phronomy/agent/agent_root.rb +66 -0
- data/lib/phronomy/agent/async_event_api.rb +55 -475
- data/lib/phronomy/agent/base.rb +351 -514
- data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
- data/lib/phronomy/agent/context/capability/base.rb +166 -297
- data/lib/phronomy/agent/context_assembler.rb +357 -0
- data/lib/phronomy/agent/context_candidate.rb +47 -0
- data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
- data/lib/phronomy/agent/context_importer.rb +217 -0
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +118 -0
- data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
- data/lib/phronomy/agent/context_plan.rb +25 -0
- data/lib/phronomy/agent/context_plan_validator.rb +134 -0
- data/lib/phronomy/agent/context_policies/default.rb +53 -0
- data/lib/phronomy/agent/context_policy.rb +15 -0
- data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
- data/lib/phronomy/agent/context_policy_registry.rb +46 -0
- data/lib/phronomy/agent/context_request.rb +35 -0
- data/lib/phronomy/agent/context_selection_unit.rb +38 -0
- data/lib/phronomy/agent/derived_content_spec.rb +34 -0
- data/lib/phronomy/agent/execution_coordinator.rb +1122 -0
- data/lib/phronomy/agent/immutable.rb +31 -0
- data/lib/phronomy/agent/journal_projection.rb +60 -0
- data/lib/phronomy/agent/journal_record.rb +67 -0
- data/lib/phronomy/agent/llm_call_record.rb +51 -0
- data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
- data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
- data/lib/phronomy/agent/llm_input_patch.rb +21 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
- data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +189 -0
- data/lib/phronomy/agent/shared_state.rb +46 -138
- data/lib/phronomy/agent/token_budget_resolver.rb +70 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
- data/lib/phronomy/agent/tool_definition_set.rb +55 -0
- data/lib/phronomy/agent/tool_invocation.rb +108 -314
- data/lib/phronomy/agent.rb +10 -16
- data/lib/phronomy/agent_busy_error.rb +5 -0
- data/lib/phronomy/canonical_json.rb +136 -0
- data/lib/phronomy/configuration.rb +17 -155
- data/lib/phronomy/content_store/base.rb +51 -0
- data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
- data/lib/phronomy/engine/event_loop.rb +3 -0
- data/lib/phronomy/engine/runtime.rb +15 -230
- data/lib/phronomy/engine/task_group.rb +30 -102
- data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
- data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
- data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
- data/lib/phronomy/multi_agent/orchestrator.rb +153 -204
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
- data/lib/phronomy/multi_agent/team_coordinator.rb +46 -133
- data/lib/phronomy/persistence/in_memory.rb +247 -0
- data/lib/phronomy/persistence.rb +39 -0
- data/lib/phronomy/tools/agent.rb +14 -36
- data/lib/phronomy/vector_store/in_memory.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +9 -115
- data/scripts/add_to_h_to_token_doubles.rb +33 -0
- data/scripts/add_to_h_unnamed_doubles.rb +27 -0
- data/scripts/api_snapshot.rb +1 -12
- data/scripts/migrate_spec_agent_definition.rb +108 -0
- data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
- data/scripts/migrate_spec_inline_pass3.rb +24 -0
- metadata +54 -13
- data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
- data/lib/phronomy/agent/before_completion_context.rb +0 -47
- data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
- data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
- data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
- data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
- data/lib/phronomy/knowledge_source.rb +0 -12
- data/lib/phronomy/llm_context_window/assembler.rb +0 -191
- data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
require_relative "concerns/filterable"
|
|
5
|
-
require_relative "concerns/
|
|
5
|
+
require_relative "concerns/before_llm_input"
|
|
6
6
|
require_relative "concerns/error_translation"
|
|
7
7
|
|
|
8
8
|
module Phronomy
|
|
@@ -10,11 +10,12 @@ module Phronomy
|
|
|
10
10
|
# Base class for all Phronomy agents.
|
|
11
11
|
#
|
|
12
12
|
# Subclass this to create a conversational agent powered by an LLM.
|
|
13
|
-
# DSL class methods configure the model, instructions, tools,
|
|
13
|
+
# DSL class methods configure the model, instructions, tools,
|
|
14
14
|
# and execution hooks. Instance methods handle invocation.
|
|
15
15
|
#
|
|
16
16
|
# @example Minimal agent
|
|
17
17
|
# class GreetingAgent < Phronomy::Agent::Base
|
|
18
|
+
# agent_definition id: "greeting-agent", version: 1
|
|
18
19
|
# model "gpt-4o-mini"
|
|
19
20
|
# instructions "You are a friendly greeter."
|
|
20
21
|
# end
|
|
@@ -23,15 +24,16 @@ module Phronomy
|
|
|
23
24
|
#
|
|
24
25
|
# @example Agent with tools
|
|
25
26
|
# class ResearchAgent < Phronomy::Agent::Base
|
|
27
|
+
# agent_definition id: "research-agent", version: 1
|
|
26
28
|
# model "gpt-4o"
|
|
27
29
|
# instructions "You are a research assistant."
|
|
28
|
-
# tools
|
|
30
|
+
# tools(WebSearchTool => nil, CalculatorTool => nil)
|
|
29
31
|
# max_iterations 15
|
|
30
32
|
# end
|
|
31
33
|
class Base
|
|
32
34
|
include Phronomy::Runnable
|
|
33
35
|
include Concerns::Filterable
|
|
34
|
-
include Concerns::
|
|
36
|
+
include Concerns::BeforeLLMInput
|
|
35
37
|
include Concerns::ErrorTranslation
|
|
36
38
|
|
|
37
39
|
APPROVAL_CONFIGURATION_INIT_MUTEX = Mutex.new
|
|
@@ -84,40 +86,34 @@ module Phronomy
|
|
|
84
86
|
|
|
85
87
|
# Registers tool classes for this agent.
|
|
86
88
|
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
# (e.g. two SearchTool classes from different modules).
|
|
89
|
+
# The setter accepts one Hash mapping each Tool class to an explicit alias
|
|
90
|
+
# name (String) or nil (use the Tool's own name). Calling without an
|
|
91
|
+
# argument returns the registered Tool classes.
|
|
91
92
|
#
|
|
92
|
-
# @example
|
|
93
|
-
# tools WeatherTool, TimeTool
|
|
94
|
-
#
|
|
95
|
-
# @example Hash form (with optional per-tool alias)
|
|
93
|
+
# @example
|
|
96
94
|
# tools(
|
|
97
95
|
# Weather::SearchTool => "weather_search",
|
|
98
96
|
# Places::SearchTool => "places_search",
|
|
99
97
|
# CurrentTimeTool => nil
|
|
100
98
|
# )
|
|
101
99
|
# @api public
|
|
102
|
-
def tools(
|
|
103
|
-
if
|
|
104
|
-
if instance_variable_defined?(:@tools)
|
|
105
|
-
return @tools
|
|
106
|
-
end
|
|
100
|
+
def tools(definitions = nil)
|
|
101
|
+
if definitions.nil?
|
|
102
|
+
return @tools if instance_variable_defined?(:@tools)
|
|
107
103
|
return superclass.respond_to?(:tools) ? superclass.tools : []
|
|
108
104
|
end
|
|
109
105
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
@tool_aliases = hash.transform_values { |v| v&.to_s }.reject { |_, v| v.nil? }
|
|
114
|
-
else
|
|
115
|
-
@tools = args
|
|
116
|
-
@tool_aliases = {}
|
|
106
|
+
unless definitions.is_a?(Hash)
|
|
107
|
+
raise ArgumentError,
|
|
108
|
+
"tools expects a Hash of ToolClass => alias_or_nil"
|
|
117
109
|
end
|
|
110
|
+
|
|
111
|
+
@tools = definitions.keys
|
|
112
|
+
@tool_aliases = definitions.transform_values { |value| value&.to_s }
|
|
113
|
+
.reject { |_, value| value.nil? }
|
|
118
114
|
end
|
|
119
115
|
|
|
120
|
-
# Returns the alias map registered via
|
|
116
|
+
# Returns the alias map registered via .tools.
|
|
121
117
|
# Merges parent class aliases so subclasses inherit their parent's mappings.
|
|
122
118
|
# Subclass-specific aliases take precedence over parent aliases.
|
|
123
119
|
# @return [Hash{Class => String}]
|
|
@@ -188,73 +184,9 @@ module Phronomy
|
|
|
188
184
|
end
|
|
189
185
|
end
|
|
190
186
|
|
|
191
|
-
# Registers one or more static knowledge sources on the agent class.
|
|
192
|
-
# Static source content is fetched and memoized at the **class** level
|
|
193
|
-
# the first time +invoke+ is called. The cache persists for the lifetime
|
|
194
|
-
# of the process; call {.static_knowledge_refresh!} to force a reload.
|
|
195
|
-
#
|
|
196
|
-
# @param sources [Array<Phronomy::Agent::Context::Knowledge::Base>]
|
|
197
|
-
# @example
|
|
198
|
-
# class PolicyAgent < Phronomy::Agent::Base
|
|
199
|
-
# static_knowledge Phronomy::Agent::Context::Knowledge::StaticKnowledge.new(POLICY_TEXT)
|
|
200
|
-
# end
|
|
201
|
-
# @api public
|
|
202
|
-
def static_knowledge(*sources)
|
|
203
|
-
@static_knowledge_sources = sources.flatten
|
|
204
|
-
# Invalidate the cached chunks so the new sources are fetched on
|
|
205
|
-
# the next call to static_knowledge_chunks.
|
|
206
|
-
@static_knowledge_chunks = nil
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
# Returns the registered static knowledge sources.
|
|
210
|
-
# @return [Array<Phronomy::Agent::Context::Knowledge::Base>]
|
|
211
|
-
# @api public
|
|
212
|
-
def static_knowledge_sources
|
|
213
|
-
@static_knowledge_sources || []
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
# Returns the fetched content from all static knowledge sources.
|
|
217
|
-
# Results are cached at the class level so that each source is fetched
|
|
218
|
-
# only once regardless of how many times the agent is invoked.
|
|
219
|
-
# @return [Array<Hash>]
|
|
220
|
-
# @api public
|
|
221
|
-
def static_knowledge_chunks
|
|
222
|
-
@static_knowledge_chunks ||= static_knowledge_sources.flat_map { |ks|
|
|
223
|
-
ks.fetch(query: nil)
|
|
224
|
-
}
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
# Clears the class-level knowledge cache so that the next +invoke+ call
|
|
228
|
-
# re-fetches content from all registered static knowledge sources.
|
|
229
|
-
#
|
|
230
|
-
# Call this method when the underlying knowledge source has been updated
|
|
231
|
-
# at runtime (e.g. a file was rewritten, a DB record changed) and you
|
|
232
|
-
# want the agent to pick up the new content without restarting the
|
|
233
|
-
# process.
|
|
234
|
-
#
|
|
235
|
-
# @return [nil]
|
|
236
|
-
# @example Refresh after updating a knowledge file
|
|
237
|
-
# MyAgent.static_knowledge_refresh!
|
|
238
|
-
# @api public
|
|
239
|
-
def static_knowledge_refresh!
|
|
240
|
-
@static_knowledge_chunks = nil
|
|
241
|
-
end
|
|
242
|
-
|
|
243
187
|
# When enabled, attaches Anthropic prompt-cache markers to the system
|
|
244
188
|
# message so that the fixed instructions are served from cache on
|
|
245
189
|
# subsequent turns, reducing input-token costs.
|
|
246
|
-
#
|
|
247
|
-
# Only has an effect when the agent also declares `provider :anthropic`.
|
|
248
|
-
# The cache_control field is provider-specific (the format differs
|
|
249
|
-
# between Anthropic direct, Bedrock, etc.), so the agent must explicitly
|
|
250
|
-
# declare its provider via the DSL rather than having it inferred from
|
|
251
|
-
# the model name.
|
|
252
|
-
#
|
|
253
|
-
# @example
|
|
254
|
-
# class MyAgent < Phronomy::Agent::Base
|
|
255
|
-
# provider :anthropic
|
|
256
|
-
# cache_instructions true
|
|
257
|
-
# end
|
|
258
190
|
# @api public
|
|
259
191
|
def cache_instructions(enabled = nil)
|
|
260
192
|
if enabled.nil?
|
|
@@ -266,11 +198,6 @@ module Phronomy
|
|
|
266
198
|
|
|
267
199
|
# Tokens to reserve for the model's output.
|
|
268
200
|
# When nil, the model's max_output_tokens from the registry is used.
|
|
269
|
-
#
|
|
270
|
-
# @example
|
|
271
|
-
# class MyAgent < Phronomy::Agent::Base
|
|
272
|
-
# max_output_tokens 4096
|
|
273
|
-
# end
|
|
274
201
|
# @api public
|
|
275
202
|
def max_output_tokens(val = nil)
|
|
276
203
|
if val.nil?
|
|
@@ -281,14 +208,6 @@ module Phronomy
|
|
|
281
208
|
end
|
|
282
209
|
|
|
283
210
|
# Overrides the context window size used for token budget calculations.
|
|
284
|
-
# When set, this value takes precedence over the RubyLLM model registry,
|
|
285
|
-
# which is useful for locally-hosted models (e.g. LM Studio) where the
|
|
286
|
-
# actually-loaded context length may differ from the catalogue value.
|
|
287
|
-
#
|
|
288
|
-
# @example
|
|
289
|
-
# class MyAgent < Phronomy::Agent::Base
|
|
290
|
-
# context_window 4096
|
|
291
|
-
# end
|
|
292
211
|
# @api public
|
|
293
212
|
def context_window(val = nil)
|
|
294
213
|
if val.nil?
|
|
@@ -298,47 +217,55 @@ module Phronomy
|
|
|
298
217
|
end
|
|
299
218
|
end
|
|
300
219
|
|
|
301
|
-
#
|
|
302
|
-
#
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
220
|
+
# Defines or reads the stable Agent definition identity.
|
|
221
|
+
# Subclass with no explicit declaration inherits the parent's definition.
|
|
222
|
+
def agent_definition(id: nil, version: nil)
|
|
223
|
+
if id || version
|
|
224
|
+
raise ArgumentError, "agent_definition requires id: and version:" unless id && version
|
|
225
|
+
@agent_definition = {id: id.to_s.freeze, version: Integer(version)}.freeze
|
|
226
|
+
end
|
|
227
|
+
return @agent_definition if @agent_definition
|
|
228
|
+
|
|
229
|
+
klass = superclass
|
|
230
|
+
while klass.respond_to?(:agent_definition, true) &&
|
|
231
|
+
klass < Phronomy::Agent::Base
|
|
232
|
+
defn = klass.instance_variable_get(:@agent_definition)
|
|
233
|
+
return defn if defn
|
|
234
|
+
klass = klass.superclass
|
|
314
235
|
end
|
|
236
|
+
|
|
237
|
+
raise Phronomy::ConfigurationError,
|
|
238
|
+
"#{name || self} must declare agent_definition id: ..., version: ..."
|
|
315
239
|
end
|
|
316
240
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
241
|
+
def create(agent_id: SecureRandom.uuid, context: nil, knowledge: [], persistence: nil, metadata: {})
|
|
242
|
+
new(
|
|
243
|
+
agent_id: agent_id,
|
|
244
|
+
context: context,
|
|
245
|
+
knowledge: knowledge,
|
|
246
|
+
persistence: persistence,
|
|
247
|
+
metadata: metadata
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def load(agent_id, persistence:)
|
|
252
|
+
new(agent_id: agent_id, persistence: persistence, load_existing: true)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def approve(execution_id, approval_request_id:, persistence:, approved: true, config: {})
|
|
256
|
+
approve_async(
|
|
257
|
+
execution_id,
|
|
326
258
|
approval_request_id: approval_request_id,
|
|
327
259
|
approved: approved,
|
|
328
|
-
config: config
|
|
329
|
-
|
|
260
|
+
config: config,
|
|
261
|
+
persistence: persistence
|
|
262
|
+
).wait_result
|
|
330
263
|
end
|
|
331
264
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
# @param config [Hash]
|
|
337
|
-
# @return [Phronomy::Task]
|
|
338
|
-
# @api public
|
|
339
|
-
def approve_async(agent_invocation_id, approval_request_id:, approved: true, config: {})
|
|
340
|
-
new.approve_async(
|
|
341
|
-
agent_invocation_id,
|
|
265
|
+
def approve_async(execution_id, approval_request_id:, persistence:, approved: true, config: {})
|
|
266
|
+
execution = persistence.executions.load(execution_id)
|
|
267
|
+
load(execution.agent_id, persistence: persistence).approve_async(
|
|
268
|
+
execution_id,
|
|
342
269
|
approval_request_id: approval_request_id,
|
|
343
270
|
approved: approved,
|
|
344
271
|
config: config
|
|
@@ -346,29 +273,256 @@ module Phronomy
|
|
|
346
273
|
end
|
|
347
274
|
end
|
|
348
275
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
276
|
+
attr_reader :agent_id, :persistence
|
|
277
|
+
|
|
278
|
+
def initialize(
|
|
279
|
+
agent_id: SecureRandom.uuid,
|
|
280
|
+
context: nil,
|
|
281
|
+
knowledge: [],
|
|
282
|
+
persistence: nil,
|
|
283
|
+
metadata: {},
|
|
284
|
+
load_existing: false
|
|
285
|
+
)
|
|
286
|
+
@persistence = persistence || Phronomy::Persistence::InMemory.new
|
|
287
|
+
@agent_id = agent_id.to_s.freeze
|
|
288
|
+
@root = if load_existing
|
|
289
|
+
loaded = @persistence.agents.load(@agent_id)
|
|
290
|
+
definition = self.class.agent_definition
|
|
291
|
+
unless loaded.agent_definition_id == definition.fetch(:id) &&
|
|
292
|
+
loaded.definition_version == definition.fetch(:version)
|
|
293
|
+
raise Phronomy::ConfigurationError,
|
|
294
|
+
"Agent definition mismatch for #{@agent_id}: stored " \
|
|
295
|
+
"#{loaded.agent_definition_id}@#{loaded.definition_version}, runtime " \
|
|
296
|
+
"#{definition.fetch(:id)}@#{definition.fetch(:version)}"
|
|
297
|
+
end
|
|
298
|
+
loaded
|
|
299
|
+
else
|
|
300
|
+
create_agent_root!(context: context, knowledge: knowledge, metadata: metadata)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def agent_root
|
|
305
|
+
@root
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def journal_projection
|
|
309
|
+
Agent::JournalProjection.new(persistence: persistence, agent_root: @root)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def transcript
|
|
313
|
+
journal_projection.transcript_records
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def clear_transcript!
|
|
317
|
+
mutate_context!(:transcript_cleared) do |root|
|
|
318
|
+
root.with(
|
|
319
|
+
agent_revision: root.agent_revision + 1,
|
|
320
|
+
context_revision: root.context_revision + 1,
|
|
321
|
+
transcript_generation: root.transcript_generation + 1
|
|
322
|
+
)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Logically clears all persistent Knowledge registered before this point.
|
|
327
|
+
# Raw Journal records remain append-only and are not deleted.
|
|
328
|
+
def clear_knowledge!
|
|
329
|
+
mutate_context!(:knowledge_cleared) do |root|
|
|
330
|
+
root.with(
|
|
331
|
+
agent_revision: root.agent_revision + 1,
|
|
332
|
+
context_revision: root.context_revision + 1
|
|
333
|
+
)
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Appends persistent Knowledge to the Agent Journal.
|
|
338
|
+
# Knowledge is an optional Context candidate; it is not part of #transcript.
|
|
339
|
+
def add_knowledge(content, metadata: {})
|
|
340
|
+
next_root = nil
|
|
341
|
+
persistence.transaction do |tx|
|
|
342
|
+
tx.executions.assert_idle!(agent_id)
|
|
343
|
+
current = tx.agents.load(agent_id)
|
|
344
|
+
record = build_knowledge_record(
|
|
345
|
+
tx: tx,
|
|
346
|
+
root: current,
|
|
347
|
+
content: content,
|
|
348
|
+
metadata: metadata
|
|
349
|
+
)
|
|
350
|
+
appended = tx.journals.append(
|
|
351
|
+
agent_id,
|
|
352
|
+
expected_position: current.journal_position,
|
|
353
|
+
records: [record]
|
|
354
|
+
)
|
|
355
|
+
next_root = current.with(
|
|
356
|
+
agent_revision: current.agent_revision + 1,
|
|
357
|
+
context_revision: current.context_revision + 1,
|
|
358
|
+
journal_position: current.journal_position + appended.length
|
|
359
|
+
)
|
|
360
|
+
tx.agents.save(
|
|
361
|
+
agent_id,
|
|
362
|
+
expected_revision: current.agent_revision,
|
|
363
|
+
root: next_root
|
|
364
|
+
)
|
|
365
|
+
end
|
|
366
|
+
@root = next_root
|
|
367
|
+
self
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def reset_context!
|
|
371
|
+
mutate_context!(:context_reset) do |root|
|
|
372
|
+
root.with(
|
|
373
|
+
agent_revision: root.agent_revision + 1,
|
|
374
|
+
context_revision: root.context_revision + 1,
|
|
375
|
+
transcript_generation: root.transcript_generation + 1
|
|
376
|
+
)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def close!
|
|
381
|
+
mutate_context!(:agent_closed, context_affecting: false) do |root|
|
|
382
|
+
root.with(
|
|
383
|
+
agent_revision: root.agent_revision + 1,
|
|
384
|
+
lifecycle_status: :closed
|
|
385
|
+
)
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def purge!
|
|
390
|
+
persistence.transaction do |tx|
|
|
391
|
+
tx.executions.assert_idle!(agent_id)
|
|
392
|
+
tx.journals.delete(agent_id)
|
|
393
|
+
tx.executions.delete_for_agent(agent_id)
|
|
394
|
+
tx.agents.delete(agent_id)
|
|
395
|
+
end
|
|
396
|
+
@root = nil
|
|
397
|
+
true
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Internal hook used after a successful Persistence transaction.
|
|
401
|
+
def __replace_root(root)
|
|
402
|
+
@root = root
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
private
|
|
406
|
+
|
|
407
|
+
def create_agent_root!(context:, knowledge:, metadata:)
|
|
408
|
+
definition = self.class.agent_definition
|
|
409
|
+
root = Agent::AgentRoot.create(
|
|
410
|
+
agent_id: agent_id,
|
|
411
|
+
agent_definition_id: definition.fetch(:id),
|
|
412
|
+
definition_version: definition.fetch(:version),
|
|
413
|
+
metadata: metadata
|
|
414
|
+
)
|
|
415
|
+
persistence.transaction do |tx|
|
|
416
|
+
tx.agents.create(root)
|
|
417
|
+
records = initial_context_records(tx: tx, root: root, context: context)
|
|
418
|
+
records.concat(initial_knowledge_records(tx: tx, root: root, knowledge: knowledge))
|
|
419
|
+
unless records.empty?
|
|
420
|
+
appended = tx.journals.append(agent_id, expected_position: 0, records: records)
|
|
421
|
+
root = root.with(
|
|
422
|
+
agent_revision: 1,
|
|
423
|
+
context_revision: 1,
|
|
424
|
+
journal_position: appended.length
|
|
425
|
+
)
|
|
426
|
+
tx.agents.save(agent_id, expected_revision: 0, root: root)
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
root
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def initial_context_records(tx:, root:, context:)
|
|
433
|
+
return [] unless context
|
|
434
|
+
|
|
435
|
+
imported = context.respond_to?(:records) ? context :
|
|
436
|
+
Agent::ContextImporter.import_messages(context)
|
|
437
|
+
imported.records.map do |record|
|
|
438
|
+
content_ref = case record.content_format
|
|
439
|
+
when :text then tx.contents.put_text(record.content)
|
|
440
|
+
when :json then tx.contents.put_json(record.content)
|
|
441
|
+
else
|
|
442
|
+
raise ArgumentError,
|
|
443
|
+
"unsupported imported content format: #{record.content_format.inspect}"
|
|
444
|
+
end
|
|
445
|
+
Agent::JournalRecord.new(
|
|
446
|
+
agent_id: agent_id,
|
|
447
|
+
kind: record.kind,
|
|
448
|
+
channel: record.channel,
|
|
449
|
+
role: record.role,
|
|
450
|
+
content_ref: content_ref,
|
|
451
|
+
context_generation: root.transcript_generation,
|
|
452
|
+
context_candidate: true,
|
|
453
|
+
metadata: record.metadata
|
|
454
|
+
)
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def initial_knowledge_records(tx:, root:, knowledge:)
|
|
459
|
+
Array(knowledge).map do |content|
|
|
460
|
+
build_knowledge_record(
|
|
461
|
+
tx: tx,
|
|
462
|
+
root: root,
|
|
463
|
+
content: content,
|
|
464
|
+
metadata: {}
|
|
465
|
+
)
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def build_knowledge_record(tx:, root:, content:, metadata:)
|
|
470
|
+
Agent::JournalRecord.new(
|
|
471
|
+
agent_id: agent_id,
|
|
472
|
+
kind: :knowledge,
|
|
473
|
+
channel: :context,
|
|
474
|
+
role: :user,
|
|
475
|
+
content_ref: tx.contents.put_text(String(content)),
|
|
476
|
+
context_generation: root.transcript_generation,
|
|
477
|
+
context_candidate: true,
|
|
478
|
+
metadata: metadata || {}
|
|
479
|
+
)
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def mutate_context!(kind, context_affecting: true)
|
|
483
|
+
next_root = nil
|
|
484
|
+
persistence.transaction do |tx|
|
|
485
|
+
tx.executions.assert_idle!(agent_id)
|
|
486
|
+
current = tx.agents.load(agent_id)
|
|
487
|
+
record = Agent::JournalRecord.new(
|
|
488
|
+
agent_id: agent_id,
|
|
489
|
+
kind: kind,
|
|
490
|
+
channel: :state,
|
|
491
|
+
context_generation: current.transcript_generation,
|
|
492
|
+
context_candidate: false
|
|
493
|
+
)
|
|
494
|
+
appended = tx.journals.append(
|
|
495
|
+
agent_id,
|
|
496
|
+
expected_position: current.journal_position,
|
|
497
|
+
records: [record]
|
|
498
|
+
)
|
|
499
|
+
proposed = yield(current)
|
|
500
|
+
next_root = proposed.with(
|
|
501
|
+
journal_position: current.journal_position + appended.length,
|
|
502
|
+
context_revision: context_affecting ?
|
|
503
|
+
yield_context_revision(current, proposed) : current.context_revision
|
|
504
|
+
)
|
|
505
|
+
tx.agents.save(agent_id, expected_revision: current.agent_revision, root: next_root)
|
|
506
|
+
end
|
|
507
|
+
@root = next_root
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def yield_context_revision(current, proposed)
|
|
511
|
+
(proposed.context_revision == current.context_revision) ? current.context_revision + 1 : proposed.context_revision
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
public
|
|
515
|
+
|
|
354
516
|
def _add_handoff_tool(tool_class)
|
|
355
517
|
@_handoff_tools ||= []
|
|
356
518
|
@_handoff_tools << tool_class
|
|
357
519
|
self
|
|
358
520
|
end
|
|
359
521
|
|
|
360
|
-
# Returns handoff tool classes registered on this instance by Runner.
|
|
361
|
-
# @return [Array<Class>]
|
|
362
|
-
# @api private
|
|
363
522
|
def _handoff_tools
|
|
364
523
|
@_handoff_tools || []
|
|
365
524
|
end
|
|
366
525
|
|
|
367
|
-
# Registers the final Agent/Application authorization policy.
|
|
368
|
-
# The block runs on the Runtime authorization pool and must return
|
|
369
|
-
# :allow, :require_approval, or :reject.
|
|
370
|
-
# @return [self]
|
|
371
|
-
# @api public
|
|
372
526
|
def tool_approval_policy(&block)
|
|
373
527
|
raise ArgumentError, "tool_approval_policy requires a block" unless block
|
|
374
528
|
|
|
@@ -376,9 +530,6 @@ module Phronomy
|
|
|
376
530
|
self
|
|
377
531
|
end
|
|
378
532
|
|
|
379
|
-
# Registers a non-blocking Application notification listener.
|
|
380
|
-
# @return [self]
|
|
381
|
-
# @api public
|
|
382
533
|
def on_tool_approval_required(&block)
|
|
383
534
|
raise ArgumentError, "on_tool_approval_required requires a block" unless block
|
|
384
535
|
|
|
@@ -386,28 +537,17 @@ module Phronomy
|
|
|
386
537
|
self
|
|
387
538
|
end
|
|
388
539
|
|
|
389
|
-
# @deprecated The context version cache has been removed. Returns nil.
|
|
390
|
-
# Retained for backward compatibility with callers using safe navigation (+&.reset+).
|
|
391
|
-
# @api private
|
|
392
|
-
def context_version_cache
|
|
393
|
-
nil
|
|
394
|
-
end
|
|
395
|
-
|
|
396
540
|
private
|
|
397
541
|
|
|
398
|
-
# Merges an {InvocationContext} into the +thread_id+ / +config+ pair.
|
|
399
|
-
# Returns +[effective_thread_id, effective_config]+.
|
|
400
|
-
#
|
|
401
|
-
# Precedence rules (existing explicit values always win):
|
|
402
|
-
# - +thread_id+ argument > +ic.thread_id+
|
|
403
|
-
# - +config[:cancellation_token]+ > +ic.cancellation_token+ > token derived from +ic.deadline+
|
|
404
|
-
# - +ic+ is stored in +config[:invocation_context]+ (overwriting any previous value)
|
|
405
542
|
def _apply_invocation_context(thread_id, config, ic)
|
|
406
543
|
effective_thread_id = thread_id || ic.thread_id
|
|
407
544
|
effective_config = config.merge(invocation_context: ic)
|
|
408
545
|
if effective_config[:cancellation_token].nil?
|
|
409
546
|
if (tok = ic.effective_timeout_token)
|
|
410
|
-
effective_config = effective_config.merge(
|
|
547
|
+
effective_config = effective_config.merge(
|
|
548
|
+
cancellation_token: tok,
|
|
549
|
+
phronomy_timeout_deadline: ic.deadline
|
|
550
|
+
)
|
|
411
551
|
end
|
|
412
552
|
end
|
|
413
553
|
[effective_thread_id, effective_config]
|
|
@@ -434,173 +574,6 @@ module Phronomy
|
|
|
434
574
|
end
|
|
435
575
|
end
|
|
436
576
|
|
|
437
|
-
# Assembles the LLM context (system prompt + conversation messages)
|
|
438
|
-
# for a single invocation. Subclasses may override this method to
|
|
439
|
-
# inject custom context editing logic without having to override
|
|
440
|
-
# the full #invoke_once pipeline.
|
|
441
|
-
#
|
|
442
|
-
# The keyword arguments +budget+, +instruction+, +tools+, and +knowledge+
|
|
443
|
-
# carry pre-computed values. Override them in a subclass call to +super+
|
|
444
|
-
# to inject custom context without recomputing the defaults.
|
|
445
|
-
#
|
|
446
|
-
# @param input [String, Hash] the user's input for this turn
|
|
447
|
-
# @param messages [Array<RubyLLM::Message>] raw conversation history
|
|
448
|
-
# @param thread_id [String, nil] conversation thread identifier
|
|
449
|
-
# @param config [Hash] the invocation config (see #invoke)
|
|
450
|
-
# @param budget [LlmContextWindow::TokenBudget, nil] pre-computed token budget
|
|
451
|
-
# @param instruction [String, nil] pre-computed system instruction
|
|
452
|
-
# @param tools [Array<Class>] tool classes to expose
|
|
453
|
-
# @param knowledge [Array<Hash>] knowledge chunks ({ content:, type:, source: })
|
|
454
|
-
# @return [Hash] { system: String|nil, messages: Array, tool_classes: Array }
|
|
455
|
-
# @api public
|
|
456
|
-
def build_context(
|
|
457
|
-
input,
|
|
458
|
-
messages: [],
|
|
459
|
-
thread_id: nil,
|
|
460
|
-
config: {},
|
|
461
|
-
budget: build_token_budget,
|
|
462
|
-
instruction: build_instructions(input),
|
|
463
|
-
tools: self.class.tools + _handoff_tools,
|
|
464
|
-
knowledge: self.class.static_knowledge_chunks + instance_knowledge_chunks
|
|
465
|
-
)
|
|
466
|
-
assembler = LlmContextWindow::Assembler.new(budget: budget)
|
|
467
|
-
assembler.add_instruction(instruction) if instruction
|
|
468
|
-
assembler.add_capability(tools)
|
|
469
|
-
knowledge.each { |chunk| assembler.add_knowledge(chunk[:content], type: chunk[:type] || :static, trusted: true, source: chunk[:source]) }
|
|
470
|
-
|
|
471
|
-
msgs = Array(messages)
|
|
472
|
-
|
|
473
|
-
if budget && budget_exceeded?(msgs)
|
|
474
|
-
# Default strategy when the token budget is tight:
|
|
475
|
-
# 1. Compact: keep the most recent half of the messages verbatim and
|
|
476
|
-
# replace the older half with a brief omission marker.
|
|
477
|
-
# 2. Trim: if the compacted history still exceeds the budget, call
|
|
478
|
-
# trim_to_budget with the :safe strategy, which discards the oldest
|
|
479
|
-
# message one at a time until the history fits.
|
|
480
|
-
# Subclasses can override build_context to apply a different strategy
|
|
481
|
-
# (e.g. LLM-based summarisation) before calling super.
|
|
482
|
-
keep = [msgs.size / 2, 2].max
|
|
483
|
-
msgs = compact_messages(msgs, keep_tail: keep) do |dropped|
|
|
484
|
-
"[#{dropped.size} earlier messages omitted]"
|
|
485
|
-
end
|
|
486
|
-
remaining = assembler.available_for_messages
|
|
487
|
-
msgs = trim_to_budget(msgs, remaining: remaining, strategy: :safe)
|
|
488
|
-
end
|
|
489
|
-
|
|
490
|
-
assembler.add_messages(msgs)
|
|
491
|
-
@last_context = assembler.build
|
|
492
|
-
end
|
|
493
|
-
protected :build_context
|
|
494
|
-
|
|
495
|
-
# Keeps the last +keep+ messages from +messages+, discarding older ones.
|
|
496
|
-
# Use this inside a +build_context+ override to trim conversation history.
|
|
497
|
-
#
|
|
498
|
-
# @param messages [Array<RubyLLM::Message>] conversation history
|
|
499
|
-
# @param keep [Integer] number of messages to retain (from the tail)
|
|
500
|
-
# @return [Array<RubyLLM::Message>]
|
|
501
|
-
# @api public
|
|
502
|
-
def trim_messages(messages, keep:)
|
|
503
|
-
Array(messages).last(keep)
|
|
504
|
-
end
|
|
505
|
-
protected :trim_messages
|
|
506
|
-
|
|
507
|
-
# Removes the oldest messages one at a time until the count is within +limit+.
|
|
508
|
-
#
|
|
509
|
-
# @param messages [Array<RubyLLM::Message>] conversation history
|
|
510
|
-
# @param limit [Integer] maximum number of messages to retain
|
|
511
|
-
# @return [Array<RubyLLM::Message>]
|
|
512
|
-
# @api public
|
|
513
|
-
def drop_messages_over(messages, limit:)
|
|
514
|
-
msgs = Array(messages).dup
|
|
515
|
-
msgs.shift while msgs.size > limit
|
|
516
|
-
msgs
|
|
517
|
-
end
|
|
518
|
-
protected :drop_messages_over
|
|
519
|
-
|
|
520
|
-
# Replaces all but the last +keep_tail+ messages with a single system summary.
|
|
521
|
-
# The block receives the dropped messages and must return a summary String.
|
|
522
|
-
#
|
|
523
|
-
# @param messages [Array<RubyLLM::Message>] conversation history
|
|
524
|
-
# @param keep_tail [Integer] number of recent messages to preserve verbatim
|
|
525
|
-
# @yield [Array<RubyLLM::Message>] the messages being summarised
|
|
526
|
-
# @yieldreturn [String] summary text
|
|
527
|
-
# @return [Array<RubyLLM::Message>]
|
|
528
|
-
# @api public
|
|
529
|
-
def compact_messages(messages, keep_tail:, &summariser)
|
|
530
|
-
msgs = Array(messages)
|
|
531
|
-
return msgs if msgs.size <= keep_tail
|
|
532
|
-
tail = msgs.last(keep_tail)
|
|
533
|
-
dropped = msgs.first(msgs.size - keep_tail)
|
|
534
|
-
summary_text = summariser.call(dropped)
|
|
535
|
-
[RubyLLM::Message.new(role: :system, content: summary_text)] + tail
|
|
536
|
-
end
|
|
537
|
-
protected :compact_messages
|
|
538
|
-
|
|
539
|
-
# Trims +messages+ to fit within +remaining+ tokens using the given
|
|
540
|
-
# +strategy+. Returns the trimmed message array without touching the
|
|
541
|
-
# assembler. The caller is responsible for passing the result to
|
|
542
|
-
# +assembler.add_messages+ and calling +assembler.build+.
|
|
543
|
-
#
|
|
544
|
-
# Supported strategies:
|
|
545
|
-
# +:safe+ — discard the oldest message one at a time (default)
|
|
546
|
-
#
|
|
547
|
-
# @param messages [Array<RubyLLM::Message>] conversation history
|
|
548
|
-
# @param remaining [Integer, nil] token allowance for messages; when +nil+
|
|
549
|
-
# the messages are returned unchanged
|
|
550
|
-
# @param strategy [Symbol] trim strategy (default +:safe+)
|
|
551
|
-
# @return [Array<RubyLLM::Message>]
|
|
552
|
-
# @api public
|
|
553
|
-
def trim_to_budget(messages, remaining:, strategy: :safe)
|
|
554
|
-
return Array(messages) unless remaining
|
|
555
|
-
msgs = Array(messages)
|
|
556
|
-
loop do
|
|
557
|
-
used = msgs.sum { |m| LlmContextWindow::TokenEstimator.estimate(m.content.to_s) }
|
|
558
|
-
return msgs if used <= remaining
|
|
559
|
-
break if msgs.empty?
|
|
560
|
-
msgs = trim_messages(msgs, keep: msgs.size - 1)
|
|
561
|
-
end
|
|
562
|
-
msgs
|
|
563
|
-
end
|
|
564
|
-
protected :trim_to_budget
|
|
565
|
-
|
|
566
|
-
# Returns +true+ when the estimated token usage of +messages+ exceeds
|
|
567
|
-
# +threshold+ times the available context budget.
|
|
568
|
-
# Always returns +false+ when no token budget is available.
|
|
569
|
-
#
|
|
570
|
-
# @param messages [Array<RubyLLM::Message>] conversation history
|
|
571
|
-
# @param threshold [Float] fraction of the available budget (default 0.8)
|
|
572
|
-
# @return [Boolean]
|
|
573
|
-
# @api public
|
|
574
|
-
def budget_exceeded?(messages, threshold: 0.8)
|
|
575
|
-
return false unless (b = build_token_budget)
|
|
576
|
-
total = Array(messages).sum { |m| LlmContextWindow::TokenEstimator.estimate(m.content.to_s) }
|
|
577
|
-
limit = b.available(used: 0)
|
|
578
|
-
total > limit * threshold
|
|
579
|
-
end
|
|
580
|
-
protected :budget_exceeded?
|
|
581
|
-
|
|
582
|
-
# Registers a per-instance knowledge source. Knowledge chunks from all
|
|
583
|
-
# registered sources are included in every LLM call via +build_context+.
|
|
584
|
-
#
|
|
585
|
-
# @param source [#fetch] any object responding to +fetch(query:)+
|
|
586
|
-
# @return [void]
|
|
587
|
-
# @api public
|
|
588
|
-
def add_knowledge_source(source)
|
|
589
|
-
@instance_knowledge_sources ||= []
|
|
590
|
-
@instance_knowledge_sources << source
|
|
591
|
-
end
|
|
592
|
-
protected :add_knowledge_source
|
|
593
|
-
|
|
594
|
-
# Returns knowledge chunks fetched from all instance-level knowledge sources.
|
|
595
|
-
#
|
|
596
|
-
# @return [Array<Hash>]
|
|
597
|
-
# @api private
|
|
598
|
-
def instance_knowledge_chunks
|
|
599
|
-
return [] unless @instance_knowledge_sources
|
|
600
|
-
@instance_knowledge_sources.flat_map { |ks| ks.fetch(query: nil) }
|
|
601
|
-
end
|
|
602
|
-
protected :instance_knowledge_chunks
|
|
603
|
-
|
|
604
577
|
def _complete_result_task(task, result)
|
|
605
578
|
task.backend.unblock(result, nil)
|
|
606
579
|
task.transition!(:completed, value: result)
|
|
@@ -617,20 +590,6 @@ module Phronomy
|
|
|
617
590
|
translated
|
|
618
591
|
end
|
|
619
592
|
|
|
620
|
-
def _build_stream_terminal_event(result)
|
|
621
|
-
if result[:suspended]
|
|
622
|
-
StreamEvent.new(
|
|
623
|
-
type: :approval_required,
|
|
624
|
-
payload: {request: result[:approval_request]}
|
|
625
|
-
)
|
|
626
|
-
else
|
|
627
|
-
StreamEvent.new(type: :done, payload: result)
|
|
628
|
-
end
|
|
629
|
-
end
|
|
630
|
-
|
|
631
|
-
# Returns the Application exception instead of allowing it to escape the
|
|
632
|
-
# shared EventLoop. A nil return means delivery succeeded or no listener
|
|
633
|
-
# was registered.
|
|
634
593
|
def _deliver_stream_event(listener, event)
|
|
635
594
|
return unless listener
|
|
636
595
|
|
|
@@ -695,94 +654,6 @@ module Phronomy
|
|
|
695
654
|
nil
|
|
696
655
|
end
|
|
697
656
|
|
|
698
|
-
# Continues a suspended AgentInvocation. The parent session is registered
|
|
699
|
-
# asynchronously; this method is only the synchronous wrapper.
|
|
700
|
-
# @return [Hash]
|
|
701
|
-
# @api public
|
|
702
|
-
def approve(agent_invocation_id, approval_request_id:, approved: true, config: {})
|
|
703
|
-
_check_scheduler_reentrancy(:approve, :approve_async)
|
|
704
|
-
approve_async(
|
|
705
|
-
agent_invocation_id,
|
|
706
|
-
approval_request_id: approval_request_id,
|
|
707
|
-
approved: approved,
|
|
708
|
-
config: config
|
|
709
|
-
).wait_result
|
|
710
|
-
end
|
|
711
|
-
public :approve
|
|
712
|
-
|
|
713
|
-
# Continues a suspended AgentInvocation without blocking the caller.
|
|
714
|
-
#
|
|
715
|
-
# This method is safe to call from an EventLoop stream callback. The
|
|
716
|
-
# returned Task completes when the resumed AgentInvocation finishes,
|
|
717
|
-
# suspends again, or fails.
|
|
718
|
-
# @return [Phronomy::Task]
|
|
719
|
-
# @api public
|
|
720
|
-
def approve_async(agent_invocation_id, approval_request_id:, approved: true, config: {})
|
|
721
|
-
result_task = Phronomy::Task.deferred(
|
|
722
|
-
name: "agent-approval-resume:#{agent_invocation_id}"
|
|
723
|
-
)
|
|
724
|
-
|
|
725
|
-
begin
|
|
726
|
-
entry = Agent::AgentInvocationRegistry.consume_approval(
|
|
727
|
-
agent_invocation_id, approval_request_id
|
|
728
|
-
)
|
|
729
|
-
unless entry
|
|
730
|
-
raise ArgumentError,
|
|
731
|
-
"No pending approval found for AgentInvocation #{agent_invocation_id}"
|
|
732
|
-
end
|
|
733
|
-
|
|
734
|
-
_start_approval_resume(
|
|
735
|
-
result_task,
|
|
736
|
-
entry.invocation,
|
|
737
|
-
approved: approved,
|
|
738
|
-
config: config
|
|
739
|
-
)
|
|
740
|
-
rescue => e
|
|
741
|
-
_fail_result_task(result_task, e)
|
|
742
|
-
end
|
|
743
|
-
|
|
744
|
-
result_task
|
|
745
|
-
end
|
|
746
|
-
public :approve_async
|
|
747
|
-
|
|
748
|
-
def _extract_invoke_result(invocation)
|
|
749
|
-
if invocation.phase == :suspended
|
|
750
|
-
request = invocation.approval_request
|
|
751
|
-
Agent::AgentInvocationRegistry.store_suspended(invocation, request)
|
|
752
|
-
_dispatch_tool_approval_notification(invocation, request)
|
|
753
|
-
{
|
|
754
|
-
suspended: true,
|
|
755
|
-
agent_invocation_id: invocation.id,
|
|
756
|
-
approval_request: request,
|
|
757
|
-
messages: invocation.messages
|
|
758
|
-
}
|
|
759
|
-
elsif invocation.input_blocked? || invocation.output_blocked?
|
|
760
|
-
raise invocation.block_error
|
|
761
|
-
elsif invocation.error
|
|
762
|
-
raise invocation.error
|
|
763
|
-
elsif invocation.rejected
|
|
764
|
-
{rejected: true, messages: invocation.messages}
|
|
765
|
-
else
|
|
766
|
-
{output: invocation.output, messages: invocation.messages, usage: invocation.usage}
|
|
767
|
-
end
|
|
768
|
-
end
|
|
769
|
-
|
|
770
|
-
def _dispatch_tool_approval_notification(invocation, request)
|
|
771
|
-
listener = invocation.approval_listener
|
|
772
|
-
return unless listener
|
|
773
|
-
|
|
774
|
-
Phronomy::Runtime.instance.blocking_io.submit(on_full: :raise) do
|
|
775
|
-
listener.call(request)
|
|
776
|
-
end
|
|
777
|
-
rescue => e
|
|
778
|
-
message = "[Phronomy] Tool approval notification failed: #{e.class}: #{e.message}"
|
|
779
|
-
if Phronomy.configuration.logger
|
|
780
|
-
Phronomy.configuration.logger.warn(message)
|
|
781
|
-
else
|
|
782
|
-
Kernel.warn(message)
|
|
783
|
-
end
|
|
784
|
-
end
|
|
785
|
-
|
|
786
657
|
def _approval_configuration_mutex
|
|
787
658
|
return @approval_configuration_mutex if @approval_configuration_mutex
|
|
788
659
|
|
|
@@ -811,64 +682,45 @@ module Phronomy
|
|
|
811
682
|
meta
|
|
812
683
|
end
|
|
813
684
|
|
|
814
|
-
def
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
# When context_window is set at the class level, that value is used directly
|
|
822
|
-
# (bypassing the RubyLLM catalogue) — useful for locally-hosted models where
|
|
823
|
-
# the loaded context length differs from the catalogue value.
|
|
824
|
-
# Returns nil when the model is not registered in RubyLLM (e.g. local/unknown models).
|
|
825
|
-
def build_token_budget
|
|
826
|
-
model_name = self.class.model
|
|
827
|
-
return nil unless model_name
|
|
828
|
-
|
|
829
|
-
if (cw = self.class.context_window)
|
|
830
|
-
Phronomy::LlmContextWindow::TokenBudget.new(
|
|
831
|
-
context_window: cw,
|
|
832
|
-
max_output_tokens: self.class.max_output_tokens || 0,
|
|
833
|
-
overhead: self.class.context_overhead
|
|
834
|
-
)
|
|
835
|
-
else
|
|
836
|
-
Phronomy::LlmContextWindow::TokenBudget.new(
|
|
837
|
-
model: model_name,
|
|
838
|
-
max_output_tokens: self.class.max_output_tokens,
|
|
839
|
-
overhead: self.class.context_overhead
|
|
685
|
+
def _apply_runtime_projection_to_chat(chat, projection, invocation: nil)
|
|
686
|
+
if projection.system
|
|
687
|
+
apply_instructions(
|
|
688
|
+
chat,
|
|
689
|
+
projection.system,
|
|
690
|
+
cache: projection.model_config["cache_instructions"],
|
|
691
|
+
provider: projection.model_config["provider"]
|
|
840
692
|
)
|
|
841
693
|
end
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
# When {Phronomy.configuration.parallel_tool_execution} is true,
|
|
848
|
-
# returns {ParallelToolChat} so that concurrent tool dispatch is enabled.
|
|
849
|
-
# Falls back to +nil+ otherwise, signalling {#build_chat} to use the
|
|
850
|
-
# standard +RubyLLM.chat+ factory.
|
|
851
|
-
def build_chat_class
|
|
852
|
-
Phronomy.configuration.parallel_tool_execution ? Phronomy::MultiAgent::ParallelToolChat : nil
|
|
694
|
+
projection.tool_classes.each do |tool_class|
|
|
695
|
+
chat.with_tool(prepare_tool_class(tool_class, invocation: invocation))
|
|
696
|
+
end
|
|
697
|
+
projection.messages.each { |message| chat.messages << message }
|
|
698
|
+
chat
|
|
853
699
|
end
|
|
854
700
|
|
|
855
|
-
def build_chat
|
|
701
|
+
def build_chat(model_config: nil)
|
|
702
|
+
config = model_config || {
|
|
703
|
+
"model" => self.class.model,
|
|
704
|
+
"provider" => self.class.provider,
|
|
705
|
+
"temperature" => self.class.temperature,
|
|
706
|
+
"max_output_tokens" => self.class.max_output_tokens,
|
|
707
|
+
"parallel_tool_execution" => Phronomy.configuration.parallel_tool_execution
|
|
708
|
+
}
|
|
856
709
|
opts = {}
|
|
857
|
-
|
|
858
|
-
opts[:model] =
|
|
859
|
-
|
|
860
|
-
if
|
|
861
|
-
opts[:provider] =
|
|
710
|
+
model = config["model"]
|
|
711
|
+
opts[:model] = model if model
|
|
712
|
+
provider = config["provider"]
|
|
713
|
+
if provider
|
|
714
|
+
opts[:provider] = provider.to_sym
|
|
862
715
|
opts[:assume_model_exists] = true
|
|
863
716
|
end
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
chat =
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
717
|
+
parallel_class = config["parallel_tool_execution"] ?
|
|
718
|
+
Phronomy::MultiAgent::ParallelToolChat : nil
|
|
719
|
+
chat = parallel_class ? parallel_class.new(**opts) : RubyLLM.chat(**opts)
|
|
720
|
+
chat.with_temperature(config["temperature"]) if config["temperature"]
|
|
721
|
+
if config["max_output_tokens"] && chat.respond_to?(:with_max_output_tokens)
|
|
722
|
+
chat.with_max_output_tokens(config["max_output_tokens"])
|
|
870
723
|
end
|
|
871
|
-
chat.with_temperature(t) if t
|
|
872
724
|
chat
|
|
873
725
|
end
|
|
874
726
|
|
|
@@ -884,12 +736,8 @@ module Phronomy
|
|
|
884
736
|
end
|
|
885
737
|
end
|
|
886
738
|
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
# attaches a cache_control marker so that the fixed system prompt is
|
|
890
|
-
# eligible for prompt caching.
|
|
891
|
-
def apply_instructions(chat, text)
|
|
892
|
-
if self.class.cache_instructions && anthropic_provider?
|
|
739
|
+
def apply_instructions(chat, text, cache: false, provider: nil)
|
|
740
|
+
if cache && provider.to_s == "anthropic"
|
|
893
741
|
content = RubyLLM::Providers::Anthropic::Content.new(text, cache: true)
|
|
894
742
|
chat.with_instructions(content)
|
|
895
743
|
else
|
|
@@ -897,14 +745,6 @@ module Phronomy
|
|
|
897
745
|
end
|
|
898
746
|
end
|
|
899
747
|
|
|
900
|
-
# Returns true when this agent explicitly declares `provider :anthropic`.
|
|
901
|
-
# Provider is intentionally checked via the DSL value rather than inferred
|
|
902
|
-
# from the model name, because cache_control format is API-endpoint-specific
|
|
903
|
-
# (Anthropic direct vs. Bedrock vs. OpenRouter all differ).
|
|
904
|
-
def anthropic_provider?
|
|
905
|
-
self.class.provider == :anthropic
|
|
906
|
-
end
|
|
907
|
-
|
|
908
748
|
def extract_message(input)
|
|
909
749
|
case input
|
|
910
750
|
when String then input
|
|
@@ -913,29 +753,26 @@ module Phronomy
|
|
|
913
753
|
end
|
|
914
754
|
end
|
|
915
755
|
|
|
916
|
-
# Raises CancellationError if the cancellation_token in config is cancelled.
|
|
917
|
-
# No-op when config has no cancellation_token or the token is not cancelled.
|
|
918
|
-
#
|
|
919
|
-
# @param config [Hash] the invocation config hash
|
|
920
|
-
# @param message [String] the message for the CancellationError
|
|
921
|
-
# @raise [Phronomy::CancellationError]
|
|
922
|
-
# @api public
|
|
923
756
|
def check_cancellation!(config, message = "invocation cancelled")
|
|
757
|
+
timeout_deadline = config[:phronomy_timeout_deadline]
|
|
758
|
+
raise Phronomy::TimeoutError, message if timeout_deadline&.expired?
|
|
759
|
+
|
|
924
760
|
ct = config[:cancellation_token]
|
|
925
|
-
|
|
761
|
+
return unless ct&.cancelled?
|
|
762
|
+
|
|
763
|
+
if ct.respond_to?(:remaining_monotonic_seconds) &&
|
|
764
|
+
ct.remaining_monotonic_seconds == 0.0
|
|
765
|
+
raise Phronomy::TimeoutError, message
|
|
766
|
+
end
|
|
767
|
+
raise Phronomy::CancellationError, message
|
|
926
768
|
end
|
|
927
769
|
|
|
928
|
-
|
|
929
|
-
# result filters remain wrappers; authorization is handled only by
|
|
930
|
-
# ToolInvocation before Tool#call begins.
|
|
931
|
-
def prepare_tool_class(tool_class)
|
|
770
|
+
def prepare_tool_class(tool_class, invocation: nil)
|
|
932
771
|
return tool_class unless tool_class.is_a?(Class)
|
|
933
772
|
|
|
934
773
|
resolved = if (alias_name = self.class.tool_aliases[tool_class])
|
|
935
|
-
parent_description = tool_class.description
|
|
936
774
|
Class.new(tool_class) do
|
|
937
775
|
tool_name alias_name
|
|
938
|
-
description parent_description if parent_description
|
|
939
776
|
end
|
|
940
777
|
else
|
|
941
778
|
tool_class
|