little_ghost 0.2.1 → 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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +72 -74
  3. data/docs/guides/assemblies.md +286 -0
  4. data/docs/guides/core_concepts.md +159 -135
  5. data/docs/guides/getting_started.md +114 -83
  6. data/docs/guides/production.md +187 -0
  7. data/docs/guides/prompt_views.md +132 -0
  8. data/lib/little_ghost/ag_ui/adapter.rb +3 -3
  9. data/lib/little_ghost/agent/delegation.rb +35 -8
  10. data/lib/little_ghost/agent/tool_loop.rb +2 -1
  11. data/lib/little_ghost/agent.rb +280 -326
  12. data/lib/little_ghost/agent_builder.rb +20 -4
  13. data/lib/little_ghost/agent_factory.rb +3 -0
  14. data/lib/little_ghost/{agent_interruptions.rb → agent_interjections.rb} +12 -12
  15. data/lib/little_ghost/assembly.rb +345 -0
  16. data/lib/little_ghost/assembly_builder.rb +497 -0
  17. data/lib/little_ghost/assembly_execution.rb +535 -0
  18. data/lib/little_ghost/configuration.rb +263 -39
  19. data/lib/little_ghost/content.rb +5 -5
  20. data/lib/little_ghost/data_map.rb +209 -0
  21. data/lib/little_ghost/errors.rb +10 -2
  22. data/lib/little_ghost/execution.rb +206 -0
  23. data/lib/little_ghost/graph.rb +930 -0
  24. data/lib/little_ghost/message.rb +4 -4
  25. data/lib/little_ghost/model_resolver.rb +2 -2
  26. data/lib/little_ghost/prompt_resolver.rb +2 -0
  27. data/lib/little_ghost/run.rb +190 -64
  28. data/lib/little_ghost/run_context.rb +33 -20
  29. data/lib/little_ghost/run_result.rb +22 -11
  30. data/lib/little_ghost/runtime/hook.rb +9 -4
  31. data/lib/little_ghost/runtime.rb +134 -36
  32. data/lib/little_ghost/sandbox.rb +1 -1
  33. data/lib/little_ghost/session.rb +12 -23
  34. data/lib/little_ghost/session_store.rb +9 -5
  35. data/lib/little_ghost/session_stores/agent_core_memory.rb +64 -56
  36. data/lib/little_ghost/session_stores/filesystem.rb +261 -0
  37. data/lib/little_ghost/session_stores/memory.rb +7 -0
  38. data/lib/little_ghost/subagents/manager.rb +42 -42
  39. data/lib/little_ghost/support/executor.rb +14 -2
  40. data/lib/little_ghost/support/loader.rb +2 -2
  41. data/lib/little_ghost/support.rb +15 -3
  42. data/lib/little_ghost/swarm.rb +439 -0
  43. data/lib/little_ghost/tool.rb +88 -20
  44. data/lib/little_ghost/tools/write_todos.rb +6 -1
  45. data/lib/little_ghost/tracing/open_telemetry.rb +14 -3
  46. data/lib/little_ghost/unrestricted_sandbox.rb +1 -1
  47. data/lib/little_ghost/version.rb +1 -1
  48. data/lib/little_ghost/workflow.rb +224 -90
  49. data/lib/little_ghost.rb +36 -25
  50. metadata +17 -5
@@ -1,54 +1,59 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
+ require_relative "assembly"
4
5
  require_relative "support/output_truncation"
5
6
  require_relative "tool_execution"
6
7
 
7
8
  module LittleGhost
8
- # Define reusable agents that can answer, stream, call tools, and delegate work.
9
- # Each subclass describes one application role with an inheritable Ruby DSL.
9
+ # Defines one reusable model-driven behavior with prompts, tools, and limits.
10
+ # Each Agent subclass describes one application role with an inheritable Ruby
11
+ # DSL. It can answer, stream, call tools, and delegate work.
10
12
  #
11
- # A customer support agent can look up an account itself and give longer investigations
12
- # to a research specialist:
13
- #
14
- # class ResearchAgent < LittleGhost::Agent
15
- # description "Researches transfer failures"
16
- # model "customer_support.research"
17
- # tools LedgerSearchTool
18
- # end
13
+ # Start with one role and add capabilities as its work grows:
19
14
  #
20
15
  # class CustomerSupportAgent < LittleGhost::Agent
21
16
  # description "Handles support requests"
22
- # model :customer_support
23
- # limits max_turns: 40
24
- # tools AccountLookupTool
25
- # subagent ResearchAgent, kind: "research"
17
+ # model "openrouter:openai/gpt-5.6-luna"
18
+ # system_prompt "Answer customer questions clearly."
26
19
  # end
27
20
  #
28
21
  # run = CustomerSupportAgent.ask("Why is transfer 481 pending?")
29
22
  # run.completed? # => true
30
- # run.response # => "Transfer 481 is waiting for the receiving bank."
23
+ # run.response
24
+ # # One possible response: Transfer 481 is waiting for the receiving bank.
25
+ #
26
+ # An Agent is the smallest Assembly: it owns one model loop while inheriting
27
+ # the same +ask+ and +stream_ask+ entrypoints as coordinated assemblies.
28
+ # Add tools for application operations and subagents for model-directed
29
+ # delegation.
31
30
  #
32
- # Class declarations are inherited. Prompts resolve by the agent's logical
33
- # path unless +system_prompt+ or +system_template+ supplies one explicitly;
34
- # tools and prompt locals may also be selected dynamically for each run.
35
- # Capabilities such as skills, context management, loop detection, and
36
- # delegation remain inactive until their DSL methods are called.
31
+ # Call a named Agent with
32
+ # ask[rdoc-ref:LittleGhost::Assembly.ask] when you need the final Run, or
33
+ # the streaming entrypoint[rdoc-ref:LittleGhost::Assembly.stream_ask] when you
34
+ # want events as the answer arrives.
37
35
  #
