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.
- checksums.yaml +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +122 -0
- data/docs/guides/Core Concepts.md +203 -0
- data/docs/guides/Getting Started.md +187 -0
- data/lib/little_ghost/ag_ui/adapter.rb +194 -0
- data/lib/little_ghost/ag_ui.rb +5 -0
- data/lib/little_ghost/agent/context_management.rb +285 -0
- data/lib/little_ghost/agent/delegation.rb +128 -0
- data/lib/little_ghost/agent/skills.rb +96 -0
- data/lib/little_ghost/agent/tool_loop.rb +239 -0
- data/lib/little_ghost/agent.rb +2111 -0
- data/lib/little_ghost/agent_builder.rb +191 -0
- data/lib/little_ghost/agent_interruptions.rb +197 -0
- data/lib/little_ghost/configuration.rb +337 -0
- data/lib/little_ghost/content.rb +324 -0
- data/lib/little_ghost/default_model_registry.rb +71 -0
- data/lib/little_ghost/errors.rb +48 -0
- data/lib/little_ghost/events.rb +264 -0
- data/lib/little_ghost/execution_state.rb +58 -0
- data/lib/little_ghost/instrumentation.rb +475 -0
- data/lib/little_ghost/invocation.rb +285 -0
- data/lib/little_ghost/lookup.rb +37 -0
- data/lib/little_ghost/mcp/client.rb +396 -0
- data/lib/little_ghost/mcp.rb +5 -0
- data/lib/little_ghost/message.rb +75 -0
- data/lib/little_ghost/model.rb +88 -0
- data/lib/little_ghost/model_capabilities.rb +126 -0
- data/lib/little_ghost/model_registry.rb +173 -0
- data/lib/little_ghost/model_request.rb +107 -0
- data/lib/little_ghost/model_response.rb +48 -0
- data/lib/little_ghost/path_set.rb +32 -0
- data/lib/little_ghost/prompt_resolver.rb +251 -0
- data/lib/little_ghost/providers/bedrock.rb +506 -0
- data/lib/little_ghost/providers/http_transport.rb +149 -0
- data/lib/little_ghost/providers/open_router.rb +171 -0
- data/lib/little_ghost/providers/openai.rb +27 -0
- data/lib/little_ghost/providers/openai_compatible.rb +745 -0
- data/lib/little_ghost/providers/sse_parser.rb +35 -0
- data/lib/little_ghost/run.rb +607 -0
- data/lib/little_ghost/run_context.rb +129 -0
- data/lib/little_ghost/run_result.rb +111 -0
- data/lib/little_ghost/runtime/hook.rb +31 -0
- data/lib/little_ghost/runtime.rb +392 -0
- data/lib/little_ghost/sandbox.rb +138 -0
- data/lib/little_ghost/session.rb +229 -0
- data/lib/little_ghost/session_store.rb +96 -0
- data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
- data/lib/little_ghost/session_stores/memory.rb +86 -0
- data/lib/little_ghost/skills/catalog.rb +283 -0
- data/lib/little_ghost/skills/skill.rb +60 -0
- data/lib/little_ghost/skills.rb +4 -0
- data/lib/little_ghost/stream_event.rb +49 -0
- data/lib/little_ghost/structured_output.rb +126 -0
- data/lib/little_ghost/subagents/agent_path.rb +63 -0
- data/lib/little_ghost/subagents/definition.rb +42 -0
- data/lib/little_ghost/subagents/manager.rb +1615 -0
- data/lib/little_ghost/support/callbacks.rb +151 -0
- data/lib/little_ghost/support/cancellation_token.rb +86 -0
- data/lib/little_ghost/support/class_attributes.rb +40 -0
- data/lib/little_ghost/support/content_capture.rb +150 -0
- data/lib/little_ghost/support/executor.rb +75 -0
- data/lib/little_ghost/support/interruptible_stream.rb +103 -0
- data/lib/little_ghost/support/loader.rb +263 -0
- data/lib/little_ghost/support/output_truncation.rb +71 -0
- data/lib/little_ghost/support/redactor.rb +66 -0
- data/lib/little_ghost/support.rb +34 -0
- data/lib/little_ghost/tool.rb +448 -0
- data/lib/little_ghost/tool_execution.rb +59 -0
- data/lib/little_ghost/tool_registry.rb +156 -0
- data/lib/little_ghost/tools/filesystem.rb +119 -0
- data/lib/little_ghost/tools/shell.rb +45 -0
- data/lib/little_ghost/tools/write_todos.rb +91 -0
- data/lib/little_ghost/tools.rb +6 -0
- data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
- data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
- data/lib/little_ghost/usage.rb +47 -0
- data/lib/little_ghost/version.rb +6 -0
- data/lib/little_ghost/workflow.rb +351 -0
- data/lib/little_ghost/workspace.rb +31 -0
- data/lib/little_ghost.rb +120 -0
- 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
|