little_ghost 0.9.0 → 0.11.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +20 -10
  3. data/docs/guides/assemblies.md +64 -12
  4. data/docs/guides/core_concepts.md +65 -61
  5. data/docs/guides/decisions.md +69 -0
  6. data/docs/guides/embeddings.md +23 -0
  7. data/docs/guides/getting_started.md +38 -68
  8. data/docs/guides/integrations.md +20 -6
  9. data/docs/guides/models_and_providers.md +10 -29
  10. data/docs/guides/production.md +17 -4
  11. data/docs/guides/providers.md +58 -11
  12. data/lib/little_ghost/ag_ui/adapter.rb +64 -1
  13. data/lib/little_ghost/agent.rb +1 -1
  14. data/lib/little_ghost/agent_stream_source.rb +41 -2
  15. data/lib/little_ghost/assembly.rb +22 -18
  16. data/lib/little_ghost/assembly_builder.rb +2 -2
  17. data/lib/little_ghost/assembly_execution.rb +3 -1
  18. data/lib/little_ghost/code_mode/broker.rb +5 -3
  19. data/lib/little_ghost/code_mode/javascript/client.rb +1 -1
  20. data/lib/little_ghost/code_mode/javascript/session.rb +22 -2
  21. data/lib/little_ghost/code_mode/ruby/session.rb +15 -1
  22. data/lib/little_ghost/code_mode/session.rb +3 -0
  23. data/lib/little_ghost/decision.rb +230 -0
  24. data/lib/little_ghost/execution.rb +3 -1
  25. data/lib/little_ghost/graph.rb +3 -1
  26. data/lib/little_ghost/model.rb +5 -0
  27. data/lib/little_ghost/model_operations.rb +14 -0
  28. data/lib/little_ghost/provider_registry.rb +1 -0
  29. data/lib/little_ghost/providers/base.rb +5 -0
  30. data/lib/little_ghost/providers/decision_http.rb +91 -0
  31. data/lib/little_ghost/providers/gemini.rb +34 -8
  32. data/lib/little_ghost/providers/open_router.rb +18 -1
  33. data/lib/little_ghost/providers/typesafe.rb +35 -0
  34. data/lib/little_ghost/run.rb +23 -8
  35. data/lib/little_ghost/run_context.rb +21 -0
  36. data/lib/little_ghost/runtime.rb +8 -3
  37. data/lib/little_ghost/stream_event.rb +5 -2
  38. data/lib/little_ghost/swarm.rb +14 -48
  39. data/lib/little_ghost/version.rb +1 -1
  40. data/lib/little_ghost/workflow.rb +74 -96
  41. data/lib/little_ghost.rb +5 -0
  42. metadata +6 -1
@@ -17,7 +17,8 @@ $ export OPENROUTER_API_KEY="..."
17
17
 
18
18
  Use your application's secret manager outside a local shell, and never commit provider credentials.
19
19
 
20
- This guide uses OpenRouter to keep setup to one key. Prefer another hosted
20
+ This guide uses OpenRouter, a service that sends requests to your chosen AI
21
+ model. Set `OPENROUTER_API_KEY` to a key from your OpenRouter account. Prefer another hosted
21
22
  provider or a local model server? Start with [Provider Support](providers.md).
22
23
 
23
24
  ## See your first answer
@@ -41,10 +42,11 @@ else
41
42
  end
42
43
  ```
43
44
 
44
- Run the file and you have a working AI feature:
45
+ `model` selects the service and model to call. `system_prompt` supplies the
46
+ instructions the model follows on each request. Run the file with your bundle:
45
47
 
46
48
  ```sh
47
- $ ruby customer_support_agent.rb
49
+ $ bundle exec ruby customer_support_agent.rb
48
50
  ```
49
51
 
50
52
  `CustomerSupportAgent.ask` creates a `LittleGhost::Run` for this request. When the work finishes, the Run holds the outcome and response.
@@ -55,7 +57,9 @@ The selected external provider may receive system instructions, caller input, co
55
57
 
56
58
  ## Connect the agent to your application
57
59
 
58
- The first agent can answer general questions. A **tool** gives it a focused operation backed by your Ruby code:
60
+ The first agent can answer general questions. A **tool** gives it a focused
61
+ operation backed by your Ruby code. Add this class after the `require` line,
62
+ before `CustomerSupportAgent`:
59
63
 
60
64
  ```ruby
61
65
  class HelpCenterLookupTool < LittleGhost::Tool