38
- # The class-level +ask+ and +stream_ask+ helpers create fresh standalone
39
- # entrypoints. Create an instance explicitly to reuse one Runtime across
40
- # calls. Runtimes build bound instances internally; their +call+ method
41
- # returns a RunResult and their +stream+ method follows the owning run's
42
- # single-execution lifecycle. Closing an agent closes owned tools and any
43
- # standalone workspace and sandbox.
44
- # LittleGhost::Agent.ask uses <tt>You are a helpful agent.</tt> as its system
45
- # prompt. Subclasses continue to use their inline or conventional prompts.
36
+ # Most applications call a named Agent class. LittleGhost automatically
37
+ # reuses the active Configuration's shared Runtime while building a fresh
38
+ # top-level Run for every call. Passing +runtime:+ is an advanced option for
39
+ # an explicitly isolated setup.
46
40
  #
47
- # Models can return ordinary text or a locally validated structured result.
48
- # Tool failures are sanitized before returning to the model, diagnostic
49
- # capture can be disabled for sensitive agents, and cancellation, deadlines,
50
- # and cleanup failures remain framework control flow.
51
- class Agent
41
+ # Agent declarations are inherited. Define a short prompt inline, or place a
42
+ # growing prompt in <tt>app/prompts/customer_support/system.erb</tt> for
43
+ # +CustomerSupportAgent+. The {Prompts as Views guide}[rdoc-ref:docs/guides/prompt_views.md]
44
+ # explains conventional lookup, locals, and partials. Optional features such
45
+ # as skills, context management, loop detection, and delegation stay inactive
46
+ # until their DSL is used.
47
+ #
48
+ # Calling LittleGhost::Agent itself uses <tt>You are a helpful agent.</tt> as
49
+ # the system prompt. Subclasses use the inline or conventional prompt they
50
+ # declare.
51
+ #
52
+ # Models may return text or locally validated structured data. LittleGhost
53
+ # hides unexpected Tool exception messages from the model. See
54
+ # Run[rdoc-ref:LittleGhost::Run] for outcomes, cancellation, and cleanup, and
55
+ # Assembly[rdoc-ref:LittleGhost::Assembly] for the advanced run-scoped form.
56
+ class Agent < Assembly
52
57
  DEFAULT_SYSTEM_PROMPT = "You are a helpful agent." # :nodoc:
53
58
  DEFAULT_MAX_TOOL_RESULT_TOKENS = 10_000 # :nodoc:
54
59
  MAX_STRUCTURED_RESULT_BYTES = 1_000_000 # :nodoc:
@@ -64,11 +69,13 @@ module LittleGhost
64
69
  before_model after_model after_model_error
65
70
  before_tool after_tool
66
71
  ].freeze # :nodoc:
72
+ ExecutedTool = Data.define(:result, :companion_content) do # :nodoc:
73
+ def status = result.status
74
+ def content = result.content
75
+ end
67
76
 
68
77
  extend Support::ClassAttributes
69
78
 
70
- class_attribute :agent_id_value
71
- class_attribute :description_value
72
79
  class_attribute :model_value
73
80
  class_attribute :limits_value, default: {}
74
81
  class_attribute :result_schema_value
@@ -81,27 +88,6 @@ module LittleGhost
81
88
  class_attribute :callback_values, default: Support::Callbacks.new(*CALLBACKS)
82
89
 
83
90
  class << self
84
- # Executes +message+ through a fresh standalone entrypoint and returns the
85
- # completed LittleGhost::Run. Invocation +options+ are forwarded to #ask.
86
- #
87
- # Create an instance explicitly when reusing a Runtime or streaming events.
88
- def ask(message, **options)
89
- new.ask(message, **options)
90
- end
91
-
92
- # Streams +message+ through a fresh standalone entrypoint and returns an
93
- # Enumerator of LittleGhost::StreamEvent objects. Invocation +options+
94
- # are forwarded to #stream_ask.
95
- #
96
- # Create an instance explicitly when reusing one Runtime across calls.
97
- def stream_ask(message, **options)
98
- stream = nil
99
- Enumerator.new do |events|
100
- stream ||= new.stream_ask(message, **options)
101
- stream.each { |event| events << event }
102
- end
103
- end
104
-
105
91
  # :call-seq:
106
92
  # agent_id() -> String
107
93
  # agent_id(value) -> String
@@ -110,9 +96,9 @@ module LittleGhost
110
96
  # Named subclasses derive it from their underscored class name without an
111
97
  # +Agent+ suffix; passing +value+ replaces that default.
112
98
  def agent_id(*values)
113
- return agent_id_value || default_agent_id if values.empty?
99
+ return assembly_id if values.empty?
114
100
 
115
- self.agent_id_value = values.fetch(0).to_s
101
+ assembly_id(values.fetch(0))
116
102
  end
117
103
 
118
104
  # The underscored, namespace-aware path used for conventional prompt lookup.
@@ -122,17 +108,6 @@ module LittleGhost
122
108
  parts.reject(&:empty?).map { |part| underscore(part) }.join("/")
123
109
  end
124
110
 
125
- # :call-seq:
126
- # description() -> String
127
- # description(value) -> String
128
- #
129
- # The human-readable description shown when this agent is delegated.
130
- def description(*values)
131
- return description_value.to_s if values.empty?
132
-
133
- self.description_value = values.fetch(0).to_s
134
- end
135
-
136
111
  # :call-seq:
137
112
  # model() -> String, Symbol, Hash, Proc, nil
138
113
  # model(role_or_target) -> String, Symbol
@@ -189,9 +164,10 @@ module LittleGhost
189
164
  # strategy selection prefers provider-native structured output and falls
190
165
  # back to a terminal tool when supported.
191
166
  #
192
- # A missing or invalid result receives one repair attempt before
193
- # LittleGhost::StructuredResultError is raised. Invalid schemas and
194
- # strategies raise LittleGhost::ConfigurationError immediately.
167
+ # During execution, a missing or invalid result receives one repair attempt
168
+ # before LittleGhost::StructuredResultError is raised inside the owning Run.
169
+ # A top-level +ask+ records it on a failed Run. Invalid schemas and strategies
170
+ # raise LittleGhost::ConfigurationError before execution begins.
195
171
  def result_schema(schema = nil, name: nil, description: nil, strategy: :auto, **schema_keywords)
