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.
Files changed (82) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +22 -0
  3. data/README.md +122 -0
  4. data/docs/guides/Core Concepts.md +203 -0
  5. data/docs/guides/Getting Started.md +187 -0
  6. data/lib/little_ghost/ag_ui/adapter.rb +194 -0
  7. data/lib/little_ghost/ag_ui.rb +5 -0
  8. data/lib/little_ghost/agent/context_management.rb +285 -0
  9. data/lib/little_ghost/agent/delegation.rb +128 -0
  10. data/lib/little_ghost/agent/skills.rb +96 -0
  11. data/lib/little_ghost/agent/tool_loop.rb +239 -0
  12. data/lib/little_ghost/agent.rb +2111 -0
  13. data/lib/little_ghost/agent_builder.rb +191 -0
  14. data/lib/little_ghost/agent_interruptions.rb +197 -0
  15. data/lib/little_ghost/configuration.rb +337 -0
  16. data/lib/little_ghost/content.rb +324 -0
  17. data/lib/little_ghost/default_model_registry.rb +71 -0
  18. data/lib/little_ghost/errors.rb +48 -0
  19. data/lib/little_ghost/events.rb +264 -0
  20. data/lib/little_ghost/execution_state.rb +58 -0
  21. data/lib/little_ghost/instrumentation.rb +475 -0
  22. data/lib/little_ghost/invocation.rb +285 -0
  23. data/lib/little_ghost/lookup.rb +37 -0
  24. data/lib/little_ghost/mcp/client.rb +396 -0
  25. data/lib/little_ghost/mcp.rb +5 -0
  26. data/lib/little_ghost/message.rb +75 -0
  27. data/lib/little_ghost/model.rb +88 -0
  28. data/lib/little_ghost/model_capabilities.rb +126 -0
  29. data/lib/little_ghost/model_registry.rb +173 -0
  30. data/lib/little_ghost/model_request.rb +107 -0
  31. data/lib/little_ghost/model_response.rb +48 -0
  32. data/lib/little_ghost/path_set.rb +32 -0
  33. data/lib/little_ghost/prompt_resolver.rb +251 -0
  34. data/lib/little_ghost/providers/bedrock.rb +506 -0
  35. data/lib/little_ghost/providers/http_transport.rb +149 -0
  36. data/lib/little_ghost/providers/open_router.rb +171 -0
  37. data/lib/little_ghost/providers/openai.rb +27 -0
  38. data/lib/little_ghost/providers/openai_compatible.rb +745 -0
  39. data/lib/little_ghost/providers/sse_parser.rb +35 -0
  40. data/lib/little_ghost/run.rb +607 -0
  41. data/lib/little_ghost/run_context.rb +129 -0
  42. data/lib/little_ghost/run_result.rb +111 -0
  43. data/lib/little_ghost/runtime/hook.rb +31 -0
  44. data/lib/little_ghost/runtime.rb +392 -0
  45. data/lib/little_ghost/sandbox.rb +138 -0
  46. data/lib/little_ghost/session.rb +229 -0
  47. data/lib/little_ghost/session_store.rb +96 -0
  48. data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
  49. data/lib/little_ghost/session_stores/memory.rb +86 -0
  50. data/lib/little_ghost/skills/catalog.rb +283 -0
  51. data/lib/little_ghost/skills/skill.rb +60 -0
  52. data/lib/little_ghost/skills.rb +4 -0
  53. data/lib/little_ghost/stream_event.rb +49 -0
  54. data/lib/little_ghost/structured_output.rb +126 -0
  55. data/lib/little_ghost/subagents/agent_path.rb +63 -0
  56. data/lib/little_ghost/subagents/definition.rb +42 -0
  57. data/lib/little_ghost/subagents/manager.rb +1615 -0
  58. data/lib/little_ghost/support/callbacks.rb +151 -0
  59. data/lib/little_ghost/support/cancellation_token.rb +86 -0
  60. data/lib/little_ghost/support/class_attributes.rb +40 -0
  61. data/lib/little_ghost/support/content_capture.rb +150 -0
  62. data/lib/little_ghost/support/executor.rb +75 -0
  63. data/lib/little_ghost/support/interruptible_stream.rb +103 -0
  64. data/lib/little_ghost/support/loader.rb +263 -0
  65. data/lib/little_ghost/support/output_truncation.rb +71 -0
  66. data/lib/little_ghost/support/redactor.rb +66 -0
  67. data/lib/little_ghost/support.rb +34 -0
  68. data/lib/little_ghost/tool.rb +448 -0
  69. data/lib/little_ghost/tool_execution.rb +59 -0
  70. data/lib/little_ghost/tool_registry.rb +156 -0
  71. data/lib/little_ghost/tools/filesystem.rb +119 -0
  72. data/lib/little_ghost/tools/shell.rb +45 -0
  73. data/lib/little_ghost/tools/write_todos.rb +91 -0
  74. data/lib/little_ghost/tools.rb +6 -0
  75. data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
  76. data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
  77. data/lib/little_ghost/usage.rb +47 -0
  78. data/lib/little_ghost/version.rb +6 -0
  79. data/lib/little_ghost/workflow.rb +351 -0
  80. data/lib/little_ghost/workspace.rb +31 -0
  81. data/lib/little_ghost.rb +120 -0
  82. 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loads LittleGhost's optional AG-UI adapter. Requiring +little_ghost+ alone does
4
+ # not load interface-specific translation code.
5
+ require_relative "ag_ui/adapter"
@@ -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