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,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