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,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
class AgentBuilder # :nodoc: all
|
|
5
|
+
class ActivityRelay
|
|
6
|
+
def initialize
|
|
7
|
+
@mutex = Mutex.new
|
|
8
|
+
@observers = []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def subscribe(&observer)
|
|
12
|
+
@mutex.synchronize { @observers << observer }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def publish
|
|
16
|
+
@mutex.synchronize { @observers.dup }.each(&:call)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(runtime:, prompt_paths:, resolve_agent:)
|
|
21
|
+
@runtime = runtime
|
|
22
|
+
@prompt_paths = prompt_paths
|
|
23
|
+
@resolve_agent = resolve_agent
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def build(
|
|
27
|
+
agent_class_or_name,
|
|
28
|
+
run:,
|
|
29
|
+
model: nil,
|
|
30
|
+
tools: [],
|
|
31
|
+
conversation_id: nil,
|
|
32
|
+
agent_path: Subagents::AgentPath::ROOT
|
|
33
|
+
)
|
|
34
|
+
agent_class = @resolve_agent.call(agent_class_or_name)
|
|
35
|
+
build_agent(agent_class, run:, model:, tools:, conversation_id:, agent_path:)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
attr_reader :runtime, :prompt_paths
|
|
41
|
+
|
|
42
|
+
def instantiate(agent_class, run:, tools:, model:, delegation_activity:, agent_path:)
|
|
43
|
+
agent_class.new(
|
|
44
|
+
model:,
|
|
45
|
+
runtime:,
|
|
46
|
+
tools:,
|
|
47
|
+
template_paths: prompt_paths,
|
|
48
|
+
run:,
|
|
49
|
+
workspace: run.workspace,
|
|
50
|
+
sandbox: run.sandbox,
|
|
51
|
+
delegation_activity:,
|
|
52
|
+
agent_path:,
|
|
53
|
+
**agent_class.limits
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def build_agent(
|
|
58
|
+
agent_class,
|
|
59
|
+
run:,
|
|
60
|
+
model:,
|
|
61
|
+
tools:,
|
|
62
|
+
conversation_id: nil,
|
|
63
|
+
agent_path: Subagents::AgentPath::ROOT
|
|
64
|
+
)
|
|
65
|
+
delegation_activity = ActivityRelay.new
|
|
66
|
+
configured_tools = Array(tools).dup
|
|
67
|
+
configured_tools.concat(
|
|
68
|
+
delegation_tools(agent_class, run, conversation_id:, delegation_activity:, agent_path:)
|
|
69
|
+
)
|
|
70
|
+
resolved_model = model || runtime.model_for(agent_class, run)
|
|
71
|
+
transferred = true
|
|
72
|
+
instantiate(
|
|
73
|
+
agent_class,
|
|
74
|
+
run:,
|
|
75
|
+
model: resolved_model,
|
|
76
|
+
tools: configured_tools,
|
|
77
|
+
delegation_activity:,
|
|
78
|
+
agent_path:
|
|
79
|
+
)
|
|
80
|
+
rescue
|
|
81
|
+
close_tools(configured_tools) unless transferred
|
|
82
|
+
raise
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def delegation_tools(agent_class, run, conversation_id:, delegation_activity:, agent_path:)
|
|
86
|
+
tools = agent_tools(agent_class, run)
|
|
87
|
+
tools.concat(subagent_tools(agent_class, run, conversation_id:, delegation_activity:, agent_path:))
|
|
88
|
+
rescue
|
|
89
|
+
close_tools(tools)
|
|
90
|
+
raise
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def agent_tools(agent_class, run)
|
|
94
|
+
tools = []
|
|
95
|
+
agent_class.agent_tool_declarations.each do |declaration|
|
|
96
|
+
child = declared_agent(declaration, run)
|
|
97
|
+
begin
|
|
98
|
+
tools << child.as_tool(
|
|
99
|
+
name: declaration.fetch(:name),
|
|
100
|
+
description: declaration.fetch(:description),
|
|
101
|
+
preserve_context: declaration.fetch(:preserve_context)
|
|
102
|
+
)
|
|
103
|
+
rescue
|
|
104
|
+
child.close
|
|
105
|
+
raise
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
tools
|
|
109
|
+
rescue
|
|
110
|
+
close_tools(tools)
|
|
111
|
+
raise
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def subagent_tools(agent_class, run, conversation_id:, delegation_activity:, agent_path:)
|
|
115
|
+
definitions = agent_class.subagent_declarations.map do |declaration|
|
|
116
|
+
Subagents::Definition.new(
|
|
117
|
+
kind: declaration.fetch(:kind),
|
|
118
|
+
description: declaration.fetch(:description),
|
|
119
|
+
persist: declaration.fetch(:persist, true),
|
|
120
|
+
accepts_conversation_id: true,
|
|
121
|
+
factory: lambda do |subagent_id, child_conversation_id = nil|
|
|
122
|
+
factory = declaration[:factory]
|
|
123
|
+
if factory
|
|
124
|
+
invoke_factory(factory, subagent_id, run)
|
|
125
|
+
else
|
|
126
|
+
declared_agent(
|
|
127
|
+
declaration,
|
|
128
|
+
run,
|
|
129
|
+
conversation_id: child_conversation_id,
|
|
130
|
+
agent_path: subagent_id
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
declared_kinds = definitions.map(&:kind)
|
|
137
|
+
agent_class.subagent_resolvers.each do |resolver|
|
|
138
|
+
resolved = Array(resolver.call(run))
|
|
139
|
+
unless resolved.all? { |definition| definition.is_a?(Subagents::Definition) }
|
|
140
|
+
raise ConfigurationError, "Subagent resolvers must return LittleGhost::Subagents::Definition objects"
|
|
141
|
+
end
|
|
142
|
+
definitions.concat(resolved.reject { |definition| declared_kinds.include?(definition.kind) })
|
|
143
|
+
end
|
|
144
|
+
return [] if definitions.empty?
|
|
145
|
+
|
|
146
|
+
Subagents::Manager.new(
|
|
147
|
+
definitions,
|
|
148
|
+
runtime:,
|
|
149
|
+
wait_timeout: agent_class.subagent_long_poll_duration,
|
|
150
|
+
parent_session: conversation_id ? runtime.open_subagent_session(run, conversation_id) : run.session,
|
|
151
|
+
cancellation_token: run.cancellation_token,
|
|
152
|
+
deadline: run.invocation.deadline_at,
|
|
153
|
+
parent_agent_path: agent_path,
|
|
154
|
+
observer: lambda do |event|
|
|
155
|
+
delegation_activity.publish
|
|
156
|
+
run.publish(:subagent, event:)
|
|
157
|
+
end
|
|
158
|
+
).tools
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def declared_agent(declaration, run, conversation_id: nil, agent_path: Subagents::AgentPath::ROOT)
|
|
162
|
+
agent_class = @resolve_agent.call(declaration.fetch(:agent))
|
|
163
|
+
build_agent(
|
|
164
|
+
agent_class, run:,
|
|
165
|
+
model: resolve(declaration[:model], run),
|
|
166
|
+
tools: Array(resolve(declaration[:tools], run)),
|
|
167
|
+
conversation_id:,
|
|
168
|
+
agent_path:
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def close_tools(tools)
|
|
173
|
+
Array(tools).reverse_each do |tool|
|
|
174
|
+
tool.close if tool.is_a?(Tool) && tool.respond_to?(:close)
|
|
175
|
+
rescue
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def resolve(value, run)
|
|
181
|
+
value.is_a?(Proc) ? value.call(run) : value
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def invoke_factory(factory, *arguments)
|
|
185
|
+
accepts_runtime = factory.parameters.any? do |kind, name|
|
|
186
|
+
%i[key keyreq keyrest].include?(kind) && (name == :runtime || kind == :keyrest)
|
|
187
|
+
end
|
|
188
|
+
accepts_runtime ? factory.call(*arguments, runtime: @runtime) : factory.call(*arguments)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module LittleGhost
|
|
6
|
+
class AgentInterruptions # :nodoc: all
|
|
7
|
+
MAX_BATCH_SIZE = 100
|
|
8
|
+
MAX_INTERRUPTION_COUNT = 1_000
|
|
9
|
+
|
|
10
|
+
Response = Data.define(:text, :tool_calls, :interruption_ids, :batch_key) do
|
|
11
|
+
def initialize(text:, tool_calls:, interruption_ids: [], batch_key: nil)
|
|
12
|
+
super(
|
|
13
|
+
text: String(text),
|
|
14
|
+
tool_calls: !!tool_calls,
|
|
15
|
+
interruption_ids: Array(interruption_ids).map { |id| String(id).dup.freeze }.freeze,
|
|
16
|
+
batch_key: batch_key.nil? ? nil : String(batch_key).dup.freeze
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def tool_calls? = tool_calls
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
Batch = Data.define(:tickets) do
|
|
24
|
+
def interruption_ids = tickets.map(&:id)
|
|
25
|
+
def batch_key = tickets.first&.batch_key
|
|
26
|
+
def metadata = tickets.last&.metadata
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class Ticket
|
|
30
|
+
attr_reader :id, :message, :batch_key, :metadata
|
|
31
|
+
|
|
32
|
+
def initialize(message, id:, batch_key:, metadata:)
|
|
33
|
+
@id = id
|
|
34
|
+
@message = message
|
|
35
|
+
@batch_key = batch_key
|
|
36
|
+
@metadata = metadata
|
|
37
|
+
@mutex = Mutex.new
|
|
38
|
+
@condition = ConditionVariable.new
|
|
39
|
+
@resolved = false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resolve(value)
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
return if @resolved
|
|
45
|
+
|
|
46
|
+
@value = value
|
|
47
|
+
@resolved = true
|
|
48
|
+
@condition.broadcast
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def reject(error)
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
return if @resolved
|
|
55
|
+
|
|
56
|
+
@error = error
|
|
57
|
+
@resolved = true
|
|
58
|
+
@condition.broadcast
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def value(cancellation_token:, deadline:)
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
until @resolved
|
|
65
|
+
cancellation_token.raise_if_cancelled!
|
|
66
|
+
raise DeadlineExceededError, "The run deadline was reached" if deadline && Time.now >= deadline
|
|
67
|
+
|
|
68
|
+
timeout = deadline ? [deadline - Time.now, 0.05].min : 0.05
|
|
69
|
+
@condition.wait(@mutex, [timeout, 0].max)
|
|
70
|
+
end
|
|
71
|
+
cancellation_token.raise_if_cancelled!
|
|
72
|
+
raise DeadlineExceededError, "The run deadline was reached" if deadline && Time.now >= deadline
|
|
73
|
+
raise @error if @error
|
|
74
|
+
|
|
75
|
+
@value
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
attr_reader :operation_id, :target_operation_id
|
|
81
|
+
|
|
82
|
+
def initialize
|
|
83
|
+
@mutex = Mutex.new
|
|
84
|
+
@queue = []
|
|
85
|
+
@tickets_by_id = {}
|
|
86
|
+
@waiters = Hash.new(0)
|
|
87
|
+
@delivered = nil
|
|
88
|
+
@closed_error = nil
|
|
89
|
+
@operation_id = nil
|
|
90
|
+
@target_operation_id = nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def bind(operation_id, target_operation_id:)
|
|
94
|
+
@mutex.synchronize do
|
|
95
|
+
@operation_id = operation_id
|
|
96
|
+
@target_operation_id = target_operation_id || operation_id
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def enqueue(message, id: SecureRandom.uuid, batch_key: nil, metadata: {})
|
|
101
|
+
id = String(id)
|
|
102
|
+
raise ArgumentError, "interruption_id cannot be empty" if id.empty?
|
|
103
|
+
|
|
104
|
+
batch_key = String(batch_key) unless batch_key.nil?
|
|
105
|
+
metadata = metadata.to_h
|
|
106
|
+
@mutex.synchronize do
|
|
107
|
+
raise @closed_error if @closed_error
|
|
108
|
+
|
|
109
|
+
existing = @tickets_by_id[id]
|
|
110
|
+
if existing
|
|
111
|
+
unless existing.message.to_h == message.to_h &&
|
|
112
|
+
existing.batch_key == batch_key &&
|
|
113
|
+
existing.metadata == metadata
|
|
114
|
+
raise ArgumentError, "interruption_id has already been used with different input"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
@waiters[existing] += 1
|
|
118
|
+
return existing
|
|
119
|
+
end
|
|
120
|
+
if @tickets_by_id.length >= MAX_INTERRUPTION_COUNT
|
|
121
|
+
raise AgentInterruptError, "Agent interruption capacity reached"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
ticket = Ticket.new(message, id: id.freeze, batch_key: batch_key&.freeze, metadata:)
|
|
125
|
+
@tickets_by_id[id] = ticket
|
|
126
|
+
@waiters[ticket] = 1
|
|
127
|
+
@queue << ticket
|
|
128
|
+
ticket
|
|
129
|
+
end
|
|
130
|
+
rescue TypeError, NoMethodError
|
|
131
|
+
raise ArgumentError, "interruption_id, batch_key, and metadata are invalid"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def deliver
|
|
135
|
+
@mutex.synchronize do
|
|
136
|
+
return if @delivered || @queue.empty?
|
|
137
|
+
|
|
138
|
+
batch_key = @queue.first.batch_key
|
|
139
|
+
tickets = []
|
|
140
|
+
tickets << @queue.shift
|
|
141
|
+
if batch_key
|
|
142
|
+
while tickets.length < MAX_BATCH_SIZE && @queue.first&.batch_key == batch_key
|
|
143
|
+
tickets << @queue.shift
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
@delivered = Batch.new(tickets: tickets.freeze)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def resolve(batch, response)
|
|
151
|
+
tickets = @mutex.synchronize do
|
|
152
|
+
return unless batch && @delivered.equal?(batch)
|
|
153
|
+
|
|
154
|
+
@delivered = nil
|
|
155
|
+
batch.tickets
|
|
156
|
+
end
|
|
157
|
+
tickets.each { |ticket| ticket.resolve(response) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def pending?
|
|
161
|
+
@mutex.synchronize { !@queue.empty? }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def finish
|
|
165
|
+
@mutex.synchronize do
|
|
166
|
+
return true if @closed_error
|
|
167
|
+
return false if @delivered || !@queue.empty?
|
|
168
|
+
|
|
169
|
+
@closed_error = AgentInterruptError.new("Agent is not currently running")
|
|
170
|
+
true
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def release(ticket, withdraw: false)
|
|
175
|
+
@mutex.synchronize do
|
|
176
|
+
@waiters[ticket] -= 1
|
|
177
|
+
return unless withdraw && @waiters[ticket] <= 0 && @queue.delete(ticket)
|
|
178
|
+
|
|
179
|
+
@tickets_by_id.delete(ticket.id)
|
|
180
|
+
@waiters.delete(ticket)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def close(error)
|
|
185
|
+
tickets = @mutex.synchronize do
|
|
186
|
+
return if @closed_error
|
|
187
|
+
|
|
188
|
+
@closed_error = error
|
|
189
|
+
values = [*@delivered&.tickets, *@queue].compact
|
|
190
|
+
@delivered = nil
|
|
191
|
+
@queue.clear
|
|
192
|
+
values
|
|
193
|
+
end
|
|
194
|
+
tickets.each { |ticket| ticket.reject(error) }
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|