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,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
# Give application roles stable model choices without coupling agents to vendors.
|
|
5
|
+
# A registry joins provider factories with named, inheritable profiles.
|
|
6
|
+
#
|
|
7
|
+
# A support application can keep its general and research agents on related
|
|
8
|
+
# profiles while selecting one provider in one place:
|
|
9
|
+
#
|
|
10
|
+
# class CustomerSupportModels < LittleGhost::ModelRegistry
|
|
11
|
+
# def initialize
|
|
12
|
+
# super
|
|
13
|
+
# provider(:openai) do |model:, **|
|
|
14
|
+
# LittleGhost::Providers::OpenAI.new(
|
|
15
|
+
# api_key: ENV.fetch("OPENAI_API_KEY"), model: model
|
|
16
|
+
# )
|
|
17
|
+
# end
|
|
18
|
+
# profile "customer_support", provider: :openai, model: "gpt-5"
|
|
19
|
+
# profile "customer_support.research", inherit: "customer_support",
|
|
20
|
+
# settings: {temperature: 0.1}
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# model = CustomerSupportModels.new.resolve("customer_support.research")
|
|
25
|
+
# model.id # => "gpt-5"
|
|
26
|
+
# model.settings # => {temperature: 0.1}
|
|
27
|
+
#
|
|
28
|
+
# Dotted roles resolve from the exact role toward shorter registered parents.
|
|
29
|
+
# Explicit profile inheritance supplies provider, model, settings, and metadata;
|
|
30
|
+
# per-invocation overrides then layer from inherited parents through the exact
|
|
31
|
+
# requested role. A final +override+ passed to +resolve+ wins over both.
|
|
32
|
+
# Settings and overrides are control-plane input: construct or allowlist them
|
|
33
|
+
# in application code rather than copying unchecked request fields. An
|
|
34
|
+
# override can select a different registered provider or model and change the
|
|
35
|
+
# destination, capability, and cost of a request.
|
|
36
|
+
#
|
|
37
|
+
# Provider factories receive the resolved identity, settings, metadata,
|
|
38
|
+
# invocation, run context, and forwarding options. Resolution raises
|
|
39
|
+
# ConfigurationError for missing roles, providers, models, factories, or
|
|
40
|
+
# circular inheritance; exceptions raised inside a factory are not masked.
|
|
41
|
+
class ModelRegistry
|
|
42
|
+
# Starts an empty registry ready for provider factories and model profiles.
|
|
43
|
+
def initialize
|
|
44
|
+
@providers = {}
|
|
45
|
+
@profiles = {}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Associates +name+ with a provider factory and returns +self+.
|
|
49
|
+
#
|
|
50
|
+
# The factory receives the resolved +model+, logical +role+, profile
|
|
51
|
+
# +settings+ and +metadata+, invocation, run context, and resolution options.
|
|
52
|
+
def provider(name, callable = nil, &factory)
|
|
53
|
+
@providers[name.to_sym] = factory || callable || raise(ArgumentError, "provider factory is required")
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Adds an inheritable model profile named +name+ and returns +self+.
|
|
58
|
+
#
|
|
59
|
+
# Parent settings and metadata merge into the child. A child may inherit its
|
|
60
|
+
# provider and model or replace either one.
|
|
61
|
+
def profile(name, provider: nil, model: nil, settings: {}, metadata: {}, inherit: nil)
|
|
62
|
+
@profiles[name.to_s] = {
|
|
63
|
+
provider: provider&.to_sym,
|
|
64
|
+
model: model&.to_s,
|
|
65
|
+
settings: settings.to_h.transform_keys(&:to_sym),
|
|
66
|
+
metadata: metadata.to_h,
|
|
67
|
+
inherit: inherit&.to_s
|
|
68
|
+
}
|
|
69
|
+
self
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Materializes +name+ as a configured Model for the current invocation.
|
|
73
|
+
#
|
|
74
|
+
# +override+ applies after registered profiles and invocation profile
|
|
75
|
+
# overrides. +context+ takes precedence over the legacy +run+ argument when
|
|
76
|
+
# passed to the provider factory.
|
|
77
|
+
def resolve(name, invocation: nil, override: nil, run: nil, context: nil, **options)
|
|
78
|
+
role = name.to_s
|
|
79
|
+
profile_name = profile_for(role)
|
|
80
|
+
configuration = resolved_profile(profile_name)
|
|
81
|
+
override_profiles(profile_name, role).each do |profile|
|
|
82
|
+
configuration = merge(configuration, profile_override(invocation, profile))
|
|
83
|
+
end
|
|
84
|
+
configuration = merge(configuration, override)
|
|
85
|
+
provider_name = configuration[:provider]
|
|
86
|
+
model_id = configuration[:model]
|
|
87
|
+
raise ConfigurationError, "Model profile #{profile_name} does not define a provider" unless provider_name
|
|
88
|
+
raise ConfigurationError, "Model profile #{profile_name} does not define a model" unless model_id
|
|
89
|
+
|
|
90
|
+
factory = @providers.fetch(provider_name) do
|
|
91
|
+
raise ConfigurationError, "No provider is registered for #{provider_name}"
|
|
92
|
+
end
|
|
93
|
+
provider = factory.call(
|
|
94
|
+
model: model_id,
|
|
95
|
+
role:,
|
|
96
|
+
settings: configuration.fetch(:settings),
|
|
97
|
+
metadata: configuration.fetch(:metadata),
|
|
98
|
+
invocation:,
|
|
99
|
+
context: context || run,
|
|
100
|
+
**options
|
|
101
|
+
)
|
|
102
|
+
Model.new(
|
|
103
|
+
provider:,
|
|
104
|
+
provider_name:,
|
|
105
|
+
model: model_id,
|
|
106
|
+
settings: configuration.fetch(:settings),
|
|
107
|
+
metadata: configuration.fetch(:metadata),
|
|
108
|
+
role:
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def merge(configuration, override)
|
|
115
|
+
values = (override || {}).to_h.transform_keys(&:to_sym)
|
|
116
|
+
settings = values.fetch(:settings, values.fetch(:parameters, {})).to_h.transform_keys(&:to_sym)
|
|
117
|
+
configuration
|
|
118
|
+
.merge(values.except(:settings, :parameters, :model_id))
|
|
119
|
+
.merge(
|
|
120
|
+
provider: values.key?(:provider) ? values[:provider]&.to_sym : configuration[:provider],
|
|
121
|
+
model: values.fetch(:model, values.fetch(:model_id, configuration[:model])),
|
|
122
|
+
settings: configuration.fetch(:settings).merge(settings),
|
|
123
|
+
metadata: configuration.fetch(:metadata).merge(values.fetch(:metadata, {}))
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def profile_for(role)
|
|
128
|
+
parts = role.to_s.split(".")
|
|
129
|
+
until parts.empty?
|
|
130
|
+
candidate = parts.join(".")
|
|
131
|
+
return candidate if @profiles.key?(candidate)
|
|
132
|
+
|
|
133
|
+
parts.pop
|
|
134
|
+
end
|
|
135
|
+
raise ConfigurationError, "Unknown model profile: #{role}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def resolved_profile(name, seen = [])
|
|
139
|
+
raise ConfigurationError, "Circular model profile inheritance: #{[*seen, name].join(" -> ")}" if seen.include?(name)
|
|
140
|
+
|
|
141
|
+
profile = @profiles.fetch(name) do
|
|
142
|
+
raise ConfigurationError, "Unknown model profile: #{name}"
|
|
143
|
+
end
|
|
144
|
+
parent_name = profile.fetch(:inherit)
|
|
145
|
+
return profile unless parent_name
|
|
146
|
+
|
|
147
|
+
parent = resolved_profile(parent_name, [*seen, name])
|
|
148
|
+
{
|
|
149
|
+
provider: profile.fetch(:provider) || parent.fetch(:provider),
|
|
150
|
+
model: profile.fetch(:model) || parent.fetch(:model),
|
|
151
|
+
settings: parent.fetch(:settings).merge(profile.fetch(:settings)),
|
|
152
|
+
metadata: parent.fetch(:metadata).merge(profile.fetch(:metadata)),
|
|
153
|
+
inherit: parent_name
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def override_profiles(profile_name, role)
|
|
158
|
+
names = []
|
|
159
|
+
current = profile_name
|
|
160
|
+
while current
|
|
161
|
+
names.unshift(current)
|
|
162
|
+
current = @profiles.fetch(current).fetch(:inherit)
|
|
163
|
+
end
|
|
164
|
+
names << role unless names.include?(role)
|
|
165
|
+
names
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def profile_override(invocation, role)
|
|
169
|
+
profiles = invocation&.model_profiles || {}
|
|
170
|
+
profiles[role.to_s] || {}
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
# ModelRequest carries everything a provider needs for one model stream. It
|
|
5
|
+
# keeps messages, tools, settings, structured-output requirements, and
|
|
6
|
+
# cooperative execution controls together.
|
|
7
|
+
#
|
|
8
|
+
# Messages are coerced into Message objects, and required capabilities are
|
|
9
|
+
# normalized to unique symbols. The messages, tools, settings, and capability
|
|
10
|
+
# containers are frozen; output schema and tool choice values are retained as
|
|
11
|
+
# supplied.
|
|
12
|
+
#
|
|
13
|
+
# Only those outer containers are frozen. Do not mutate retained output
|
|
14
|
+
# schemas, tool choices, or nested settings after construction, and do not
|
|
15
|
+
# share mutable control values across concurrent requests.
|
|
16
|
+
ModelRequest = Data.define( # :nodoc:
|
|
17
|
+
:messages,
|
|
18
|
+
:tools,
|
|
19
|
+
:settings,
|
|
20
|
+
:output_schema,
|
|
21
|
+
:tool_choice,
|
|
22
|
+
:required_capabilities,
|
|
23
|
+
:cancellation_token,
|
|
24
|
+
:deadline
|
|
25
|
+
) do
|
|
26
|
+
# Creates a model request with optional tools, structured output, tool choice,
|
|
27
|
+
# cancellation, and deadline controls.
|
|
28
|
+
def initialize(
|
|
29
|
+
messages:,
|
|
30
|
+
tools: [],
|
|
31
|
+
settings: {},
|
|
32
|
+
output_schema: nil,
|
|
33
|
+
tool_choice: nil,
|
|
34
|
+
required_capabilities: [],
|
|
35
|
+
cancellation_token: Support::CancellationToken.new,
|
|
36
|
+
deadline: nil
|
|
37
|
+
)
|
|
38
|
+
super(
|
|
39
|
+
messages: messages.map { |message| Message.coerce(message) }.freeze,
|
|
40
|
+
tools: tools.freeze,
|
|
41
|
+
settings: settings.freeze,
|
|
42
|
+
output_schema:,
|
|
43
|
+
tool_choice:,
|
|
44
|
+
required_capabilities: required_capabilities.map(&:to_sym).uniq.freeze,
|
|
45
|
+
cancellation_token: cancellation_token,
|
|
46
|
+
deadline:
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Carries everything a provider needs for one model stream. Messages are
|
|
52
|
+
# normalized to Message objects, and required capabilities become unique
|
|
53
|
+
# symbols. The main request containers are frozen, but nested values are not
|
|
54
|
+
# defensively copied.
|
|
55
|
+
#
|
|
56
|
+
# Treat settings, output schemas, and tool-choice values as immutable after
|
|
57
|
+
# construction. Create a separate copy before using mutable control data in
|
|
58
|
+
# another request or thread.
|
|
59
|
+
class ModelRequest < Data # :doc:
|
|
60
|
+
##
|
|
61
|
+
# :singleton-method: new
|
|
62
|
+
# :call-seq:
|
|
63
|
+
# new(messages:, tools: [], settings: {}, output_schema: nil,
|
|
64
|
+
# tool_choice: nil, required_capabilities: [],
|
|
65
|
+
# cancellation_token: Support::CancellationToken.new,
|
|
66
|
+
# deadline: nil) -> ModelRequest
|
|
67
|
+
#
|
|
68
|
+
# Creates one normalized provider request with cooperative cancellation and
|
|
69
|
+
# deadline controls. Freezing is shallow; retained nested control values must
|
|
70
|
+
# not be mutated afterward.
|
|
71
|
+
|
|
72
|
+
##
|
|
73
|
+
# :attr_reader: messages
|
|
74
|
+
# The normalized conversation in a frozen Array.
|
|
75
|
+
|
|
76
|
+
##
|
|
77
|
+
# :attr_reader: tools
|
|
78
|
+
# The model-visible tool specifications in a frozen Array.
|
|
79
|
+
|
|
80
|
+
##
|
|
81
|
+
# :attr_reader: settings
|
|
82
|
+
# Trusted provider settings in the caller's now-frozen Hash. Nested values
|
|
83
|
+
# remain mutable and must not change after construction.
|
|
84
|
+
|
|
85
|
+
##
|
|
86
|
+
# :attr_reader: output_schema
|
|
87
|
+
# The requested structured-output schema, or +nil+ for ordinary text. The
|
|
88
|
+
# caller-owned value is retained and must not be mutated afterward.
|
|
89
|
+
|
|
90
|
+
##
|
|
91
|
+
# :attr_reader: tool_choice
|
|
92
|
+
# The requested tool-selection policy, when one applies. The caller-owned
|
|
93
|
+
# value is retained and must not be mutated afterward.
|
|
94
|
+
|
|
95
|
+
##
|
|
96
|
+
# :attr_reader: required_capabilities
|
|
97
|
+
# The normalized capabilities the selected model must support.
|
|
98
|
+
|
|
99
|
+
##
|
|
100
|
+
# :attr_reader: cancellation_token
|
|
101
|
+
# The cooperative cancellation token shared with the provider.
|
|
102
|
+
|
|
103
|
+
##
|
|
104
|
+
# :attr_reader: deadline
|
|
105
|
+
# The absolute request deadline, or +nil+ when none was configured.
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
# ModelResponse is the final result shared by every provider stream. It keeps
|
|
5
|
+
# provider-specific response shapes out of the agent loop.
|
|
6
|
+
#
|
|
7
|
+
# +message+ is the assistant Message, +stop_reason+ is a normalized symbol,
|
|
8
|
+
# and +usage+ contains provider-independent token counts.
|
|
9
|
+
ModelResponse = Data.define(:message, :stop_reason, :usage, :metadata) do # :nodoc:
|
|
10
|
+
# Creates an immutable response and coerces +message+ into a Message.
|
|
11
|
+
def initialize(message:, stop_reason:, usage: Usage.new, metadata: {})
|
|
12
|
+
super(
|
|
13
|
+
message: Message.coerce(message),
|
|
14
|
+
stop_reason: stop_reason&.to_sym,
|
|
15
|
+
usage: usage,
|
|
16
|
+
metadata: metadata.freeze
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Represents the final result shared by every provider stream. It keeps
|
|
22
|
+
# provider-specific response shapes out of the agent loop.
|
|
23
|
+
class ModelResponse < Data # :doc:
|
|
24
|
+
##
|
|
25
|
+
# :singleton-method: new
|
|
26
|
+
# :call-seq:
|
|
27
|
+
# new(message:, stop_reason:, usage: Usage.new, metadata: {}) -> ModelResponse
|
|
28
|
+
#
|
|
29
|
+
# Coerces +message+ to Message, normalizes +stop_reason+ to a Symbol, and
|
|
30
|
+
# freezes +metadata+.
|
|
31
|
+
|
|
32
|
+
##
|
|
33
|
+
# :attr_reader: message
|
|
34
|
+
# The normalized assistant Message.
|
|
35
|
+
|
|
36
|
+
##
|
|
37
|
+
# :attr_reader: stop_reason
|
|
38
|
+
# The normalized reason the model stopped.
|
|
39
|
+
|
|
40
|
+
##
|
|
41
|
+
# :attr_reader: usage
|
|
42
|
+
# Provider-independent token usage for this response.
|
|
43
|
+
|
|
44
|
+
##
|
|
45
|
+
# :attr_reader: metadata
|
|
46
|
+
# Frozen provider-specific response metadata.
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
# PathSet keeps prompt or skill lookup roots in deterministic search order. It
|
|
5
|
+
# is immutable, so appending a path does not change a running configuration.
|
|
6
|
+
class PathSet
|
|
7
|
+
include Enumerable
|
|
8
|
+
|
|
9
|
+
# The immutable Lookup::Root values in search order.
|
|
10
|
+
attr_reader :paths
|
|
11
|
+
|
|
12
|
+
# Accepts path strings and Lookup::Root objects.
|
|
13
|
+
def initialize(paths = [])
|
|
14
|
+
@paths = Array(paths).map { |path| path.is_a?(Lookup::Root) ? path : Lookup::Root.new(path:) }.freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Yields each Lookup::Root in order.
|
|
18
|
+
def each(&block)
|
|
19
|
+
paths.each(&block)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Copies the roots into a mutable array.
|
|
23
|
+
def to_a
|
|
24
|
+
paths.dup
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Appends +other+ in a new path set; neither input is mutated.
|
|
28
|
+
def +(other)
|
|
29
|
+
PathSet.new([*paths, *Array(other)])
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module LittleGhost
|
|
6
|
+
# TrustedPath marks a caller-supplied prompt directory as trusted application
|
|
7
|
+
# code. Invocation-specific prompt roots must use this wrapper.
|
|
8
|
+
#
|
|
9
|
+
# Invocation paths must use this wrapper. Construction resolves symbolic
|
|
10
|
+
# links immediately and rejects missing or non-directory paths.
|
|
11
|
+
TrustedPath = Data.define(:path) do # :nodoc:
|
|
12
|
+
def initialize(path:)
|
|
13
|
+
expanded = File.realpath(path)
|
|
14
|
+
raise ArgumentError, "trusted template path must be a directory" unless File.directory?(expanded)
|
|
15
|
+
super(path: expanded.freeze)
|
|
16
|
+
rescue Errno::ENOENT
|
|
17
|
+
raise ArgumentError, "trusted template path must exist"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Marks a caller-supplied prompt directory as trusted application code.
|
|
22
|
+
# Construction resolves symbolic links immediately and rejects paths that are
|
|
23
|
+
# missing or are not directories.
|
|
24
|
+
#
|
|
25
|
+
# === Security and trust
|
|
26
|
+
#
|
|
27
|
+
# Construction is a trust assertion, not a sanitizer. It does not inspect
|
|
28
|
+
# ownership, permissions, or who can modify the directory. ERB templates run
|
|
29
|
+
# as Ruby inside the current process, so create TrustedPath values only from
|
|
30
|
+
# allowlisted, application-controlled, non-user-writable roots. Never wrap a
|
|
31
|
+
# path taken from unchecked request or model input.
|
|
32
|
+
class TrustedPath < Data # :doc:
|
|
33
|
+
##
|
|
34
|
+
# :singleton-method: new
|
|
35
|
+
# :call-seq:
|
|
36
|
+
# new(path:) -> TrustedPath
|
|
37
|
+
#
|
|
38
|
+
# Resolves +path+ to an existing directory the caller asserts is trusted
|
|
39
|
+
# prompt code. This checks existence and type, not ownership or permissions.
|
|
40
|
+
|
|
41
|
+
##
|
|
42
|
+
# :attr_reader: path
|
|
43
|
+
# The resolved, existing directory asserted as trusted by the caller.
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Base error raised while locating or rendering a prompt template.
|
|
47
|
+
class PromptTemplateError < Error; end
|
|
48
|
+
# Raised when no configured root contains the requested template.
|
|
49
|
+
class MissingPromptTemplateError < PromptTemplateError; end
|
|
50
|
+
# Raised for unsafe names, escaped roots, cycles, or excessive recursion.
|
|
51
|
+
class InvalidPromptTemplateError < PromptTemplateError; end
|
|
52
|
+
# Raised when an ERB template references a missing local variable.
|
|
53
|
+
class MissingPromptLocalError < PromptTemplateError; end
|
|
54
|
+
|
|
55
|
+
# PromptResolver turns conventional ERB files into an agent's system prompt. It
|
|
56
|
+
# supports ordered application roots and partials without allowing a template
|
|
57
|
+
# name to escape those roots.
|
|
58
|
+
#
|
|
59
|
+
# resolver = LittleGhost::PromptResolver.new(paths: ["app/prompts"])
|
|
60
|
+
# prompt = resolver.render("support/system", locals: {product: "Acme"})
|
|
61
|
+
# prompt.include?("Acme") # => true
|
|
62
|
+
#
|
|
63
|
+
# In +support/system.erb+:
|
|
64
|
+
#
|
|
65
|
+
# <%= partial "shared/rules", locals: {product: product} %>
|
|
66
|
+
#
|
|
67
|
+
# Earlier invocation roots override configured roots. Template names must be
|
|
68
|
+
# relative, and both lexical traversal and symbolic-link escapes are rejected.
|
|
69
|
+
# Partials use an underscore-prefixed filename and receive only their
|
|
70
|
+
# explicitly supplied locals.
|
|
71
|
+
#
|
|
72
|
+
# Every configured root is trusted Ruby code because ERB executes inside the
|
|
73
|
+
# current process. Keep roots application-controlled and non-user-writable.
|
|
74
|
+
class PromptResolver
|
|
75
|
+
DEFAULT_MAX_DEPTH = 20 # :nodoc:
|
|
76
|
+
|
|
77
|
+
# Configures ordered application roots and a partial recursion bound, which
|
|
78
|
+
# defaults to 20 nested templates.
|
|
79
|
+
def initialize(paths: [], max_depth: DEFAULT_MAX_DEPTH)
|
|
80
|
+
@paths = normalize_roots(paths)
|
|
81
|
+
@max_depth = Integer(max_depth)
|
|
82
|
+
raise ArgumentError, "max_depth must be positive" unless @max_depth.positive?
|
|
83
|
+
|
|
84
|
+
@cache = {}
|
|
85
|
+
@cache_mutex = Mutex.new
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Renders +name+ with validated local variables.
|
|
89
|
+
#
|
|
90
|
+
# +invocation_paths+ accepts only TrustedPath values because those roots
|
|
91
|
+
# take precedence over application configuration. The wrapper records the
|
|
92
|
+
# caller's trust decision; it does not make an untrusted directory safe.
|
|
93
|
+
def render(name, locals: {}, invocation_paths: [])
|
|
94
|
+
roots = normalize_invocation_roots(invocation_paths) + @paths
|
|
95
|
+
render_template(normalize_name(name), locals, roots, [])
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def render_template(name, locals, roots, stack)
|
|
101
|
+
raise InvalidPromptTemplateError, "Prompt template recursion exceeds #{@max_depth} levels" if stack.length >= @max_depth
|
|
102
|
+
|
|
103
|
+
path = resolve(name, roots)
|
|
104
|
+
raise InvalidPromptTemplateError, "Prompt template cycle detected: #{(stack + [path]).join(" -> ")}" if stack.include?(path)
|
|
105
|
+
|
|
106
|
+
template = compiled_template(path)
|
|
107
|
+
context = RenderContext.new(self, roots, stack + [path], name, locals)
|
|
108
|
+
template.result(context.template_binding)
|
|
109
|
+
rescue NameError => error
|
|
110
|
+
if error.name && local_name?(error.name)
|
|
111
|
+
raise MissingPromptLocalError, "Missing prompt template local: #{error.name}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
raise
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def render_partial(name, locals, roots, stack, parent_name)
|
|
118
|
+
logical_name = partial_name(name, parent_name)
|
|
119
|
+
render_template(logical_name, locals, roots, stack)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def partial_name(name, parent_name)
|
|
123
|
+
normalized = normalize_name(name)
|
|
124
|
+
directory = File.dirname(parent_name)
|
|
125
|
+
directory = "" if directory == "."
|
|
126
|
+
basename = File.basename(normalized)
|
|
127
|
+
basename = "_#{basename}" unless basename.start_with?("_")
|
|
128
|
+
path = File.join(File.dirname(normalized), basename)
|
|
129
|
+
path = File.join(directory, path) unless directory.empty? || name.to_s.include?("/")
|
|
130
|
+
path
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def resolve(name, roots)
|
|
134
|
+
candidates = template_candidates(name)
|
|
135
|
+
roots.each do |root_spec|
|
|
136
|
+
root, real_root = validate_root(root_spec)
|
|
137
|
+
candidates.each do |candidate|
|
|
138
|
+
path = File.expand_path(candidate, root)
|
|
139
|
+
next unless inside_root?(path, root) && File.file?(path)
|
|
140
|
+
|
|
141
|
+
real_path = File.realpath(path)
|
|
142
|
+
return real_path if inside_root?(real_path, real_root)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
raise MissingPromptTemplateError, "Prompt template not found: #{name}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def template_candidates(name)
|
|
150
|
+
name.end_with?(".erb") ? [name] : ["#{name}.erb", name]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def compiled_template(path)
|
|
154
|
+
stat = File.stat(path)
|
|
155
|
+
fingerprint = [stat.mtime.to_r, stat.size]
|
|
156
|
+
|
|
157
|
+
@cache_mutex.synchronize do
|
|
158
|
+
cached = @cache[path]
|
|
159
|
+
return cached[:template] if cached && cached[:fingerprint] == fingerprint
|
|
160
|
+
|
|
161
|
+
template = ERB.new(File.read(path), trim_mode: "-")
|
|
162
|
+
@cache[path] = {fingerprint: fingerprint, template: template}
|
|
163
|
+
template
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def normalize_roots(paths)
|
|
168
|
+
Array(paths).map do |path|
|
|
169
|
+
path.is_a?(Lookup::Root) ? path : Lookup::Root.new(path: path.to_s)
|
|
170
|
+
end.freeze
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def normalize_invocation_roots(paths)
|
|
174
|
+
Array(paths).map do |path|
|
|
175
|
+
unless path.is_a?(TrustedPath)
|
|
176
|
+
raise ArgumentError, "invocation template paths must be LittleGhost::TrustedPath values"
|
|
177
|
+
end
|
|
178
|
+
Lookup::Root.new(path: path.path)
|
|
179
|
+
end.freeze
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def validate_root(root)
|
|
183
|
+
real_root = File.realpath(root.path)
|
|
184
|
+
raise InvalidPromptTemplateError, "Prompt template root is not a directory" unless File.directory?(real_root)
|
|
185
|
+
if root.boundary
|
|
186
|
+
boundary = File.realpath(root.boundary)
|
|
187
|
+
unless inside_root?(real_root, boundary)
|
|
188
|
+
raise InvalidPromptTemplateError, "Prompt template root escapes its trusted boundary"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
[root.path, real_root]
|
|
192
|
+
rescue Errno::ENOENT
|
|
193
|
+
[root.path, root.path]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def normalize_name(name)
|
|
197
|
+
value = String(name)
|
|
198
|
+
path_parts = value.split(/[\\\/]/)
|
|
199
|
+
if value.empty? || value.include?("\0") || File.absolute_path(value) == value || path_parts.include?("..")
|
|
200
|
+
raise InvalidPromptTemplateError, "Unsafe prompt template name: #{value.inspect}"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
value.sub(%r{\A\./}, "")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def inside_root?(path, root)
|
|
207
|
+
path == root || path.start_with?("#{root}#{File::SEPARATOR}")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def local_name?(name)
|
|
211
|
+
name.to_s.match?(/\A[a-z_]\w*\z/)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
class RenderContext # :nodoc:
|
|
215
|
+
def initialize(resolver, roots, stack, name, locals)
|
|
216
|
+
@resolver = resolver
|
|
217
|
+
@roots = roots
|
|
218
|
+
@stack = stack
|
|
219
|
+
@name = name
|
|
220
|
+
@locals = validate_locals(locals)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def partial(name, locals: {})
|
|
224
|
+
@resolver.send(:render_partial, name, locals, @roots, @stack, @name)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def template_binding
|
|
228
|
+
context_binding = binding
|
|
229
|
+
@locals.each { |name, value| context_binding.local_variable_set(name, value) }
|
|
230
|
+
context_binding
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
private
|
|
234
|
+
|
|
235
|
+
def validate_locals(locals)
|
|
236
|
+
unless locals.respond_to?(:each_pair)
|
|
237
|
+
raise ArgumentError, "locals must be a hash"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
locals.each_with_object({}) do |(name, value), result|
|
|
241
|
+
symbol = name.to_sym
|
|
242
|
+
unless symbol.to_s.match?(/\A[a-z_]\w*\z/)
|
|
243
|
+
raise ArgumentError, "Invalid local name: #{name.inspect}"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
result[symbol] = value
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|