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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +90 -84
  3. data/docs/guides/assemblies.md +412 -0
  4. data/docs/guides/code_mode.md +275 -0
  5. data/docs/guides/core_concepts.md +150 -239
  6. data/docs/guides/getting_started.md +125 -87
  7. data/docs/guides/integrations.md +217 -0
  8. data/docs/guides/models_and_providers.md +125 -0
  9. data/docs/guides/production.md +253 -0
  10. data/docs/guides/prompt_views.md +139 -0
  11. data/docs/guides/sandboxing.md +282 -0
  12. data/docs/guides/skills.md +141 -0
  13. data/docs/guides/structured_outputs_and_content.md +135 -0
  14. data/docs/guides/tools.md +329 -0
  15. data/lib/little_ghost/ag_ui/adapter.rb +5 -5
  16. data/lib/little_ghost/agent/delegation.rb +3 -3
  17. data/lib/little_ghost/agent/skills.rb +6 -1
  18. data/lib/little_ghost/agent/tool_loop.rb +6 -2
  19. data/lib/little_ghost/agent.rb +411 -214
  20. data/lib/little_ghost/agent_builder.rb +61 -22
  21. data/lib/little_ghost/{agent_interruptions.rb → agent_interjections.rb} +12 -12
  22. data/lib/little_ghost/agent_stream_source.rb +262 -0
  23. data/lib/little_ghost/artifact.rb +182 -0
  24. data/lib/little_ghost/artifacts/presentation_budget.rb +44 -0
  25. data/lib/little_ghost/artifacts/workspace_store.rb +370 -0
  26. data/lib/little_ghost/artifacts.rb +11 -0
  27. data/lib/little_ghost/assembly.rb +110 -30
  28. data/lib/little_ghost/assembly_builder.rb +59 -21
  29. data/lib/little_ghost/assembly_execution.rb +110 -6
  30. data/lib/little_ghost/code_mode/broker.rb +164 -0
  31. data/lib/little_ghost/code_mode/catalog.rb +54 -0
  32. data/lib/little_ghost/code_mode/engine.rb +58 -0
  33. data/lib/little_ghost/code_mode/javascript/catalog.rb +118 -0
  34. data/lib/little_ghost/code_mode/javascript/client.rb +550 -0
  35. data/lib/little_ghost/code_mode/javascript/host.rb +347 -0
  36. data/lib/little_ghost/code_mode/javascript/session.rb +579 -0
  37. data/lib/little_ghost/code_mode/javascript_engine.rb +172 -0
  38. data/lib/little_ghost/code_mode/protocol.rb +78 -0
  39. data/lib/little_ghost/code_mode/ruby/catalog.rb +80 -0
  40. data/lib/little_ghost/code_mode/ruby/host.rb +102 -0
  41. data/lib/little_ghost/code_mode/ruby/session.rb +508 -0
  42. data/lib/little_ghost/code_mode/ruby_engine.rb +86 -0
  43. data/lib/little_ghost/code_mode/runtime.rb +335 -0
  44. data/lib/little_ghost/code_mode/session.rb +61 -0
  45. data/lib/little_ghost/code_mode/types.rb +58 -0
  46. data/lib/little_ghost/code_mode.rb +53 -0
  47. data/lib/little_ghost/configuration.rb +366 -47
  48. data/lib/little_ghost/content.rb +24 -13
  49. data/lib/little_ghost/data_map.rb +209 -0
  50. data/lib/little_ghost/errors.rb +28 -9
  51. data/lib/little_ghost/execution.rb +33 -34
  52. data/lib/little_ghost/graph.rb +376 -197
  53. data/lib/little_ghost/mcp/client.rb +487 -88
  54. data/lib/little_ghost/mcp/toolset.rb +210 -0
  55. data/lib/little_ghost/mcp/types.rb +216 -0
  56. data/lib/little_ghost/mcp.rb +3 -0
  57. data/lib/little_ghost/message.rb +4 -4
  58. data/lib/little_ghost/model_capabilities.rb +9 -6
  59. data/lib/little_ghost/model_request.rb +0 -12
  60. data/lib/little_ghost/model_resolver.rb +15 -5
  61. data/lib/little_ghost/model_response.rb +3 -7
  62. data/lib/little_ghost/network/authorizer_server.rb +162 -0
  63. data/lib/little_ghost/network/certificate_authority.rb +98 -0
  64. data/lib/little_ghost/network/envoy_config.rb +362 -0
  65. data/lib/little_ghost/network/envoy_gateway.rb +409 -0
  66. data/lib/little_ghost/network/external_gateway.rb +68 -0
  67. data/lib/little_ghost/network.rb +96 -0
  68. data/lib/little_ghost/prompt_resolver.rb +9 -7
  69. data/lib/little_ghost/provider_registry.rb +3 -3
  70. data/lib/little_ghost/providers/anthropic.rb +8 -1
  71. data/lib/little_ghost/providers/gemini.rb +10 -1
  72. data/lib/little_ghost/providers/vertex_ai.rb +6 -1
  73. data/lib/little_ghost/run.rb +162 -54
  74. data/lib/little_ghost/run_context.rb +42 -20
  75. data/lib/little_ghost/run_result.rb +0 -7
  76. data/lib/little_ghost/runtime/hook.rb +8 -3
  77. data/lib/little_ghost/runtime/hooks/artifacts.rb +338 -0
  78. data/lib/little_ghost/runtime.rb +160 -52
  79. data/lib/little_ghost/sandbox/capabilities.rb +68 -0
  80. data/lib/little_ghost/sandbox/environment_policy.rb +41 -0
  81. data/lib/little_ghost/sandbox/filesystem.rb +377 -0
  82. data/lib/little_ghost/sandbox/isolated_backend.rb +163 -0
  83. data/lib/little_ghost/sandbox/limits.rb +48 -0
  84. data/lib/little_ghost/sandbox/mount.rb +129 -0
  85. data/lib/little_ghost/sandbox/network_policy.rb +108 -0
  86. data/lib/little_ghost/sandbox/policy.rb +88 -0
  87. data/lib/little_ghost/sandbox/process_runner.rb +103 -0
  88. data/lib/little_ghost/sandbox/process_session.rb +335 -0
  89. data/lib/little_ghost/sandbox/scope.rb +304 -0
  90. data/lib/little_ghost/sandbox.rb +158 -32
  91. data/lib/little_ghost/sandboxes/bubblewrap.rb +351 -0
  92. data/lib/little_ghost/sandboxes/native.rb +51 -0
  93. data/lib/little_ghost/sandboxes/seatbelt.rb +261 -0
  94. data/lib/little_ghost/sandboxes/unrestricted.rb +241 -0
  95. data/lib/little_ghost/session.rb +39 -26
  96. data/lib/little_ghost/session_store.rb +9 -5
  97. data/lib/little_ghost/session_stores/agent_core_memory.rb +64 -56
  98. data/lib/little_ghost/session_stores/filesystem.rb +261 -0
  99. data/lib/little_ghost/session_stores/memory.rb +7 -0
  100. data/lib/little_ghost/skills/catalog.rb +94 -14
  101. data/lib/little_ghost/skills/resource_root.rb +42 -0
  102. data/lib/little_ghost/skills/skill.rb +0 -3
  103. data/lib/little_ghost/stream_event.rb +8 -13
  104. data/lib/little_ghost/subagents/control_tool.rb +8 -0
  105. data/lib/little_ghost/subagents/manager.rb +53 -50
  106. data/lib/little_ghost/support/callbacks.rb +3 -1
  107. data/lib/little_ghost/support/content_capture.rb +3 -3
  108. data/lib/little_ghost/support/http_client.rb +2 -2
  109. data/lib/little_ghost/support/redactor.rb +1 -1
  110. data/lib/little_ghost/swarm.rb +13 -5
  111. data/lib/little_ghost/tool.rb +157 -64
  112. data/lib/little_ghost/tool_registry.rb +1 -1
  113. data/lib/little_ghost/tools/filesystem.rb +1 -1
  114. data/lib/little_ghost/tools/shell.rb +4 -3
  115. data/lib/little_ghost/tools/write_todos.rb +6 -1
  116. data/lib/little_ghost/tracing/open_telemetry.rb +1 -1
  117. data/lib/little_ghost/version.rb +1 -1
  118. data/lib/little_ghost/workflow.rb +30 -21
  119. data/lib/little_ghost/workspace.rb +222 -8
  120. data/lib/little_ghost.rb +40 -27
  121. metadata +104 -3
  122. data/lib/little_ghost/unrestricted_sandbox.rb +0 -306
