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
@@ -29,17 +29,18 @@ module LittleGhost
29
29
  model: nil,
30
30
  tools: [],
31
31
  conversation_id: nil,
32
- agent_path: Subagents::AgentPath::ROOT
32
+ agent_path: Subagents::AgentPath::ROOT,
33
+ agent_stream_path: []
33
34
  )
34
35
  agent_class = @resolve_agent.call(agent_class_or_name)
35
- build_agent(agent_class, run:, model:, tools:, conversation_id:, agent_path:)
36
+ build_agent(agent_class, run:, model:, tools:, conversation_id:, agent_path:, agent_stream_path:)
36
37
  end
37
38
 
38
39
  private
39
40
 
40
41
  attr_reader :runtime, :prompt_paths
41
42
 
42
- def instantiate(agent_class, run:, tools:, model:, delegation_activity:, agent_path:)
43
+ def instantiate(agent_class, run:, tools:, model:, delegation_activity:, agent_path:, agent_stream_path:)
43
44
  agent_class.new(
44
45
  model:,
45
46
  runtime:,
@@ -51,7 +52,7 @@ module LittleGhost
51
52
  delegation_activity:,
52
53
  agent_path:,
53
54
  **agent_class.limits
54
- )
55
+ ).bind_agent_stream_path(agent_stream_path)
55
56
  end
56
57
 
57
58
  def build_agent(
@@ -60,12 +61,13 @@ module LittleGhost
60
61
  model:,
61
62
  tools:,
62
63
  conversation_id: nil,
63
- agent_path: Subagents::AgentPath::ROOT
64
+ agent_path: Subagents::AgentPath::ROOT,
65
+ agent_stream_path: []
64
66
  )
65
67
  delegation_activity = ActivityRelay.new
66
68
  configured_tools = Array(tools).dup
67
69
  configured_tools.concat(
68
- delegation_tools(agent_class, run, conversation_id:, delegation_activity:, agent_path:)
70
+ delegation_tools(agent_class, run, conversation_id:, delegation_activity:, agent_path:, agent_stream_path:)
69
71
  )
70
72
  resolved_model = model || runtime.model_for(agent_class, run)
71
73
  transferred = true
@@ -75,25 +77,33 @@ module LittleGhost
75
77
  model: resolved_model,
76
78
  tools: configured_tools,
77
79
  delegation_activity:,
78
- agent_path:
80
+ agent_path:,
81
+ agent_stream_path:
79
82
  )
80
83
  rescue
81
84
  close_tools(configured_tools) unless transferred
82
85
  raise
83
86
  end
84
87
 
85
- def delegation_tools(agent_class, run, conversation_id:, delegation_activity:, agent_path:)
86
- tools = agent_tools(agent_class, run)
87
- tools.concat(subagent_tools(agent_class, run, conversation_id:, delegation_activity:, agent_path:))
88
+ def delegation_tools(agent_class, run, conversation_id:, delegation_activity:, agent_path:, agent_stream_path:)
89
+ tools = agent_tools(agent_class, run, agent_stream_path:)
90
+ tools.concat(subagent_tools(
91
+ agent_class,
92
+ run,
93
+ conversation_id:,
94
+ delegation_activity:,
95
+ agent_path:,
96
+ agent_stream_path:
97
+ ))
88
98
  rescue
89
99
  close_tools(tools)
90
100
  raise
91
101
  end
92
102
 
93
- def agent_tools(agent_class, run)
103
+ def agent_tools(agent_class, run, agent_stream_path:)
94
104
  tools = []
95
105
  agent_class.assembly_tool_declarations.each do |declaration|
96
- child = declared_assembly(declaration, run)
106
+ child = declared_assembly(declaration, run, agent_stream_path:)
97
107
  begin
