phronomy 0.15.0 → 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 +107 -18
- 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 -393
- data/lib/phronomy/agent/base.rb +301 -641
- 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 -47
- 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,173 +588,6 @@ module Phronomy
|
|
|
386
588
|
self
|
|
387
589
|
end
|
|
388
590
|
|
|
389
|
-
# Invokes the agent with the given input and returns a result Hash.
|
|
390
|
-
# Provider errors are translated after the configured LLM adapter returns
|
|
391
|
-
# its final result. Phronomy does not replay the Agent invocation.
|
|
392
|
-
#
|
|
393
|
-
# @param input [String, Hash] the user message; a Hash may supply
|
|
394
|
-
# +:message+, +:query+, or +:user+ as the text key, plus any template
|
|
395
|
-
# variables consumed by the configured instructions template.
|
|
396
|
-
# @param messages [Array<RubyLLM::Message>] conversation history from a
|
|
397
|
-
# previous invocation. The application owns and persists this array;
|
|
398
|
-
# pass it on every turn to maintain multi-turn context.
|
|
399
|
-
# @param thread_id [String, nil] conversation thread identifier, forwarded
|
|
400
|
-
# to the compaction context when on_compact is configured.
|
|
401
|
-
# @param config [Hash] additional runtime options:
|
|
402
|
-
# +:user_id+ (+String+, optional) — caller identity forwarded to the tracer
|
|
403
|
-
# +:session_id+ (+String+, optional) — session identity forwarded to the tracer
|
|
404
|
-
# @param invocation_context [Phronomy::InvocationContext, nil] optional first-class context
|
|
405
|
-
# object. When present, +thread_id+, +cancellation_token+, and +deadline+ are
|
|
406
|
-
# derived from it (existing +config:+ keys take precedence as backward-compat
|
|
407
|
-
# aliases). The object is also stored in +config[:invocation_context]+ so that
|
|
408
|
-
# +task_id+ / +parent_task_id+ appear in trace spans automatically.
|
|
409
|
-
# @return [Hash] +{ output: String, messages: Array, usage: Phronomy::TokenUsage }+,
|
|
410
|
-
# or +{ output: nil, suspended: true, checkpoint: Phronomy::Agent::Checkpoint,
|
|
411
|
-
# messages: Array }+ when the invocation was suspended awaiting tool approval.
|
|
412
|
-
# @raise [Phronomy::FilterBlockError] when an input or output filter rejects the value
|
|
413
|
-
# @example Normal invocation
|
|
414
|
-
# result = MyAgent.new.invoke("What is Ruby?")
|
|
415
|
-
# puts result[:output]
|
|
416
|
-
# @example Multi-turn conversation
|
|
417
|
-
# result1 = agent.invoke("Hi, I'm Alice.")
|
|
418
|
-
# result2 = agent.invoke("What's my name?", messages: result1[:messages])
|
|
419
|
-
# @example Suspend / resume flow
|
|
420
|
-
# result = agent.invoke("Perform task X")
|
|
421
|
-
# if result[:suspended]
|
|
422
|
-
# result = agent.resume(result[:checkpoint], approved: true)
|
|
423
|
-
# end
|
|
424
|
-
# puts result[:output]
|
|
425
|
-
# @example With InvocationContext (deadline-based timeout)
|
|
426
|
-
# ctx = Phronomy::InvocationContext.new(
|
|
427
|
-
# thread_id: "conv-123",
|
|
428
|
-
# deadline: Phronomy::Concurrency::Deadline.in(30),
|
|
429
|
-
# task_id: SecureRandom.uuid
|
|
430
|
-
# )
|
|
431
|
-
# result = MyAgent.new.invoke("Hello", invocation_context: ctx)
|
|
432
|
-
# @api public
|
|
433
|
-
def invoke(input, messages: [], thread_id: nil, config: {}, invocation_context: nil)
|
|
434
|
-
if invocation_context
|
|
435
|
-
thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
|
|
436
|
-
end
|
|
437
|
-
_check_scheduler_reentrancy(:invoke, :invoke_async)
|
|
438
|
-
|
|
439
|
-
trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
|
|
440
|
-
result = invoke_async(
|
|
441
|
-
input,
|
|
442
|
-
messages: messages,
|
|
443
|
-
thread_id: thread_id,
|
|
444
|
-
config: config
|
|
445
|
-
).wait_result
|
|
446
|
-
[result, result[:usage]]
|
|
447
|
-
end
|
|
448
|
-
end
|
|
449
|
-
|
|
450
|
-
# Invokes this agent asynchronously and returns a {Phronomy::Task}.
|
|
451
|
-
#
|
|
452
|
-
# This is the primary async entry point. {#invoke} is a synchronous wrapper
|
|
453
|
-
# that calls this method and blocks the caller until the task completes.
|
|
454
|
-
# Calling {#invoke} from inside an active scheduler task raises
|
|
455
|
-
# {Phronomy::SchedulerReentrancyError}; use +invoke_async+ directly in that
|
|
456
|
-
# context.
|
|
457
|
-
#
|
|
458
|
-
# The task is registered with the Runtime task registry so {Runtime#shutdown}
|
|
459
|
-
# drains in-flight invocations before process exit.
|
|
460
|
-
#
|
|
461
|
-
# @example
|
|
462
|
-
# task = agent.invoke_async("Hello!")
|
|
463
|
-
# result = task.wait_result # => { output: "...", messages: [...], usage: ... }
|
|
464
|
-
#
|
|
465
|
-
# @param input [String, Hash]
|
|
466
|
-
# @param messages [Array]
|
|
467
|
-
# @param thread_id [String, nil]
|
|
468
|
-
# @param config [Hash]
|
|
469
|
-
# @param invocation_context [Phronomy::InvocationContext, nil]
|
|
470
|
-
# @return [Phronomy::Task]
|
|
471
|
-
# @api public
|
|
472
|
-
def invoke_async(input, messages: [], thread_id: nil, config: {},
|
|
473
|
-
invocation_context: nil, on_tool_approval_required: nil)
|
|
474
|
-
if invocation_context
|
|
475
|
-
thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
|
|
476
|
-
end
|
|
477
|
-
result_task = Phronomy::Task.deferred(name: "agent-#{(self.class.name || "anonymous").downcase}-async")
|
|
478
|
-
approval_snapshot = _approval_configuration_snapshot(on_tool_approval_required)
|
|
479
|
-
_start_invocation(
|
|
480
|
-
result_task, input,
|
|
481
|
-
messages: messages, thread_id: thread_id, config: config,
|
|
482
|
-
approval_snapshot: approval_snapshot
|
|
483
|
-
)
|
|
484
|
-
result_task
|
|
485
|
-
end
|
|
486
|
-
|
|
487
|
-
# Invokes this agent asynchronously and delivers stream events from the
|
|
488
|
-
# Runtime-owned EventLoop thread.
|
|
489
|
-
#
|
|
490
|
-
# The callback must return quickly. Blocking I/O, synchronous Agent calls,
|
|
491
|
-
# sleep, and heavy CPU work must be delegated by the Application.
|
|
492
|
-
#
|
|
493
|
-
# @return [Phronomy::Task] final invocation result
|
|
494
|
-
# @api public
|
|
495
|
-
def stream_async(input, messages: [], thread_id: nil, config: {},
|
|
496
|
-
invocation_context: nil, on_tool_approval_required: nil, &block)
|
|
497
|
-
raise ArgumentError, "stream_async requires a block" unless block
|
|
498
|
-
|
|
499
|
-
if invocation_context
|
|
500
|
-
thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
|
|
501
|
-
end
|
|
502
|
-
|
|
503
|
-
result_task = Phronomy::Task.deferred(
|
|
504
|
-
name: "agent-#{(self.class.name || "anonymous").downcase}-stream-async"
|
|
505
|
-
)
|
|
506
|
-
approval_snapshot = _approval_configuration_snapshot(on_tool_approval_required)
|
|
507
|
-
_start_invocation(
|
|
508
|
-
result_task,
|
|
509
|
-
input,
|
|
510
|
-
messages: messages,
|
|
511
|
-
thread_id: thread_id,
|
|
512
|
-
config: config,
|
|
513
|
-
approval_snapshot: approval_snapshot,
|
|
514
|
-
mode: :stream,
|
|
515
|
-
on_event: block
|
|
516
|
-
)
|
|
517
|
-
result_task
|
|
518
|
-
end
|
|
519
|
-
|
|
520
|
-
# Synchronous wrapper around {#stream_async}.
|
|
521
|
-
#
|
|
522
|
-
# Stream callbacks execute on the EventLoop thread, not on the thread that
|
|
523
|
-
# calls this method. This method only blocks while waiting for the final Task.
|
|
524
|
-
# @yield [Phronomy::Agent::StreamEvent]
|
|
525
|
-
# @return [Hash] same result shape as #invoke
|
|
526
|
-
# @api public
|
|
527
|
-
def stream(input, messages: [], thread_id: nil, config: {},
|
|
528
|
-
invocation_context: nil, on_tool_approval_required: nil, &block)
|
|
529
|
-
raise ArgumentError, "stream requires a block" unless block
|
|
530
|
-
|
|
531
|
-
if invocation_context
|
|
532
|
-
thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
|
|
533
|
-
end
|
|
534
|
-
_check_scheduler_reentrancy(:stream, :stream_async)
|
|
535
|
-
|
|
536
|
-
trace("agent.stream", input: input, **_build_caller_meta(config)) do |_span|
|
|
537
|
-
result = stream_async(
|
|
538
|
-
input,
|
|
539
|
-
messages: messages,
|
|
540
|
-
thread_id: thread_id,
|
|
541
|
-
config: config,
|
|
542
|
-
on_tool_approval_required: on_tool_approval_required,
|
|
543
|
-
&block
|
|
544
|
-
).wait_result
|
|
545
|
-
[result, result[:usage]]
|
|
546
|
-
end
|
|
547
|
-
end
|
|
548
|
-
|
|
549
|
-
# @deprecated The context version cache has been removed. Returns nil.
|
|
550
|
-
# Retained for backward compatibility with callers using safe navigation (+&.reset+).
|
|
551
|
-
# @api private
|
|
552
|
-
def context_version_cache
|
|
553
|
-
nil
|
|
554
|
-
end
|
|
555
|
-
|
|
556
591
|
private
|
|
557
592
|
|
|
558
593
|
# Merges an {InvocationContext} into the +thread_id+ / +config+ pair.
|
|
@@ -594,153 +629,8 @@ module Phronomy
|
|
|
594
629
|
end
|
|
595
630
|
end
|
|
596
631
|
|
|
597
|
-
# Assembles the LLM context (system prompt + conversation messages)
|
|
598
|
-
# for a single invocation. Subclasses may override this method to
|
|
599
|
-
# inject custom context editing logic without having to override
|
|
600
|
-
# the full #invoke_once pipeline.
|
|
601
|
-
#
|
|
602
|
-
# The keyword arguments +budget+, +instruction+, +tools+, and +knowledge+
|
|
603
|
-
# carry pre-computed values. Override them in a subclass call to +super+
|
|
604
|
-
# to inject custom context without recomputing the defaults.
|
|
605
|
-
#
|
|
606
|
-
# @param input [String, Hash] the user's input for this turn
|
|
607
|
-
# @param messages [Array<RubyLLM::Message>] raw conversation history
|
|
608
|
-
# @param thread_id [String, nil] conversation thread identifier
|
|
609
|
-
# @param config [Hash] the invocation config (see #invoke)
|
|
610
|
-
# @param budget [LlmContextWindow::TokenBudget, nil] pre-computed token budget
|
|
611
|
-
# @param instruction [String, nil] pre-computed system instruction
|
|
612
|
-
# @param tools [Array<Class>] tool classes to expose
|
|
613
|
-
# @param knowledge [Array<Hash>] knowledge chunks ({ content:, type:, source: })
|
|
614
|
-
# @return [Hash] { system: String|nil, messages: Array, tool_classes: Array }
|
|
615
|
-
# @api public
|
|
616
|
-
def build_context(
|
|
617
|
-
input,
|
|
618
|
-
messages: [],
|
|
619
|
-
thread_id: nil,
|
|
620
|
-
config: {},
|
|
621
|
-
budget: build_token_budget,
|
|
622
|
-
instruction: build_instructions(input),
|
|
623
|
-
tools: self.class.tools + _handoff_tools,
|
|
624
|
-
knowledge: self.class.static_knowledge_chunks + instance_knowledge_chunks
|
|
625
|
-
)
|
|
626
|
-
assembler = LlmContextWindow::Assembler.new(budget: budget)
|
|
627
|
-
assembler.add_instruction(instruction) if instruction
|
|
628
|
-
assembler.add_capability(tools)
|
|
629
|
-
knowledge.each { |chunk| assembler.add_knowledge(chunk[:content], type: chunk[:type] || :static, trusted: true, source: chunk[:source]) }
|
|
630
|
-
|
|
631
|
-
msgs = Array(messages)
|
|
632
|
-
|
|
633
|
-
if budget && budget_exceeded?(msgs)
|
|
634
|
-
# Default strategy when the token budget is tight:
|
|
635
|
-
# 1. Compact: keep the most recent half of the messages verbatim and
|
|
636
|
-
# replace the older half with a brief omission marker.
|
|
637
|
-
# 2. Trim: if the compacted history still exceeds the budget, call
|
|
638
|
-
# trim_to_budget with the :safe strategy, which discards the oldest
|
|
639
|
-
# message one at a time until the history fits.
|
|
640
|
-
# Subclasses can override build_context to apply a different strategy
|
|
641
|
-
# (e.g. LLM-based summarisation) before calling super.
|
|
642
|
-
keep = [msgs.size / 2, 2].max
|
|
643
|
-
msgs = compact_messages(msgs, keep_tail: keep) do |dropped|
|
|
644
|
-
"[#{dropped.size} earlier messages omitted]"
|
|
645
|
-
end
|
|
646
|
-
remaining = assembler.available_for_messages
|
|
647
|
-
msgs = trim_to_budget(msgs, remaining: remaining, strategy: :safe)
|
|
648
|
-
end
|
|
649
|
-
|
|
650
|
-
assembler.add_messages(msgs)
|
|
651
|
-
@last_context = assembler.build
|
|
652
|
-
end
|
|
653
|
-
protected :build_context
|
|
654
|
-
|
|
655
|
-
# Keeps the last +keep+ messages from +messages+, discarding older ones.
|
|
656
|
-
# Use this inside a +build_context+ override to trim conversation history.
|
|
657
|
-
#
|
|
658
|
-
# @param messages [Array<RubyLLM::Message>] conversation history
|
|
659
|
-
# @param keep [Integer] number of messages to retain (from the tail)
|
|
660
|
-
# @return [Array<RubyLLM::Message>]
|
|
661
|
-
# @api public
|
|
662
|
-
def trim_messages(messages, keep:)
|
|
663
|
-
Array(messages).last(keep)
|
|
664
|
-
end
|
|
665
|
-
protected :trim_messages
|
|
666
|
-
|
|
667
|
-
# Removes the oldest messages one at a time until the count is within +limit+.
|
|
668
|
-
#
|
|
669
|
-
# @param messages [Array<RubyLLM::Message>] conversation history
|
|
670
|
-
# @param limit [Integer] maximum number of messages to retain
|
|
671
|
-
# @return [Array<RubyLLM::Message>]
|
|
672
|
-
# @api public
|
|
673
|
-
def drop_messages_over(messages, limit:)
|
|
674
|
-
msgs = Array(messages).dup
|
|
675
|
-
msgs.shift while msgs.size > limit
|
|
676
|
-
msgs
|
|
677
|
-
end
|
|
678
|
-
protected :drop_messages_over
|
|
679
|
-
|
|
680
|
-
# Replaces all but the last +keep_tail+ messages with a single system summary.
|
|
681
|
-
# The block receives the dropped messages and must return a summary String.
|
|
682
|
-
#
|
|
683
|
-
# @param messages [Array<RubyLLM::Message>] conversation history
|
|
684
|
-
# @param keep_tail [Integer] number of recent messages to preserve verbatim
|
|
685
|
-
# @yield [Array<RubyLLM::Message>] the messages being summarised
|
|
686
|
-
# @yieldreturn [String] summary text
|
|
687
|
-
# @return [Array<RubyLLM::Message>]
|
|
688
|
-
# @api public
|
|
689
|
-
def compact_messages(messages, keep_tail:, &summariser)
|
|
690
|
-
msgs = Array(messages)
|
|
691
|
-
return msgs if msgs.size <= keep_tail
|
|
692
|
-
tail = msgs.last(keep_tail)
|
|
693
|
-
dropped = msgs.first(msgs.size - keep_tail)
|
|
694
|
-
summary_text = summariser.call(dropped)
|
|
695
|
-
[RubyLLM::Message.new(role: :system, content: summary_text)] + tail
|
|
696
|
-
end
|
|
697
|
-
protected :compact_messages
|
|
698
|
-
|
|
699
|
-
# Trims +messages+ to fit within +remaining+ tokens using the given
|
|
700
|
-
# +strategy+. Returns the trimmed message array without touching the
|
|
701
|
-
# assembler. The caller is responsible for passing the result to
|
|
702
|
-
# +assembler.add_messages+ and calling +assembler.build+.
|
|
703
|
-
#
|
|
704
|
-
# Supported strategies:
|
|
705
|
-
# +:safe+ — discard the oldest message one at a time (default)
|
|
706
|
-
#
|
|
707
|
-
# @param messages [Array<RubyLLM::Message>] conversation history
|
|
708
|
-
# @param remaining [Integer, nil] token allowance for messages; when +nil+
|
|
709
|
-
# the messages are returned unchanged
|
|
710
|
-
# @param strategy [Symbol] trim strategy (default +:safe+)
|
|
711
|
-
# @return [Array<RubyLLM::Message>]
|
|
712
|
-
# @api public
|
|
713
|
-
def trim_to_budget(messages, remaining:, strategy: :safe)
|
|
714
|
-
return Array(messages) unless remaining
|
|
715
|
-
msgs = Array(messages)
|
|
716
|
-
loop do
|
|
717
|
-
used = msgs.sum { |m| LlmContextWindow::TokenEstimator.estimate(m.content.to_s) }
|
|
718
|
-
return msgs if used <= remaining
|
|
719
|
-
break if msgs.empty?
|
|
720
|
-
msgs = trim_messages(msgs, keep: msgs.size - 1)
|
|
721
|
-
end
|
|
722
|
-
msgs
|
|
723
|
-
end
|
|
724
|
-
protected :trim_to_budget
|
|
725
|
-
|
|
726
|
-
# Returns +true+ when the estimated token usage of +messages+ exceeds
|
|
727
|
-
# +threshold+ times the available context budget.
|
|
728
|
-
# Always returns +false+ when no token budget is available.
|
|
729
|
-
#
|
|
730
|
-
# @param messages [Array<RubyLLM::Message>] conversation history
|
|
731
|
-
# @param threshold [Float] fraction of the available budget (default 0.8)
|
|
732
|
-
# @return [Boolean]
|
|
733
|
-
# @api public
|
|
734
|
-
def budget_exceeded?(messages, threshold: 0.8)
|
|
735
|
-
return false unless (b = build_token_budget)
|
|
736
|
-
total = Array(messages).sum { |m| LlmContextWindow::TokenEstimator.estimate(m.content.to_s) }
|
|
737
|
-
limit = b.available(used: 0)
|
|
738
|
-
total > limit * threshold
|
|
739
|
-
end
|
|
740
|
-
protected :budget_exceeded?
|
|
741
|
-
|
|
742
632
|
# Registers a per-instance knowledge source. Knowledge chunks from all
|
|
743
|
-
# registered sources are included in every LLM call via
|
|
633
|
+
# registered sources are included in every LLM call via the Context Policy.
|
|
744
634
|
#
|
|
745
635
|
# @param source [#fetch] any object responding to +fetch(query:)+
|
|
746
636
|
# @return [void]
|
|
@@ -761,50 +651,6 @@ module Phronomy
|
|
|
761
651
|
end
|
|
762
652
|
protected :instance_knowledge_chunks
|
|
763
653
|
|
|
764
|
-
# Starts one AgentInvocation and resolves +result_task+ from that session.
|
|
765
|
-
# Phronomy translates the adapter's final error but never starts another
|
|
766
|
-
# AgentInvocation automatically.
|
|
767
|
-
# @api private
|
|
768
|
-
def _start_invocation(result_task, input, messages:, thread_id:, config:,
|
|
769
|
-
approval_snapshot:, mode: :invoke, on_event: nil)
|
|
770
|
-
effective_config = thread_id ? config.merge(thread_id: thread_id) : config
|
|
771
|
-
check_cancellation!(effective_config, "invocation cancelled")
|
|
772
|
-
runtime = Phronomy::Runtime.instance
|
|
773
|
-
event_loop = runtime.event_loop
|
|
774
|
-
session = Agent::AgentInvocationSessionBuilder.build(
|
|
775
|
-
agent: self,
|
|
776
|
-
input: input,
|
|
777
|
-
messages: messages,
|
|
778
|
-
config: effective_config,
|
|
779
|
-
approval_policy: approval_snapshot[:policy],
|
|
780
|
-
approval_listener: approval_snapshot[:listener],
|
|
781
|
-
mode: mode,
|
|
782
|
-
on_event: on_event,
|
|
783
|
-
runtime: runtime
|
|
784
|
-
)
|
|
785
|
-
callback_error_policy =
|
|
786
|
-
Phronomy.configuration.stream_callback_error_policy
|
|
787
|
-
source_task = Phronomy::Task.deferred(name: "#{result_task.name}-source")
|
|
788
|
-
source_task.on_complete do |invocation, error|
|
|
789
|
-
_handle_agent_completion(
|
|
790
|
-
result_task: result_task,
|
|
791
|
-
invocation: invocation,
|
|
792
|
-
error: error,
|
|
793
|
-
mode: mode,
|
|
794
|
-
listener: on_event,
|
|
795
|
-
event_loop: event_loop,
|
|
796
|
-
callback_error_policy: callback_error_policy
|
|
797
|
-
)
|
|
798
|
-
end
|
|
799
|
-
|
|
800
|
-
# Register completion handling before EventLoop admission. Otherwise an
|
|
801
|
-
# immediately finishing session can complete source_task before the
|
|
802
|
-
# callback is installed, causing Task#on_complete to run on this thread.
|
|
803
|
-
event_loop.register(session, completion: source_task)
|
|
804
|
-
rescue => e
|
|
805
|
-
_fail_result_task(result_task, e)
|
|
806
|
-
end
|
|
807
|
-
|
|
808
654
|
def _complete_result_task(task, result)
|
|
809
655
|
task.backend.unblock(result, nil)
|
|
810
656
|
task.transition!(:completed, value: result)
|
|
@@ -821,83 +667,6 @@ module Phronomy
|
|
|
821
667
|
translated
|
|
822
668
|
end
|
|
823
669
|
|
|
824
|
-
# Completes one Agent execution interval. Execution failures and
|
|
825
|
-
# Application callback failures are deliberately handled in separate
|
|
826
|
-
# exception domains.
|
|
827
|
-
def _handle_agent_completion(result_task:, invocation:, error:, mode:, listener:,
|
|
828
|
-
event_loop:, callback_error_policy:)
|
|
829
|
-
if mode == :stream && !event_loop.current?
|
|
830
|
-
completion_error = error || Phronomy::Error.new(
|
|
831
|
-
"Stream completion occurred outside the EventLoop"
|
|
832
|
-
)
|
|
833
|
-
_fail_result_task(result_task, _translated_error(completion_error))
|
|
834
|
-
return
|
|
835
|
-
end
|
|
836
|
-
|
|
837
|
-
result = nil
|
|
838
|
-
execution_error = nil
|
|
839
|
-
begin
|
|
840
|
-
raise error if error
|
|
841
|
-
|
|
842
|
-
result = _extract_invoke_result(invocation)
|
|
843
|
-
rescue => e
|
|
844
|
-
execution_error = _translated_error(e)
|
|
845
|
-
end
|
|
846
|
-
|
|
847
|
-
if execution_error
|
|
848
|
-
if mode == :stream
|
|
849
|
-
event = StreamEvent.new(
|
|
850
|
-
type: :error,
|
|
851
|
-
payload: {error: execution_error}
|
|
852
|
-
)
|
|
853
|
-
callback_error = _deliver_stream_event(listener, event)
|
|
854
|
-
if callback_error
|
|
855
|
-
_report_stream_callback_error(
|
|
856
|
-
callback_error,
|
|
857
|
-
event: event,
|
|
858
|
-
invocation_id: invocation&.id,
|
|
859
|
-
callback_error_policy: callback_error_policy
|
|
860
|
-
)
|
|
861
|
-
end
|
|
862
|
-
end
|
|
863
|
-
|
|
864
|
-
# An Application failure while consuming :error never replaces the
|
|
865
|
-
# original Agent/LLM/Tool/Runtime failure.
|
|
866
|
-
_fail_result_task(result_task, execution_error)
|
|
867
|
-
return
|
|
868
|
-
end
|
|
869
|
-
|
|
870
|
-
unless mode == :stream
|
|
871
|
-
_complete_result_task(result_task, result)
|
|
872
|
-
return
|
|
873
|
-
end
|
|
874
|
-
|
|
875
|
-
event = _build_stream_terminal_event(result)
|
|
876
|
-
callback_error = _deliver_stream_event(listener, event)
|
|
877
|
-
unless callback_error
|
|
878
|
-
_complete_result_task(result_task, result)
|
|
879
|
-
return
|
|
880
|
-
end
|
|
881
|
-
|
|
882
|
-
_report_stream_callback_error(
|
|
883
|
-
callback_error,
|
|
884
|
-
event: event,
|
|
885
|
-
invocation_id: invocation&.id,
|
|
886
|
-
callback_error_policy: callback_error_policy
|
|
887
|
-
)
|
|
888
|
-
|
|
889
|
-
if callback_error_policy == :fail_task
|
|
890
|
-
wrapped = _build_stream_callback_error(
|
|
891
|
-
event_type: event.type,
|
|
892
|
-
callback_error: callback_error,
|
|
893
|
-
result: result
|
|
894
|
-
)
|
|
895
|
-
_fail_result_task(result_task, wrapped)
|
|
896
|
-
else
|
|
897
|
-
_complete_result_task(result_task, result)
|
|
898
|
-
end
|
|
899
|
-
end
|
|
900
|
-
|
|
901
670
|
def _build_stream_terminal_event(result)
|
|
902
671
|
if result[:suspended]
|
|
903
672
|
StreamEvent.new(
|
|
@@ -976,169 +745,6 @@ module Phronomy
|
|
|
976
745
|
nil
|
|
977
746
|
end
|
|
978
747
|
|
|
979
|
-
# Continues a suspended AgentInvocation. The parent session is registered
|
|
980
|
-
# asynchronously; this method is only the synchronous wrapper.
|
|
981
|
-
# @return [Hash]
|
|
982
|
-
# @api public
|
|
983
|
-
def approve(agent_invocation_id, approval_request_id:, approved: true, config: {})
|
|
984
|
-
_check_scheduler_reentrancy(:approve, :approve_async)
|
|
985
|
-
approve_async(
|
|
986
|
-
agent_invocation_id,
|
|
987
|
-
approval_request_id: approval_request_id,
|
|
988
|
-
approved: approved,
|
|
989
|
-
config: config
|
|
990
|
-
).wait_result
|
|
991
|
-
end
|
|
992
|
-
public :approve
|
|
993
|
-
|
|
994
|
-
# Continues a suspended AgentInvocation without blocking the caller.
|
|
995
|
-
#
|
|
996
|
-
# This method is safe to call from an EventLoop stream callback. The
|
|
997
|
-
# returned Task completes when the resumed AgentInvocation finishes,
|
|
998
|
-
# suspends again, or fails.
|
|
999
|
-
# @return [Phronomy::Task]
|
|
1000
|
-
# @api public
|
|
1001
|
-
def approve_async(agent_invocation_id, approval_request_id:, approved: true, config: {})
|
|
1002
|
-
result_task = Phronomy::Task.deferred(
|
|
1003
|
-
name: "agent-approval-resume:#{agent_invocation_id}"
|
|
1004
|
-
)
|
|
1005
|
-
|
|
1006
|
-
begin
|
|
1007
|
-
entry = Agent::AgentInvocationRegistry.consume_approval(
|
|
1008
|
-
agent_invocation_id, approval_request_id
|
|
1009
|
-
)
|
|
1010
|
-
unless entry
|
|
1011
|
-
raise ArgumentError,
|
|
1012
|
-
"No pending approval found for AgentInvocation #{agent_invocation_id}"
|
|
1013
|
-
end
|
|
1014
|
-
|
|
1015
|
-
_start_approval_resume(
|
|
1016
|
-
result_task,
|
|
1017
|
-
entry.invocation,
|
|
1018
|
-
approved: approved,
|
|
1019
|
-
config: config
|
|
1020
|
-
)
|
|
1021
|
-
rescue => e
|
|
1022
|
-
_fail_result_task(result_task, e)
|
|
1023
|
-
end
|
|
1024
|
-
|
|
1025
|
-
result_task
|
|
1026
|
-
end
|
|
1027
|
-
public :approve_async
|
|
1028
|
-
|
|
1029
|
-
# Parent completion handling is installed before EventLoop registration,
|
|
1030
|
-
# and the parent session is registered before child sessions so immediate
|
|
1031
|
-
# child events cannot be lost.
|
|
1032
|
-
# @api private
|
|
1033
|
-
def _start_approval_resume(result_task, invocation, approved:, config:)
|
|
1034
|
-
invocation.merge_config!(config)
|
|
1035
|
-
invocation.begin_approval_resume!(approved: approved)
|
|
1036
|
-
runtime = Phronomy::Runtime.instance
|
|
1037
|
-
event_loop = runtime.event_loop
|
|
1038
|
-
source_task = Phronomy::Task.deferred(
|
|
1039
|
-
name: "#{result_task.name}-source"
|
|
1040
|
-
)
|
|
1041
|
-
parent_session = Agent::AgentInvocationSessionBuilder.build_for_resume(
|
|
1042
|
-
agent_invocation: invocation,
|
|
1043
|
-
resume_event: :resume,
|
|
1044
|
-
resume_phase: :suspended,
|
|
1045
|
-
runtime: runtime
|
|
1046
|
-
)
|
|
1047
|
-
stream_listener = invocation.stream_listener
|
|
1048
|
-
mode = stream_listener ? :stream : :invoke
|
|
1049
|
-
callback_error_policy =
|
|
1050
|
-
Phronomy.configuration.stream_callback_error_policy
|
|
1051
|
-
|
|
1052
|
-
source_task.on_complete do |completed_invocation, error|
|
|
1053
|
-
_handle_agent_completion(
|
|
1054
|
-
result_task: result_task,
|
|
1055
|
-
invocation: completed_invocation,
|
|
1056
|
-
error: error,
|
|
1057
|
-
mode: mode,
|
|
1058
|
-
listener: stream_listener,
|
|
1059
|
-
event_loop: event_loop,
|
|
1060
|
-
callback_error_policy: callback_error_policy
|
|
1061
|
-
)
|
|
1062
|
-
end
|
|
1063
|
-
|
|
1064
|
-
# The parent must exist before any child can post an immediate result.
|
|
1065
|
-
event_loop.register(parent_session, completion: source_task)
|
|
1066
|
-
|
|
1067
|
-
invocation.tool_invocations.each do |child|
|
|
1068
|
-
child_session = if child.awaiting_approval?
|
|
1069
|
-
Agent::ToolInvocationSessionBuilder.build_for_resume(
|
|
1070
|
-
tool_invocation: child,
|
|
1071
|
-
resume_event: approved ? :approve : :reject,
|
|
1072
|
-
resume_phase: :awaiting_approval,
|
|
1073
|
-
runtime: runtime
|
|
1074
|
-
)
|
|
1075
|
-
elsif !approved && child.authorized?
|
|
1076
|
-
Agent::ToolInvocationSessionBuilder.build_for_resume(
|
|
1077
|
-
tool_invocation: child,
|
|
1078
|
-
resume_event: :cancel,
|
|
1079
|
-
resume_phase: :authorized,
|
|
1080
|
-
runtime: runtime
|
|
1081
|
-
)
|
|
1082
|
-
end
|
|
1083
|
-
_register_tool_invocation_session(event_loop, runtime, child, child_session) if child_session
|
|
1084
|
-
end
|
|
1085
|
-
end
|
|
1086
|
-
|
|
1087
|
-
def _extract_invoke_result(invocation)
|
|
1088
|
-
if invocation.phase == :suspended
|
|
1089
|
-
request = invocation.approval_request
|
|
1090
|
-
Agent::AgentInvocationRegistry.store_suspended(invocation, request)
|
|
1091
|
-
_dispatch_tool_approval_notification(invocation, request)
|
|
1092
|
-
{
|
|
1093
|
-
suspended: true,
|
|
1094
|
-
agent_invocation_id: invocation.id,
|
|
1095
|
-
approval_request: request,
|
|
1096
|
-
messages: invocation.messages
|
|
1097
|
-
}
|
|
1098
|
-
elsif invocation.input_blocked? || invocation.output_blocked?
|
|
1099
|
-
raise invocation.block_error
|
|
1100
|
-
elsif invocation.error
|
|
1101
|
-
raise invocation.error
|
|
1102
|
-
elsif invocation.rejected
|
|
1103
|
-
{rejected: true, messages: invocation.messages}
|
|
1104
|
-
else
|
|
1105
|
-
{output: invocation.output, messages: invocation.messages, usage: invocation.usage}
|
|
1106
|
-
end
|
|
1107
|
-
end
|
|
1108
|
-
|
|
1109
|
-
def _register_tool_invocation_session(event_loop, runtime, child, session)
|
|
1110
|
-
completion = Phronomy::Task.deferred(name: "tool-session:#{child.id}")
|
|
1111
|
-
completion.on_complete do |_result, error|
|
|
1112
|
-
next unless error
|
|
1113
|
-
|
|
1114
|
-
child.mark_framework_failed!(error)
|
|
1115
|
-
runtime.event_loop.post(
|
|
1116
|
-
Phronomy::Event.new(
|
|
1117
|
-
type: :tool_failed,
|
|
1118
|
-
target_id: child.parent_agent_invocation_id,
|
|
1119
|
-
payload: {tool_invocation_id: child.id}
|
|
1120
|
-
)
|
|
1121
|
-
)
|
|
1122
|
-
end
|
|
1123
|
-
event_loop.register(session, completion: completion)
|
|
1124
|
-
end
|
|
1125
|
-
|
|
1126
|
-
def _dispatch_tool_approval_notification(invocation, request)
|
|
1127
|
-
listener = invocation.approval_listener
|
|
1128
|
-
return unless listener
|
|
1129
|
-
|
|
1130
|
-
Phronomy::Runtime.instance.blocking_io.submit(on_full: :raise) do
|
|
1131
|
-
listener.call(request)
|
|
1132
|
-
end
|
|
1133
|
-
rescue => e
|
|
1134
|
-
message = "[Phronomy] Tool approval notification failed: #{e.class}: #{e.message}"
|
|
1135
|
-
if Phronomy.configuration.logger
|
|
1136
|
-
Phronomy.configuration.logger.warn(message)
|
|
1137
|
-
else
|
|
1138
|
-
Kernel.warn(message)
|
|
1139
|
-
end
|
|
1140
|
-
end
|
|
1141
|
-
|
|
1142
748
|
def _approval_configuration_mutex
|
|
1143
749
|
return @approval_configuration_mutex if @approval_configuration_mutex
|
|
1144
750
|
|
|
@@ -1168,11 +774,33 @@ module Phronomy
|
|
|
1168
774
|
end
|
|
1169
775
|
|
|
1170
776
|
def _apply_context_to_chat(chat, context)
|
|
1171
|
-
|
|
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
|
|
1172
786
|
(context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
|
|
1173
787
|
context[:messages].each { |msg| chat.messages << msg }
|
|
1174
788
|
end
|
|
1175
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
|
+
|
|
1176
804
|
# Builds a TokenBudget for this agent's model if possible.
|
|
1177
805
|
# When context_window is set at the class level, that value is used directly
|
|
1178
806
|
# (bypassing the RubyLLM catalogue) — useful for locally-hosted models where
|
|
@@ -1189,9 +817,27 @@ module Phronomy
|
|
|
1189
817
|
overhead: self.class.context_overhead
|
|
1190
818
|
)
|
|
1191
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
|
+
|
|
1192
838
|
Phronomy::LlmContextWindow::TokenBudget.new(
|
|
1193
|
-
|
|
1194
|
-
max_output_tokens:
|
|
839
|
+
context_window: registry_context,
|
|
840
|
+
max_output_tokens: output_reserve,
|
|
1195
841
|
overhead: self.class.context_overhead
|
|
1196
842
|
)
|
|
1197
843
|
end
|
|
@@ -1208,23 +854,29 @@ module Phronomy
|
|
|
1208
854
|
Phronomy.configuration.parallel_tool_execution ? Phronomy::MultiAgent::ParallelToolChat : nil
|
|
1209
855
|
end
|
|
1210
856
|
|
|
1211
|
-
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
|
+
}
|
|
1212
865
|
opts = {}
|
|
1213
|
-
|
|
1214
|
-
opts[:model] =
|
|
1215
|
-
|
|
1216
|
-
if
|
|
1217
|
-
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
|
|
1218
871
|
opts[:assume_model_exists] = true
|
|
1219
872
|
end
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
chat =
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
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"])
|
|
1226
879
|
end
|
|
1227
|
-
chat.with_temperature(t) if t
|
|
1228
880
|
chat
|
|
1229
881
|
end
|
|
1230
882
|
|
|
@@ -1244,8 +896,8 @@ module Phronomy
|
|
|
1244
896
|
# When cache_instructions is enabled and the provider is Anthropic,
|
|
1245
897
|
# attaches a cache_control marker so that the fixed system prompt is
|
|
1246
898
|
# eligible for prompt caching.
|
|
1247
|
-
def apply_instructions(chat, text)
|
|
1248
|
-
if
|
|
899
|
+
def apply_instructions(chat, text, cache: false, provider: nil)
|
|
900
|
+
if cache && provider.to_s == "anthropic"
|
|
1249
901
|
content = RubyLLM::Providers::Anthropic::Content.new(text, cache: true)
|
|
1250
902
|
chat.with_instructions(content)
|
|
1251
903
|
else
|
|
@@ -1278,7 +930,15 @@ module Phronomy
|
|
|
1278
930
|
# @api public
|
|
1279
931
|
def check_cancellation!(config, message = "invocation cancelled")
|
|
1280
932
|
ct = config[:cancellation_token]
|
|
1281
|
-
|
|
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
|
|
1282
942
|
end
|
|
1283
943
|
|
|
1284
944
|
# Builds the final Tool class to register with RubyLLM. Alias and Tool
|