little_ghost 0.3.0 → 0.5.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/README.md +90 -84
- data/docs/guides/assemblies.md +412 -0
- data/docs/guides/code_mode.md +275 -0
- data/docs/guides/core_concepts.md +150 -239
- data/docs/guides/getting_started.md +125 -87
- data/docs/guides/integrations.md +217 -0
- data/docs/guides/models_and_providers.md +125 -0
- data/docs/guides/production.md +253 -0
- data/docs/guides/prompt_views.md +139 -0
- data/docs/guides/sandboxing.md +282 -0
- data/docs/guides/skills.md +141 -0
- data/docs/guides/structured_outputs_and_content.md +135 -0
- data/docs/guides/tools.md +329 -0
- data/lib/little_ghost/ag_ui/adapter.rb +5 -5
- data/lib/little_ghost/agent/delegation.rb +3 -3
- data/lib/little_ghost/agent/skills.rb +6 -1
- data/lib/little_ghost/agent/tool_loop.rb +6 -2
- data/lib/little_ghost/agent.rb +411 -214
- data/lib/little_ghost/agent_builder.rb +61 -22
- data/lib/little_ghost/{agent_interruptions.rb → agent_interjections.rb} +12 -12
- data/lib/little_ghost/agent_stream_source.rb +262 -0
- data/lib/little_ghost/artifact.rb +182 -0
- data/lib/little_ghost/artifacts/presentation_budget.rb +44 -0
- data/lib/little_ghost/artifacts/workspace_store.rb +370 -0
- data/lib/little_ghost/artifacts.rb +11 -0
- data/lib/little_ghost/assembly.rb +110 -30
- data/lib/little_ghost/assembly_builder.rb +59 -21
- data/lib/little_ghost/assembly_execution.rb +110 -6
- data/lib/little_ghost/code_mode/broker.rb +164 -0
- data/lib/little_ghost/code_mode/catalog.rb +54 -0
- data/lib/little_ghost/code_mode/engine.rb +58 -0
- data/lib/little_ghost/code_mode/javascript/catalog.rb +118 -0
- data/lib/little_ghost/code_mode/javascript/client.rb +550 -0
- data/lib/little_ghost/code_mode/javascript/host.rb +347 -0
- data/lib/little_ghost/code_mode/javascript/session.rb +579 -0
- data/lib/little_ghost/code_mode/javascript_engine.rb +172 -0
- data/lib/little_ghost/code_mode/protocol.rb +78 -0
- data/lib/little_ghost/code_mode/ruby/catalog.rb +80 -0
- data/lib/little_ghost/code_mode/ruby/host.rb +102 -0
- data/lib/little_ghost/code_mode/ruby/session.rb +508 -0
- data/lib/little_ghost/code_mode/ruby_engine.rb +86 -0
- data/lib/little_ghost/code_mode/runtime.rb +335 -0
- data/lib/little_ghost/code_mode/session.rb +61 -0
- data/lib/little_ghost/code_mode/types.rb +58 -0
- data/lib/little_ghost/code_mode.rb +53 -0
- data/lib/little_ghost/configuration.rb +366 -47
- data/lib/little_ghost/content.rb +24 -13
- data/lib/little_ghost/data_map.rb +209 -0
- data/lib/little_ghost/errors.rb +28 -9
- data/lib/little_ghost/execution.rb +33 -34
- data/lib/little_ghost/graph.rb +376 -197
- data/lib/little_ghost/mcp/client.rb +487 -88
- data/lib/little_ghost/mcp/toolset.rb +210 -0
- data/lib/little_ghost/mcp/types.rb +216 -0
- data/lib/little_ghost/mcp.rb +3 -0
- data/lib/little_ghost/message.rb +4 -4
- data/lib/little_ghost/model_capabilities.rb +9 -6
- data/lib/little_ghost/model_request.rb +0 -12
- data/lib/little_ghost/model_resolver.rb +15 -5
- data/lib/little_ghost/model_response.rb +3 -7
- data/lib/little_ghost/network/authorizer_server.rb +162 -0
- data/lib/little_ghost/network/certificate_authority.rb +98 -0
- data/lib/little_ghost/network/envoy_config.rb +362 -0
- data/lib/little_ghost/network/envoy_gateway.rb +409 -0
- data/lib/little_ghost/network/external_gateway.rb +68 -0
- data/lib/little_ghost/network.rb +96 -0
- data/lib/little_ghost/prompt_resolver.rb +9 -7
- data/lib/little_ghost/provider_registry.rb +3 -3
- data/lib/little_ghost/providers/anthropic.rb +8 -1
- data/lib/little_ghost/providers/gemini.rb +10 -1
- data/lib/little_ghost/providers/vertex_ai.rb +6 -1
- data/lib/little_ghost/run.rb +162 -54
- data/lib/little_ghost/run_context.rb +42 -20
- data/lib/little_ghost/run_result.rb +0 -7
- data/lib/little_ghost/runtime/hook.rb +8 -3
- data/lib/little_ghost/runtime/hooks/artifacts.rb +338 -0
- data/lib/little_ghost/runtime.rb +160 -52
- data/lib/little_ghost/sandbox/capabilities.rb +68 -0
- data/lib/little_ghost/sandbox/environment_policy.rb +41 -0
- data/lib/little_ghost/sandbox/filesystem.rb +377 -0
- data/lib/little_ghost/sandbox/isolated_backend.rb +163 -0
- data/lib/little_ghost/sandbox/limits.rb +48 -0
- data/lib/little_ghost/sandbox/mount.rb +129 -0
- data/lib/little_ghost/sandbox/network_policy.rb +108 -0
- data/lib/little_ghost/sandbox/policy.rb +88 -0
- data/lib/little_ghost/sandbox/process_runner.rb +103 -0
- data/lib/little_ghost/sandbox/process_session.rb +335 -0
- data/lib/little_ghost/sandbox/scope.rb +304 -0
- data/lib/little_ghost/sandbox.rb +158 -32
- data/lib/little_ghost/sandboxes/bubblewrap.rb +351 -0
- data/lib/little_ghost/sandboxes/native.rb +51 -0
- data/lib/little_ghost/sandboxes/seatbelt.rb +261 -0
- data/lib/little_ghost/sandboxes/unrestricted.rb +241 -0
- data/lib/little_ghost/session.rb +39 -26
- data/lib/little_ghost/session_store.rb +9 -5
- data/lib/little_ghost/session_stores/agent_core_memory.rb +64 -56
- data/lib/little_ghost/session_stores/filesystem.rb +261 -0
- data/lib/little_ghost/session_stores/memory.rb +7 -0
- data/lib/little_ghost/skills/catalog.rb +94 -14
- data/lib/little_ghost/skills/resource_root.rb +42 -0
- data/lib/little_ghost/skills/skill.rb +0 -3
- data/lib/little_ghost/stream_event.rb +8 -13
- data/lib/little_ghost/subagents/control_tool.rb +8 -0
- data/lib/little_ghost/subagents/manager.rb +53 -50
- data/lib/little_ghost/support/callbacks.rb +3 -1
- data/lib/little_ghost/support/content_capture.rb +3 -3
- data/lib/little_ghost/support/http_client.rb +2 -2
- data/lib/little_ghost/support/redactor.rb +1 -1
- data/lib/little_ghost/swarm.rb +13 -5
- data/lib/little_ghost/tool.rb +157 -64
- data/lib/little_ghost/tool_registry.rb +1 -1
- data/lib/little_ghost/tools/filesystem.rb +1 -1
- data/lib/little_ghost/tools/shell.rb +4 -3
- data/lib/little_ghost/tools/write_todos.rb +6 -1
- data/lib/little_ghost/tracing/open_telemetry.rb +1 -1
- data/lib/little_ghost/version.rb +1 -1
- data/lib/little_ghost/workflow.rb +30 -21
- data/lib/little_ghost/workspace.rb +222 -8
- data/lib/little_ghost.rb +40 -27
- metadata +104 -3
- data/lib/little_ghost/unrestricted_sandbox.rb +0 -306
data/lib/little_ghost/agent.rb
CHANGED
|
@@ -10,46 +10,45 @@ module LittleGhost
|
|
|
10
10
|
# Each Agent subclass describes one application role with an inheritable Ruby
|
|
11
11
|
# DSL. It can answer, stream, call tools, and delegate work.
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
# investigations to a research specialist:
|
|
15
|
-
#
|
|
16
|
-
# class ResearchAgent < LittleGhost::Agent
|
|
17
|
-
# description "Researches transfer failures"
|
|
18
|
-
# model "customer_support.research"
|
|
19
|
-
# tools LedgerSearchTool
|
|
20
|
-
# end
|
|
13
|
+
# Start with one role and add capabilities as its work grows:
|
|
21
14
|
#
|
|
22
15
|
# class CustomerSupportAgent < LittleGhost::Agent
|
|
23
16
|
# description "Handles support requests"
|
|
24
|
-
# model :
|
|
25
|
-
#
|
|
26
|
-
# tools AccountLookupTool
|
|
27
|
-
# subagent ResearchAgent, kind: "research"
|
|
17
|
+
# model "openrouter:openai/gpt-5.6-luna"
|
|
18
|
+
# system_prompt "Answer customer questions clearly."
|
|
28
19
|
# end
|
|
29
20
|
#
|
|
30
21
|
# run = CustomerSupportAgent.ask("Why is transfer 481 pending?")
|
|
31
22
|
# run.completed? # => true
|
|
32
|
-
# run.response
|
|
23
|
+
# run.response
|
|
24
|
+
# # One possible response: Transfer 481 is waiting for the receiving bank.
|
|
25
|
+
#
|
|
26
|
+
# An Agent is the smallest Assembly: it owns one model loop while inheriting
|
|
27
|
+
# the same +ask+ and +stream_ask+ entrypoints as coordinated assemblies.
|
|
28
|
+
# Add tools for application operations and subagents for model-directed
|
|
29
|
+
# delegation.
|
|
33
30
|
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
# delegation remain inactive until their DSL methods are called.
|
|
31
|
+
# Call a named Agent with
|
|
32
|
+
# ask[rdoc-ref:LittleGhost::Assembly.ask] when you need the final Run, or
|
|
33
|
+
# the streaming entrypoint[rdoc-ref:LittleGhost::Assembly.stream_ask] when you
|
|
34
|
+
# want events as the answer arrives.
|
|
39
35
|
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
# single-execution lifecycle. Closing an agent closes owned tools and any
|
|
45
|
-
# standalone workspace and sandbox.
|
|
46
|
-
# LittleGhost::Agent.ask uses <tt>You are a helpful agent.</tt> as its system
|
|
47
|
-
# prompt. Subclasses continue to use their inline or conventional prompts.
|
|
36
|
+
# Most applications call a named Agent class. LittleGhost automatically
|
|
37
|
+
# reuses the active Configuration's shared Runtime while building a fresh
|
|
38
|
+
# top-level Run for every call. Passing +runtime:+ is an advanced option for
|
|
39
|
+
# an explicitly isolated setup.
|
|
48
40
|
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
41
|
+
# Agent declarations are inherited. Define a short prompt inline, or place a
|
|
42
|
+
# growing prompt in <tt>app/prompts/customer_support/system.erb</tt> for
|
|
43
|
+
# +CustomerSupportAgent+. The {Prompts as Views guide}[rdoc-ref:docs/guides/prompt_views.md]
|
|
44
|
+
# explains conventional lookup, locals, and partials. Optional features such
|
|
45
|
+
# as skills, context management, loop detection, and delegation stay inactive
|
|
46
|
+
# until their DSL is used.
|
|
47
|
+
#
|
|
48
|
+
# Models may return text or locally validated structured data. LittleGhost
|
|
49
|
+
# hides unexpected Tool exception messages from the model. See
|
|
50
|
+
# Run[rdoc-ref:LittleGhost::Run] for outcomes, cancellation, and cleanup, and
|
|
51
|
+
# Assembly[rdoc-ref:LittleGhost::Assembly] for the advanced run-scoped form.
|
|
53
52
|
class Agent < Assembly
|
|
54
53
|
DEFAULT_SYSTEM_PROMPT = "You are a helpful agent." # :nodoc:
|
|
55
54
|
DEFAULT_MAX_TOOL_RESULT_TOKENS = 10_000 # :nodoc:
|
|
@@ -66,9 +65,12 @@ module LittleGhost
|
|
|
66
65
|
before_model after_model after_model_error
|
|
67
66
|
before_tool after_tool
|
|
68
67
|
].freeze # :nodoc:
|
|
69
|
-
ExecutedTool = Data.define(:result, :
|
|
68
|
+
ExecutedTool = Data.define(:result, :execution_result) do # :nodoc:
|
|
70
69
|
def status = result.status
|
|
71
70
|
def content = result.content
|
|
71
|
+
def value = execution_result.value
|
|
72
|
+
def artifacts = execution_result.artifacts
|
|
73
|
+
def presentation_content = execution_result.presentation_content
|
|
72
74
|
end
|
|
73
75
|
|
|
74
76
|
extend Support::ClassAttributes
|
|
@@ -81,6 +83,7 @@ module LittleGhost
|
|
|
81
83
|
class_attribute :system_prompt_value
|
|
82
84
|
class_attribute :system_prompt_builder_value
|
|
83
85
|
class_attribute :tool_declarations_value, default: []
|
|
86
|
+
class_attribute :code_mode_configuration_value
|
|
84
87
|
class_attribute :prompt_local_values, default: {}
|
|
85
88
|
class_attribute :callback_values, default: Support::Callbacks.new(*CALLBACKS)
|
|
86
89
|
|
|
@@ -161,9 +164,10 @@ module LittleGhost
|
|
|
161
164
|
# strategy selection prefers provider-native structured output and falls
|
|
162
165
|
# back to a terminal tool when supported.
|
|
163
166
|
#
|
|
164
|
-
#
|
|
165
|
-
# LittleGhost::StructuredResultError is raised
|
|
166
|
-
#
|
|
167
|
+
# During execution, a missing or invalid result receives one repair attempt
|
|
168
|
+
# before LittleGhost::StructuredResultError is raised inside the owning Run.
|
|
169
|
+
# A top-level +ask+ records it on a failed Run. Invalid schemas and strategies
|
|
170
|
+
# raise LittleGhost::ConfigurationError before execution begins.
|
|
167
171
|
def result_schema(schema = nil, name: nil, description: nil, strategy: :auto, **schema_keywords)
|
|
168
172
|
return result_schema_value if schema.nil? && schema_keywords.empty? && name.nil? && description.nil? && strategy == :auto
|
|
169
173
|
|
|
@@ -250,8 +254,9 @@ module LittleGhost
|
|
|
250
254
|
|
|
251
255
|
# Adds tool or provider classes to the agent.
|
|
252
256
|
#
|
|
253
|
-
# Every declaration must be a class.
|
|
254
|
-
#
|
|
257
|
+
# Every declaration must be a class. Pass Tool classes directly, or pass
|
|
258
|
+
# provider classes that supply tools dynamically through
|
|
259
|
+
# <tt>tools(binding)</tt>. Multiple declarations are cumulative.
|
|
255
260
|
def tools(*values)
|
|
256
261
|
invalid = values.flatten.compact.find { |value| !value.is_a?(Class) }
|
|
257
262
|
if invalid
|
|
@@ -265,6 +270,20 @@ module LittleGhost
|
|
|
265
270
|
|
|
266
271
|
def tool_declarations = tool_declarations_value # :nodoc:
|
|
267
272
|
|
|
273
|
+
# Enables code mode for this agent. +except+ names the application Tools
|
|
274
|
+
# that remain model-facing; every other application Tool moves into the
|
|
275
|
+
# engine catalog and is called through the parent-process Broker.
|
|
276
|
+
# Framework-owned subagent controls remain model-facing automatically.
|
|
277
|
+
def code_mode(engine: nil, except: nil, **options)
|
|
278
|
+
unknown = options.keys - %i[sandbox limits]
|
|
279
|
+
raise ArgumentError, "unknown keyword: #{unknown.first.inspect}" unless unknown.empty?
|
|
280
|
+
|
|
281
|
+
declaration = options.merge(engine:, except:).compact
|
|
282
|
+
self.code_mode_configuration_value = declaration.freeze
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def code_mode_configuration = code_mode_configuration_value # :nodoc:
|
|
286
|
+
|
|
268
287
|
# Adds a named value or resolver to every prompt rendered for the agent.
|
|
269
288
|
def prompt_local(name, *values, &resolver)
|
|
270
289
|
raise ArgumentError, "Provide a prompt local value or block" if values.empty? && !resolver
|
|
@@ -296,8 +315,9 @@ module LittleGhost
|
|
|
296
315
|
##
|
|
297
316
|
# Runs before one invocation begins.
|
|
298
317
|
#
|
|
299
|
-
# The payload
|
|
300
|
-
#
|
|
318
|
+
# The payload is <tt>{messages: Array<Message>}</tt>. A replacement must
|
|
319
|
+
# contain +:messages+. Cancellation stops the invocation. A callback may
|
|
320
|
+
# also accept <tt>context:</tt> to receive the current RunContext.
|
|
301
321
|
#
|
|
302
322
|
# :singleton-method: before_invocation
|
|
303
323
|
# :call-seq:
|
|
@@ -306,6 +326,10 @@ module LittleGhost
|
|
|
306
326
|
##
|
|
307
327
|
# Observes or transforms the terminal invocation payload.
|
|
308
328
|
#
|
|
329
|
+
# The payload is <tt>{result: RunResult}</tt>. A replacement must contain
|
|
330
|
+
# +:result+. Cancellation stops result delivery. A callback may accept
|
|
331
|
+
# <tt>context:</tt>.
|
|
332
|
+
#
|
|
309
333
|
# :singleton-method: after_invocation
|
|
310
334
|
# :call-seq:
|
|
311
335
|
# after_invocation(callable = nil, prepend: false) { |payload| ... } -> self
|
|
@@ -313,6 +337,11 @@ module LittleGhost
|
|
|
313
337
|
##
|
|
314
338
|
# Runs before a model request is sent.
|
|
315
339
|
#
|
|
340
|
+
# The payload contains +:request+ (ModelRequest), zero-based +:turn+, and
|
|
341
|
+
# +:parent_operation_id+. A replacement must contain +:request+.
|
|
342
|
+
# Cancellation stops the invocation. A callback may accept
|
|
343
|
+
# <tt>context:</tt>.
|
|
344
|
+
#
|
|
316
345
|
# :singleton-method: before_model
|
|
317
346
|
# :call-seq:
|
|
318
347
|
# before_model(callable = nil, prepend: false) { |payload| ... } -> self
|
|
@@ -320,6 +349,11 @@ module LittleGhost
|
|
|
320
349
|
##
|
|
321
350
|
# Observes or transforms a successful model response.
|
|
322
351
|
#
|
|
352
|
+
# The payload contains +:request+, +:response+ (ModelResponse), and
|
|
353
|
+
# zero-based +:turn+. A replacement must contain +:response+.
|
|
354
|
+
# Cancellation stops the invocation. A callback may accept
|
|
355
|
+
# <tt>context:</tt>.
|
|
356
|
+
#
|
|
323
357
|
# :singleton-method: after_model
|
|
324
358
|
# :call-seq:
|
|
325
359
|
# after_model(callable = nil, prepend: false) { |payload| ... } -> self
|
|
@@ -327,6 +361,11 @@ module LittleGhost
|
|
|
327
361
|
##
|
|
328
362
|
# Handles a model error before it leaves the agent loop.
|
|
329
363
|
#
|
|
364
|
+
# The payload contains +:request+, +:error+, zero-based +:turn+, and
|
|
365
|
+
# +:parent_operation_id+. Replacing +:request+ with a ModelRequest retries
|
|
366
|
+
# the model call, up to the framework recovery limit. Cancellation stops
|
|
367
|
+
# the invocation. A callback may accept <tt>context:</tt>.
|
|
368
|
+
#
|
|
330
369
|
# :singleton-method: after_model_error
|
|
331
370
|
# :call-seq:
|
|
332
371
|
# after_model_error(callable = nil, prepend: false) { |payload| ... } -> self
|
|
@@ -334,6 +373,11 @@ module LittleGhost
|
|
|
334
373
|
##
|
|
335
374
|
# Runs after validation but before a tool call starts.
|
|
336
375
|
#
|
|
376
|
+
# The payload contains +:tool_use+, the bound +:tool+, +:operation_id+,
|
|
377
|
+
# and +:parent_operation_id+. Cancellation returns a model-visible Tool
|
|
378
|
+
# error, so its reason must be safe to disclose. Replacements are not
|
|
379
|
+
# consumed. A callback may accept <tt>context:</tt>.
|
|
380
|
+
#
|
|
337
381
|
# :singleton-method: before_tool
|
|
338
382
|
# :call-seq:
|
|
339
383
|
# before_tool(callable = nil, prepend: false) { |payload| ... } -> self
|
|
@@ -341,6 +385,10 @@ module LittleGhost
|
|
|
341
385
|
##
|
|
342
386
|
# Observes or transforms a completed tool result.
|
|
343
387
|
#
|
|
388
|
+
# The payload contains the before-tool fields plus the normalized
|
|
389
|
+
# +:result+. A replacement must contain +:result+.
|
|
390
|
+
# Cancellation is not consumed. A callback may accept <tt>context:</tt>.
|
|
391
|
+
#
|
|
344
392
|
# :singleton-method: after_tool
|
|
345
393
|
# :call-seq:
|
|
346
394
|
# after_tool(callable = nil, prepend: false) { |payload| ... } -> self
|
|
@@ -466,16 +514,32 @@ module LittleGhost
|
|
|
466
514
|
end
|
|
467
515
|
end
|
|
468
516
|
|
|
469
|
-
#
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
517
|
+
# The resolved model used by this run-scoped Agent.
|
|
518
|
+
attr_reader :model
|
|
519
|
+
# Tools created and bound for this Agent's owning Run.
|
|
520
|
+
attr_reader :tool_registry
|
|
521
|
+
# The owning Run, or +nil+ for a standalone entrypoint.
|
|
522
|
+
attr_reader :run
|
|
523
|
+
# Shared delegation tracker, when subagents are enabled.
|
|
524
|
+
attr_reader :delegation_activity
|
|
525
|
+
# This Agent's location in the bounded subagent tree.
|
|
526
|
+
attr_reader :agent_path
|
|
527
|
+
# Run-scoped workspace available to Tools and extensions.
|
|
528
|
+
attr_reader :workspace
|
|
529
|
+
# Run-scoped sandbox used for filesystem and process operations.
|
|
530
|
+
attr_reader :sandbox
|
|
531
|
+
# Maximum Tool calls allowed during one invocation.
|
|
532
|
+
attr_reader :max_tool_calls
|
|
473
533
|
|
|
474
534
|
# Creates either a standalone entrypoint or a run-scoped agent.
|
|
535
|
+
# :call-seq:
|
|
536
|
+
# new(runtime: nil) -> Agent
|
|
537
|
+
# new(model:, runtime:, tools:, run:, ...) -> Agent
|
|
475
538
|
#
|
|
476
|
-
#
|
|
477
|
-
#
|
|
478
|
-
#
|
|
539
|
+
# The first form is the application-facing entrypoint. It may be reused for
|
|
540
|
+
# independent concurrent calls and creates a fresh Run for each one. The
|
|
541
|
+
# second form is run-scoped; Runtime builders supply its dependencies and it
|
|
542
|
+
# must not outlive or be shared outside its owning Run.
|
|
479
543
|
def initialize(
|
|
480
544
|
model: nil,
|
|
481
545
|
runtime: nil,
|
|
@@ -499,8 +563,8 @@ module LittleGhost
|
|
|
499
563
|
@owns_resources = true
|
|
500
564
|
@closed = false
|
|
501
565
|
@close_mutex = Mutex.new
|
|
502
|
-
@
|
|
503
|
-
@
|
|
566
|
+
@interjections_mutex = Mutex.new
|
|
567
|
+
@active_interjections = []
|
|
504
568
|
return
|
|
505
569
|
end
|
|
506
570
|
|
|
@@ -519,6 +583,7 @@ module LittleGhost
|
|
|
519
583
|
self.class.tool_declarations.each do |declaration|
|
|
520
584
|
@tool_registry.register(declaration, replace: true)
|
|
521
585
|
end
|
|
586
|
+
initialize_code_mode
|
|
522
587
|
@structured_output_strategy = StructuredOutput.resolve(
|
|
523
588
|
self.class.result_schema,
|
|
524
589
|
model:,
|
|
@@ -535,8 +600,8 @@ module LittleGhost
|
|
|
535
600
|
@closed = false
|
|
536
601
|
@close_mutex = Mutex.new
|
|
537
602
|
@exclusive_tools_mutex = Mutex.new
|
|
538
|
-
@
|
|
539
|
-
@
|
|
603
|
+
@interjections_mutex = Mutex.new
|
|
604
|
+
@active_interjections = []
|
|
540
605
|
@assembly_transitions_mutex = Mutex.new
|
|
541
606
|
@assembly_transitions = {}
|
|
542
607
|
@assembly_tool_batch_sizes = {}
|
|
@@ -544,9 +609,13 @@ module LittleGhost
|
|
|
544
609
|
raise ArgumentError, "max_turns must be at least 1" if @max_turns < 1
|
|
545
610
|
raise ArgumentError, "max_tool_calls must be at least 1" if @max_tool_calls < 1
|
|
546
611
|
raise ArgumentError, "max_tool_result_tokens must be at least 1" if @max_tool_result_tokens < 1
|
|
612
|
+
@artifact_lifecycle = if runtime&.respond_to?(:runtime_hooks)
|
|
613
|
+
runtime.runtime_hooks.find { |hook| hook.is_a?(Runtime::Hooks::Artifacts) }
|
|
614
|
+
end
|
|
547
615
|
apply_cancellation_decision!(run_callbacks(:after_initialize, self))
|
|
548
616
|
rescue
|
|
549
617
|
@tool_registry&.close
|
|
618
|
+
@code_mode_runtime&.close
|
|
550
619
|
raise
|
|
551
620
|
end
|
|
552
621
|
|
|
@@ -556,7 +625,19 @@ module LittleGhost
|
|
|
556
625
|
def entrypoint_name = self.class.agent_id # :nodoc:
|
|
557
626
|
|
|
558
627
|
def dispatch_tools(tool_uses, context:, events:, parent_operation_id:, parent_trace_context: nil) # :nodoc:
|
|
559
|
-
|
|
628
|
+
counted = tool_uses.count { |tool_use| !code_mode_control_tool?(tool_use) }
|
|
629
|
+
context.record_tool_calls!(counted, maximum: @max_tool_calls) if counted.positive?
|
|
630
|
+
execute_tools(
|
|
631
|
+
tool_uses,
|
|
632
|
+
context,
|
|
633
|
+
events,
|
|
634
|
+
parent_operation_id:,
|
|
635
|
+
parent_trace_context:
|
|
636
|
+
)
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def code_mode_runtime # :nodoc:
|
|
640
|
+
@code_mode_runtime || raise(ConfigurationError, "code mode is not enabled for this agent")
|
|
560
641
|
end
|
|
561
642
|
|
|
562
643
|
def request_assembly_transition(value, context:) # :nodoc:
|
|
@@ -575,94 +656,69 @@ module LittleGhost
|
|
|
575
656
|
|
|
576
657
|
attr_reader :assembly_transition # :nodoc:
|
|
577
658
|
|
|
578
|
-
# Adds
|
|
579
|
-
#
|
|
580
|
-
# The response may continue into tool calls; delivery does not stop the
|
|
581
|
-
# original invocation. Raises LittleGhost::AgentInterruptError when there is
|
|
582
|
-
# no unambiguous active target.
|
|
583
|
-
def interrupt(
|
|
584
|
-
message,
|
|
585
|
-
cancellation_token: Support::CancellationToken.new,
|
|
586
|
-
deadline: nil,
|
|
587
|
-
target_operation_id: nil,
|
|
588
|
-
interruption_id: nil,
|
|
589
|
-
batch_key: nil,
|
|
590
|
-
metadata: {}
|
|
591
|
-
)
|
|
592
|
-
interrupt_response(
|
|
593
|
-
message,
|
|
594
|
-
cancellation_token:,
|
|
595
|
-
deadline:,
|
|
596
|
-
target_operation_id:,
|
|
597
|
-
interruption_id:,
|
|
598
|
-
batch_key:,
|
|
599
|
-
metadata:
|
|
600
|
-
).text
|
|
601
|
-
end
|
|
602
|
-
|
|
603
|
-
# Adds an interruption and returns the model's immediate response details.
|
|
659
|
+
# Adds an interjection and returns the model's immediate result details.
|
|
604
660
|
#
|
|
605
661
|
# Use +target_operation_id+ when an agent has multiple active invocations.
|
|
606
662
|
# Messages may contain only text, image, or document content. The returned
|
|
607
|
-
#
|
|
608
|
-
# +batch_key+; tool calls may continue after this
|
|
609
|
-
# methods rather than the
|
|
610
|
-
def
|
|
663
|
+
# result value exposes +text+, +tool_calls?+, +interjection_ids+, and
|
|
664
|
+
# +batch_key+; tool calls may continue after this result. Depend on these
|
|
665
|
+
# methods rather than the result's concrete class.
|
|
666
|
+
def interject(
|
|
611
667
|
message,
|
|
612
668
|
cancellation_token: Support::CancellationToken.new,
|
|
613
669
|
deadline: nil,
|
|
614
670
|
target_operation_id: nil,
|
|
615
|
-
|
|
671
|
+
interjection_id: nil,
|
|
616
672
|
batch_key: nil,
|
|
617
673
|
metadata: {}
|
|
618
674
|
)
|
|
619
675
|
message = Message.new(role: :user, content: message) if message.is_a?(String)
|
|
620
|
-
raise ArgumentError, "
|
|
676
|
+
raise ArgumentError, "interject message must be a String or LittleGhost::Message" unless message.is_a?(Message)
|
|
621
677
|
safe_content = message.content.all? do |content|
|
|
622
678
|
content.is_a?(Content::Text) ||
|
|
623
679
|
content.is_a?(Content::Image) ||
|
|
624
680
|
content.is_a?(Content::Document)
|
|
625
681
|
end
|
|
626
682
|
unless safe_content
|
|
627
|
-
raise ArgumentError, "
|
|
683
|
+
raise ArgumentError, "interject message content must contain only text, images, or documents"
|
|
628
684
|
end
|
|
629
685
|
|
|
630
|
-
|
|
686
|
+
interjections = @interjections_mutex.synchronize do
|
|
631
687
|
active = if target_operation_id
|
|
632
|
-
@
|
|
688
|
+
@active_interjections.select { |candidate| candidate.target_operation_id == target_operation_id }
|
|
633
689
|
else
|
|
634
|
-
@
|
|
690
|
+
@active_interjections
|
|
635
691
|
end
|
|
636
692
|
if active.empty?
|
|
637
|
-
raise
|
|
693
|
+
raise AgentInterjectionError, "Agent is not currently running"
|
|
638
694
|
end
|
|
639
695
|
if active.length > 1
|
|
640
|
-
raise
|
|
696
|
+
raise AgentInterjectionError, "Agent has multiple active invocations; the interjection target is ambiguous"
|
|
641
697
|
end
|
|
642
698
|
|
|
643
699
|
active.first
|
|
644
700
|
end
|
|
645
701
|
options = {batch_key:, metadata:}
|
|
646
|
-
options[:id] =
|
|
647
|
-
ticket =
|
|
702
|
+
options[:id] = interjection_id unless interjection_id.nil?
|
|
703
|
+
ticket = interjections.enqueue(message, **options)
|
|
648
704
|
instrument(
|
|
649
|
-
:
|
|
650
|
-
parent_operation_id:
|
|
651
|
-
|
|
652
|
-
event_kind: :
|
|
705
|
+
:agent_interjection_queued,
|
|
706
|
+
parent_operation_id: interjections.operation_id,
|
|
707
|
+
interjection_id: ticket.id,
|
|
708
|
+
event_kind: :interjection,
|
|
653
709
|
diagnostic: {input: diagnostic_message(message)}
|
|
654
710
|
)
|
|
655
711
|
begin
|
|
656
712
|
response = ticket.value(cancellation_token:, deadline:)
|
|
657
|
-
|
|
713
|
+
interjections.release(ticket)
|
|
658
714
|
response
|
|
659
715
|
rescue => error
|
|
660
|
-
|
|
716
|
+
interjections.release(ticket, withdraw: true)
|
|
661
717
|
instrument(
|
|
662
|
-
:
|
|
663
|
-
parent_operation_id:
|
|
664
|
-
|
|
665
|
-
event_kind: :
|
|
718
|
+
:agent_interjection_failed,
|
|
719
|
+
parent_operation_id: interjections.operation_id,
|
|
720
|
+
interjection_id: ticket.id,
|
|
721
|
+
event_kind: :interjection,
|
|
666
722
|
error_type: error.class.name,
|
|
667
723
|
diagnostic: {exception: diagnostic_exception(error)}
|
|
668
724
|
)
|
|
@@ -673,9 +729,9 @@ module LittleGhost
|
|
|
673
729
|
# Streams one invocation as StreamEvent objects.
|
|
674
730
|
#
|
|
675
731
|
# Agents built inside a run accept history, JSON-like context, cancellation,
|
|
676
|
-
# deadlines, settings, and trusted invocation template paths. An
|
|
677
|
-
# instance may be streamed only
|
|
678
|
-
#
|
|
732
|
+
# deadlines, settings, and trusted invocation template paths. An Agent
|
|
733
|
+
# instance may be streamed only by its owning Run. Every template path must
|
|
734
|
+
# be an application-created TrustedPath;
|
|
679
735
|
# the wrapper records a trust decision and must never contain unchecked
|
|
680
736
|
# request or model input.
|
|
681
737
|
def stream(
|
|
@@ -690,9 +746,9 @@ module LittleGhost
|
|
|
690
746
|
parent_operation_id: nil,
|
|
691
747
|
checkpoint: nil,
|
|
692
748
|
conversation_id: nil,
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
749
|
+
interjection_metadata: nil,
|
|
750
|
+
interjection_ids: [],
|
|
751
|
+
interject_ready: nil
|
|
696
752
|
)
|
|
697
753
|
if standalone?
|
|
698
754
|
raise ArgumentError, "input is required" if input.nil?
|
|
@@ -722,7 +778,7 @@ module LittleGhost
|
|
|
722
778
|
end
|
|
723
779
|
settings = @model_settings.merge(settings)
|
|
724
780
|
Enumerator.new do |events|
|
|
725
|
-
|
|
781
|
+
interjections = AgentInterjections.new
|
|
726
782
|
run_context = RunContext.new(
|
|
727
783
|
state: context,
|
|
728
784
|
cancellation_token: cancellation_token,
|
|
@@ -730,8 +786,8 @@ module LittleGhost
|
|
|
730
786
|
metadata: {agent_id: self.class.agent_id},
|
|
731
787
|
checkpoint:,
|
|
732
788
|
conversation_id:,
|
|
733
|
-
|
|
734
|
-
|
|
789
|
+
interjection_metadata:,
|
|
790
|
+
interjection_ids:
|
|
735
791
|
)
|
|
736
792
|
begin
|
|
737
793
|
with_invocation(run_context) do
|
|
@@ -744,16 +800,17 @@ module LittleGhost
|
|
|
744
800
|
template_paths: invocation_paths,
|
|
745
801
|
events: events,
|
|
746
802
|
parent_operation_id:,
|
|
747
|
-
|
|
748
|
-
|
|
803
|
+
interjections:,
|
|
804
|
+
interject_ready:
|
|
749
805
|
)
|
|
750
806
|
end
|
|
751
807
|
rescue => error
|
|
752
|
-
|
|
808
|
+
interjections.close(error)
|
|
753
809
|
raise
|
|
754
810
|
ensure
|
|
755
|
-
|
|
756
|
-
|
|
811
|
+
@code_mode_runtime&.close(context: run_context)
|
|
812
|
+
interjections.close(AgentInterjectionError.new("Agent finished before the interjection was delivered"))
|
|
813
|
+
unregister_interjections(interjections)
|
|
757
814
|
end
|
|
758
815
|
end
|
|
759
816
|
end
|
|
@@ -770,24 +827,24 @@ module LittleGhost
|
|
|
770
827
|
end.freeze
|
|
771
828
|
end
|
|
772
829
|
|
|
773
|
-
# The
|
|
830
|
+
# The Tool registry available during this Agent run.
|
|
774
831
|
def tools = tool_registry
|
|
775
832
|
|
|
776
|
-
# Closes owned tools,
|
|
833
|
+
# Closes owned tools, interjections, sandbox, and workspace resources.
|
|
777
834
|
# The operation is idempotent and re-raises the first cleanup failure.
|
|
778
835
|
def close
|
|
779
|
-
resources,
|
|
836
|
+
resources, interjections = @close_mutex.synchronize do
|
|
780
837
|
return if @closed
|
|
781
838
|
|
|
782
839
|
@closed = true
|
|
783
840
|
[
|
|
784
|
-
[tool_registry, (@sandbox if @owns_resources), (@workspace if @owns_resources)],
|
|
785
|
-
@
|
|
841
|
+
[@code_mode_runtime, tool_registry, (@sandbox if @owns_resources), (@workspace if @owns_resources)],
|
|
842
|
+
@interjections_mutex.synchronize { @active_interjections.dup }
|
|
786
843
|
]
|
|
787
844
|
end
|
|
788
845
|
first_error = nil
|
|
789
|
-
|
|
790
|
-
active.close(
|
|
846
|
+
interjections.each do |active|
|
|
847
|
+
active.close(AgentInterjectionError.new("Agent was closed"))
|
|
791
848
|
end
|
|
792
849
|
resources.each do |resource|
|
|
793
850
|
resource.close if resource.respond_to?(:close)
|
|
@@ -810,7 +867,15 @@ module LittleGhost
|
|
|
810
867
|
# Returns the tools exposed to the model for +turn+. Subclasses may override
|
|
811
868
|
# this hook to filter the already-authorized tool list.
|
|
812
869
|
def model_tools(tools, context:, turn:)
|
|
813
|
-
tools
|
|
870
|
+
return tools unless @code_mode_runtime
|
|
871
|
+
|
|
872
|
+
exceptions = Array(@code_mode_declaration[:except]).map(&:to_s)
|
|
873
|
+
tools.select do |specification|
|
|
874
|
+
name = specification.fetch(:name, specification["name"]).to_s
|
|
875
|
+
tool = tool_registry.fetch(name) if tool_registry.names.include?(name)
|
|
876
|
+
exceptions.include?(name) || %w[exec wait stop].include?(name) ||
|
|
877
|
+
tool.is_a?(Subagents::ControlTool) || !tool
|
|
878
|
+
end
|
|
814
879
|
end
|
|
815
880
|
|
|
816
881
|
# :doc:
|
|
@@ -829,45 +894,45 @@ module LittleGhost
|
|
|
829
894
|
{message: input, **options}
|
|
830
895
|
end
|
|
831
896
|
|
|
832
|
-
def
|
|
897
|
+
def register_interjections(interjections)
|
|
833
898
|
@close_mutex.synchronize do
|
|
834
899
|
raise InvocationError, "Agent is closed" if @closed
|
|
835
900
|
|
|
836
|
-
@
|
|
901
|
+
@interjections_mutex.synchronize { @active_interjections << interjections }
|
|
837
902
|
end
|
|
838
903
|
end
|
|
839
904
|
|
|
840
|
-
def
|
|
841
|
-
@
|
|
905
|
+
def unregister_interjections(interjections)
|
|
906
|
+
@interjections_mutex.synchronize { @active_interjections.delete(interjections) }
|
|
842
907
|
end
|
|
843
908
|
|
|
844
|
-
def
|
|
845
|
-
source =
|
|
909
|
+
def interjection_message(interjection)
|
|
910
|
+
source = interjection.message
|
|
846
911
|
Message.new(
|
|
847
912
|
role: :user,
|
|
848
913
|
content: [
|
|
849
914
|
Content::Text.new(text: <<~MESSAGE.strip),
|
|
850
|
-
Agent
|
|
915
|
+
Agent interjection:
|
|
851
916
|
|
|
852
917
|
Respond briefly in ordinary text before any tool calls, then continue the current task unless this
|
|
853
|
-
|
|
918
|
+
interjection asks you to finish.
|
|
854
919
|
MESSAGE
|
|
855
920
|
*source.content
|
|
856
921
|
],
|
|
857
|
-
metadata: source.metadata.merge(
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
922
|
+
metadata: source.metadata.merge(interjection.metadata).merge(
|
|
923
|
+
little_ghost_interjection_id: interjection.id,
|
|
924
|
+
little_ghost_interjection_batch_key: interjection.batch_key,
|
|
925
|
+
little_ghost_interjection_metadata: interjection.metadata
|
|
861
926
|
)
|
|
862
927
|
)
|
|
863
928
|
end
|
|
864
929
|
|
|
865
|
-
def
|
|
930
|
+
def request_with_interjection(request, interjection)
|
|
866
931
|
ModelRequest.new(
|
|
867
932
|
messages: [
|
|
868
933
|
*request.messages,
|
|
869
|
-
*
|
|
870
|
-
.map { |ticket|
|
|
934
|
+
*interjection.tickets.reject { |ticket| request_contains_interjection?(request, ticket) }
|
|
935
|
+
.map { |ticket| interjection_message(ticket) }
|
|
871
936
|
],
|
|
872
937
|
tools: request.tools,
|
|
873
938
|
settings: request.settings,
|
|
@@ -879,10 +944,10 @@ module LittleGhost
|
|
|
879
944
|
)
|
|
880
945
|
end
|
|
881
946
|
|
|
882
|
-
def
|
|
947
|
+
def request_contains_interjection?(request, interjection)
|
|
883
948
|
request.messages.any? do |message|
|
|
884
|
-
(message.metadata[:
|
|
885
|
-
message.metadata["
|
|
949
|
+
(message.metadata[:little_ghost_interjection_id] ||
|
|
950
|
+
message.metadata["little_ghost_interjection_id"]) == interjection.id
|
|
886
951
|
end
|
|
887
952
|
end
|
|
888
953
|
|
|
@@ -906,16 +971,22 @@ module LittleGhost
|
|
|
906
971
|
template_paths:,
|
|
907
972
|
events:,
|
|
908
973
|
parent_operation_id:,
|
|
909
|
-
|
|
910
|
-
|
|
974
|
+
interjections:,
|
|
975
|
+
interject_ready:
|
|
911
976
|
)
|
|
912
977
|
started_at = monotonic_time
|
|
913
978
|
operation_id = SecureRandom.uuid
|
|
979
|
+
events = agent_stream_events(
|
|
980
|
+
events,
|
|
981
|
+
input:,
|
|
982
|
+
operation_id:,
|
|
983
|
+
parent_operation_id:
|
|
984
|
+
)
|
|
914
985
|
context.bind_agent_operation_id(operation_id)
|
|
915
986
|
@assembly_transitions_mutex.synchronize { @assembly_transition = nil }
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
987
|
+
interjections.bind(operation_id, target_operation_id: parent_operation_id)
|
|
988
|
+
register_interjections(interjections)
|
|
989
|
+
interject_ready&.call
|
|
919
990
|
agent_handle = start_instrumentation(
|
|
920
991
|
:agent,
|
|
921
992
|
parent: parent_operation_id || active_instrumentation_parent,
|
|
@@ -928,7 +999,6 @@ module LittleGhost
|
|
|
928
999
|
prompt = rendered_system_prompt(template_locals, template_paths)
|
|
929
1000
|
messages.unshift(Message.new(role: :system, content: prompt)) unless prompt.to_s.empty?
|
|
930
1001
|
messages << (input.is_a?(Message) ? input : Message.new(role: :user, content: input))
|
|
931
|
-
tool_call_count = 0
|
|
932
1002
|
structured_result_repair_due = false
|
|
933
1003
|
|
|
934
1004
|
decision = run_callbacks(:before_invocation, {messages: messages}, context: context)
|
|
@@ -946,7 +1016,7 @@ module LittleGhost
|
|
|
946
1016
|
)
|
|
947
1017
|
begin
|
|
948
1018
|
context.check!
|
|
949
|
-
response,
|
|
1019
|
+
response, interjected = invoke_model(
|
|
950
1020
|
messages,
|
|
951
1021
|
context,
|
|
952
1022
|
settings,
|
|
@@ -954,9 +1024,9 @@ module LittleGhost
|
|
|
954
1024
|
events,
|
|
955
1025
|
parent_operation_id: turn_operation_id,
|
|
956
1026
|
structured_result_repair_due:,
|
|
957
|
-
|
|
1027
|
+
interjections:
|
|
958
1028
|
)
|
|
959
|
-
messages.reject! { |message|
|
|
1029
|
+
messages.reject! { |message| artifact_presentation_message?(message) }
|
|
960
1030
|
messages << response.message
|
|
961
1031
|
tool_uses = response.message.content.grep(Content::ToolUse)
|
|
962
1032
|
result_tool_uses = structured_result_tool_uses(tool_uses)
|
|
@@ -969,12 +1039,12 @@ module LittleGhost
|
|
|
969
1039
|
)
|
|
970
1040
|
unless validation_error
|
|
971
1041
|
messages[-1] = redact_structured_result_message(response.message)
|
|
972
|
-
unless
|
|
1042
|
+
unless interjections.finish
|
|
973
1043
|
context.checkpoint(messages)
|
|
974
1044
|
finish_instrumentation(
|
|
975
1045
|
turn_handle,
|
|
976
1046
|
operation_id: turn_operation_id,
|
|
977
|
-
outcome: :
|
|
1047
|
+
outcome: :interjected,
|
|
978
1048
|
turn: turn + 1
|
|
979
1049
|
)
|
|
980
1050
|
next
|
|
@@ -1029,20 +1099,20 @@ module LittleGhost
|
|
|
1029
1099
|
raise OutputLimitError, "The model stopped before completing its response"
|
|
1030
1100
|
end
|
|
1031
1101
|
|
|
1032
|
-
if
|
|
1033
|
-
unless
|
|
1102
|
+
if interjected || !@structured_output_strategy
|
|
1103
|
+
unless interjections.finish
|
|
1034
1104
|
context.checkpoint(messages)
|
|
1035
1105
|
finish_instrumentation(
|
|
1036
1106
|
turn_handle,
|
|
1037
1107
|
operation_id: turn_operation_id,
|
|
1038
|
-
outcome: :
|
|
1108
|
+
outcome: :interjected,
|
|
1039
1109
|
turn: turn + 1
|
|
1040
1110
|
)
|
|
1041
1111
|
next
|
|
1042
1112
|
end
|
|
1043
1113
|
end
|
|
1044
1114
|
|
|
1045
|
-
if @structured_output_strategy && !
|
|
1115
|
+
if @structured_output_strategy && !interjected
|
|
1046
1116
|
validation_error = if @structured_output_strategy.provider?
|
|
1047
1117
|
capture_structured_result(response.message.text, context)
|
|
1048
1118
|
else
|
|
@@ -1050,12 +1120,12 @@ module LittleGhost
|
|
|
1050
1120
|
end
|
|
1051
1121
|
unless validation_error
|
|
1052
1122
|
messages[-1] = redact_structured_result_message(response.message)
|
|
1053
|
-
unless
|
|
1123
|
+
unless interjections.finish
|
|
1054
1124
|
context.checkpoint(messages)
|
|
1055
1125
|
finish_instrumentation(
|
|
1056
1126
|
turn_handle,
|
|
1057
1127
|
operation_id: turn_operation_id,
|
|
1058
|
-
outcome: :
|
|
1128
|
+
outcome: :interjected,
|
|
1059
1129
|
turn: turn + 1
|
|
1060
1130
|
)
|
|
1061
1131
|
next
|
|
@@ -1145,9 +1215,6 @@ module LittleGhost
|
|
|
1145
1215
|
)
|
|
1146
1216
|
end
|
|
1147
1217
|
|
|
1148
|
-
tool_call_count += tool_uses.length
|
|
1149
|
-
raise ProtocolError, "The agent reached its maximum tool calls" if tool_call_count > @max_tool_calls
|
|
1150
|
-
|
|
1151
1218
|
executed_tools = with_assembly_tool_batch(context, tool_uses.length) do
|
|
1152
1219
|
dispatch_tools(
|
|
1153
1220
|
tool_uses,
|
|
@@ -1158,15 +1225,16 @@ module LittleGhost
|
|
|
1158
1225
|
end
|
|
1159
1226
|
messages << Message.new(
|
|
1160
1227
|
role: :tool,
|
|
1161
|
-
content: executed_tools.map(&:result)
|
|
1228
|
+
content: executed_tools.map(&:result),
|
|
1229
|
+
metadata: artifact_message_metadata(executed_tools)
|
|
1162
1230
|
)
|
|
1163
1231
|
executed_tools.each do |executed_tool|
|
|
1164
|
-
next if executed_tool.
|
|
1232
|
+
next if executed_tool.presentation_content.empty?
|
|
1165
1233
|
|
|
1166
1234
|
messages << Message.new(
|
|
1167
1235
|
role: :user,
|
|
1168
|
-
content: executed_tool.
|
|
1169
|
-
metadata: {transient: true,
|
|
1236
|
+
content: executed_tool.presentation_content,
|
|
1237
|
+
metadata: {transient: true, little_ghost_artifact_presentation: true}
|
|
1170
1238
|
)
|
|
1171
1239
|
end
|
|
1172
1240
|
context.checkpoint(messages)
|
|
@@ -1263,10 +1331,10 @@ module LittleGhost
|
|
|
1263
1331
|
turn,
|
|
1264
1332
|
events,
|
|
1265
1333
|
parent_operation_id:,
|
|
1266
|
-
|
|
1334
|
+
interjections:,
|
|
1267
1335
|
structured_result_repair_due: false,
|
|
1268
1336
|
recovery_attempt: 0,
|
|
1269
|
-
|
|
1337
|
+
interjection: nil
|
|
1270
1338
|
)
|
|
1271
1339
|
started_at = monotonic_time
|
|
1272
1340
|
operation_id = SecureRandom.uuid
|
|
@@ -1283,9 +1351,9 @@ module LittleGhost
|
|
|
1283
1351
|
cancellation_token: context.cancellation_token,
|
|
1284
1352
|
deadline: context.deadline
|
|
1285
1353
|
)
|
|
1286
|
-
|
|
1287
|
-
if
|
|
1288
|
-
context.
|
|
1354
|
+
interjection ||= interjections.deliver
|
|
1355
|
+
if interjection
|
|
1356
|
+
context.activate_interjection(metadata: interjection.metadata, ids: interjection.interjection_ids)
|
|
1289
1357
|
end
|
|
1290
1358
|
decision = run_callbacks(
|
|
1291
1359
|
:before_model,
|
|
@@ -1294,11 +1362,11 @@ module LittleGhost
|
|
|
1294
1362
|
)
|
|
1295
1363
|
apply_cancellation_decision!(decision)
|
|
1296
1364
|
request = replacement_value(decision, :request, request)
|
|
1297
|
-
|
|
1298
|
-
!
|
|
1365
|
+
interjection_delivered = interjection&.tickets&.any? do |ticket|
|
|
1366
|
+
!request_contains_interjection?(request, ticket)
|
|
1299
1367
|
end
|
|
1300
|
-
if
|
|
1301
|
-
request =
|
|
1368
|
+
if interjection_delivered
|
|
1369
|
+
request = request_with_interjection(request, interjection)
|
|
1302
1370
|
end
|
|
1303
1371
|
messages.replace(request.messages)
|
|
1304
1372
|
context.checkpoint(messages)
|
|
@@ -1313,26 +1381,27 @@ module LittleGhost
|
|
|
1313
1381
|
model_settings: request.settings,
|
|
1314
1382
|
**model_attributes
|
|
1315
1383
|
)
|
|
1316
|
-
if
|
|
1317
|
-
|
|
1384
|
+
if interjection_delivered
|
|
1385
|
+
interjection.tickets.each do |ticket|
|
|
1318
1386
|
instrument(
|
|
1319
|
-
:
|
|
1387
|
+
:agent_interjection_delivered,
|
|
1320
1388
|
parent_operation_id: operation_id,
|
|
1321
|
-
|
|
1322
|
-
event_kind: :
|
|
1389
|
+
interjection_id: ticket.id,
|
|
1390
|
+
event_kind: :interjection
|
|
1323
1391
|
)
|
|
1324
1392
|
end
|
|
1325
1393
|
emit(
|
|
1326
1394
|
events,
|
|
1327
|
-
:
|
|
1328
|
-
|
|
1329
|
-
batch_key:
|
|
1395
|
+
:agent_interjection_delivered,
|
|
1396
|
+
interjection_ids: interjection.interjection_ids,
|
|
1397
|
+
batch_key: interjection.batch_key
|
|
1330
1398
|
)
|
|
1331
1399
|
end
|
|
1332
1400
|
emit(events, :model_start, turn: turn)
|
|
1333
1401
|
response = nil
|
|
1334
1402
|
time_to_first_token = nil
|
|
1335
1403
|
buffered_events = strategy ? [] : nil
|
|
1404
|
+
code_mode_control_indexes = {}
|
|
1336
1405
|
|
|
1337
1406
|
model.stream(request).each do |event|
|
|
1338
1407
|
context.check!
|
|
@@ -1353,7 +1422,9 @@ module LittleGhost
|
|
|
1353
1422
|
**model_attributes
|
|
1354
1423
|
)
|
|
1355
1424
|
end
|
|
1356
|
-
|
|
1425
|
+
unless code_mode_control_event?(event, code_mode_control_indexes)
|
|
1426
|
+
buffered_events ? buffered_events << event : events << event
|
|
1427
|
+
end
|
|
1357
1428
|
response = event.data[:response] if event.type == :message_stop
|
|
1358
1429
|
end
|
|
1359
1430
|
raise ProtocolError, "The model stream ended without a response" unless response
|
|
@@ -1364,21 +1435,21 @@ module LittleGhost
|
|
|
1364
1435
|
decision = run_callbacks(:after_model, {request: request, response: response, turn: turn}, context: context)
|
|
1365
1436
|
apply_cancellation_decision!(decision)
|
|
1366
1437
|
response = replacement_value(decision, :response, response)
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1438
|
+
interjections.resolve(
|
|
1439
|
+
interjection,
|
|
1440
|
+
AgentInterjections::Result.new(
|
|
1370
1441
|
text: response.message.text,
|
|
1371
1442
|
tool_calls: response.message.content.any? { |content| content.is_a?(Content::ToolUse) },
|
|
1372
|
-
|
|
1373
|
-
batch_key:
|
|
1443
|
+
interjection_ids: interjection&.interjection_ids || [],
|
|
1444
|
+
batch_key: interjection&.batch_key
|
|
1374
1445
|
)
|
|
1375
1446
|
)
|
|
1376
|
-
|
|
1447
|
+
interjection&.tickets&.each do |ticket|
|
|
1377
1448
|
instrument(
|
|
1378
|
-
:
|
|
1449
|
+
:agent_interjection_responded,
|
|
1379
1450
|
parent_operation_id: operation_id,
|
|
1380
|
-
|
|
1381
|
-
event_kind: :
|
|
1451
|
+
interjection_id: ticket.id,
|
|
1452
|
+
event_kind: :interjection,
|
|
1382
1453
|
diagnostic: {output: response.message.text}
|
|
1383
1454
|
)
|
|
1384
1455
|
end
|
|
@@ -1427,7 +1498,7 @@ module LittleGhost
|
|
|
1427
1498
|
repair: structured_result_repair_due
|
|
1428
1499
|
)
|
|
1429
1500
|
)
|
|
1430
|
-
[response, !
|
|
1501
|
+
[response, !interjection.nil?]
|
|
1431
1502
|
rescue => error
|
|
1432
1503
|
finish_instrumentation(
|
|
1433
1504
|
model_handle,
|
|
@@ -1463,8 +1534,8 @@ module LittleGhost
|
|
|
1463
1534
|
parent_operation_id:,
|
|
1464
1535
|
structured_result_repair_due:,
|
|
1465
1536
|
recovery_attempt: recovery_attempt + 1,
|
|
1466
|
-
|
|
1467
|
-
|
|
1537
|
+
interjections:,
|
|
1538
|
+
interjection:
|
|
1468
1539
|
)
|
|
1469
1540
|
end
|
|
1470
1541
|
end
|
|
@@ -1476,7 +1547,8 @@ module LittleGhost
|
|
|
1476
1547
|
raise ProtocolError, "The model returned duplicate tool use ids"
|
|
1477
1548
|
end
|
|
1478
1549
|
|
|
1479
|
-
tool_uses.each { |tool_use| emit(events, :tool_start, tool_use: tool_use) }
|
|
1550
|
+
tool_uses.each { |tool_use| emit(events, :tool_start, tool_use: tool_use) unless code_mode_control_tool?(tool_use) }
|
|
1551
|
+
presentation_budget = Artifacts::PresentationBudget.new
|
|
1480
1552
|
pairs = tool_uses.map do |tool_use|
|
|
1481
1553
|
[tool_use, tool_registry.fetch(tool_use.name)]
|
|
1482
1554
|
rescue ToolError => error
|
|
@@ -1515,7 +1587,8 @@ module LittleGhost
|
|
|
1515
1587
|
exception: diagnostic_tool_exception(tool, tool:)
|
|
1516
1588
|
}
|
|
1517
1589
|
)
|
|
1518
|
-
|
|
1590
|
+
execution_result = Tool::ExecutionResult.new(content: result.content, status: :error, error: tool)
|
|
1591
|
+
next ExecutedTool.new(result:, execution_result:)
|
|
1519
1592
|
end
|
|
1520
1593
|
|
|
1521
1594
|
context.check!
|
|
@@ -1547,7 +1620,8 @@ module LittleGhost
|
|
|
1547
1620
|
exception: diagnostic_tool_exception(rejection, tool:)
|
|
1548
1621
|
}
|
|
1549
1622
|
)
|
|
1550
|
-
|
|
1623
|
+
execution_result = Tool::ExecutionResult.new(content: result.content, status: :error, error: rejection)
|
|
1624
|
+
next ExecutedTool.new(result:, execution_result:)
|
|
1551
1625
|
end
|
|
1552
1626
|
|
|
1553
1627
|
execution_context = ToolExecution.new(
|
|
@@ -1560,20 +1634,36 @@ module LittleGhost
|
|
|
1560
1634
|
parent_trace_context:
|
|
1561
1635
|
)
|
|
1562
1636
|
invoke = lambda do
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1637
|
+
ExecutionState.with(tool_execution: execution_context) do
|
|
1638
|
+
with_tool_execution(execution_context) do
|
|
1639
|
+
invoke_tool(
|
|
1640
|
+
tool_use, tool, context,
|
|
1641
|
+
operation_id:, parent_operation_id:
|
|
1642
|
+
)
|
|
1643
|
+
end
|
|
1568
1644
|
end
|
|
1569
1645
|
end
|
|
1570
|
-
tool_result = tool.exclusive? ?
|
|
1646
|
+
tool_result = if tool.exclusive? && !code_mode_control_tool?(tool_use)
|
|
1647
|
+
synchronize_exclusive_tools(&invoke)
|
|
1648
|
+
else
|
|
1649
|
+
invoke.call
|
|
1650
|
+
end
|
|
1571
1651
|
after_decision = run_callbacks(
|
|
1572
1652
|
:after_tool,
|
|
1573
1653
|
callback_payload.merge(result: tool_result),
|
|
1574
1654
|
context: context
|
|
1575
1655
|
)
|
|
1576
1656
|
tool_result = replacement_value(after_decision, :result, tool_result)
|
|
1657
|
+
if @artifact_lifecycle
|
|
1658
|
+
tool_result = @artifact_lifecycle.prepare_tool_result(
|
|
1659
|
+
tool_result,
|
|
1660
|
+
tool_use:,
|
|
1661
|
+
run: @run,
|
|
1662
|
+
workspace:,
|
|
1663
|
+
context:
|
|
1664
|
+
)
|
|
1665
|
+
end
|
|
1666
|
+
tool_result = bound_artifact_presentation(tool_result, presentation_budget)
|
|
1577
1667
|
result = build_tool_result(
|
|
1578
1668
|
tool_use_id: tool_use.id,
|
|
1579
1669
|
content: tool_result.content,
|
|
@@ -1594,7 +1684,7 @@ module LittleGhost
|
|
|
1594
1684
|
exception: tool_error && diagnostic_tool_exception(tool_error, tool:)
|
|
1595
1685
|
}.compact
|
|
1596
1686
|
)
|
|
1597
|
-
ExecutedTool.new(result:,
|
|
1687
|
+
ExecutedTool.new(result:, execution_result: tool_result)
|
|
1598
1688
|
rescue ToolError => error
|
|
1599
1689
|
result = build_tool_result(tool_use_id: tool_use.id, content: error.message, status: :error)
|
|
1600
1690
|
finish_instrumentation(
|
|
@@ -1610,7 +1700,8 @@ module LittleGhost
|
|
|
1610
1700
|
exception: diagnostic_tool_exception(error, tool:)
|
|
1611
1701
|
}
|
|
1612
1702
|
)
|
|
1613
|
-
|
|
1703
|
+
execution_result = Tool::ExecutionResult.new(content: result.content, status: :error, error:)
|
|
1704
|
+
ExecutedTool.new(result:, execution_result:)
|
|
1614
1705
|
rescue => error
|
|
1615
1706
|
finish_instrumentation(
|
|
1616
1707
|
tool_handle,
|
|
@@ -1627,7 +1718,7 @@ module LittleGhost
|
|
|
1627
1718
|
if tools.any?(&:exclusive?)
|
|
1628
1719
|
pairs.map do |tool_use, tool|
|
|
1629
1720
|
executed_tool = execution.call(tool_use, tool)
|
|
1630
|
-
emit(events, :tool_stop, tool_use:, result: executed_tool.result)
|
|
1721
|
+
emit(events, :tool_stop, tool_use:, result: executed_tool.result) unless code_mode_control_tool?(tool_use)
|
|
1631
1722
|
executed_tool
|
|
1632
1723
|
end
|
|
1633
1724
|
else
|
|
@@ -1635,7 +1726,8 @@ module LittleGhost
|
|
|
1635
1726
|
pairs,
|
|
1636
1727
|
cancellation_token: context.cancellation_token,
|
|
1637
1728
|
on_result: lambda do |index, executed_tool|
|
|
1638
|
-
|
|
1729
|
+
tool_use = tool_uses.fetch(index)
|
|
1730
|
+
emit(events, :tool_stop, tool_use:, result: executed_tool.result) unless code_mode_control_tool?(tool_use)
|
|
1639
1731
|
end
|
|
1640
1732
|
) do |tool_use, tool|
|
|
1641
1733
|
execution.call(tool_use, tool)
|
|
@@ -1643,10 +1735,82 @@ module LittleGhost
|
|
|
1643
1735
|
end
|
|
1644
1736
|
end
|
|
1645
1737
|
|
|
1738
|
+
def bound_artifact_presentation(result, budget)
|
|
1739
|
+
accepted = budget.accept(result.presentation_content)
|
|
1740
|
+
return result if accepted.equal?(result.presentation_content) || accepted == result.presentation_content
|
|
1741
|
+
|
|
1742
|
+
fallback = result.artifacts.reject do |artifact|
|
|
1743
|
+
artifact.metadata[:complete_result] || artifact.metadata["complete_result"]
|
|
1744
|
+
end
|
|
1745
|
+
references = artifact_references(fallback)
|
|
1746
|
+
content = if references.empty?
|
|
1747
|
+
result.content
|
|
1748
|
+
else
|
|
1749
|
+
"#{result.content}\n\nArtifacts:\n#{references.join("\n")}"
|
|
1750
|
+
end
|
|
1751
|
+
|
|
1752
|
+
Tool::ExecutionResult.new(
|
|
1753
|
+
value: result.value,
|
|
1754
|
+
content:,
|
|
1755
|
+
status: result.status,
|
|
1756
|
+
error: result.error,
|
|
1757
|
+
artifacts: result.artifacts,
|
|
1758
|
+
presentation_content: accepted
|
|
1759
|
+
)
|
|
1760
|
+
end
|
|
1761
|
+
|
|
1762
|
+
def artifact_references(artifacts)
|
|
1763
|
+
artifacts.filter_map do |artifact|
|
|
1764
|
+
next unless artifact.reference.is_a?(String) && artifact.bytes
|
|
1765
|
+
|
|
1766
|
+
details = [artifact.media_type, "#{artifact.bytes} bytes"].compact.join(", ")
|
|
1767
|
+
"- #{artifact.reference} (#{details})"
|
|
1768
|
+
end
|
|
1769
|
+
end
|
|
1770
|
+
|
|
1771
|
+
def artifact_message_metadata(executed_tools)
|
|
1772
|
+
artifacts = executed_tools.flat_map(&:artifacts).select do |artifact|
|
|
1773
|
+
artifact.reference.is_a?(String) && artifact.bytes
|
|
1774
|
+
end
|
|
1775
|
+
return {} if artifacts.empty?
|
|
1776
|
+
|
|
1777
|
+
{
|
|
1778
|
+
"little_ghost_artifacts" => artifacts.map do |artifact|
|
|
1779
|
+
{
|
|
1780
|
+
"reference" => artifact.reference,
|
|
1781
|
+
"name" => artifact.name,
|
|
1782
|
+
"media_type" => artifact.media_type,
|
|
1783
|
+
"bytes" => artifact.bytes
|
|
1784
|
+
}.compact
|
|
1785
|
+
end
|
|
1786
|
+
}
|
|
1787
|
+
end
|
|
1788
|
+
|
|
1646
1789
|
def invoke_tool(tool_use, tool, context, operation_id:, parent_operation_id:)
|
|
1647
1790
|
tool.execute(tool_use.input, context:)
|
|
1648
1791
|
end
|
|
1649
1792
|
|
|
1793
|
+
def code_mode_control_tool?(tool_use)
|
|
1794
|
+
@code_mode_runtime && %w[exec wait stop].include?(tool_use.name)
|
|
1795
|
+
end
|
|
1796
|
+
|
|
1797
|
+
def code_mode_control_event?(event, indexes)
|
|
1798
|
+
return false unless @code_mode_runtime
|
|
1799
|
+
|
|
1800
|
+
index = event.data[:index]
|
|
1801
|
+
case event.type
|
|
1802
|
+
when :tool_call_start
|
|
1803
|
+
indexes[index] = true if %w[exec wait stop].include?(event.data[:name])
|
|
1804
|
+
indexes.key?(index)
|
|
1805
|
+
when :tool_call_delta
|
|
1806
|
+
indexes.key?(index)
|
|
1807
|
+
when :tool_call_stop
|
|
1808
|
+
indexes.delete(index)
|
|
1809
|
+
else
|
|
1810
|
+
false
|
|
1811
|
+
end
|
|
1812
|
+
end
|
|
1813
|
+
|
|
1650
1814
|
def parent_trace_attributes(trace_context)
|
|
1651
1815
|
return {} unless trace_context.is_a?(Hash) && !trace_context.empty?
|
|
1652
1816
|
|
|
@@ -1943,19 +2107,39 @@ module LittleGhost
|
|
|
1943
2107
|
|
|
1944
2108
|
def rendered_system_prompt(locals, invocation_paths)
|
|
1945
2109
|
prompt = self.class.system_prompt
|
|
1946
|
-
|
|
1947
|
-
return prompt if prompt
|
|
2110
|
+
prompt = prompt.call(locals) if prompt.respond_to?(:call)
|
|
2111
|
+
return append_code_mode_instructions(prompt) if prompt
|
|
1948
2112
|
template = self.class.system_template
|
|
1949
|
-
return DEFAULT_SYSTEM_PROMPT if instance_of?(Agent) && run && !template
|
|
2113
|
+
return append_code_mode_instructions(DEFAULT_SYSTEM_PROMPT) if instance_of?(Agent) && run && !template
|
|
1950
2114
|
|
|
1951
2115
|
template ||= "#{self.class.logical_path}/system" if run
|
|
1952
2116
|
return nil unless template
|
|
1953
2117
|
|
|
1954
|
-
@template_resolver.render(
|
|
2118
|
+
append_code_mode_instructions(@template_resolver.render(
|
|
1955
2119
|
template,
|
|
1956
2120
|
locals: locals,
|
|
1957
2121
|
invocation_paths: invocation_paths
|
|
1958
|
-
)
|
|
2122
|
+
))
|
|
2123
|
+
end
|
|
2124
|
+
|
|
2125
|
+
def initialize_code_mode
|
|
2126
|
+
declaration = self.class.code_mode_configuration
|
|
2127
|
+
return unless declaration
|
|
2128
|
+
|
|
2129
|
+
defaults = @runtime.respond_to?(:code_mode_configuration) ? @runtime.code_mode_configuration : nil
|
|
2130
|
+
@code_mode_declaration = (defaults || {}).merge(declaration).transform_keys(&:to_sym).freeze
|
|
2131
|
+
unknown = @code_mode_declaration.keys - %i[engine except sandbox limits]
|
|
2132
|
+
raise ConfigurationError, "Unknown code-mode option: #{unknown.first.inspect}" unless unknown.empty?
|
|
2133
|
+
@code_mode_runtime = CodeMode::AgentRuntime.new(agent: self, declaration: @code_mode_declaration)
|
|
2134
|
+
@tool_registry.register(CodeMode::ExecTool)
|
|
2135
|
+
@tool_registry.register(CodeMode::WaitTool)
|
|
2136
|
+
@tool_registry.register(CodeMode::StopTool)
|
|
2137
|
+
end
|
|
2138
|
+
|
|
2139
|
+
def append_code_mode_instructions(prompt)
|
|
2140
|
+
return prompt unless @code_mode_runtime
|
|
2141
|
+
|
|
2142
|
+
[prompt, @code_mode_runtime.instructions].compact.reject(&:empty?).join("\n\n")
|
|
1959
2143
|
end
|
|
1960
2144
|
|
|
1961
2145
|
def default_template_resolver(paths)
|
|
@@ -1981,6 +2165,19 @@ module LittleGhost
|
|
|
1981
2165
|
events << StreamEvent.build(type, **data)
|
|
1982
2166
|
end
|
|
1983
2167
|
|
|
2168
|
+
def agent_stream_events(events, input:, operation_id:, parent_operation_id:)
|
|
2169
|
+
return events unless run&.include_agent_events?
|
|
2170
|
+
|
|
2171
|
+
source = AgentStreamSource.build(
|
|
2172
|
+
agent_id: self.class.agent_id,
|
|
2173
|
+
agent_path:,
|
|
2174
|
+
operation_id:,
|
|
2175
|
+
parent_operation_id:,
|
|
2176
|
+
assembly_path: agent_stream_path
|
|
2177
|
+
)
|
|
2178
|
+
AgentStreamSink.new(destination: events, run:, source:, input:)
|
|
2179
|
+
end
|
|
2180
|
+
|
|
1984
2181
|
def instrument(name, **attributes)
|
|
1985
2182
|
attributes.delete(:diagnostic) unless self.class.capture_diagnostics
|
|
1986
2183
|
values = correlation_attributes.merge(attributes.compact)
|
|
@@ -2091,8 +2288,8 @@ module LittleGhost
|
|
|
2091
2288
|
value.respond_to?(:role) ? diagnostic_message(value) : value.to_s
|
|
2092
2289
|
end
|
|
2093
2290
|
|
|
2094
|
-
def
|
|
2095
|
-
message.metadata[:
|
|
2291
|
+
def artifact_presentation_message?(message)
|
|
2292
|
+
message.metadata[:little_ghost_artifact_presentation] || message.metadata["little_ghost_artifact_presentation"]
|
|
2096
2293
|
end
|
|
2097
2294
|
|
|
2098
2295
|
def diagnostic_tool_exception(error, tool:)
|