196
172
  return result_schema_value if schema.nil? && schema_keywords.empty? && name.nil? && description.nil? && strategy == :auto
197
173
 
@@ -489,26 +465,37 @@ module LittleGhost
489
465
  end
490
466
  end
491
467
 
492
- def default_agent_id
493
- value = name.to_s.split("::").last.to_s.gsub(/Agent\z/, "").gsub(/([a-z\d])([A-Z])/, "\\1_\\2").downcase
494
- (value.empty? ? "agent" : value).freeze
495
- end
496
-
497
468
  def underscore(value)
498
469
  value.gsub(/([a-z\d])([A-Z])/, "\\1_\\2").downcase
499
470
  end
500
471
  end
501
472
 
502
- # Run-scoped model, tools, lifecycle, delegation, and execution resources
503
- # available to agent extensions.
504
- attr_reader :model, :tool_registry, :run, :delegation_activity, :agent_path, :workspace, :sandbox,
505
- :max_tool_calls
473
+ # The resolved model used by this run-scoped Agent.
474
+ attr_reader :model
475
+ # Tools created and bound for this Agent's owning Run.
476
+ attr_reader :tool_registry
477
+ # The owning Run, or +nil+ for a standalone entrypoint.
478
+ attr_reader :run
479
+ # Shared delegation tracker, when subagents are enabled.
480
+ attr_reader :delegation_activity
481
+ # This Agent's location in the bounded subagent tree.
482
+ attr_reader :agent_path
483
+ # Run-scoped workspace available to Tools and extensions.
484
+ attr_reader :workspace
485
+ # Run-scoped sandbox used for filesystem and process operations.
486
+ attr_reader :sandbox
487
+ # Maximum Tool calls allowed during one invocation.
488
+ attr_reader :max_tool_calls
506
489
 
507
490
  # Creates either a standalone entrypoint or a run-scoped agent.
491
+ # :call-seq:
492
+ # new(runtime: nil) -> Agent
493
+ # new(model:, runtime:, tools:, run:, ...) -> Agent
508
494
  #
509
- # Calling <tt>new</tt> without +model+ and +run+ creates the standalone form
510
- # used by +ask+ and +stream_ask+. Runtime builders supply the remaining
511
- # dependencies and apply class-level limits and declarations.
495
+ # The first form is the application-facing entrypoint. It may be reused for
496
+ # independent concurrent calls and creates a fresh Run for each one. The
497
+ # second form is run-scoped; Runtime builders supply its dependencies and it
498
+ # must not outlive or be shared outside its owning Run.
512
499
  def initialize(
513
500
  model: nil,
514
501
  runtime: nil,
@@ -526,16 +513,14 @@ module LittleGhost
526
513
  workspace: nil,
527
514
  sandbox: nil
528
515
  )
529
- if model.nil? && run.nil?
530
- @standalone = true
531
- @runtime = runtime || Runtime.new(configuration: LittleGhost.configuration)
532
- @workspace = workspace
533
- @sandbox = sandbox
516
+ standalone = model.nil? && run.nil?
517
+ super(run:, runtime:, workspace:, sandbox:, standalone:)
518
+ if standalone
534
519
  @owns_resources = true
535
520
  @closed = false
536
521
  @close_mutex = Mutex.new
537
- @interruptions_mutex = Mutex.new
538
- @active_interruptions = []
522
+ @interjections_mutex = Mutex.new
523
+ @active_interjections = []
539
524
  return
540
525
  end
541
526
 
@@ -570,8 +555,12 @@ module LittleGhost
570
555
  @closed = false
571
556
  @close_mutex = Mutex.new
572
557
  @exclusive_tools_mutex = Mutex.new
573
- @interruptions_mutex = Mutex.new
574
- @active_interruptions = []
558
+ @interjections_mutex = Mutex.new
559
+ @active_interjections = []
560
+ @assembly_transitions_mutex = Mutex.new
561
+ @assembly_transitions = {}
562
+ @assembly_tool_batch_sizes = {}
563
+ @assembly_transition = nil
575
564
  raise ArgumentError, "max_turns must be at least 1" if @max_turns < 1
576
565
  raise ArgumentError, "max_tool_calls must be at least 1" if @max_tool_calls < 1
577
566
  raise ArgumentError, "max_tool_result_tokens must be at least 1" if @max_tool_result_tokens < 1
@@ -590,123 +579,85 @@ module LittleGhost
590
579
  execute_tools(tool_uses, context, events, parent_operation_id:, parent_trace_context:)
591
580
  end
592
581
 
593
- def build_run(payload) # :nodoc:
594
- options = {
595
- agent_class: self.class,
596
- entrypoint_class: self.class
597
- }
598
- options[:workspace] = workspace if workspace
599
- options[:sandbox] = sandbox if sandbox
600
- runtime.build_run(payload, **options)
601
- end
582
+ def request_assembly_transition(value, context:) # :nodoc:
583
+ @assembly_transitions_mutex.synchronize do
584
+ unless @assembly_tool_batch_sizes[context] == 1
585
+ raise ToolError, "An assembly transition must be the only tool call in a model response"
586
+ end
587
+ if @assembly_transitions.key?(context)
588
+ raise ProtocolError, "Multiple assembly transitions were requested in one agent turn"
589
+ end
602
590
 
603
- # Runs +input+ to completion.
604
- #
605
- # A standalone agent returns a LittleGhost::Run. An agent built inside a run
606
- # returns its LittleGhost::RunResult.
607
- def call(input = nil, **options)
608
- return build_run(entrypoint_payload(input, options)).call if @standalone
609
-
610
- result = nil
611
- stream(input, **options).each do |event|
612
- result = event.data[:result] if event.type == :invocation_stop
591
+ @assembly_transitions[context] = value
613
592
  end
614
- result
615
- end
616
-
617
- # Runs +message+ to completion through the standalone or run-scoped agent.
618
- def ask(message, **options)
619
- call(message, **options)
593
+ value
620
594
  end
621
595
 