98
108
  tools << child.as_tool(
99
109
  name: declaration.fetch(:name),
@@ -111,18 +121,18 @@ module LittleGhost
111
121
  raise
112
122
  end
113
123
 
114
- def declared_assembly(declaration, run)
124
+ def declared_assembly(declaration, run, agent_stream_path:)
115
125
  assembly_class = declaration.fetch(:assembly)
116
126
  if assembly_class.is_a?(AssemblyDefinition) && assembly_class.kind == :agent
117
- declared_agent(declaration.merge(agent: assembly_class.implementation), run)
127
+ declared_agent(declaration.merge(agent: assembly_class.implementation), run, agent_stream_path:)
118
128
  elsif assembly_class <= Agent
119
- declared_agent(declaration.merge(agent: assembly_class), run)
129
+ declared_agent(declaration.merge(agent: assembly_class), run, agent_stream_path:)
120
130
  else
121
- runtime.build_assembly(assembly_class, run:)
131
+ runtime.build_assembly(assembly_class, run:, agent_stream_path:)
122
132
  end
123
133
  end
124
134
 
125
- def subagent_tools(agent_class, run, conversation_id:, delegation_activity:, agent_path:)
135
+ def subagent_tools(agent_class, run, conversation_id:, delegation_activity:, agent_path:, agent_stream_path:)
126
136
  definitions = agent_class.subagent_declarations.map do |declaration|
127
137
  Subagents::Definition.new(
128
138
  kind: declaration.fetch(:kind),
@@ -132,13 +142,16 @@ module LittleGhost
132
142
  factory: lambda do |subagent_id, child_conversation_id = nil|
133
143
  factory = declaration[:factory]
134
144
  if factory
135
- invoke_factory(factory, subagent_id, run)
145
+ invoke_factory(factory, subagent_id, run).tap do |agent|
146
+ agent.bind_agent_stream_path(agent_stream_path) if agent.respond_to?(:bind_agent_stream_path)
147
+ end
136
148
  else
137
149
  declared_agent(
138
150
  declaration,
139
151
  run,
140
152
  conversation_id: child_conversation_id,
141
- agent_path: subagent_id
153
+ agent_path: subagent_id,
154
+ agent_stream_path:
142
155
  )
143
156
  end
144
157
  end
@@ -150,7 +163,11 @@ module LittleGhost
150
163
  unless resolved.all? { |definition| definition.is_a?(Subagents::Definition) }
151
164
  raise ConfigurationError, "Subagent resolvers must return LittleGhost::Subagents::Definition objects"
152
165
  end
153
- definitions.concat(resolved.reject { |definition| declared_kinds.include?(definition.kind) })
166
+ definitions.concat(
167
+ resolved
168
+ .reject { |definition| declared_kinds.include?(definition.kind) }
169
+ .map { |definition| stream_bound_definition(definition, agent_stream_path) }
170
+ )
154
171
  end
155
172
  return [] if definitions.empty?
156
173
 
@@ -169,7 +186,13 @@ module LittleGhost
169
186
  ).tools
170
187
  end
171
188
 
172
- def declared_agent(declaration, run, conversation_id: nil, agent_path: Subagents::AgentPath::ROOT)
189
+ def declared_agent(
190
+ declaration,
191
+ run,
192
+ conversation_id: nil,
193
+ agent_path: Subagents::AgentPath::ROOT,
194
+ agent_stream_path: []
195
+ )
173
196
  reference = declaration.fetch(:agent)
174
197
  agent_class = if reference.is_a?(AssemblyDefinition)
175
198
  reference.implementation
@@ -181,7 +204,23 @@ module LittleGhost
181
204
  model: resolve(declaration[:model], run),
182
205
  tools: Array(resolve(declaration[:tools], run)),
183
206
  conversation_id:,
184
- agent_path:
207
+ agent_path:,
208
+ agent_stream_path:
209
+ )
210
+ end
211
+
212
+ def stream_bound_definition(definition, agent_stream_path)
213
+ factory = definition.factory
214
+ Subagents::Definition.new(
215
+ kind: definition.kind,
216
+ description: definition.description,
217
+ persist: definition.persist,
218
+ accepts_conversation_id: definition.accepts_conversation_id,
219
+ factory: lambda do |*arguments, **_options|
220
+ invoke_factory(factory, *arguments).tap do |agent|
221
+ agent.bind_agent_stream_path(agent_stream_path) if agent.respond_to?(:bind_agent_stream_path)
222
+ end
223
+ end
185
224
  )
