phronomy 0.2.2 → 0.4.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 +127 -30
- data/README.md +106 -122
- data/lib/phronomy/agent/base.rb +135 -57
- data/lib/phronomy/agent/checkpoint.rb +53 -0
- data/lib/phronomy/agent/orchestrator.rb +119 -0
- data/lib/phronomy/agent/react_agent.rb +18 -28
- data/lib/phronomy/agent/shared_state.rb +303 -0
- data/lib/phronomy/agent/suspend_signal.rb +35 -0
- data/lib/phronomy/agent/team_coordinator.rb +285 -0
- data/lib/phronomy/agent.rb +2 -1
- data/lib/phronomy/configuration.rb +0 -24
- data/lib/phronomy/generator_verifier.rb +250 -0
- data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +10 -27
- data/lib/phronomy/railtie.rb +0 -6
- data/lib/phronomy/ruby_llm_patches.rb +20 -0
- data/lib/phronomy/tool/mcp_tool.rb +23 -26
- data/lib/phronomy/tracing/langfuse_tracer.rb +3 -6
- data/lib/phronomy/vector_store/redis_search.rb +4 -4
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +4 -7
- data/lib/phronomy/workflow_runner.rb +42 -30
- data/lib/phronomy.rb +18 -0
- data/scripts/check_readme_ruby.rb +38 -0
- metadata +12 -38
- data/docs/trustworthy_ai_enhancements.md +0 -332
- data/lib/phronomy/active_record/acts_as.rb +0 -48
- data/lib/phronomy/active_record/checkpoint.rb +0 -20
- data/lib/phronomy/active_record/extensions.rb +0 -14
- data/lib/phronomy/active_record/message.rb +0 -20
- data/lib/phronomy/actor.rb +0 -68
- data/lib/phronomy/memory/compression/base.rb +0 -37
- data/lib/phronomy/memory/compression/summary.rb +0 -107
- data/lib/phronomy/memory/compression/tool_output_pruner.rb +0 -67
- data/lib/phronomy/memory/compression.rb +0 -11
- data/lib/phronomy/memory/conversation_manager.rb +0 -213
- data/lib/phronomy/memory/retrieval/base.rb +0 -22
- data/lib/phronomy/memory/retrieval/composite.rb +0 -76
- data/lib/phronomy/memory/retrieval/recent.rb +0 -35
- data/lib/phronomy/memory/retrieval/semantic.rb +0 -114
- data/lib/phronomy/memory/retrieval.rb +0 -12
- data/lib/phronomy/memory/storage/active_record.rb +0 -248
- data/lib/phronomy/memory/storage/base.rb +0 -155
- data/lib/phronomy/memory/storage/in_memory.rb +0 -152
- data/lib/phronomy/memory/storage.rb +0 -11
- data/lib/phronomy/memory.rb +0 -21
- data/lib/phronomy/rails/agent_job.rb +0 -75
- data/lib/phronomy/state_store/active_record.rb +0 -76
- data/lib/phronomy/state_store/base.rb +0 -112
- data/lib/phronomy/state_store/encryptor/active_support.rb +0 -49
- data/lib/phronomy/state_store/encryptor/base.rb +0 -34
- data/lib/phronomy/state_store/encryptor.rb +0 -16
- data/lib/phronomy/state_store/file.rb +0 -85
- data/lib/phronomy/state_store/in_memory.rb +0 -53
- data/lib/phronomy/state_store/redis.rb +0 -70
- data/lib/phronomy/state_store.rb +0 -9
- data/lib/phronomy/thread_actor_registry.rb +0 -85
- data/lib/phronomy/trust_pipeline.rb +0 -264
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -66,7 +66,8 @@ module Phronomy
|
|
|
66
66
|
if text || block_given?
|
|
67
67
|
@instructions = text || block
|
|
68
68
|
else
|
|
69
|
-
@instructions
|
|
69
|
+
return @instructions if instance_variable_defined?(:@instructions)
|
|
70
|
+
superclass.respond_to?(:instructions) ? superclass.instructions : nil
|
|
70
71
|
end
|
|
71
72
|
end
|
|
72
73
|
|
|
@@ -88,7 +89,10 @@ module Phronomy
|
|
|
88
89
|
# )
|
|
89
90
|
def tools(*args)
|
|
90
91
|
if args.empty?
|
|
91
|
-
|
|
92
|
+
if instance_variable_defined?(:@tools)
|
|
93
|
+
return @tools
|
|
94
|
+
end
|
|
95
|
+
return superclass.respond_to?(:tools) ? superclass.tools : []
|
|
92
96
|
end
|
|
93
97
|
|
|
94
98
|
if args.length == 1 && args.first.is_a?(Hash)
|
|
@@ -122,7 +126,8 @@ module Phronomy
|
|
|
122
126
|
if name
|
|
123
127
|
@provider = name
|
|
124
128
|
else
|
|
125
|
-
@provider
|
|
129
|
+
return @provider if instance_variable_defined?(:@provider)
|
|
130
|
+
superclass.respond_to?(:provider) ? superclass.provider : nil
|
|
126
131
|
end
|
|
127
132
|
end
|
|
128
133
|
|
|
@@ -402,18 +407,25 @@ module Phronomy
|
|
|
402
407
|
# +:message+, +:query+, or +:user+ as the text key, plus any template
|
|
403
408
|
# variables consumed by the configured instructions template.
|
|
404
409
|
# @param config [Hash] runtime options:
|
|
405
|
-
# +:
|
|
410
|
+
# +:messages+ (Array<RubyLLM::Message>) — conversation history from a previous invocation
|
|
406
411
|
# +:thread_id+ (+String+) — conversation thread identifier
|
|
407
412
|
# +:user_id+ (+String+, optional) — caller identity forwarded to the tracer
|
|
408
413
|
# +:session_id+ (+String+, optional) — session identity forwarded to the tracer
|
|
409
|
-
# @return [Hash] +{ output: String, messages: Array, usage: Phronomy::TokenUsage }
|
|
414
|
+
# @return [Hash] +{ output: String, messages: Array, usage: Phronomy::TokenUsage }+,
|
|
415
|
+
# or +{ output: nil, suspended: true, checkpoint: Phronomy::Agent::Checkpoint,
|
|
416
|
+
# messages: Array }+ when the invocation was suspended awaiting tool approval.
|
|
410
417
|
# @raise [Phronomy::GuardrailError] when an input or output guardrail rejects the value
|
|
411
|
-
# @example
|
|
418
|
+
# @example Normal invocation
|
|
412
419
|
# result = MyAgent.new.invoke("What is Ruby?")
|
|
413
420
|
# puts result[:output]
|
|
421
|
+
# @example Suspend / resume flow
|
|
422
|
+
# result = agent.invoke("Perform task X")
|
|
423
|
+
# if result[:suspended]
|
|
424
|
+
# result = agent.resume(result[:checkpoint], approved: true)
|
|
425
|
+
# end
|
|
426
|
+
# puts result[:output]
|
|
414
427
|
def invoke(input, config: {})
|
|
415
|
-
|
|
416
|
-
_run_in_thread_actor(thread_id) { _invoke_impl(input, config: config) }
|
|
428
|
+
_invoke_impl(input, config: config)
|
|
417
429
|
end
|
|
418
430
|
|
|
419
431
|
# Streaming version of #invoke. Yields {Phronomy::Agent::StreamEvent} objects
|
|
@@ -433,23 +445,73 @@ module Phronomy
|
|
|
433
445
|
def stream(input, config: {}, &block)
|
|
434
446
|
return invoke(input, config: config) unless block
|
|
435
447
|
|
|
436
|
-
|
|
437
|
-
_run_in_thread_actor(thread_id) { _stream_impl(input, config: config, &block) }
|
|
448
|
+
_stream_impl(input, config: config, &block)
|
|
438
449
|
rescue => e
|
|
439
450
|
block&.call(StreamEvent.new(type: :error, payload: {error: e}))
|
|
440
451
|
raise
|
|
441
452
|
end
|
|
442
453
|
|
|
454
|
+
# Resumes a previously suspended invocation from a {Phronomy::Agent::Checkpoint}.
|
|
455
|
+
#
|
|
456
|
+
# This method reconstructs the conversation state captured at suspension
|
|
457
|
+
# time, injects the tool result (executed or denied), and continues the
|
|
458
|
+
# LLM loop until it produces a final answer.
|
|
459
|
+
#
|
|
460
|
+
# @param checkpoint [Phronomy::Agent::Checkpoint] the checkpoint returned by
|
|
461
|
+
# the suspended #invoke call
|
|
462
|
+
# @param approved [Boolean] +true+ to execute the pending tool; +false+
|
|
463
|
+
# to inject a denial message and let the LLM handle it gracefully
|
|
464
|
+
# @param config [Hash] same runtime options as #invoke
|
|
465
|
+
# @return [Hash] +{ output: String, suspended: false, messages: Array, usage: Phronomy::TokenUsage }+
|
|
466
|
+
# @raise [Phronomy::GuardrailError] when an output guardrail rejects the value
|
|
467
|
+
def resume(checkpoint, approved:, config: {})
|
|
468
|
+
checkpoint.thread_id
|
|
469
|
+
|
|
470
|
+
# Build a fresh chat with all tools registered.
|
|
471
|
+
chat = build_chat
|
|
472
|
+
|
|
473
|
+
# Restore the full conversation (system + history + user + assistant).
|
|
474
|
+
checkpoint.messages.each { |msg| chat.messages << msg }
|
|
475
|
+
|
|
476
|
+
# Determine the tool result: execute it or inject a denial string.
|
|
477
|
+
tool_result =
|
|
478
|
+
if approved
|
|
479
|
+
tool_instance = chat.tools[checkpoint.pending_tool_name.to_sym]
|
|
480
|
+
tool_instance ? tool_instance.call(checkpoint.pending_tool_args) : "Tool not found."
|
|
481
|
+
else
|
|
482
|
+
"Tool execution denied."
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Inject the tool result so the LLM can continue.
|
|
486
|
+
chat.add_message(
|
|
487
|
+
role: :tool,
|
|
488
|
+
content: tool_result.to_s,
|
|
489
|
+
tool_call_id: checkpoint.pending_tool_call_id
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
# Continue the React loop.
|
|
493
|
+
response = chat.complete
|
|
494
|
+
|
|
495
|
+
output = response.content
|
|
496
|
+
usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
497
|
+
|
|
498
|
+
run_output_guardrails!(output)
|
|
499
|
+
|
|
500
|
+
{output: output, suspended: false, messages: chat.messages, usage: usage}
|
|
501
|
+
end
|
|
502
|
+
|
|
443
503
|
# Registers a callback that is invoked before executing any tool that has
|
|
444
504
|
# +requires_approval true+ set. The block receives the tool name (String)
|
|
445
505
|
# and the arguments Hash, and must return a truthy value to allow execution.
|
|
446
506
|
# Returning a falsy value causes the tool to return a denial message instead
|
|
447
507
|
# of executing.
|
|
448
508
|
#
|
|
449
|
-
# When no handler is registered
|
|
450
|
-
#
|
|
509
|
+
# When no handler is registered and a tool with +requires_approval+ is
|
|
510
|
+
# called, #invoke returns a suspended result hash containing a
|
|
511
|
+
# {Phronomy::Agent::Checkpoint}. Call #resume to continue execution after
|
|
512
|
+
# obtaining an approval decision from the user or an external system.
|
|
451
513
|
#
|
|
452
|
-
# @example
|
|
514
|
+
# @example Synchronous handler
|
|
453
515
|
# agent = MyAgent.new
|
|
454
516
|
# agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
|
|
455
517
|
# @return [self]
|
|
@@ -510,7 +572,6 @@ module Phronomy
|
|
|
510
572
|
trace("agent.invoke", input: input, **caller_meta) do |_span|
|
|
511
573
|
run_input_guardrails!(input)
|
|
512
574
|
|
|
513
|
-
memory = config[:memory]
|
|
514
575
|
thread_id = config[:thread_id]
|
|
515
576
|
|
|
516
577
|
chat = build_chat
|
|
@@ -528,8 +589,8 @@ module Phronomy
|
|
|
528
589
|
end
|
|
529
590
|
end
|
|
530
591
|
|
|
531
|
-
|
|
532
|
-
|
|
592
|
+
msgs = Array(config[:messages])
|
|
593
|
+
unless msgs.empty?
|
|
533
594
|
message_elements = build_message_elements(msgs)
|
|
534
595
|
|
|
535
596
|
# Run on_trim: app may call ctx.remove(seqs) to drop messages this turn.
|
|
@@ -547,8 +608,7 @@ module Phronomy
|
|
|
547
608
|
compact_ctx = Context::CompactionContext.new(
|
|
548
609
|
message_elements: message_elements,
|
|
549
610
|
budget: budget,
|
|
550
|
-
thread_id: thread_id
|
|
551
|
-
memory: memory
|
|
611
|
+
thread_id: thread_id
|
|
552
612
|
)
|
|
553
613
|
compact_cb.call(compact_ctx)
|
|
554
614
|
message_elements = build_message_elements(compact_ctx.result_messages)
|
|
@@ -564,8 +624,18 @@ module Phronomy
|
|
|
564
624
|
context[:messages].each { |msg| chat.messages << msg }
|
|
565
625
|
|
|
566
626
|
# Wire per-event callbacks to yield StreamEvents.
|
|
567
|
-
|
|
568
|
-
chat.
|
|
627
|
+
current_tool_call = nil
|
|
628
|
+
chat.on_tool_call do |tool_call|
|
|
629
|
+
current_tool_call = tool_call
|
|
630
|
+
block.call(StreamEvent.new(type: :tool_call, payload: {tool_call: tool_call}))
|
|
631
|
+
end
|
|
632
|
+
chat.on_tool_result do |tool_result|
|
|
633
|
+
block.call(StreamEvent.new(type: :tool_result, payload: {
|
|
634
|
+
tool_call_id: current_tool_call&.id,
|
|
635
|
+
tool_name: current_tool_call&.name,
|
|
636
|
+
tool_result: tool_result
|
|
637
|
+
}))
|
|
638
|
+
end
|
|
569
639
|
|
|
570
640
|
# Run before_completion hooks (global → class → instance) before the LLM call.
|
|
571
641
|
run_before_completion_hooks!(chat, config)
|
|
@@ -574,8 +644,6 @@ module Phronomy
|
|
|
574
644
|
block.call(StreamEvent.new(type: :token, payload: {content: chunk.content}))
|
|
575
645
|
end
|
|
576
646
|
|
|
577
|
-
save_to_memory(memory, thread_id: thread_id, messages: chat.messages) if memory && thread_id
|
|
578
|
-
|
|
579
647
|
output = response.content
|
|
580
648
|
usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
581
649
|
|
|
@@ -587,14 +655,6 @@ module Phronomy
|
|
|
587
655
|
end
|
|
588
656
|
end
|
|
589
657
|
|
|
590
|
-
# Runs +block+ inside the {Phronomy::ThreadActorRegistry} Actor for
|
|
591
|
-
# +thread_id+. When +thread_id+ is nil the block executes on the calling thread.
|
|
592
|
-
def _run_in_thread_actor(thread_id, &block)
|
|
593
|
-
return block.call unless thread_id
|
|
594
|
-
|
|
595
|
-
Phronomy::ThreadActorRegistry.for(thread_id).call(&block)
|
|
596
|
-
end
|
|
597
|
-
|
|
598
658
|
# Performs a single (non-retried) invocation. Extracted so that #invoke can
|
|
599
659
|
# wrap it in a retry loop without duplicating the LLM interaction logic.
|
|
600
660
|
def invoke_once(input, config: {})
|
|
@@ -606,15 +666,13 @@ module Phronomy
|
|
|
606
666
|
# Run input guardrails before touching the LLM.
|
|
607
667
|
run_input_guardrails!(input)
|
|
608
668
|
|
|
609
|
-
memory = config[:memory]
|
|
610
669
|
thread_id = config[:thread_id]
|
|
611
670
|
user_message = extract_message(input)
|
|
612
671
|
chat = build_chat
|
|
613
672
|
budget = build_token_budget
|
|
614
673
|
|
|
615
|
-
# Load conversation history from
|
|
616
|
-
raw_messages = (
|
|
617
|
-
load_from_memory(memory, thread_id: thread_id, query: user_message) : []
|
|
674
|
+
# Load conversation history from config[:messages] (app-managed).
|
|
675
|
+
raw_messages = Array(config[:messages])
|
|
618
676
|
|
|
619
677
|
# Assign synthetic 0-based seq numbers for use by trim/compaction callbacks.
|
|
620
678
|
message_elements = build_message_elements(raw_messages)
|
|
@@ -636,8 +694,7 @@ module Phronomy
|
|
|
636
694
|
compact_ctx = Context::CompactionContext.new(
|
|
637
695
|
message_elements: message_elements,
|
|
638
696
|
budget: budget,
|
|
639
|
-
thread_id: thread_id
|
|
640
|
-
memory: memory
|
|
697
|
+
thread_id: thread_id
|
|
641
698
|
)
|
|
642
699
|
compact_cb.call(compact_ctx)
|
|
643
700
|
message_elements = build_message_elements(compact_ctx.result_messages)
|
|
@@ -671,10 +728,23 @@ module Phronomy
|
|
|
671
728
|
# Run before_completion hooks (global → class → instance) before the LLM call.
|
|
672
729
|
run_before_completion_hooks!(chat, config)
|
|
673
730
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
731
|
+
# Register suspension hook for approval-required tools (no-op when a
|
|
732
|
+
# synchronous on_approval_required handler is already registered).
|
|
733
|
+
_register_suspension_hook!(chat)
|
|
734
|
+
|
|
735
|
+
begin
|
|
736
|
+
response = chat.ask(user_message)
|
|
737
|
+
rescue SuspendSignal => signal
|
|
738
|
+
checkpoint = Checkpoint.new(
|
|
739
|
+
thread_id: thread_id,
|
|
740
|
+
messages: chat.messages.dup,
|
|
741
|
+
pending_tool_name: signal.tool_name,
|
|
742
|
+
pending_tool_args: signal.args,
|
|
743
|
+
pending_tool_call_id: signal.tool_call_id
|
|
744
|
+
)
|
|
745
|
+
suspended_result = {output: nil, suspended: true, checkpoint: checkpoint, messages: chat.messages}
|
|
746
|
+
next [suspended_result, nil]
|
|
747
|
+
end
|
|
678
748
|
|
|
679
749
|
output = response.content
|
|
680
750
|
usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
@@ -832,23 +902,6 @@ module Phronomy
|
|
|
832
902
|
|
|
833
903
|
# Load messages from a ConversationManager.
|
|
834
904
|
#
|
|
835
|
-
# @param memory [Memory::ConversationManager]
|
|
836
|
-
# @param thread_id [String]
|
|
837
|
-
# @param query [String, nil]
|
|
838
|
-
# @return [Array]
|
|
839
|
-
def load_from_memory(memory, thread_id:, query: nil)
|
|
840
|
-
memory.load(thread_id: thread_id, query: query)
|
|
841
|
-
end
|
|
842
|
-
|
|
843
|
-
# Persist messages to a ConversationManager.
|
|
844
|
-
#
|
|
845
|
-
# @param memory [Memory::ConversationManager]
|
|
846
|
-
# @param thread_id [String]
|
|
847
|
-
# @param messages [Array]
|
|
848
|
-
def save_to_memory(memory, thread_id:, messages:)
|
|
849
|
-
memory.save(thread_id: thread_id, messages: messages)
|
|
850
|
-
end
|
|
851
|
-
|
|
852
905
|
def build_chat
|
|
853
906
|
opts = {}
|
|
854
907
|
m = self.class.model
|
|
@@ -917,6 +970,31 @@ module Phronomy
|
|
|
917
970
|
(@output_guardrails || []).each { |g| g.run!(output) }
|
|
918
971
|
end
|
|
919
972
|
|
|
973
|
+
# Registers an on_tool_call hook on the chat object that raises SuspendSignal
|
|
974
|
+
# when an approval-required tool is about to be executed and no synchronous
|
|
975
|
+
# on_approval_required handler has been registered.
|
|
976
|
+
#
|
|
977
|
+
# Does nothing when:
|
|
978
|
+
# - a synchronous handler is already registered (@approval_handler is set), or
|
|
979
|
+
# - none of the agent's tools have requires_approval set.
|
|
980
|
+
#
|
|
981
|
+
# @param chat [RubyLLM::Chat]
|
|
982
|
+
def _register_suspension_hook!(chat)
|
|
983
|
+
return if @approval_handler
|
|
984
|
+
return if self.class.tools.none? { |tc| tc.requires_approval }
|
|
985
|
+
|
|
986
|
+
chat.on_tool_call do |tool_call|
|
|
987
|
+
tool_instance = chat.tools[tool_call.name.to_sym]
|
|
988
|
+
if tool_instance&.requires_approval
|
|
989
|
+
raise SuspendSignal.new(
|
|
990
|
+
tool_name: tool_call.name,
|
|
991
|
+
args: tool_call.arguments,
|
|
992
|
+
tool_call_id: tool_call.id
|
|
993
|
+
)
|
|
994
|
+
end
|
|
995
|
+
end
|
|
996
|
+
end
|
|
997
|
+
|
|
920
998
|
# Builds the final tool class to register with the chat.
|
|
921
999
|
#
|
|
922
1000
|
# Two transformations are applied in order:
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# Encapsulates the suspended state of an agent invocation.
|
|
6
|
+
#
|
|
7
|
+
# A Checkpoint is returned as the +:checkpoint+ key of the result hash when
|
|
8
|
+
# an approval-required tool is encountered and no synchronous
|
|
9
|
+
# on_approval_required handler has been registered.
|
|
10
|
+
#
|
|
11
|
+
# Pass the checkpoint to Agent::Base#resume to continue execution after
|
|
12
|
+
# obtaining an approval decision from the user or an external system.
|
|
13
|
+
#
|
|
14
|
+
# @example Suspend and resume
|
|
15
|
+
# result = agent.invoke("Do task X")
|
|
16
|
+
# if result[:suspended]
|
|
17
|
+
# approved = prompt_user(result[:checkpoint].pending_tool_name)
|
|
18
|
+
# result = agent.resume(result[:checkpoint], approved: approved)
|
|
19
|
+
# end
|
|
20
|
+
# puts result[:output]
|
|
21
|
+
class Checkpoint
|
|
22
|
+
# @return [String, nil] the thread_id from the invocation config
|
|
23
|
+
attr_reader :thread_id
|
|
24
|
+
|
|
25
|
+
# @return [Array<RubyLLM::Message>] conversation messages up to and including
|
|
26
|
+
# the assistant message that requested the pending tool call
|
|
27
|
+
attr_reader :messages
|
|
28
|
+
|
|
29
|
+
# @return [String] the name of the tool awaiting approval
|
|
30
|
+
attr_reader :pending_tool_name
|
|
31
|
+
|
|
32
|
+
# @return [Hash] the arguments the LLM passed to the pending tool
|
|
33
|
+
attr_reader :pending_tool_args
|
|
34
|
+
|
|
35
|
+
# @return [String] the tool_call_id from the LLM response (required to
|
|
36
|
+
# inject the tool result message on resume)
|
|
37
|
+
attr_reader :pending_tool_call_id
|
|
38
|
+
|
|
39
|
+
# @param thread_id [String, nil]
|
|
40
|
+
# @param messages [Array<RubyLLM::Message>]
|
|
41
|
+
# @param pending_tool_name [String]
|
|
42
|
+
# @param pending_tool_args [Hash]
|
|
43
|
+
# @param pending_tool_call_id [String]
|
|
44
|
+
def initialize(thread_id:, messages:, pending_tool_name:, pending_tool_args:, pending_tool_call_id:)
|
|
45
|
+
@thread_id = thread_id
|
|
46
|
+
@messages = messages.dup.freeze
|
|
47
|
+
@pending_tool_name = pending_tool_name
|
|
48
|
+
@pending_tool_args = pending_tool_args
|
|
49
|
+
@pending_tool_call_id = pending_tool_call_id
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# Base class for orchestrator agents that coordinate multiple subagents.
|
|
6
|
+
# Implements the Orchestrator-Subagent multi-agent coordination pattern
|
|
7
|
+
# (Anthropic blog, Pattern 2).
|
|
8
|
+
#
|
|
9
|
+
# @see https://claude.com/blog/multi-agent-coordination-patterns
|
|
10
|
+
#
|
|
11
|
+
# Extends {Phronomy::Agent::Base} with:
|
|
12
|
+
# - A +subagent+ class-level DSL for declarative subagent registration. Each
|
|
13
|
+
# declared subagent is automatically exposed as an LLM-callable tool.
|
|
14
|
+
# - +dispatch_parallel+ for programmatic parallel invocation of heterogeneous
|
|
15
|
+
# agents.
|
|
16
|
+
# - +fan_out+ for parallel invocation of the same agent across multiple inputs.
|
|
17
|
+
#
|
|
18
|
+
# @example Declarative DSL
|
|
19
|
+
# class ResearchOrchestrator < Phronomy::Agent::Orchestrator
|
|
20
|
+
# model "gpt-4o"
|
|
21
|
+
# instructions "You coordinate research tasks."
|
|
22
|
+
# subagent :searcher, SearchAgent
|
|
23
|
+
# subagent :summarizer, SummaryAgent
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# result = ResearchOrchestrator.new.invoke("Research the latest AI news.")
|
|
27
|
+
#
|
|
28
|
+
# @example Programmatic parallel dispatch
|
|
29
|
+
# class MyOrchestrator < Phronomy::Agent::Orchestrator
|
|
30
|
+
# model "gpt-4o"
|
|
31
|
+
# instructions "Dispatch tasks in parallel."
|
|
32
|
+
#
|
|
33
|
+
# def run(input)
|
|
34
|
+
# results = dispatch_parallel(
|
|
35
|
+
# { agent: SearchAgent, input: "topic A" },
|
|
36
|
+
# { agent: AnalysisAgent, input: input }
|
|
37
|
+
# )
|
|
38
|
+
# results.map { |r| r[:output] }.join("\n")
|
|
39
|
+
# end
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# @example Fan-out (same agent, multiple inputs)
|
|
43
|
+
# results = fan_out(agent: TranslationAgent, inputs: ["Hello", "World"])
|
|
44
|
+
class Orchestrator < Base
|
|
45
|
+
# Declares a named subagent and registers it as a tool accessible to the
|
|
46
|
+
# LLM during an +invoke+ call.
|
|
47
|
+
#
|
|
48
|
+
# Each call appends a new tool to this class's tool list. The generated
|
|
49
|
+
# tool's function name is +dispatch_to_<name>+. When the LLM calls the
|
|
50
|
+
# tool, a fresh instance of +agent_class+ is created and +invoke+ is called
|
|
51
|
+
# with the provided input string.
|
|
52
|
+
#
|
|
53
|
+
# @param name [Symbol] logical name that identifies the subagent
|
|
54
|
+
# @param agent_class [Class] subclass of {Phronomy::Agent::Base}
|
|
55
|
+
# @param on_error [Symbol] +:raise+ (default) re-raises any exception
|
|
56
|
+
# from the subagent; +:skip+ returns +nil+ so the LLM can decide how to
|
|
57
|
+
# proceed
|
|
58
|
+
def self.subagent(name, agent_class, on_error: :raise)
|
|
59
|
+
tool_class = Class.new(Phronomy::Tool::Base) do
|
|
60
|
+
tool_name "dispatch_to_#{name}"
|
|
61
|
+
description "Dispatch work to the #{name} subagent (#{agent_class.name})"
|
|
62
|
+
param :input, type: :string, desc: "The task or question for the subagent"
|
|
63
|
+
|
|
64
|
+
define_method(:execute) do |input:|
|
|
65
|
+
result = agent_class.new.invoke(input)
|
|
66
|
+
result[:output]
|
|
67
|
+
rescue
|
|
68
|
+
raise if on_error == :raise
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Append without clobbering previously registered tools or aliases.
|
|
74
|
+
@tools = (@tools || []) + [tool_class]
|
|
75
|
+
@tool_aliases ||= {}
|
|
76
|
+
|
|
77
|
+
registered_subagents[name] = {agent_class: agent_class, on_error: on_error}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Returns the subagent registry for this specific class (not inherited).
|
|
81
|
+
#
|
|
82
|
+
# @return [Hash{Symbol => Hash}]
|
|
83
|
+
def self.registered_subagents
|
|
84
|
+
@registered_subagents ||= {}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Dispatches multiple heterogeneous agent tasks in parallel using Ruby
|
|
88
|
+
# threads. Each task is a Hash describing one agent invocation.
|
|
89
|
+
#
|
|
90
|
+
# Results are returned in the same order as the input +tasks+ array.
|
|
91
|
+
# If any thread raises an exception, the exception is re-raised in the
|
|
92
|
+
# calling thread after all threads have completed (via +Thread#value+).
|
|
93
|
+
#
|
|
94
|
+
# @param tasks [Array<Hash>]
|
|
95
|
+
# @option task [Class] :agent agent class to invoke (required)
|
|
96
|
+
# @option task [String] :input input string for the agent (required)
|
|
97
|
+
# @option task [Hash] :config forwarded to +agent#invoke+ (default: +{}+)
|
|
98
|
+
# @return [Array<Hash>] agent results in the same order as +tasks+
|
|
99
|
+
def dispatch_parallel(*tasks)
|
|
100
|
+
threads = tasks.map do |task|
|
|
101
|
+
Thread.new do
|
|
102
|
+
task[:agent].new.invoke(task[:input], config: task.fetch(:config, {}))
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
threads.map(&:value)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Runs the same agent against multiple inputs in parallel (fan-out pattern).
|
|
109
|
+
#
|
|
110
|
+
# @param agent [Class] agent class to invoke for every input
|
|
111
|
+
# @param inputs [Array<String>] list of input strings
|
|
112
|
+
# @param config [Hash] forwarded to every +agent#invoke+ call
|
|
113
|
+
# @return [Array<Hash>] results in the same order as +inputs+
|
|
114
|
+
def fan_out(agent:, inputs:, config: {})
|
|
115
|
+
dispatch_parallel(*inputs.map { |input| {agent: agent, input: input, config: config} })
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -18,18 +18,11 @@ module Phronomy
|
|
|
18
18
|
# Run input guardrails before any LLM interaction.
|
|
19
19
|
run_input_guardrails!(input)
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
thread_id = config[:thread_id]
|
|
21
|
+
config[:thread_id]
|
|
23
22
|
max_iter = self.class.max_iterations
|
|
24
23
|
|
|
25
|
-
# Seed with
|
|
26
|
-
|
|
27
|
-
load_from_memory(memory, thread_id: thread_id, query: extract_message(input))
|
|
28
|
-
else
|
|
29
|
-
[]
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
messages = initial_messages.dup
|
|
24
|
+
# Seed with app-managed conversation history when provided.
|
|
25
|
+
messages = Array(config[:messages]).dup
|
|
33
26
|
user_asked = false
|
|
34
27
|
total_usage = Phronomy::TokenUsage.zero
|
|
35
28
|
iterations_exhausted = true
|
|
@@ -45,12 +38,8 @@ module Phronomy
|
|
|
45
38
|
end
|
|
46
39
|
end
|
|
47
40
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
# Fall back to the last message that carries non-nil content. This
|
|
41
|
+
# Fall back to the last message
|
|
51
42
|
# guards against the case where the final message is a tool-call or
|
|
52
|
-
# tool-result message (content == nil) when max_iterations is
|
|
53
|
-
# exhausted before the model produces a text reply.
|
|
54
43
|
output = messages.reverse.find { |m| m.content && !m.content.empty? }&.content
|
|
55
44
|
|
|
56
45
|
# Run output guardrails before returning to the caller.
|
|
@@ -80,17 +69,10 @@ module Phronomy
|
|
|
80
69
|
trace("agent.invoke", input: input, **caller_meta) do |_span|
|
|
81
70
|
run_input_guardrails!(input)
|
|
82
71
|
|
|
83
|
-
|
|
84
|
-
thread_id = config[:thread_id]
|
|
72
|
+
config[:thread_id]
|
|
85
73
|
max_iter = self.class.max_iterations
|
|
86
74
|
|
|
87
|
-
|
|
88
|
-
load_from_memory(memory, thread_id: thread_id, query: extract_message(input))
|
|
89
|
-
else
|
|
90
|
-
[]
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
messages = initial_messages.dup
|
|
75
|
+
messages = Array(config[:messages]).dup
|
|
94
76
|
user_asked = false
|
|
95
77
|
total_usage = Phronomy::TokenUsage.zero
|
|
96
78
|
iterations_exhausted = true
|
|
@@ -106,8 +88,6 @@ module Phronomy
|
|
|
106
88
|
end
|
|
107
89
|
end
|
|
108
90
|
|
|
109
|
-
save_to_memory(memory, thread_id: thread_id, messages: messages) if memory && thread_id
|
|
110
|
-
|
|
111
91
|
# Fall back to the last message that carries non-nil content (same as
|
|
112
92
|
# the non-streaming path above).
|
|
113
93
|
output = messages.reverse.find { |m| m.content && !m.content.empty? }&.content
|
|
@@ -154,8 +134,18 @@ module Phronomy
|
|
|
154
134
|
chat = build_chat
|
|
155
135
|
messages.each { |m| chat.add_message(m) }
|
|
156
136
|
|
|
157
|
-
|
|
158
|
-
chat.
|
|
137
|
+
current_tool_call = nil
|
|
138
|
+
chat.on_tool_call do |tc|
|
|
139
|
+
current_tool_call = tc
|
|
140
|
+
block.call(StreamEvent.new(type: :tool_call, payload: {tool_call: tc}))
|
|
141
|
+
end
|
|
142
|
+
chat.on_tool_result do |tr|
|
|
143
|
+
block.call(StreamEvent.new(type: :tool_result, payload: {
|
|
144
|
+
tool_call_id: current_tool_call&.id,
|
|
145
|
+
tool_name: current_tool_call&.name,
|
|
146
|
+
tool_result: tr
|
|
147
|
+
}))
|
|
148
|
+
end
|
|
159
149
|
|
|
160
150
|
# Run before_completion hooks before each LLM call in the streaming loop.
|
|
161
151
|
run_before_completion_hooks!(chat, config)
|