phronomy 0.16.0 → 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 +54 -0
- data/CONTRIBUTING.md +28 -16
- data/README.md +124 -92
- data/benchmark/baseline.json +2 -3
- data/benchmark/bench_agent_invoke.rb +4 -4
- data/benchmark/bench_context_assembler.rb +134 -34
- data/benchmark/bench_regression.rb +1 -1
- data/benchmark/bench_tool_schema.rb +2 -35
- 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 +2 -2
- data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
- data/lib/phronomy/agent/agent_invocation.rb +2 -36
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +156 -93
- data/lib/phronomy/agent/agent_root.rb +1 -2
- data/lib/phronomy/agent/base.rb +135 -314
- data/lib/phronomy/agent/context/capability/base.rb +166 -297
- data/lib/phronomy/agent/context_assembler.rb +65 -29
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
- data/lib/phronomy/agent/context_plan_validator.rb +0 -33
- data/lib/phronomy/agent/execution_coordinator.rb +0 -1
- data/lib/phronomy/agent/journal_projection.rb +28 -2
- data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
- data/lib/phronomy/agent/shared_state.rb +46 -138
- data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
- data/lib/phronomy/agent/tool_invocation.rb +108 -314
- data/lib/phronomy/agent.rb +6 -10
- data/lib/phronomy/configuration.rb +15 -158
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
- data/lib/phronomy/engine/runtime.rb +15 -230
- data/lib/phronomy/engine/task_group.rb +30 -102
- data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
- data/lib/phronomy/multi_agent/orchestrator.rb +152 -204
- data/lib/phronomy/multi_agent/team_coordinator.rb +42 -133
- data/lib/phronomy/vector_store/in_memory.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +3 -120
- data/scripts/api_snapshot.rb +1 -12
- metadata +3 -9
- 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/agent/fsm_runtime_adapter.rb +0 -210
- 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
|
@@ -10,7 +10,7 @@ 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
|
|
@@ -27,7 +27,7 @@ module Phronomy
|
|
|
27
27
|
# agent_definition id: "research-agent", version: 1
|
|
28
28
|
# model "gpt-4o"
|
|
29
29
|
# instructions "You are a research assistant."
|
|
30
|
-
# tools
|
|
30
|
+
# tools(WebSearchTool => nil, CalculatorTool => nil)
|
|
31
31
|
# max_iterations 15
|
|
32
32
|
# end
|
|
33
33
|
class Base
|
|
@@ -86,40 +86,34 @@ module Phronomy
|
|
|
86
86
|
|
|
87
87
|
# Registers tool classes for this agent.
|
|
88
88
|
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
# (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.
|
|
93
92
|
#
|
|
94
|
-
# @example
|
|
95
|
-
# tools WeatherTool, TimeTool
|
|
96
|
-
#
|
|
97
|
-
# @example Hash form (with optional per-tool alias)
|
|
93
|
+
# @example
|
|
98
94
|
# tools(
|
|
99
95
|
# Weather::SearchTool => "weather_search",
|
|
100
96
|
# Places::SearchTool => "places_search",
|
|
101
97
|
# CurrentTimeTool => nil
|
|
102
98
|
# )
|
|
103
99
|
# @api public
|
|
104
|
-
def tools(
|
|
105
|
-
if
|
|
106
|
-
if instance_variable_defined?(:@tools)
|
|
107
|
-
return @tools
|
|
108
|
-
end
|
|
100
|
+
def tools(definitions = nil)
|
|
101
|
+
if definitions.nil?
|
|
102
|
+
return @tools if instance_variable_defined?(:@tools)
|
|
109
103
|
return superclass.respond_to?(:tools) ? superclass.tools : []
|
|
110
104
|
end
|
|
111
105
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
@tool_aliases = hash.transform_values { |v| v&.to_s }.reject { |_, v| v.nil? }
|
|
116
|
-
else
|
|
117
|
-
@tools = args
|
|
118
|
-
@tool_aliases = {}
|
|
106
|
+
unless definitions.is_a?(Hash)
|
|
107
|
+
raise ArgumentError,
|
|
108
|
+
"tools expects a Hash of ToolClass => alias_or_nil"
|
|
119
109
|
end
|
|
110
|
+
|
|
111
|
+
@tools = definitions.keys
|
|
112
|
+
@tool_aliases = definitions.transform_values { |value| value&.to_s }
|
|
113
|
+
.reject { |_, value| value.nil? }
|
|
120
114
|
end
|
|
121
115
|
|
|
122
|
-
# Returns the alias map registered via
|
|
116
|
+
# Returns the alias map registered via .tools.
|
|
123
117
|
# Merges parent class aliases so subclasses inherit their parent's mappings.
|
|
124
118
|
# Subclass-specific aliases take precedence over parent aliases.
|
|
125
119
|
# @return [Hash{Class => String}]
|
|
@@ -190,73 +184,9 @@ module Phronomy
|
|
|
190
184
|
end
|
|
191
185
|
end
|
|
192
186
|
|
|
193
|
-
# Registers one or more static knowledge sources on the agent class.
|
|
194
|
-
# Static source content is fetched and memoized at the **class** level
|
|
195
|
-
# the first time +invoke+ is called. The cache persists for the lifetime
|
|
196
|
-
# of the process; call {.static_knowledge_refresh!} to force a reload.
|
|
197
|
-
#
|
|
198
|
-
# @param sources [Array<Phronomy::Agent::Context::Knowledge::Base>]
|
|
199
|
-
# @example
|
|
200
|
-
# class PolicyAgent < Phronomy::Agent::Base
|
|
201
|
-
# static_knowledge Phronomy::Agent::Context::Knowledge::StaticKnowledge.new(POLICY_TEXT)
|
|
202
|
-
# end
|
|
203
|
-
# @api public
|
|
204
|
-
def static_knowledge(*sources)
|
|
205
|
-
@static_knowledge_sources = sources.flatten
|
|
206
|
-
# Invalidate the cached chunks so the new sources are fetched on
|
|
207
|
-
# the next call to static_knowledge_chunks.
|
|
208
|
-
@static_knowledge_chunks = nil
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
# Returns the registered static knowledge sources.
|
|
212
|
-
# @return [Array<Phronomy::Agent::Context::Knowledge::Base>]
|
|
213
|
-
# @api public
|
|
214
|
-
def static_knowledge_sources
|
|
215
|
-
@static_knowledge_sources || []
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
# Returns the fetched content from all static knowledge sources.
|
|
219
|
-
# Results are cached at the class level so that each source is fetched
|
|
220
|
-
# only once regardless of how many times the agent is invoked.
|
|
221
|
-
# @return [Array<Hash>]
|
|
222
|
-
# @api public
|
|
223
|
-
def static_knowledge_chunks
|
|
224
|
-
@static_knowledge_chunks ||= static_knowledge_sources.flat_map { |ks|
|
|
225
|
-
ks.fetch(query: nil)
|
|
226
|
-
}
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
# Clears the class-level knowledge cache so that the next +invoke+ call
|
|
230
|
-
# re-fetches content from all registered static knowledge sources.
|
|
231
|
-
#
|
|
232
|
-
# Call this method when the underlying knowledge source has been updated
|
|
233
|
-
# at runtime (e.g. a file was rewritten, a DB record changed) and you
|
|
234
|
-
# want the agent to pick up the new content without restarting the
|
|
235
|
-
# process.
|
|
236
|
-
#
|
|
237
|
-
# @return [nil]
|
|
238
|
-
# @example Refresh after updating a knowledge file
|
|
239
|
-
# MyAgent.static_knowledge_refresh!
|
|
240
|
-
# @api public
|
|
241
|
-
def static_knowledge_refresh!
|
|
242
|
-
@static_knowledge_chunks = nil
|
|
243
|
-
end
|
|
244
|
-
|
|
245
187
|
# When enabled, attaches Anthropic prompt-cache markers to the system
|
|
246
188
|
# message so that the fixed instructions are served from cache on
|
|
247
189
|
# subsequent turns, reducing input-token costs.
|
|
248
|
-
#
|
|
249
|
-
# Only has an effect when the agent also declares `provider :anthropic`.
|
|
250
|
-
# The cache_control field is provider-specific (the format differs
|
|
251
|
-
# between Anthropic direct, Bedrock, etc.), so the agent must explicitly
|
|
252
|
-
# declare its provider via the DSL rather than having it inferred from
|
|
253
|
-
# the model name.
|
|
254
|
-
#
|
|
255
|
-
# @example
|
|
256
|
-
# class MyAgent < Phronomy::Agent::Base
|
|
257
|
-
# provider :anthropic
|
|
258
|
-
# cache_instructions true
|
|
259
|
-
# end
|
|
260
190
|
# @api public
|
|
261
191
|
def cache_instructions(enabled = nil)
|
|
262
192
|
if enabled.nil?
|
|
@@ -268,11 +198,6 @@ module Phronomy
|
|
|
268
198
|
|
|
269
199
|
# Tokens to reserve for the model's output.
|
|
270
200
|
# When nil, the model's max_output_tokens from the registry is used.
|
|
271
|
-
#
|
|
272
|
-
# @example
|
|
273
|
-
# class MyAgent < Phronomy::Agent::Base
|
|
274
|
-
# max_output_tokens 4096
|
|
275
|
-
# end
|
|
276
201
|
# @api public
|
|
277
202
|
def max_output_tokens(val = nil)
|
|
278
203
|
if val.nil?
|
|
@@ -283,14 +208,6 @@ module Phronomy
|
|
|
283
208
|
end
|
|
284
209
|
|
|
285
210
|
# Overrides the context window size used for token budget calculations.
|
|
286
|
-
# When set, this value takes precedence over the RubyLLM model registry,
|
|
287
|
-
# which is useful for locally-hosted models (e.g. LM Studio) where the
|
|
288
|
-
# actually-loaded context length may differ from the catalogue value.
|
|
289
|
-
#
|
|
290
|
-
# @example
|
|
291
|
-
# class MyAgent < Phronomy::Agent::Base
|
|
292
|
-
# context_window 4096
|
|
293
|
-
# end
|
|
294
211
|
# @api public
|
|
295
212
|
def context_window(val = nil)
|
|
296
213
|
if val.nil?
|
|
@@ -300,23 +217,6 @@ module Phronomy
|
|
|
300
217
|
end
|
|
301
218
|
end
|
|
302
219
|
|
|
303
|
-
# Tokens reserved in the legacy build_context path only.
|
|
304
|
-
# Manifest-first assembly ignores this value because
|
|
305
|
-
# ContextAssembler estimates actual mandatory content for each LLM Call.
|
|
306
|
-
#
|
|
307
|
-
# @example
|
|
308
|
-
# class MyAgent < Phronomy::Agent::Base
|
|
309
|
-
# context_overhead 500
|
|
310
|
-
# end
|
|
311
|
-
# @api public
|
|
312
|
-
def context_overhead(val = nil)
|
|
313
|
-
if val.nil?
|
|
314
|
-
@context_overhead || 0
|
|
315
|
-
else
|
|
316
|
-
@context_overhead = val.to_i
|
|
317
|
-
end
|
|
318
|
-
end
|
|
319
|
-
|
|
320
220
|
# Defines or reads the stable Agent definition identity.
|
|
321
221
|
# Subclass with no explicit declaration inherits the parent's definition.
|
|
322
222
|
def agent_definition(id: nil, version: nil)
|
|
@@ -326,7 +226,6 @@ module Phronomy
|
|
|
326
226
|
end
|
|
327
227
|
return @agent_definition if @agent_definition
|
|
328
228
|
|
|
329
|
-
# Walk ancestors to support anonymous runtime subclasses and abstract bases.
|
|
330
229
|
klass = superclass
|
|
331
230
|
while klass.respond_to?(:agent_definition, true) &&
|
|
332
231
|
klass < Phronomy::Agent::Base
|
|
@@ -339,8 +238,14 @@ module Phronomy
|
|
|
339
238
|
"#{name || self} must declare agent_definition id: ..., version: ..."
|
|
340
239
|
end
|
|
341
240
|
|
|
342
|
-
def create(agent_id: SecureRandom.uuid, context: nil, persistence: nil, metadata: {})
|
|
343
|
-
new(
|
|
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
|
+
)
|
|
344
249
|
end
|
|
345
250
|
|
|
346
251
|
def load(agent_id, persistence:)
|
|
@@ -373,6 +278,7 @@ module Phronomy
|
|
|
373
278
|
def initialize(
|
|
374
279
|
agent_id: SecureRandom.uuid,
|
|
375
280
|
context: nil,
|
|
281
|
+
knowledge: [],
|
|
376
282
|
persistence: nil,
|
|
377
283
|
metadata: {},
|
|
378
284
|
load_existing: false
|
|
@@ -391,7 +297,7 @@ module Phronomy
|
|
|
391
297
|
end
|
|
392
298
|
loaded
|
|
393
299
|
else
|
|
394
|
-
create_agent_root!(context: context, metadata: metadata)
|
|
300
|
+
create_agent_root!(context: context, knowledge: knowledge, metadata: metadata)
|
|
395
301
|
end
|
|
396
302
|
end
|
|
397
303
|
|
|
@@ -417,23 +323,56 @@ module Phronomy
|
|
|
417
323
|
end
|
|
418
324
|
end
|
|
419
325
|
|
|
420
|
-
|
|
421
|
-
|
|
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|
|
|
422
330
|
root.with(
|
|
423
331
|
agent_revision: root.agent_revision + 1,
|
|
424
|
-
context_revision: root.context_revision + 1
|
|
425
|
-
memory_generation: root.memory_generation + 1
|
|
332
|
+
context_revision: root.context_revision + 1
|
|
426
333
|
)
|
|
427
334
|
end
|
|
428
335
|
end
|
|
429
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
|
+
|
|
430
370
|
def reset_context!
|
|
431
371
|
mutate_context!(:context_reset) do |root|
|
|
432
372
|
root.with(
|
|
433
373
|
agent_revision: root.agent_revision + 1,
|
|
434
374
|
context_revision: root.context_revision + 1,
|
|
435
|
-
transcript_generation: root.transcript_generation + 1
|
|
436
|
-
memory_generation: root.memory_generation + 1
|
|
375
|
+
transcript_generation: root.transcript_generation + 1
|
|
437
376
|
)
|
|
438
377
|
end
|
|
439
378
|
end
|
|
@@ -465,7 +404,7 @@ module Phronomy
|
|
|
465
404
|
|
|
466
405
|
private
|
|
467
406
|
|
|
468
|
-
def create_agent_root!(context:, metadata:)
|
|
407
|
+
def create_agent_root!(context:, knowledge:, metadata:)
|
|
469
408
|
definition = self.class.agent_definition
|
|
470
409
|
root = Agent::AgentRoot.create(
|
|
471
410
|
agent_id: agent_id,
|
|
@@ -475,32 +414,13 @@ module Phronomy
|
|
|
475
414
|
)
|
|
476
415
|
persistence.transaction do |tx|
|
|
477
416
|
tx.agents.create(root)
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
records = imported.records.map do |record|
|
|
482
|
-
content_ref = case record.content_format
|
|
483
|
-
when :text then tx.contents.put_text(record.content)
|
|
484
|
-
when :json then tx.contents.put_json(record.content)
|
|
485
|
-
else
|
|
486
|
-
raise ArgumentError,
|
|
487
|
-
"unsupported imported content format: #{record.content_format.inspect}"
|
|
488
|
-
end
|
|
489
|
-
Agent::JournalRecord.new(
|
|
490
|
-
agent_id: agent_id,
|
|
491
|
-
kind: record.kind,
|
|
492
|
-
channel: record.channel,
|
|
493
|
-
role: record.role,
|
|
494
|
-
content_ref: content_ref,
|
|
495
|
-
context_generation: root.transcript_generation,
|
|
496
|
-
context_candidate: true,
|
|
497
|
-
metadata: record.metadata
|
|
498
|
-
)
|
|
499
|
-
end
|
|
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?
|
|
500
420
|
appended = tx.journals.append(agent_id, expected_position: 0, records: records)
|
|
501
421
|
root = root.with(
|
|
502
422
|
agent_revision: 1,
|
|
503
|
-
context_revision:
|
|
423
|
+
context_revision: 1,
|
|
504
424
|
journal_position: appended.length
|
|
505
425
|
)
|
|
506
426
|
tx.agents.save(agent_id, expected_revision: 0, root: root)
|
|
@@ -509,6 +429,56 @@ module Phronomy
|
|
|
509
429
|
root
|
|
510
430
|
end
|
|
511
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
|
+
|
|
512
482
|
def mutate_context!(kind, context_affecting: true)
|
|
513
483
|
next_root = nil
|
|
514
484
|
persistence.transaction do |tx|
|
|
@@ -541,36 +511,18 @@ module Phronomy
|
|
|
541
511
|
(proposed.context_revision == current.context_revision) ? current.context_revision + 1 : proposed.context_revision
|
|
542
512
|
end
|
|
543
513
|
|
|
544
|
-
def ensure_no_active_execution!
|
|
545
|
-
return if persistence.executions.list_active(agent_id).empty?
|
|
546
|
-
raise Phronomy::AgentBusyError, "agent has an active or suspended execution: #{agent_id}"
|
|
547
|
-
end
|
|
548
|
-
|
|
549
514
|
public
|
|
550
515
|
|
|
551
|
-
# Registers an anonymous handoff tool class on this agent instance.
|
|
552
|
-
# Called by Runner during construction when routes are configured.
|
|
553
|
-
# @param tool_class [Class<Phronomy::Agent::Context::Capability::Base>]
|
|
554
|
-
# @return [self]
|
|
555
|
-
# @api private
|
|
556
516
|
def _add_handoff_tool(tool_class)
|
|
557
517
|
@_handoff_tools ||= []
|
|
558
518
|
@_handoff_tools << tool_class
|
|
559
519
|
self
|
|
560
520
|
end
|
|
561
521
|
|
|
562
|
-
# Returns handoff tool classes registered on this instance by Runner.
|
|
563
|
-
# @return [Array<Class>]
|
|
564
|
-
# @api private
|
|
565
522
|
def _handoff_tools
|
|
566
523
|
@_handoff_tools || []
|
|
567
524
|
end
|
|
568
525
|
|
|
569
|
-
# Registers the final Agent/Application authorization policy.
|
|
570
|
-
# The block runs on the Runtime authorization pool and must return
|
|
571
|
-
# :allow, :require_approval, or :reject.
|
|
572
|
-
# @return [self]
|
|
573
|
-
# @api public
|
|
574
526
|
def tool_approval_policy(&block)
|
|
575
527
|
raise ArgumentError, "tool_approval_policy requires a block" unless block
|
|
576
528
|
|
|
@@ -578,9 +530,6 @@ module Phronomy
|
|
|
578
530
|
self
|
|
579
531
|
end
|
|
580
532
|
|
|
581
|
-
# Registers a non-blocking Application notification listener.
|
|
582
|
-
# @return [self]
|
|
583
|
-
# @api public
|
|
584
533
|
def on_tool_approval_required(&block)
|
|
585
534
|
raise ArgumentError, "on_tool_approval_required requires a block" unless block
|
|
586
535
|
|
|
@@ -590,19 +539,15 @@ module Phronomy
|
|
|
590
539
|
|
|
591
540
|
private
|
|
592
541
|
|
|
593
|
-
# Merges an {InvocationContext} into the +thread_id+ / +config+ pair.
|
|
594
|
-
# Returns +[effective_thread_id, effective_config]+.
|
|
595
|
-
#
|
|
596
|
-
# Precedence rules (existing explicit values always win):
|
|
597
|
-
# - +thread_id+ argument > +ic.thread_id+
|
|
598
|
-
# - +config[:cancellation_token]+ > +ic.cancellation_token+ > token derived from +ic.deadline+
|
|
599
|
-
# - +ic+ is stored in +config[:invocation_context]+ (overwriting any previous value)
|
|
600
542
|
def _apply_invocation_context(thread_id, config, ic)
|
|
601
543
|
effective_thread_id = thread_id || ic.thread_id
|
|
602
544
|
effective_config = config.merge(invocation_context: ic)
|
|
603
545
|
if effective_config[:cancellation_token].nil?
|
|
604
546
|
if (tok = ic.effective_timeout_token)
|
|
605
|
-
effective_config = effective_config.merge(
|
|
547
|
+
effective_config = effective_config.merge(
|
|
548
|
+
cancellation_token: tok,
|
|
549
|
+
phronomy_timeout_deadline: ic.deadline
|
|
550
|
+
)
|
|
606
551
|
end
|
|
607
552
|
end
|
|
608
553
|
[effective_thread_id, effective_config]
|
|
@@ -629,28 +574,6 @@ module Phronomy
|
|
|
629
574
|
end
|
|
630
575
|
end
|
|
631
576
|
|
|
632
|
-
# Registers a per-instance knowledge source. Knowledge chunks from all
|
|
633
|
-
# registered sources are included in every LLM call via the Context Policy.
|
|
634
|
-
#
|
|
635
|
-
# @param source [#fetch] any object responding to +fetch(query:)+
|
|
636
|
-
# @return [void]
|
|
637
|
-
# @api public
|
|
638
|
-
def add_knowledge_source(source)
|
|
639
|
-
@instance_knowledge_sources ||= []
|
|
640
|
-
@instance_knowledge_sources << source
|
|
641
|
-
end
|
|
642
|
-
protected :add_knowledge_source
|
|
643
|
-
|
|
644
|
-
# Returns knowledge chunks fetched from all instance-level knowledge sources.
|
|
645
|
-
#
|
|
646
|
-
# @return [Array<Hash>]
|
|
647
|
-
# @api private
|
|
648
|
-
def instance_knowledge_chunks
|
|
649
|
-
return [] unless @instance_knowledge_sources
|
|
650
|
-
@instance_knowledge_sources.flat_map { |ks| ks.fetch(query: nil) }
|
|
651
|
-
end
|
|
652
|
-
protected :instance_knowledge_chunks
|
|
653
|
-
|
|
654
577
|
def _complete_result_task(task, result)
|
|
655
578
|
task.backend.unblock(result, nil)
|
|
656
579
|
task.transition!(:completed, value: result)
|
|
@@ -667,20 +590,6 @@ module Phronomy
|
|
|
667
590
|
translated
|
|
668
591
|
end
|
|
669
592
|
|
|
670
|
-
def _build_stream_terminal_event(result)
|
|
671
|
-
if result[:suspended]
|
|
672
|
-
StreamEvent.new(
|
|
673
|
-
type: :approval_required,
|
|
674
|
-
payload: {request: result[:approval_request]}
|
|
675
|
-
)
|
|
676
|
-
else
|
|
677
|
-
StreamEvent.new(type: :done, payload: result)
|
|
678
|
-
end
|
|
679
|
-
end
|
|
680
|
-
|
|
681
|
-
# Returns the Application exception instead of allowing it to escape the
|
|
682
|
-
# shared EventLoop. A nil return means delivery succeeded or no listener
|
|
683
|
-
# was registered.
|
|
684
593
|
def _deliver_stream_event(listener, event)
|
|
685
594
|
return unless listener
|
|
686
595
|
|
|
@@ -773,22 +682,7 @@ module Phronomy
|
|
|
773
682
|
meta
|
|
774
683
|
end
|
|
775
684
|
|
|
776
|
-
def
|
|
777
|
-
model_config = context[:model_config] || {}
|
|
778
|
-
if context[:system]
|
|
779
|
-
apply_instructions(
|
|
780
|
-
chat,
|
|
781
|
-
context[:system],
|
|
782
|
-
cache: model_config["cache_instructions"],
|
|
783
|
-
provider: model_config["provider"]
|
|
784
|
-
)
|
|
785
|
-
end
|
|
786
|
-
(context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
|
|
787
|
-
context[:messages].each { |msg| chat.messages << msg }
|
|
788
|
-
end
|
|
789
|
-
|
|
790
|
-
def _replace_chat_messages(chat, projection)
|
|
791
|
-
chat.messages.clear
|
|
685
|
+
def _apply_runtime_projection_to_chat(chat, projection, invocation: nil)
|
|
792
686
|
if projection.system
|
|
793
687
|
apply_instructions(
|
|
794
688
|
chat,
|
|
@@ -797,63 +691,13 @@ module Phronomy
|
|
|
797
691
|
provider: projection.model_config["provider"]
|
|
798
692
|
)
|
|
799
693
|
end
|
|
694
|
+
projection.tool_classes.each do |tool_class|
|
|
695
|
+
chat.with_tool(prepare_tool_class(tool_class, invocation: invocation))
|
|
696
|
+
end
|
|
800
697
|
projection.messages.each { |message| chat.messages << message }
|
|
801
698
|
chat
|
|
802
699
|
end
|
|
803
700
|
|
|
804
|
-
# Builds a TokenBudget for this agent's model if possible.
|
|
805
|
-
# When context_window is set at the class level, that value is used directly
|
|
806
|
-
# (bypassing the RubyLLM catalogue) — useful for locally-hosted models where
|
|
807
|
-
# the loaded context length differs from the catalogue value.
|
|
808
|
-
# Returns nil when the model is not registered in RubyLLM (e.g. local/unknown models).
|
|
809
|
-
def build_token_budget
|
|
810
|
-
model_name = self.class.model
|
|
811
|
-
return nil unless model_name
|
|
812
|
-
|
|
813
|
-
if (cw = self.class.context_window)
|
|
814
|
-
Phronomy::LlmContextWindow::TokenBudget.new(
|
|
815
|
-
context_window: cw,
|
|
816
|
-
max_output_tokens: self.class.max_output_tokens || 0,
|
|
817
|
-
overhead: self.class.context_overhead
|
|
818
|
-
)
|
|
819
|
-
else
|
|
820
|
-
ruby_llm_model = RubyLLM.models.find(model_name)
|
|
821
|
-
return nil unless ruby_llm_model
|
|
822
|
-
|
|
823
|
-
registry_context = ruby_llm_model.context_window.to_i
|
|
824
|
-
registry_max_output = ruby_llm_model.max_output_tokens.to_i
|
|
825
|
-
|
|
826
|
-
# Priority: agent explicit → framework default → registry (if < context_window)
|
|
827
|
-
output_reserve =
|
|
828
|
-
self.class.max_output_tokens ||
|
|
829
|
-
Phronomy.configuration.default_output_reserve ||
|
|
830
|
-
((registry_max_output < registry_context) ? registry_max_output : nil)
|
|
831
|
-
|
|
832
|
-
if output_reserve.nil?
|
|
833
|
-
raise Phronomy::InvalidContextBudgetConfigurationError,
|
|
834
|
-
"Cannot determine output token reserve for model '#{model_name}'. " \
|
|
835
|
-
"Set max_output_tokens on the agent or Phronomy.configure { |c| c.default_output_reserve = N }."
|
|
836
|
-
end
|
|
837
|
-
|
|
838
|
-
Phronomy::LlmContextWindow::TokenBudget.new(
|
|
839
|
-
context_window: registry_context,
|
|
840
|
-
max_output_tokens: output_reserve,
|
|
841
|
-
overhead: self.class.context_overhead
|
|
842
|
-
)
|
|
843
|
-
end
|
|
844
|
-
rescue Phronomy::LlmContextWindow::UnknownModelError, RubyLLM::ModelNotFoundError
|
|
845
|
-
nil
|
|
846
|
-
end
|
|
847
|
-
|
|
848
|
-
# Returns the chat class to instantiate for this invocation.
|
|
849
|
-
# When {Phronomy.configuration.parallel_tool_execution} is true,
|
|
850
|
-
# returns {ParallelToolChat} so that concurrent tool dispatch is enabled.
|
|
851
|
-
# Falls back to +nil+ otherwise, signalling {#build_chat} to use the
|
|
852
|
-
# standard +RubyLLM.chat+ factory.
|
|
853
|
-
def build_chat_class
|
|
854
|
-
Phronomy.configuration.parallel_tool_execution ? Phronomy::MultiAgent::ParallelToolChat : nil
|
|
855
|
-
end
|
|
856
|
-
|
|
857
701
|
def build_chat(model_config: nil)
|
|
858
702
|
config = model_config || {
|
|
859
703
|
"model" => self.class.model,
|
|
@@ -892,10 +736,6 @@ module Phronomy
|
|
|
892
736
|
end
|
|
893
737
|
end
|
|
894
738
|
|
|
895
|
-
# Applies system instructions to a chat object.
|
|
896
|
-
# When cache_instructions is enabled and the provider is Anthropic,
|
|
897
|
-
# attaches a cache_control marker so that the fixed system prompt is
|
|
898
|
-
# eligible for prompt caching.
|
|
899
739
|
def apply_instructions(chat, text, cache: false, provider: nil)
|
|
900
740
|
if cache && provider.to_s == "anthropic"
|
|
901
741
|
content = RubyLLM::Providers::Anthropic::Content.new(text, cache: true)
|
|
@@ -905,14 +745,6 @@ module Phronomy
|
|
|
905
745
|
end
|
|
906
746
|
end
|
|
907
747
|
|
|
908
|
-
# Returns true when this agent explicitly declares `provider :anthropic`.
|
|
909
|
-
# Provider is intentionally checked via the DSL value rather than inferred
|
|
910
|
-
# from the model name, because cache_control format is API-endpoint-specific
|
|
911
|
-
# (Anthropic direct vs. Bedrock vs. OpenRouter all differ).
|
|
912
|
-
def anthropic_provider?
|
|
913
|
-
self.class.provider == :anthropic
|
|
914
|
-
end
|
|
915
|
-
|
|
916
748
|
def extract_message(input)
|
|
917
749
|
case input
|
|
918
750
|
when String then input
|
|
@@ -921,37 +753,26 @@ module Phronomy
|
|
|
921
753
|
end
|
|
922
754
|
end
|
|
923
755
|
|
|
924
|
-
# Raises CancellationError if the cancellation_token in config is cancelled.
|
|
925
|
-
# No-op when config has no cancellation_token or the token is not cancelled.
|
|
926
|
-
#
|
|
927
|
-
# @param config [Hash] the invocation config hash
|
|
928
|
-
# @param message [String] the message for the CancellationError
|
|
929
|
-
# @raise [Phronomy::CancellationError]
|
|
930
|
-
# @api public
|
|
931
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
|
+
|
|
932
760
|
ct = config[:cancellation_token]
|
|
933
761
|
return unless ct&.cancelled?
|
|
934
762
|
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
(ct.respond_to?(:remaining_monotonic_seconds) &&
|
|
938
|
-
ct.remaining_monotonic_seconds == 0.0)
|
|
763
|
+
if ct.respond_to?(:remaining_monotonic_seconds) &&
|
|
764
|
+
ct.remaining_monotonic_seconds == 0.0
|
|
939
765
|
raise Phronomy::TimeoutError, message
|
|
940
766
|
end
|
|
941
767
|
raise Phronomy::CancellationError, message
|
|
942
768
|
end
|
|
943
769
|
|
|
944
|
-
|
|
945
|
-
# result filters remain wrappers; authorization is handled only by
|
|
946
|
-
# ToolInvocation before Tool#call begins.
|
|
947
|
-
def prepare_tool_class(tool_class)
|
|
770
|
+
def prepare_tool_class(tool_class, invocation: nil)
|
|
948
771
|
return tool_class unless tool_class.is_a?(Class)
|
|
949
772
|
|
|
950
773
|
resolved = if (alias_name = self.class.tool_aliases[tool_class])
|
|
951
|
-
parent_description = tool_class.description
|
|
952
774
|
Class.new(tool_class) do
|
|
953
775
|
tool_name alias_name
|
|
954
|
-
description parent_description if parent_description
|
|
955
776
|
end
|
|
956
777
|
else
|
|
957
778
|
tool_class
|