@@ -80,7 +84,9 @@ class HelpCenterLookupTool < LittleGhost::Tool
80
84
  end
81
85
  ```
82
86
 
83
- Make the tool available to the agent and tell the model when to use it:
87
+ `input_schema` describes the arguments the model may supply. Here, `topic` must
88
+ be one of the help center's keys. Replace the Agent definition and the call at
89
+ the end of the file with these:
84
90
 
85
91
  ```ruby
86
92
  class CustomerSupportAgent < LittleGhost::Agent
@@ -106,84 +112,48 @@ LittleGhost checks the model's arguments before it calls
106
112
  `HelpCenterLookupTool#call`. The Tool's result then becomes context for the
107
113
  model.
108
114
 
109
- ### Use application context for private data
110
-
111
- The schema checks shape, not permission. When a Tool reads private data or
112
- changes something, use identity and account information established by your
113
- application rather than asking the model to supply it.
114
-
115
- While an Agent is working, LittleGhost binds each Tool instance to the current
116
- Run. The Tool can read request values through its `run` accessor:
117
-
118
- ```ruby
119
- class OrderStatusTool < LittleGhost::Tool
120
- ORDER_STATUSES = {
121
- ["user-7", "account-2", "481"] => "out for delivery"
122
- }.freeze
123
-
124
- description "Look up an order that belongs to the current customer."
125
- input_schema(
126
- type: "object",
127
- properties: {order_number: {type: "string"}},
128
- required: ["order_number"],
129
- additionalProperties: false
130
- )
131
-
132
- def call(input)
133
- lookup = [
134
- run.invocation.actor_id,
135
- run.invocation.context.fetch("account_id"),
136
- input.fetch("order_number")
137
- ]
138
-
139
- ORDER_STATUSES.fetch(lookup) do
140
- raise LittleGhost::ToolError, "Order not found"
141
- end
142
- end
143
- end
144
-
145
- class CustomerSupportAgent < LittleGhost::Agent
146
- tools HelpCenterLookupTool, OrderStatusTool
147
- end
148
-
149
- run = CustomerSupportAgent.ask(
150
- "Where is order 481?",
151
- actor_id: "user-7",
152
- context: {account_id: "account-2"}
153
- )
154
- ```
155
-
156
- Here, `order_number` came from the model. The application supplied `actor_id`
157
- and `account_id` after authenticating the caller. LittleGhost places those
158
- request values on `run.invocation`; context keys become strings.
159
-
160
- > **Safety note:** Treat model-selected Tool arguments like any other external
161
- > input. Check permission using the current user and account before returning
162
- > private data or performing a write.
163
-
164
- That is enough to authorize the first Tool safely. [Core Concepts](core_concepts.md) names the request and working-state objects behind `run`, and [Running in Production](production.md) explains what changes when you add saved conversations.
115
+ > **Safety note:** The schema checks arguments, not permission. This example
116
+ > reads a public help center. Before a Tool returns private data or changes
117
+ > anything, check permission using the user and account identified by your
118
+ > application—not values supplied by the model. [Tools](tools.md) shows how to
119
+ > pass that information to a Tool.
165
120
 
166
121
  ## Stream the same agent
167
122
 
168
- Use `.stream_ask` when a console, HTTP response, or user interface should receive progress as it happens:
123
+ Use `.stream_ask` when a console, HTTP response, or user interface should receive
124
+ the answer as it is written. Replace the `.ask` call with the following code.
125
+
126
+ Each `:agent_stream` event contains the Agent's progress and a `source` that
127
+ identifies which Agent produced it. The source check below selects the Agent
128
+ you called directly: `/root` with no enclosing assembly steps. `:text_delta`
129
+ contains the next piece of its answer.
169
130
 
170
131
  ```ruby
171
132
  stream = CustomerSupportAgent.stream_ask("Can I get a refund?")
172
133
 
173
134
  run = stream.each do |event|
174
135
  case event.type
175
- when :text_delta
176
- print event.data.fetch(:text)
136
+ when :agent_stream
137
+ source = event.data.fetch(:source)
138
+ next unless source.agent_path == "/root" && source.assembly_path.empty?
139
+
140
+ progress = event.data.fetch(:event)
141
+ print progress.data.fetch(:text) if progress.type == :text_delta
177
142
  when :run_error
178
143
  warn event.data.fetch(:message)
179
144
  end
180
145
  end
181
146
 
182
- puts "\n#{run.response}" if run.completed?
147
+ run.response # The completed answer, separate from live progress.
183
148
  warn run.error.class.name if run.failed?
184
149
  ```
