little_ghost 0.2.0 → 0.3.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.
@@ -1,15 +1,17 @@
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
+ # A customer support agent can look up an account itself and give longer
14
+ # investigations to a research specialist:
13
15
  #
14
16
  # class ResearchAgent < LittleGhost::Agent
15
17
  # description "Researches transfer failures"
@@ -48,7 +50,7 @@ module LittleGhost
48
50
  # Tool failures are sanitized before returning to the model, diagnostic
49
51
  # capture can be disabled for sensitive agents, and cancellation, deadlines,
50
52
  # and cleanup failures remain framework control flow.
51
- class Agent
53
+ class Agent < Assembly
52
54
  DEFAULT_SYSTEM_PROMPT = "You are a helpful agent." # :nodoc:
53
55
  DEFAULT_MAX_TOOL_RESULT_TOKENS = 10_000 # :nodoc:
54
56
  MAX_STRUCTURED_RESULT_BYTES = 1_000_000 # :nodoc:
@@ -64,11 +66,13 @@ module LittleGhost
64
66
  before_model after_model after_model_error
65
67
  before_tool after_tool
66
68
  ].freeze # :nodoc:
69
+ ExecutedTool = Data.define(:result, :companion_content) do # :nodoc:
70
+ def status = result.status
71
+ def content = result.content
72
+ end
67
73
 
68
74
  extend Support::ClassAttributes
69
75
 
70
- class_attribute :agent_id_value
71
- class_attribute :description_value
72
76
  class_attribute :model_value
73
77
  class_attribute :limits_value, default: {}
74
78
  class_attribute :result_schema_value
@@ -81,27 +85,6 @@ module LittleGhost
81
85
  class_attribute :callback_values, default: Support::Callbacks.new(*CALLBACKS)
82
86
 
83
87
  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
88
  # :call-seq:
106
89
  # agent_id() -> String
107
90
  # agent_id(value) -> String
@@ -110,9 +93,9 @@ module LittleGhost
110
93
  # Named subclasses derive it from their underscored class name without an
111
94
  # +Agent+ suffix; passing +value+ replaces that default.
112
95
  def agent_id(*values)
113
- return agent_id_value || default_agent_id if values.empty?
96
+ return assembly_id if values.empty?
114
97
 
115
- self.agent_id_value = values.fetch(0).to_s
98
+ assembly_id(values.fetch(0))
116
99
  end
117
100
 
118
101
  # The underscored, namespace-aware path used for conventional prompt lookup.
@@ -122,17 +105,6 @@ module LittleGhost
122
105
  parts.reject(&:empty?).map { |part| underscore(part) }.join("/")
123
106
  end
124
107
 
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
108
  # :call-seq:
137
109
  # model() -> String, Symbol, Hash, Proc, nil
138
110
  # model(role_or_target) -> String, Symbol
@@ -489,11 +461,6 @@ module LittleGhost
489
461
  end
490
462
  end
491
463
 
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
464
  def underscore(value)
498
465
  value.gsub(/([a-z\d])([A-Z])/, "\\1_\\2").downcase
499
466
  end
@@ -526,11 +493,9 @@ module LittleGhost
526
493
  workspace: nil,
527
494
  sandbox: nil
528
495
  )
529
- if model.nil? && run.nil?
530
- @standalone = true
531
- @runtime = runtime || Runtime.new(configuration: LittleGhost.configuration)
532
- @workspace = workspace
533
- @sandbox = sandbox
496
+ standalone = model.nil? && run.nil?
497
+ super(run:, runtime:, workspace:, sandbox:, standalone:)
498
+ if standalone
534
499
  @owns_resources = true
535
500
  @closed = false
536
501
  @close_mutex = Mutex.new
@@ -572,6 +537,10 @@ module LittleGhost
572
537
  @exclusive_tools_mutex = Mutex.new
573
538
  @interruptions_mutex = Mutex.new
574
539
  @active_interruptions = []
540
+ @assembly_transitions_mutex = Mutex.new
541
+ @assembly_transitions = {}
542
+ @assembly_tool_batch_sizes = {}
543
+ @assembly_transition = nil
575
544
  raise ArgumentError, "max_turns must be at least 1" if @max_turns < 1