186
225
  end
187
226
 
@@ -3,16 +3,16 @@
3
3
  require "securerandom"
4
4
 
5
5
  module LittleGhost
6
- class AgentInterruptions # :nodoc: all
6
+ class AgentInterjections # :nodoc: all
7
7
  MAX_BATCH_SIZE = 100
8
- MAX_INTERRUPTION_COUNT = 1_000
8
+ MAX_INTERJECTION_COUNT = 1_000
9
9
 
10
- Response = Data.define(:text, :tool_calls, :interruption_ids, :batch_key) do
11
- def initialize(text:, tool_calls:, interruption_ids: [], batch_key: nil)
10
+ Result = Data.define(:text, :tool_calls, :interjection_ids, :batch_key) do
11
+ def initialize(text:, tool_calls:, interjection_ids: [], batch_key: nil)
12
12
  super(
13
13
  text: String(text),
14
14
  tool_calls: !!tool_calls,
15
- interruption_ids: Array(interruption_ids).map { |id| String(id).dup.freeze }.freeze,
15
+ interjection_ids: Array(interjection_ids).map { |id| String(id).dup.freeze }.freeze,
16
16
  batch_key: batch_key.nil? ? nil : String(batch_key).dup.freeze
17
17
  )
18
18
  end
@@ -21,7 +21,7 @@ module LittleGhost
21
21
  end
22
22
 
23
23
  Batch = Data.define(:tickets) do
24
- def interruption_ids = tickets.map(&:id)
24
+ def interjection_ids = tickets.map(&:id)
25
25
  def batch_key = tickets.first&.batch_key
26
26
  def metadata = tickets.last&.metadata
27
27
  end
@@ -99,7 +99,7 @@ module LittleGhost
99
99
 
100
100
  def enqueue(message, id: SecureRandom.uuid, batch_key: nil, metadata: {})
101
101
  id = String(id)
102
- raise ArgumentError, "interruption_id cannot be empty" if id.empty?
102
+ raise ArgumentError, "interjection_id cannot be empty" if id.empty?
103
103
 
104
104
  batch_key = String(batch_key) unless batch_key.nil?
105
105
  metadata = metadata.to_h
@@ -111,14 +111,14 @@ module LittleGhost
111
111
  unless existing.message.to_h == message.to_h &&
112
112
  existing.batch_key == batch_key &&
113
113
  existing.metadata == metadata
114
- raise ArgumentError, "interruption_id has already been used with different input"
114
+ raise ArgumentError, "interjection_id has already been used with different input"
115
115
  end
116
116
 
117
117
  @waiters[existing] += 1
118
118
  return existing
119
119
  end
120
- if @tickets_by_id.length >= MAX_INTERRUPTION_COUNT
121
- raise AgentInterruptError, "Agent interruption capacity reached"
120
+ if @tickets_by_id.length >= MAX_INTERJECTION_COUNT
121
+ raise AgentInterjectionError, "Agent interjection capacity reached"
122
122
  end
123
123
 
124
124
  ticket = Ticket.new(message, id: id.freeze, batch_key: batch_key&.freeze, metadata:)
@@ -128,7 +128,7 @@ module LittleGhost
128
128
  ticket
129
129
  end
130
130
  rescue TypeError, NoMethodError
131
- raise ArgumentError, "interruption_id, batch_key, and metadata are invalid"
131
+ raise ArgumentError, "interjection_id, batch_key, and metadata are invalid"
132
132
  end
133
133
 
134
134
  def deliver
@@ -166,7 +166,7 @@ module LittleGhost
166
166
  return true if @closed_error
167
167
  return false if @delivered || !@queue.empty?
168
168
 
