phronomy 0.15.1 → 0.16.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/CHANGELOG.md +105 -28
- data/README.md +300 -75
- data/benchmark/bench_agent_invoke.rb +3 -0
- data/benchmark/bench_regression.rb +2 -18
- data/benchmark/bench_tool_schema.rb +1 -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/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 +42 -10
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +50 -11
- data/lib/phronomy/agent/agent_root.rb +67 -0
- data/lib/phronomy/agent/async_event_api.rb +55 -475
- data/lib/phronomy/agent/base.rb +301 -285
- data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
- data/lib/phronomy/agent/context_assembler.rb +321 -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 +188 -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 +167 -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 +1123 -0
- data/lib/phronomy/agent/fsm_runtime_adapter.rb +210 -0
- data/lib/phronomy/agent/immutable.rb +31 -0
- data/lib/phronomy/agent/journal_projection.rb +34 -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 +298 -0
- data/lib/phronomy/agent/token_budget_resolver.rb +69 -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.rb +14 -16
- data/lib/phronomy/agent_busy_error.rb +5 -0
- data/lib/phronomy/canonical_json.rb +136 -0
- data/lib/phronomy/configuration.rb +9 -4
- data/lib/phronomy/content_store/base.rb +51 -0
- data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
- data/lib/phronomy/engine/event_loop.rb +3 -0
- 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/assembler.rb +8 -8
- data/lib/phronomy/multi_agent/orchestrator.rb +1 -0
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
- data/lib/phronomy/multi_agent/team_coordinator.rb +6 -2
- 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/version.rb +1 -1
- data/lib/phronomy.rb +11 -0
- data/scripts/add_to_h_to_token_doubles.rb +33 -0
- data/scripts/add_to_h_unnamed_doubles.rb +27 -0
- 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 -7
- 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/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
|
|
@@ -15,6 +15,7 @@ module Phronomy
|
|
|
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,6 +24,7 @@ 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
30
|
# tools WebSearchTool, CalculatorTool
|
|
@@ -31,7 +33,7 @@ module Phronomy
|
|
|
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
|
|
@@ -298,8 +300,9 @@ module Phronomy
|
|
|
298
300
|
end
|
|
299
301
|
end
|
|
300
302
|
|
|
301
|
-
# Tokens reserved
|
|
302
|
-
#
|
|
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.
|
|
303
306
|
#
|
|
304
307
|
# @example
|
|
305
308
|
# class MyAgent < Phronomy::Agent::Base
|
|
@@ -314,31 +317,50 @@ module Phronomy
|
|
|
314
317
|
end
|
|
315
318
|
end
|
|
316
319
|
|
|
317
|
-
#
|
|
318
|
-
#
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
320
|
+
# Defines or reads the stable Agent definition identity.
|
|
321
|
+
# Subclass with no explicit declaration inherits the parent's definition.
|
|
322
|
+
def agent_definition(id: nil, version: nil)
|
|
323
|
+
if id || version
|
|
324
|
+
raise ArgumentError, "agent_definition requires id: and version:" unless id && version
|
|
325
|
+
@agent_definition = {id: id.to_s.freeze, version: Integer(version)}.freeze
|
|
326
|
+
end
|
|
327
|
+
return @agent_definition if @agent_definition
|
|
328
|
+
|
|
329
|
+
# Walk ancestors to support anonymous runtime subclasses and abstract bases.
|
|
330
|
+
klass = superclass
|
|
331
|
+
while klass.respond_to?(:agent_definition, true) &&
|
|
332
|
+
klass < Phronomy::Agent::Base
|
|
333
|
+
defn = klass.instance_variable_get(:@agent_definition)
|
|
334
|
+
return defn if defn
|
|
335
|
+
klass = klass.superclass
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
raise Phronomy::ConfigurationError,
|
|
339
|
+
"#{name || self} must declare agent_definition id: ..., version: ..."
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def create(agent_id: SecureRandom.uuid, context: nil, persistence: nil, metadata: {})
|
|
343
|
+
new(agent_id: agent_id, context: context, persistence: persistence, metadata: metadata)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def load(agent_id, persistence:)
|
|
347
|
+
new(agent_id: agent_id, persistence: persistence, load_existing: true)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def approve(execution_id, approval_request_id:, persistence:, approved: true, config: {})
|
|
351
|
+
approve_async(
|
|
352
|
+
execution_id,
|
|
326
353
|
approval_request_id: approval_request_id,
|
|
327
354
|
approved: approved,
|
|
328
|
-
config: config
|
|
329
|
-
|
|
355
|
+
config: config,
|
|
356
|
+
persistence: persistence
|
|
357
|
+
).wait_result
|
|
330
358
|
end
|
|
331
359
|
|
|
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,
|
|
360
|
+
def approve_async(execution_id, approval_request_id:, persistence:, approved: true, config: {})
|
|
361
|
+
execution = persistence.executions.load(execution_id)
|
|
362
|
+
load(execution.agent_id, persistence: persistence).approve_async(
|
|
363
|
+
execution_id,
|
|
342
364
|
approval_request_id: approval_request_id,
|
|
343
365
|
approved: approved,
|
|
344
366
|
config: config
|
|
@@ -346,6 +368,186 @@ module Phronomy
|
|
|
346
368
|
end
|
|
347
369
|
end
|
|
348
370
|
|
|
371
|
+
attr_reader :agent_id, :persistence
|
|
372
|
+
|
|
373
|
+
def initialize(
|
|
374
|
+
agent_id: SecureRandom.uuid,
|
|
375
|
+
context: nil,
|
|
376
|
+
persistence: nil,
|
|
377
|
+
metadata: {},
|
|
378
|
+
load_existing: false
|
|
379
|
+
)
|
|
380
|
+
@persistence = persistence || Phronomy::Persistence::InMemory.new
|
|
381
|
+
@agent_id = agent_id.to_s.freeze
|
|
382
|
+
@root = if load_existing
|
|
383
|
+
loaded = @persistence.agents.load(@agent_id)
|
|
384
|
+
definition = self.class.agent_definition
|
|
385
|
+
unless loaded.agent_definition_id == definition.fetch(:id) &&
|
|
386
|
+
loaded.definition_version == definition.fetch(:version)
|
|
387
|
+
raise Phronomy::ConfigurationError,
|
|
388
|
+
"Agent definition mismatch for #{@agent_id}: stored " \
|
|
389
|
+
"#{loaded.agent_definition_id}@#{loaded.definition_version}, runtime " \
|
|
390
|
+
"#{definition.fetch(:id)}@#{definition.fetch(:version)}"
|
|
391
|
+
end
|
|
392
|
+
loaded
|
|
393
|
+
else
|
|
394
|
+
create_agent_root!(context: context, metadata: metadata)
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def agent_root
|
|
399
|
+
@root
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def journal_projection
|
|
403
|
+
Agent::JournalProjection.new(persistence: persistence, agent_root: @root)
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def transcript
|
|
407
|
+
journal_projection.transcript_records
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def clear_transcript!
|
|
411
|
+
mutate_context!(:transcript_cleared) do |root|
|
|
412
|
+
root.with(
|
|
413
|
+
agent_revision: root.agent_revision + 1,
|
|
414
|
+
context_revision: root.context_revision + 1,
|
|
415
|
+
transcript_generation: root.transcript_generation + 1
|
|
416
|
+
)
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def clear_memory!
|
|
421
|
+
mutate_context!(:memory_cleared) do |root|
|
|
422
|
+
root.with(
|
|
423
|
+
agent_revision: root.agent_revision + 1,
|
|
424
|
+
context_revision: root.context_revision + 1,
|
|
425
|
+
memory_generation: root.memory_generation + 1
|
|
426
|
+
)
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def reset_context!
|
|
431
|
+
mutate_context!(:context_reset) do |root|
|
|
432
|
+
root.with(
|
|
433
|
+
agent_revision: root.agent_revision + 1,
|
|
434
|
+
context_revision: root.context_revision + 1,
|
|
435
|
+
transcript_generation: root.transcript_generation + 1,
|
|
436
|
+
memory_generation: root.memory_generation + 1
|
|
437
|
+
)
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def close!
|
|
442
|
+
mutate_context!(:agent_closed, context_affecting: false) do |root|
|
|
443
|
+
root.with(
|
|
444
|
+
agent_revision: root.agent_revision + 1,
|
|
445
|
+
lifecycle_status: :closed
|
|
446
|
+
)
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def purge!
|
|
451
|
+
persistence.transaction do |tx|
|
|
452
|
+
tx.executions.assert_idle!(agent_id)
|
|
453
|
+
tx.journals.delete(agent_id)
|
|
454
|
+
tx.executions.delete_for_agent(agent_id)
|
|
455
|
+
tx.agents.delete(agent_id)
|
|
456
|
+
end
|
|
457
|
+
@root = nil
|
|
458
|
+
true
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Internal hook used after a successful Persistence transaction.
|
|
462
|
+
def __replace_root(root)
|
|
463
|
+
@root = root
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
private
|
|
467
|
+
|
|
468
|
+
def create_agent_root!(context:, metadata:)
|
|
469
|
+
definition = self.class.agent_definition
|
|
470
|
+
root = Agent::AgentRoot.create(
|
|
471
|
+
agent_id: agent_id,
|
|
472
|
+
agent_definition_id: definition.fetch(:id),
|
|
473
|
+
definition_version: definition.fetch(:version),
|
|
474
|
+
metadata: metadata
|
|
475
|
+
)
|
|
476
|
+
persistence.transaction do |tx|
|
|
477
|
+
tx.agents.create(root)
|
|
478
|
+
if context
|
|
479
|
+
imported = context.respond_to?(:records) ? context :
|
|
480
|
+
Agent::ContextImporter.import_messages(context)
|
|
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
|
|
500
|
+
appended = tx.journals.append(agent_id, expected_position: 0, records: records)
|
|
501
|
+
root = root.with(
|
|
502
|
+
agent_revision: 1,
|
|
503
|
+
context_revision: records.any? ? 1 : 0,
|
|
504
|
+
journal_position: appended.length
|
|
505
|
+
)
|
|
506
|
+
tx.agents.save(agent_id, expected_revision: 0, root: root)
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
root
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def mutate_context!(kind, context_affecting: true)
|
|
513
|
+
next_root = nil
|
|
514
|
+
persistence.transaction do |tx|
|
|
515
|
+
tx.executions.assert_idle!(agent_id)
|
|
516
|
+
current = tx.agents.load(agent_id)
|
|
517
|
+
record = Agent::JournalRecord.new(
|
|
518
|
+
agent_id: agent_id,
|
|
519
|
+
kind: kind,
|
|
520
|
+
channel: :state,
|
|
521
|
+
context_generation: current.transcript_generation,
|
|
522
|
+
context_candidate: false
|
|
523
|
+
)
|
|
524
|
+
appended = tx.journals.append(
|
|
525
|
+
agent_id,
|
|
526
|
+
expected_position: current.journal_position,
|
|
527
|
+
records: [record]
|
|
528
|
+
)
|
|
529
|
+
proposed = yield(current)
|
|
530
|
+
next_root = proposed.with(
|
|
531
|
+
journal_position: current.journal_position + appended.length,
|
|
532
|
+
context_revision: context_affecting ?
|
|
533
|
+
yield_context_revision(current, proposed) : current.context_revision
|
|
534
|
+
)
|
|
535
|
+
tx.agents.save(agent_id, expected_revision: current.agent_revision, root: next_root)
|
|
536
|
+
end
|
|
537
|
+
@root = next_root
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def yield_context_revision(current, proposed)
|
|
541
|
+
(proposed.context_revision == current.context_revision) ? current.context_revision + 1 : proposed.context_revision
|
|
542
|
+
end
|
|
543
|
+
|
|
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
|
+
public
|
|
550
|
+
|
|
349
551
|
# Registers an anonymous handoff tool class on this agent instance.
|
|
350
552
|
# Called by Runner during construction when routes are configured.
|
|
351
553
|
# @param tool_class [Class<Phronomy::Agent::Context::Capability::Base>]
|
|
@@ -386,13 +588,6 @@ module Phronomy
|
|
|
386
588
|
self
|
|
387
589
|
end
|
|
388
590
|
|
|
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
591
|
private
|
|
397
592
|
|
|
398
593
|
# Merges an {InvocationContext} into the +thread_id+ / +config+ pair.
|
|
@@ -434,153 +629,8 @@ module Phronomy
|
|
|
434
629
|
end
|
|
435
630
|
end
|
|
436
631
|
|
|
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
632
|
# Registers a per-instance knowledge source. Knowledge chunks from all
|
|
583
|
-
# registered sources are included in every LLM call via
|
|
633
|
+
# registered sources are included in every LLM call via the Context Policy.
|
|
584
634
|
#
|
|
585
635
|
# @param source [#fetch] any object responding to +fetch(query:)+
|
|
586
636
|
# @return [void]
|
|
@@ -695,94 +745,6 @@ module Phronomy
|
|
|
695
745
|
nil
|
|
696
746
|
end
|
|
697
747
|
|
|
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
748
|
def _approval_configuration_mutex
|
|
787
749
|
return @approval_configuration_mutex if @approval_configuration_mutex
|
|
788
750
|
|
|
@@ -812,11 +774,33 @@ module Phronomy
|
|
|
812
774
|
end
|
|
813
775
|
|
|
814
776
|
def _apply_context_to_chat(chat, context)
|
|
815
|
-
|
|
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
|
|
816
786
|
(context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
|
|
817
787
|
context[:messages].each { |msg| chat.messages << msg }
|
|
818
788
|
end
|
|
819
789
|
|
|
790
|
+
def _replace_chat_messages(chat, projection)
|
|
791
|
+
chat.messages.clear
|
|
792
|
+
if projection.system
|
|
793
|
+
apply_instructions(
|
|
794
|
+
chat,
|
|
795
|
+
projection.system,
|
|
796
|
+
cache: projection.model_config["cache_instructions"],
|
|
797
|
+
provider: projection.model_config["provider"]
|
|
798
|
+
)
|
|
799
|
+
end
|
|
800
|
+
projection.messages.each { |message| chat.messages << message }
|
|
801
|
+
chat
|
|
802
|
+
end
|
|
803
|
+
|
|
820
804
|
# Builds a TokenBudget for this agent's model if possible.
|
|
821
805
|
# When context_window is set at the class level, that value is used directly
|
|
822
806
|
# (bypassing the RubyLLM catalogue) — useful for locally-hosted models where
|
|
@@ -833,9 +817,27 @@ module Phronomy
|
|
|
833
817
|
overhead: self.class.context_overhead
|
|
834
818
|
)
|
|
835
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
|
+
|
|
836
838
|
Phronomy::LlmContextWindow::TokenBudget.new(
|
|
837
|
-
|
|
838
|
-
max_output_tokens:
|
|
839
|
+
context_window: registry_context,
|
|
840
|
+
max_output_tokens: output_reserve,
|
|
839
841
|
overhead: self.class.context_overhead
|
|
840
842
|
)
|
|
841
843
|
end
|
|
@@ -852,23 +854,29 @@ module Phronomy
|
|
|
852
854
|
Phronomy.configuration.parallel_tool_execution ? Phronomy::MultiAgent::ParallelToolChat : nil
|
|
853
855
|
end
|
|
854
856
|
|
|
855
|
-
def build_chat
|
|
857
|
+
def build_chat(model_config: nil)
|
|
858
|
+
config = model_config || {
|
|
859
|
+
"model" => self.class.model,
|
|
860
|
+
"provider" => self.class.provider,
|
|
861
|
+
"temperature" => self.class.temperature,
|
|
862
|
+
"max_output_tokens" => self.class.max_output_tokens,
|
|
863
|
+
"parallel_tool_execution" => Phronomy.configuration.parallel_tool_execution
|
|
864
|
+
}
|
|
856
865
|
opts = {}
|
|
857
|
-
|
|
858
|
-
opts[:model] =
|
|
859
|
-
|
|
860
|
-
if
|
|
861
|
-
opts[:provider] =
|
|
866
|
+
model = config["model"]
|
|
867
|
+
opts[:model] = model if model
|
|
868
|
+
provider = config["provider"]
|
|
869
|
+
if provider
|
|
870
|
+
opts[:provider] = provider.to_sym
|
|
862
871
|
opts[:assume_model_exists] = true
|
|
863
872
|
end
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
chat =
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
873
|
+
parallel_class = config["parallel_tool_execution"] ?
|
|
874
|
+
Phronomy::MultiAgent::ParallelToolChat : nil
|
|
875
|
+
chat = parallel_class ? parallel_class.new(**opts) : RubyLLM.chat(**opts)
|
|
876
|
+
chat.with_temperature(config["temperature"]) if config["temperature"]
|
|
877
|
+
if config["max_output_tokens"] && chat.respond_to?(:with_max_output_tokens)
|
|
878
|
+
chat.with_max_output_tokens(config["max_output_tokens"])
|
|
870
879
|
end
|
|
871
|
-
chat.with_temperature(t) if t
|
|
872
880
|
chat
|
|
873
881
|
end
|
|
874
882
|
|
|
@@ -888,8 +896,8 @@ module Phronomy
|
|
|
888
896
|
# When cache_instructions is enabled and the provider is Anthropic,
|
|
889
897
|
# attaches a cache_control marker so that the fixed system prompt is
|
|
890
898
|
# eligible for prompt caching.
|
|
891
|
-
def apply_instructions(chat, text)
|
|
892
|
-
if
|
|
899
|
+
def apply_instructions(chat, text, cache: false, provider: nil)
|
|
900
|
+
if cache && provider.to_s == "anthropic"
|
|
893
901
|
content = RubyLLM::Providers::Anthropic::Content.new(text, cache: true)
|
|
894
902
|
chat.with_instructions(content)
|
|
895
903
|
else
|
|
@@ -922,7 +930,15 @@ module Phronomy
|
|
|
922
930
|
# @api public
|
|
923
931
|
def check_cancellation!(config, message = "invocation cancelled")
|
|
924
932
|
ct = config[:cancellation_token]
|
|
925
|
-
|
|
933
|
+
return unless ct&.cancelled?
|
|
934
|
+
|
|
935
|
+
# Deadline expiry is a timeout; explicit cancel! is a cancellation.
|
|
936
|
+
if (ct.respond_to?(:deadline) && ct.deadline && Time.now >= ct.deadline) ||
|
|
937
|
+
(ct.respond_to?(:remaining_monotonic_seconds) &&
|
|
938
|
+
ct.remaining_monotonic_seconds == 0.0)
|
|
939
|
+
raise Phronomy::TimeoutError, message
|
|
940
|
+
end
|
|
941
|
+
raise Phronomy::CancellationError, message
|
|
926
942
|
end
|
|
927
943
|
|
|
928
944
|
# Builds the final Tool class to register with RubyLLM. Alias and Tool
|