576
545
  raise ArgumentError, "max_tool_calls must be at least 1" if @max_tool_calls < 1
577
546
  raise ArgumentError, "max_tool_result_tokens must be at least 1" if @max_tool_result_tokens < 1
@@ -590,34 +559,21 @@ module LittleGhost
590
559
  execute_tools(tool_uses, context, events, parent_operation_id:, parent_trace_context:)
591
560
  end
592
561
 
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
562
+ def request_assembly_transition(value, context:) # :nodoc:
563
+ @assembly_transitions_mutex.synchronize do
564
+ unless @assembly_tool_batch_sizes[context] == 1
565
+ raise ToolError, "An assembly transition must be the only tool call in a model response"
566
+ end
567
+ if @assembly_transitions.key?(context)
568
+ raise ProtocolError, "Multiple assembly transitions were requested in one agent turn"
569
+ end
602
570
 
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
571
+ @assembly_transitions[context] = value
613
572
  end
614
- result
573
+ value
615
574
  end
616
575
 
617
- # Runs +message+ to completion through the standalone or run-scoped agent.
618
- def ask(message, **options)
619
- call(message, **options)
620
- end
576
+ attr_reader :assembly_transition # :nodoc:
621
577
 
622
578
  # Adds +message+ to one active invocation and waits for its ordinary text reply.
623
579
  #
@@ -738,7 +694,7 @@ module LittleGhost
738
694
  interruption_ids: [],
739
695
  interrupt_ready: nil
740
696
  )
741
- if @standalone
697
+ if standalone?
742
698
  raise ArgumentError, "input is required" if input.nil?
743
699
 
744
700
  return build_run(entrypoint_payload(input, {
@@ -746,7 +702,8 @@ module LittleGhost
746
702
  context:,
747
703
  settings:,
748
704
  template_paths:,
749
- deadline_at: deadline
705
+ deadline_at: deadline,
706
+ cancellation_token:
750
707
  }.compact)).each
751
708
  end
752
709
 
@@ -801,69 +758,6 @@ module LittleGhost
801
758
  end
802
759
  end
803
760
 
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 }
814
- end
815
- 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
- end
866
-
867
761
  # Materializes and freezes the prompt locals declared on the agent class.
868
762
  def prompt_locals
869
763
  self.class.prompt_local_resolvers.to_h do |name, resolver|
@@ -992,6 +886,17 @@ module LittleGhost
992
886
  end
993
887
  end
994
888
 
889
+ def consume_assembly_transition(context)
890
+ @assembly_transitions_mutex.synchronize { @assembly_transitions.delete(context) }
891
+ end
892
+
893
+ def with_assembly_tool_batch(context, size)
894
+ @assembly_transitions_mutex.synchronize { @assembly_tool_batch_sizes[context] = size }
895
+ yield
896
+ ensure
897
+ @assembly_transitions_mutex.synchronize { @assembly_tool_batch_sizes.delete(context) }
898
+ end
899
+
995
900
  def execute(
996
901
  input,
997
902
  history:,
@@ -1007,6 +912,7 @@ module LittleGhost
1007
912
  started_at = monotonic_time
1008
913
  operation_id = SecureRandom.uuid
1009
914
  context.bind_agent_operation_id(operation_id)
915
+ @assembly_transitions_mutex.synchronize { @assembly_transition = nil }
1010
916
  interruptions.bind(operation_id, target_operation_id: parent_operation_id)
1011
917
  register_interruptions(interruptions)
1012
918
  interrupt_ready&.call
@@ -1050,6 +956,7 @@ module LittleGhost
1050
956
  structured_result_repair_due:,
1051
957
  interruptions:
1052
958
  )
959
+ messages.reject! { |message| tool_companion_message?(message) }
1053
960
  messages << response.message
1054
961
  tool_uses = response.message.content.grep(Content::ToolUse)
1055
962
  result_tool_uses = structured_result_tool_uses(tool_uses)
@@ -1241,17 +1148,61 @@ module LittleGhost
1241
1148
  tool_call_count += tool_uses.length
1242
1149
  raise ProtocolError, "The agent reached its maximum tool calls" if tool_call_count > @max_tool_calls
1243
1150
 
