little_ghost 0.4.0 → 0.5.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 +4 -4
- data/README.md +28 -6
- data/docs/guides/assemblies.md +143 -17
- data/docs/guides/code_mode.md +275 -0
- data/docs/guides/core_concepts.md +46 -30
- data/docs/guides/getting_started.md +17 -6
- data/docs/guides/integrations.md +217 -0
- data/docs/guides/models_and_providers.md +125 -0
- data/docs/guides/production.md +81 -15
- data/docs/guides/prompt_views.md +14 -7
- data/docs/guides/sandboxing.md +282 -0
- data/docs/guides/skills.md +141 -0
- data/docs/guides/structured_outputs_and_content.md +135 -0
- data/docs/guides/tools.md +329 -0
- data/lib/little_ghost/ag_ui/adapter.rb +2 -2
- data/lib/little_ghost/agent/delegation.rb +2 -2
- data/lib/little_ghost/agent/skills.rb +6 -1
- data/lib/little_ghost/agent/tool_loop.rb +6 -2
- data/lib/little_ghost/agent.rb +248 -46
- data/lib/little_ghost/agent_builder.rb +61 -22
- data/lib/little_ghost/agent_stream_source.rb +262 -0
- data/lib/little_ghost/artifact.rb +182 -0
- data/lib/little_ghost/artifacts/presentation_budget.rb +44 -0
- data/lib/little_ghost/artifacts/workspace_store.rb +370 -0
- data/lib/little_ghost/artifacts.rb +11 -0
- data/lib/little_ghost/assembly.rb +63 -17
- data/lib/little_ghost/assembly_builder.rb +26 -26
- data/lib/little_ghost/assembly_execution.rb +23 -2
- data/lib/little_ghost/code_mode/broker.rb +164 -0
- data/lib/little_ghost/code_mode/catalog.rb +54 -0
- data/lib/little_ghost/code_mode/engine.rb +58 -0
- data/lib/little_ghost/code_mode/javascript/catalog.rb +118 -0
- data/lib/little_ghost/code_mode/javascript/client.rb +550 -0
- data/lib/little_ghost/code_mode/javascript/host.rb +347 -0
- data/lib/little_ghost/code_mode/javascript/session.rb +579 -0
- data/lib/little_ghost/code_mode/javascript_engine.rb +172 -0
- data/lib/little_ghost/code_mode/protocol.rb +78 -0
- data/lib/little_ghost/code_mode/ruby/catalog.rb +80 -0
- data/lib/little_ghost/code_mode/ruby/host.rb +102 -0
- data/lib/little_ghost/code_mode/ruby/session.rb +508 -0
- data/lib/little_ghost/code_mode/ruby_engine.rb +86 -0
- data/lib/little_ghost/code_mode/runtime.rb +335 -0
- data/lib/little_ghost/code_mode/session.rb +61 -0
- data/lib/little_ghost/code_mode/types.rb +58 -0
- data/lib/little_ghost/code_mode.rb +53 -0
- data/lib/little_ghost/configuration.rb +107 -12
- data/lib/little_ghost/content.rb +19 -8
- data/lib/little_ghost/data_map.rb +2 -2
- data/lib/little_ghost/errors.rb +26 -7
- data/lib/little_ghost/execution.rb +1 -2
- data/lib/little_ghost/graph.rb +367 -207
- data/lib/little_ghost/mcp/client.rb +487 -88
- data/lib/little_ghost/mcp/toolset.rb +210 -0
- data/lib/little_ghost/mcp/types.rb +216 -0
- data/lib/little_ghost/mcp.rb +3 -0
- data/lib/little_ghost/model_capabilities.rb +9 -6
- data/lib/little_ghost/model_request.rb +0 -12
- data/lib/little_ghost/model_resolver.rb +13 -3
- data/lib/little_ghost/model_response.rb +3 -7
- data/lib/little_ghost/network/authorizer_server.rb +162 -0
- data/lib/little_ghost/network/certificate_authority.rb +98 -0
- data/lib/little_ghost/network/envoy_config.rb +362 -0
- data/lib/little_ghost/network/envoy_gateway.rb +409 -0
- data/lib/little_ghost/network/external_gateway.rb +68 -0
- data/lib/little_ghost/network.rb +96 -0
- data/lib/little_ghost/prompt_resolver.rb +7 -7
- data/lib/little_ghost/provider_registry.rb +3 -3
- data/lib/little_ghost/providers/anthropic.rb +8 -1
- data/lib/little_ghost/providers/gemini.rb +10 -1
- data/lib/little_ghost/providers/vertex_ai.rb +6 -1
- data/lib/little_ghost/run.rb +83 -13
- data/lib/little_ghost/run_context.rb +9 -0
- data/lib/little_ghost/run_result.rb +0 -7
- data/lib/little_ghost/runtime/hook.rb +5 -0
- data/lib/little_ghost/runtime/hooks/artifacts.rb +338 -0
- data/lib/little_ghost/runtime.rb +102 -34
- data/lib/little_ghost/sandbox/capabilities.rb +68 -0
- data/lib/little_ghost/sandbox/environment_policy.rb +41 -0
- data/lib/little_ghost/sandbox/filesystem.rb +377 -0
- data/lib/little_ghost/sandbox/isolated_backend.rb +163 -0
- data/lib/little_ghost/sandbox/limits.rb +48 -0
- data/lib/little_ghost/sandbox/mount.rb +129 -0
- data/lib/little_ghost/sandbox/network_policy.rb +108 -0
- data/lib/little_ghost/sandbox/policy.rb +88 -0
- data/lib/little_ghost/sandbox/process_runner.rb +103 -0
- data/lib/little_ghost/sandbox/process_session.rb +335 -0
- data/lib/little_ghost/sandbox/scope.rb +304 -0
- data/lib/little_ghost/sandbox.rb +158 -32
- data/lib/little_ghost/sandboxes/bubblewrap.rb +351 -0
- data/lib/little_ghost/sandboxes/native.rb +51 -0
- data/lib/little_ghost/sandboxes/seatbelt.rb +261 -0
- data/lib/little_ghost/sandboxes/unrestricted.rb +241 -0
- data/lib/little_ghost/session.rb +30 -6
- data/lib/little_ghost/skills/catalog.rb +94 -14
- data/lib/little_ghost/skills/resource_root.rb +42 -0
- data/lib/little_ghost/skills/skill.rb +0 -3
- data/lib/little_ghost/stream_event.rb +8 -13
- data/lib/little_ghost/subagents/control_tool.rb +8 -0
- data/lib/little_ghost/subagents/manager.rb +12 -9
- data/lib/little_ghost/support/callbacks.rb +3 -1
- data/lib/little_ghost/support/content_capture.rb +3 -3
- data/lib/little_ghost/support/http_client.rb +2 -2
- data/lib/little_ghost/support/redactor.rb +1 -1
- data/lib/little_ghost/tool.rb +104 -53
- data/lib/little_ghost/tool_registry.rb +1 -1
- data/lib/little_ghost/tools/filesystem.rb +1 -1
- data/lib/little_ghost/tools/shell.rb +4 -3
- data/lib/little_ghost/version.rb +1 -1
- data/lib/little_ghost/workspace.rb +222 -8
- data/lib/little_ghost.rb +21 -12
- metadata +98 -2
- data/lib/little_ghost/unrestricted_sandbox.rb +0 -306
|
@@ -12,11 +12,11 @@ module LittleGhost
|
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
#
|
|
15
|
+
# A complete Assembly configuration produced by an AssemblyBuilder.
|
|
16
16
|
#
|
|
17
17
|
# Runtime and other builders accept a definition anywhere they accept an
|
|
18
|
-
# Assembly reference. +implementation+ is the
|
|
19
|
-
# this
|
|
18
|
+
# Assembly reference. +implementation+ is the Assembly subclass used for
|
|
19
|
+
# executions built from this definition.
|
|
20
20
|
class AssemblyDefinition < Data # :doc:
|
|
21
21
|
##
|
|
22
22
|
# :attr_reader: kind
|
|
@@ -24,15 +24,15 @@ module LittleGhost
|
|
|
24
24
|
|
|
25
25
|
##
|
|
26
26
|
# :attr_reader: assembly_id
|
|
27
|
-
# The
|
|
27
|
+
# The Assembly identifier at the time the definition was built.
|
|
28
28
|
|
|
29
29
|
##
|
|
30
30
|
# :attr_reader: description
|
|
31
|
-
# The human-readable description
|
|
31
|
+
# The human-readable description at the time the definition was built.
|
|
32
32
|
|
|
33
33
|
##
|
|
34
34
|
# :attr_reader: implementation
|
|
35
|
-
# The
|
|
35
|
+
# The Assembly subclass used to build executions.
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
# Builds an Assembly when its participants or routes are discovered at runtime.
|
|
@@ -51,10 +51,10 @@ module LittleGhost
|
|
|
51
51
|
#
|
|
52
52
|
# run = graph.ask("Can I get a refund?")
|
|
53
53
|
#
|
|
54
|
-
# A builder remains mutable. Each build or invocation
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
# objects they reference remain live
|
|
54
|
+
# A builder remains mutable. Each build or invocation copies its declarations
|
|
55
|
+
# and referenced Assembly definitions. Later builder changes affect future
|
|
56
|
+
# executions without changing an Assembly already built. Ruby closures and
|
|
57
|
+
# the objects they reference remain live application code.
|
|
58
58
|
class AssemblyBuilder
|
|
59
59
|
# Optional Runtime reused by standalone executions from this builder.
|
|
60
60
|
attr_reader :runtime
|
|
@@ -87,13 +87,13 @@ module LittleGhost
|
|
|
87
87
|
# Returns +:agent+, +:workflow+, +:swarm+, or +:graph+.
|
|
88
88
|
def assembly_kind = self.class.assembly_kind
|
|
89
89
|
|
|
90
|
-
# Validates the current
|
|
90
|
+
# Validates the builder's current declarations and returns this builder.
|
|
91
91
|
def validate!
|
|
92
92
|
definition
|
|
93
93
|
self
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
-
# Returns
|
|
96
|
+
# Returns a complete definition for the builder's current declarations.
|
|
97
97
|
def definition
|
|
98
98
|
stack = Thread.current[:little_ghost_assembly_definition_stack] ||= []
|
|
99
99
|
identity = base || self
|
|
@@ -118,7 +118,7 @@ module LittleGhost
|
|
|
118
118
|
Thread.current[:little_ghost_assembly_definition_stack] = nil if stack && stack.empty?
|
|
119
119
|
end
|
|
120
120
|
|
|
121
|
-
# Builds one execution instance from the current
|
|
121
|
+
# Builds one execution instance from the current declarations.
|
|
122
122
|
def build(runtime: self.runtime, run: nil)
|
|
123
123
|
snapshot = definition
|
|
124
124
|
return (runtime || run.runtime).build_assembly(snapshot, run:) if run
|
|
@@ -126,10 +126,10 @@ module LittleGhost
|
|
|
126
126
|
snapshot.implementation.new(runtime:)
|
|
127
127
|
end
|
|
128
128
|
|
|
129
|
-
# Executes
|
|
129
|
+
# Executes the current declarations and returns their Run.
|
|
130
130
|
def ask(message, **options) = build.ask(message, **options)
|
|
131
131
|
|
|
132
|
-
# Lazily streams
|
|
132
|
+
# Lazily streams an execution built from the current declarations.
|
|
133
133
|
def stream_ask(message, **options)
|
|
134
134
|
snapshot = definition
|
|
135
135
|
Enumerator.new do |events|
|
|
@@ -137,13 +137,13 @@ module LittleGhost
|
|
|
137
137
|
end
|
|
138
138
|
end
|
|
139
139
|
|
|
140
|
-
# Executes
|
|
140
|
+
# Executes the current declarations to completion.
|
|
141
141
|
def call(input = nil, **options) = build.call(input, **options)
|
|
142
|
-
# Streams
|
|
142
|
+
# Streams the current declarations as StreamEvent objects.
|
|
143
143
|
def stream(input = nil, **options) = build.stream(input, **options)
|
|
144
|
-
# Starts a supervised execution from
|
|
144
|
+
# Starts a supervised execution from the current declarations.
|
|
145
145
|
def start_execution(payload, &block) = build.start_execution(payload, &block)
|
|
146
|
-
# Exposes
|
|
146
|
+
# Exposes the current declarations as a Tool.
|
|
147
147
|
def as_tool(**options) = build.as_tool(**options)
|
|
148
148
|
|
|
149
149
|
protected
|
|
@@ -258,8 +258,8 @@ module LittleGhost
|
|
|
258
258
|
# agent.system_prompt "Answer customer questions clearly."
|
|
259
259
|
# agent.ask("Where is my order?")
|
|
260
260
|
#
|
|
261
|
-
#
|
|
262
|
-
#
|
|
261
|
+
# It accepts the same configuration calls as an Agent class. Changing the
|
|
262
|
+
# builder affects future builds without changing Agents already built.
|
|
263
263
|
class AgentBuilder < AssemblyBuilder
|
|
264
264
|
DECLARATIONS = %i[
|
|
265
265
|
model limits result_schema capture_diagnostics system_template system_prompt
|
|
@@ -286,7 +286,7 @@ module LittleGhost
|
|
|
286
286
|
value.nil? ? assembly_id : assembly_id(value)
|
|
287
287
|
end
|
|
288
288
|
|
|
289
|
-
#
|
|
289
|
+
# Applies a supported Agent configuration call to future builds.
|
|
290
290
|
def method_missing(name, *arguments, **keywords, &block)
|
|
291
291
|
return super unless DECLARATIONS.include?(name)
|
|
292
292
|
|
|
@@ -338,7 +338,7 @@ module LittleGhost
|
|
|
338
338
|
# Builds a Workflow whose Ruby composition block is supplied at runtime.
|
|
339
339
|
# Use a named Workflow subclass's +to_builder+ when the class supplies the
|
|
340
340
|
# composition and runtime configuration supplies its identity or description.
|
|
341
|
-
# +perform+ supplies the underlying dynamic form for
|
|
341
|
+
# +perform+ supplies the underlying dynamic form for application code.
|
|
342
342
|
class WorkflowBuilder < AssemblyBuilder
|
|
343
343
|
class << self
|
|
344
344
|
# :nodoc:
|
|
@@ -458,19 +458,19 @@ module LittleGhost
|
|
|
458
458
|
@declarations = []
|
|
459
459
|
end
|
|
460
460
|
|
|
461
|
-
%i[node start edge finish max_steps
|
|
461
|
+
%i[node start edge finish max_steps max_concurrency error_edge].each do |name|
|
|
462
462
|
define_method(name) do |*arguments, **keywords, &block|
|
|
463
463
|
@mutex.synchronize { @declarations << [name, arguments, keywords, block] }
|
|
464
464
|
self
|
|
465
465
|
end
|
|
466
466
|
end
|
|
467
467
|
|
|
468
|
-
#
|
|
468
|
+
# Validates the current declarations and renders them as Mermaid flowchart text.
|
|
469
469
|
def to_mermaid = definition.implementation.to_mermaid
|
|
470
470
|
|
|
471
471
|
protected
|
|
472
472
|
|
|
473
|
-
def snapshot_mutators = super + %i[node start edge finish max_steps
|
|
473
|
+
def snapshot_mutators = super + %i[node start edge finish max_steps max_concurrency error_edge]
|
|
474
474
|
|
|
475
475
|
def snapshot_state = super.merge(declarations: @declarations.map { |value| value.dup.freeze }.freeze)
|
|
476
476
|
|
|
@@ -193,14 +193,23 @@ module LittleGhost
|
|
|
193
193
|
policy = normalize_step_policy(policies)
|
|
194
194
|
attempts = []
|
|
195
195
|
usage = Usage.new
|
|
196
|
+
stream_path = agent_stream_path + [
|
|
197
|
+
AgentStreamStep.build(
|
|
198
|
+
assembly_id: self.class.assembly_id,
|
|
199
|
+
assembly_kind: self.class.assembly_kind,
|
|
200
|
+
participant:,
|
|
201
|
+
step_id:,
|
|
202
|
+
branch_id:
|
|
203
|
+
)
|
|
204
|
+
]
|
|
196
205
|
|
|
197
206
|
(policy.fetch(:retries) + 1).times do |index|
|
|
198
207
|
attempt_number = index + 1
|
|
199
208
|
started_at = Time.now
|
|
200
209
|
child = if build_options.any? && runtime.respond_to?(:build_agent)
|
|
201
|
-
|
|
210
|
+
build_stream_participant(:build_agent, reference, stream_path, **build_options)
|
|
202
211
|
else
|
|
203
|
-
|
|
212
|
+
build_stream_participant(:build_assembly, reference, stream_path, **build_options)
|
|
204
213
|
end
|
|
205
214
|
child_deadline = step_deadline(deadline, policy[:timeout])
|
|
206
215
|
attempt_events = []
|
|
@@ -329,6 +338,18 @@ module LittleGhost
|
|
|
329
338
|
end
|
|
330
339
|
end
|
|
331
340
|
|
|
341
|
+
def build_stream_participant(builder_name, reference, stream_path, **options)
|
|
342
|
+
builder = runtime.method(builder_name)
|
|
343
|
+
parameters = builder.parameters
|
|
344
|
+
accepts_path = parameters.any? do |kind, name|
|
|
345
|
+
kind == :keyrest || (%i[key keyreq].include?(kind) && name == :agent_stream_path)
|
|
346
|
+
end
|
|
347
|
+
options[:agent_stream_path] = stream_path if accepts_path
|
|
348
|
+
child = builder.call(reference, run:, **options)
|
|
349
|
+
child.bind_agent_stream_path(stream_path) if child.respond_to?(:bind_agent_stream_path)
|
|
350
|
+
child
|
|
351
|
+
end
|
|
352
|
+
|
|
332
353
|
def normalize_step_policy(values)
|
|
333
354
|
self.class.validate_step_policy!(values)
|
|
334
355
|
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module LittleGhost
|
|
7
|
+
module CodeMode
|
|
8
|
+
# Keeps tool authorization and execution in the trusted parent process.
|
|
9
|
+
class Broker
|
|
10
|
+
# Builds a broker for an Agent's Tool registry. +except+ removes names from
|
|
11
|
+
# the code-mode catalog. +dispatch+ is a trusted adapter hook used by
|
|
12
|
+
# custom hosts; ordinary Agent integrations should let the broker dispatch
|
|
13
|
+
# through +agent+.
|
|
14
|
+
def initialize(agent: nil, registry: nil, context: RunContext.new, events: [],
|
|
15
|
+
parent_operation_id: nil, parent_trace_context: nil, except: nil, dispatch: nil, max_calls: nil)
|
|
16
|
+
@agent = agent
|
|
17
|
+
@registry = registry || agent&.tool_registry
|
|
18
|
+
@context = context
|
|
19
|
+
@events = events
|
|
20
|
+
@parent_operation_id = parent_operation_id
|
|
21
|
+
@parent_trace_context = parent_trace_context
|
|
22
|
+
@except = Array(except).map(&:to_s).freeze
|
|
23
|
+
@dispatch = dispatch
|
|
24
|
+
@max_calls = max_calls && Integer(max_calls)
|
|
25
|
+
@call_count = 0
|
|
26
|
+
@call_mutex = Mutex.new
|
|
27
|
+
@delivery_mutex = Mutex.new
|
|
28
|
+
@presentation_content = []
|
|
29
|
+
@artifacts = []
|
|
30
|
+
@fallback_references = []
|
|
31
|
+
@presentation_budget = Artifacts::PresentationBudget.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns the Tool specifications available to model-authored code.
|
|
35
|
+
# Excluded Tools, code-mode controls, and subagent controls stay outside
|
|
36
|
+
# this catalog.
|
|
37
|
+
def catalog
|
|
38
|
+
specifications = @registry&.specifications || []
|
|
39
|
+
specifications.reject do |specification|
|
|
40
|
+
name = specification.fetch(:name, specification["name"]).to_s
|
|
41
|
+
tool = @registry.fetch(name)
|
|
42
|
+
@except.include?(name) || %w[exec wait stop].include?(name) || subagent_control?(tool)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
attr_writer :context # :nodoc:
|
|
47
|
+
|
|
48
|
+
def bind(context:, events:, parent_operation_id:, parent_trace_context: nil) # :nodoc:
|
|
49
|
+
@context = context
|
|
50
|
+
@events = events
|
|
51
|
+
@parent_operation_id = parent_operation_id
|
|
52
|
+
@parent_trace_context = parent_trace_context
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Calls an available Tool by model-visible +name+. The returned CallResult
|
|
57
|
+
# has +id+, decoded +value+, and model-safe +error+ fields. +arguments+
|
|
58
|
+
# remains untrusted until the ordinary Tool path validates and authorizes
|
|
59
|
+
# it.
|
|
60
|
+
def call(name, arguments = {}, id: SecureRandom.uuid)
|
|
61
|
+
@call_mutex.synchronize do
|
|
62
|
+
@call_count += 1
|
|
63
|
+
raise ToolError, "Code-mode tool call budget exceeded" if @max_calls && @call_count > @max_calls
|
|
64
|
+
end
|
|
65
|
+
name = String(name).delete_prefix("tools.")
|
|
66
|
+
available = catalog.any? { |specification| specification.fetch(:name, specification["name"]).to_s == name }
|
|
67
|
+
raise ToolError, "Tool is not available in code mode: #{name}" unless available
|
|
68
|
+
raise ToolError, "Tool arguments must be an object" unless arguments.is_a?(Hash)
|
|
69
|
+
|
|
70
|
+
execution_result = nil
|
|
71
|
+
result = if @dispatch
|
|
72
|
+
dispatched = @dispatch.call(Call.new(id:, name:, arguments:))
|
|
73
|
+
if dispatched.respond_to?(:presentation_content) && dispatched.respond_to?(:artifacts)
|
|
74
|
+
record_delivery(dispatched.presentation_content, dispatched.artifacts)
|
|
75
|
+
end
|
|
76
|
+
dispatched
|
|
77
|
+
elsif @agent
|
|
78
|
+
use = Content::ToolUse.new(id:, name:, input: arguments)
|
|
79
|
+
emit_brokered_tool_call(use)
|
|
80
|
+
executed = @agent.dispatch_tools(
|
|
81
|
+
[use],
|
|
82
|
+
context: @context,
|
|
83
|
+
events: @events,
|
|
84
|
+
parent_operation_id: @parent_operation_id,
|
|
85
|
+
parent_trace_context: @parent_trace_context
|
|
86
|
+
).first
|
|
87
|
+
record_delivery(executed.presentation_content, executed.artifacts)
|
|
88
|
+
execution_result = executed.execution_result
|
|
89
|
+
else
|
|
90
|
+
tool = @registry.fetch(name)
|
|
91
|
+
execution = tool.execute(arguments, context: @context)
|
|
92
|
+
record_delivery(execution.presentation_content, execution.artifacts)
|
|
93
|
+
return CallResult.new(id:, value: execution.value, error: execution.error? ? execution.content : nil)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
return result if result.is_a?(CallResult)
|
|
97
|
+
|
|
98
|
+
execution_result ||= result
|
|
99
|
+
content = execution_result.respond_to?(:content) ? execution_result.content : execution_result
|
|
100
|
+
value = execution_result.respond_to?(:value) ? execution_result.value : decode(content)
|
|
101
|
+
status = execution_result.respond_to?(:status) ? execution_result.status : :success
|
|
102
|
+
CallResult.new(id:, value:, error: (content if status == :error))
|
|
103
|
+
rescue ToolError => error
|
|
104
|
+
CallResult.new(id:, value: nil, error: error.message)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def drain_delivery # :nodoc:
|
|
108
|
+
@delivery_mutex.synchronize do
|
|
109
|
+
delivery = [@presentation_content.freeze, @artifacts.freeze, @fallback_references.uniq.freeze]
|
|
110
|
+
@presentation_content = []
|
|
111
|
+
@artifacts = []
|
|
112
|
+
@fallback_references = []
|
|
113
|
+
@presentation_budget = Artifacts::PresentationBudget.new
|
|
114
|
+
delivery
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def subagent_control?(tool)
|
|
121
|
+
defined?(Subagents::ControlTool) && tool.is_a?(Subagents::ControlTool)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def emit_brokered_tool_call(tool_use)
|
|
125
|
+
return unless @events.respond_to?(:<<)
|
|
126
|
+
|
|
127
|
+
@events << StreamEvent.build(:tool_call_start, index: 0, id: tool_use.id, name: tool_use.name)
|
|
128
|
+
@events << StreamEvent.build(:tool_call_delta, index: 0, arguments: JSON.generate(tool_use.input))
|
|
129
|
+
@events << StreamEvent.build(:tool_call_stop, index: 0, tool_use:)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def record_delivery(presentations, artifacts)
|
|
133
|
+
return if presentations.empty? && artifacts.empty?
|
|
134
|
+
|
|
135
|
+
@delivery_mutex.synchronize do
|
|
136
|
+
accepted = @presentation_budget.accept(presentations)
|
|
137
|
+
@presentation_content.concat(accepted)
|
|
138
|
+
@artifacts.concat(artifacts)
|
|
139
|
+
if accepted.empty? && artifacts.any?
|
|
140
|
+
@fallback_references.concat(artifact_references(artifacts))
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def artifact_references(artifacts)
|
|
146
|
+
artifacts.filter_map do |artifact|
|
|
147
|
+
next if artifact.metadata[:complete_result] || artifact.metadata["complete_result"]
|
|
148
|
+
next unless artifact.reference.is_a?(String) && artifact.bytes
|
|
149
|
+
|
|
150
|
+
details = [artifact.media_type, "#{artifact.bytes} bytes"].compact.join(", ")
|
|
151
|
+
"- #{artifact.reference} (#{details})"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def decode(value)
|
|
156
|
+
return value unless value.is_a?(String)
|
|
157
|
+
|
|
158
|
+
JSON.parse(value)
|
|
159
|
+
rescue JSON::ParserError
|
|
160
|
+
value
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
module CodeMode
|
|
5
|
+
# Normalizes a trusted Tool catalog for a guest language and rejects names
|
|
6
|
+
# that would collide or shadow code-mode controls.
|
|
7
|
+
class Catalog
|
|
8
|
+
# Frozen normalized Tool definitions in declaration order.
|
|
9
|
+
attr_reader :definitions
|
|
10
|
+
|
|
11
|
+
# Normalizes trusted Tool +specifications+ with +normalize+. Names in
|
|
12
|
+
# +reserved+ and names that collide after normalization are rejected.
|
|
13
|
+
def initialize(specifications, normalize:, reserved: %w[exec wait stop])
|
|
14
|
+
aliases = {}
|
|
15
|
+
@definitions = Array(specifications).map do |specification|
|
|
16
|
+
canonical_name = value(specification, :name).to_s
|
|
17
|
+
name = normalize.call(canonical_name).to_s
|
|
18
|
+
if reserved.include?(name)
|
|
19
|
+
raise ConfigurationError, "Code-mode tool name #{canonical_name.inspect} conflicts with reserved tool #{name.inspect}"
|
|
20
|
+
end
|
|
21
|
+
if aliases.key?(name)
|
|
22
|
+
raise ConfigurationError,
|
|
23
|
+
"Code-mode tool names #{aliases.fetch(name).inspect} and #{canonical_name.inspect} both normalize to #{name.inspect}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
aliases[name] = canonical_name
|
|
27
|
+
{
|
|
28
|
+
"name" => name.freeze,
|
|
29
|
+
"canonical_name" => canonical_name.freeze,
|
|
30
|
+
"description" => value(specification, :description, "").to_s.freeze,
|
|
31
|
+
"input_schema" => value(specification, :input_schema, {}).freeze,
|
|
32
|
+
"specification" => specification
|
|
33
|
+
}.freeze
|
|
34
|
+
end.freeze
|
|
35
|
+
@by_name = @definitions.to_h { |definition| [definition.fetch("name"), definition] }.freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns the normalized definition for +name+.
|
|
39
|
+
def fetch(name) = @by_name.fetch(name.to_s)
|
|
40
|
+
# Indicates whether +name+ is present in the catalog.
|
|
41
|
+
def key?(name) = @by_name.key?(name.to_s)
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def value(specification, key, default = nil)
|
|
46
|
+
return specification.fetch(key) if specification.key?(key)
|
|
47
|
+
return specification.fetch(key.to_s) if specification.key?(key.to_s)
|
|
48
|
+
return default unless default.nil?
|
|
49
|
+
|
|
50
|
+
raise KeyError, "key not found: #{key.inspect}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
module CodeMode
|
|
5
|
+
# Adapter contract for a code-mode language.
|
|
6
|
+
#
|
|
7
|
+
# An Engine describes the language shown to the model and opens one Session.
|
|
8
|
+
# Model-authored code must run behind the supplied +sandbox_factory+. The
|
|
9
|
+
# Broker must remain in the trusted parent process; do not expose it or its
|
|
10
|
+
# application objects inside the child interpreter.
|
|
11
|
+
#
|
|
12
|
+
# LittleGhost may call one registered Engine instance concurrently for
|
|
13
|
+
# different Agents and RunContexts. Keep mutable program state in the
|
|
14
|
+
# Session returned by #open_session, not on the Engine.
|
|
15
|
+
class Engine
|
|
16
|
+
# Returns the engine's language identifier as a Symbol.
|
|
17
|
+
def language = raise(AbstractMethodError, "engine must report its language")
|
|
18
|
+
|
|
19
|
+
# Returns model-facing instructions for the supplied Tool Catalog. This
|
|
20
|
+
# method may be called concurrently. Instructions must describe only names
|
|
21
|
+
# and behavior the Session actually implements.
|
|
22
|
+
def instructions(catalog:) = raise(AbstractMethodError, "engine must provide instructions")
|
|
23
|
+
|
|
24
|
+
# Opens and returns a CodeMode::Session.
|
|
25
|
+
#
|
|
26
|
+
# +broker+ is called only from trusted parent code. +sandbox_factory+
|
|
27
|
+
# creates the Sandbox that contains model-authored execution. +limits+ is
|
|
28
|
+
# the application's mapping, passed through unchanged. A custom Engine
|
|
29
|
+
# must normalize keys and values, validate supported settings, and merge
|
|
30
|
+
# its own safe defaults. The Session owns every Workspace, Sandbox,
|
|
31
|
+
# process, thread, and channel it creates. This method may be called
|
|
32
|
+
# concurrently; each call must return an independent Session.
|
|
33
|
+
def open_session(broker:, sandbox_factory:, limits:) = raise(AbstractMethodError, "engine must open a session")
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def normalize_limits(limits, defaults:)
|
|
38
|
+
configured = limits.to_h.transform_keys(&:to_sym)
|
|
39
|
+
unsupported = configured.keys - defaults.keys
|
|
40
|
+
unless unsupported.empty?
|
|
41
|
+
names = unsupported.sort_by(&:to_s).map(&:inspect).join(", ")
|
|
42
|
+
raise ArgumentError, "unsupported code-mode limits: #{names}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
defaults.merge(configured)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def allow_subprocesses_for(sandbox)
|
|
49
|
+
capabilities = sandbox.capabilities
|
|
50
|
+
return true if capabilities.supports?(:process_tree_ownership)
|
|
51
|
+
return false if capabilities.supports?(:process_spawn_denial)
|
|
52
|
+
return true if capabilities.isolation == :none
|
|
53
|
+
|
|
54
|
+
raise CapabilityError, "code mode requires owned subprocesses or enforced subprocess denial"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LittleGhost
|
|
6
|
+
module CodeMode
|
|
7
|
+
class Javascript::Catalog < CodeMode::Catalog # :nodoc: all
|
|
8
|
+
IDENTIFIER_CHARACTER = /[^A-Za-z0-9_$]/
|
|
9
|
+
RESERVED_NAMES = %w[exec wait stop].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(specifications)
|
|
12
|
+
super(specifications, normalize: method(:normalize), reserved: RESERVED_NAMES)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def host_definitions
|
|
16
|
+
@host_definitions ||= @definitions.map do |definition|
|
|
17
|
+
definition.slice("name", "description", "input_schema").freeze
|
|
18
|
+
end.freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def declarations
|
|
22
|
+
body = @definitions.map do |definition|
|
|
23
|
+
description = TypeScript.comment(definition.fetch("description"))
|
|
24
|
+
signature = "#{definition.fetch("name")}(args: " \
|
|
25
|
+
"#{TypeScript.render(definition.fetch("input_schema"))}): Promise<unknown>;"
|
|
26
|
+
[description, signature].compact.join("\n")
|
|
27
|
+
end.join("\n")
|
|
28
|
+
"declare const tools: {\n#{TypeScript.indent(body)}\n};"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.normalize(name)
|
|
32
|
+
normalized = name.to_s.gsub(IDENTIFIER_CHARACTER, "_")
|
|
33
|
+
normalized = "_#{normalized}" unless normalized.match?(/\A[A-Za-z_$]/)
|
|
34
|
+
normalized = "_then" if normalized == "then"
|
|
35
|
+
normalized
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def normalize(name) = self.class.normalize(name)
|
|
39
|
+
|
|
40
|
+
module TypeScript
|
|
41
|
+
module_function
|
|
42
|
+
|
|
43
|
+
def render(schema)
|
|
44
|
+
schema = schema.to_h.transform_keys(&:to_s)
|
|
45
|
+
return JSON.generate(schema.fetch("const")) if schema.key?("const")
|
|
46
|
+
return schema.fetch("enum").map { |value| JSON.generate(value) }.join(" | ") if schema["enum"]
|
|
47
|
+
|
|
48
|
+
alternatives = schema["anyOf"] || schema["oneOf"]
|
|
49
|
+
return alternatives.map { |child| render(child) }.join(" | ") if alternatives
|
|
50
|
+
if schema["allOf"]
|
|
51
|
+
return schema.fetch("allOf").map do |child|
|
|
52
|
+
value = render(child)
|
|
53
|
+
value.include?(" | ") ? "(#{value})" : value
|
|
54
|
+
end.join(" & ")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
types = Array(schema["type"])
|
|
58
|
+
return types.map { |type| render(schema.merge("type" => type)) }.uniq.join(" | ") if types.length > 1
|
|
59
|
+
|
|
60
|
+
case types.first
|
|
61
|
+
when "object" then object(schema)
|
|
62
|
+
when "array" then array(schema)
|
|
63
|
+
when "string" then "string"
|
|
64
|
+
when "integer", "number" then "number"
|
|
65
|
+
when "boolean" then "boolean"
|
|
66
|
+
when "null" then "null"
|
|
67
|
+
else
|
|
68
|
+
schema.key?("properties") ? object(schema) : "unknown"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def object(schema)
|
|
73
|
+
required = Array(schema["required"])
|
|
74
|
+
properties = schema.fetch("properties", {}).map do |name, child|
|
|
75
|
+
property = identifier(name) ? name : JSON.generate(name)
|
|
76
|
+
description = comment(child.to_h["description"] || child.to_h[:description])
|
|
77
|
+
optional = "?" unless required.include?(name.to_s)
|
|
78
|
+
line = "#{property}#{optional}: #{render(child)};"
|
|
79
|
+
[description, line].compact.join("\n")
|
|
80
|
+
end
|
|
81
|
+
additional = schema["additionalProperties"]
|
|
82
|
+
if additional == true
|
|
83
|
+
properties << "[key: string]: unknown;"
|
|
84
|
+
elsif additional.is_a?(Hash)
|
|
85
|
+
properties << "[key: string]: #{render(additional)};"
|
|
86
|
+
end
|
|
87
|
+
return "Record<string, never>" if properties.empty? && additional == false
|
|
88
|
+
return "Record<string, unknown>" if properties.empty?
|
|
89
|
+
|
|
90
|
+
"{\n#{indent(properties.join("\n"))}\n}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def array(schema)
|
|
94
|
+
items = schema["items"]
|
|
95
|
+
return "unknown[]" unless items
|
|
96
|
+
return "[#{items.map { |child| render(child) }.join(", ")}]" if items.is_a?(Array)
|
|
97
|
+
|
|
98
|
+
"Array<#{render(items)}>"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def comment(value)
|
|
102
|
+
return if value.to_s.empty?
|
|
103
|
+
|
|
104
|
+
sanitized = value.to_s.gsub("*/", "*\\/").gsub(/\s+/, " ").strip
|
|
105
|
+
"/** #{sanitized} */"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def indent(value)
|
|
109
|
+
value.lines.map { |line| " #{line}" }.join
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def identifier(value)
|
|
113
|
+
value.to_s.match?(/\A[A-Za-z_$][A-Za-z0-9_$]*\z/)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|