169
- @closed_error = AgentInterruptError.new("Agent is not currently running")
169
+ @closed_error = AgentInterjectionError.new("Agent is not currently running")
170
170
  true
171
171
  end
172
172
  end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ AgentStreamStep = Data.define( # :nodoc:
5
+ :assembly_id,
6
+ :assembly_kind,
7
+ :participant,
8
+ :step_id,
9
+ :branch_id
10
+ ) do
11
+ def self.build(assembly_id:, assembly_kind:, participant:, step_id:, branch_id: nil) # :nodoc:
12
+ new(
13
+ assembly_id: assembly_id.to_s.dup.freeze,
14
+ assembly_kind: assembly_kind.to_sym,
15
+ participant: participant.to_s.dup.freeze,
16
+ step_id: step_id.to_s.dup.freeze,
17
+ branch_id: branch_id&.to_s&.dup&.freeze
18
+ ).freeze
19
+ end
20
+ end
21
+
22
+ # Identifies one assembly step in the path to a streamed Agent invocation.
23
+ # Paths list outer steps before inner steps, so the final value identifies the
24
+ # participant that directly contains the Agent.
25
+ class AgentStreamStep < Data # :doc:
26
+ ##
27
+ # :attr_reader: assembly_id
28
+ # Stable String identifier for the containing assembly.
29
+
30
+ ##
31
+ # :attr_reader: assembly_kind
32
+ # The +:workflow+, +:swarm+, +:graph+, or custom assembly kind.
33
+
34
+ ##
35
+ # :attr_reader: participant
36
+ # String name used to route to this participant.
37
+
38
+ ##
39
+ # :attr_reader: step_id
40
+ # Unique String identifier for this assembly step.
41
+
42
+ ##
43
+ # :attr_reader: branch_id
44
+ # String branch identifier for a graph branch, or +nil+.
45
+ end
46
+
47
+ AgentStreamSource = Data.define( # :nodoc:
48
+ :agent_id,
49
+ :agent_path,
50
+ :operation_id,
51
+ :parent_operation_id,
52
+ :assembly_path
53
+ ) do
54
+ def self.build(agent_id:, agent_path:, operation_id:, parent_operation_id:, assembly_path:) # :nodoc:
55
+ new(
56
+ agent_id: agent_id.to_s.dup.freeze,
57
+ agent_path: agent_path.to_s.dup.freeze,
58
+ operation_id: operation_id.to_s.dup.freeze,
59
+ parent_operation_id: parent_operation_id&.to_s&.dup&.freeze,
60
+ assembly_path: Array(assembly_path).dup.freeze
61
+ ).freeze
62
+ end
63
+ end
64
+
65
+ # Describes which Agent produced an event during a Run.
66
+ #
67
+ # A +:agent_stream+ StreamEvent carries this value in +data[:source]+ and the
68
+ # Agent's event as a separate frozen StreamEvent in +data[:event]+.
69
+ # +assembly_path+ is empty for a top-level Agent and contains one
70
+ # AgentStreamStep for each enclosing composite assembly participant.
71
+ class AgentStreamSource < Data # :doc:
72
+ ##
73
+ # :attr_reader: agent_id
74
+ # Stable String identifier declared by the Agent class.
75
+
76
+ ##
77
+ # :attr_reader: agent_path
78
+ # Stable subagent path, beginning at <tt>/root</tt>.
79
+
80
+ ##
81
+ # :attr_reader: operation_id
82
+ # Unique String identifier for this Agent invocation.
83
+
84
+ ##
85
+ # :attr_reader: parent_operation_id
86
+ # Parent operation identifier, or +nil+ when the caller did not supply one.
87
+
88
+ ##
89
+ # :attr_reader: assembly_path
90
+ # Frozen Array of AgentStreamStep values from the outermost assembly inward.
91
+ end
92
+
93
+ module AgentStreamSnapshot # :nodoc: all
94
+ module_function
95
+
96
+ def event(event)
97
+ data = copy(event.data)
98
+ freeze_value(StreamEvent.build(event.type, **data))
99
+ end
100
+
101
+ def message(message)
102
+ freeze_value(copy(message))
103
+ end
104
+
105
+ def copy(value, copies = {})
106
+ return value if immutable_scalar?(value)
107
+ return copies.fetch(value.object_id) if copies.key?(value.object_id)
108
+
109
+ case value
110
+ when Message
111
+ copy_message(value, copies)
112
+ when DataMap
113
+ copy_data_map(value, copies)
114
+ when Hash
115
+ copy_hash(value, copies)
116
+ when Array
117
+ copy_array(value, copies)
118
+ when String
119
+ value.dup.freeze
120
+ when Data
121
+ copy_data(value, copies)
122
+ when Exception
123
+ copy_exception(value, copies)
124
+ else
125
+ copy_object(value, copies)
126
+ end
127
+ end
128
+
129
+ def copy_message(value, copies)
130
+ content = copy_array(value.content, copies)
131
+ metadata = copy_data_map(value.metadata, copies)
132
+ Message.new(role: value.role, content:, metadata:).freeze.tap do |snapshot|
133
+ copies[value.object_id] = snapshot
134
+ end
135
+ end
136
+
137
+ def copy_data_map(value, copies)
138
+ snapshot = DataMap.new
139
+ copies[value.object_id] = snapshot
140
+ value.each do |key, child|
141
+ snapshot[key] = copy(child, copies)
142
+ end
143
+ snapshot.freeze
144
+ end
145
+
146
+ def copy_hash(value, copies)
147
+ snapshot = {}
148
+ copies[value.object_id] = snapshot
149
+ value.each do |key, child|
150
+ snapshot[copy(key, copies)] = copy(child, copies)
151
+ end
152
+ snapshot.freeze
153
+ end
154
+
155
+ def copy_array(value, copies)
156
+ snapshot = []
157
+ copies[value.object_id] = snapshot
158
+ value.each { |child| snapshot << copy(child, copies) }
159
+ snapshot.freeze
160
+ end
161
+
162
+ def copy_data(value, copies)
163
+ attributes = value.members.to_h do |member|
164
+ [member, copy(value.public_send(member), copies)]
165
+ end
166
+ value.class.new(**attributes).freeze.tap do |snapshot|
167
+ copies[value.object_id] = snapshot
168
+ end
169
+ end
170
+
171
+ def copy_object(value, copies)
172
+ return value if value.is_a?(Module)
173
+
174
+ snapshot = value.dup
175
+ copies[value.object_id] = snapshot
176
+ value.instance_variables.each do |name|
177
+ snapshot.instance_variable_set(name, copy(value.instance_variable_get(name), copies))
178
+ end
179
+ snapshot.freeze
180
+ rescue TypeError
181
+ value.frozen? ? value : value.to_s.dup.freeze
182
+ end
183
+
184
+ def copy_exception(value, copies)
185
+ message = value.message.to_s.dup.freeze
186
+ snapshot = value.exception(message)
187
+ if snapshot.equal?(value)
188
+ snapshot = value.class.allocate
189
+ Exception.instance_method(:initialize).bind_call(snapshot, message)
190
+ end
191
+ copies[value.object_id] = snapshot
192
+ if value.cause
193
+ cause = copy(value.cause, copies)
194
+ begin
195
+ raise snapshot, cause:
196
+ rescue => raised
197
+ snapshot = raised
198
+ copies[value.object_id] = snapshot
199
+ end
200
+ end
201
+ value.instance_variables.each do |name|
202
+ snapshot.instance_variable_set(name, copy(value.instance_variable_get(name), copies))
203
+ end
204
+ backtrace = copy(value.backtrace, copies)
205
+ snapshot.set_backtrace(backtrace)
206
+ freeze_value(snapshot.backtrace) if snapshot.backtrace
207
+ snapshot.freeze
208
+ end
209
+
210
+ def immutable_scalar?(value)
211
+ value.nil? || value == true || value == false || value.is_a?(Numeric) || value.is_a?(Symbol)
212
+ end
213
+
214
+ def freeze_value(value, seen = {})
215
+ return value if immutable_scalar?(value) || value.is_a?(Module)
216
+ return value if seen[value.object_id]
217
+
218
+ seen[value.object_id] = true
219
+ case value
220
+ when Message
221
+ freeze_value(value.content, seen)
222
+ freeze_value(value.metadata, seen)
223
+ when Hash
224
+ value.each do |key, child|
225
+ freeze_value(key, seen)
226
+ freeze_value(child, seen)
227
+ end
228
+ when Array
229
+ value.each { |child| freeze_value(child, seen) }
230
+ when Data
231
+ value.members.each { |member| freeze_value(value.public_send(member), seen) }
232
+ else
233
+ value.instance_variables.each do |name|
234
+ freeze_value(value.instance_variable_get(name), seen)
235
+ end
236
+ end
237
+ value.freeze
238
+ end
239
+ end
240
+
241
+ class AgentStreamSink # :nodoc: all
242
+ def initialize(destination:, run:, source:, input:)
243
+ @destination = destination
244
+ @run = run
245
+ @source = source
246
+ @input = input
247
+ end
248
+
249
+ def <<(event)
250
+ data = {source: @source, event: AgentStreamSnapshot.event(event)}
251
+ data[:input] = AgentStreamSnapshot.message(normalized_input) if event.type == :invocation_start
252
+ @run.publish(:agent_stream, **data)
253
+ @destination << event
254
+ end
255
+
256
+ private
257
+
258
+ def normalized_input
259
+ @input.is_a?(Message) ? @input : Message.new(role: :user, content: @input)
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # Represents a file, image, or document produced by a Tool or supplied to a
5
+ # Run. LittleGhost sends supported media to the model once and may store the
6
+ # same bytes for filesystem Tools. Inline artifacts contain their bytes.
7
+ # Deferred artifacts contain an application-defined reference that the block
8
+ # passed to Configuration#artifacts may use to load the bytes.
9
+ #
10
+ # image = LittleGhost::Artifact.new(
11
+ # data: File.binread("chart.png"),
12
+ # media_type: "image/png",
13
+ # name: "chart.png"
14
+ # )
15
+ #
16
+ # download = LittleGhost::Artifact.deferred(
17
+ # reference: {file_id: "file-481"},
18
+ # media_type: "application/pdf",
19
+ # name: "report.pdf"
20
+ # )
21
+ class Artifact
22
+ # Creates an inline artifact from binary +data+ and a MIME +media_type+.
23
+ def initialize(data:, media_type:, name: nil, metadata: {})
24
+ data = String(data).b
25
+ initialize_fields(
26
+ data: data.freeze,
27
+ reference: nil,
28
+ media_type:,
29
+ name:,
30
+ bytes: data.bytesize,
31
+ metadata:
32
+ )
33
+ rescue TypeError
34
+ raise ArgumentError, "artifact data must be a string"
35
+ end
36
+
37
+ # Creates an artifact whose bytes may be loaded later by the block passed to
38
+ # Configuration#artifacts.
39
+ def self.deferred(reference:, media_type:, name: nil, metadata: {})
40
+ raise ArgumentError, "artifact reference is required" if reference.nil?
41
+ reference = immutable_reference(reference)
42
+
43
+ allocate.tap do |artifact|
44
+ artifact.__send__(
45
+ :initialize_fields,
46
+ data: nil,
47
+ reference:,
48
+ media_type:,
49
+ name:,
50
+ bytes: nil,
51
+ metadata:
52
+ )
53
+ end
54
+ end
55
+
56
+ # Binary content for an inline artifact, otherwise nil.
57
+ attr_reader :data
58
+ # Application-defined deferred reference, or generated Workspace reference
59
+ # after storage; otherwise nil.
60
+ attr_reader :reference
61
+ # Optional display filename.
62
+ attr_reader :name
63
+ # MIME media type used to present the artifact.
64
+ attr_reader :media_type
65
+ # Known byte count, otherwise nil for an unresolved artifact.
66
+ attr_reader :bytes
67
+ # Deeply frozen application metadata.
68
+ attr_reader :metadata
69
+
70
+ # Whether this artifact contains its bytes directly.
71
+ def inline? = !data.nil?
72
+
73
+ # Whether this artifact requires an application resolver.
74
+ def deferred? = !reference.nil? && bytes.nil?
75
+
76
+ # Compares all immutable artifact fields.
77
+ def ==(other)
78
+ other.instance_of?(self.class) &&
79
+ [data, reference, name, media_type, bytes, metadata] ==
80
+ [other.data, other.reference, other.name, other.media_type, other.bytes, other.metadata]
81
+ end
82
+ # Uses the same field comparison when an artifact is a Hash key.
83
+ alias_method :eql?, :==
84
+
85
+ # Computes a Hash key from all immutable artifact fields.
86
+ def hash = [self.class, data, reference, name, media_type, bytes, metadata].hash
87
+
88
+ # Avoids placing bytes, names, metadata, or deferred references in diagnostics.
89
+ def inspect
90
+ kind = if inline?
91
+ "inline"
92
+ elsif deferred?
93
+ "deferred"
94
+ else
95
+ "stored"
96
+ end
97
+ "#<#{self.class} kind=#{kind.inspect} media_type=#{media_type.inspect} bytes=#{bytes.inspect}>"
98
+ end
99
+
100
+ class << self
101
+ def materialized(reference:, bytes:, media_type:, name: nil, metadata: {}) # :nodoc:
102
+ reference = String(reference)
103
+ bytes = Integer(bytes)
104
+ raise ArgumentError, "artifact reference is required" if reference.empty?
105
+ raise ArgumentError, "artifact bytes must not be negative" if bytes.negative?
106
+
107
+ allocate.tap do |artifact|
108
+ artifact.__send__(
109
+ :initialize_fields,
110
+ data: nil,
111
+ reference: reference.freeze,
112
+ media_type:,
113
+ name:,
114
+ bytes:,
115
+ metadata:
116
+ )
117
+ end
118
+ rescue TypeError
119
+ raise ArgumentError, "materialized artifact fields are invalid"
120
+ end
121
+
122
+ private
123
+
124
+ def immutable_reference(value)
125
+ case value
126
+ when String
127
+ raise ArgumentError, "artifact reference is required" if value.empty?
128
+
129
+ value.dup.freeze
130
+ when Hash
131
+ value.each_with_object({}) do |(key, child), copy|
132
+ copy[immutable_reference(key)] = immutable_reference(child)
133
+ end.freeze
134
+ when Array
135
+ value.map { |child| immutable_reference(child) }.freeze
136
+ else
137
+ unless value.frozen?
138
+ raise ArgumentError, "artifact reference must be immutable"
139
+ end
140
+
141
+ value
142
+ end
143
+ end
144
+ end
145
+
146
+ private
147
+
148
+ def initialize_fields(data:, reference:, media_type:, name:, bytes:, metadata:)
149
+ media_type = String(media_type)
150
+ raise ArgumentError, "artifact media_type is required" if media_type.empty?
151
+
152
+ name = String(name) if name
153
+ @data = data
154
+ @reference = reference
155
+ @name = name&.freeze
156
+ @media_type = media_type.freeze
157
+ @bytes = bytes
158
+ @metadata = deep_freeze(DataMap.new(metadata))
159
+ freeze
160
+ rescue TypeError
161
+ raise ArgumentError, "artifact fields are invalid"
162
+ end
163
+
164
+ def deep_freeze(value)
165
+ pending = [value]
166
+ until pending.empty?
167
+ current = pending.pop
168
+ case current
169
+ when Hash
170
+ current.each do |key, child|
171
+ key.freeze
172
+ pending << child
173
+ end
174
+ when Array
175
+ current.each { |child| pending << child }
176
+ end
177
+ current.freeze
178
+ end
179
+ value
180
+ end
181
+ end
182
+ end