little_ghost 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/docs/guides/code_mode.md +4 -3
- data/docs/guides/integrations.md +4 -4
- data/docs/guides/production.md +114 -7
- data/docs/guides/tools.md +26 -0
- data/lib/little_ghost/agent.rb +5 -5
- data/lib/little_ghost/assembly.rb +9 -11
- data/lib/little_ghost/assembly_execution.rb +8 -17
- data/lib/little_ghost/code_mode/broker.rb +4 -0
- data/lib/little_ghost/code_mode/javascript/session.rb +23 -24
- data/lib/little_ghost/code_mode/ruby/session.rb +18 -18
- data/lib/little_ghost/configuration.rb +101 -9
- data/lib/little_ghost/execution.rb +31 -25
- data/lib/little_ghost/execution_state.rb +3 -3
- data/lib/little_ghost/graph.rb +6 -3
- data/lib/little_ghost/instrumentation.rb +2 -1
- data/lib/little_ghost/mcp/toolset.rb +1 -1
- data/lib/little_ghost/network/certificate_authority.rb +6 -0
- data/lib/little_ghost/network/envoy_gateway.rb +11 -2
- data/lib/little_ghost/run.rb +9 -15
- data/lib/little_ghost/run_context.rb +2 -1
- data/lib/little_ghost/runtime.rb +12 -3
- data/lib/little_ghost/sandbox/process_runner.rb +1 -1
- data/lib/little_ghost/sandboxes/unrestricted.rb +1 -2
- data/lib/little_ghost/session_stores/filesystem.rb +29 -13
- data/lib/little_ghost/subagents/manager.rb +170 -70
- data/lib/little_ghost/support/cancellation_token.rb +2 -2
- data/lib/little_ghost/support/executor.rb +84 -15
- data/lib/little_ghost/support/http_client.rb +9 -2
- data/lib/little_ghost/support/interruptible_stream.rb +4 -2
- data/lib/little_ghost/support/pooled_thread_runner.rb +126 -0
- data/lib/little_ghost/support/serialized_dispatcher.rb +84 -0
- data/lib/little_ghost/support/task.rb +126 -0
- data/lib/little_ghost/support/task_runner.rb +74 -0
- data/lib/little_ghost/support.rb +4 -0
- data/lib/little_ghost/version.rb +1 -1
- data/lib/little_ghost/workflow.rb +3 -3
- data/lib/little_ghost.rb +17 -0
- metadata +19 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f7e4df5249569e4b93c22c44929064ad12632ca6119667429004ffc3bc4b7f7a
|
|
4
|
+
data.tar.gz: 6ad9b9d7252c1e0749f8d4503ba820259257fb4b0e46bc638ff3bd426ed191d9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0a1e7fdc5cfe228456703b0d8b383f67461f5a465f962f010cacbee5ef55a90881f7e7135c5107745f1442a0803ddbe865b4d50912485e738b21566282404c34
|
|
7
|
+
data.tar.gz: db5f637104b9c42a8d470b80079b29a877fddf8a41c1a5c110d8a4b926c136b36933ffe234870a0ab1a53f98083d5f856f2fd1c44ea967ae9852f5d7abebc878
|
data/docs/guides/code_mode.md
CHANGED
|
@@ -260,10 +260,11 @@ contains model-written code. An engine may request named runtime paths for its
|
|
|
260
260
|
interpreter libraries. Those paths are visible to the child process, but they
|
|
261
261
|
never become filesystem grants available through Tools.
|
|
262
262
|
|
|
263
|
-
The session owns every Workspace, Sandbox, child process,
|
|
263
|
+
The session owns every Workspace, Sandbox, child process, background task, and
|
|
264
264
|
communication channel it creates. It closes those resources in reverse order.
|
|
265
|
-
|
|
266
|
-
|
|
265
|
+
One registered engine can open several sessions concurrently on threads or
|
|
266
|
+
fibers. Keep each program's mutable state inside the returned session, and
|
|
267
|
+
synchronize any state the engine shares across sessions.
|
|
267
268
|
|
|
268
269
|
A sandboxed engine must use a backend that owns the complete child process tree
|
|
269
270
|
or can prevent child processes. An explicitly unrestricted backend may run an
|
data/docs/guides/integrations.md
CHANGED
|
@@ -170,8 +170,8 @@ events.each { |event| websocket.write(JSON.generate(event)) }
|
|
|
170
170
|
|
|
171
171
|
The adapter translates text, reasoning, Tool activity, usage, retries, trace
|
|
172
172
|
context, subagent activity, and terminal outcomes. It is stateless between
|
|
173
|
-
calls. Your application still owns the connection,
|
|
174
|
-
|
|
173
|
+
calls. Your application still owns the connection, backpressure, disconnect
|
|
174
|
+
behavior, and any request state its callbacks need.
|
|
175
175
|
|
|
176
176
|
LittleGhost also emits namespaced custom events. Consumers should preserve or
|
|
177
177
|
deliberately ignore event types they don't recognize. See the [AG-UI event
|
|
@@ -182,8 +182,8 @@ client.
|
|
|
182
182
|
> results, errors, and participant activity. Check that the connected user may
|
|
183
183
|
> see the complete Run, then filter fields before sending or storing events.
|
|
184
184
|
|
|
185
|
-
|
|
186
|
-
disconnects, stop enumerating and apply the cancellation behavior your
|
|
185
|
+
Calling `each` drives the source stream on the caller's fiber or thread. When a
|
|
186
|
+
client disconnects, stop enumerating and apply the cancellation behavior your
|
|
187
187
|
application needs. Closing the socket can't undo Tool work that already ran.
|
|
188
188
|
|
|
189
189
|
## Trace Runs with OpenTelemetry
|
data/docs/guides/production.md
CHANGED
|
@@ -75,6 +75,103 @@ The controller supplies identity and account access from authenticated applicati
|
|
|
75
75
|
|
|
76
76
|
Configure LittleGhost before the first Agent or Assembly call. Once application services start successfully, the configuration is locked so every request sees one stable setup.
|
|
77
77
|
|
|
78
|
+
## Use an existing fiber scheduler
|
|
79
|
+
|
|
80
|
+
If your application already runs inside a Ruby Fiber scheduler, LittleGhost
|
|
81
|
+
uses it for parallel Tool calls, Workflow and Graph branches, subagent turns,
|
|
82
|
+
background Executions, and nested code-mode Tool calls. LittleGhost does not
|
|
83
|
+
install or run a scheduler. Your scheduler must support `Fiber.schedule`. The
|
|
84
|
+
optional `async` gem used below does.
|
|
85
|
+
|
|
86
|
+
For example, an application using the optional `async` gem can let several
|
|
87
|
+
requests make progress on one thread while each request waits for network I/O.
|
|
88
|
+
Add `gem "async"` to the application's bundle, then start the calls inside an
|
|
89
|
+
Async task:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
require "async"
|
|
93
|
+
|
|
94
|
+
questions = [
|
|
95
|
+
"How long do refunds take?",
|
|
96
|
+
"Can I update my delivery address?"
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
answers = Async do |task|
|
|
100
|
+
questions.map do |question|
|
|
101
|
+
task.async { CustomerSupportAgent.ask(question).response }
|
|
102
|
+
end.map(&:wait)
|
|
103
|
+
end.wait
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The default `:auto` setting uses fibers when a call begins inside a scheduled
|
|
107
|
+
fiber and worker threads everywhere else. Set `concurrency_backend` to `:fiber`
|
|
108
|
+
when calling outside a scheduled fiber should be an application error.
|
|
109
|
+
LittleGhost then raises `LittleGhost::ConfigurationError` instead of quietly
|
|
110
|
+
starting a thread.
|
|
111
|
+
|
|
112
|
+
### Keep one blocking call from pausing other fibers
|
|
113
|
+
|
|
114
|
+
Fiber scheduling helps while work waits for I/O; it does not make CPU-heavy
|
|
115
|
+
Ruby code run in parallel. Whether an I/O call lets other fibers run depends on
|
|
116
|
+
the Ruby version, scheduler, and library. Call libraries normally at first. If
|
|
117
|
+
documentation or measurement shows that one call pauses the other fibers—and
|
|
118
|
+
the work can continue on another Ruby thread—wrap that call:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
article = LittleGhost.offload_blocking do
|
|
122
|
+
HelpCenterSearch.lookup(question)
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Outside a scheduled fiber, `offload_blocking` runs the block inline. Inside one,
|
|
127
|
+
it uses LittleGhost's shared pool of reusable threads so the other fibers can
|
|
128
|
+
continue. If the caller is cancelled after the pool accepts the block,
|
|
129
|
+
LittleGhost waits for the block to finish before reporting the cancellation.
|
|
130
|
+
The helper does not add a timeout or cancellation mechanism to the underlying
|
|
131
|
+
call, so use the library's controls when available.
|
|
132
|
+
|
|
133
|
+
If most of your Tool or extension code prevents other fibers from running, use
|
|
134
|
+
worker threads for all LittleGhost concurrency instead:
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
LittleGhost.configure do |config|
|
|
138
|
+
config.concurrency_backend = :thread
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
LittleGhost also keeps a few jobs on threads so it can finish or clean them up
|
|
143
|
+
reliably. Provider and subprocess streams may use dedicated threads. Inside a
|
|
144
|
+
scheduled fiber, certificate generation and Filesystem SessionStore
|
|
145
|
+
transactions use the same shared pool as `offload_blocking`. A Run may
|
|
146
|
+
therefore still create or use threads when its concurrency backend is `:fiber`.
|
|
147
|
+
|
|
148
|
+
The blocking pool allows two operations at a time by default. Applications
|
|
149
|
+
that observe calls waiting for a pool worker can increase its process-wide
|
|
150
|
+
capacity during startup, before any call can start the pool:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
LittleGhost.configure do |config|
|
|
154
|
+
config.blocking_pool_capacity 4
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Increasing this value permits more operating-system threads. It does not
|
|
159
|
+
change Tool, Workflow, Graph, or subagent concurrency. The value is
|
|
160
|
+
process-wide; every `Configuration` reads and writes the same setting.
|
|
161
|
+
|
|
162
|
+
### Protect state shared by concurrent calls
|
|
163
|
+
|
|
164
|
+
LittleGhost may call a shared Tool, provider, SessionStore, hook, or callback
|
|
165
|
+
from different threads. Fibers can also take turns entering the same object on
|
|
166
|
+
one thread. Protect shared mutable state, keep lock scope narrow, and do not
|
|
167
|
+
call application callbacks while holding a lock.
|
|
168
|
+
|
|
169
|
+
Pass request-specific values through the Invocation context or another explicit
|
|
170
|
+
argument. Do not use `thread_variable_set` for request state because every
|
|
171
|
+
fiber on the thread shares those values. `Thread.current[:key]` is fiber-local,
|
|
172
|
+
but LittleGhost does not copy application-defined entries into each worker
|
|
173
|
+
task.
|
|
174
|
+
|
|
78
175
|
## Preserve conversation with Sessions
|
|
79
176
|
|
|
80
177
|
A **Session** lets one request continue an earlier conversation. Pass the same session ID and trusted actor ID with each related call:
|
|
@@ -106,7 +203,8 @@ Every Run has a session ID so LittleGhost can checkpoint its progress. If you do
|
|
|
106
203
|
|
|
107
204
|
## Stream or supervise long-running work
|
|
108
205
|
|
|
109
|
-
`.stream_ask` runs on the caller's thread and yields `StreamEvent`
|
|
206
|
+
`.stream_ask` runs on the caller's fiber or thread and yields `StreamEvent`
|
|
207
|
+
values as the answer arrives:
|
|
110
208
|
|
|
111
209
|
```ruby
|
|
112
210
|
stream = CustomerSupportAgent.stream_ask(question)
|
|
@@ -138,7 +236,13 @@ execution.wait(deadline: Time.now + 30)
|
|
|
138
236
|
execution.run.completed?
|
|
139
237
|
```
|
|
140
238
|
|
|
141
|
-
The event block runs on the
|
|
239
|
+
The event block runs on the same fiber or thread as the Execution. With `:auto`,
|
|
240
|
+
`start_execution` uses a fiber when its caller is already in a scheduled fiber;
|
|
241
|
+
otherwise, it uses a worker thread. Keep the block quick because it slows event
|
|
242
|
+
delivery while it runs. Cancellation, deadlines, and `close` ask the work to
|
|
243
|
+
stop; they cannot forcibly end arbitrary provider or Tool code or undo actions
|
|
244
|
+
that already happened. Keep the application's scheduler running until an
|
|
245
|
+
Execution using it finishes or closes.
|
|
142
246
|
|
|
143
247
|
## Keep Tool permission checks in application code
|
|
144
248
|
|
|
@@ -239,14 +343,17 @@ agent = CustomerSupportAgent.new(runtime: runtime)
|
|
|
239
343
|
An explicit Runtime has its own independent configuration. It does not replace
|
|
240
344
|
LittleGhost's shared default.
|
|
241
345
|
|
|
242
|
-
One Runtime can serve independent calls from several threads. Each
|
|
243
|
-
its own Run, participants, Tools, and Runtime-created Workspace and
|
|
244
|
-
Agent or Assembly already bound to an active Run must stay with
|
|
346
|
+
One Runtime can serve independent calls from several threads and fibers. Each
|
|
347
|
+
call gets its own Run, participants, Tools, and Runtime-created Workspace and
|
|
348
|
+
Sandbox. An Agent or Assembly already bound to an active Run must stay with
|
|
349
|
+
that Run.
|
|
245
350
|
|
|
246
351
|
Within one SessionStore instance, LittleGhost serializes calls sharing a
|
|
247
352
|
Session. Multi-process deployments need coordination from their store. Custom
|
|
248
|
-
stores and other shared extension objects may receive concurrent calls
|
|
249
|
-
|
|
353
|
+
stores and other shared extension objects may receive concurrent calls. Calls
|
|
354
|
+
can overlap on different threads, or fibers can take turns entering the same
|
|
355
|
+
object on one thread. Protect shared mutable state without relying on thread
|
|
356
|
+
identity.
|
|
250
357
|
|
|
251
358
|
Runtime has no shutdown step. Shared services supplied by the application keep their own lifecycle. Shut those services down with the rest of your application. If you installed process-wide instrumentation subscribers, flush or shut down `LittleGhost::Instrumentation` during application shutdown.
|
|
252
359
|
|
data/docs/guides/tools.md
CHANGED
|
@@ -220,6 +220,32 @@ class UpdateDraftTool < LittleGhost::Tool
|
|
|
220
220
|
end
|
|
221
221
|
```
|
|
222
222
|
|
|
223
|
+
When LittleGhost selects the fiber backend, concurrent Tool calls can run as
|
|
224
|
+
fibers on one thread. If a Tool calls a library that blocks that thread, every
|
|
225
|
+
other fiber on it must wait too. Suppose the help center lookup later moves to
|
|
226
|
+
a client whose `lookup` method is documented to behave this way. Change only
|
|
227
|
+
the Tool method:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
def call(input)
|
|
231
|
+
LittleGhost.offload_blocking do
|
|
232
|
+
HelpCenterClient.lookup(input.fetch("topic"))
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Many Ruby I/O calls already let the scheduler run other fibers. Keep those
|
|
238
|
+
calls unchanged. Use `offload_blocking` only when documentation or measurement
|
|
239
|
+
shows that the exact call pauses other fibers and the work can continue on
|
|
240
|
+
another Ruby thread. Configure the call's own timeout or cancellation when it
|
|
241
|
+
provides one.
|
|
242
|
+
|
|
243
|
+
If most of a Tool's implementation blocks, configure LittleGhost to use the
|
|
244
|
+
`:thread` backend instead of wrapping each call. `exclusive true` prevents
|
|
245
|
+
overlap with another exclusive Tool; it does not change where the Tool runs.
|
|
246
|
+
[Running in Production](production.md#use-an-existing-fiber-scheduler) explains
|
|
247
|
+
the concurrency settings and when to adjust the shared thread pool.
|
|
248
|
+
|
|
223
249
|
Retries can repeat a Tool call. Prefer read-only operations, idempotency keys,
|
|
224
250
|
or writes that are safe to apply more than once. Do not rely on the prompt to
|
|
225
251
|
prevent duplicate side effects.
|
data/lib/little_ghost/agent.rb
CHANGED
|
@@ -547,7 +547,7 @@ module LittleGhost
|
|
|
547
547
|
template_resolver: nil,
|
|
548
548
|
template_paths: [],
|
|
549
549
|
run: nil,
|
|
550
|
-
executor:
|
|
550
|
+
executor: nil,
|
|
551
551
|
delegation_activity: nil,
|
|
552
552
|
agent_path: Subagents::AgentPath::ROOT,
|
|
553
553
|
max_turns: 100,
|
|
@@ -591,7 +591,7 @@ module LittleGhost
|
|
|
591
591
|
)
|
|
592
592
|
@model_settings = model_settings.to_h.freeze
|
|
593
593
|
@template_resolver = template_resolver || default_template_resolver(template_paths)
|
|
594
|
-
@executor = executor
|
|
594
|
+
@executor = executor || Support::Executor.new(runner: task_runner)
|
|
595
595
|
@delegation_activity = delegation_activity
|
|
596
596
|
@agent_path = Subagents::AgentPath.validate!(agent_path)
|
|
597
597
|
@max_turns = Integer(max_turns)
|
|
@@ -609,8 +609,8 @@ module LittleGhost
|
|
|
609
609
|
raise ArgumentError, "max_turns must be at least 1" if @max_turns < 1
|
|
610
610
|
raise ArgumentError, "max_tool_calls must be at least 1" if @max_tool_calls < 1
|
|
611
611
|
raise ArgumentError, "max_tool_result_tokens must be at least 1" if @max_tool_result_tokens < 1
|
|
612
|
-
@artifact_lifecycle =
|
|
613
|
-
|
|
612
|
+
@artifact_lifecycle = @runtime&.then do |resolved_runtime|
|
|
613
|
+
resolved_runtime.runtime_hooks.find { |hook| hook.is_a?(Runtime::Hooks::Artifacts) }
|
|
614
614
|
end
|
|
615
615
|
apply_cancellation_decision!(run_callbacks(:after_initialize, self))
|
|
616
616
|
rescue
|
|
@@ -2126,7 +2126,7 @@ module LittleGhost
|
|
|
2126
2126
|
declaration = self.class.code_mode_configuration
|
|
2127
2127
|
return unless declaration
|
|
2128
2128
|
|
|
2129
|
-
defaults = @runtime
|
|
2129
|
+
defaults = @runtime&.code_mode_configuration
|
|
2130
2130
|
@code_mode_declaration = (defaults || {}).merge(declaration).transform_keys(&:to_sym).freeze
|
|
2131
2131
|
unknown = @code_mode_declaration.keys - %i[engine except sandbox limits]
|
|
2132
2132
|
raise ConfigurationError, "Unknown code-mode option: #{unknown.first.inspect}" unless unknown.empty?
|
|
@@ -155,8 +155,8 @@ module LittleGhost
|
|
|
155
155
|
def initialize(run: nil, runtime: nil, workspace: nil, sandbox: nil, standalone: run.nil?) # :nodoc:
|
|
156
156
|
@run = run
|
|
157
157
|
@runtime = runtime || run&.runtime || LittleGhost.runtime
|
|
158
|
-
@workspace = workspace ||
|
|
159
|
-
@sandbox = sandbox ||
|
|
158
|
+
@workspace = workspace || run&.workspace
|
|
159
|
+
@sandbox = sandbox || run&.sandbox
|
|
160
160
|
@standalone = standalone
|
|
161
161
|
@assembly_mutex = Mutex.new
|
|
162
162
|
@assembly_closed = false
|
|
@@ -187,7 +187,7 @@ module LittleGhost
|
|
|
187
187
|
runtime.build_run(payload, **options)
|
|
188
188
|
end
|
|
189
189
|
|
|
190
|
-
# Starts +payload+
|
|
190
|
+
# Starts +payload+ in the background and returns an Execution.
|
|
191
191
|
# Composite assemblies include contextual +:agent_stream+ events in the
|
|
192
192
|
# consumer by default. Set +include_agent_events+ to +false+ in +payload+
|
|
193
193
|
# to keep only the ordinary public stream.
|
|
@@ -341,6 +341,10 @@ module LittleGhost
|
|
|
341
341
|
|
|
342
342
|
def standalone? = @standalone # :nodoc:
|
|
343
343
|
|
|
344
|
+
def task_runner # :nodoc:
|
|
345
|
+
@task_runner ||= runtime ? runtime.task_runner : Support::TaskRunner.new
|
|
346
|
+
end
|
|
347
|
+
|
|
344
348
|
def with_active_assembly(assembly) # :nodoc:
|
|
345
349
|
@assembly_mutex.synchronize do
|
|
346
350
|
raise Error, "assembly is already closed" if @assembly_closed
|
|
@@ -373,14 +377,8 @@ module LittleGhost
|
|
|
373
377
|
end
|
|
374
378
|
|
|
375
379
|
def build_tool_assembly
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
kind == :keyrest || (%i[key keyreq].include?(kind) && name == :agent_stream_path)
|
|
379
|
-
end
|
|
380
|
-
options = {run:}
|
|
381
|
-
options[:agent_stream_path] = agent_stream_path if accepts_path
|
|
382
|
-
child = builder.call(self.class, **options)
|
|
383
|
-
child.bind_agent_stream_path(agent_stream_path) if child.respond_to?(:bind_agent_stream_path)
|
|
380
|
+
child = runtime.build_assembly(self.class, run:, agent_stream_path:)
|
|
381
|
+
child.bind_agent_stream_path(agent_stream_path)
|
|
384
382
|
child
|
|
385
383
|
end
|
|
386
384
|
|
|
@@ -206,7 +206,7 @@ module LittleGhost
|
|
|
206
206
|
(policy.fetch(:retries) + 1).times do |index|
|
|
207
207
|
attempt_number = index + 1
|
|
208
208
|
started_at = Time.now
|
|
209
|
-
child = if build_options.any?
|
|
209
|
+
child = if build_options.any?
|
|
210
210
|
build_stream_participant(:build_agent, reference, stream_path, **build_options)
|
|
211
211
|
else
|
|
212
212
|
build_stream_participant(:build_assembly, reference, stream_path, **build_options)
|
|
@@ -254,8 +254,8 @@ module LittleGhost
|
|
|
254
254
|
predecessor_ids:,
|
|
255
255
|
branch_id:,
|
|
256
256
|
participant:,
|
|
257
|
-
assembly_id: child.class.
|
|
258
|
-
assembly_kind: child.class.
|
|
257
|
+
assembly_id: child.class.assembly_id,
|
|
258
|
+
assembly_kind: child.class.assembly_kind,
|
|
259
259
|
status: :completed,
|
|
260
260
|
attempts:,
|
|
261
261
|
usage:,
|
|
@@ -339,14 +339,8 @@ module LittleGhost
|
|
|
339
339
|
end
|
|
340
340
|
|
|
341
341
|
def build_stream_participant(builder_name, reference, stream_path, **options)
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
accepts_path = parameters.any? do |kind, name|
|
|
345
|
-
kind == :keyrest || (%i[key keyreq].include?(kind) && name == :agent_stream_path)
|
|
346
|
-
end
|
|
347
|
-
options[:agent_stream_path] = stream_path if accepts_path
|
|
348
|
-
child = builder.call(reference, run:, **options)
|
|
349
|
-
child.bind_agent_stream_path(stream_path) if child.respond_to?(:bind_agent_stream_path)
|
|
342
|
+
child = runtime.public_send(builder_name, reference, run:, agent_stream_path: stream_path, **options)
|
|
343
|
+
child.bind_agent_stream_path(stream_path)
|
|
350
344
|
child
|
|
351
345
|
end
|
|
352
346
|
|
|
@@ -420,11 +414,11 @@ module LittleGhost
|
|
|
420
414
|
error.instance_variable_set(:@little_ghost_step_usage, usage)
|
|
421
415
|
error.instance_variable_set(
|
|
422
416
|
:@little_ghost_step_assembly_id,
|
|
423
|
-
child.class.
|
|
417
|
+
child.class.assembly_id
|
|
424
418
|
)
|
|
425
419
|
error.instance_variable_set(
|
|
426
420
|
:@little_ghost_step_assembly_kind,
|
|
427
|
-
child.class.
|
|
421
|
+
child.class.assembly_kind
|
|
428
422
|
)
|
|
429
423
|
end
|
|
430
424
|
|
|
@@ -539,10 +533,7 @@ module LittleGhost
|
|
|
539
533
|
def enqueue_assembly_event(queue, value, cancellation_token)
|
|
540
534
|
loop do
|
|
541
535
|
cancellation_token.raise_if_cancelled!
|
|
542
|
-
queue.push(value,
|
|
543
|
-
return
|
|
544
|
-
rescue ThreadError
|
|
545
|
-
cancellation_token.wait(0.01)
|
|
536
|
+
return if queue.push(value, timeout: 0.01)
|
|
546
537
|
end
|
|
547
538
|
end
|
|
548
539
|
|
|
@@ -14,6 +14,8 @@ module LittleGhost
|
|
|
14
14
|
def initialize(agent: nil, registry: nil, context: RunContext.new, events: [],
|
|
15
15
|
parent_operation_id: nil, parent_trace_context: nil, except: nil, dispatch: nil, max_calls: nil)
|
|
16
16
|
@agent = agent
|
|
17
|
+
runtime = agent&.runtime
|
|
18
|
+
@task_runner = runtime ? runtime.task_runner : Support::TaskRunner.new
|
|
17
19
|
@registry = registry || agent&.tool_registry
|
|
18
20
|
@context = context
|
|
19
21
|
@events = events
|
|
@@ -45,6 +47,8 @@ module LittleGhost
|
|
|
45
47
|
|
|
46
48
|
attr_writer :context # :nodoc:
|
|
47
49
|
|
|
50
|
+
attr_reader :task_runner # :nodoc:
|
|
51
|
+
|
|
48
52
|
def bind(context:, events:, parent_operation_id:, parent_trace_context: nil) # :nodoc:
|
|
49
53
|
@context = context
|
|
50
54
|
@events = events
|
|
@@ -14,11 +14,12 @@ module LittleGhost
|
|
|
14
14
|
|
|
15
15
|
ToolCall = Data.define(:program_id, :call_id, :name, :arguments)
|
|
16
16
|
ToolBatch = Data.define(:calls)
|
|
17
|
-
Deadline = Struct.new(:
|
|
17
|
+
Deadline = Struct.new(:task, :cancelled, :expiring, :finished, :error)
|
|
18
18
|
|
|
19
19
|
def initialize(broker:, client:, sandbox: nil, workspace: nil, max_concurrency: 8,
|
|
20
20
|
wall_seconds: 3_600, observation_seconds: OBSERVATION_SECONDS, cleanup_timeout: CLEANUP_TIMEOUT)
|
|
21
21
|
@broker = broker
|
|
22
|
+
@task_runner = broker.task_runner
|
|
22
23
|
@client = client
|
|
23
24
|
@sandbox = sandbox
|
|
24
25
|
@workspace = workspace
|
|
@@ -82,8 +83,12 @@ module LittleGhost
|
|
|
82
83
|
begin
|
|
83
84
|
@client.close_owner(self)
|
|
84
85
|
wait_for_dispatches(program_ids)
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
if worker
|
|
87
|
+
begin
|
|
88
|
+
worker.wait(deadline: Time.now + @cleanup_timeout)
|
|
89
|
+
rescue LittleGhost::DeadlineExceededError
|
|
90
|
+
raise LittleGhost::CleanupError, "Code-mode dispatch worker cleanup timed out"
|
|
91
|
+
end
|
|
87
92
|
end
|
|
88
93
|
ensure
|
|
89
94
|
@client.close
|
|
@@ -126,6 +131,7 @@ module LittleGhost
|
|
|
126
131
|
@frames[program_id] = javascript_catalog
|
|
127
132
|
@current_program_id = program_id
|
|
128
133
|
end
|
|
134
|
+
ensure_worker if @task_runner.uses_current_scheduler?
|
|
129
135
|
@client.start_program(
|
|
130
136
|
owner: self, dispatcher: self, source:, tools: javascript_catalog.host_definitions, program_id:
|
|
131
137
|
)
|
|
@@ -264,8 +270,7 @@ module LittleGhost
|
|
|
264
270
|
def ensure_worker
|
|
265
271
|
return if @worker&.alive?
|
|
266
272
|
|
|
267
|
-
@worker =
|
|
268
|
-
@worker.report_on_exception = false
|
|
273
|
+
@worker = @task_runner.spawn { dispatch }
|
|
269
274
|
end
|
|
270
275
|
|
|
271
276
|
def drain_dispatch_queue
|
|
@@ -317,17 +322,12 @@ module LittleGhost
|
|
|
317
322
|
invalid.each { |call| reject(call, "Unknown or invalid code-mode tool call: #{call.name}") }
|
|
318
323
|
next if valid.empty?
|
|
319
324
|
|
|
320
|
-
results =
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
id: "code-mode-#{call.program_id}-#{call.call_id}"
|
|
327
|
-
)
|
|
328
|
-
end.tap { |thread| thread.report_on_exception = false }
|
|
329
|
-
end
|
|
330
|
-
threads.map(&:value)
|
|
325
|
+
results = Support::Executor.new(max_concurrency: @max_concurrency, runner: @task_runner).map(valid) do |call|
|
|
326
|
+
definition = catalog.fetch(call.name)
|
|
327
|
+
@broker.call(
|
|
328
|
+
definition.fetch("canonical_name"), call.arguments,
|
|
329
|
+
id: "code-mode-#{call.program_id}-#{call.call_id}"
|
|
330
|
+
)
|
|
331
331
|
end
|
|
332
332
|
valid.zip(results).each do |call, result|
|
|
333
333
|
if result.error.nil?
|
|
@@ -455,7 +455,7 @@ module LittleGhost
|
|
|
455
455
|
state = Deadline.new(nil, false, false, false, nil)
|
|
456
456
|
@deadline_mutex.synchronize do
|
|
457
457
|
@deadlines[program_id] = state
|
|
458
|
-
state.
|
|
458
|
+
state.task = @task_runner.spawn do
|
|
459
459
|
expired = @deadline_mutex.synchronize do
|
|
460
460
|
loop do
|
|
461
461
|
break false if state.cancelled
|
|
@@ -470,7 +470,6 @@ module LittleGhost
|
|
|
470
470
|
rescue => error
|
|
471
471
|
finish_deadline(program_id, state, error)
|
|
472
472
|
end
|
|
473
|
-
state.thread.report_on_exception = false
|
|
474
473
|
end
|
|
475
474
|
end
|
|
476
475
|
|
|
@@ -509,7 +508,7 @@ module LittleGhost
|
|
|
509
508
|
end
|
|
510
509
|
|
|
511
510
|
def cancel_deadline(program_id)
|
|
512
|
-
|
|
511
|
+
task = @deadline_mutex.synchronize do
|
|
513
512
|
state = @deadlines[program_id]
|
|
514
513
|
return unless state
|
|
515
514
|
|
|
@@ -520,9 +519,9 @@ module LittleGhost
|
|
|
520
519
|
@deadline_condition.broadcast
|
|
521
520
|
end
|
|
522
521
|
@deadlines.delete(program_id)
|
|
523
|
-
state.
|
|
522
|
+
state.task
|
|
524
523
|
end
|
|
525
|
-
|
|
524
|
+
task&.wait unless task&.current?
|
|
526
525
|
end
|
|
527
526
|
|
|
528
527
|
def cancel_all_deadlines
|
|
@@ -541,12 +540,12 @@ module LittleGhost
|
|
|
541
540
|
end
|
|
542
541
|
|
|
543
542
|
def raise_pending_deadline_error!
|
|
544
|
-
error,
|
|
543
|
+
error, task = @deadline_mutex.synchronize do
|
|
545
544
|
pair = @pending_deadline_errors.shift
|
|
546
545
|
state = @deadlines.delete(pair&.first)
|
|
547
|
-
[pair&.last, state&.
|
|
546
|
+
[pair&.last, state&.task]
|
|
548
547
|
end
|
|
549
|
-
|
|
548
|
+
task&.wait unless task&.current?
|
|
550
549
|
raise error if error
|
|
551
550
|
end
|
|
552
551
|
|
|
@@ -14,6 +14,7 @@ module LittleGhost
|
|
|
14
14
|
|
|
15
15
|
def initialize(broker:, sandbox_factory:, subprocess_policy:, limits:, observation_seconds: OBSERVATION_SECONDS)
|
|
16
16
|
@broker = broker
|
|
17
|
+
@task_runner = broker.task_runner
|
|
17
18
|
@sandbox_factory = sandbox_factory
|
|
18
19
|
@subprocess_policy = subprocess_policy
|
|
19
20
|
@limits = limits
|
|
@@ -28,7 +29,7 @@ module LittleGhost
|
|
|
28
29
|
@control_mutex = Mutex.new
|
|
29
30
|
@programs = 0
|
|
30
31
|
@call_mutex = Mutex.new
|
|
31
|
-
@
|
|
32
|
+
@call_tasks = []
|
|
32
33
|
@call_errors = []
|
|
33
34
|
@closing_marker = {closing: false}
|
|
34
35
|
@lifecycle_mutex = Mutex.new
|
|
@@ -268,20 +269,18 @@ module LittleGhost
|
|
|
268
269
|
end
|
|
269
270
|
|
|
270
271
|
def dispatch_call(message, process)
|
|
271
|
-
|
|
272
|
-
active = @
|
|
272
|
+
@call_mutex.synchronize do
|
|
273
|
+
active = @call_tasks.count(&:alive?)
|
|
273
274
|
raise ProtocolError, "code-mode concurrent tool call limit exceeded" if active >= @limits.fetch(:concurrency)
|
|
274
275
|
|
|
275
276
|
call_errors = @call_errors
|
|
276
277
|
closing_marker = @closing_marker
|
|
277
|
-
|
|
278
|
+
@call_tasks << @task_runner.spawn do
|
|
278
279
|
answer_call(message, process, closing_marker)
|
|
279
280
|
rescue => error
|
|
280
281
|
@call_mutex.synchronize { call_errors << error }
|
|
281
282
|
end
|
|
282
283
|
end
|
|
283
|
-
thread.report_on_exception = false
|
|
284
|
-
@call_mutex.synchronize { @call_threads << thread }
|
|
285
284
|
end
|
|
286
285
|
|
|
287
286
|
def append_output(value)
|
|
@@ -342,14 +341,14 @@ module LittleGhost
|
|
|
342
341
|
end
|
|
343
342
|
target, session, sandbox, workspace, directory = claimed
|
|
344
343
|
watchdog = cancel_watchdog(target)
|
|
345
|
-
|
|
346
|
-
|
|
344
|
+
tasks, call_errors = @call_mutex.synchronize do
|
|
345
|
+
current_tasks = @call_tasks
|
|
347
346
|
current_errors = @call_errors
|
|
348
347
|
@closing_marker[:closing] = true
|
|
349
|
-
@
|
|
348
|
+
@call_tasks = []
|
|
350
349
|
@call_errors = []
|
|
351
350
|
@closing_marker = {closing: false}
|
|
352
|
-
[
|
|
351
|
+
[current_tasks, current_errors]
|
|
353
352
|
end
|
|
354
353
|
first_error = nil
|
|
355
354
|
begin
|
|
@@ -358,17 +357,19 @@ module LittleGhost
|
|
|
358
357
|
first_error ||= error
|
|
359
358
|
end
|
|
360
359
|
deadline = monotonic_time + @limits.fetch(:cleanup_seconds)
|
|
361
|
-
|
|
360
|
+
tasks.each do |task|
|
|
362
361
|
remaining = deadline - monotonic_time
|
|
363
362
|
break unless remaining.positive?
|
|
364
363
|
|
|
365
364
|
begin
|
|
366
|
-
|
|
365
|
+
task.wait(deadline: Time.now + remaining)
|
|
366
|
+
rescue DeadlineExceededError
|
|
367
|
+
break
|
|
367
368
|
rescue => error
|
|
368
369
|
first_error ||= error
|
|
369
370
|
end
|
|
370
371
|
end
|
|
371
|
-
if
|
|
372
|
+
if tasks.any?(&:alive?)
|
|
372
373
|
@closed = true
|
|
373
374
|
first_error ||= CleanupError.new("Code-mode nested tool cleanup timed out")
|
|
374
375
|
end
|
|
@@ -389,14 +390,14 @@ module LittleGhost
|
|
|
389
390
|
rescue => error
|
|
390
391
|
first_error ||= error
|
|
391
392
|
end
|
|
392
|
-
watchdog&.
|
|
393
|
+
watchdog&.wait unless watchdog&.current?
|
|
393
394
|
raise first_error if first_error
|
|
394
395
|
end
|
|
395
396
|
|
|
396
397
|
def start_watchdog(generation, deadline)
|
|
397
398
|
@watchdog_mutex.synchronize do
|
|
398
399
|
@watchdog_generation = generation
|
|
399
|
-
@watchdog =
|
|
400
|
+
@watchdog = @task_runner.spawn do
|
|
400
401
|
expired = @watchdog_mutex.synchronize do
|
|
401
402
|
loop do
|
|
402
403
|
break false unless @watchdog_generation.equal?(generation)
|
|
@@ -411,7 +412,6 @@ module LittleGhost
|
|
|
411
412
|
rescue => error
|
|
412
413
|
record_program_error(generation, error)
|
|
413
414
|
end
|
|
414
|
-
@watchdog.report_on_exception = false
|
|
415
415
|
end
|
|
416
416
|
end
|
|
417
417
|
|
|
@@ -419,11 +419,11 @@ module LittleGhost
|
|
|
419
419
|
@watchdog_mutex.synchronize do
|
|
420
420
|
return unless !generation || @watchdog_generation.equal?(generation)
|
|
421
421
|
|
|
422
|
-
|
|
422
|
+
task = @watchdog
|
|
423
423
|
@watchdog_generation = nil
|
|
424
424
|
@watchdog = nil
|
|
425
425
|
@watchdog_condition.broadcast
|
|
426
|
-
|
|
426
|
+
task
|
|
427
427
|
end
|
|
428
428
|
end
|
|
429
429
|
|