622
- # Adds +message+ to one active invocation and waits for its ordinary text reply.
623
- #
624
- # The response may continue into tool calls; delivery does not stop the
625
- # original invocation. Raises LittleGhost::AgentInterruptError when there is
626
- # no unambiguous active target.
627
- def interrupt(
628
- message,
629
- cancellation_token: Support::CancellationToken.new,
630
- deadline: nil,
631
- target_operation_id: nil,
632
- interruption_id: nil,
633
- batch_key: nil,
634
- metadata: {}
635
- )
636
- interrupt_response(
637
- message,
638
- cancellation_token:,
639
- deadline:,
640
- target_operation_id:,
641
- interruption_id:,
642
- batch_key:,
643
- metadata:
644
- ).text
645
- end
596
+ attr_reader :assembly_transition # :nodoc:
646
597
 
647
- # Adds an interruption and returns the model's immediate response details.
598
+ # Adds an interjection and returns the model's immediate result details.
648
599
  #
649
600
  # Use +target_operation_id+ when an agent has multiple active invocations.
650
601
  # Messages may contain only text, image, or document content. The returned
651
- # response value exposes +text+, +tool_calls?+, +interruption_ids+, and
652
- # +batch_key+; tool calls may continue after this response. Depend on these
653
- # methods rather than the response's concrete class.
654
- def interrupt_response(
602
+ # result value exposes +text+, +tool_calls?+, +interjection_ids+, and
603
+ # +batch_key+; tool calls may continue after this result. Depend on these
604
+ # methods rather than the result's concrete class.
605
+ def interject(
655
606
  message,
656
607
  cancellation_token: Support::CancellationToken.new,
657
608
  deadline: nil,
658
609
  target_operation_id: nil,
659
- interruption_id: nil,
610
+ interjection_id: nil,
660
611
  batch_key: nil,
661
612
  metadata: {}
662
613
  )
663
614
  message = Message.new(role: :user, content: message) if message.is_a?(String)
664
- raise ArgumentError, "interrupt message must be a String or LittleGhost::Message" unless message.is_a?(Message)
615
+ raise ArgumentError, "interject message must be a String or LittleGhost::Message" unless message.is_a?(Message)
665
616
  safe_content = message.content.all? do |content|
666
617
  content.is_a?(Content::Text) ||
667
618
  content.is_a?(Content::Image) ||
668
619
  content.is_a?(Content::Document)
669
620
  end
670
621
  unless safe_content
671
- raise ArgumentError, "interrupt message content must contain only text, images, or documents"
622
+ raise ArgumentError, "interject message content must contain only text, images, or documents"
672
623
  end
673
624
 
674
- interruptions = @interruptions_mutex.synchronize do
625
+ interjections = @interjections_mutex.synchronize do
675
626
  active = if target_operation_id
676
- @active_interruptions.select { |candidate| candidate.target_operation_id == target_operation_id }
627
+ @active_interjections.select { |candidate| candidate.target_operation_id == target_operation_id }
677
628
  else
678
- @active_interruptions
629
+ @active_interjections
679
630
  end
680
631
  if active.empty?
681
- raise AgentInterruptError, "Agent is not currently running"
632
+ raise AgentInterjectionError, "Agent is not currently running"
682
633
  end
683
634
  if active.length > 1
684
- raise AgentInterruptError, "Agent has multiple active invocations; the interruption target is ambiguous"
635
+ raise AgentInterjectionError, "Agent has multiple active invocations; the interjection target is ambiguous"
685
636
  end
686
637
 
687
638
  active.first
688
639
  end
689
640
  options = {batch_key:, metadata:}
690
- options[:id] = interruption_id unless interruption_id.nil?
691
- ticket = interruptions.enqueue(message, **options)
641
+ options[:id] = interjection_id unless interjection_id.nil?
642
+ ticket = interjections.enqueue(message, **options)
692
643
  instrument(
693
- :agent_interrupt_queued,
694
- parent_operation_id: interruptions.operation_id,
695
- interruption_id: ticket.id,
696
- event_kind: :interrupt,
644
+ :agent_interjection_queued,
645
+ parent_operation_id: interjections.operation_id,
646
+ interjection_id: ticket.id,
647
+ event_kind: :interjection,
697
648
  diagnostic: {input: diagnostic_message(message)}
698
649
  )
699
650
  begin
700
651
  response = ticket.value(cancellation_token:, deadline:)
701
- interruptions.release(ticket)
652
+ interjections.release(ticket)
702
653
  response
703
654
  rescue => error
704
- interruptions.release(ticket, withdraw: true)
655
+ interjections.release(ticket, withdraw: true)
705
656
  instrument(
706
- :agent_interrupt_failed,
707
- parent_operation_id: interruptions.operation_id,
708
- interruption_id: ticket.id,
709
- event_kind: :interrupt,
657
+ :agent_interjection_failed,
658
+ parent_operation_id: interjections.operation_id,
659
+ interjection_id: ticket.id,
660
+ event_kind: :interjection,
710
661
  error_type: error.class.name,
711
662
  diagnostic: {exception: diagnostic_exception(error)}
712
663
  )
@@ -734,11 +685,11 @@ module LittleGhost
734
685
  parent_operation_id: nil,
735
686
  checkpoint: nil,
736
687
  conversation_id: nil,
737
- interruption_metadata: nil,
738
- interruption_ids: [],
739
- interrupt_ready: nil
688
+ interjection_metadata: nil,
689
+ interjection_ids: [],
690
+ interject_ready: nil
740
691
  )
741
- if @standalone
692
+ if standalone?
742
693
  raise ArgumentError, "input is required" if input.nil?
743
694
 
744
695
  return build_run(entrypoint_payload(input, {
@@ -746,7 +697,8 @@ module LittleGhost
746
697
  context:,
747
698
  settings:,
748
699
  template_paths:,
749
- deadline_at: deadline
700
+ deadline_at: deadline,
701
+ cancellation_token:
750
702
  }.compact)).each
751
703
  end
752
704
 
@@ -765,7 +717,7 @@ module LittleGhost
765
717
  end
766
718
  settings = @model_settings.merge(settings)
767
719
  Enumerator.new do |events|
