little_ghost 0.3.0 → 0.4.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +68 -84
  3. data/docs/guides/assemblies.md +286 -0
  4. data/docs/guides/core_concepts.md +126 -231
  5. data/docs/guides/getting_started.md +114 -87
  6. data/docs/guides/production.md +187 -0
  7. data/docs/guides/prompt_views.md +132 -0
  8. data/lib/little_ghost/ag_ui/adapter.rb +3 -3
  9. data/lib/little_ghost/agent/delegation.rb +1 -1
  10. data/lib/little_ghost/agent.rb +167 -172
  11. data/lib/little_ghost/{agent_interruptions.rb → agent_interjections.rb} +12 -12
  12. data/lib/little_ghost/assembly.rb +55 -21
  13. data/lib/little_ghost/assembly_builder.rb +40 -2
  14. data/lib/little_ghost/assembly_execution.rb +87 -4
  15. data/lib/little_ghost/configuration.rb +263 -39
  16. data/lib/little_ghost/content.rb +5 -5
  17. data/lib/little_ghost/data_map.rb +209 -0
  18. data/lib/little_ghost/errors.rb +2 -2
  19. data/lib/little_ghost/execution.rb +32 -32
  20. data/lib/little_ghost/graph.rb +22 -3
  21. data/lib/little_ghost/message.rb +4 -4
  22. data/lib/little_ghost/model_resolver.rb +2 -2
  23. data/lib/little_ghost/prompt_resolver.rb +2 -0
  24. data/lib/little_ghost/run.rb +87 -49
  25. data/lib/little_ghost/run_context.rb +33 -20
  26. data/lib/little_ghost/runtime/hook.rb +3 -3
  27. data/lib/little_ghost/runtime.rb +71 -31
  28. data/lib/little_ghost/session.rb +12 -23
  29. data/lib/little_ghost/session_store.rb +9 -5
  30. data/lib/little_ghost/session_stores/agent_core_memory.rb +64 -56
  31. data/lib/little_ghost/session_stores/filesystem.rb +261 -0
  32. data/lib/little_ghost/session_stores/memory.rb +7 -0
  33. data/lib/little_ghost/subagents/manager.rb +42 -42
  34. data/lib/little_ghost/swarm.rb +13 -5
  35. data/lib/little_ghost/tool.rb +56 -14
  36. data/lib/little_ghost/tools/write_todos.rb +6 -1
  37. data/lib/little_ghost/tracing/open_telemetry.rb +1 -1
  38. data/lib/little_ghost/version.rb +1 -1
  39. data/lib/little_ghost/workflow.rb +30 -21
  40. data/lib/little_ghost.rb +29 -25
  41. metadata +7 -2
@@ -21,11 +21,11 @@ module LittleGhost
21
21
  # end
22
22
  #
23
23
  # LittleGhost then gives +CustomerSupportAgent+ tools to spawn, message, wait for,
24
- # interrupt, and list research agents. The manager keeps each child identity
24
+ # interject, and list research agents. The manager keeps each child identity
25
25
  # stable across follow-up turns.
26
26
  #
27
- # Follow-up messages are FIFO turns and never interrupt active work.
28
- # #interrupt is the separate synchronous path for delivery at the next model
27
+ # Follow-up messages are FIFO turns and never interject active work.
28
+ # #interject is the separate synchronous path for delivery at the next model
29
29
  # boundary; delivery does not stop the child, and tool calls from that model
30
30
  # response continue in the child run.
31
31
  #
@@ -58,16 +58,16 @@ module LittleGhost
58
58
  CURSOR_MAX_BYTES = 512 # :nodoc:
59
59
  UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/ # :nodoc:
60
60
 
61
- InterruptExchange = Struct.new(:message, :response, :complete) # :nodoc:
61
+ InterjectionExchange = Struct.new(:message, :response, :complete) # :nodoc:
62
62
  Turn = Struct.new( # :nodoc:
63
63
  :number,
64
64
  :message,
65
65
  :completion,
66
66
  :operation_id,
67
67
  :parent_operation_id,
68
- :interrupts,
69
- :interruption_metadata,
70
- :interruption_ids
68
+ :interjections,
69
+ :interjection_metadata,
70
+ :interjection_ids
71
71
  )
