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,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
|