little_ghost 0.1.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 +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +122 -0
- data/docs/guides/Core Concepts.md +203 -0
- data/docs/guides/Getting Started.md +187 -0
- data/lib/little_ghost/ag_ui/adapter.rb +194 -0
- data/lib/little_ghost/ag_ui.rb +5 -0
- data/lib/little_ghost/agent/context_management.rb +285 -0
- data/lib/little_ghost/agent/delegation.rb +128 -0
- data/lib/little_ghost/agent/skills.rb +96 -0
- data/lib/little_ghost/agent/tool_loop.rb +239 -0
- data/lib/little_ghost/agent.rb +2111 -0
- data/lib/little_ghost/agent_builder.rb +191 -0
- data/lib/little_ghost/agent_interruptions.rb +197 -0
- data/lib/little_ghost/configuration.rb +337 -0
- data/lib/little_ghost/content.rb +324 -0
- data/lib/little_ghost/default_model_registry.rb +71 -0
- data/lib/little_ghost/errors.rb +48 -0
- data/lib/little_ghost/events.rb +264 -0
- data/lib/little_ghost/execution_state.rb +58 -0
- data/lib/little_ghost/instrumentation.rb +475 -0
- data/lib/little_ghost/invocation.rb +285 -0
- data/lib/little_ghost/lookup.rb +37 -0
- data/lib/little_ghost/mcp/client.rb +396 -0
- data/lib/little_ghost/mcp.rb +5 -0
- data/lib/little_ghost/message.rb +75 -0
- data/lib/little_ghost/model.rb +88 -0
- data/lib/little_ghost/model_capabilities.rb +126 -0
- data/lib/little_ghost/model_registry.rb +173 -0
- data/lib/little_ghost/model_request.rb +107 -0
- data/lib/little_ghost/model_response.rb +48 -0
- data/lib/little_ghost/path_set.rb +32 -0
- data/lib/little_ghost/prompt_resolver.rb +251 -0
- data/lib/little_ghost/providers/bedrock.rb +506 -0
- data/lib/little_ghost/providers/http_transport.rb +149 -0
- data/lib/little_ghost/providers/open_router.rb +171 -0
- data/lib/little_ghost/providers/openai.rb +27 -0
- data/lib/little_ghost/providers/openai_compatible.rb +745 -0
- data/lib/little_ghost/providers/sse_parser.rb +35 -0
- data/lib/little_ghost/run.rb +607 -0
- data/lib/little_ghost/run_context.rb +129 -0
- data/lib/little_ghost/run_result.rb +111 -0
- data/lib/little_ghost/runtime/hook.rb +31 -0
- data/lib/little_ghost/runtime.rb +392 -0
- data/lib/little_ghost/sandbox.rb +138 -0
- data/lib/little_ghost/session.rb +229 -0
- data/lib/little_ghost/session_store.rb +96 -0
- data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
- data/lib/little_ghost/session_stores/memory.rb +86 -0
- data/lib/little_ghost/skills/catalog.rb +283 -0
- data/lib/little_ghost/skills/skill.rb +60 -0
- data/lib/little_ghost/skills.rb +4 -0
- data/lib/little_ghost/stream_event.rb +49 -0
- data/lib/little_ghost/structured_output.rb +126 -0
- data/lib/little_ghost/subagents/agent_path.rb +63 -0
- data/lib/little_ghost/subagents/definition.rb +42 -0
- data/lib/little_ghost/subagents/manager.rb +1615 -0
- data/lib/little_ghost/support/callbacks.rb +151 -0
- data/lib/little_ghost/support/cancellation_token.rb +86 -0
- data/lib/little_ghost/support/class_attributes.rb +40 -0
- data/lib/little_ghost/support/content_capture.rb +150 -0
- data/lib/little_ghost/support/executor.rb +75 -0
- data/lib/little_ghost/support/interruptible_stream.rb +103 -0
- data/lib/little_ghost/support/loader.rb +263 -0
- data/lib/little_ghost/support/output_truncation.rb +71 -0
- data/lib/little_ghost/support/redactor.rb +66 -0
- data/lib/little_ghost/support.rb +34 -0
- data/lib/little_ghost/tool.rb +448 -0
- data/lib/little_ghost/tool_execution.rb +59 -0
- data/lib/little_ghost/tool_registry.rb +156 -0
- data/lib/little_ghost/tools/filesystem.rb +119 -0
- data/lib/little_ghost/tools/shell.rb +45 -0
- data/lib/little_ghost/tools/write_todos.rb +91 -0
- data/lib/little_ghost/tools.rb +6 -0
- data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
- data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
- data/lib/little_ghost/usage.rb +47 -0
- data/lib/little_ghost/version.rb +6 -0
- data/lib/little_ghost/workflow.rb +351 -0
- data/lib/little_ghost/workspace.rb +31 -0
- data/lib/little_ghost.rb +120 -0
- metadata +225 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module LittleGhost
|
|
6
|
+
# AG-UI connects LittleGhost streams to user interfaces that speak the AG-UI
|
|
7
|
+
# protocol. Require +little_ghost/ag_ui+ to load this optional integration.
|
|
8
|
+
module AGUI
|
|
9
|
+
# Adapter turns a LittleGhost stream into AG-UI event hashes. It lets a Ruby
|
|
10
|
+
# agent drive compatible chat interfaces without changing the agent itself.
|
|
11
|
+
#
|
|
12
|
+
# events = CustomerSupportAgent.new.stream_ask("Where is my order?")
|
|
13
|
+
# adapter = LittleGhost::AGUI::Adapter.new
|
|
14
|
+
# adapter.stream(events, thread_id: "thread-1", run_id: "run-1").each do |event|
|
|
15
|
+
# websocket.write(JSON.generate(event))
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# The adapter has no state between #stream calls, so one instance can
|
|
19
|
+
# translate independent runs.
|
|
20
|
+
#
|
|
21
|
+
# === Security and trust
|
|
22
|
+
#
|
|
23
|
+
# Provider plaintext reasoning becomes AG-UI reasoning events. Tool
|
|
24
|
+
# arguments and results, invocation metadata, subagent events, trace context,
|
|
25
|
+
# and selected error text also pass through without redaction. Authorize and
|
|
26
|
+
# filter the complete stream before transport, and send it only to an
|
|
27
|
+
# interface trusted to display that data. Encrypted reasoning and provider
|
|
28
|
+
# continuity artifacts are never exposed here.
|
|
29
|
+
class Adapter
|
|
30
|
+
TERMINAL_EVENTS = %i[run_partial run_cancel run_stop run_error].freeze # :nodoc:
|
|
31
|
+
|
|
32
|
+
# Lazily translates +events+ for one AG-UI run.
|
|
33
|
+
def stream(events, thread_id:, run_id:)
|
|
34
|
+
Enumerator.new do |output|
|
|
35
|
+
message_id = nil
|
|
36
|
+
message_started = false
|
|
37
|
+
reasoning_id = nil
|
|
38
|
+
reasoning_message_id = nil
|
|
39
|
+
tool_call_ids = {}
|
|
40
|
+
|
|
41
|
+
events.each do |source|
|
|
42
|
+
superseded_message_id = message_id if source.type == :model_retry && message_started
|
|
43
|
+
if reasoning_id && source.type != :reasoning_delta
|
|
44
|
+
output << event("REASONING_MESSAGE_END", messageId: reasoning_message_id)
|
|
45
|
+
output << event("REASONING_END", messageId: reasoning_id)
|
|
46
|
+
reasoning_id = nil
|
|
47
|
+
reasoning_message_id = nil
|
|
48
|
+
end
|
|
49
|
+
if message_started && (TERMINAL_EVENTS.include?(source.type) || source.type == :model_retry)
|
|
50
|
+
output << event("TEXT_MESSAGE_END", messageId: message_id)
|
|
51
|
+
message_id = nil
|
|
52
|
+
message_started = false
|
|
53
|
+
end
|
|
54
|
+
if message_started && source.type == :message_start
|
|
55
|
+
output << event("TEXT_MESSAGE_END", messageId: message_id)
|
|
56
|
+
message_id = nil
|
|
57
|
+
message_started = false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
case source.type
|
|
61
|
+
when :run_start
|
|
62
|
+
output << event("RUN_STARTED", threadId: thread_id, runId: run_id)
|
|
63
|
+
when :message_start
|
|
64
|
+
message_id = SecureRandom.uuid
|
|
65
|
+
when :reasoning_delta
|
|
66
|
+
if message_started
|
|
67
|
+
output << event("TEXT_MESSAGE_END", messageId: message_id)
|
|
68
|
+
message_id = nil
|
|
69
|
+
message_started = false
|
|
70
|
+
end
|
|
71
|
+
unless reasoning_id
|
|
72
|
+
reasoning_id = SecureRandom.uuid
|
|
73
|
+
reasoning_message_id = SecureRandom.uuid
|
|
74
|
+
output << event("REASONING_START", messageId: reasoning_id)
|
|
75
|
+
output << event(
|
|
76
|
+
"REASONING_MESSAGE_START",
|
|
77
|
+
messageId: reasoning_message_id,
|
|
78
|
+
role: "reasoning"
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
output << event(
|
|
82
|
+
"REASONING_MESSAGE_CONTENT",
|
|
83
|
+
messageId: reasoning_message_id,
|
|
84
|
+
delta: source.data.fetch(:text)
|
|
85
|
+
)
|
|
86
|
+
when :text_delta
|
|
87
|
+
message_id ||= SecureRandom.uuid
|
|
88
|
+
unless message_started
|
|
89
|
+
output << event("TEXT_MESSAGE_START", messageId: message_id, role: "assistant")
|
|
90
|
+
message_started = true
|
|
91
|
+
end
|
|
92
|
+
output << event("TEXT_MESSAGE_CONTENT", messageId: message_id, delta: source.data.fetch(:text))
|
|
93
|
+
when :message_stop
|
|
94
|
+
if message_started
|
|
95
|
+
output << event("TEXT_MESSAGE_END", messageId: message_id)
|
|
96
|
+
end
|
|
97
|
+
message_id = nil
|
|
98
|
+
message_started = false
|
|
99
|
+
when :tool_call_start
|
|
100
|
+
tool_call_ids[source.data.fetch(:index)] = source.data.fetch(:id)
|
|
101
|
+
output << event(
|
|
102
|
+
"TOOL_CALL_START",
|
|
103
|
+
toolCallId: source.data.fetch(:id),
|
|
104
|
+
toolCallName: source.data.fetch(:name),
|
|
105
|
+
parentMessageId: (message_id if message_started)
|
|
106
|
+
)
|
|
107
|
+
when :tool_call_delta
|
|
108
|
+
output << event(
|
|
109
|
+
"TOOL_CALL_ARGS",
|
|
110
|
+
toolCallId: tool_call_ids.fetch(source.data.fetch(:index), source.data.fetch(:index).to_s),
|
|
111
|
+
delta: source.data.fetch(:arguments)
|
|
112
|
+
)
|
|
113
|
+
when :tool_call_stop
|
|
114
|
+
output << event("TOOL_CALL_END", toolCallId: source.data.fetch(:tool_use).id)
|
|
115
|
+
when :tool_stop
|
|
116
|
+
tool_use = source.data.fetch(:tool_use)
|
|
117
|
+
result = source.data.fetch(:result)
|
|
118
|
+
output << event(
|
|
119
|
+
"TOOL_CALL_RESULT",
|
|
120
|
+
messageId: SecureRandom.uuid,
|
|
121
|
+
toolCallId: tool_use.id,
|
|
122
|
+
content: result.content,
|
|
123
|
+
status: result.status,
|
|
124
|
+
role: "tool"
|
|
125
|
+
)
|
|
126
|
+
when :invocation_stop
|
|
127
|
+
result = source.data.fetch(:result)
|
|
128
|
+
output << custom(
|
|
129
|
+
"little_ghost.usage",
|
|
130
|
+
usage: result.usage.to_h,
|
|
131
|
+
metadata: source.data.fetch(:metadata, {})
|
|
132
|
+
)
|
|
133
|
+
when :invocation_error
|
|
134
|
+
output << custom(
|
|
135
|
+
"little_ghost.usage",
|
|
136
|
+
usage: source.data.fetch(:usage).to_h,
|
|
137
|
+
metadata: source.data.fetch(:metadata, {})
|
|
138
|
+
)
|
|
139
|
+
when :model_retry
|
|
140
|
+
tool_call_ids.clear
|
|
141
|
+
output << custom(
|
|
142
|
+
"little_ghost.model_retry",
|
|
143
|
+
source.data.merge(superseded_message_id:).compact
|
|
144
|
+
)
|
|
145
|
+
when :agent_interrupt_delivered
|
|
146
|
+
output << custom(
|
|
147
|
+
"little_ghost.agent_interrupt_delivered",
|
|
148
|
+
source.data.slice(:interruption_ids, :batch_key).compact
|
|
149
|
+
)
|
|
150
|
+
when :subagent
|
|
151
|
+
output << custom("little_ghost.subagent", source.data.fetch(:event, source.data))
|
|
152
|
+
when :trace_context
|
|
153
|
+
output << custom("little_ghost.trace_context", source.data.fetch(:context, source.data))
|
|
154
|
+
when :run_partial
|
|
155
|
+
output << custom(
|
|
156
|
+
"little_ghost.run.partial",
|
|
157
|
+
response: source.data.fetch(:response),
|
|
158
|
+
message: source.data[:error]&.message
|
|
159
|
+
)
|
|
160
|
+
output << event(
|
|
161
|
+
"RUN_FINISHED", threadId: thread_id, runId: run_id,
|
|
162
|
+
result: {response: source.data.fetch(:response)}
|
|
163
|
+
)
|
|
164
|
+
when :run_cancel
|
|
165
|
+
output << custom("little_ghost.run.canceled", reason: source.data[:error]&.message)
|
|
166
|
+
output << event("RUN_FINISHED", threadId: thread_id, runId: run_id)
|
|
167
|
+
when :run_stop
|
|
168
|
+
output << event(
|
|
169
|
+
"RUN_FINISHED", threadId: thread_id, runId: run_id,
|
|
170
|
+
result: {response: source.data.fetch(:response)}
|
|
171
|
+
)
|
|
172
|
+
when :run_error
|
|
173
|
+
output << event(
|
|
174
|
+
"RUN_ERROR", threadId: thread_id, runId: run_id,
|
|
175
|
+
message: source.data.fetch(:message),
|
|
176
|
+
cleanupFailed: source.data.fetch(:cleanup_failed, true)
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
def event(type, **attributes)
|
|
186
|
+
{type:, **attributes.compact}
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def custom(name, value = nil, **attributes)
|
|
190
|
+
event("CUSTOM", name:, value: value || attributes)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module LittleGhost
|
|
7
|
+
class Agent
|
|
8
|
+
# Keep long conversations within the model's available context window.
|
|
9
|
+
# The capability summarizes older turns while preserving trusted instructions
|
|
10
|
+
# and recent messages.
|
|
11
|
+
#
|
|
12
|
+
# class CustomerSupportAgent < LittleGhost::Agent
|
|
13
|
+
# manage_context compression_threshold: 0.75,
|
|
14
|
+
# preserve_recent_messages: 12
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# As a support thread reaches the threshold, the next model request contains
|
|
18
|
+
# a generated summary and targets retaining its 12 most recent conversation
|
|
19
|
+
# messages. System and developer messages remain intact, and tool-use/result
|
|
20
|
+
# pairs are never split merely to hit the requested count.
|
|
21
|
+
#
|
|
22
|
+
# Context management is inactive until +manage_context+ is declared. The
|
|
23
|
+
# configured window is a fallback: provider metadata takes precedence when
|
|
24
|
+
# it advertises a positive context-window size. Compaction uses the current
|
|
25
|
+
# model with the request's settings, cancellation token, and deadline.
|
|
26
|
+
#
|
|
27
|
+
# Proactive compaction failures leave the original request unchanged and emit
|
|
28
|
+
# diagnostic instrumentation. A provider context-overflow error triggers one
|
|
29
|
+
# compaction replacement through the model-error callback; cancellation,
|
|
30
|
+
# deadlines, and cleanup failures still escape as control flow.
|
|
31
|
+
module ContextManagement
|
|
32
|
+
DEFAULT_CONTEXT_WINDOW_TOKENS = 200_000 # :nodoc:
|
|
33
|
+
DEFAULT_COMPRESSION_THRESHOLD = 0.85 # :nodoc:
|
|
34
|
+
DEFAULT_SUMMARY_RATIO = 0.3 # :nodoc:
|
|
35
|
+
DEFAULT_PRESERVE_RECENT_MESSAGES = 10 # :nodoc:
|
|
36
|
+
ESTIMATED_CHARS_PER_TOKEN = 4 # :nodoc:
|
|
37
|
+
OUTPUT_LIMIT_STOP_REASONS = %i[max_tokens limit_output_tokens limit_total_tokens limit_turns].freeze # :nodoc:
|
|
38
|
+
SUMMARIZATION_PROMPT = <<~PROMPT # :nodoc:
|
|
39
|
+
You are a conversation summarizer. Provide a concise summary of the conversation history.
|
|
40
|
+
|
|
41
|
+
Format requirements:
|
|
42
|
+
- Create a structured, concise summary in bullet-point format.
|
|
43
|
+
- Do not respond conversationally, address the user directly, or comment on tool availability.
|
|
44
|
+
- Preserve key topics, questions, significant tool executions and results, code or technical information, and key insights.
|
|
45
|
+
- Do not assume tool executions failed unless otherwise stated.
|
|
46
|
+
- Write the summary in the third person.
|
|
47
|
+
PROMPT
|
|
48
|
+
|
|
49
|
+
def self.included(base) # :nodoc:
|
|
50
|
+
base.extend(ClassMethods)
|
|
51
|
+
base.class_attribute :context_management_configuration_value
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Exposes context-management declarations on agent classes.
|
|
55
|
+
# These methods become inheritable DSL entries when the capability is included.
|
|
56
|
+
module ClassMethods
|
|
57
|
+
# Enables automatic context compaction for the agent class.
|
|
58
|
+
#
|
|
59
|
+
# The defaults assume a 200,000-token window, compact at 85% usage,
|
|
60
|
+
# summarize about 30% of conversation messages, and preserve the 10 most
|
|
61
|
+
# recent messages. The model's declared context window takes precedence
|
|
62
|
+
# over +context_window_tokens+ when available.
|
|
63
|
+
#
|
|
64
|
+
# Invalid ranges raise ArgumentError when the agent class is defined.
|
|
65
|
+
def manage_context(
|
|
66
|
+
context_window_tokens: DEFAULT_CONTEXT_WINDOW_TOKENS,
|
|
67
|
+
compression_threshold: DEFAULT_COMPRESSION_THRESHOLD,
|
|
68
|
+
summary_ratio: DEFAULT_SUMMARY_RATIO,
|
|
69
|
+
preserve_recent_messages: DEFAULT_PRESERVE_RECENT_MESSAGES
|
|
70
|
+
)
|
|
71
|
+
context_window_tokens = Integer(context_window_tokens)
|
|
72
|
+
compression_threshold = Float(compression_threshold)
|
|
73
|
+
summary_ratio = Float(summary_ratio)
|
|
74
|
+
preserve_recent_messages = Integer(preserve_recent_messages)
|
|
75
|
+
raise ArgumentError, "context_window_tokens must be positive" unless context_window_tokens.positive?
|
|
76
|
+
unless compression_threshold.positive? && compression_threshold <= 1
|
|
77
|
+
raise ArgumentError, "compression_threshold must be between 0 and 1"
|
|
78
|
+
end
|
|
79
|
+
unless summary_ratio.between?(0.1, 0.8)
|
|
80
|
+
raise ArgumentError, "summary_ratio must be between 0.1 and 0.8"
|
|
81
|
+
end
|
|
82
|
+
raise ArgumentError, "preserve_recent_messages must be at least 2" if preserve_recent_messages < 2
|
|
83
|
+
|
|
84
|
+
self.context_management_configuration_value = {
|
|
85
|
+
context_window_tokens:,
|
|
86
|
+
compression_threshold:,
|
|
87
|
+
summary_ratio:,
|
|
88
|
+
preserve_recent_messages:
|
|
89
|
+
}
|
|
90
|
+
before_model :compact_model_context
|
|
91
|
+
after_model_error :compact_context_after_overflow
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def context_management_configuration = context_management_configuration_value # :nodoc:
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def compact_model_context(payload, context: nil)
|
|
100
|
+
request = payload.fetch(:request)
|
|
101
|
+
configuration = self.class.context_management_configuration
|
|
102
|
+
limit = model_context_window_tokens(configuration)
|
|
103
|
+
threshold = limit * configuration.fetch(:compression_threshold)
|
|
104
|
+
return Support::Callbacks.continue if estimated_request_tokens(request) < threshold
|
|
105
|
+
|
|
106
|
+
compacted = compact_context(
|
|
107
|
+
request,
|
|
108
|
+
configuration,
|
|
109
|
+
context,
|
|
110
|
+
context_window_tokens: limit,
|
|
111
|
+
parent_operation_id: payload[:parent_operation_id]
|
|
112
|
+
)
|
|
113
|
+
Support::Callbacks.replace(payload.merge(request: compacted))
|
|
114
|
+
rescue CancelledError, DeadlineExceededError, CleanupError
|
|
115
|
+
raise
|
|
116
|
+
rescue => error
|
|
117
|
+
Instrumentation.publish(:context_compaction, outcome: :error, error_class: error.class.name)
|
|
118
|
+
Support::Callbacks.continue
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def compact_context_after_overflow(payload, context: nil)
|
|
122
|
+
return Support::Callbacks.continue unless payload.fetch(:error).is_a?(ContextWindowOverflowError)
|
|
123
|
+
|
|
124
|
+
configuration = self.class.context_management_configuration
|
|
125
|
+
request = payload.fetch(:request)
|
|
126
|
+
compacted = compact_context(
|
|
127
|
+
request,
|
|
128
|
+
configuration,
|
|
129
|
+
context,
|
|
130
|
+
context_window_tokens: model_context_window_tokens(configuration),
|
|
131
|
+
reason: :overflow,
|
|
132
|
+
parent_operation_id: payload[:parent_operation_id]
|
|
133
|
+
)
|
|
134
|
+
Support::Callbacks.replace(payload.merge(request: compacted))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def summarize_oldest_messages(request, configuration, context, parent_operation_id:)
|
|
138
|
+
trusted, conversation = request.messages.partition { |message| %i[system developer].include?(message.role) }
|
|
139
|
+
preserve = configuration.fetch(:preserve_recent_messages)
|
|
140
|
+
split = [(conversation.length * configuration.fetch(:summary_ratio)).floor, 1].max
|
|
141
|
+
split = [split, conversation.length - preserve].min
|
|
142
|
+
raise ProtocolError, "Not enough conversation history to compact" unless split.positive?
|
|
143
|
+
|
|
144
|
+
split = safe_summary_split(conversation, split)
|
|
145
|
+
to_summarize = conversation.first(split)
|
|
146
|
+
summary = generate_context_summary(to_summarize, request, context, parent_operation_id:)
|
|
147
|
+
ModelRequest.new(
|
|
148
|
+
messages: [*trusted, summary, *conversation.drop(split)],
|
|
149
|
+
tools: request.tools,
|
|
150
|
+
settings: request.settings,
|
|
151
|
+
output_schema: request.output_schema,
|
|
152
|
+
tool_choice: request.tool_choice,
|
|
153
|
+
required_capabilities: request.required_capabilities,
|
|
154
|
+
cancellation_token: request.cancellation_token,
|
|
155
|
+
deadline: request.deadline
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def compact_context(
|
|
160
|
+
request,
|
|
161
|
+
configuration,
|
|
162
|
+
context,
|
|
163
|
+
context_window_tokens:,
|
|
164
|
+
parent_operation_id:,
|
|
165
|
+
reason: :threshold
|
|
166
|
+
)
|
|
167
|
+
compacted = summarize_oldest_messages(request, configuration, context, parent_operation_id:)
|
|
168
|
+
Instrumentation.publish(
|
|
169
|
+
:context_compaction,
|
|
170
|
+
reason:,
|
|
171
|
+
removed_messages: request.messages.length - compacted.messages.length,
|
|
172
|
+
estimated_tokens: estimated_request_tokens(request),
|
|
173
|
+
context_window_tokens:
|
|
174
|
+
)
|
|
175
|
+
compacted
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def safe_summary_split(messages, split)
|
|
179
|
+
while split < messages.length
|
|
180
|
+
current = messages.fetch(split)
|
|
181
|
+
previous = messages.fetch(split - 1)
|
|
182
|
+
current_results = current.content.grep(Content::ToolResult).map(&:tool_use_id)
|
|
183
|
+
previous_uses = previous.content.grep(Content::ToolUse).map(&:id)
|
|
184
|
+
break if current_results.empty? && previous_uses.empty?
|
|
185
|
+
|
|
186
|
+
split += 1
|
|
187
|
+
end
|
|
188
|
+
split
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def generate_context_summary(messages, request, context, parent_operation_id:)
|
|
192
|
+
operation_id = SecureRandom.uuid
|
|
193
|
+
started_at = monotonic_time
|
|
194
|
+
summary_request = ModelRequest.new(
|
|
195
|
+
messages: [
|
|
196
|
+
Message.new(role: :system, content: SUMMARIZATION_PROMPT),
|
|
197
|
+
*messages,
|
|
198
|
+
Message.new(role: :user, content: "Please summarize this conversation.")
|
|
199
|
+
],
|
|
200
|
+
tools: [],
|
|
201
|
+
settings: request.settings,
|
|
202
|
+
cancellation_token: request.cancellation_token,
|
|
203
|
+
deadline: request.deadline
|
|
204
|
+
)
|
|
205
|
+
instrument(
|
|
206
|
+
:model_start,
|
|
207
|
+
operation_id:,
|
|
208
|
+
parent_operation_id:,
|
|
209
|
+
purpose: :context_compaction,
|
|
210
|
+
diagnostic: {
|
|
211
|
+
input: summary_request.messages.map { |message| diagnostic_message(message) },
|
|
212
|
+
tool_definitions: summary_request.tools
|
|
213
|
+
},
|
|
214
|
+
model_settings: summary_request.settings,
|
|
215
|
+
**model_attributes
|
|
216
|
+
)
|
|
217
|
+
response = nil
|
|
218
|
+
time_to_first_token = nil
|
|
219
|
+
model.stream(summary_request).each do |event|
|
|
220
|
+
context&.check!
|
|
221
|
+
time_to_first_token ||= duration_seconds(started_at) if model_output_event?(event)
|
|
222
|
+
response = event.data[:response] if event.type == :message_stop
|
|
223
|
+
end
|
|
224
|
+
raise ProtocolError, "Context summarization ended without a response" unless response
|
|
225
|
+
context&.record_usage(response.usage)
|
|
226
|
+
if OUTPUT_LIMIT_STOP_REASONS.include?(response.stop_reason)
|
|
227
|
+
raise OutputLimitError, "The context summary stopped before completion"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
instrument(
|
|
231
|
+
:model_stop,
|
|
232
|
+
operation_id:,
|
|
233
|
+
parent_operation_id:,
|
|
234
|
+
purpose: :context_compaction,
|
|
235
|
+
outcome: :completed,
|
|
236
|
+
duration_ms: duration_ms(started_at),
|
|
237
|
+
time_to_first_token:,
|
|
238
|
+
stop_reason: response.stop_reason,
|
|
239
|
+
**response_attributes(response),
|
|
240
|
+
diagnostic: {output: diagnostic_message(response.message)},
|
|
241
|
+
**model_attributes,
|
|
242
|
+
**usage_attributes(response.usage)
|
|
243
|
+
)
|
|
244
|
+
Message.new(role: :user, content: response.message.content)
|
|
245
|
+
rescue => error
|
|
246
|
+
instrument(
|
|
247
|
+
:model_stop,
|
|
248
|
+
operation_id:,
|
|
249
|
+
parent_operation_id:,
|
|
250
|
+
purpose: :context_compaction,
|
|
251
|
+
outcome: :error,
|
|
252
|
+
duration_ms: duration_ms(started_at),
|
|
253
|
+
time_to_first_token:,
|
|
254
|
+
error_type: error.class.name,
|
|
255
|
+
diagnostic: {exception: diagnostic_exception(error)},
|
|
256
|
+
**model_attributes
|
|
257
|
+
)
|
|
258
|
+
raise
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def model_context_window_tokens(configuration)
|
|
262
|
+
value = model.respond_to?(:metadata) && (
|
|
263
|
+
model.metadata[:context_window_tokens] || model.metadata["context_window_tokens"] ||
|
|
264
|
+
model.metadata[:context_window] || model.metadata["context_window"]
|
|
265
|
+
)
|
|
266
|
+
value = Integer(value) if value
|
|
267
|
+
value&.positive? ? value : configuration.fetch(:context_window_tokens)
|
|
268
|
+
rescue ArgumentError, TypeError
|
|
269
|
+
configuration.fetch(:context_window_tokens)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def estimated_request_tokens(request)
|
|
273
|
+
characters = JSON.generate(
|
|
274
|
+
messages: request.messages.map(&:to_h),
|
|
275
|
+
tools: request.tools,
|
|
276
|
+
output_schema: request.output_schema,
|
|
277
|
+
tool_choice: request.tool_choice
|
|
278
|
+
).length
|
|
279
|
+
(characters.to_f / ESTIMATED_CHARS_PER_TOKEN).ceil
|
|
280
|
+
rescue JSON::GeneratorError
|
|
281
|
+
request.messages.sum { |message| message.text.length } / ESTIMATED_CHARS_PER_TOKEN
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
class Agent
|
|
5
|
+
# Give one agent a bounded way to ask another agent for help.
|
|
6
|
+
# Delegated agents can run as managed subagents or behind an ordinary tool call.
|
|
7
|
+
#
|
|
8
|
+
# class CustomerSupportAgent < LittleGhost::Agent
|
|
9
|
+
# subagent ResearchAgent, kind: "research"
|
|
10
|
+
# agent_as_tool SentimentAgent, name: "classify_sentiment"
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# The support model receives spawn, messaging, interruption, waiting, and
|
|
14
|
+
# listing tools for the +research+ kind. It sees the sentiment agent as one
|
|
15
|
+
# regular tool whose result is returned to the current turn.
|
|
16
|
+
#
|
|
17
|
+
# Static declarations may be combined with a resolver that returns dynamic
|
|
18
|
+
# Subagents::Definition objects. Managed conversations persist when a session
|
|
19
|
+
# store is configured unless <tt>persist: false</tt> keeps them local to one
|
|
20
|
+
# invocation. An agent exposed as a tool starts with empty history unless
|
|
21
|
+
# <tt>preserve_context: true</tt> serializes calls and retains its history.
|
|
22
|
+
#
|
|
23
|
+
# Tool overrides must be classes. A delegated agent otherwise receives only
|
|
24
|
+
# its own declared tools; it does not inherit the parent's registry. The
|
|
25
|
+
# manager enforces its concurrency, identity, polling, and persistence bounds.
|
|
26
|
+
module Delegation
|
|
27
|
+
def self.included(base) # :nodoc:
|
|
28
|
+
base.extend(ClassMethods)
|
|
29
|
+
base.class_attribute :subagent_long_poll_duration_value,
|
|
30
|
+
default: Subagents::Manager::DEFAULT_WAIT_TIMEOUT
|
|
31
|
+
base.class_attribute :subagent_declarations_value, default: []
|
|
32
|
+
base.class_attribute :subagent_resolvers_value, default: []
|
|
33
|
+
base.class_attribute :agent_tool_declarations_value, default: []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Exposes delegation declarations on agent classes.
|
|
37
|
+
# These methods become inheritable DSL entries when the capability is included.
|
|
38
|
+
module ClassMethods
|
|
39
|
+
# :call-seq:
|
|
40
|
+
# subagent_long_poll_duration() -> Float
|
|
41
|
+
# subagent_long_poll_duration(seconds) -> Float
|
|
42
|
+
#
|
|
43
|
+
# The maximum long-poll duration used by subagent wait tools.
|
|
44
|
+
#
|
|
45
|
+
# The default comes from Subagents::Manager. Values must be positive,
|
|
46
|
+
# finite numbers and are normalized to Float.
|
|
47
|
+
def subagent_long_poll_duration(*values)
|
|
48
|
+
return subagent_long_poll_duration_value if values.empty?
|
|
49
|
+
|
|
50
|
+
timeout = Float(values.fetch(0))
|
|
51
|
+
unless timeout.positive? && timeout.finite?
|
|
52
|
+
raise ConfigurationError, "subagent_long_poll_duration must be a positive finite number"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
self.subagent_long_poll_duration_value = timeout
|
|
56
|
+
rescue ArgumentError, TypeError
|
|
57
|
+
raise ConfigurationError, "subagent_long_poll_duration must be a positive finite number"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Adds +agent_class+ as an available managed subagent.
|
|
61
|
+
#
|
|
62
|
+
# +kind+ defaults to the agent ID and +description+ defaults to the
|
|
63
|
+
# agent description. Conversations persist when a session store exists;
|
|
64
|
+
# pass <tt>persist: false</tt> for invocation-local work.
|
|
65
|
+
#
|
|
66
|
+
# subagent ResearchAgent, kind: "research"
|
|
67
|
+
def subagent(agent_class, kind: nil, description: nil, model: nil, tools: nil, factory: nil, persist: true)
|
|
68
|
+
validate_delegated_tools!(tools)
|
|
69
|
+
declaration = {
|
|
70
|
+
agent: agent_class,
|
|
71
|
+
kind: (kind || agent_class.agent_id).to_s,
|
|
72
|
+
description: description || agent_class.description,
|
|
73
|
+
model:,
|
|
74
|
+
tools:,
|
|
75
|
+
factory:,
|
|
76
|
+
persist:
|
|
77
|
+
}
|
|
78
|
+
self.subagent_declarations_value = [*subagent_declarations, declaration]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Adds several static subagents and an optional dynamic definition resolver.
|
|
82
|
+
def subagents(*agent_classes, **options, &resolver)
|
|
83
|
+
agent_classes.each { |agent_class| subagent(agent_class, **options) }
|
|
84
|
+
self.subagent_resolvers_value = [*subagent_resolvers, resolver] if resolver
|
|
85
|
+
subagent_declarations
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def subagent_declarations = subagent_declarations_value # :nodoc:
|
|
89
|
+
|
|
90
|
+
def subagent_resolvers = subagent_resolvers_value # :nodoc:
|
|
91
|
+
|
|
92
|
+
# Exposes +agent_class+ as one ordinary tool.
|
|
93
|
+
#
|
|
94
|
+
# Pass <tt>preserve_context: true</tt> to retain the delegated agent's
|
|
95
|
+
# conversational history between calls to that tool instance.
|
|
96
|
+
def agent_as_tool(agent_class, name: nil, description: nil, model: nil, tools: nil,
|
|
97
|
+
preserve_context: false)
|
|
98
|
+
validate_delegated_tools!(tools)
|
|
99
|
+
declaration = {
|
|
100
|
+
agent: agent_class,
|
|
101
|
+
name: (name || agent_class.agent_id).to_s,
|
|
102
|
+
description: description || agent_class.description,
|
|
103
|
+
model:,
|
|
104
|
+
tools:,
|
|
105
|
+
preserve_context:
|
|
106
|
+
}
|
|
107
|
+
self.agent_tool_declarations_value = [*agent_tool_declarations, declaration]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Exposes several agent classes as ordinary tools with shared options.
|
|
111
|
+
def agents_as_tools(*agent_classes, **options)
|
|
112
|
+
agent_classes.each { |agent_class| agent_as_tool(agent_class, **options) }
|
|
113
|
+
agent_tool_declarations
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def agent_tool_declarations = agent_tool_declarations_value # :nodoc:
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def validate_delegated_tools!(tools)
|
|
121
|
+
return unless Array(tools).flatten.any? { |tool| !tool.is_a?(Class) }
|
|
122
|
+
|
|
123
|
+
raise ConfigurationError, "Delegated tools must be classes"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|