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,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ class Agent
5
+ # Let an agent discover application-authored instructions only when it needs them.
6
+ # Skills are file-backed guides advertised in the system prompt and loaded
7
+ # through a model-callable catalog tool.
8
+ #
9
+ # class CustomerSupportAgent < LittleGhost::Agent
10
+ # skills paths: [File.expand_path("../skills", __dir__)]
11
+ # end
12
+ #
13
+ # A run with a +refunds+ skill sees that skill in its discovery prompt. The
14
+ # model can then call the +skills+ tool to read the full guide before handling
15
+ # the refund request.
16
+ #
17
+ # Including this module alone has no effect. The +skills+ declaration installs
18
+ # the catalog tool and prompt callback; paths may be static or resolved for
19
+ # each run, and omitted paths use the runtime's configured skill roots. An
20
+ # empty catalog exposes neither a tool nor discovery text.
21
+ #
22
+ # Skill files influence model behavior and should come from application-owned,
23
+ # trusted roots. Catalog loading applies its own file, count, and size bounds.
24
+ module Skills
25
+ def self.included(base) # :nodoc:
26
+ base.extend(ClassMethods)
27
+ base.class_attribute :skills_configuration_value
28
+ end
29
+
30
+ class CatalogTools # :nodoc:
31
+ def self.tools(binding)
32
+ configuration = binding.agent.class.skills_configuration
33
+ paths = configuration.fetch(:paths)
34
+ if paths.is_a?(Proc)
35
+ paths = paths.parameters.empty? ? binding.agent.instance_exec(&paths) : paths.call(binding.run)
36
+ end
37
+ paths ||= binding.run.runtime.skill_paths
38
+ catalog = LittleGhost::Skills::Catalog.new(
39
+ **configuration.merge(paths:, resource_root: binding.run&.runtime&.skill_resource_root)
40
+ )
41
+ return [] if catalog.names.empty?
42
+
43
+ [catalog.tool.tap { |tool| tool.define_method(:catalog) { catalog } }]
44
+ end
45
+ end
46
+
47
+ # Exposes skill discovery declarations on agent classes.
48
+ # These methods become inheritable DSL entries when the capability is included.
49
+ module ClassMethods
50
+ # :call-seq:
51
+ # skills(*paths, **options) -> configuration
52
+ # skills(paths: paths_or_resolver, **options) -> configuration
53
+ #
54
+ # Enables skill discovery for this agent. +paths+ may be paths or a
55
+ # callable resolved for each run. When omitted, the runtime's configured
56
+ # skill paths are used.
57
+ #
58
+ def skills(*values, **options)
59
+ configured_paths = if options.key?(:paths)
60
+ options.delete(:paths)
61
+ elsif values.empty?
62
+ nil
63
+ else
64
+ values.flatten
65
+ end
66
+ self.skills_configuration_value = options.merge(paths: configured_paths)
67
+ tools CatalogTools
68
+ prompt_local(:skills_prompt) do
69
+ tool = tools.fetch("skills") if tools.names.include?("skills")
70
+ tool&.catalog&.discovery_prompt.to_s
71
+ end
72
+ before_invocation :include_skills_prompt
73
+ end
74
+
75
+ def skills_configuration = skills_configuration_value # :nodoc:
76
+ end
77
+
78
+ private
79
+
80
+ def include_skills_prompt(payload)
81
+ prompt = prompt_locals[:skills_prompt].to_s
82
+ return Support::Callbacks.continue if prompt.empty?
83
+
84
+ messages = payload.fetch(:messages).dup
85
+ index = messages.index { |message| message.role == :system }
86
+ return Support::Callbacks.continue unless index
87
+
88
+ message = messages.fetch(index)
89
+ content = message.content.dup
90
+ content << Content::Text.new(text: "\n\n#{prompt}")
91
+ messages[index] = Message.new(role: :system, content:, metadata: message.metadata)
92
+ Support::Callbacks.replace(payload.merge(messages:))
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module LittleGhost
7
+ class Agent
8
+ # Stop an agent from repeating a tool call that cannot make progress.
9
+ # Detection follows identical tool names, arguments, result status, and result
10
+ # content within one invocation.
11
+ #
12
+ # class CustomerSupportAgent < LittleGhost::Agent
13
+ # detect_tool_loops warning_at: 3, terminate_at: 5,
14
+ # except: AccountRefreshTool
15
+ # end
16
+ #
17
+ # If a support model makes the same ineffective lookup three times, its third
18
+ # result includes a warning to change approach. A fourth repeat carries the
19
+ # final warning, and a fifth stops the run with ToolLoopError.
20
+ #
21
+ # Detection is inactive until +detect_tool_loops+ is declared. Exclusions may
22
+ # be tool classes, instances, names, or symbols. State is isolated per active
23
+ # invocation and guarded for concurrent tool batches; unfinished subagent
24
+ # waits do not count as repeated progress failures.
25
+ #
26
+ # Only identical normalized arguments and results advance the counter. A
27
+ # changed result resets that sequence, while a terminating repeat prevents
28
+ # the duplicate tool body from running again.
29
+ module ToolLoop
30
+ WARNING = "Repeated tool call detected. Change the approach or arguments before calling this tool again." # :nodoc:
31
+ FINAL_WARNING = "Final repeated tool call warning. Calling this tool again with identical arguments and result will stop the run." # :nodoc:
32
+ TRACKED_INVOCATION_LIMIT = 1_000 # :nodoc:
33
+
34
+ def self.included(base) # :nodoc:
35
+ base.extend(ClassMethods)
36
+ base.class_attribute :tool_loop_configuration_value
37
+ end
38
+
39
+ # Exposes tool-loop detection declarations on agent classes.
40
+ # These methods become inheritable DSL entries when the capability is included.
41
+ module ClassMethods
42
+ # Enables repeated tool-call detection.
43
+ #
44
+ # +warning_at+ defaults to 3 identical calls and +terminate_at+ defaults
45
+ # to 5. +except+ accepts tool classes, instances, names, or symbols.
46
+ #
47
+ # Raises ArgumentError unless +warning_at+ is at least 2 and
48
+ # +terminate_at+ is greater than +warning_at+.
49
+ def detect_tool_loops(warning_at: 3, terminate_at: 5, except: [])
50
+ warning_at = Integer(warning_at)
51
+ terminate_at = Integer(terminate_at)
52
+ raise ArgumentError, "warning_at must be at least 2" if warning_at < 2
53
+ raise ArgumentError, "terminate_at must be greater than warning_at" if terminate_at <= warning_at
54
+
55
+ self.tool_loop_configuration_value = {
56
+ warning_at:,
57
+ terminate_at:,
58
+ except: Array(except).map { |tool| tool_name(tool) }
59
+ }
60
+ after_initialize :initialize_tool_loop
61
+ before_invocation :reset_tool_loop
62
+ after_invocation :clear_tool_loop
63
+ before_model :check_for_terminated_tool_loop
64
+ before_tool :track_tool_call
65
+ after_tool :detect_repeated_tool_call, prepend: true
66
+ end
67
+
68
+ def tool_loop_configuration = tool_loop_configuration_value # :nodoc:
69
+
70
+ private
71
+
72
+ def tool_name(tool)
73
+ return tool.class.tool_name if tool.is_a?(LittleGhost::Tool)
74
+ return tool.tool_name if tool.is_a?(Class) && tool <= LittleGhost::Tool
75
+
76
+ tool.to_s
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def reset_tool_loop(*, context: nil)
83
+ @tool_loop_mutex.synchronize do
84
+ if context
85
+ remember_tool_loop_state(context, new_tool_loop_state)
86
+ else
87
+ @tool_loop_fallback = new_tool_loop_state
88
+ end
89
+ end
90
+ Support::Callbacks.continue
91
+ end
92
+
93
+ def clear_tool_loop(*, context: nil)
94
+ @tool_loop_mutex.synchronize do
95
+ if context
96
+ @tool_loop_runs.delete(context)
97
+ else
98
+ @tool_loop_fallback = new_tool_loop_state
99
+ end
100
+ end
101
+ Support::Callbacks.continue
102
+ end
103
+
104
+ def check_for_terminated_tool_loop(*, context: nil)
105
+ message = @tool_loop_mutex.synchronize { tool_loop_state(context)[:termination] }
106
+ raise ToolLoopError, message if message
107
+
108
+ Support::Callbacks.continue
109
+ end
110
+
111
+ def track_tool_call(payload, context: nil)
112
+ tool_use = payload.fetch(:tool_use)
113
+ return Support::Callbacks.continue if @tool_loop_except.include?(tool_use.name)
114
+
115
+ key = [tool_use.name, tool_loop_digest(tool_use.input)]
116
+ termination, newly_terminated = @tool_loop_mutex.synchronize do
117
+ state = tool_loop_state(context)
118
+ repeat = state[:repeats][key]
119
+ newly_terminated = false
120
+ if repeat && repeat[:count] >= @tool_loop_terminate_at - 1
121
+ unless state[:termination]
122
+ state[:termination] = "Stopped after detecting a repeated tool-call loop in #{tool_use.name.inspect}."
123
+ newly_terminated = true
124
+ end
125
+ end
126
+ unless state[:termination]
127
+ advances = state[:inflight].values.none? { |call| call.fetch(:key) == key }
128
+ state[:inflight][tool_use.id] = {key:, advances:}
129
+ end
130
+ [state[:termination], newly_terminated]
131
+ end
132
+ if termination
133
+ if newly_terminated
134
+ Instrumentation.publish(
135
+ :tool_loop,
136
+ operation_id: payload[:operation_id],
137
+ parent_operation_id: payload[:parent_operation_id],
138
+ action: :terminate,
139
+ tool_name: tool_use.name
140
+ )
141
+ end
142
+ Support::Callbacks.cancel(termination)
143
+ else
144
+ Support::Callbacks.continue
145
+ end
146
+ end
147
+
148
+ def detect_repeated_tool_call(payload, context: nil)
149
+ tool_use = payload.fetch(:tool_use)
150
+ result = payload.fetch(:result)
151
+ count = @tool_loop_mutex.synchronize do
152
+ state = tool_loop_state(context)
153
+ call = state[:inflight].delete(tool_use.id)
154
+ next unless call&.fetch(:advances)
155
+ next if unfinished_subagent_wait?(tool_use, result)
156
+
157
+ result_digest = tool_loop_digest(status: result.status, content: result.content)
158
+ key = call.fetch(:key)
159
+ previous = state[:repeats][key]
160
+ count = (previous && previous[:result] == result_digest) ? previous[:count] + 1 : 1
161
+ state[:repeats][key] = {result: result_digest, count: count}
162
+ count
163
+ end
164
+ return Support::Callbacks.continue unless count == @tool_loop_warning_at || count == @tool_loop_terminate_at - 1
165
+
166
+ action = (count == @tool_loop_warning_at) ? :warn : :final_warning
167
+ warning = (action == :warn) ? WARNING : FINAL_WARNING
168
+ Instrumentation.publish(
169
+ :tool_loop,
170
+ operation_id: payload[:operation_id],
171
+ parent_operation_id: payload[:parent_operation_id],
172
+ action: action,
173
+ tool_name: tool_use.name,
174
+ count: count
175
+ )
176
+ replacement = Tool::ExecutionResult.new(
177
+ content: "#{warning}\n\n#{result.content}",
178
+ status: result.status,
179
+ error: result.error
180
+ )
181
+ Support::Callbacks.replace(payload.merge(result: replacement))
182
+ end
183
+
184
+ def tool_loop_state(context)
185
+ return @tool_loop_fallback unless context
186
+
187
+ state = @tool_loop_runs[context] || new_tool_loop_state
188
+ remember_tool_loop_state(context, state)
189
+ state
190
+ end
191
+
192
+ def remember_tool_loop_state(context, state)
193
+ @tool_loop_runs.delete(context)
194
+ @tool_loop_runs[context] = state
195
+ @tool_loop_runs.shift while @tool_loop_runs.length > TRACKED_INVOCATION_LIMIT
196
+ end
197
+
198
+ def initialize_tool_loop
199
+ configuration = self.class.tool_loop_configuration
200
+ @tool_loop_warning_at = configuration.fetch(:warning_at)
201
+ @tool_loop_terminate_at = configuration.fetch(:terminate_at)
202
+ @tool_loop_except = configuration.fetch(:except)
203
+ @tool_loop_mutex = Mutex.new
204
+ @tool_loop_runs = {}
205
+ @tool_loop_fallback = new_tool_loop_state
206
+ end
207
+
208
+ def new_tool_loop_state
209
+ {repeats: {}, inflight: {}, termination: nil}
210
+ end
211
+
212
+ def unfinished_subagent_wait?(tool_use, result)
213
+ return false unless tool_use.name == "wait_for_subagents"
214
+
215
+ JSON.parse(result.content.to_s)["status"] == "still_working"
216
+ rescue JSON::ParserError, TypeError
217
+ false
218
+ end
219
+
220
+ def tool_loop_digest(value)
221
+ Digest::SHA256.hexdigest(JSON.generate(canonical_tool_loop_value(value)))
222
+ end
223
+
224
+ def canonical_tool_loop_value(value)
225
+ case value
226
+ when Hash
227
+ value.keys.map(&:to_s).sort.to_h do |key|
228
+ child = value.key?(key) ? value[key] : value[key.to_sym]
229
+ [key, canonical_tool_loop_value(child)]
230
+ end
231
+ when Array
232
+ value.map { |item| canonical_tool_loop_value(item) }
233
+ else
234
+ value
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end