@@ -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
- # A customer support agent can look up an account itself and give longer
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 :customer_support
25
- # limits max_turns: 40
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 # => "Transfer 481 is waiting for the receiving bank."
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
- # Class declarations are inherited. Prompts resolve by the agent's logical
35
- # path unless +system_prompt+ or +system_template+ supplies one explicitly;
36
- # tools and prompt locals may also be selected dynamically for each run.
37
- # Capabilities such as skills, context management, loop detection, and
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
- # The class-level +ask+ and +stream_ask+ helpers create fresh standalone
41
- # entrypoints. Create an instance explicitly to reuse one Runtime across
42
- # calls. Runtimes build bound instances internally; their +call+ method
43
- # returns a RunResult and their +stream+ method follows the owning run's
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
- # Models can return ordinary text or a locally validated structured result.
50
- # Tool failures are sanitized before returning to the model, diagnostic
51
- # capture can be disabled for sensitive agents, and cancellation, deadlines,
52
- # and cleanup failures remain framework control flow.
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, :companion_content) do # :nodoc:
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
- # A missing or invalid result receives one repair attempt before
165
- # LittleGhost::StructuredResultError is raised. Invalid schemas and
166
- # strategies raise LittleGhost::ConfigurationError immediately.
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. A provider class can supply tools
254
- # dynamically by implementing <tt>tools(binding)</tt>.
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 may be continued, replaced, or cancelled with a decision
300
- # from Support::Callbacks.
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
- # Run-scoped model, tools, lifecycle, delegation, and execution resources
470
- # available to agent extensions.
471
- attr_reader :model, :tool_registry, :run, :delegation_activity, :agent_path, :workspace, :sandbox,
472
- :max_tool_calls
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
- # Calling <tt>new</tt> without +model+ and +run+ creates the standalone form
477
- # used by +ask+ and +stream_ask+. Runtime builders supply the remaining
478
- # dependencies and apply class-level limits and declarations.
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
- @interruptions_mutex = Mutex.new
503
- @active_interruptions = []
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
- @interruptions_mutex = Mutex.new
539
- @active_interruptions = []
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
- execute_tools(tool_uses, context, events, parent_operation_id:, parent_trace_context:)
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 +message+ to one active invocation and waits for its ordinary text reply.
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
- # response value exposes +text+, +tool_calls?+, +interruption_ids+, and
608
- # +batch_key+; tool calls may continue after this response. Depend on these
609
- # methods rather than the response's concrete class.
610
- def interrupt_response(
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
- interruption_id: nil,
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, "interrupt message must be a String or LittleGhost::Message" unless message.is_a?(Message)
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, "interrupt message content must contain only text, images, or documents"
683
+ raise ArgumentError, "interject message content must contain only text, images, or documents"
628
684
  end
629
685
 
630
- interruptions = @interruptions_mutex.synchronize do
686
+ interjections = @interjections_mutex.synchronize do
631
687
  active = if target_operation_id
632
- @active_interruptions.select { |candidate| candidate.target_operation_id == target_operation_id }
688
+ @active_interjections.select { |candidate| candidate.target_operation_id == target_operation_id }
633
689
  else
634
- @active_interruptions
690
+ @active_interjections
635
691
  end
636
692
  if active.empty?
637
- raise AgentInterruptError, "Agent is not currently running"
693
+ raise AgentInterjectionError, "Agent is not currently running"
638
694
  end
639
695
  if active.length > 1
640
- raise AgentInterruptError, "Agent has multiple active invocations; the interruption target is ambiguous"
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] = interruption_id unless interruption_id.nil?
647
- ticket = interruptions.enqueue(message, **options)
702
+ options[:id] = interjection_id unless interjection_id.nil?
703
+ ticket = interjections.enqueue(message, **options)
648
704
  instrument(
649
- :agent_interrupt_queued,
650
- parent_operation_id: interruptions.operation_id,
651
- interruption_id: ticket.id,
652
- event_kind: :interrupt,
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
- interruptions.release(ticket)
713
+ interjections.release(ticket)
658
714
  response
659
715
  rescue => error
660
- interruptions.release(ticket, withdraw: true)
716
+ interjections.release(ticket, withdraw: true)
661
717
  instrument(
662
- :agent_interrupt_failed,
663
- parent_operation_id: interruptions.operation_id,
664
- interruption_id: ticket.id,
665
- event_kind: :interrupt,
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 agent
677
- # instance may be streamed only according to the lifecycle managed by its
678
- # owning run. Every template path must be an application-created TrustedPath;
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
- interruption_metadata: nil,
694
- interruption_ids: [],
695
- interrupt_ready: nil
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
- interruptions = AgentInterruptions.new
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
- interruption_metadata:,
734
- interruption_ids:
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
- interruptions:,
748
- interrupt_ready:
803
+ interjections:,
804
+ interject_ready:
749
805
  )
750
806
  end
751
807
  rescue => error
752
- interruptions.close(error)
808
+ interjections.close(error)
753
809
  raise
754
810
  ensure
755
- interruptions.close(AgentInterruptError.new("Agent finished before the interruption was delivered"))
756
- unregister_interruptions(interruptions)
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 materialized tools available during this agent run.
830
+ # The Tool registry available during this Agent run.
774
831
  def tools = tool_registry
775
832
 
776
- # Closes owned tools, interruptions, sandbox, and workspace resources.
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, interruptions = @close_mutex.synchronize do
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
- @interruptions_mutex.synchronize { @active_interruptions.dup }
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
- interruptions.each do |active|
790
- active.close(AgentInterruptError.new("Agent was closed"))
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 register_interruptions(interruptions)
897
+ def register_interjections(interjections)
833
898
  @close_mutex.synchronize do
834
899
  raise InvocationError, "Agent is closed" if @closed
835
900
 
836
- @interruptions_mutex.synchronize { @active_interruptions << interruptions }
901
+ @interjections_mutex.synchronize { @active_interjections << interjections }
837
902
  end
838
903
  end
839
904
 
840
- def unregister_interruptions(interruptions)
841
- @interruptions_mutex.synchronize { @active_interruptions.delete(interruptions) }
905
+ def unregister_interjections(interjections)
906
+ @interjections_mutex.synchronize { @active_interjections.delete(interjections) }
842
907
  end
843
908
 
844
- def interruption_message(interruption)
845
- source = interruption.message
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 interruption:
915
+ Agent interjection:
851
916
 
852
917
  Respond briefly in ordinary text before any tool calls, then continue the current task unless this
853
- interruption asks you to finish.
918
+ interjection asks you to finish.
854
919
  MESSAGE
855
920
  *source.content
856
921
  ],