185
150
 
186
- The stream yields `LittleGhost::StreamEvent` values. Text, tool activity, usage, and completion all look the same across providers. When enumeration finishes, `.each` returns the same `LittleGhost::Run` that now holds the final outcome and response.
151
+ When enumeration finishes, `.each` returns the Run with the final outcome and
152
+ complete response. You can display progress and still read the finished answer.
153
+
154
+ If you later add other Agents, their progress arrives in the same stream. Keep
155
+ the source check when your audience should see only this Agent's text. See
156
+ [Compose Agents](assemblies.md) to display several participants.
187
157
 
188
158
  ## Give the code a home
189
159
 
@@ -223,8 +193,8 @@ The source repository also contains a complete
223
193
  [single-file Agent](https://github.com/littleghostai/little_ghost/tree/main/examples/basic_agent)
224
194
  and a
225
195
  [coding harness](https://github.com/littleghostai/little_ghost/tree/main/examples/coding_harness)
226
- that demonstrates Agents, prompt views, Tools, a Graph, a Workspace, and a
227
- native Sandbox.
196
+ that shows a larger application with several agents and tools for working
197
+ with files.
228
198
 
229
199
  You now have the smallest useful LittleGhost application: one Agent, one Tool, and one familiar Ruby call.
230
200
 
@@ -27,10 +27,22 @@ events = LittleGhost::AGUI::Adapter.new.stream(
27
27
  events.each { |event| websocket.write(JSON.generate(event)) }
28
28
  ```
29
29
 
30
- The adapter translates the full Run, including model output, Tool activity,
31
- retries, subagent activity, and the final outcome. It does not keep state between
32
- calls. Your application owns the connection, backpressure, disconnect behavior,
33
- and any request state its callbacks need.
30
+ The adapter translates Run lifecycle and final outcomes, plus selected Agent
31
+ progress such as model output, Tool activity, and retries. By default, it selects
32
+ only the root Agent: `agent_path == "/root"` with an empty `assembly_path`.
33
+ Nested Agent progress is not forwarded. The adapter reads Agent progress from
34
+ `:agent_stream` and reports aggregate usage separately.
35
+
36
+ Pass `source_filter:` to `LittleGhost::AGUI::Adapter.new` to select other
37
+ participants. This application-owned callable receives an `AgentStreamSource`
38
+ and returns a truthy value only for sources the client may see. Match the
39
+ enclosing assembly path as well as the Agent identity when selecting a nested
40
+ participant. Computed assembly answers arrive in `RUN_FINISHED`'s result, not
41
+ as synthetic text deltas.
42
+
43
+ The adapter does not keep state between calls. Your application owns the
44
+ connection, backpressure, disconnect behavior, and any request state its
45
+ callbacks need.
34
46
 
35
47
  LittleGhost may emit event types beyond the core AG-UI set. Decide whether the
36
48
  client preserves or ignores types it does not recognize. See the [AG-UI event
@@ -38,8 +50,10 @@ documentation](https://docs.ag-ui.com/concepts/events) when implementing the
38
50
  client.
39
51
 
40
52
  > **Safety note:** A Run stream can include model output, Tool arguments and
41
- > results, errors, and participant activity. Check that the connected user may
42
- > see the complete Run, then filter fields before sending or storing events.
53
+ > results, errors, and participant activity. Source selection controls which
54
+ > Agent progress is forwarded; it does not redact that progress or filter the
55
+ > final Run result. Check that the connected user may see those values, and
56
+ > filter sensitive fields before sending or storing events.
43
57
 
44
58
  Calling `each` drives the source stream on the caller's fiber or thread. When a
45
59
  client disconnects, stop enumerating and decide whether the application should
@@ -74,9 +74,10 @@ application obtains short-lived credentials at runtime, configure a credential
74
74
  resolver that returns them for the selected connection.
75
75
 
76
76
  > **Safety note:** The selected provider may receive system instructions,
77
- > caller input, conversation history, Tool results, schemas, and attachments.
78
- > Choose a provider that is appropriate for that data, and keep credentials and
79
- > provider endpoints under application control.
77
+ > caller input, application state, question text, conversation
78
+ > history, Tool results, schemas, and attachments. Choose a provider that is
79
+ > appropriate for that data, and keep credentials and provider endpoints under
80
+ > application control.
80
81
 
81
82
  ## Choose a role for each request
82
83
 
@@ -155,29 +156,9 @@ Results and Content](structured_outputs_and_content.md) covers strict schemas,
155
156
  provider strategies, repair behavior, and the application checks that still
156
157
  belong outside the schema.
157
158
 
158
- ## Create embeddings
159
-
160
- Use `LittleGhost.embed` when your application needs numeric representations for
161
- search, clustering, or another similarity-based feature:
162
-
163
- ```ruby
164
- response = LittleGhost.embed(
165
- model: "primary:openai/text-embedding-3-small",
166
- inputs: ["Reset a password", "Track a transfer"]
167
- )
168
-
169
- response.vectors.length # => 2
170
- response.dimensions
171
- response.usage.input_tokens
172
- ```
173
-
174
- The response keeps vectors in the same order as the inputs. LittleGhost rejects
175
- an incomplete or malformed response instead of returning a partial batch.
176
- Embedding text is sent to the selected provider, so choose one appropriate for
177
- that data. [Provider Support](providers.md) identifies adapters with embedding
178
- support; `LittleGhost::Embeddings::Request` documents settings and request
179
- bounds.
180
-
181
- Continue with [Prompts as Views](prompt_views.md) when an Agent's instructions
182
- outgrow one string. See [Structured Results and Content](structured_outputs_and_content.md)
183
- when you need checked result shapes, images, or documents.
159
+ Continue with [Decisions](decisions.md) for typed, structured answers or
160
+ [Embeddings](embeddings.md) for text representations. [Prompts as
161
+ Views](prompt_views.md) moves growing Agent instructions into conventional ERB
162
+ files. See [Structured Results and
163
+ Content](structured_outputs_and_content.md) for checked result shapes, images,
164
+ or documents.
@@ -218,19 +218,32 @@ values as the answer arrives:
218
218
  stream = CustomerSupportAgent.stream_ask(question)
219
219
 
220
220
  run = stream.each do |event|
221
- publish(event) if event.type == :text_delta
221
+ next unless event.type == :agent_stream
222
+
223
+ source = event.data.fetch(:source)
224
+ next unless source.agent_path == "/root" && source.assembly_path.empty?
225
+
226
+ progress = event.data.fetch(:event)
227
+ publish(progress) if progress.type == :text_delta
222
228
  end
223
229
 
224
230
  record_outcome(run.outcome, error_type: run.error&.class&.name)
225
231
  ```
226
232
 
227
- Composite assembly streams include intermediate and nested Agent work as `:agent_stream` events. This default also applies to the event consumer passed to `start_execution`. Pass `include_agent_events: false` when only the ordinary public stream is needed.
233
+ Every Run includes live source-tagged `:agent_stream` events from all its Agents,
234
+ including nested assemblies and subagents. This example displays only root Agent
235
+ text. The same contract applies to `start_execution`. Progress is not repeated
236
+ as raw events or replayed when a Workflow selects a result. Read `run.result`
237
+ or the terminal Run event for the complete answer and aggregate usage. Pass
238
+ `include_agent_events: false` to omit all Agent progress without changing work,
239
+ lifecycle events, or final results.
228
240
 
229
241
  > **Safety note:** Contextual events can include inputs, reasoning, Tool
230
242
  > arguments and results, errors, and output from every participant. Check that
231
243
  > the destination may see the complete Run, or filter the events before sending
232
- > or storing them. LittleGhost's AG-UI adapter ignores these events unless the
233
- > application translates them explicitly.
244
+ > or storing them. LittleGhost's [AG-UI adapter](integrations.md) selects root
245
+ > Agent progress by default; its `source_filter:` option lets the application
246
+ > select permitted assembly participants.
234
247
 
235
248
  Use `start_execution` when the caller must stay free for other work, or when you want to deliver an interjection to an active response:
236
249
 
@@ -23,17 +23,24 @@ end
23
23
  protocol. An Agent can now use a target such as
24
24
  `primary:openai/gpt-5.6-luna`.
25
25
 
26
- | Provider or endpoint | Adapter | Generation | Embeddings |
27
- | --- | --- | --- | --- |
28
- | OpenRouter | `:openrouter` | Chat Completions | When the model supports it |
29
- | OpenAI | `:openai` | Responses or Chat Completions | Yes |
30
- | Compatible API | `:openai_compatible` | Responses or Chat Completions | When the endpoint implements it |
31
- | Ollama | `:openai_compatible` | Responses or Chat Completions | When the model supports it |
32
- | Anthropic | `:anthropic` | Messages | No |
33
- | Gemini | `:gemini` | Gemini API | No |
34
- | Vertex AI | `:vertex_ai` | Gemini on Vertex AI | No |
35
- | Bedrock | `:bedrock` | Converse | Titan Text Embeddings V2 |
36
- | LM Studio | `:lm_studio` | Responses or Chat Completions | Yes |
26
+ | Provider or endpoint | Adapter | Generation | Decisions | Embeddings |
27
+ | --- | --- | --- | --- | --- |
28
+ | OpenRouter | `:openrouter` | Chat Completions | Typed decision endpoints | When the model supports it |
29
+ | TypeSafe | `:typesafe` | No | System One decisions | No |
30
+ | OpenAI | `:openai` | Responses or Chat Completions | No | Yes |
31
+ | Compatible API | `:openai_compatible` | Responses or Chat Completions | No | When the endpoint implements it |
32
+ | Ollama | `:openai_compatible` | Responses or Chat Completions | No | When the model supports it |
33
+ | Anthropic | `:anthropic` | Messages | No | No |
34
+ | Gemini | `:gemini` | Gemini API | No | No |
35
+ | Vertex AI | `:vertex_ai` | Gemini on Vertex AI | No | No |
36
+ | Bedrock | `:bedrock` | Converse | No | Titan Text Embeddings V2 |
37
+ | LM Studio | `:lm_studio` | Responses or Chat Completions | No | Yes |
38
+
39
+ TypeSafe Jev is a decision model that returns structured answers to typed
40
+ questions. Its API and OpenRouter's Jev routes support the same question
41
+ types.
42
+ These operations return structured answers directly and do not create an Agent
43
+ Run. See [Decisions](decisions.md) for the question and answer model.
37
44
 
38
45
  Capabilities can vary by model, account, and server version. Handle provider
39
46
  failures even when a model is expected to support a feature.
@@ -60,6 +67,46 @@ Use OpenRouter's `publisher/model` form, such as
60
67
  The selected model must support embeddings before you call
61
68
  `LittleGhost.embed`.
62
69
 
70
+ For Jev decisions, OpenRouter supports both decision endpoints. The default
71
+ uses the Decisions API and namespaced model identifiers such as
72
+ `openrouter:~typesafe/jev-latest`:
73
+
74
+ ```ruby
75
+ LittleGhost.configure do |config|
76
+ config.providers = {
77
+ openrouter: {
78
+ adapter: :openrouter,
79
+ api_key: ENV.fetch("OPENROUTER_API_KEY"),
80
+ decision_api: :decisions
81
+ }
82
+ }
83
+ end
84
+ ```
85
+
86
+ Set `decision_api: :system_one` to use `/api/v1/systemone`; that route
87
+ accepts a bare TypeSafe model identifier such as `jev-latest`, used as
88
+ `openrouter:jev-latest`.
89
+ OpenRouter describes these routes in its [Jev guide](https://openrouter.ai/blog/insights/what-is-jev/).
90
+
91
+ ### TypeSafe
92
+
93
+ Connect directly to TypeSafe's System One endpoint with a TypeSafe API key:
94
+
95
+ ```ruby
96
+ LittleGhost.configure do |config|
97
+ config.providers = {
98
+ typesafe: {
99
+ adapter: :typesafe,
100
+ api_key: ENV.fetch("TYPESAFE_API_KEY")
101
+ }
102
+ }
103
+ end
104
+ ```
105
+
106
+ Use a Jev identifier such as `typesafe:jev-latest`. This adapter supports
107
+ typed decisions only. See the [TypeSafe API reference](https://docs.typesafe.ai/api)
108
+ for question and answer fields.
109
+
63
110
  ### OpenAI
64
111
 
65
112
  ```ruby
@@ -18,8 +18,27 @@ module LittleGhost
18
18
  # The adapter has no state between #stream calls, so one instance can
19
19
  # translate independent runs.
20
20
  #
21
+ # Progress comes from the Run's native +:agent_stream+ events. By default,
22
+ # only the top-level Agent's progress reaches the interface: its
23
+ # AgentStreamSource[rdoc-ref:LittleGhost::AgentStreamSource] has +agent_path+
24
+ # <tt>/root</tt> and an empty +assembly_path+. A composite run's final
25
+ # response arrives in +RUN_FINISHED+ independently of participant progress.
26
+ # Unwrapped progress and wrapped invocation usage or terminal events are
27
+ # ignored; run lifecycle and aggregate usage remain separate.
28
+ #
21
29
  # === Choose what the interface receives
22
30
  #
31
+ # Pass a +source_filter+ callable to select a participant whose progress is
32
+ # intended for the destination. The callable receives an AgentStreamSource
33
+ # and replaces the default root selection. Choose it in trusted application
34
+ # code, not from request or model values.
35
+ #
36
+ # adapter = LittleGhost::AGUI::Adapter.new(source_filter: lambda { |source|
37
+ # step = source.assembly_path.first
38
+ # source.agent_path == "/root" && source.assembly_path.length == 1 &&
39
+ # step.assembly_id == "response_workflow" && step.participant == "response"
40
+ # })
41
+ #
23
42
  # Provider plaintext reasoning becomes AG-UI reasoning events. Tool
24
43
  # arguments and results, invocation metadata, subagent events, trace context,
25
44
  # and selected error text also pass through without redaction. Authorize and
@@ -28,8 +47,32 @@ module LittleGhost
28
47
  # continuity artifacts are never exposed here.
29
48
  class Adapter
30
49
  TERMINAL_EVENTS = %i[run_partial run_cancel run_stop run_error].freeze # :nodoc:
50
+ PROGRESS_EVENTS = %i[
51
+ message_start reasoning_delta text_delta message_stop
52
+ tool_call_start tool_call_delta tool_call_stop tool_stop
53
+ model_retry agent_interjection_delivered
54
+ ].freeze # :nodoc:
55
+
56
+ # Selects which Agent's progress reaches the interface.
57
+ #
58
+ # +source_filter+ is a callable receiving an AgentStreamSource and returning
59
+ # a truthy value to include its progress. Omit it to include only a root
60
+ # Agent outside any composite assembly. A shared adapter may call the filter
61
+ # concurrently for independent streams, so keep its captured state safe for
62
+ # concurrent use. Filter exceptions propagate to the caller of #stream.
63
+ # Raises ArgumentError when +source_filter+ is not callable.
64
+ def initialize(source_filter: nil)
65
+ if !source_filter.nil? && !source_filter.respond_to?(:call)
66
+ raise ArgumentError, "source_filter must be callable"
67
+ end
68
+
69
+ @source_filter = source_filter
70
+ end
31
71
 
32
- # Lazily translates +events+ for one AG-UI run.
72
+ # Lazily translates a Run's +events+ into AG-UI hashes for one interface run.
73
+ #
74
+ # Returns an Enumerator. Selected Agent progress arrives as it is produced;
75
+ # +RUN_FINISHED+ carries the run's final response in <tt>result[:response]</tt>.
33
76
  def stream(events, thread_id:, run_id:)
34
77
  Enumerator.new do |output|
35
78
  message_id = nil
@@ -39,6 +82,9 @@ module LittleGhost
39
82
  tool_call_ids = {}
40
83
 
41
84
  events.each do |source|
85
+ source = selected_event(source)
86
+ next unless source
87
+
42
88
  superseded_message_id = message_id if source.type == :model_retry && message_started
43
89
  if reasoning_id && source.type != :reasoning_delta
44
90
  output << event("REASONING_MESSAGE_END", messageId: reasoning_message_id)
@@ -182,6 +228,23 @@ module LittleGhost
182
228
 
183
229
  private
184
230
 
231
+ def selected_event(event)
232
+ return unless event.is_a?(StreamEvent)
233
+ return PROGRESS_EVENTS.include?(event.type) ? nil : event unless event.type == :agent_stream
234
+
235
+ source = event.data[:source]
236
+ progress = event.data[:event]
237
+ return unless source.is_a?(AgentStreamSource) && progress.is_a?(StreamEvent)
238
+ return unless PROGRESS_EVENTS.include?(progress.type)
239
+
240
+ selected = if @source_filter
241
+ @source_filter.call(source)
242
+ else
243
+ source.agent_path == "/root" && source.assembly_path.empty?
244
+ end
245
+ progress if selected
246
+ end
247
+
185
248
  def event(type, **attributes)
186
249
  {type:, **attributes.compact}
187
250
  end
@@ -2327,7 +2327,7 @@ module LittleGhost
2327
2327
  agent_path:,
2328
2328
  operation_id:,
2329
2329
  parent_operation_id:,
2330
- assembly_path: agent_stream_path
2330
+ assembly_path: AgentStreamScope.path(run:, path: agent_stream_path)
2331
2331
  )
2332
2332
  AgentStreamSink.new(destination: events, run:, source:, input:)
2333
2333
  end
@@ -29,7 +29,8 @@ module LittleGhost
29
29
 
30
30
  ##
31
31
  # :attr_reader: assembly_kind
32
- # The +:workflow+, +:swarm+, +:graph+, or custom assembly kind.
32
+ # The +:workflow+, +:swarm+, +:graph+, +:tool+, or custom assembly kind.
33
+ # A +:tool+ step marks an assembly invoked through a Tool.
33
34
 
34
35
  ##
35
36
  # :attr_reader: participant
@@ -67,7 +68,11 @@ module LittleGhost
67
68
  # A +:agent_stream+ StreamEvent carries this value in +data[:source]+ and the
68
69
  # Agent's event as a separate frozen StreamEvent in +data[:event]+.
69
70
  # +assembly_path+ is empty for a top-level Agent and contains one
70
- # AgentStreamStep for each enclosing composite assembly participant.
71
+ # AgentStreamStep for each enclosing composite assembly participant or
72
+ # assembly-as-tool boundary. When an Agent invokes the Tool, the boundary's
73
+ # +assembly_id+ identifies that Agent and +participant+ names the Tool. Tool
74
+ # boundaries do not change the delegated Agent's authorization or
75
+ # managed-subagent path.
71
76
  class AgentStreamSource < Data # :doc:
72
77
  ##
73
78
  # :attr_reader: agent_id
@@ -238,7 +243,41 @@ module LittleGhost
238
243
  end
239
244
  end
240
245
 
246
+ module AgentStreamScope # :nodoc: all
247
+ module_function
248
+
249
+ def with_tool(target:, name:)
250
+ return yield unless target.run
251
+
252
+ execution = ExecutionState[:tool_execution]
253
+ source = execution.events.source if execution&.events.is_a?(AgentStreamSink)
254
+ prefix = target.agent_stream_path
255
+ parent_path = source&.assembly_path || path(run: target.run, path: prefix)
256
+ boundary = AgentStreamStep.build(
257
+ assembly_id: source&.agent_id || target.class.assembly_id,
258
+ assembly_kind: :tool,
259
+ participant: name,
260
+ step_id: execution&.operation_id || SecureRandom.uuid
261
+ )
262
+ # Prebuilt descendant factories inherit this scope without mutating a
263
+ # reusable Agent's bound path or its authorization identity.
264
+ scope = {run: target.run, prefix:, path: (parent_path + [boundary]).freeze}.freeze
265
+ ExecutionState.with(agent_stream_tool_scope: scope) { yield }
266
+ end
267
+
268
+ def path(run:, path:)
269
+ scope = ExecutionState[:agent_stream_tool_scope]
270
+ return path unless run && scope && scope.fetch(:run).equal?(run)
271
+
272
+ prefix = scope.fetch(:prefix)
273
+ suffix = (path.take(prefix.length) == prefix) ? path.drop(prefix.length) : path
274
+ scope.fetch(:path) + suffix
275
+ end
276
+ end
277
+
241
278
  class AgentStreamSink # :nodoc: all
279
+ attr_reader :source
280
+
242
281
  def initialize(destination:, run:, source:, input:)
243
282
  @destination = destination
244
283
  @run = run
@@ -58,11 +58,12 @@ module LittleGhost
58
58
  #
59
59
  # Enumeration yields StreamEvent objects and returns the terminal Run.
60
60
  # The same Invocation fields accepted by .ask may be supplied as +options+.
61
- # Composite assemblies also emit an +:agent_stream+ event for every
62
- # normalized event from every Agent in the run, including intermediate
63
- # and nested participants. Set <tt>include_agent_events: false</tt> to
64
- # keep only the ordinary public stream. A standalone Agent retains its
65
- # ordinary stream by default and accepts +true+ to opt in.
61
+ # Agent progress arrives once through +:agent_stream+, with source metadata
62
+ # identifying every Agent in the Run, including nested participants.
63
+ # Lifecycle and terminal results remain separate events. Filter sources
64
+ # before forwarding progress to a destination that may see only some
65
+ # participants. Set <tt>include_agent_events: false</tt> to omit all Agent
66
+ # progress without changing execution or final results. See Run.
66
67
  def stream_ask(message, **options)
67
68
  snapshot = definition
68
69
  stream = nil
@@ -177,7 +178,7 @@ module LittleGhost
177
178
  end
178
179
 
179
180
  # Builds the top-level Run used by a standalone assembly.
180
- def build_run(payload = nil, include_agent_events_by_default: false, **payload_options) # :nodoc:
181
+ def build_run(payload = nil, **payload_options) # :nodoc:
181
182
  payload = entrypoint_payload(payload, payload_options) unless payload_options.empty?
182
183
  payload = payload.dup if payload.is_a?(Hash)
183
184
  cancellation_token = if payload.is_a?(Hash)
@@ -191,14 +192,14 @@ module LittleGhost
191
192
  options[:cancellation_token] = cancellation_token if cancellation_token
192
193
  options[:workspace] = workspace if workspace
193
194
  options[:sandbox] = sandbox if sandbox
194
- options[:include_agent_events_by_default] = true if include_agent_events_by_default
195
195
  runtime.build_run(payload, **options)
196
196
  end
197
197
 
198
198
  # Starts +payload+ in the background and returns an Execution.
199
- # Composite assemblies include contextual +:agent_stream+ events in the
200
- # consumer by default. Set +include_agent_events+ to +false+ in +payload+
201
- # to keep only the ordinary public stream.
199
+ # The consumer receives source-tagged +:agent_stream+ progress from every
200
+ # Agent in the Run, including nested participants. Filter sources for the
201
+ # intended destination. Set +include_agent_events+ to +false+ in +payload+
202
+ # to omit Agent progress while keeping lifecycle and terminal results.
202
203
  def start_execution(payload, &event_consumer)
203
204
  ensure_standalone!
204
205
  Execution.start(build_stream_run(payload), &event_consumer)
@@ -230,10 +231,10 @@ module LittleGhost
230
231
  #
231
232
  # A standalone stream returns its terminal Run after enumeration. A
232
233
  # run-scoped stream finishes with an +invocation_stop+ event containing its
233
- # RunResult. A standalone composite Assembly receives contextual
234
- # +:agent_stream+ events from every Agent in the Run by default and may set
235
- # <tt>include_agent_events: false</tt> to omit them. A standalone Agent may
236
- # set the option to +true+ to include its contextual wrapper.
234
+ # RunResult. Standalone streams include source-tagged +:agent_stream+ events
235
+ # from every Agent in the Run by default. Applications filter sources before
236
+ # forwarding progress, or set <tt>include_agent_events: false</tt> to omit
237
+ # all Agent progress. This option does not change execution or final results.
237
238
  def stream_ask(message, **options)
238
239
  if standalone?
239
240
  options[:deadline_at] = options.delete(:deadline) if options.key?(:deadline)
@@ -259,6 +260,9 @@ module LittleGhost
259
260
  # A directly bound Agent remains the target for every call, so overlapping
260
261
  # calls raise AgentBusyError. Class-level Agent#agent_as_tool declarations
261
262
  # build a fresh Agent per context-free call.
263
+ # Within the same Run, progress from the target and its descendants carries a
264
+ # +:tool+ boundary in AgentStreamSource#assembly_path. It cannot be mistaken
265
+ # for root Agent progress, including when this instance is reused across calls.
262
266
  def as_tool(name: self.class.assembly_id, description: self.class.description, preserve_context: false)
263
267
  build_tool(name:, description:, preserve_context:, factory: nil)
264
268
  end
@@ -321,7 +325,9 @@ module LittleGhost
321
325
  options[:interjection_metadata] = context&.interjection_metadata
322
326
  options[:interjection_ids] = context&.interjection_ids || []
323
327
  end
324
- result = target.call(input.fetch("input"), **options)
328
+ result = AgentStreamScope.with_tool(target:, name:) do
329
+ target.call(input.fetch("input"), **options)
330
+ end
325
331
  if result.is_a?(Run)
326
332
  raise result.error if result.error
327
333
 
@@ -406,9 +412,7 @@ module LittleGhost
406
412
  private
407
413
 
408
414
  def build_stream_run(payload) # :nodoc:
409
- return build_run(payload) if is_a?(Agent)
410
-
411
- build_run(payload, include_agent_events_by_default: true)
415
+ build_run(payload)
412
416
  end
413
417
 
414
418
  def run_entrypoint_class # :nodoc: