little_ghost 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +22 -0
  3. data/README.md +122 -0
  4. data/docs/guides/Core Concepts.md +203 -0
  5. data/docs/guides/Getting Started.md +187 -0
  6. data/lib/little_ghost/ag_ui/adapter.rb +194 -0
  7. data/lib/little_ghost/ag_ui.rb +5 -0
  8. data/lib/little_ghost/agent/context_management.rb +285 -0
  9. data/lib/little_ghost/agent/delegation.rb +128 -0
  10. data/lib/little_ghost/agent/skills.rb +96 -0
  11. data/lib/little_ghost/agent/tool_loop.rb +239 -0
  12. data/lib/little_ghost/agent.rb +2111 -0
  13. data/lib/little_ghost/agent_builder.rb +191 -0
  14. data/lib/little_ghost/agent_interruptions.rb +197 -0
  15. data/lib/little_ghost/configuration.rb +337 -0
  16. data/lib/little_ghost/content.rb +324 -0
  17. data/lib/little_ghost/default_model_registry.rb +71 -0
  18. data/lib/little_ghost/errors.rb +48 -0
  19. data/lib/little_ghost/events.rb +264 -0
  20. data/lib/little_ghost/execution_state.rb +58 -0
  21. data/lib/little_ghost/instrumentation.rb +475 -0
  22. data/lib/little_ghost/invocation.rb +285 -0
  23. data/lib/little_ghost/lookup.rb +37 -0
  24. data/lib/little_ghost/mcp/client.rb +396 -0
  25. data/lib/little_ghost/mcp.rb +5 -0
  26. data/lib/little_ghost/message.rb +75 -0
  27. data/lib/little_ghost/model.rb +88 -0
  28. data/lib/little_ghost/model_capabilities.rb +126 -0
  29. data/lib/little_ghost/model_registry.rb +173 -0
  30. data/lib/little_ghost/model_request.rb +107 -0
  31. data/lib/little_ghost/model_response.rb +48 -0
  32. data/lib/little_ghost/path_set.rb +32 -0
  33. data/lib/little_ghost/prompt_resolver.rb +251 -0
  34. data/lib/little_ghost/providers/bedrock.rb +506 -0
  35. data/lib/little_ghost/providers/http_transport.rb +149 -0
  36. data/lib/little_ghost/providers/open_router.rb +171 -0
  37. data/lib/little_ghost/providers/openai.rb +27 -0
  38. data/lib/little_ghost/providers/openai_compatible.rb +745 -0
  39. data/lib/little_ghost/providers/sse_parser.rb +35 -0
  40. data/lib/little_ghost/run.rb +607 -0
  41. data/lib/little_ghost/run_context.rb +129 -0
  42. data/lib/little_ghost/run_result.rb +111 -0
  43. data/lib/little_ghost/runtime/hook.rb +31 -0
  44. data/lib/little_ghost/runtime.rb +392 -0
  45. data/lib/little_ghost/sandbox.rb +138 -0
  46. data/lib/little_ghost/session.rb +229 -0
  47. data/lib/little_ghost/session_store.rb +96 -0
  48. data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
  49. data/lib/little_ghost/session_stores/memory.rb +86 -0
  50. data/lib/little_ghost/skills/catalog.rb +283 -0
  51. data/lib/little_ghost/skills/skill.rb +60 -0
  52. data/lib/little_ghost/skills.rb +4 -0
  53. data/lib/little_ghost/stream_event.rb +49 -0
  54. data/lib/little_ghost/structured_output.rb +126 -0
  55. data/lib/little_ghost/subagents/agent_path.rb +63 -0
  56. data/lib/little_ghost/subagents/definition.rb +42 -0
  57. data/lib/little_ghost/subagents/manager.rb +1615 -0
  58. data/lib/little_ghost/support/callbacks.rb +151 -0
  59. data/lib/little_ghost/support/cancellation_token.rb +86 -0
  60. data/lib/little_ghost/support/class_attributes.rb +40 -0
  61. data/lib/little_ghost/support/content_capture.rb +150 -0
  62. data/lib/little_ghost/support/executor.rb +75 -0
  63. data/lib/little_ghost/support/interruptible_stream.rb +103 -0
  64. data/lib/little_ghost/support/loader.rb +263 -0
  65. data/lib/little_ghost/support/output_truncation.rb +71 -0
  66. data/lib/little_ghost/support/redactor.rb +66 -0
  67. data/lib/little_ghost/support.rb +34 -0
  68. data/lib/little_ghost/tool.rb +448 -0
  69. data/lib/little_ghost/tool_execution.rb +59 -0
  70. data/lib/little_ghost/tool_registry.rb +156 -0
  71. data/lib/little_ghost/tools/filesystem.rb +119 -0
  72. data/lib/little_ghost/tools/shell.rb +45 -0
  73. data/lib/little_ghost/tools/write_todos.rb +91 -0
  74. data/lib/little_ghost/tools.rb +6 -0
  75. data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
  76. data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
  77. data/lib/little_ghost/usage.rb +47 -0
  78. data/lib/little_ghost/version.rb +6 -0
  79. data/lib/little_ghost/workflow.rb +351 -0
  80. data/lib/little_ghost/workspace.rb +31 -0
  81. data/lib/little_ghost.rb +120 -0
  82. metadata +225 -0
@@ -0,0 +1,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,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "skills/skill"
4
+ require_relative "skills/catalog"
@@ -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