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,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
# Ready-to-use persistence implementations for LittleGhost conversations.
|
|
5
|
+
module SessionStores
|
|
6
|
+
# Memory keeps conversations available for the life of one Ruby process. It
|
|
7
|
+
# is the default store and needs no application setup.
|
|
8
|
+
#
|
|
9
|
+
# Data disappears when the process exits. Supplying an actor ID binds the
|
|
10
|
+
# session key to that actor; a later mismatch raises an error.
|
|
11
|
+
class Memory < SessionStore
|
|
12
|
+
# Starts with no saved conversations.
|
|
13
|
+
def initialize
|
|
14
|
+
super
|
|
15
|
+
@records = {}
|
|
16
|
+
@records_mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Loads the snapshot for +id+, returning +nil+ before the first checkpoint.
|
|
20
|
+
# A supplied +actor_id+ claims a new ID and must match on later access.
|
|
21
|
+
def load(id, actor_id: nil)
|
|
22
|
+
@records_mutex.synchronize do
|
|
23
|
+
key = id.to_s
|
|
24
|
+
record = @records[key]
|
|
25
|
+
validate_actor!(record, actor_id)
|
|
26
|
+
@records[key] = {actor_id: actor_id.to_s, snapshot: nil} if !record && actor_id
|
|
27
|
+
record&.fetch(:snapshot)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Appends sanitized +messages+ when +expected_count+ still matches the
|
|
32
|
+
# stored conversation, then returns the updated snapshot.
|
|
33
|
+
def append(id, messages:, state:, metadata:, expected_count:, actor_id: nil)
|
|
34
|
+
messages = persistable_messages(messages)
|
|
35
|
+
@records_mutex.synchronize do
|
|
36
|
+
key = id.to_s
|
|
37
|
+
record = @records[key]
|
|
38
|
+
validate_actor!(record, actor_id)
|
|
39
|
+
current = record&.fetch(:snapshot) || empty_snapshot
|
|
40
|
+
unless current.fetch(:messages).length == expected_count
|
|
41
|
+
raise ProtocolError, "Session changed while it was being updated"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@records[key] = {
|
|
45
|
+
actor_id: actor_id&.to_s,
|
|
46
|
+
snapshot: {
|
|
47
|
+
messages: [*current.fetch(:messages), *messages].freeze,
|
|
48
|
+
state:,
|
|
49
|
+
metadata:
|
|
50
|
+
}.freeze
|
|
51
|
+
}.freeze
|
|
52
|
+
@records[key].fetch(:snapshot)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Replaces the complete in-memory snapshot with sanitized +messages+.
|
|
57
|
+
def replace(id, messages:, state:, metadata:, actor_id: nil)
|
|
58
|
+
messages = persistable_messages(messages)
|
|
59
|
+
@records_mutex.synchronize do
|
|
60
|
+
key = id.to_s
|
|
61
|
+
validate_actor!(@records[key], actor_id)
|
|
62
|
+
@records[key] = {
|
|
63
|
+
actor_id: actor_id&.to_s,
|
|
64
|
+
snapshot: {messages:, state:, metadata:}.freeze
|
|
65
|
+
}.freeze
|
|
66
|
+
@records[key].fetch(:snapshot)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def empty_snapshot
|
|
73
|
+
{messages: [].freeze, state: {}.freeze, metadata: {}.freeze}.freeze
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def validate_actor!(record, actor_id)
|
|
77
|
+
actor = actor_id&.to_s
|
|
78
|
+
return unless record
|
|
79
|
+
return if record[:actor_id].nil? && actor.nil?
|
|
80
|
+
return if record[:actor_id] == actor
|
|
81
|
+
|
|
82
|
+
raise Error, "Session actor does not match"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "erb"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module LittleGhost
|
|
8
|
+
module Skills
|
|
9
|
+
# A Catalog lets an agent discover focused instructions without putting every
|
|
10
|
+
# skill in its prompt. The model sees short descriptions first and can load a
|
|
11
|
+
# skill's full instructions when the task calls for them.
|
|
12
|
+
#
|
|
13
|
+
# catalog = LittleGhost::Skills::Catalog.new(paths: ["app/skills"])
|
|
14
|
+
# catalog.names # => ["refund_policy", "search_orders"]
|
|
15
|
+
# catalog.discovery_prompt.include?("refund_policy") # => true
|
|
16
|
+
# catalog.tool # a LittleGhost::Tool that loads full instructions on demand
|
|
17
|
+
#
|
|
18
|
+
# Each immediate child directory may contain one +SKILL.md+ with YAML front
|
|
19
|
+
# matter. Symbolic-link escapes, unsafe names, oversized files, and invalid
|
|
20
|
+
# YAML are rejected or skipped before instructions reach a model. Optional
|
|
21
|
+
# resource listings are limited by count and depth.
|
|
22
|
+
#
|
|
23
|
+
# === Security and trust
|
|
24
|
+
#
|
|
25
|
+
# Configured roots and their contents are fully trusted instruction sources.
|
|
26
|
+
# The +allowed-tools+ field is metadata shown to the model, not an
|
|
27
|
+
# authorization boundary. Applications must enforce tool access separately
|
|
28
|
+
# and keep skill roots non-user-writable.
|
|
29
|
+
class Catalog
|
|
30
|
+
include Enumerable
|
|
31
|
+
|
|
32
|
+
class InvalidSkillError < ConfigurationError; end # :nodoc:
|
|
33
|
+
private_constant :InvalidSkillError
|
|
34
|
+
|
|
35
|
+
DEFAULT_MAX_SKILLS = 1_000 # :nodoc:
|
|
36
|
+
DEFAULT_MAX_FILE_BYTES = 1_000_000 # :nodoc:
|
|
37
|
+
DEFAULT_MAX_RESOURCE_FILES = 20 # :nodoc:
|
|
38
|
+
MAX_RESOURCE_DEPTH = 3 # :nodoc:
|
|
39
|
+
RESOURCE_DIRECTORIES = %w[scripts references assets].freeze # :nodoc:
|
|
40
|
+
SAFE_NAME_PATTERN = /\A[a-zA-Z0-9_-]+\z/ # :nodoc:
|
|
41
|
+
|
|
42
|
+
# Loads valid skills immediately using the supplied safety limits.
|
|
43
|
+
def initialize(
|
|
44
|
+
paths:,
|
|
45
|
+
max_skills: DEFAULT_MAX_SKILLS,
|
|
46
|
+
max_file_bytes: DEFAULT_MAX_FILE_BYTES,
|
|
47
|
+
max_resource_files: DEFAULT_MAX_RESOURCE_FILES,
|
|
48
|
+
only: nil,
|
|
49
|
+
resource_root: nil
|
|
50
|
+
)
|
|
51
|
+
@paths = PathSet.new(paths)
|
|
52
|
+
@max_skills = positive_integer(max_skills, :max_skills)
|
|
53
|
+
@max_file_bytes = positive_integer(max_file_bytes, :max_file_bytes)
|
|
54
|
+
@max_resource_files = positive_integer(max_resource_files, :max_resource_files)
|
|
55
|
+
@only = Array(only).map(&:to_s).freeze if only
|
|
56
|
+
@resource_root = canonical_resource_root(resource_root)
|
|
57
|
+
@skills = load_skills
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Yields each Skill in lookup order.
|
|
61
|
+
def each(&block)
|
|
62
|
+
@skills.each_value(&block)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Finds the named Skill or raises ConfigurationError.
|
|
66
|
+
def fetch(name)
|
|
67
|
+
@skills.fetch(name.to_s) { raise ConfigurationError, "Unknown skill: #{name}" }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Lists immutable skill names in lookup order.
|
|
71
|
+
def names
|
|
72
|
+
@skills.keys.freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Produces the escaped, metadata-only prompt used for discovery.
|
|
76
|
+
def discovery_prompt
|
|
77
|
+
return "" if @skills.empty?
|
|
78
|
+
|
|
79
|
+
lines = ["<available_skills>"]
|
|
80
|
+
@skills.each_value do |skill|
|
|
81
|
+
lines.concat([
|
|
82
|
+
"<skill>",
|
|
83
|
+
"<name>#{ERB::Util.html_escape(skill.name)}</name>",
|
|
84
|
+
"<description>#{ERB::Util.html_escape(skill.description)}</description>",
|
|
85
|
+
"<location>#{ERB::Util.html_escape(skill.path)}</location>",
|
|
86
|
+
"</skill>"
|
|
87
|
+
])
|
|
88
|
+
end
|
|
89
|
+
lines << "</available_skills>"
|
|
90
|
+
lines.join("\n")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Exposes full instructions on demand through a +skills+ Tool.
|
|
94
|
+
def tool
|
|
95
|
+
catalog = self
|
|
96
|
+
Tool.define(
|
|
97
|
+
name: "skills",
|
|
98
|
+
description: <<~DESCRIPTION.strip,
|
|
99
|
+
Activate a skill to load its full instructions.
|
|
100
|
+
|
|
101
|
+
Use this tool to load the complete instructions for a skill listed in
|
|
102
|
+
the available_skills section of your system prompt.
|
|
103
|
+
DESCRIPTION
|
|
104
|
+
input_schema: {
|
|
105
|
+
type: "object",
|
|
106
|
+
properties: {skill_name: {type: "string", description: "Name of the skill to activate."}},
|
|
107
|
+
required: ["skill_name"],
|
|
108
|
+
additionalProperties: false
|
|
109
|
+
}
|
|
110
|
+
) do |input|
|
|
111
|
+
catalog.format(catalog.fetch(input.fetch("skill_name")))
|
|
112
|
+
rescue ConfigurationError => error
|
|
113
|
+
raise ToolError, error.message
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Formats one Skill, including allowed tools, compatibility, and bounded
|
|
118
|
+
# resource paths.
|
|
119
|
+
def format(skill)
|
|
120
|
+
parts = [skill.instructions]
|
|
121
|
+
metadata = []
|
|
122
|
+
metadata << "Allowed tools: #{skill.allowed_tools.join(", ")}" unless skill.allowed_tools.empty?
|
|
123
|
+
metadata << "Compatibility: #{skill.compatibility}" if skill.compatibility
|
|
124
|
+
metadata << "Location: #{skill.path}"
|
|
125
|
+
parts << "\n---\n#{metadata.join("\n")}" unless metadata.empty?
|
|
126
|
+
resources = skill_resources(skill)
|
|
127
|
+
unless resources.empty?
|
|
128
|
+
parts << "\nAvailable resources:\n#{resources.map { |path| " #{path}" }.join("\n")}"
|
|
129
|
+
end
|
|
130
|
+
parts.join("\n")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def load_skills
|
|
136
|
+
missing_root = @paths.find { |root| !Dir.exist?(root.path) && !root.boundary }
|
|
137
|
+
raise Errno::ENOENT, missing_root.path if missing_root
|
|
138
|
+
@paths.each do |root|
|
|
139
|
+
next unless Dir.exist?(root.path)
|
|
140
|
+
|
|
141
|
+
real_root = File.realpath(root.path)
|
|
142
|
+
next if boundary_allows?(real_root, root.boundary)
|
|
143
|
+
|
|
144
|
+
raise ConfigurationError, "Skill root escapes its configured boundary: #{root.path}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
paths = @paths.flat_map do |root|
|
|
148
|
+
next [] unless Dir.exist?(root.path)
|
|
149
|
+
|
|
150
|
+
Dir.glob(File.join(root.path, "*", "SKILL.md")).sort
|
|
151
|
+
end
|
|
152
|
+
raise ConfigurationError, "Skill catalog exceeds #{@max_skills} skills" if paths.length > @max_skills
|
|
153
|
+
|
|
154
|
+
paths.each_with_object({}) do |path, loaded|
|
|
155
|
+
skill = begin
|
|
156
|
+
parse(path)
|
|
157
|
+
rescue InvalidSkillError, SystemCallError
|
|
158
|
+
next
|
|
159
|
+
end
|
|
160
|
+
next if @only && !@only.include?(skill.name)
|
|
161
|
+
|
|
162
|
+
loaded[skill.name] = skill
|
|
163
|
+
end.freeze
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def parse(path)
|
|
167
|
+
real_path = File.realpath(path)
|
|
168
|
+
root = @paths.find do |candidate|
|
|
169
|
+
next false unless Dir.exist?(candidate.path)
|
|
170
|
+
|
|
171
|
+
real_root = File.realpath(candidate.path)
|
|
172
|
+
inside_root?(real_path, real_root) && boundary_allows?(real_root, candidate.boundary)
|
|
173
|
+
end
|
|
174
|
+
raise ConfigurationError, "Skill path escapes its configured root: #{path}" unless root
|
|
175
|
+
root_path = File.realpath(root.path)
|
|
176
|
+
raise ConfigurationError, "Skill exceeds #{@max_file_bytes} bytes: #{path}" if File.size(real_path) > @max_file_bytes
|
|
177
|
+
|
|
178
|
+
text = File.read(real_path, encoding: "UTF-8")
|
|
179
|
+
raise InvalidSkillError, "Skill is not valid UTF-8: #{path}" unless text.valid_encoding?
|
|
180
|
+
|
|
181
|
+
match = text.match(/\A---\s*\n(.*?)\n---\s*\n(.*)\z/m)
|
|
182
|
+
raise InvalidSkillError, "Skill must have YAML front matter: #{path}" unless match
|
|
183
|
+
|
|
184
|
+
metadata = YAML.safe_load(match[1], permitted_classes: [], aliases: false) || {}
|
|
185
|
+
raise InvalidSkillError, "Skill front matter must be a mapping: #{path}" unless metadata.is_a?(Hash)
|
|
186
|
+
|
|
187
|
+
name = metadata["name"].to_s.strip
|
|
188
|
+
description = metadata["description"].to_s.strip
|
|
189
|
+
raise InvalidSkillError, "Skill name is required: #{path}" if name.empty?
|
|
190
|
+
raise InvalidSkillError, "Skill description is required: #{path}" if description.empty?
|
|
191
|
+
raise InvalidSkillError, "Skill name contains unsafe characters: #{path}" unless SAFE_NAME_PATTERN.match?(name)
|
|
192
|
+
raise InvalidSkillError, "Skill description must be one line: #{path}" if description.match?(/[\r\n]/)
|
|
193
|
+
|
|
194
|
+
allowed_tools = metadata["allowed-tools"] || metadata["allowed_tools"]
|
|
195
|
+
allowed_tools = allowed_tools.split if allowed_tools.is_a?(String)
|
|
196
|
+
allowed_tools = Array(allowed_tools).map(&:to_s).freeze
|
|
197
|
+
compatibility = metadata["compatibility"]&.to_s
|
|
198
|
+
Skill.new(
|
|
199
|
+
name:, description:, instructions: match[2].strip,
|
|
200
|
+
path: agent_path(real_path, root_path), source_path: real_path,
|
|
201
|
+
allowed_tools:, compatibility:
|
|
202
|
+
)
|
|
203
|
+
rescue Psych::Exception => error
|
|
204
|
+
raise InvalidSkillError, "Invalid skill front matter in #{path}: #{error.message}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def inside_root?(path, root)
|
|
208
|
+
path == root || path.start_with?("#{root}#{File::SEPARATOR}")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def boundary_allows?(path, boundary)
|
|
212
|
+
return true unless boundary
|
|
213
|
+
|
|
214
|
+
boundary = File.realpath(boundary)
|
|
215
|
+
inside_root?(path, boundary)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def skill_resources(skill)
|
|
219
|
+
directory = File.dirname(skill.source_path)
|
|
220
|
+
files = RESOURCE_DIRECTORIES.flat_map do |name|
|
|
221
|
+
root = File.join(directory, name)
|
|
222
|
+
next [] unless File.directory?(root) && !File.symlink?(root)
|
|
223
|
+
|
|
224
|
+
resource_files(root, prefix: name)
|
|
225
|
+
end.sort
|
|
226
|
+
files.map! { |path| resource_path(skill, path) } if @resource_root
|
|
227
|
+
return files if files.length <= @max_resource_files
|
|
228
|
+
|
|
229
|
+
[*files.first(@max_resource_files), "... (truncated at #{@max_resource_files} files)"]
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def resource_files(directory, prefix:, depth: 0)
|
|
233
|
+
return [] if depth >= MAX_RESOURCE_DEPTH
|
|
234
|
+
|
|
235
|
+
Dir.children(directory).sort.flat_map do |name|
|
|
236
|
+
path = File.join(directory, name)
|
|
237
|
+
relative = "#{prefix}/#{name}"
|
|
238
|
+
stat = File.lstat(path)
|
|
239
|
+
if stat.directory? && !stat.symlink?
|
|
240
|
+
resource_files(path, prefix: relative, depth: depth + 1)
|
|
241
|
+
elsif stat.file?
|
|
242
|
+
[relative]
|
|
243
|
+
else
|
|
244
|
+
[]
|
|
245
|
+
end
|
|
246
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
247
|
+
[]
|
|
248
|
+
end
|
|
249
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
250
|
+
[]
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def positive_integer(value, name)
|
|
254
|
+
integer = Integer(value)
|
|
255
|
+
raise ArgumentError, "#{name} must be positive" unless integer.positive?
|
|
256
|
+
|
|
257
|
+
integer
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def canonical_resource_root(value)
|
|
261
|
+
return unless value
|
|
262
|
+
|
|
263
|
+
path = value.to_s
|
|
264
|
+
if path.include?("\0") || !Pathname.new(path).absolute? || path.split(File::SEPARATOR).include?("..")
|
|
265
|
+
raise ArgumentError, "resource_root must be an absolute path"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
File.expand_path(path).freeze
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def agent_path(source_path, source_root)
|
|
272
|
+
return source_path unless @resource_root
|
|
273
|
+
|
|
274
|
+
relative = source_path.delete_prefix("#{source_root}#{File::SEPARATOR}")
|
|
275
|
+
File.join(@resource_root, relative)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def resource_path(skill, relative)
|
|
279
|
+
File.join(File.dirname(skill.path), relative)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
# Skills give agents focused instructions and supporting resources on demand.
|
|
5
|
+
module Skills
|
|
6
|
+
# Skill holds the metadata and instructions loaded from one +SKILL.md+ file.
|
|
7
|
+
# +path+ is shown to the model; +source_path+ is the local file used for
|
|
8
|
+
# boundary validation and resource discovery.
|
|
9
|
+
Skill = Data.define( # :nodoc:
|
|
10
|
+
:name,
|
|
11
|
+
:description,
|
|
12
|
+
:instructions,
|
|
13
|
+
:path,
|
|
14
|
+
:source_path,
|
|
15
|
+
:allowed_tools,
|
|
16
|
+
:compatibility
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Holds the metadata and instructions loaded from one +SKILL.md+ file.
|
|
20
|
+
# +path+ is shown to the model; +source_path+ is the local file used for
|
|
21
|
+
# boundary validation and resource discovery.
|
|
22
|
+
class Skill < Data # :doc:
|
|
23
|
+
##
|
|
24
|
+
# :singleton-method: new
|
|
25
|
+
# :call-seq:
|
|
26
|
+
# new(name:, description:, instructions:, path:, source_path:,
|
|
27
|
+
# allowed_tools:, compatibility:) -> Skill
|
|
28
|
+
#
|
|
29
|
+
# Collects one validated skill definition loaded by Skills::Catalog.
|
|
30
|
+
|
|
31
|
+
##
|
|
32
|
+
# :attr_reader: name
|
|
33
|
+
# The skill name declared in front matter.
|
|
34
|
+
|
|
35
|
+
##
|
|
36
|
+
# :attr_reader: description
|
|
37
|
+
# The short description used for model-visible discovery.
|
|
38
|
+
|
|
39
|
+
##
|
|
40
|
+
# :attr_reader: instructions
|
|
41
|
+
# The complete instructions loaded on activation.
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# :attr_reader: path
|
|
45
|
+
# The model-visible skill path.
|
|
46
|
+
|
|
47
|
+
##
|
|
48
|
+
# :attr_reader: source_path
|
|
49
|
+
# The trusted local source path used for boundary checks.
|
|
50
|
+
|
|
51
|
+
##
|
|
52
|
+
# :attr_reader: allowed_tools
|
|
53
|
+
# Informational tool names from front matter; this is not authorization.
|
|
54
|
+
|
|
55
|
+
##
|
|
56
|
+
# :attr_reader: compatibility
|
|
57
|
+
# Optional compatibility guidance from front matter.
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
# StreamEvent gives every provider and interface the same language for live
|
|
5
|
+
# agent output. Consumers can handle text, reasoning, tools, usage, retries,
|
|
6
|
+
# and completion without branching on a provider SDK.
|
|
7
|
+
#
|
|
8
|
+
# Providers emit events such as +:message_start+, +:text_delta+,
|
|
9
|
+
# +:reasoning_delta+, +:tool_call_start+, +:tool_call_delta+,
|
|
10
|
+
# +:tool_call_stop+, +:usage+, +:model_retry+, and +:message_stop+. The
|
|
11
|
+
# terminal event carries a {ModelResponse}[rdoc-ref:LittleGhost::ModelResponse]
|
|
12
|
+
# in +data[:response]+.
|
|
13
|
+
StreamEvent = Data.define(:type, :data) do # :nodoc:
|
|
14
|
+
# Creates an immutable event with a symbol +type+ and keyword payload.
|
|
15
|
+
def self.build(type, **data)
|
|
16
|
+
new(type: type.to_sym, data: data.freeze)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# StreamEvent gives every provider and interface the same language for live
|
|
21
|
+
# agent output. Consumers can handle text, reasoning, tools, usage, retries,
|
|
22
|
+
# and completion without branching on a provider SDK.
|
|
23
|
+
#
|
|
24
|
+
# Providers emit events such as +:message_start+, +:text_delta+,
|
|
25
|
+
# +:reasoning_delta+, +:tool_call_start+, +:tool_call_delta+,
|
|
26
|
+
# +:tool_call_stop+, +:usage+, +:model_retry+, and +:message_stop+. The
|
|
27
|
+
# terminal event carries a {ModelResponse}[rdoc-ref:LittleGhost::ModelResponse]
|
|
28
|
+
# in +data[:response]+.
|
|
29
|
+
#
|
|
30
|
+
# event = LittleGhost::StreamEvent.build(:text_delta, text: "Hello")
|
|
31
|
+
# event.type # => :text_delta
|
|
32
|
+
# event.data[:text] # => "Hello"
|
|
33
|
+
class StreamEvent < Data # :doc:
|
|
34
|
+
##
|
|
35
|
+
# :attr_reader: type
|
|
36
|
+
# The event kind, such as +:text_delta+, +:usage+, or +:message_stop+.
|
|
37
|
+
|
|
38
|
+
##
|
|
39
|
+
# :attr_reader: data
|
|
40
|
+
# The immutable payload for this event kind.
|
|
41
|
+
|
|
42
|
+
##
|
|
43
|
+
# :singleton-method: build
|
|
44
|
+
# :call-seq:
|
|
45
|
+
# build(type, **data) -> StreamEvent
|
|
46
|
+
#
|
|
47
|
+
# Creates an immutable event with a symbol +type+ and keyword payload.
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
module StructuredOutput # :nodoc: all
|
|
5
|
+
STRATEGIES = %i[auto provider tool].freeze
|
|
6
|
+
|
|
7
|
+
class Strategy
|
|
8
|
+
attr_reader :configuration
|
|
9
|
+
|
|
10
|
+
def initialize(configuration)
|
|
11
|
+
@configuration = configuration
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def schema_name = configuration.fetch(:name)
|
|
15
|
+
def provider? = false
|
|
16
|
+
def tool? = false
|
|
17
|
+
def tools(ordinary_tools) = ordinary_tools
|
|
18
|
+
def output_schema = nil
|
|
19
|
+
def tool_choice(repair:) = nil
|
|
20
|
+
def required_capabilities = [].freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class ProviderStrategy < Strategy
|
|
24
|
+
def provider? = true
|
|
25
|
+
def output_schema = configuration
|
|
26
|
+
def required_capabilities = [:native_structured_output].freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class ToolStrategy < Strategy
|
|
30
|
+
def tool? = true
|
|
31
|
+
|
|
32
|
+
def tools(ordinary_tools)
|
|
33
|
+
[*ordinary_tools, result_tool].freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def tool_choice(repair:)
|
|
37
|
+
repair ? {name: schema_name}.freeze : :required
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def required_capabilities = %i[tools tool_choice].freeze
|
|
41
|
+
|
|
42
|
+
def result_tool
|
|
43
|
+
{
|
|
44
|
+
name: schema_name,
|
|
45
|
+
description: configuration[:description] ||
|
|
46
|
+
"Submit the final structured result. Call this tool only as the final action.",
|
|
47
|
+
input_schema: configuration.fetch(:schema),
|
|
48
|
+
strict: true
|
|
49
|
+
}.freeze
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
module_function
|
|
54
|
+
|
|
55
|
+
def resolve(configuration, model:, ordinary_tools:)
|
|
56
|
+
return unless configuration
|
|
57
|
+
|
|
58
|
+
requested = configuration.fetch(:strategy, :auto).to_sym
|
|
59
|
+
capabilities = if model.respond_to?(:capabilities)
|
|
60
|
+
model.capabilities
|
|
61
|
+
else
|
|
62
|
+
ModelCapabilities.legacy
|
|
63
|
+
end
|
|
64
|
+
strategy = case requested
|
|
65
|
+
when :auto then automatic_strategy(model, capabilities, ordinary_tools)
|
|
66
|
+
when :provider
|
|
67
|
+
validate_explicit_provider!(model, capabilities)
|
|
68
|
+
ProviderStrategy
|
|
69
|
+
when :tool
|
|
70
|
+
validate_explicit_tool!(model, capabilities)
|
|
71
|
+
ToolStrategy
|
|
72
|
+
else
|
|
73
|
+
raise ConfigurationError, "Unknown structured output strategy: #{requested}"
|
|
74
|
+
end
|
|
75
|
+
resolved = strategy.new(configuration)
|
|
76
|
+
validate_tool_collision!(resolved, ordinary_tools)
|
|
77
|
+
resolved
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_tool_collision!(strategy, ordinary_tools)
|
|
81
|
+
return unless strategy.tool?
|
|
82
|
+
return unless ordinary_tools.any? { |tool| tool.fetch(:name).to_s == strategy.schema_name }
|
|
83
|
+
|
|
84
|
+
raise ConfigurationError, "Structured result tool name collides with an agent tool: #{strategy.schema_name}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def automatic_strategy(model, capabilities, ordinary_tools)
|
|
88
|
+
unless capabilities.known?
|
|
89
|
+
raise ConfigurationError,
|
|
90
|
+
"Structured output capabilities are unavailable for #{model_identity(model)}; " \
|
|
91
|
+
"provide model capability metadata or select a strategy explicitly"
|
|
92
|
+
end
|
|
93
|
+
if capabilities.native_structured_output? && (ordinary_tools.empty? || capabilities.tools?)
|
|
94
|
+
return ProviderStrategy
|
|
95
|
+
end
|
|
96
|
+
return ToolStrategy if capabilities.tools? && capabilities.tool_choice?
|
|
97
|
+
|
|
98
|
+
raise ConfigurationError,
|
|
99
|
+
"#{model_identity(model)} supports neither provider-native structured output nor reliable tool-based output"
|
|
100
|
+
end
|
|
101
|
+
private_class_method :automatic_strategy
|
|
102
|
+
|
|
103
|
+
def validate_explicit_provider!(model, capabilities)
|
|
104
|
+
return unless capabilities.known?
|
|
105
|
+
return if capabilities.native_structured_output?
|
|
106
|
+
|
|
107
|
+
raise ConfigurationError, "#{model_identity(model)} does not support provider-native structured output"
|
|
108
|
+
end
|
|
109
|
+
private_class_method :validate_explicit_provider!
|
|
110
|
+
|
|
111
|
+
def validate_explicit_tool!(model, capabilities)
|
|
112
|
+
return unless capabilities.known?
|
|
113
|
+
return if capabilities.tools? && capabilities.tool_choice?
|
|
114
|
+
|
|
115
|
+
raise ConfigurationError, "#{model_identity(model)} does not support reliable tool-based structured output"
|
|
116
|
+
end
|
|
117
|
+
private_class_method :validate_explicit_tool!
|
|
118
|
+
|
|
119
|
+
def model_identity(model)
|
|
120
|
+
provider = model.respond_to?(:provider_name) ? model.provider_name : model.class.name
|
|
121
|
+
id = model.respond_to?(:id) ? model.id : nil
|
|
122
|
+
[provider, id].compact.join("/")
|
|
123
|
+
end
|
|
124
|
+
private_class_method :model_identity
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
module Subagents
|
|
5
|
+
# AgentPath gives every delegated conversation a stable place beneath its
|
|
6
|
+
# parent. Paths begin at +/root+, keeping nested delegation visible in logs
|
|
7
|
+
# and metadata.
|
|
8
|
+
#
|
|
9
|
+
# Child task names contain only lowercase letters,
|
|
10
|
+
# digits, and underscores, are limited to 40 characters, and must be unique
|
|
11
|
+
# among siblings when reserved by a manager.
|
|
12
|
+
#
|
|
13
|
+
# AgentPath.join("/root", "review_api") # => "/root/review_api"
|
|
14
|
+
class AgentPath
|
|
15
|
+
ROOT = "/root" # :nodoc:
|
|
16
|
+
MAX_NAME_LENGTH = 40 # :nodoc:
|
|
17
|
+
MAX_PATH_LENGTH = 1024 # :nodoc:
|
|
18
|
+
NAME_PATTERN = /\A[a-z0-9_]+\z/ # :nodoc:
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Validates an absolute agent path and returns it unchanged.
|
|
22
|
+
def validate!(path)
|
|
23
|
+
value = String(path)
|
|
24
|
+
raise ArgumentError, "agent path must start with /root" unless value == ROOT || value.start_with?("#{ROOT}/")
|
|
25
|
+
raise ArgumentError, "agent path must not end with /" if value.end_with?("/")
|
|
26
|
+
raise ArgumentError, "agent path is too long" if value.length > MAX_PATH_LENGTH
|
|
27
|
+
|
|
28
|
+
segments = value.split("/").drop(2)
|
|
29
|
+
raise ArgumentError, "agent path must not contain empty segments" if segments.any?(&:empty?)
|
|
30
|
+
|
|
31
|
+
segments.each { |segment| validate_name!(segment) }
|
|
32
|
+
value
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Validates both parts and returns a direct child path.
|
|
36
|
+
def join(parent, name)
|
|
37
|
+
validate!("#{validate!(parent)}/#{validate_name!(name)}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Validates and returns one model-chosen task name.
|
|
41
|
+
def validate_name!(name)
|
|
42
|
+
value = String(name)
|
|
43
|
+
if value.length > MAX_NAME_LENGTH
|
|
44
|
+
raise ArgumentError, "task_name must be at most #{MAX_NAME_LENGTH} characters"
|
|
45
|
+
end
|
|
46
|
+
if value == "root" || !value.match?(NAME_PATTERN)
|
|
47
|
+
raise ArgumentError, "task_name must use lowercase letters, digits, and underscores"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
value
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Checks whether +path+ is exactly one level beneath +parent+.
|
|
54
|
+
def immediate_child?(path, parent)
|
|
55
|
+
value = validate!(path)
|
|
56
|
+
ancestor = validate!(parent)
|
|
57
|
+
value.start_with?("#{ancestor}/") &&
|
|
58
|
+
!value.delete_prefix("#{ancestor}/").include?("/")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|