1244
- tool_results = dispatch_tools(
1245
- tool_uses,
1246
- context:,
1247
- events:,
1248
- parent_operation_id: turn_operation_id
1249
- )
1151
+ executed_tools = with_assembly_tool_batch(context, tool_uses.length) do
1152
+ dispatch_tools(
1153
+ tool_uses,
1154
+ context:,
1155
+ events:,
1156
+ parent_operation_id: turn_operation_id
1157
+ )
1158
+ end
1250
1159
  messages << Message.new(
1251
1160
  role: :tool,
1252
- content: tool_results
1161
+ content: executed_tools.map(&:result)
1253
1162
  )
1163
+ executed_tools.each do |executed_tool|
1164
+ next if executed_tool.companion_content.empty?
1165
+
1166
+ messages << Message.new(
1167
+ role: :user,
1168
+ content: executed_tool.companion_content,
1169
+ metadata: {transient: true, little_ghost_tool_companion: true}
1170
+ )
1171
+ end
1254
1172
  context.checkpoint(messages)
1173
+ transition = consume_assembly_transition(context)
1174
+ if transition
1175
+ result = RunResult.new(
1176
+ message: response.message,
1177
+ stop_reason: :assembly_transition,
1178
+ usage: context.usage,
1179
+ messages: messages.freeze,
1180
+ state: context.state
1181
+ )
1182
+ decision = run_callbacks(:after_invocation, {result:}, context:)
1183
+ apply_cancellation_decision!(decision)
1184
+ result = replacement_value(decision, :result, result)
1185
+ @assembly_transition = transition
1186
+ context.checkpoint(result.messages)
1187
+ finish_instrumentation(
1188
+ turn_handle,
1189
+ operation_id: turn_operation_id,
1190
+ outcome: :completed,
1191
+ turn: turn + 1
1192
+ )
1193
+ metadata = model.details.to_h.merge(model_role: model.role)
1194
+ finish_instrumentation(
1195
+ agent_handle,
1196
+ outcome: :completed,
1197
+ duration_ms: duration_ms(started_at),
1198
+ stop_reason: result.stop_reason,
1199
+ operation_id:,
1200
+ diagnostic: {output: diagnostic_message(result.message)},
1201
+ **usage_attributes(result.usage)
1202
+ )
1203
+ emit(events, :invocation_stop, result:, metadata:)
1204
+ return result
1205
+ end
1255
1206
  finish_instrumentation(
1256
1207
  turn_handle,
1257
1208
  operation_id: turn_operation_id,
@@ -1287,6 +1238,10 @@ module LittleGhost
1287
1238
  end
1288
1239
  raise ProtocolError, "The agent reached its maximum model turns"
1289
1240
  rescue => error
1241
+ @assembly_transitions_mutex.synchronize do
1242
+ @assembly_transitions.delete(context)
1243
+ @assembly_tool_batch_sizes.delete(context)
1244
+ end
1290
1245
  finish_instrumentation(
1291
1246
  agent_handle,
1292
1247
  operation_id:,
@@ -1560,7 +1515,7 @@ module LittleGhost
1560
1515
  exception: diagnostic_tool_exception(tool, tool:)
1561
1516
  }
1562
1517
  )
1563
- next result
1518
+ next ExecutedTool.new(result:, companion_content: [])
1564
1519
  end
1565
1520
 
1566
1521
  context.check!
@@ -1592,7 +1547,7 @@ module LittleGhost
1592
1547
  exception: diagnostic_tool_exception(rejection, tool:)
1593
1548
  }
1594
1549
  )
1595
- next result
1550
+ next ExecutedTool.new(result:, companion_content: [])
1596
1551
  end
1597
1552
 
1598
1553
  execution_context = ToolExecution.new(
@@ -1639,7 +1594,7 @@ module LittleGhost
1639
1594
  exception: tool_error && diagnostic_tool_exception(tool_error, tool:)
1640
1595
  }.compact
1641
1596
  )
1642
- result
1597
+ ExecutedTool.new(result:, companion_content: tool_result.companion_content)
1643
1598
  rescue ToolError => error
1644
1599
  result = build_tool_result(tool_use_id: tool_use.id, content: error.message, status: :error)
1645
1600
  finish_instrumentation(
@@ -1655,7 +1610,7 @@ module LittleGhost
1655
1610
  exception: diagnostic_tool_exception(error, tool:)
1656
1611
  }