72
72
  Identity = Struct.new( # :nodoc:
73
73
  :subagent_id,
@@ -368,7 +368,7 @@ module LittleGhost
368
368
  # model response. The returned +response_disposition+ says whether that
369
369
  # response also initiated tool calls; it does not imply the subagent has
370
370
  # stopped.
371
- def interrupt(subagent_id:, message:, cancellation_token: @cancellation_token, deadline: @deadline)
371
+ def interject(subagent_id:, message:, cancellation_token: @cancellation_token, deadline: @deadline)
372
372
  unless message.is_a?(String)
373
373
  raise ToolError, "Subagent messages must be strings."
374
374
  end
@@ -376,30 +376,30 @@ module LittleGhost
376
376
  raise ToolError, "Subagent messages cannot exceed #{@max_message_chars} characters."
377
377
  end
378
378
 
379
- exchange = InterruptExchange.new(message:, complete: false)
379
+ exchange = InterjectionExchange.new(message:, complete: false)
380
380
  identity, turn = @mutex.synchronize do
381
381
  ensure_open!
382
382
  value = fetch_identity!(subagent_id)
383
- unless value.agent.respond_to?(:interrupt_response)
384
- raise ToolError, "Subagent #{subagent_id.inspect} does not support interruptions."
383
+ unless value.agent.respond_to?(:interject)
384
+ raise ToolError, "Subagent #{subagent_id.inspect} does not support interjections."
385
385
  end
386
386
  unless value.status == "running"
387
387
  raise ToolError, "Subagent #{subagent_id.inspect} is not currently running."
388
388
  end
389
- if value.current.interrupts.length >= @max_queued_turns_per_identity
390
- raise ToolError, "Subagent #{subagent_id.inspect} has reached its interrupt limit."
389
+ if value.current.interjections.length >= @max_queued_turns_per_identity
390
+ raise ToolError, "Subagent #{subagent_id.inspect} has reached its interject limit."
391
391
  end
392
- interrupt_chars = value.current.interrupts.sum { |pending| pending.message.length }
393
- if interrupt_chars + message.length > @max_message_chars
394
- raise ToolError, "Subagent interrupt messages cannot exceed #{@max_message_chars} total characters."
392
+ interjection_chars = value.current.interjections.sum { |pending| pending.message.length }
393
+ if interjection_chars + message.length > @max_message_chars
394
+ raise ToolError, "Subagent interjection messages cannot exceed #{@max_message_chars} total characters."
395
395
  end
396
396
 
397
- value.current.interrupts << exchange
397
+ value.current.interjections << exchange
398
398
  [value, value.current]
399
399
  end
400
400
 
401
- interrupt_response = begin
402
- identity.agent.interrupt_response(
401
+ result = begin
402
+ identity.agent.interject(
403
403
  message,
404
404
  cancellation_token:,
405
405
  deadline:,
@@ -407,16 +407,16 @@ module LittleGhost
407
407
  )
408
408
  rescue
409
409
  @mutex.synchronize do
410
- turn.interrupts.delete(exchange)
410
+ turn.interjections.delete(exchange)
411
411
  @condition.broadcast
412
412
  end
413
413
  raise
414
414
  end
415
- response = interrupt_response.text
415
+ response = result.text
416
416
  truncated = response.length > @max_response_chars
417
417
  returned_response = truncated ? response[0, @max_response_chars] : response
418
418
  @mutex.synchronize do
419
- used_response_chars = turn.interrupts.sum do |pending|
419
+ used_response_chars = turn.interjections.sum do |pending|
420
420
  pending.equal?(exchange) ? 0 : pending.response.to_s.length
421
421
  end
422
422
  remaining_response_chars = [@max_response_chars - used_response_chars, 0].max
@@ -428,17 +428,17 @@ module LittleGhost
428
428
  snapshot(identity, include_response: true, include_progress: true)
429
429
  end
430
430
  value = {
431
- status: "interruption_delivered",
431
+ status: "interjection_delivered",
432
432
  subagent_id: identity.subagent_id,
433
433
  kind: identity.definition.kind,
434
434
  subagent:,
435
435
  turn: turn.number,
436
436
  response: returned_response,
437
- response_disposition: interrupt_response.tool_calls? ? "text_with_tool_calls" : "text_only"
437
+ response_disposition: result.tool_calls? ? "text_with_tool_calls" : "text_only"
438
438
  }
439
439
  value[:response_truncated] = true if truncated
440
440
  value
441
- rescue AgentInterruptError => error
441
+ rescue AgentInterjectionError => error
442
442
  raise ToolError, error.message
443
443
  end
444
444
 
@@ -504,7 +504,7 @@ module LittleGhost
504
504
  end
505
505
  end
506
506
 
507
- # Builds spawn, follow-up, interrupt, wait, and list tools bound to this
507
+ # Builds spawn, follow-up, interject, wait, and list tools bound to this
508
508
  # manager. Closing the first tool closes the shared manager.
509
509
  def tools
510
510
  manager = self
@@ -559,8 +559,8 @@ module LittleGhost
559
559
  description: <<~DESCRIPTION.strip,
560
560
  Send a follow-up turn to an existing active or persisted subagent identity. Persisted conversations
561
561
  are restored transparently before the follow-up. Messages are processed in order after the
562
- current turn and never interrupt active work. Do not use this for status, steering, stopping, or
563
- finalization; use interrupt_subagent for an active subagent. Mode controls delivery: sync waits for the
562
+ current turn and never interject active work. Do not use this for status, steering, stopping, or
563
+ finalization; use interject_subagent for an active subagent. Mode controls delivery: sync waits for the
564
564
  later turn's response, while async enqueues the turn and returns immediately.
565
565
  DESCRIPTION
566
566
  input_schema: {
@@ -586,7 +586,7 @@ module LittleGhost
586
586
  )
587
587
  end,
588
588
  Tool.define(
589
- name: "interrupt_subagent",
589
+ name: "interject_subagent",
590
590
  description: <<~DESCRIPTION.strip,
591
591
  Interrupt an actively running subagent in its current turn. The message is added at the next model
592
592
  boundary. This call waits for that model response and reports its ordinary text, whether the same
@@ -606,7 +606,7 @@ module LittleGhost
606
606
  options = {}
607
607
  options[:cancellation_token] = context.cancellation_token if context
608
608
  options[:deadline] = context.deadline if context&.deadline
609
- manager.interrupt(
609
+ manager.interject(
610
610
  subagent_id: input.fetch("subagent_id"),
611
611
  message: input.fetch("message"),
612
612
  **options
@@ -1089,9 +1089,9 @@ module LittleGhost
1089
1089
  completion: Completion.new,
1090
1090
  operation_id: SecureRandom.uuid,
1091
1091
  parent_operation_id:,
1092
- interrupts: [],
1093
- interruption_metadata: context&.interruption_metadata,
1094
- interruption_ids: context&.interruption_ids || []
1092
+ interjections: [],
1093
+ interjection_metadata: context&.interjection_metadata,
1094
+ interjection_ids: context&.interjection_ids || []
1095
1095
  )
1096
1096
  identity.next_turn += 1
1097
1097
  @turn_count += 1 if count_turn
@@ -1157,8 +1157,8 @@ module LittleGhost
1157
1157
  options[:history] = identity.history
1158
1158
  options[:context] = identity.state
1159
1159
  options[:conversation_id] = identity.conversation_id
1160
- options[:interruption_metadata] = turn.interruption_metadata
1161
- options[:interruption_ids] = turn.interruption_ids
1160
+ options[:interjection_metadata] = turn.interjection_metadata
1161
+ options[:interjection_ids] = turn.interjection_ids
1162
1162
  end
1163
1163
  result = if identity.agent.is_a?(Agent)
1164
1164
  run_agent_turn(identity, turn, options)
@@ -1284,8 +1284,8 @@ module LittleGhost
1284
1284
  response = serialized_response[0, @max_response_chars] if truncated
1285
1285
  persisted_response = response.is_a?(String) ? response : serialized_response
1286
1286
 
1287
- interrupt_exchanges = @mutex.synchronize do
1288
- while identity.durable && turn.interrupts.any? { |exchange| !exchange.complete } && !@closed
1287
+ interject_exchanges = @mutex.synchronize do
1288
+ while identity.durable && turn.interjections.any? { |exchange| !exchange.complete } && !@closed
1289
1289
  @condition.wait(@mutex, CANCELLATION_POLL_INTERVAL)
1290
1290
  end
1291
1291
  if @closed
@@ -1294,11 +1294,11 @@ module LittleGhost
1294
1294
  end
1295
1295
 
1296
1296
  identity.status = "persisting" if identity.durable
1297
- turn.interrupts.select(&:complete).map { |exchange| [exchange.message, exchange.response] }
1297
+ turn.interjections.select(&:complete).map { |exchange| [exchange.message, exchange.response] }
1298
1298
  end
1299
- return unless interrupt_exchanges
1299
+ return unless interject_exchanges
1300
1300
 
1301
- retain_agent_conversation(identity, turn, result, persisted_response, interrupt_exchanges)
1301
+ retain_agent_conversation(identity, turn, result, persisted_response, interject_exchanges)
1302
1302
 
1303
1303
  @mutex.synchronize do
1304
1304
  identity.latest_turn = turn.number
@@ -1323,11 +1323,11 @@ module LittleGhost
1323
1323
  end
1324
1324
  end
1325
1325
 
1326
- def retain_agent_conversation(identity, turn, result, persisted_response, interrupt_exchanges)
1326
+ def retain_agent_conversation(identity, turn, result, persisted_response, interject_exchanges)
1327
1327
  if identity.durable
1328
1328
  state = result.is_a?(RunResult) ? result.state : identity.state
1329
1329
  messages = [Message.new(role: :user, content: turn.message)]
1330
- interrupt_exchanges.each do |message, response|
1330
+ interject_exchanges.each do |message, response|
1331
1331
  messages << Message.new(role: :user, content: message)
1332
1332
  messages << Message.new(role: :assistant, content: response)
1333
1333
  end
@@ -7,8 +7,8 @@ module LittleGhost
7
7
  # Lets configured Agent members hand one request directly to one another.
8
8
  #
9
9
  # A swarm is an Assembly for model-selected routing. One member is active at a
10
- # time. It either produces the final answer or calls a reserved handoff tool
11
- # to select an allowed next member.
10
+ # time. It either produces the final answer or uses a model-visible handoff
11
+ # tool to choose one of the next members allowed by the application.
12
12
  #
13
13
  # class ProblemSolverSwarm < LittleGhost::Swarm
14
14
  # member TriageAgent
@@ -23,10 +23,15 @@ module LittleGhost
23
23
  # run = ProblemSolverSwarm.ask("Why was I charged twice?")
24
24
  # run.response
25
25
  #
26
+ # Call a named Swarm with
27
+ # ask[rdoc-ref:LittleGhost::Assembly.ask] for its final Run, or
28
+ # the streaming entrypoint[rdoc-ref:LittleGhost::Assembly.stream_ask] for
29
+ # coordination and final-response events.
30
+ #
26
31
  # Swarm members are Agent definitions rather than arbitrary assemblies so a
27
32
  # handoff remains a direct model-to-model transition. Original conversation
28
33
  # history and application context stay isolated unless a member opts in with
29
- # +history: true+ or +context: true+. Streams expose coordination lifecycle
34
+ # <tt>history: true</tt> or <tt>context: true</tt>. Streams expose coordination
30
35
  # events and the final member response, but not intermediate model text.
31
36
  class Swarm < Assembly
32
37
  MAX_BUFFERED_EVENTS = 10_000 # :nodoc:
@@ -91,7 +96,10 @@ module LittleGhost
91
96
  self.swarm_max_steps_value = value
92
97
  end
93
98
 
94
- # Reads or assigns the repeated directed-handoff limit.
99
+ # Reads or assigns how often the same directed handoff may repeat.
100
+ #
101
+ # For example, a value of +2+ allows the transition from triage to billing
102
+ # twice during one Swarm run. +max_steps+ still limits total member calls.
95
103
  def max_handoff_repeats(value = nil)
96
104
  return swarm_max_handoff_repeats_value if value.nil?
97
105
 
@@ -101,7 +109,7 @@ module LittleGhost
101
109
  self.swarm_max_handoff_repeats_value = value
102
110
  end
103
111
 
104
- # Validates the member set and topology and returns this Swarm class.
112
+ # Validates the members and allowed handoff routes, then returns this class.
105
113
  def validate!
106
114
  swarm_definition!
107
115
  self
@@ -19,20 +19,60 @@ module LittleGhost
19
19
  # end
20
20
  # end
21
21
  #
22
- # result = TicketStatusTool.new.execute("ticket_id" => "SUP-481")
23
- # result.success? # => true
24
- # JSON.parse(result.content) # => {"ticket_id"=>"SUP-481", "status"=>"waiting_on_customer"}
22
+ # class CustomerSupportAgent < LittleGhost::Agent
23
+ # tools TicketStatusTool
24
+ # end
25
+ #
26
+ # run = CustomerSupportAgent.ask("What is happening with ticket SUP-481?")
27
+ # run.response
28
+ #
29
+ # Use application context for authorization, never model-selected input:
30
+ #
31
+ # class OrderStatusTool < LittleGhost::Tool
32
+ # description "Look up an order for the current account."
33
+ # input_schema type: "object", properties: {
34
+ # order_number: {type: "string"}
35
+ # }, required: ["order_number"], additionalProperties: false
36
+ #
37
+ # def call(input)
38
+ # Orders.status_for(
39
+ # actor_id: run.invocation.actor_id,
40
+ # account_id: run.invocation.context.fetch("account_id"),
41
+ # order_number: input.fetch("order_number")
42
+ # )
43
+ # end
44
+ # end
45
+ #
46
+ # OrderSupportAgent.ask(
47
+ # "Where is order 481?",
48
+ # actor_id: authenticated_user.id,
49
+ # context: {account_id: authenticated_user.account_id}
50
+ # )
51
+ #
52
+ # These values cross different trust boundaries:
53
+ #
54
+ # [<tt>input</tt>]
55
+ # Arguments selected by the model. The schema checks their shape, not their
56
+ # permission to perform an operation.
57
+ # [<tt>run.invocation.context</tt>]
58
+ # Current request values supplied by the application. Use these for
59
+ # authorization after the application authenticates the caller.
60
+ # [<tt>context.state</tt>]
61
+ # Mutable working state for the run. It may include values restored from a
62
+ # Session, so check saved values again before trusting them.
63
+ # [Tool::Binding]
64
+ # Run-scoped objects such as the Agent, Run, Runtime, workspace, and sandbox.
65
+ # The Binding supplies #run; it does not contain model arguments.
25
66
  #
26
- # The class DSL produces the frozen specification sent to models. +execute+
27
- # validates incoming arguments, invokes +call+, and normalizes strings,
28
- # JSON-compatible collections, and other return values to model-facing text.
29
- # Tool.define offers the same contract for an embedded implementation.
67
+ # The class DSL produces the specification sent to models. During an Agent
68
+ # run, the tool registry creates and binds one Tool instance. Tests and custom
69
+ # integrations may call +execute+ directly; it validates the arguments, calls
70
+ # +call+, and returns an ExecutionResult. Tool.define offers the same contract
71
+ # for an embedded implementation.
30
72
  #
31
- # A tool registry creates one instance per agent run and supplies a Binding for
32
- # access to the agent, run, runtime, model, workspace, and sandbox. Mutable
33
- # per-instance state therefore belongs to that run. Registries close tools that
34
- # implement +close+; <tt>exclusive true</tt> serializes calls against every
35
- # other exclusive tool in the same run.
73
+ # Mutable Tool instance state belongs to one Agent run. Registries close tool
74
+ # instances that implement +close+; <tt>exclusive true</tt> prevents that tool
75
+ # from overlapping other exclusive tools in the same run.
36
76
  #
37
77
  # Validation and application ToolError failures become error results. A
38
78
  # ToolError message is visible to the model and must be safe to disclose;
@@ -45,8 +85,10 @@ module LittleGhost
45
85
  # A binding can be copied with selected collaborators replaced.
46
86
  #
47
87
  # Tool instances expose the bound agent, run, runtime, model, workspace, and
48
- # sandbox through matching accessors. ToolRegistry and Agent normally create
49
- # bindings on behalf of application code.
88
+ # sandbox through matching accessors. A Binding does not carry model Tool
89
+ # arguments or application state; the current RunContext carries that state.
90
+ # ToolRegistry and Agent normally create bindings on behalf of application
91
+ # code.
50
92
  class Binding
51
93
  # Agent, run, runtime, model, workspace, and sandbox available to a tool.
52
94
  attr_reader :agent, :run, :runtime, :model, :workspace, :sandbox
@@ -59,7 +59,12 @@ module LittleGhost
59
59
  ids = todos.map { |todo| todo.fetch("id") }
60
60
  raise ToolError, "todo IDs must be unique" unless ids.uniq.length == ids.length
61
61
 
62
- state = context ? (context.state["little_ghost.plan"] ||= empty_plan) : (@fallback_state ||= empty_plan)
62
+ if context
63
+ context.state["little_ghost.plan"] ||= empty_plan
64
+ state = context.state.fetch("little_ghost.plan")
65
+ else
66
+ state = @fallback_state ||= empty_plan
67
+ end
63
68
  state.replace("plan_title" => input.fetch("plan_title"), "todos" => todos.map(&:dup))
64
69
  state.dup
65
70
  end
@@ -358,7 +358,7 @@ module LittleGhost
358
358
  return unless %i[diagnostic_input diagnostic_output].include?(key.to_sym)
359
359
 
360
360
  prefix = (key.to_sym == :diagnostic_input) ? "input" : "output"
361
- if %i[agent interrupt tool].include?(kind) &&
361
+ if %i[agent interjection tool].include?(kind) &&
362
362
  !(kind == :tool && prefix == "output" && attributes[:error_type])
363
363
  result["#{prefix}.value"] = value
364
364
  result["#{prefix}.mime_type"] = diagnostic_mime_type(value)
@@ -2,5 +2,5 @@
2
2
 
3
3
  module LittleGhost
4
4
  # Current LittleGhost gem version.
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.0"
6
6
  end
@@ -10,16 +10,13 @@ module LittleGhost
10
10
  # Agent or another coordinated Assembly. The workflow consumes intermediate
11
11
  # answers and streams one final participant response.
12
12
  #
13
- # A support workflow can route a difficult request through research before the
14
- # responder writes the caller-visible answer:
13
+ # A support workflow can guarantee that research happens before the responder
14
+ # writes the caller-visible answer:
15
15
  #
16
16
  # class ResponseWorkflow < LittleGhost::Workflow
17
17
  # private
18
18
  #
19
19
  # def perform
20
- # route = invoke(RouterAgent).output
21
- # return invoke(CustomerSupportAgent) unless route["research"]
22
- #
23
20
  # evidence = invoke(ResearchAgent).output
24
21
  # invoke CustomerSupportAgent, input: <<~PROMPT
25
22
  # #{input.text}
@@ -31,27 +28,31 @@ module LittleGhost
31
28
  # end
32
29
  #
33
30
  # run = ResponseWorkflow.ask("Why is transfer 481 pending?")
34
- # run.response # => "Transfer 481 is waiting for the receiving bank."
31
+ # run.response
32
+ # # One possible response: Transfer 481 is waiting for the receiving bank.
33
+ #
34
+ # Call a named Workflow with
35
+ # ask[rdoc-ref:LittleGhost::Assembly.ask] for its final Run, or
36
+ # the streaming entrypoint[rdoc-ref:LittleGhost::Assembly.stream_ask] for live
37
+ # events.
35
38
  #
36
39
  # +invoke+ returns a lazy Workflow::Invocation. Reading +output+ consumes an
37
40
  # intermediate invocation and returns RunResult#output; +perform+ must return
38
41
  # its final invocation without consuming it so those events reach the caller.
39
42
  # Intermediate usage is added to the final result.
40
43
  #
41
- # Every child inherits input, history, settings, cancellation, deadline,
42
- # template paths, and trace parentage unless +invoke+ overrides its input.
43
- # JSON-like context is copied for each child, preventing one intermediate agent
44
- # from mutating a sibling's state. Non-JSON-like workflow context raises
45
- # ArgumentError.
44
+ # A child receives the Workflow input unless +invoke+ supplies another one.
45
+ # It also inherits history, settings, cancellation, deadline, template paths,
46
+ # and the parent tracing relationship. JSON-like context is copied for each
47
+ # child, preventing one intermediate Agent from mutating a sibling's state.
48
+ # Non-JSON-like workflow context raises ArgumentError.
46
49
  #
47
- # A workflow instance streams once. Returning the wrong value, returning an
48
- # already consumed invocation, or consuming an invocation twice raises
49
- # ProtocolError. Each child assembly closes after its execution attempt;
50
- # cleanup failures surface from that attempt. Closing the Workflow closes its
51
- # lightweight invocation wrappers in reverse declaration order. Composition
52
- # errors emit an +invocation_error+ event and then re-raise.
50
+ # A Workflow instance streams once. Returning the wrong value, returning an
51
+ # already consumed invocation, or consuming one twice raises ProtocolError.
52
+ # A composition error fails the owning top-level Run. Each child Assembly
53
+ # closes after its attempt, and a cleanup failure raises from that attempt.
53
54
  class Workflow < Assembly
54
- # Hold one lazy agent call inside a workflow composition.
55
+ # Hold one lazy Assembly call inside a workflow composition.
55
56
  # Workflow implementations normally use only its output method or return the
56
57
  # object as the final invocation.
57
58
  class Invocation
@@ -126,8 +127,10 @@ module LittleGhost
126
127
  end
127
128
  end
128
129
 
129
- # Owning run and the runtime used to resolve workflow agents.
130
- attr_reader :run, :runtime
130
+ # Run that owns this run-scoped Workflow.
131
+ attr_reader :run
132
+ # Runtime used to resolve child Assemblies.
133
+ attr_reader :runtime
131
134
 
132
135
  def initialize(run: nil, runtime: nil) # :nodoc:
133
136
  super(run:, runtime:, standalone: run.nil?)
@@ -269,7 +272,9 @@ module LittleGhost
269
272
  # Creates a lazy invocation for +assembly+.
270
273
  #
271
274
  # Intermediate calls may use +output+; the final call must be returned from
272
- # +perform+ without being consumed.
275
+ # +perform+ without being consumed. +as+ supplies the participant name used
276
+ # in steps and telemetry. Retries default to zero; a positive +retries+
277
+ # value requires explicit exception classes in +retry_on+.
273
278
  def invoke(
274
279
  assembly,
275
280
  as: nil,
@@ -299,8 +304,12 @@ module LittleGhost
299
304
  invocation
300
305
  end
301
306
 
307
+ # :doc:
302
308
  # Consumes independent invocations concurrently and returns their outputs in
303
309
  # declaration order.
310
+ #
311
+ # +max_concurrency+ bounds active child executions. A child failure cancels
312
+ # siblings cooperatively before the error is raised.
304
313
  def parallel(*invocations, max_concurrency: 8)
305
314
  raise ArgumentError, "parallel requires at least one invocation" if invocations.empty?
306
315
  unless invocations.all? { |invocation| invocation.is_a?(Invocation) && !invocation.consumed? }
data/lib/little_ghost.rb CHANGED
@@ -4,6 +4,7 @@ require_relative "little_ghost/version"
4
4
  require_relative "little_ghost/errors"
5
5
  require_relative "little_ghost/execution_state"
6
6
  require_relative "little_ghost/support"
7
+ require_relative "little_ghost/data_map"
7
8
  require_relative "little_ghost/events"
8
9
  require_relative "little_ghost/instrumentation"
9
10
  require_relative "little_ghost/tracing/open_telemetry"
@@ -50,6 +51,7 @@ require_relative "little_ghost/structured_output"
50
51
  require_relative "little_ghost/prompt_resolver"
51
52
  require_relative "little_ghost/session_store"
52
53
  require_relative "little_ghost/session_stores/memory"
54
+ require_relative "little_ghost/session_stores/filesystem"
53
55
  require_relative "little_ghost/session"
54
56
  require_relative "little_ghost/skills"
55
57
  require_relative "little_ghost/tools/write_todos"
@@ -58,7 +60,7 @@ require_relative "little_ghost/execution"
58
60
  require_relative "little_ghost/subagents/definition"
59
61
  require_relative "little_ghost/subagents/agent_path"
60
62
  require_relative "little_ghost/subagents/manager"
61
- require_relative "little_ghost/agent_interruptions"
63
+ require_relative "little_ghost/agent_interjections"
62
64
  require_relative "little_ghost/assembly"
63
65
  require_relative "little_ghost/assembly_execution"
64
66
  require_relative "little_ghost/agent"
@@ -70,39 +72,32 @@ require_relative "little_ghost/agent_factory"
70
72
  require_relative "little_ghost/runtime/hook"
71
73
  require_relative "little_ghost/runtime"
72
74
 
73
- # Build AI features with reusable agents and composable assemblies. LittleGhost can
74
- # sit inside an existing Ruby system or support a dedicated AI service, keeping
75
- # models, prompts, tools, sessions, streaming, and instrumentation behind a
76
- # cohesive set of Ruby conventions.
75
+ # LittleGhost is a Ruby library for building AI features with reusable agents
76
+ # and composable assemblies. It can sit inside an existing Ruby system or
77
+ # support a dedicated AI service.
77
78
  #
78
- # A customer support agent can answer directly, use an application tool, or ask a
79
- # specialist for research while the caller observes one run:
80
- #
81
- # class ResearchAgent < LittleGhost::Agent
82
- # description "Researches difficult support questions"
83
- # model "openai:gpt-5.6-luna"
84
- # end
79
+ # Start with one model-driven behavior:
85
80
  #
86
81
  # class CustomerSupportAgent < LittleGhost::Agent
87
82
  # description "Handles support requests"
88
- # model "openai:gpt-5.6-luna"
89
- # tools AccountTool
90
- # subagent ResearchAgent, kind: "research"
83
+ # model "openrouter:openai/gpt-5.6-luna"
84
+ # system_prompt "Answer customer questions clearly."
91
85
  # end
92
86
  #
93
87
  # run = CustomerSupportAgent.ask("Why is transfer 481 still pending?")
94
88
  # run.completed? # => true
95
- # run.response # => "Transfer 481 is waiting for the receiving bank."
89
+ # run.response
90
+ # # One possible response: Transfer 481 is waiting for the receiving bank.
96
91
  #
97
- # Agent subclasses hold reusable behavior; Invocation objects carry one request,
98
- # and Run objects own execution and cleanup. Assembly gives Agent, Workflow,
99
- # Swarm, and Graph the same caller interface while each type owns a different
100
- # coordination policy.
92
+ # Agent subclasses hold reusable behavior, Invocation objects carry one request,
93
+ # and Run objects own execution and cleanup. When one model loop is not enough,
94
+ # Assembly gives Agent, Workflow, Swarm, and Graph the same caller interface
95
+ # while each type owns a different coordination policy.
101
96
  #
102
97
  # LittleGhost.configuration is process-wide unless LittleGhost.with_configuration
103
- # supplies an execution-scoped replacement. Configuration is loaded lazily and
104
- # snapshotted into a Runtime, so make application changes before the first agent
105
- # runtime is built.
98
+ # supplies an execution-scoped replacement. The first standalone call lazily
99
+ # builds one shared Runtime from that configuration, so make application changes
100
+ # before invoking an Agent or Assembly.
106
101
  module LittleGhost
107
102
  class << self
108
103
  # The configuration active in the current execution context, falling back to
@@ -114,15 +109,24 @@ module LittleGhost
114
109
  # Opens the active Configuration for application setup and returns it.
115
110
  #
116
111
  # Configuration files are loaded lazily when a runtime is first built, so
117
- # make application-level changes before invoking an agent.
112
+ # make application-level changes before invoking an agent. Once the shared
113
+ # Runtime is ready, later mutations raise ConfigurationError.
118
114
  def configure(&block)
119
115
  configuration.configure(&block)
120
116
  end
121
117
 
118
+ # Returns the shared Runtime for the active Configuration.
119
+ #
120
+ # Most applications do not need to call this method. Standalone Agent and
121
+ # Assembly entrypoints use it automatically. Runtime construction is lazy,
122
+ # thread-safe, and locks the active Configuration after it succeeds.
123
+ def runtime = configuration.runtime
124
+
122
125
  # Returns the model resolver owned by the active process configuration.
123
126
  def model_resolver = configuration.model_resolver
124
127
 
125
- # Makes +configuration+ current only while the block runs.
128
+ # Makes +configuration+ and its independent shared Runtime current only
129
+ # while the block runs.
126
130
  #
127
131
  # Execution state restores the previous configuration even when the block
128
132
  # raises. Other execution contexts continue to see their own configuration.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: little_ghost
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Robinson
@@ -117,8 +117,11 @@ extra_rdoc_files: []
117
117
  files:
118
118
  - LICENSE.txt
119
119
  - README.md
120
+ - docs/guides/assemblies.md
120
121
  - docs/guides/core_concepts.md
121
122
  - docs/guides/getting_started.md
123
+ - docs/guides/production.md
124
+ - docs/guides/prompt_views.md
122
125
  - lib/little_ghost.rb
123
126
  - lib/little_ghost/ag_ui.rb
124
127
  - lib/little_ghost/ag_ui/adapter.rb
@@ -129,13 +132,14 @@ files:
129
132
  - lib/little_ghost/agent/tool_loop.rb
130
133
  - lib/little_ghost/agent_builder.rb
131
134
  - lib/little_ghost/agent_factory.rb
132
- - lib/little_ghost/agent_interruptions.rb
135
+ - lib/little_ghost/agent_interjections.rb
133
136
  - lib/little_ghost/assembly.rb
134
137
  - lib/little_ghost/assembly_builder.rb
135
138
  - lib/little_ghost/assembly_execution.rb
136
139
  - lib/little_ghost/configuration.rb
137
140
  - lib/little_ghost/content.rb
138
141
  - lib/little_ghost/data/model_catalog.json
142
+ - lib/little_ghost/data_map.rb
139
143
  - lib/little_ghost/errors.rb
140
144
  - lib/little_ghost/events.rb
141
145
  - lib/little_ghost/execution.rb
@@ -188,6 +192,7 @@ files:
188
192
  - lib/little_ghost/session.rb
189
193
  - lib/little_ghost/session_store.rb
190
194
  - lib/little_ghost/session_stores/agent_core_memory.rb
195
+ - lib/little_ghost/session_stores/filesystem.rb
191
196
  - lib/little_ghost/session_stores/memory.rb
192
197
  - lib/little_ghost/skills.rb
193
198
  - lib/little_ghost/skills/catalog.rb