768
- interruptions = AgentInterruptions.new
720
+ interjections = AgentInterjections.new
769
721
  run_context = RunContext.new(
770
722
  state: context,
771
723
  cancellation_token: cancellation_token,
@@ -773,8 +725,8 @@ module LittleGhost
773
725
  metadata: {agent_id: self.class.agent_id},
774
726
  checkpoint:,
775
727
  conversation_id:,
776
- interruption_metadata:,
777
- interruption_ids:
728
+ interjection_metadata:,
729
+ interjection_ids:
778
730
  )
779
731
  begin
780
732
  with_invocation(run_context) do
@@ -787,81 +739,18 @@ module LittleGhost
787
739
  template_paths: invocation_paths,
788
740
  events: events,
789
741
  parent_operation_id:,
790
- interruptions:,
791
- interrupt_ready:
742
+ interjections:,
743
+ interject_ready:
792
744
  )
793
745
  end
794
746
  rescue => error
795
- interruptions.close(error)
747
+ interjections.close(error)
796
748
  raise
797
749
  ensure
798
- interruptions.close(AgentInterruptError.new("Agent finished before the interruption was delivered"))
799
- unregister_interruptions(interruptions)
800
- end
801
- end
802
- end
803
-
804
- # Streams +message+ through the standalone or run-scoped agent.
805
- # Standalone +options+ become Invocation fields; run-scoped options are
806
- # forwarded to #stream.
807
- def stream_ask(message, **options)
808
- if @standalone
809
- options[:deadline_at] = options.delete(:deadline) if options.key?(:deadline)
810
- stream = nil
811
- return Enumerator.new do |events|
812
- stream ||= build_run(entrypoint_payload(message, options)).each
813
- stream.each { |event| events << event }
750
+ interjections.close(AgentInterjectionError.new("Agent finished before the interjection was delivered"))
751
+ unregister_interjections(interjections)
814
752
  end
815
753
  end
816
-
817
- stream(message, **options)
818
- end
819
-
820
- # Exposes this agent as a Tool instance.
821
- #
822
- # By default, each call starts with empty conversational history. Set
823
- # <tt>preserve_context: true</tt> to retain history serially between calls.
824
- def as_tool(name: self.class.agent_id, description: self.class.description, preserve_context: false)
825
- agent = self
826
- description = "Delegate a task to #{name}." if description.to_s.empty?
827
- mutex = Mutex.new
828
- retained_history = []
829
- tool_class = Tool.define(
830
- name: name,
831
- description: description,
832
- input_schema: {
833
- type: "object",
834
- properties: {input: {type: "string"}},
835
- required: ["input"],
836
- additionalProperties: false
837
- }
838
- ) do |input, context: nil|
839
- invocation = lambda do
840
- result = agent.call(
841
- input.fetch("input"),
842
- history: preserve_context ? retained_history : [],
843
- context: context&.state || {},
844
- cancellation_token: context&.cancellation_token || Support::CancellationToken.new,
845
- interruption_metadata: context&.interruption_metadata,
846
- interruption_ids: context&.interruption_ids || [],
847
- deadline: context&.deadline,
848
- parent_operation_id: run&.operation_id
849
- )
850
- retained_history.replace(result.messages.reject { |message| message.role == :system }) if preserve_context
851
- result.structured? ? result.structured_result.value : result.text
852
- end
853
- preserve_context ? mutex.synchronize(&invocation) : invocation.call
854
- end
855
- tool_class.define_method(:close) { agent.close }
856
- binding = Tool::Binding.new(
857
- agent: self,
858
- run:,
859
- runtime:,
860
- model:,
861
- workspace:,
862
- sandbox:
863
- )
864
- tool_class.new(binding:)
865
754
  end
866
755
 
867
756
  # Materializes and freezes the prompt locals declared on the agent class.
@@ -879,21 +768,21 @@ module LittleGhost
879
768
  # The materialized tools available during this agent run.
880
769
  def tools = tool_registry
881
770
 
882
- # Closes owned tools, interruptions, sandbox, and workspace resources.
771
+ # Closes owned tools, interjections, sandbox, and workspace resources.
883
772
  # The operation is idempotent and re-raises the first cleanup failure.
884
773
  def close
885
- resources, interruptions = @close_mutex.synchronize do
774
+ resources, interjections = @close_mutex.synchronize do
886
775
  return if @closed
887
776
 
888
777
  @closed = true
889
778
  [
890
779
  [tool_registry, (@sandbox if @owns_resources), (@workspace if @owns_resources)],
891
- @interruptions_mutex.synchronize { @active_interruptions.dup }
780
+ @interjections_mutex.synchronize { @active_interjections.dup }
892
781
  ]
893
782
  end
894
783
  first_error = nil
895
- interruptions.each do |active|
896
- active.close(AgentInterruptError.new("Agent was closed"))
784
+ interjections.each do |active|
785
+ active.close(AgentInterjectionError.new("Agent was closed"))
897
786
  end
898
787
  resources.each do |resource|
899
788
  resource.close if resource.respond_to?(:close)
@@ -935,45 +824,45 @@ module LittleGhost
935
824
  {message: input, **options}
936
825
  end
937
826
 
938
- def register_interruptions(interruptions)
827
+ def register_interjections(interjections)
939
828
  @close_mutex.synchronize do
940
829
  raise InvocationError, "Agent is closed" if @closed
941
830
 
942
- @interruptions_mutex.synchronize { @active_interruptions << interruptions }
831
+ @interjections_mutex.synchronize { @active_interjections << interjections }
943
832
  end
944
833
  end
945
834
 
946
- def unregister_interruptions(interruptions)
947
- @interruptions_mutex.synchronize { @active_interruptions.delete(interruptions) }
835
+ def unregister_interjections(interjections)
836
+ @interjections_mutex.synchronize { @active_interjections.delete(interjections) }
948
837
  end
949
838
 
