little_ghost 0.1.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 (82) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +22 -0
  3. data/README.md +122 -0
  4. data/docs/guides/Core Concepts.md +203 -0
  5. data/docs/guides/Getting Started.md +187 -0
  6. data/lib/little_ghost/ag_ui/adapter.rb +194 -0
  7. data/lib/little_ghost/ag_ui.rb +5 -0
  8. data/lib/little_ghost/agent/context_management.rb +285 -0
  9. data/lib/little_ghost/agent/delegation.rb +128 -0
  10. data/lib/little_ghost/agent/skills.rb +96 -0
  11. data/lib/little_ghost/agent/tool_loop.rb +239 -0
  12. data/lib/little_ghost/agent.rb +2111 -0
  13. data/lib/little_ghost/agent_builder.rb +191 -0
  14. data/lib/little_ghost/agent_interruptions.rb +197 -0
  15. data/lib/little_ghost/configuration.rb +337 -0
  16. data/lib/little_ghost/content.rb +324 -0
  17. data/lib/little_ghost/default_model_registry.rb +71 -0
  18. data/lib/little_ghost/errors.rb +48 -0
  19. data/lib/little_ghost/events.rb +264 -0
  20. data/lib/little_ghost/execution_state.rb +58 -0
  21. data/lib/little_ghost/instrumentation.rb +475 -0
  22. data/lib/little_ghost/invocation.rb +285 -0
  23. data/lib/little_ghost/lookup.rb +37 -0
  24. data/lib/little_ghost/mcp/client.rb +396 -0
  25. data/lib/little_ghost/mcp.rb +5 -0
  26. data/lib/little_ghost/message.rb +75 -0
  27. data/lib/little_ghost/model.rb +88 -0
  28. data/lib/little_ghost/model_capabilities.rb +126 -0
  29. data/lib/little_ghost/model_registry.rb +173 -0
  30. data/lib/little_ghost/model_request.rb +107 -0
  31. data/lib/little_ghost/model_response.rb +48 -0
  32. data/lib/little_ghost/path_set.rb +32 -0
  33. data/lib/little_ghost/prompt_resolver.rb +251 -0
  34. data/lib/little_ghost/providers/bedrock.rb +506 -0
  35. data/lib/little_ghost/providers/http_transport.rb +149 -0
  36. data/lib/little_ghost/providers/open_router.rb +171 -0
  37. data/lib/little_ghost/providers/openai.rb +27 -0
  38. data/lib/little_ghost/providers/openai_compatible.rb +745 -0
  39. data/lib/little_ghost/providers/sse_parser.rb +35 -0
  40. data/lib/little_ghost/run.rb +607 -0
  41. data/lib/little_ghost/run_context.rb +129 -0
  42. data/lib/little_ghost/run_result.rb +111 -0
  43. data/lib/little_ghost/runtime/hook.rb +31 -0
  44. data/lib/little_ghost/runtime.rb +392 -0
  45. data/lib/little_ghost/sandbox.rb +138 -0
  46. data/lib/little_ghost/session.rb +229 -0
  47. data/lib/little_ghost/session_store.rb +96 -0
  48. data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
  49. data/lib/little_ghost/session_stores/memory.rb +86 -0
  50. data/lib/little_ghost/skills/catalog.rb +283 -0
  51. data/lib/little_ghost/skills/skill.rb +60 -0
  52. data/lib/little_ghost/skills.rb +4 -0
  53. data/lib/little_ghost/stream_event.rb +49 -0
  54. data/lib/little_ghost/structured_output.rb +126 -0
  55. data/lib/little_ghost/subagents/agent_path.rb +63 -0
  56. data/lib/little_ghost/subagents/definition.rb +42 -0
  57. data/lib/little_ghost/subagents/manager.rb +1615 -0
  58. data/lib/little_ghost/support/callbacks.rb +151 -0
  59. data/lib/little_ghost/support/cancellation_token.rb +86 -0
  60. data/lib/little_ghost/support/class_attributes.rb +40 -0
  61. data/lib/little_ghost/support/content_capture.rb +150 -0
  62. data/lib/little_ghost/support/executor.rb +75 -0
  63. data/lib/little_ghost/support/interruptible_stream.rb +103 -0
  64. data/lib/little_ghost/support/loader.rb +263 -0
  65. data/lib/little_ghost/support/output_truncation.rb +71 -0
  66. data/lib/little_ghost/support/redactor.rb +66 -0
  67. data/lib/little_ghost/support.rb +34 -0
  68. data/lib/little_ghost/tool.rb +448 -0
  69. data/lib/little_ghost/tool_execution.rb +59 -0
  70. data/lib/little_ghost/tool_registry.rb +156 -0
  71. data/lib/little_ghost/tools/filesystem.rb +119 -0
  72. data/lib/little_ghost/tools/shell.rb +45 -0
  73. data/lib/little_ghost/tools/write_todos.rb +91 -0
  74. data/lib/little_ghost/tools.rb +6 -0
  75. data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
  76. data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
  77. data/lib/little_ghost/usage.rb +47 -0
  78. data/lib/little_ghost/version.rb +6 -0
  79. data/lib/little_ghost/workflow.rb +351 -0
  80. data/lib/little_ghost/workspace.rb +31 -0
  81. data/lib/little_ghost.rb +120 -0
  82. metadata +225 -0
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # RunContext gives tools and workflows one place for shared state, cancellation,
5
+ # deadlines, checkpoints, and accumulated usage. It travels with work inside a
6
+ # run without becoming global process state.
7
+ #
8
+ # Tools and workflows use it to share JSON-like state, check
9
+ # cancellation and deadlines, checkpoint messages, and accumulate usage.
10
+ # Access to framework-managed fields is thread-safe.
11
+ class RunContext
12
+ # Shared state, cancellation, deadline, metadata, operation identity, and
13
+ # durable conversation identity for the current work.
14
+ attr_reader :state, :cancellation_token, :deadline, :metadata,
15
+ :agent_operation_id, :conversation_id
16
+
17
+ # Creates a context with optional checkpoint and interruption state.
18
+ def initialize(
19
+ state: {},
20
+ cancellation_token: Support::CancellationToken.new,
21
+ deadline: nil,
22
+ metadata: {},
23
+ checkpoint: nil,
24
+ conversation_id: nil,
25
+ interruption_metadata: nil,
26
+ interruption_ids: []
27
+ )
28
+ if conversation_id
29
+ conversation_id = String(conversation_id)
30
+ raise ArgumentError, "conversation_id cannot be empty" if conversation_id.empty?
31
+ conversation_id = conversation_id.dup.freeze
32
+ end
33
+ @state = state
34
+ @cancellation_token = cancellation_token
35
+ @deadline = deadline
36
+ @metadata = metadata.freeze
37
+ @checkpoint = checkpoint
38
+ @conversation_id = conversation_id
39
+ @usage = Usage.new
40
+ @usage_mutex = Mutex.new
41
+ @structured_result = nil
42
+ @structured_result_mutex = Mutex.new
43
+ @agent_operation_id = nil
44
+ @agent_operation_id_mutex = Mutex.new
45
+ @interruption_mutex = Mutex.new
46
+ @interruption_metadata = interruption_metadata&.to_h
47
+ @interruption_ids = Array(interruption_ids).map { |id| String(id).dup.freeze }.freeze
48
+ end
49
+
50
+ # Raises LittleGhost::CancelledError or LittleGhost::DeadlineExceededError
51
+ # when execution should stop.
52
+ def check!
53
+ cancellation_token.raise_if_cancelled!
54
+ raise DeadlineExceededError, "The run deadline was reached" if deadline && Time.now >= deadline
55
+ end
56
+
57
+ # Sends +messages+ and current state to the configured checkpoint callback.
58
+ # With no checkpoint callback, this method does nothing and returns +nil+.
59
+ def checkpoint(messages)
60
+ return unless @checkpoint
61
+
62
+ if agent_operation_id
63
+ @checkpoint.call(messages:, state:, parent_operation_id: agent_operation_id)
64
+ else
65
+ @checkpoint.call(messages:, state:)
66
+ end
67
+ end
68
+
69
+ # Adds +value+ to accumulated model usage.
70
+ def record_usage(value)
71
+ @usage_mutex.synchronize { @usage += value }
72
+ end
73
+
74
+ # Takes a snapshot of accumulated usage.
75
+ def usage
76
+ @usage_mutex.synchronize { @usage }
77
+ end
78
+
79
+ # Calculates seconds remaining before the deadline.
80
+ #
81
+ # When +maximum+ is provided, the result is capped at that value. With no
82
+ # deadline, returns +maximum+.
83
+ def remaining_time(maximum = nil)
84
+ check!
85
+ return maximum unless deadline
86
+
87
+ remaining = deadline - Time.now
88
+ maximum ? [remaining, maximum].min : remaining
89
+ end
90
+
91
+ # Stores a validated LittleGhost::StructuredResult and returns it.
92
+ def submit_structured_result(result)
93
+ @structured_result_mutex.synchronize { @structured_result = result }
94
+ result
95
+ end
96
+
97
+ # Finds the latest validated structured result, if any.
98
+ def structured_result
99
+ @structured_result_mutex.synchronize { @structured_result }
100
+ end
101
+
102
+ def interruption_metadata # :nodoc:
103
+ @interruption_mutex.synchronize { @interruption_metadata }
104
+ end
105
+
106
+ def interruption_ids # :nodoc:
107
+ @interruption_mutex.synchronize { @interruption_ids }
108
+ end
109
+
110
+ def activate_interruption(metadata:, ids:) # :nodoc:
111
+ value = metadata&.to_h
112
+ values = Array(ids).map { |id| String(id).dup.freeze }.freeze
113
+ @interruption_mutex.synchronize do
114
+ @interruption_metadata = value
115
+ @interruption_ids = values
116
+ end
117
+ end
118
+
119
+ def bind_agent_operation_id(operation_id) # :nodoc:
120
+ @agent_operation_id_mutex.synchronize do
121
+ if @agent_operation_id && @agent_operation_id != operation_id
122
+ raise Error, "run context is already bound to an agent operation"
123
+ end
124
+
125
+ @agent_operation_id ||= operation_id
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # Associates a validated structured value with its declared schema name.
5
+ StructuredResult = Data.define(:schema_name, :value) # :nodoc:
6
+
7
+ # RunResult gives callers one final view of an agent or workflow invocation.
8
+ # It includes the response, usage, updated conversation, state, and any
9
+ # validated structured value.
10
+ #
11
+ # Use #output when the caller should accept either structured or textual
12
+ # agents. It returns the validated structured value when present and #text
13
+ # otherwise.
14
+ RunResult = Data.define(:message, :stop_reason, :usage, :messages, :state, :structured_result) do # :nodoc:
15
+ def initialize(message:, stop_reason:, usage:, messages:, state:, structured_result: nil)
16
+ super
17
+ end
18
+
19
+ # Reads the final message text, or an empty string when no message exists.
20
+ def text
21
+ message&.text.to_s
22
+ end
23
+
24
+ # Indicates whether the run produced a validated structured result.
25
+ def structured?
26
+ !structured_result.nil?
27
+ end
28
+
29
+ # Uses the structured value when present and otherwise #text.
30
+ def output
31
+ structured? ? structured_result.value : text
32
+ end
33
+ end
34
+
35
+ # Associates a validated structured value with its declared schema name.
36
+ #
37
+ # result = LittleGhost::StructuredResult.new(
38
+ # schema_name: "support_research",
39
+ # value: {"summary" => "The transfer is still settling."}
40
+ # )
41
+ # result.value.fetch("summary") # => "The transfer is still settling."
42
+ class StructuredResult < Data # :doc:
43
+ ##
44
+ # :singleton-method: new
45
+ # :call-seq:
46
+ # new(schema_name:, value:) -> StructuredResult
47
+ #
48
+ # Associates +value+ with the schema that validated it.
49
+
50
+ ##
51
+ # :attr_reader: schema_name
52
+ # The name declared with <tt>Agent.result_schema</tt>.
53
+
54
+ ##
55
+ # :attr_reader: value
56
+ # The locally validated application value.
57
+ end
58
+
59
+ # RunResult gives callers one final view of an agent or workflow invocation.
60
+ # It includes the response, usage, updated conversation, state, and any
61
+ # validated structured value.
62
+ #
63
+ # Use #output when the caller should accept either structured or textual
64
+ # agents. It returns the validated structured value when present and #text
65
+ # otherwise.
66
+ class RunResult < Data # :doc:
67
+ ##
68
+ # :singleton-method: new
69
+ # :call-seq:
70
+ # new(message:, stop_reason:, usage:, messages:, state:,
71
+ # structured_result: nil) -> RunResult
72
+ #
73
+ # Creates the terminal value for one agent or workflow invocation.
74
+
75
+ ##
76
+ # :attr_reader: message
77
+ # The final assistant Message, or +nil+ when no message was produced.
78
+
79
+ ##
80
+ # :attr_reader: stop_reason
81
+ # The normalized reason the terminal model stream stopped.
82
+
83
+ ##
84
+ # :attr_reader: usage
85
+ # The Usage accumulated across this invocation.
86
+
87
+ ##
88
+ # :attr_reader: messages
89
+ # The complete, updated conversation.
90
+
91
+ ##
92
+ # :attr_reader: state
93
+ # The application state at the end of the invocation.
94
+
95
+ ##
96
+ # :attr_reader: structured_result
97
+ # The validated StructuredResult, or +nil+ for a textual result.
98
+
99
+ ##
100
+ # :method: text
101
+ # Reads the final message text, or an empty string when no message exists.
102
+
103
+ ##
104
+ # :method: structured?
105
+ # Indicates whether the invocation produced a validated structured result.
106
+
107
+ ##
108
+ # :method: output
109
+ # Uses the structured value when present and otherwise #text.
110
+ end
111
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ class Runtime
5
+ # Hooks let applications prepare runs, transform interruptions, and map
6
+ # errors to caller-safe messages.
7
+ #
8
+ # Hooks are instantiated once per Runtime in configuration order. Override
9
+ # only the methods needed and return the supplied value when leaving it
10
+ # unchanged.
11
+ #
12
+ # class TenantHook < LittleGhost::Runtime::Hook
13
+ # def prepare_run(run)
14
+ # run.register(TenantConnection.new(run.invocation.actor_id))
15
+ # run
16
+ # end
17
+ # end
18
+ class Hook
19
+ # Prepares a newly built Run. Resources registered on the run share its
20
+ # lifecycle and close in reverse order.
21
+ def prepare_run(run) = run
22
+
23
+ # Transforms an interruption payload before it reaches the agent.
24
+ def prepare_interruption(_run, payload) = payload
25
+
26
+ # Returns a caller-safe error message, or nil to defer to later hooks and
27
+ # the runtime default. Avoid exposing secrets or internal exception text.
28
+ def error_message(_error, _run) = nil
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,392 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "configuration"
5
+
6
+ module LittleGhost
7
+ # Prepare the shared services that agents and workflows use across many runs.
8
+ # A runtime owns model resolution, loading, persistence, lookup paths, hooks,
9
+ # and resource factories for one Ruby setup.
10
+ #
11
+ # configuration = LittleGhost::Configuration.new(
12
+ # root: Dir.pwd,
13
+ # models: CustomerSupportModels,
14
+ # default_model: "customer_support",
15
+ # service_name: "support-api"
16
+ # )
17
+ # runtime = LittleGhost::Runtime.new(configuration: configuration)
18
+ #
19
+ # runtime.service_name # => "support-api"
20
+ # runtime.root == Pathname.new(File.realpath(Dir.pwd)) # => true
21
+ #
22
+ # Without explicit +settings+, construction canonicalizes the root, loads
23
+ # +config/little_ghost.rb+ once through the Configuration, snapshots settings,
24
+ # configures instrumentation, eager-loads application constants, and builds the
25
+ # selected model registry and session store. Supplying +settings+ is the
26
+ # lower-level path used to create a sibling runtime from an existing snapshot.
27
+ #
28
+ # Reuse a runtime across runs. +build_run+ creates any missing workspace and
29
+ # sandbox, transfers ownership only after both are built, and closes partial
30
+ # resources if construction fails. +build+ creates a sibling with explicit
31
+ # overrides and reuses the loader only when the application root is unchanged.
32
+ #
33
+ # Startup emits structured lifecycle instrumentation; a failed phase emits a
34
+ # failure event, flushes instrumentation, and re-raises the original exception.
35
+ # Session actor resolution must use trusted authenticated identity for tenant
36
+ # isolation. The default UnrestrictedSandbox is convenient application plumbing,
37
+ # not a security boundary for untrusted work.
38
+ class Runtime
39
+ # The snapshotted setup and materialized services used by new runs.
40
+ attr_reader :configuration, :settings, :root, :loader, :prompt_paths, :skill_paths,
41
+ :skill_resource_root, :models, :session_store, :workspace_class, :sandbox_class,
42
+ :runtime_hooks
43
+
44
+ # Starts a runtime from +configuration+ or an existing settings snapshot.
45
+ def initialize(configuration:, settings: nil)
46
+ @startup_started_at = monotonic_time
47
+ @startup_phase = "configuration"
48
+ @startup_reported = false
49
+
50
+ begin
51
+ raise ArgumentError, "configuration must be a LittleGhost::Configuration" unless configuration.is_a?(Configuration)
52
+
53
+ @configuration = configuration
54
+ if settings
55
+ @settings = settings
56
+ else
57
+ bootstrap_root = canonical_application_root(configuration.root)
58
+ configuration.load_file!(root: bootstrap_root)
59
+ @settings = configuration.settings(root: bootstrap_root)
60
+ end
61
+ report_startup(status: "starting")
62
+ @startup_reported = true
63
+ @root = canonical_application_root(@settings.fetch(:root))
64
+ @skill_resource_root = @settings[:skill_resource_root]
65
+ @workspace_class = @settings.fetch(:workspace)
66
+ @sandbox_class = @settings.fetch(:sandbox)
67
+ @runtime_hooks = build_runtime_hooks(@settings[:runtime_hooks])
68
+
69
+ @startup_phase = "instrumentation"
70
+ subscribe_instrumentation(@settings[:instrumentation_subscribers])
71
+ emit_startup(:runtime_start)
72
+
73
+ @startup_phase = "loader"
74
+ @loader = @settings[:loader] || Support::Loader.new(root: @root)
75
+ loader.setup
76
+ loader.eager_load
77
+
78
+ @startup_phase = "models"
79
+ @invocation_class = @settings[:invocation] || Invocation
80
+ @models = build_service(@settings[:models], default: -> { DefaultModelRegistry.new })
81
+ @default_model = @settings.fetch(:default_model, "default").to_s
82
+
83
+ @startup_phase = "session_store"
84
+ @session_store = build_session_store(@settings[:session_store])
85
+ @session_actor = @settings[:session_actor]
86
+
87
+ @startup_phase = "prompts"
88
+ @prompt_paths = build_lookup_paths(:prompt_paths)
89
+ @skill_paths = build_lookup_paths(:skill_paths)
90
+
91
+ @startup_phase = "agent_builder"
92
+ @agent_builder = AgentBuilder.new(
93
+ runtime: self,
94
+ prompt_paths: @prompt_paths,
95
+ resolve_agent: method(:resolve_agent_class)
96
+ )
97
+
98
+ @startup_phase = "complete"
99
+ emit_startup(:runtime_stop, outcome: "ready")
100
+ report_startup(status: "ready")
101
+ rescue => error
102
+ unless @startup_reported
103
+ report_startup(status: "starting")
104
+ @startup_reported = true
105
+ end
106
+ emit_startup(:runtime_stop, outcome: "failed", error:)
107
+ Instrumentation.flush
108
+ report_startup(status: "failed", error:)
109
+ raise
110
+ end
111
+ end
112
+
113
+ # Creates a sibling runtime with explicit setting overrides.
114
+ def build(**overrides)
115
+ values = @settings.merge(overrides)
116
+ values[:root] = canonical_application_root(values.fetch(:root))
117
+ values[:loader] = loader unless overrides.key?(:loader) || overrides.key?(:root)
118
+ self.class.new(
119
+ configuration:,
120
+ settings: values
121
+ )
122
+ end
123
+
124
+ # Coerces an application payload into the configured Invocation class.
125
+ def parse(payload)
126
+ payload.is_a?(@invocation_class) ? payload : @invocation_class.new(payload)
127
+ end
128
+
129
+ # Creates a Run and transfers ownership of newly created workspace and
130
+ # sandbox resources to it.
131
+ def build_run(
132
+ payload,
133
+ agent_class:,
134
+ entrypoint_class: agent_class,
135
+ workspace: nil,
136
+ sandbox: nil
137
+ )
138
+ owned_resources = []
139
+ workspace ||= build_workspace.tap { |resource| owned_resources << resource }
140
+ sandbox ||= build_sandbox(workspace:).tap { |resource| owned_resources << resource }
141
+ run = Run.new(
142
+ invocation: parse(payload),
143
+ runtime: self,
144
+ agent_class:,
145
+ entrypoint_class:,
146
+ workspace:,
147
+ sandbox:
148
+ )
149
+ owned_resources.each { |resource| run.register(resource) }
150
+ prepare_run(run)
151
+ rescue
152
+ if run
153
+ run.close
154
+ else
155
+ close_resources(owned_resources)
156
+ end
157
+ raise
158
+ end
159
+
160
+ # Instantiates the configured workspace, or a root-scoped Workspace by default.
161
+ def build_workspace
162
+ return workspace_class.new if workspace_class
163
+
164
+ Workspace.new(root: root)
165
+ end
166
+
167
+ # Instantiates the configured sandbox around +workspace+, or an unrestricted
168
+ # sandbox by default.
169
+ def build_sandbox(workspace:)
170
+ return sandbox_class.new(workspace:) if sandbox_class
171
+
172
+ UnrestrictedSandbox.new(workspace:)
173
+ end
174
+
175
+ # :nodoc:
176
+ def build_agent(
177
+ agent_class_or_name,
178
+ run:,
179
+ model: nil,
180
+ tools: [],
181
+ agent_path: Subagents::AgentPath::ROOT
182
+ )
183
+ @agent_builder.build(agent_class_or_name, run:, model:, tools:, agent_path:)
184
+ end
185
+
186
+ # The low-cardinality service name attached to runtime telemetry.
187
+ def service_name
188
+ @settings&.[](:service_name) || default_service_name
189
+ end
190
+
191
+ def model_for(agent_class, run) # :nodoc:
192
+ role = agent_class.model_role(run.invocation) || @default_model
193
+ models.resolve(role, invocation: run.invocation, run:)
194
+ end
195
+
196
+ def open_session(run) # :nodoc:
197
+ Session.new(
198
+ id: run.invocation.session_id,
199
+ actor_id: session_actor_for(run.invocation),
200
+ store: session_store,
201
+ operation_id: run.operation_id
202
+ )
203
+ end
204
+
205
+ def prepare_run(run) # :nodoc:
206
+ runtime_hooks.each { |hook| hook.prepare_run(run) }
207
+ run
208
+ end
209
+
210
+ def prepare_interruption(run, payload) # :nodoc:
211
+ runtime_hooks.reduce(payload) do |prepared, hook|
212
+ hook.prepare_interruption(run, prepared)
213
+ end
214
+ end
215
+
216
+ def open_subagent_session(run, conversation_id) # :nodoc:
217
+ parent_link = Subagents::Manager.parent_link(run.session)
218
+ Session.new(
219
+ id: Subagents::Manager.conversation_session_id(conversation_id),
220
+ actor_id: session_actor_for(run.invocation),
221
+ store: session_store,
222
+ operation_id: run.operation_id,
223
+ metadata: {
224
+ "little_ghost_kind" => "subagent_conversation",
225
+ "little_ghost_parent_link" => parent_link,
226
+ "little_ghost_conversation_id" => conversation_id
227
+ }
228
+ )
229
+ end
230
+
231
+ def session_actor_for(invocation) # :nodoc:
232
+ @session_actor ? @session_actor.call(invocation) : invocation.actor_id
233
+ end
234
+
235
+ def template_locals(run:, agent:) # :nodoc:
236
+ {invocation: run.invocation, run:, agent:}.merge(agent.prompt_locals)
237
+ end
238
+
239
+ def error_message(error, run) # :nodoc:
240
+ runtime_hooks.each do |hook|
241
+ message = hook.error_message(error, run)
242
+ return message if message
243
+ end
244
+
245
+ default_error_message(error, run)
246
+ end
247
+
248
+ def default_error_message(error, _run) # :nodoc:
249
+ return error.message if error.is_a?(UnsupportedInputError)
250
+ return error.message if error.is_a?(ToolLoopError)
251
+ return "The model reached its output limit before completing a response. Please retry with a narrower request." if error.is_a?(OutputLimitError)
252
+ if error.is_a?(MalformedToolCallError)
253
+ return "The model returned an invalid tool call before completing the response. Please retry with a narrower request."
254
+ end
255
+
256
+ "Agent failed: #{error.class}"
257
+ end
258
+
259
+ def resolve_agent(value) # :nodoc:
260
+ resolve_agent_class(value)
261
+ end
262
+
263
+ private
264
+
265
+ def close_resources(resources)
266
+ resources.reverse_each do |resource|
267
+ resource.close if resource.respond_to?(:close)
268
+ rescue
269
+ nil
270
+ end
271
+ end
272
+
273
+ def build_service(value, default:)
274
+ value ||= default.call
275
+ value.is_a?(Class) ? value.new : value
276
+ end
277
+
278
+ def build_runtime_hooks(hook_classes)
279
+ Array(hook_classes).map(&:new)
280
+ end
281
+
282
+ def build_session_store(definition)
283
+ return SessionStores::Memory.new unless definition
284
+
285
+ provider = definition.fetch(:provider)
286
+ options = definition.except(:provider)
287
+ store = provider.new(**options)
288
+ unless store.is_a?(SessionStore)
289
+ raise ConfigurationError, "session_store must be a LittleGhost::SessionStore"
290
+ end
291
+
292
+ store
293
+ end
294
+
295
+ def subscribe_instrumentation(subscribers)
296
+ Array(subscribers).each { |subscriber| Instrumentation.subscribe(subscriber) }
297
+ end
298
+
299
+ def default_service_name
300
+ return @settings[:service_name].to_s if @settings&.[](:service_name)
301
+
302
+ "little-ghost"
303
+ end
304
+
305
+ def emit_startup(name, outcome: nil, error: nil)
306
+ attributes = {
307
+ service_name: service_name,
308
+ startup_phase: @startup_phase,
309
+ duration_ms: startup_duration_ms,
310
+ outcome:
311
+ }.compact
312
+ if error
313
+ attributes[:error_type] = error.class.name
314
+ attributes[:diagnostic_exception] = JSON.generate(diagnostic_exception(error))
315
+ end
316
+ if name == :runtime_start
317
+ @startup_handle = Instrumentation.start(:runtime, parent: nil, **attributes)
318
+ else
319
+ @startup_handle&.finish(**attributes)
320
+ end
321
+ end
322
+
323
+ def report_startup(status:, error: nil)
324
+ payload = {
325
+ status:,
326
+ phase: @startup_phase,
327
+ service_name:
328
+ }
329
+ payload[:duration_ms] = startup_duration_ms unless status == "starting"
330
+ payload[:error_type] = error.class.name if error
331
+ return Events.error("little_ghost.runtime.startup", payload) if error
332
+
333
+ Events.info("little_ghost.runtime.startup", payload)
334
+ end
335
+
336
+ def diagnostic_exception(error)
337
+ {
338
+ type: error.class.name,
339
+ message: error.message,
340
+ stacktrace: Array(error.backtrace).join("\n")
341
+ }
342
+ end
343
+
344
+ def startup_duration_ms
345
+ ((monotonic_time - @startup_started_at) * 1_000).round(3)
346
+ end
347
+
348
+ def monotonic_time
349
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
350
+ end
351
+
352
+ def canonical_application_root(value)
353
+ path = Pathname.new(File.realpath(File.expand_path(value)))
354
+ raise ConfigurationError, "application root must be a directory" unless path.directory?
355
+
356
+ path.freeze
357
+ rescue Errno::ENOENT
358
+ raise ConfigurationError, "application root must exist"
359
+ end
360
+
361
+ def resolve_agent_class(value)
362
+ klass = if value.is_a?(String) || value.is_a?(Symbol)
363
+ Object.const_get(value.to_s)
364
+ else
365
+ value
366
+ end
367
+ raise ConfigurationError, "agent must inherit from LittleGhost::Agent" unless klass.is_a?(Class) && klass <= Agent
368
+
369
+ klass
370
+ rescue NameError
371
+ klass = loader.constant(value)
372
+ raise ConfigurationError, "agent must inherit from LittleGhost::Agent" unless klass.is_a?(Class) && klass <= Agent
373
+
374
+ klass
375
+ end
376
+
377
+ def build_lookup_paths(name)
378
+ configured = Array(@settings.fetch(name))
379
+ default = if name == :prompt_paths
380
+ Configuration::DEFAULT_PROMPT_PATHS
381
+ else
382
+ Configuration::DEFAULT_SKILL_PATHS
383
+ end
384
+ roots = configured.map do |path|
385
+ expanded = File.expand_path(path, root)
386
+ boundary = default.include?(path.to_s) ? root : nil
387
+ Lookup::Root.new(path: expanded, boundary:)
388
+ end
389
+ PathSet.new(roots)
390
+ end
391
+ end
392
+ end