1657
1612
  )
1658
- result
1613
+ ExecutedTool.new(result:, companion_content: [])
1659
1614
  rescue => error
1660
1615
  finish_instrumentation(
1661
1616
  tool_handle,
@@ -1671,16 +1626,16 @@ module LittleGhost
1671
1626
  end
1672
1627
  if tools.any?(&:exclusive?)
1673
1628
  pairs.map do |tool_use, tool|
1674
- result = execution.call(tool_use, tool)
1675
- emit(events, :tool_stop, tool_use:, result:)
1676
- result
1629
+ executed_tool = execution.call(tool_use, tool)
1630
+ emit(events, :tool_stop, tool_use:, result: executed_tool.result)
1631
+ executed_tool
1677
1632
  end
1678
1633
  else
1679
1634
  @executor.map(
1680
1635
  pairs,
1681
1636
  cancellation_token: context.cancellation_token,
1682
- on_result: lambda do |index, result|
1683
- emit(events, :tool_stop, tool_use: tool_uses.fetch(index), result:)
1637
+ on_result: lambda do |index, executed_tool|
1638
+ emit(events, :tool_stop, tool_use: tool_uses.fetch(index), result: executed_tool.result)
1684
1639
  end
1685
1640
  ) do |tool_use, tool|
1686
1641
  execution.call(tool_use, tool)
@@ -2136,6 +2091,10 @@ module LittleGhost
2136
2091
  value.respond_to?(:role) ? diagnostic_message(value) : value.to_s
2137
2092
  end
2138
2093
 
2094
+ def tool_companion_message?(message)
2095
+ message.metadata[:little_ghost_tool_companion] || message.metadata["little_ghost_tool_companion"]
2096
+ end
2097
+
2139
2098
  def diagnostic_tool_exception(error, tool:)
2140
2099
  diagnostic_exception(error).merge(message: truncated_tool_result(error.message))
2141
2100
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LittleGhost
4
- class AgentBuilder # :nodoc: all
4
+ class AgentFactory # :nodoc: all
5
5
  class ActivityRelay
6
6
  def initialize
7
7
  @mutex = Mutex.new
@@ -92,8 +92,8 @@ module LittleGhost
92
92
 
93
93
  def agent_tools(agent_class, run)
94
94
  tools = []
95
- agent_class.agent_tool_declarations.each do |declaration|
96
- child = declared_agent(declaration, run)
95
+ agent_class.assembly_tool_declarations.each do |declaration|
96
+ child = declared_assembly(declaration, run)
97
97
  begin
98
98
  tools << child.as_tool(
99
99
  name: declaration.fetch(:name),
@@ -111,6 +111,17 @@ module LittleGhost
111
111
  raise
112
112
  end
113
113
 
114
+ def declared_assembly(declaration, run)
115
+ assembly_class = declaration.fetch(:assembly)
116
+ if assembly_class.is_a?(AssemblyDefinition) && assembly_class.kind == :agent
117
+ declared_agent(declaration.merge(agent: assembly_class.implementation), run)
118
+ elsif assembly_class <= Agent
119
+ declared_agent(declaration.merge(agent: assembly_class), run)
120
+ else
121
+ runtime.build_assembly(assembly_class, run:)
122
+ end
123
+ end
124
+
114
125
  def subagent_tools(agent_class, run, conversation_id:, delegation_activity:, agent_path:)
115
126
  definitions = agent_class.subagent_declarations.map do |declaration|
116
127
  Subagents::Definition.new(
@@ -159,7 +170,12 @@ module LittleGhost
159
170
  end
160
171
 
161
172
  def declared_agent(declaration, run, conversation_id: nil, agent_path: Subagents::AgentPath::ROOT)
162
- agent_class = @resolve_agent.call(declaration.fetch(:agent))
173
+ reference = declaration.fetch(:agent)
174
+ agent_class = if reference.is_a?(AssemblyDefinition)
175
+ reference.implementation
176
+ else
177
+ @resolve_agent.call(reference)
178
+ end
163
179
  build_agent(
164
180
  agent_class, run:,
165
181
  model: resolve(declaration[:model], run),
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "agent_builder"