950
- def interruption_message(interruption)
951
- source = interruption.message
839
+ def interjection_message(interjection)
840
+ source = interjection.message
952
841
  Message.new(
953
842
  role: :user,
954
843
  content: [
955
844
  Content::Text.new(text: <<~MESSAGE.strip),
956
- Agent interruption:
845
+ Agent interjection:
957
846
 
958
847
  Respond briefly in ordinary text before any tool calls, then continue the current task unless this
959
- interruption asks you to finish.
848
+ interjection asks you to finish.
960
849
  MESSAGE
961
850
  *source.content
962
851
  ],
963
- metadata: source.metadata.merge(interruption.metadata).merge(
964
- little_ghost_interruption_id: interruption.id,
965
- little_ghost_interruption_batch_key: interruption.batch_key,
966
- little_ghost_interruption_metadata: interruption.metadata
852
+ metadata: source.metadata.merge(interjection.metadata).merge(
853
+ little_ghost_interjection_id: interjection.id,
854
+ little_ghost_interjection_batch_key: interjection.batch_key,
855
+ little_ghost_interjection_metadata: interjection.metadata
967
856
  )
968
857
  )
969
858
  end
970
859
 
971
- def request_with_interruption(request, interruption)
860
+ def request_with_interjection(request, interjection)
972
861
  ModelRequest.new(
973
862
  messages: [
974
863
  *request.messages,
975
- *interruption.tickets.reject { |ticket| request_contains_interruption?(request, ticket) }
976
- .map { |ticket| interruption_message(ticket) }
864
+ *interjection.tickets.reject { |ticket| request_contains_interjection?(request, ticket) }
865
+ .map { |ticket| interjection_message(ticket) }
977
866
  ],
978
867
  tools: request.tools,
979
868
  settings: request.settings,
@@ -985,13 +874,24 @@ module LittleGhost
985
874
  )
986
875
  end
987
876
 
988
- def request_contains_interruption?(request, interruption)
877
+ def request_contains_interjection?(request, interjection)
989
878
  request.messages.any? do |message|
990
- (message.metadata[:little_ghost_interruption_id] ||
991
- message.metadata["little_ghost_interruption_id"]) == interruption.id
879
+ (message.metadata[:little_ghost_interjection_id] ||
880
+ message.metadata["little_ghost_interjection_id"]) == interjection.id
992
881
  end
993
882
  end
994
883
 
884
+ def consume_assembly_transition(context)
885
+ @assembly_transitions_mutex.synchronize { @assembly_transitions.delete(context) }
886
+ end
887
+
888
+ def with_assembly_tool_batch(context, size)
889
+ @assembly_transitions_mutex.synchronize { @assembly_tool_batch_sizes[context] = size }
890
+ yield
891
+ ensure
892
+ @assembly_transitions_mutex.synchronize { @assembly_tool_batch_sizes.delete(context) }
893
+ end
894
+
995
895
  def execute(
996
896
  input,
997
897
  history:,
@@ -1001,15 +901,16 @@ module LittleGhost
1001
901
  template_paths:,
1002
902
  events:,
1003
903
  parent_operation_id:,
1004
- interruptions:,
1005
- interrupt_ready:
904
+ interjections:,
905
+ interject_ready:
1006
906
  )
1007
907
  started_at = monotonic_time
1008
908
  operation_id = SecureRandom.uuid
1009
909
  context.bind_agent_operation_id(operation_id)
1010
- interruptions.bind(operation_id, target_operation_id: parent_operation_id)
1011
- register_interruptions(interruptions)
1012
- interrupt_ready&.call
910
+ @assembly_transitions_mutex.synchronize { @assembly_transition = nil }
911
+ interjections.bind(operation_id, target_operation_id: parent_operation_id)
912
+ register_interjections(interjections)
913
+ interject_ready&.call
1013
914
  agent_handle = start_instrumentation(
1014
915
  :agent,
1015
916
  parent: parent_operation_id || active_instrumentation_parent,
@@ -1040,7 +941,7 @@ module LittleGhost
1040
941
  )
1041
942
  begin
1042
943
  context.check!
1043
- response, interrupted = invoke_model(
944
+ response, interjected = invoke_model(
1044
945
  messages,
1045
946
  context,
1046
947
  settings,
@@ -1048,8 +949,9 @@ module LittleGhost
1048
949
  events,
1049
950
  parent_operation_id: turn_operation_id,
1050
951
  structured_result_repair_due:,
1051
- interruptions:
952
+ interjections:
1052
953
  )
954
+ messages.reject! { |message| tool_companion_message?(message) }
1053
955
  messages << response.message
1054
956
  tool_uses = response.message.content.grep(Content::ToolUse)
1055
957
  result_tool_uses = structured_result_tool_uses(tool_uses)
@@ -1062,12 +964,12 @@ module LittleGhost
1062
964
  )
1063
965
  unless validation_error
1064
966
  messages[-1] = redact_structured_result_message(response.message)
1065
- unless interruptions.finish
967
+ unless interjections.finish
1066
968
  context.checkpoint(messages)
1067
969
  finish_instrumentation(
1068
970
  turn_handle,
1069
971
  operation_id: turn_operation_id,
1070
- outcome: :interrupted,
972
+ outcome: :interjected,
1071
973
  turn: turn + 1
1072
974
  )
1073
975
  next
@@ -1122,20 +1024,20 @@ module LittleGhost
1122
1024
  raise OutputLimitError, "The model stopped before completing its response"
1123
1025
  end
1124
1026
 
1125
- if interrupted || !@structured_output_strategy
1126
- unless interruptions.finish
1027
+ if interjected || !@structured_output_strategy
1028
+ unless interjections.finish
1127
1029
  context.checkpoint(messages)
1128
1030
  finish_instrumentation(
1129
1031
  turn_handle,
1130
1032
  operation_id: turn_operation_id,
1131
- outcome: :interrupted,
1033
+ outcome: :interjected,
1132
1034
  turn: turn + 1
1133
1035
  )
1134
1036
  next
1135
1037
  end
1136
1038
  end
1137
1039
 
1138
- if @structured_output_strategy && !interrupted
1040
+ if @structured_output_strategy && !interjected
1139
1041
  validation_error = if @structured_output_strategy.provider?
1140
1042
  capture_structured_result(response.message.text, context)
1141
1043
  else
@@ -1143,12 +1045,12 @@ module LittleGhost
1143
1045
  end
1144
1046
  unless validation_error
1145
1047
  messages[-1] = redact_structured_result_message(response.message)
1146
- unless interruptions.finish
1048
+ unless interjections.finish
1147
1049
  context.checkpoint(messages)
1148
1050
  finish_instrumentation(
1149
1051
  turn_handle,
1150
1052
  operation_id: turn_operation_id,
1151
- outcome: :interrupted,
1053
+ outcome: :interjected,
1152
1054
  turn: turn + 1
1153
1055
  )
1154
1056
  next
@@ -1241,17 +1143,61 @@ module LittleGhost
1241
1143
  tool_call_count += tool_uses.length
1242
1144
  raise ProtocolError, "The agent reached its maximum tool calls" if tool_call_count > @max_tool_calls
1243
1145
 
1244
- tool_results = dispatch_tools(
1245
- tool_uses,
1246
- context:,
1247
- events:,
1248
- parent_operation_id: turn_operation_id
1249
- )
1146
+ executed_tools = with_assembly_tool_batch(context, tool_uses.length) do
1147
+ dispatch_tools(
1148
+ tool_uses,
1149
+ context:,
1150
+ events:,
1151
+ parent_operation_id: turn_operation_id
1152
+ )
1153
+ end
1250
1154
  messages << Message.new(
1251
1155
  role: :tool,
1252
- content: tool_results
1156
+ content: executed_tools.map(&:result)
1253
1157
  )
1158
+ executed_tools.each do |executed_tool|
1159
+ next if executed_tool.companion_content.empty?
1160
+
1161
+ messages << Message.new(
1162
+ role: :user,
1163
+ content: executed_tool.companion_content,
1164
+ metadata: {transient: true, little_ghost_tool_companion: true}
1165
+ )
1166
+ end
1254
1167
  context.checkpoint(messages)
1168
+ transition = consume_assembly_transition(context)
1169
+ if transition
1170
+ result = RunResult.new(
1171
+ message: response.message,
1172
+ stop_reason: :assembly_transition,
1173
+ usage: context.usage,
1174
+ messages: messages.freeze,
1175
+ state: context.state
1176
+ )
1177
+ decision = run_callbacks(:after_invocation, {result:}, context:)
1178
+ apply_cancellation_decision!(decision)
1179
+ result = replacement_value(decision, :result, result)
1180
+ @assembly_transition = transition
1181
+ context.checkpoint(result.messages)
1182
+ finish_instrumentation(
1183
+ turn_handle,
1184
+ operation_id: turn_operation_id,
1185
+ outcome: :completed,
1186
+ turn: turn + 1
1187
+ )
1188
+ metadata = model.details.to_h.merge(model_role: model.role)
1189
+ finish_instrumentation(
1190
+ agent_handle,
1191
+ outcome: :completed,
1192
+ duration_ms: duration_ms(started_at),
1193
+ stop_reason: result.stop_reason,
1194
+ operation_id:,
1195
+ diagnostic: {output: diagnostic_message(result.message)},
1196
+ **usage_attributes(result.usage)
1197
+ )
1198
+ emit(events, :invocation_stop, result:, metadata:)
1199
+ return result
1200
+ end
1255
1201
  finish_instrumentation(
1256
1202
  turn_handle,
1257
1203
  operation_id: turn_operation_id,
@@ -1287,6 +1233,10 @@ module LittleGhost
1287
1233
  end
1288
1234
  raise ProtocolError, "The agent reached its maximum model turns"
1289
1235
  rescue => error
1236
+ @assembly_transitions_mutex.synchronize do
1237
+ @assembly_transitions.delete(context)
1238
+ @assembly_tool_batch_sizes.delete(context)
1239
+ end
1290
1240
  finish_instrumentation(
1291
1241
  agent_handle,
1292
1242
  operation_id:,
@@ -1308,10 +1258,10 @@ module LittleGhost
1308
1258
  turn,
1309
1259
  events,
1310
1260
  parent_operation_id:,
1311
- interruptions:,
1261
+ interjections:,
1312
1262
  structured_result_repair_due: false,
1313
1263
  recovery_attempt: 0,
1314
- interruption: nil
1264
+ interjection: nil
1315
1265
  )
1316
1266
  started_at = monotonic_time
1317
1267
  operation_id = SecureRandom.uuid
@@ -1328,9 +1278,9 @@ module LittleGhost
1328
1278
  cancellation_token: context.cancellation_token,
1329
1279
  deadline: context.deadline
1330
1280
  )
1331
- interruption ||= interruptions.deliver
1332
- if interruption
1333
- context.activate_interruption(metadata: interruption.metadata, ids: interruption.interruption_ids)
1281
+ interjection ||= interjections.deliver
1282
+ if interjection
1283
+ context.activate_interjection(metadata: interjection.metadata, ids: interjection.interjection_ids)
1334
1284
  end
1335
1285
  decision = run_callbacks(
1336
1286
  :before_model,
@@ -1339,11 +1289,11 @@ module LittleGhost
1339
1289
  )
1340
1290
  apply_cancellation_decision!(decision)
1341
1291
  request = replacement_value(decision, :request, request)
1342
- interruption_delivered = interruption&.tickets&.any? do |ticket|
1343
- !request_contains_interruption?(request, ticket)
1292
+ interjection_delivered = interjection&.tickets&.any? do |ticket|
1293
+ !request_contains_interjection?(request, ticket)
1344
1294
  end
1345
- if interruption_delivered
1346
- request = request_with_interruption(request, interruption)
1295
+ if interjection_delivered
1296
+ request = request_with_interjection(request, interjection)
1347
1297
  end
1348
1298
  messages.replace(request.messages)
1349
1299
  context.checkpoint(messages)
@@ -1358,20 +1308,20 @@ module LittleGhost
1358
1308
  model_settings: request.settings,
1359
1309
  **model_attributes
1360
1310
  )
