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.
Files changed (112) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +28 -6
  3. data/docs/guides/assemblies.md +143 -17
  4. data/docs/guides/code_mode.md +275 -0
  5. data/docs/guides/core_concepts.md +46 -30
  6. data/docs/guides/getting_started.md +17 -6
  7. data/docs/guides/integrations.md +217 -0
  8. data/docs/guides/models_and_providers.md +125 -0
  9. data/docs/guides/production.md +81 -15
  10. data/docs/guides/prompt_views.md +14 -7
  11. data/docs/guides/sandboxing.md +282 -0
  12. data/docs/guides/skills.md +141 -0
  13. data/docs/guides/structured_outputs_and_content.md +135 -0
  14. data/docs/guides/tools.md +329 -0
  15. data/lib/little_ghost/ag_ui/adapter.rb +2 -2
  16. data/lib/little_ghost/agent/delegation.rb +2 -2
  17. data/lib/little_ghost/agent/skills.rb +6 -1
  18. data/lib/little_ghost/agent/tool_loop.rb +6 -2
  19. data/lib/little_ghost/agent.rb +248 -46
  20. data/lib/little_ghost/agent_builder.rb +61 -22
  21. data/lib/little_ghost/agent_stream_source.rb +262 -0
  22. data/lib/little_ghost/artifact.rb +182 -0
  23. data/lib/little_ghost/artifacts/presentation_budget.rb +44 -0
  24. data/lib/little_ghost/artifacts/workspace_store.rb +370 -0
  25. data/lib/little_ghost/artifacts.rb +11 -0
  26. data/lib/little_ghost/assembly.rb +63 -17
  27. data/lib/little_ghost/assembly_builder.rb +26 -26
  28. data/lib/little_ghost/assembly_execution.rb +23 -2
  29. data/lib/little_ghost/code_mode/broker.rb +164 -0
  30. data/lib/little_ghost/code_mode/catalog.rb +54 -0
  31. data/lib/little_ghost/code_mode/engine.rb +58 -0
  32. data/lib/little_ghost/code_mode/javascript/catalog.rb +118 -0
  33. data/lib/little_ghost/code_mode/javascript/client.rb +550 -0
  34. data/lib/little_ghost/code_mode/javascript/host.rb +347 -0
  35. data/lib/little_ghost/code_mode/javascript/session.rb +579 -0
  36. data/lib/little_ghost/code_mode/javascript_engine.rb +172 -0
  37. data/lib/little_ghost/code_mode/protocol.rb +78 -0
  38. data/lib/little_ghost/code_mode/ruby/catalog.rb +80 -0
  39. data/lib/little_ghost/code_mode/ruby/host.rb +102 -0
  40. data/lib/little_ghost/code_mode/ruby/session.rb +508 -0
  41. data/lib/little_ghost/code_mode/ruby_engine.rb +86 -0
  42. data/lib/little_ghost/code_mode/runtime.rb +335 -0
  43. data/lib/little_ghost/code_mode/session.rb +61 -0
  44. data/lib/little_ghost/code_mode/types.rb +58 -0
  45. data/lib/little_ghost/code_mode.rb +53 -0
  46. data/lib/little_ghost/configuration.rb +107 -12
  47. data/lib/little_ghost/content.rb +19 -8
  48. data/lib/little_ghost/data_map.rb +2 -2
  49. data/lib/little_ghost/errors.rb +26 -7
  50. data/lib/little_ghost/execution.rb +1 -2
  51. data/lib/little_ghost/graph.rb +367 -207
  52. data/lib/little_ghost/mcp/client.rb +487 -88
  53. data/lib/little_ghost/mcp/toolset.rb +210 -0
  54. data/lib/little_ghost/mcp/types.rb +216 -0
  55. data/lib/little_ghost/mcp.rb +3 -0
  56. data/lib/little_ghost/model_capabilities.rb +9 -6
  57. data/lib/little_ghost/model_request.rb +0 -12
  58. data/lib/little_ghost/model_resolver.rb +13 -3
  59. data/lib/little_ghost/model_response.rb +3 -7
  60. data/lib/little_ghost/network/authorizer_server.rb +162 -0
  61. data/lib/little_ghost/network/certificate_authority.rb +98 -0
  62. data/lib/little_ghost/network/envoy_config.rb +362 -0
  63. data/lib/little_ghost/network/envoy_gateway.rb +409 -0
  64. data/lib/little_ghost/network/external_gateway.rb +68 -0
  65. data/lib/little_ghost/network.rb +96 -0
  66. data/lib/little_ghost/prompt_resolver.rb +7 -7
  67. data/lib/little_ghost/provider_registry.rb +3 -3
  68. data/lib/little_ghost/providers/anthropic.rb +8 -1
  69. data/lib/little_ghost/providers/gemini.rb +10 -1
  70. data/lib/little_ghost/providers/vertex_ai.rb +6 -1
  71. data/lib/little_ghost/run.rb +83 -13
  72. data/lib/little_ghost/run_context.rb +9 -0
  73. data/lib/little_ghost/run_result.rb +0 -7
  74. data/lib/little_ghost/runtime/hook.rb +5 -0
  75. data/lib/little_ghost/runtime/hooks/artifacts.rb +338 -0
  76. data/lib/little_ghost/runtime.rb +102 -34
  77. data/lib/little_ghost/sandbox/capabilities.rb +68 -0
  78. data/lib/little_ghost/sandbox/environment_policy.rb +41 -0
  79. data/lib/little_ghost/sandbox/filesystem.rb +377 -0
  80. data/lib/little_ghost/sandbox/isolated_backend.rb +163 -0
  81. data/lib/little_ghost/sandbox/limits.rb +48 -0
  82. data/lib/little_ghost/sandbox/mount.rb +129 -0
  83. data/lib/little_ghost/sandbox/network_policy.rb +108 -0
  84. data/lib/little_ghost/sandbox/policy.rb +88 -0
  85. data/lib/little_ghost/sandbox/process_runner.rb +103 -0
  86. data/lib/little_ghost/sandbox/process_session.rb +335 -0
  87. data/lib/little_ghost/sandbox/scope.rb +304 -0
  88. data/lib/little_ghost/sandbox.rb +158 -32
  89. data/lib/little_ghost/sandboxes/bubblewrap.rb +351 -0
  90. data/lib/little_ghost/sandboxes/native.rb +51 -0
  91. data/lib/little_ghost/sandboxes/seatbelt.rb +261 -0
  92. data/lib/little_ghost/sandboxes/unrestricted.rb +241 -0
  93. data/lib/little_ghost/session.rb +30 -6
  94. data/lib/little_ghost/skills/catalog.rb +94 -14
  95. data/lib/little_ghost/skills/resource_root.rb +42 -0
  96. data/lib/little_ghost/skills/skill.rb +0 -3
  97. data/lib/little_ghost/stream_event.rb +8 -13
  98. data/lib/little_ghost/subagents/control_tool.rb +8 -0
  99. data/lib/little_ghost/subagents/manager.rb +12 -9
  100. data/lib/little_ghost/support/callbacks.rb +3 -1
  101. data/lib/little_ghost/support/content_capture.rb +3 -3
  102. data/lib/little_ghost/support/http_client.rb +2 -2
  103. data/lib/little_ghost/support/redactor.rb +1 -1
  104. data/lib/little_ghost/tool.rb +104 -53
  105. data/lib/little_ghost/tool_registry.rb +1 -1
  106. data/lib/little_ghost/tools/filesystem.rb +1 -1
  107. data/lib/little_ghost/tools/shell.rb +4 -3
  108. data/lib/little_ghost/version.rb +1 -1
  109. data/lib/little_ghost/workspace.rb +222 -8
  110. data/lib/little_ghost.rb +21 -12
  111. metadata +98 -2
  112. data/lib/little_ghost/unrestricted_sandbox.rb +0 -306
