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.
- checksums.yaml +4 -4
- data/README.md +72 -74
- data/docs/guides/assemblies.md +286 -0
- data/docs/guides/core_concepts.md +159 -135
- data/docs/guides/getting_started.md +114 -83
- data/docs/guides/production.md +187 -0
- data/docs/guides/prompt_views.md +132 -0
- data/lib/little_ghost/ag_ui/adapter.rb +3 -3
- data/lib/little_ghost/agent/delegation.rb +35 -8
- data/lib/little_ghost/agent/tool_loop.rb +2 -1
- data/lib/little_ghost/agent.rb +280 -326
- data/lib/little_ghost/agent_builder.rb +20 -4
- data/lib/little_ghost/agent_factory.rb +3 -0
- data/lib/little_ghost/{agent_interruptions.rb → agent_interjections.rb} +12 -12
- data/lib/little_ghost/assembly.rb +345 -0
- data/lib/little_ghost/assembly_builder.rb +497 -0
- data/lib/little_ghost/assembly_execution.rb +535 -0
- data/lib/little_ghost/configuration.rb +263 -39
- data/lib/little_ghost/content.rb +5 -5
- data/lib/little_ghost/data_map.rb +209 -0
- data/lib/little_ghost/errors.rb +10 -2
- data/lib/little_ghost/execution.rb +206 -0
- data/lib/little_ghost/graph.rb +930 -0
- data/lib/little_ghost/message.rb +4 -4
- data/lib/little_ghost/model_resolver.rb +2 -2
- data/lib/little_ghost/prompt_resolver.rb +2 -0
- data/lib/little_ghost/run.rb +190 -64
- data/lib/little_ghost/run_context.rb +33 -20
- data/lib/little_ghost/run_result.rb +22 -11
- data/lib/little_ghost/runtime/hook.rb +9 -4
- data/lib/little_ghost/runtime.rb +134 -36
- data/lib/little_ghost/sandbox.rb +1 -1
- data/lib/little_ghost/session.rb +12 -23
- data/lib/little_ghost/session_store.rb +9 -5
- data/lib/little_ghost/session_stores/agent_core_memory.rb +64 -56
- data/lib/little_ghost/session_stores/filesystem.rb +261 -0
- data/lib/little_ghost/session_stores/memory.rb +7 -0
- data/lib/little_ghost/subagents/manager.rb +42 -42
- data/lib/little_ghost/support/executor.rb +14 -2
- data/lib/little_ghost/support/loader.rb +2 -2
- data/lib/little_ghost/support.rb +15 -3
- data/lib/little_ghost/swarm.rb +439 -0
- data/lib/little_ghost/tool.rb +88 -20
- data/lib/little_ghost/tools/write_todos.rb +6 -1
- data/lib/little_ghost/tracing/open_telemetry.rb +14 -3
- data/lib/little_ghost/unrestricted_sandbox.rb +1 -1
- data/lib/little_ghost/version.rb +1 -1
- data/lib/little_ghost/workflow.rb +224 -90
- data/lib/little_ghost.rb +36 -25
- metadata +17 -5
data/lib/little_ghost/agent.rb
CHANGED
|
@@ -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
|
-
#
|
|
9
|
-
# Each subclass describes one application role with an inheritable Ruby
|
|
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
|
-
#
|
|
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 :
|
|
23
|
-
#
|
|
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
|
|
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
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
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
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
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
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
|
|
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
|
|
99
|
+
return assembly_id if values.empty?
|
|
114
100
|
|
|
115
|
-
|
|
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
|
-
#
|
|
193
|
-
# LittleGhost::StructuredResultError is raised
|
|
194
|
-
#
|
|
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
|
-
#
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
#
|
|
510
|
-
#
|
|
511
|
-
#
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
@
|
|
538
|
-
@
|
|
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
|
-
@
|
|
574
|
-
@
|
|
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
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
652
|
-
# +batch_key+; tool calls may continue after this
|
|
653
|
-
# methods rather than the
|
|
654
|
-
def
|
|
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
|
-
|
|
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, "
|
|
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, "
|
|
622
|
+
raise ArgumentError, "interject message content must contain only text, images, or documents"
|
|
672
623
|
end
|
|
673
624
|
|
|
674
|
-
|
|
625
|
+
interjections = @interjections_mutex.synchronize do
|
|
675
626
|
active = if target_operation_id
|
|
676
|
-
@
|
|
627
|
+
@active_interjections.select { |candidate| candidate.target_operation_id == target_operation_id }
|
|
677
628
|
else
|
|
678
|
-
@
|
|
629
|
+
@active_interjections
|
|
679
630
|
end
|
|
680
631
|
if active.empty?
|
|
681
|
-
raise
|
|
632
|
+
raise AgentInterjectionError, "Agent is not currently running"
|
|
682
633
|
end
|
|
683
634
|
if active.length > 1
|
|
684
|
-
raise
|
|
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] =
|
|
691
|
-
ticket =
|
|
641
|
+
options[:id] = interjection_id unless interjection_id.nil?
|
|
642
|
+
ticket = interjections.enqueue(message, **options)
|
|
692
643
|
instrument(
|
|
693
|
-
:
|
|
694
|
-
parent_operation_id:
|
|
695
|
-
|
|
696
|
-
event_kind: :
|
|
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
|
-
|
|
652
|
+
interjections.release(ticket)
|
|
702
653
|
response
|
|
703
654
|
rescue => error
|
|
704
|
-
|
|
655
|
+
interjections.release(ticket, withdraw: true)
|
|
705
656
|
instrument(
|
|
706
|
-
:
|
|
707
|
-
parent_operation_id:
|
|
708
|
-
|
|
709
|
-
event_kind: :
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
688
|
+
interjection_metadata: nil,
|
|
689
|
+
interjection_ids: [],
|
|
690
|
+
interject_ready: nil
|
|
740
691
|
)
|
|
741
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
777
|
-
|
|
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
|
-
|
|
791
|
-
|
|
742
|
+
interjections:,
|
|
743
|
+
interject_ready:
|
|
792
744
|
)
|
|
793
745
|
end
|
|
794
746
|
rescue => error
|
|
795
|
-
|
|
747
|
+
interjections.close(error)
|
|
796
748
|
raise
|
|
797
749
|
ensure
|
|
798
|
-
|
|
799
|
-
|
|
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,
|
|
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,
|
|
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
|
-
@
|
|
780
|
+
@interjections_mutex.synchronize { @active_interjections.dup }
|
|
892
781
|
]
|
|
893
782
|
end
|
|
894
783
|
first_error = nil
|
|
895
|
-
|
|
896
|
-
active.close(
|
|
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
|
|
827
|
+
def register_interjections(interjections)
|
|
939
828
|
@close_mutex.synchronize do
|
|
940
829
|
raise InvocationError, "Agent is closed" if @closed
|
|
941
830
|
|
|
942
|
-
@
|
|
831
|
+
@interjections_mutex.synchronize { @active_interjections << interjections }
|
|
943
832
|
end
|
|
944
833
|
end
|
|
945
834
|
|
|
946
|
-
def
|
|
947
|
-
@
|
|
835
|
+
def unregister_interjections(interjections)
|
|
836
|
+
@interjections_mutex.synchronize { @active_interjections.delete(interjections) }
|
|
948
837
|
end
|
|
949
838
|
|
|
950
|
-
def
|
|
951
|
-
source =
|
|
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
|
|
845
|
+
Agent interjection:
|
|
957
846
|
|
|
958
847
|
Respond briefly in ordinary text before any tool calls, then continue the current task unless this
|
|
959
|
-
|
|
848
|
+
interjection asks you to finish.
|
|
960
849
|
MESSAGE
|
|
961
850
|
*source.content
|
|
962
851
|
],
|
|
963
|
-
metadata: source.metadata.merge(
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
|
860
|
+
def request_with_interjection(request, interjection)
|
|
972
861
|
ModelRequest.new(
|
|
973
862
|
messages: [
|
|
974
863
|
*request.messages,
|
|
975
|
-
*
|
|
976
|
-
.map { |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
|
|
877
|
+
def request_contains_interjection?(request, interjection)
|
|
989
878
|
request.messages.any? do |message|
|
|
990
|
-
(message.metadata[:
|
|
991
|
-
message.metadata["
|
|
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
|
-
|
|
1005
|
-
|
|
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
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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: :
|
|
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
|
|
1126
|
-
unless
|
|
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: :
|
|
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 && !
|
|
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
|
|
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: :
|
|
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
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
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:
|
|
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
|
-
|
|
1261
|
+
interjections:,
|
|
1312
1262
|
structured_result_repair_due: false,
|
|
1313
1263
|
recovery_attempt: 0,
|
|
1314
|
-
|
|
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
|
-
|
|
1332
|
-
if
|
|
1333
|
-
context.
|
|
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
|
-
|
|
1343
|
-
!
|
|
1292
|
+
interjection_delivered = interjection&.tickets&.any? do |ticket|
|
|
1293
|
+
!request_contains_interjection?(request, ticket)
|
|
1344
1294
|
end
|
|
1345
|
-
if
|
|
1346
|
-
request =
|
|
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
|
|
1362
|
-
|
|
1311
|
+
if interjection_delivered
|
|
1312
|
+
interjection.tickets.each do |ticket|
|
|
1363
1313
|
instrument(
|
|
1364
|
-
:
|
|
1314
|
+
:agent_interjection_delivered,
|
|
1365
1315
|
parent_operation_id: operation_id,
|
|
1366
|
-
|
|
1367
|
-
event_kind: :
|
|
1316
|
+
interjection_id: ticket.id,
|
|
1317
|
+
event_kind: :interjection
|
|
1368
1318
|
)
|
|
1369
1319
|
end
|
|
1370
1320
|
emit(
|
|
1371
1321
|
events,
|
|
1372
|
-
:
|
|
1373
|
-
|
|
1374
|
-
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
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
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
|
-
|
|
1418
|
-
batch_key:
|
|
1367
|
+
interjection_ids: interjection&.interjection_ids || [],
|
|
1368
|
+
batch_key: interjection&.batch_key
|
|
1419
1369
|
)
|
|
1420
1370
|
)
|
|
1421
|
-
|
|
1371
|
+
interjection&.tickets&.each do |ticket|
|
|
1422
1372
|
instrument(
|
|
1423
|
-
:
|
|
1373
|
+
:agent_interjection_responded,
|
|
1424
1374
|
parent_operation_id: operation_id,
|
|
1425
|
-
|
|
1426
|
-
event_kind: :
|
|
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, !
|
|
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
|
-
|
|
1512
|
-
|
|
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
|
-
|
|
1675
|
-
emit(events, :tool_stop, tool_use:, result:)
|
|
1676
|
-
|
|
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,
|
|
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
|