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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +127 -30
  3. data/README.md +106 -122
  4. data/lib/phronomy/agent/base.rb +135 -57
  5. data/lib/phronomy/agent/checkpoint.rb +53 -0
  6. data/lib/phronomy/agent/orchestrator.rb +119 -0
  7. data/lib/phronomy/agent/react_agent.rb +18 -28
  8. data/lib/phronomy/agent/shared_state.rb +303 -0
  9. data/lib/phronomy/agent/suspend_signal.rb +35 -0
  10. data/lib/phronomy/agent/team_coordinator.rb +285 -0
  11. data/lib/phronomy/agent.rb +2 -1
  12. data/lib/phronomy/configuration.rb +0 -24
  13. data/lib/phronomy/generator_verifier.rb +250 -0
  14. data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +10 -27
  15. data/lib/phronomy/railtie.rb +0 -6
  16. data/lib/phronomy/ruby_llm_patches.rb +20 -0
  17. data/lib/phronomy/tool/mcp_tool.rb +23 -26
  18. data/lib/phronomy/tracing/langfuse_tracer.rb +3 -6
  19. data/lib/phronomy/vector_store/redis_search.rb +4 -4
  20. data/lib/phronomy/version.rb +1 -1
  21. data/lib/phronomy/workflow.rb +4 -7
  22. data/lib/phronomy/workflow_runner.rb +42 -30
  23. data/lib/phronomy.rb +18 -0
  24. data/scripts/check_readme_ruby.rb +38 -0
  25. metadata +12 -38
  26. data/docs/trustworthy_ai_enhancements.md +0 -332
  27. data/lib/phronomy/active_record/acts_as.rb +0 -48
  28. data/lib/phronomy/active_record/checkpoint.rb +0 -20
  29. data/lib/phronomy/active_record/extensions.rb +0 -14
  30. data/lib/phronomy/active_record/message.rb +0 -20
  31. data/lib/phronomy/actor.rb +0 -68
  32. data/lib/phronomy/memory/compression/base.rb +0 -37
  33. data/lib/phronomy/memory/compression/summary.rb +0 -107
  34. data/lib/phronomy/memory/compression/tool_output_pruner.rb +0 -67
  35. data/lib/phronomy/memory/compression.rb +0 -11
  36. data/lib/phronomy/memory/conversation_manager.rb +0 -213
  37. data/lib/phronomy/memory/retrieval/base.rb +0 -22
  38. data/lib/phronomy/memory/retrieval/composite.rb +0 -76
  39. data/lib/phronomy/memory/retrieval/recent.rb +0 -35
  40. data/lib/phronomy/memory/retrieval/semantic.rb +0 -114
  41. data/lib/phronomy/memory/retrieval.rb +0 -12
  42. data/lib/phronomy/memory/storage/active_record.rb +0 -248
  43. data/lib/phronomy/memory/storage/base.rb +0 -155
  44. data/lib/phronomy/memory/storage/in_memory.rb +0 -152
  45. data/lib/phronomy/memory/storage.rb +0 -11
  46. data/lib/phronomy/memory.rb +0 -21
  47. data/lib/phronomy/rails/agent_job.rb +0 -75
  48. data/lib/phronomy/state_store/active_record.rb +0 -76
  49. data/lib/phronomy/state_store/base.rb +0 -112
  50. data/lib/phronomy/state_store/encryptor/active_support.rb +0 -49
  51. data/lib/phronomy/state_store/encryptor/base.rb +0 -34
  52. data/lib/phronomy/state_store/encryptor.rb +0 -16
  53. data/lib/phronomy/state_store/file.rb +0 -85
  54. data/lib/phronomy/state_store/in_memory.rb +0 -53
  55. data/lib/phronomy/state_store/redis.rb +0 -70
  56. data/lib/phronomy/state_store.rb +0 -9
  57. data/lib/phronomy/thread_actor_registry.rb +0 -85
  58. data/lib/phronomy/trust_pipeline.rb +0 -264
@@ -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
- return @tools || []
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
- # +:memory+ ({Phronomy::Memory::ConversationManager}) memory backend
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
- thread_id = config[:thread_id]
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
- thread_id = config[:thread_id]
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, tools with +requires_approval+ execute
450
- # without interruption (backward-compatible behaviour).
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
- if memory && thread_id
532
- msgs = load_from_memory(memory, thread_id: thread_id, query: user_message)
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
- chat.before_tool_call { |tool_call| block.call(StreamEvent.new(type: :tool_call, payload: {tool_call: tool_call})) }
568
- chat.after_tool_result { |tool_result| block.call(StreamEvent.new(type: :tool_result, payload: {tool_result: tool_result})) }
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 memory.
616
- raw_messages = (memory && thread_id) ?
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
- response = chat.ask(user_message)
675
-
676
- # Persist the updated conversation to memory.
677
- save_to_memory(memory, thread_id: thread_id, messages: chat.messages) if memory && thread_id
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
- memory = config[:memory]
22
- thread_id = config[:thread_id]
21
+ config[:thread_id]
23
22
  max_iter = self.class.max_iterations
24
23
 
25
- # Seed with persisted messages when memory is provided.
26
- initial_messages = if memory && thread_id
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
- save_to_memory(memory, thread_id: thread_id, messages: messages) if memory && thread_id
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
- memory = config[:memory]
84
- thread_id = config[:thread_id]
72
+ config[:thread_id]
85
73
  max_iter = self.class.max_iterations
86
74
 
87
- initial_messages = if memory && thread_id
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
- chat.before_tool_call { |tc| block.call(StreamEvent.new(type: :tool_call, payload: {tool_call: tc})) }
158
- chat.after_tool_result { |tr| block.call(StreamEvent.new(type: :tool_result, payload: {tool_result: tr})) }
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)