@@ -12,11 +12,11 @@ module LittleGhost
12
12
  end
13
13
  end
14
14
 
15
- # The immutable, executable snapshot produced by an AssemblyBuilder.
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 sealed class instantiated for
19
- # this snapshot.
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 stable identifier captured by the snapshot.
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 captured by the snapshot.
31
+ # The human-readable description at the time the definition was built.
32
32
 
33
33
  ##
34
34
  # :attr_reader: implementation
35
- # The sealed Assembly subclass used to build executions.
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 snapshots declaration
55
- # containers and referenced Assembly definitions, so later builder changes
56
- # affect only future executions. Executable Ruby closures and the external
57
- # objects they reference remain live trusted application code.
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 snapshot and returns this mutable builder.
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 an immutable, recursively snapshotted definition.
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 definition snapshot.
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 a standalone snapshot and returns its Run.
129
+ # Executes the current declarations and returns their Run.
130
130
  def ask(message, **options) = build.ask(message, **options)
131
131
 
132
- # Lazily streams a standalone snapshot.
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 a snapshot to completion.
140
+ # Executes the current declarations to completion.
141
141
  def call(input = nil, **options) = build.call(input, **options)
142
- # Streams a snapshot as StreamEvent objects.
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 a snapshot.
144
+ # Starts a supervised execution from the current declarations.
145
145
  def start_execution(payload, &block) = build.start_execution(payload, &block)
146
- # Exposes a snapshot as a Tool.
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
- # Supported Agent class-DSL calls are recorded and replayed into each
262
- # immutable snapshot.
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
- # Records supported Agent class-DSL declarations for the next snapshot.
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 trusted application code.
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 fork join error_edge].each do |name|
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
- # Renders the current validated snapshot as Mermaid flowchart text.
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 fork join error_edge]
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
- runtime.build_agent(reference, run:, **build_options)
210
+ build_stream_participant(:build_agent, reference, stream_path, **build_options)
202
211
  else
203
- runtime.build_assembly(reference, run:, **build_options)
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