1361
- if interruption_delivered
1362
- interruption.tickets.each do |ticket|
1311
+ if interjection_delivered
1312
+ interjection.tickets.each do |ticket|
1363
1313
  instrument(
1364
- :agent_interrupt_delivered,
1314
+ :agent_interjection_delivered,
1365
1315
  parent_operation_id: operation_id,
1366
- interruption_id: ticket.id,
1367
- event_kind: :interrupt
1316
+ interjection_id: ticket.id,
1317
+ event_kind: :interjection
1368
1318
  )
1369
1319
  end
1370
1320
  emit(
1371
1321
  events,
1372
- :agent_interrupt_delivered,
1373
- interruption_ids: interruption.interruption_ids,
1374
- batch_key: interruption.batch_key
1322
+ :agent_interjection_delivered,
1323
+ interjection_ids: interjection.interjection_ids,
1324
+ batch_key: interjection.batch_key
1375
1325
  )
1376
1326
  end
1377
1327
  emit(events, :model_start, turn: turn)
@@ -1409,21 +1359,21 @@ module LittleGhost
1409
1359
  decision = run_callbacks(:after_model, {request: request, response: response, turn: turn}, context: context)
1410
1360
  apply_cancellation_decision!(decision)
1411
1361
  response = replacement_value(decision, :response, response)