857
- metadata: source.metadata.merge(interruption.metadata).merge(
858
- little_ghost_interruption_id: interruption.id,
859
- little_ghost_interruption_batch_key: interruption.batch_key,
860
- little_ghost_interruption_metadata: interruption.metadata
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 request_with_interruption(request, interruption)
930
+ def request_with_interjection(request, interjection)
866
931
  ModelRequest.new(
867
932
  messages: [
868
933
  *request.messages,
869
- *interruption.tickets.reject { |ticket| request_contains_interruption?(request, ticket) }
870
- .map { |ticket| interruption_message(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 request_contains_interruption?(request, interruption)
947
+ def request_contains_interjection?(request, interjection)
883
948
  request.messages.any? do |message|
884
- (message.metadata[:little_ghost_interruption_id] ||
885
- message.metadata["little_ghost_interruption_id"]) == interruption.id
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
- interruptions:,
910
- interrupt_ready:
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
- interruptions.bind(operation_id, target_operation_id: parent_operation_id)
917
- register_interruptions(interruptions)
918
- interrupt_ready&.call
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, interrupted = invoke_model(
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
- interruptions:
1027
+ interjections:
958
1028
  )
959
- messages.reject! { |message| tool_companion_message?(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 interruptions.finish
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: :interrupted,
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 interrupted || !@structured_output_strategy
1033
- unless interruptions.finish
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: :interrupted,
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 && !interrupted
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 interruptions.finish
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: :interrupted,
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.companion_content.empty?
1232
+ next if executed_tool.presentation_content.empty?
1165
1233
 
1166
1234
  messages << Message.new(
1167
1235
  role: :user,
1168
- content: executed_tool.companion_content,
1169
- metadata: {transient: true, little_ghost_tool_companion: 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
- interruptions:,
1334
+ interjections:,
1267
1335
  structured_result_repair_due: false,
1268
1336
  recovery_attempt: 0,
1269
- interruption: nil
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
- interruption ||= interruptions.deliver
1287
- if interruption
1288
- context.activate_interruption(metadata: interruption.metadata, ids: interruption.interruption_ids)
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
- interruption_delivered = interruption&.tickets&.any? do |ticket|
1298
- !request_contains_interruption?(request, ticket)
1365
+ interjection_delivered = interjection&.tickets&.any? do |ticket|
1366
+ !request_contains_interjection?(request, ticket)
1299
1367
  end
1300
- if interruption_delivered
1301
- request = request_with_interruption(request, interruption)
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 interruption_delivered
1317
- interruption.tickets.each do |ticket|
1384
+ if interjection_delivered
1385
+ interjection.tickets.each do |ticket|
1318
1386
  instrument(
1319
- :agent_interrupt_delivered,
1387
+ :agent_interjection_delivered,
1320
1388
  parent_operation_id: operation_id,
1321
- interruption_id: ticket.id,
1322
- event_kind: :interrupt
1389
+ interjection_id: ticket.id,
1390
+ event_kind: :interjection
1323
1391
  )
1324
1392
  end
1325
1393
  emit(
1326
1394
  events,
1327
- :agent_interrupt_delivered,
1328
- interruption_ids: interruption.interruption_ids,
1329
- batch_key: interruption.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
- buffered_events ? buffered_events << event : events << event
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
- interruptions.resolve(
1368
- interruption,
1369
- AgentInterruptions::Response.new(
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
- interruption_ids: interruption&.interruption_ids || [],
1373
- batch_key: interruption&.batch_key
1443
+ interjection_ids: interjection&.interjection_ids || [],
1444
+ batch_key: interjection&.batch_key
1374
1445
  )
1375
1446
  )
1376
- interruption&.tickets&.each do |ticket|
1447
+ interjection&.tickets&.each do |ticket|
1377
1448
  instrument(
1378
- :agent_interrupt_responded,
1449
+ :agent_interjection_responded,
1379
1450
  parent_operation_id: operation_id,
1380
- interruption_id: ticket.id,
1381
- event_kind: :interrupt,
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, !interruption.nil?]
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
- interruptions:,
1467
- interruption:
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
- next ExecutedTool.new(result:, companion_content: [])
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
- next ExecutedTool.new(result:, companion_content: [])
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
- with_tool_execution(execution_context) do
1564
- invoke_tool(
1565
- tool_use, tool, context,
1566
- operation_id:, parent_operation_id:
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? ? synchronize_exclusive_tools(&invoke) : invoke.call
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:, companion_content: tool_result.companion_content)
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
- ExecutedTool.new(result:, companion_content: [])
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
- emit(events, :tool_stop, tool_use: tool_uses.fetch(index), result: executed_tool.result)
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
- return prompt.call(locals) if prompt.respond_to?(:call)
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 tool_companion_message?(message)
2095
- message.metadata[:little_ghost_tool_companion] || message.metadata["little_ghost_tool_companion"]
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:)