1412
- interruptions.resolve(
1413
- interruption,
1414
- AgentInterruptions::Response.new(
1362
+ interjections.resolve(
1363
+ interjection,
1364
+ AgentInterjections::Result.new(
1415
1365
  text: response.message.text,
1416
1366
  tool_calls: response.message.content.any? { |content| content.is_a?(Content::ToolUse) },
1417
- interruption_ids: interruption&.interruption_ids || [],
1418
- batch_key: interruption&.batch_key
1367
+ interjection_ids: interjection&.interjection_ids || [],
1368
+ batch_key: interjection&.batch_key
1419
1369
  )
1420
1370
  )
1421
- interruption&.tickets&.each do |ticket|
1371
+ interjection&.tickets&.each do |ticket|
1422
1372
  instrument(
1423
- :agent_interrupt_responded,
1373
+ :agent_interjection_responded,
1424
1374
  parent_operation_id: operation_id,
1425
- interruption_id: ticket.id,
1426
- event_kind: :interrupt,
1375
+ interjection_id: ticket.id,
1376
+ event_kind: :interjection,
1427
1377
  diagnostic: {output: response.message.text}
1428
1378
  )
1429
1379
  end
@@ -1472,7 +1422,7 @@ module LittleGhost
1472
1422
  repair: structured_result_repair_due
1473
1423
  )
1474
1424
  )
1475
- [response, !interruption.nil?]
1425
+ [response, !interjection.nil?]
1476
1426
  rescue => error
1477
1427
  finish_instrumentation(
1478
1428
  model_handle,
@@ -1508,8 +1458,8 @@ module LittleGhost
1508
1458
  parent_operation_id:,
1509
1459
  structured_result_repair_due:,
1510
1460
  recovery_attempt: recovery_attempt + 1,
1511
- interruptions:,
1512
- interruption:
1461
+ interjections:,
1462
+ interjection:
1513
1463
  )
1514
1464
  end
1515
1465
  end
@@ -1560,7 +1510,7 @@ module LittleGhost
1560
1510
  exception: diagnostic_tool_exception(tool, tool:)
1561
1511
  }
1562
1512
  )
1563
- next result
1513
+ next ExecutedTool.new(result:, companion_content: [])
1564
1514
  end
1565
1515
 
1566
1516
  context.check!
@@ -1592,7 +1542,7 @@ module LittleGhost
1592
1542
  exception: diagnostic_tool_exception(rejection, tool:)
1593
1543
  }
1594
1544
  )
1595
- next result
1545
+ next ExecutedTool.new(result:, companion_content: [])
1596
1546
  end
1597
1547
 
1598
1548
  execution_context = ToolExecution.new(
@@ -1639,7 +1589,7 @@ module LittleGhost
1639
1589
  exception: tool_error && diagnostic_tool_exception(tool_error, tool:)
1640
1590
  }.compact
1641
1591
  )
1642
- result
1592
+ ExecutedTool.new(result:, companion_content: tool_result.companion_content)
1643
1593
  rescue ToolError => error
1644
1594
  result = build_tool_result(tool_use_id: tool_use.id, content: error.message, status: :error)
1645
1595
  finish_instrumentation(
@@ -1655,7 +1605,7 @@ module LittleGhost
1655
1605
  exception: diagnostic_tool_exception(error, tool:)
1656
1606
  }
1657
1607
  )
1658
- result
1608
+ ExecutedTool.new(result:, companion_content: [])
1659
1609
  rescue => error
1660
1610
  finish_instrumentation(
1661
1611
  tool_handle,
@@ -1671,16 +1621,16 @@ module LittleGhost
1671
1621
  end
1672
1622
  if tools.any?(&:exclusive?)
1673
1623
  pairs.map do |tool_use, tool|
1674
- result = execution.call(tool_use, tool)
1675
- emit(events, :tool_stop, tool_use:, result:)
1676
- result
1624
+ executed_tool = execution.call(tool_use, tool)
1625
+ emit(events, :tool_stop, tool_use:, result: executed_tool.result)
1626
+ executed_tool
1677
1627
  end
1678
1628
  else
1679
1629
  @executor.map(
1680
1630
  pairs,
1681
1631
  cancellation_token: context.cancellation_token,
1682
- on_result: lambda do |index, result|
1683
- emit(events, :tool_stop, tool_use: tool_uses.fetch(index), result:)
1632
+ on_result: lambda do |index, executed_tool|
1633
+ emit(events, :tool_stop, tool_use: tool_uses.fetch(index), result: executed_tool.result)
1684
1634
  end
1685
1635
  ) do |tool_use, tool|
1686
1636
  execution.call(tool_use, tool)
@@ -2136,6 +2086,10 @@ module LittleGhost
2136
2086
  value.respond_to?(:role) ? diagnostic_message(value) : value.to_s
2137
2087
  end
2138
2088
 
2089
+ def tool_companion_message?(message)
2090
+ message.metadata[:little_ghost_tool_companion] || message.metadata["little_ghost_tool_companion"]
2091
+ end
2092
+
2139
2093
  def diagnostic_tool_exception(error, tool:)
2140
2094
  diagnostic_exception(error).merge(message: truncated_tool_result(error.message))
2141
2095
  end