language-operator 0.1.71 → 0.1.81
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/Gemfile.lock +1 -1
- data/components/agent/Gemfile +1 -1
- data/components/tool/Gemfile +1 -1
- data/lib/language_operator/agent/base.rb +12 -7
- data/lib/language_operator/agent/execution_state.rb +92 -0
- data/lib/language_operator/agent/web_server.rb +536 -10
- data/lib/language_operator/agent.rb +34 -25
- data/lib/language_operator/templates/schema/agent_dsl_openapi.yaml +1 -1
- data/lib/language_operator/templates/schema/agent_dsl_schema.json +1 -1
- data/lib/language_operator/version.rb +1 -1
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a1b11d94e50c2d449d46b151cb14c708454f0f5ce8c62a88a0266d8cbd55ce67
|
|
4
|
+
data.tar.gz: 0645ea712822902e55770b415587fb2bc5b4ed1fa15bed8ddd9e29b8a514997e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3874c4e2b45e0934c407a3bb89e6422aff60193f231f513c6ea639aa8888555722b9facc6ed697e8804ab44aa0c37a034c79ae69ecc3e6c6aad84682537cfea9
|
|
7
|
+
data.tar.gz: 0ff65814f78ac27e77b86614bfb102771d47deb6e70dc36a42d16ba4a1cc36446fd7f918ac8e35c79455e1357ccbc585b39d5713706df34394d84f18aad2efd6
|
data/Gemfile.lock
CHANGED
data/components/agent/Gemfile
CHANGED
data/components/tool/Gemfile
CHANGED
|
@@ -107,24 +107,29 @@ module LanguageOperator
|
|
|
107
107
|
@executor.run_loop
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
-
# Run in scheduled mode (
|
|
110
|
+
# Run in scheduled mode (standby - waits for HTTP triggers)
|
|
111
111
|
#
|
|
112
112
|
# @return [void]
|
|
113
113
|
def run_scheduled
|
|
114
|
-
logger.info('Agent running in scheduled mode
|
|
114
|
+
logger.info('Agent running in scheduled mode (standby) - waiting for HTTP triggers')
|
|
115
115
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
require_relative 'web_server'
|
|
117
|
+
@web_server = WebServer.new(self)
|
|
118
|
+
@web_server.register_execute_endpoint(self, nil)
|
|
119
|
+
@web_server.register_workspace_endpoints(self)
|
|
120
|
+
@web_server.start
|
|
120
121
|
end
|
|
121
122
|
|
|
122
|
-
# Run in reactive mode (HTTP server)
|
|
123
|
+
# Run in reactive mode (standby - HTTP server with execute endpoint)
|
|
123
124
|
#
|
|
124
125
|
# @return [void]
|
|
125
126
|
def run_reactive
|
|
127
|
+
logger.info('Agent running in reactive mode (standby) - web server only')
|
|
128
|
+
|
|
126
129
|
require_relative 'web_server'
|
|
127
130
|
@web_server = WebServer.new(self)
|
|
131
|
+
@web_server.register_execute_endpoint(self, nil) # Enable /api/v1/execute endpoint
|
|
132
|
+
@web_server.register_workspace_endpoints(self)
|
|
128
133
|
@web_server.start
|
|
129
134
|
end
|
|
130
135
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LanguageOperator
|
|
4
|
+
module Agent
|
|
5
|
+
# Execution State Manager
|
|
6
|
+
#
|
|
7
|
+
# Manages the execution state of agents to prevent concurrent executions.
|
|
8
|
+
# Thread-safe implementation ensures only one execution runs at a time.
|
|
9
|
+
#
|
|
10
|
+
# @example Check if execution is running
|
|
11
|
+
# state = ExecutionState.new
|
|
12
|
+
# state.running? # => false
|
|
13
|
+
#
|
|
14
|
+
# @example Start an execution
|
|
15
|
+
# state.start_execution('exec-123')
|
|
16
|
+
# state.running? # => true
|
|
17
|
+
class ExecutionState
|
|
18
|
+
attr_reader :current_execution_id, :started_at, :status
|
|
19
|
+
|
|
20
|
+
# Initialize a new execution state
|
|
21
|
+
def initialize
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
@current_execution_id = nil
|
|
24
|
+
@started_at = nil
|
|
25
|
+
@status = :idle # :idle, :running, :completed, :failed
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Start a new execution
|
|
29
|
+
#
|
|
30
|
+
# @param execution_id [String] Unique identifier for the execution
|
|
31
|
+
# @raise [ExecutionInProgressError] if an execution is already running
|
|
32
|
+
# @return [void]
|
|
33
|
+
def start_execution(execution_id)
|
|
34
|
+
@mutex.synchronize do
|
|
35
|
+
if @status == :running
|
|
36
|
+
raise ExecutionInProgressError,
|
|
37
|
+
"Execution #{@current_execution_id} already running"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@current_execution_id = execution_id
|
|
41
|
+
@started_at = Time.now
|
|
42
|
+
@status = :running
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Mark execution as completed
|
|
47
|
+
#
|
|
48
|
+
# @param result [Object] Optional execution result
|
|
49
|
+
# @return [Object] The result passed in
|
|
50
|
+
def complete_execution(result = nil)
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
@status = :completed
|
|
53
|
+
result
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Mark execution as failed
|
|
58
|
+
#
|
|
59
|
+
# @param error [StandardError] The error that caused the failure
|
|
60
|
+
# @return [void]
|
|
61
|
+
def fail_execution(error)
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
@status = :failed
|
|
64
|
+
@last_error = error
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Check if an execution is currently running
|
|
69
|
+
#
|
|
70
|
+
# @return [Boolean] true if execution is in progress
|
|
71
|
+
def running?
|
|
72
|
+
@mutex.synchronize { @status == :running }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Get current execution information
|
|
76
|
+
#
|
|
77
|
+
# @return [Hash] Current execution state details
|
|
78
|
+
def current_info
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
{
|
|
81
|
+
status: @status,
|
|
82
|
+
execution_id: @current_execution_id,
|
|
83
|
+
started_at: @started_at
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Error raised when attempting to start an execution while one is already running
|
|
90
|
+
class ExecutionInProgressError < StandardError; end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require 'rack'
|
|
4
4
|
require 'rackup'
|
|
5
5
|
require 'mcp'
|
|
6
|
+
require 'fileutils'
|
|
7
|
+
require 'securerandom'
|
|
6
8
|
require_relative 'executor'
|
|
7
9
|
require_relative 'prompt_builder'
|
|
8
10
|
|
|
@@ -30,6 +32,7 @@ module LanguageOperator
|
|
|
30
32
|
@routes = {}
|
|
31
33
|
@mcp_server = nil
|
|
32
34
|
@mcp_transport = nil
|
|
35
|
+
@execution_state = nil # Initialized when register_execute_endpoint called
|
|
33
36
|
|
|
34
37
|
# Initialize executor pool to prevent MCP connection leaks
|
|
35
38
|
@executor_pool_size = ENV.fetch('EXECUTOR_POOL_SIZE', '4').to_i
|
|
@@ -118,7 +121,7 @@ module LanguageOperator
|
|
|
118
121
|
# @return [void]
|
|
119
122
|
def register_chat_endpoint(agent)
|
|
120
123
|
@chat_agent = agent
|
|
121
|
-
|
|
124
|
+
|
|
122
125
|
# Create simple chat configuration (identity awareness always enabled)
|
|
123
126
|
@chat_config = {
|
|
124
127
|
model_name: ENV.fetch('AGENT_NAME', agent.config&.dig('agent', 'name') || 'agent'),
|
|
@@ -153,6 +156,65 @@ module LanguageOperator
|
|
|
153
156
|
puts "Registered identity-aware chat endpoint as model: #{@chat_config[:model_name]}"
|
|
154
157
|
end
|
|
155
158
|
|
|
159
|
+
# Register execution trigger endpoint
|
|
160
|
+
#
|
|
161
|
+
# Enables scheduled/reactive agents to execute tasks via HTTP POST.
|
|
162
|
+
# Prevents concurrent executions via ExecutionState.
|
|
163
|
+
#
|
|
164
|
+
# @param agent [LanguageOperator::Agent::Base] The agent instance
|
|
165
|
+
# @param agent_def [LanguageOperator::Dsl::AgentDefinition, nil] Optional agent definition
|
|
166
|
+
# @return [void]
|
|
167
|
+
def register_execute_endpoint(agent, agent_def = nil)
|
|
168
|
+
require_relative 'execution_state'
|
|
169
|
+
|
|
170
|
+
@execute_agent = agent
|
|
171
|
+
@execute_agent_def = agent_def
|
|
172
|
+
@execution_state = LanguageOperator::Agent::ExecutionState.new
|
|
173
|
+
|
|
174
|
+
register_route('/api/v1/execute', method: :post) do |context|
|
|
175
|
+
handle_execute_request(context)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
puts 'Registered /api/v1/execute endpoint for triggered execution'
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Register workspace file management endpoints
|
|
182
|
+
#
|
|
183
|
+
# @param agent [LanguageOperator::Agent::Base] The agent instance
|
|
184
|
+
def register_workspace_endpoints(agent)
|
|
185
|
+
return unless agent.workspace_available?
|
|
186
|
+
|
|
187
|
+
@workspace_agent = agent
|
|
188
|
+
@workspace_path = agent.workspace_path
|
|
189
|
+
|
|
190
|
+
# List directory contents
|
|
191
|
+
register_route('/api/v1/workspace/files', method: :get) do |context|
|
|
192
|
+
handle_workspace_list(context)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# View file contents
|
|
196
|
+
register_route('/api/v1/workspace/files/view', method: :get) do |context|
|
|
197
|
+
handle_workspace_view(context)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Upload file
|
|
201
|
+
register_route('/api/v1/workspace/files', method: :post) do |context|
|
|
202
|
+
handle_workspace_upload(context)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Delete file
|
|
206
|
+
register_route('/api/v1/workspace/files', method: :delete) do |context|
|
|
207
|
+
handle_workspace_delete(context)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Download file/directory
|
|
211
|
+
register_route('/api/v1/workspace/files/download', method: :get) do |context|
|
|
212
|
+
handle_workspace_download(context)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
puts 'Registered /api/v1/workspace/* endpoints for file management'
|
|
216
|
+
end
|
|
217
|
+
|
|
156
218
|
# Handle incoming HTTP request
|
|
157
219
|
#
|
|
158
220
|
# @param env [Hash] Rack environment
|
|
@@ -207,10 +269,10 @@ module LanguageOperator
|
|
|
207
269
|
# @param agent [LanguageOperator::Agent::Base] The agent instance
|
|
208
270
|
# @return [String] Default system prompt
|
|
209
271
|
def build_default_system_prompt(agent)
|
|
210
|
-
description = agent.config&.dig('agent', 'instructions') ||
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
272
|
+
description = agent.config&.dig('agent', 'instructions') ||
|
|
273
|
+
agent.config&.dig('agent', 'description') ||
|
|
274
|
+
'AI assistant'
|
|
275
|
+
|
|
214
276
|
if description.downcase.start_with?('you are')
|
|
215
277
|
description
|
|
216
278
|
else
|
|
@@ -218,6 +280,145 @@ module LanguageOperator
|
|
|
218
280
|
end
|
|
219
281
|
end
|
|
220
282
|
|
|
283
|
+
# Handle POST /api/v1/execute request
|
|
284
|
+
#
|
|
285
|
+
# @param context [Hash] Request context with :body, :headers, :request
|
|
286
|
+
# @return [Hash] Response data
|
|
287
|
+
def handle_execute_request(context)
|
|
288
|
+
puts "Received execute request: #{context[:body]}"
|
|
289
|
+
|
|
290
|
+
# Check for concurrent execution
|
|
291
|
+
if @execution_state.running?
|
|
292
|
+
info = @execution_state.current_info
|
|
293
|
+
puts "Execution already running: #{info}"
|
|
294
|
+
return {
|
|
295
|
+
status: 409,
|
|
296
|
+
body: {
|
|
297
|
+
error: 'ExecutionInProgress',
|
|
298
|
+
message: 'Agent is currently executing a task',
|
|
299
|
+
current_execution: info
|
|
300
|
+
}.to_json,
|
|
301
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
302
|
+
}
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Parse request
|
|
306
|
+
request_data = JSON.parse(context[:body] || '{}')
|
|
307
|
+
puts "Parsed request data: #{request_data.inspect}"
|
|
308
|
+
|
|
309
|
+
execution_id = "exec-#{SecureRandom.hex(8)}"
|
|
310
|
+
puts "Generated execution ID: #{execution_id}"
|
|
311
|
+
|
|
312
|
+
wait_for_completion = request_data.fetch('wait', true)
|
|
313
|
+
puts "Execution details - ID: #{execution_id}, wait: #{wait_for_completion}"
|
|
314
|
+
|
|
315
|
+
# Start execution
|
|
316
|
+
puts 'About to start execution state...'
|
|
317
|
+
@execution_state.start_execution(execution_id)
|
|
318
|
+
puts "Started execution state for #{execution_id}"
|
|
319
|
+
|
|
320
|
+
if wait_for_completion
|
|
321
|
+
execute_sync(execution_id, request_data['context'])
|
|
322
|
+
else
|
|
323
|
+
execute_async(execution_id, request_data['context'])
|
|
324
|
+
end
|
|
325
|
+
rescue JSON::ParserError
|
|
326
|
+
execute_error_response(400, 'InvalidJSON', 'Request body must be valid JSON')
|
|
327
|
+
rescue LanguageOperator::Agent::ExecutionInProgressError => e
|
|
328
|
+
execute_error_response(409, 'ExecutionInProgress', e.message)
|
|
329
|
+
rescue StandardError => e
|
|
330
|
+
puts "Exception in handle_execute_request: #{e.class}: #{e.message}"
|
|
331
|
+
puts "Exception backtrace: #{e.backtrace.first(5).join('\n')}"
|
|
332
|
+
@execution_state&.fail_execution(e)
|
|
333
|
+
execute_error_response(500, 'ExecutionError', e.message)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Execute task synchronously
|
|
337
|
+
#
|
|
338
|
+
# @param execution_id [String] Execution identifier
|
|
339
|
+
# @param context_data [Hash] Additional context data
|
|
340
|
+
# @return [Hash] Response data
|
|
341
|
+
def execute_sync(execution_id, _context_data)
|
|
342
|
+
start_time = Time.now
|
|
343
|
+
puts "Starting execution #{execution_id}"
|
|
344
|
+
|
|
345
|
+
# Execute agent main block (convention: all DSL agents have main blocks)
|
|
346
|
+
puts 'About to execute main block...'
|
|
347
|
+
$stdout.flush
|
|
348
|
+
|
|
349
|
+
# Test logging capture during execution
|
|
350
|
+
original_stdout = $stdout
|
|
351
|
+
original_stderr = $stderr
|
|
352
|
+
puts "Original stdout: #{original_stdout.class}"
|
|
353
|
+
puts "Original stderr: #{original_stderr.class}"
|
|
354
|
+
$stdout.flush
|
|
355
|
+
|
|
356
|
+
result = LanguageOperator::Agent.execute_main_block(@execute_agent, @execute_agent_def)
|
|
357
|
+
|
|
358
|
+
puts "Main block execution returned: #{result.inspect}"
|
|
359
|
+
puts "Stdout after execution: #{$stdout.class}"
|
|
360
|
+
puts "Stderr after execution: #{$stderr.class}"
|
|
361
|
+
$stdout.flush
|
|
362
|
+
|
|
363
|
+
puts "Execution #{execution_id} completed with result: #{result.inspect}"
|
|
364
|
+
@execution_state.complete_execution(result)
|
|
365
|
+
|
|
366
|
+
{
|
|
367
|
+
status: 200,
|
|
368
|
+
body: {
|
|
369
|
+
status: 'completed',
|
|
370
|
+
result: result,
|
|
371
|
+
execution_id: execution_id,
|
|
372
|
+
started_at: start_time.iso8601,
|
|
373
|
+
completed_at: Time.now.iso8601,
|
|
374
|
+
duration_seconds: (Time.now - start_time).round(2)
|
|
375
|
+
}.to_json,
|
|
376
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
377
|
+
}
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Execute task asynchronously
|
|
381
|
+
#
|
|
382
|
+
# @param execution_id [String] Execution identifier
|
|
383
|
+
# @param context_data [Hash] Additional context data
|
|
384
|
+
# @return [Hash] Response data
|
|
385
|
+
def execute_async(execution_id, _context_data)
|
|
386
|
+
Thread.new do
|
|
387
|
+
result = LanguageOperator::Agent.execute_main_block(@execute_agent, @execute_agent_def)
|
|
388
|
+
@execution_state.complete_execution(result)
|
|
389
|
+
rescue StandardError => e
|
|
390
|
+
@execution_state.fail_execution(e)
|
|
391
|
+
logger.error('Async execution failed', error: e.message, execution_id: execution_id)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
{
|
|
395
|
+
status: 202,
|
|
396
|
+
body: {
|
|
397
|
+
status: 'accepted',
|
|
398
|
+
execution_id: execution_id,
|
|
399
|
+
message: 'Execution started asynchronously'
|
|
400
|
+
}.to_json,
|
|
401
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
402
|
+
}
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Generate error response for execute endpoint
|
|
406
|
+
#
|
|
407
|
+
# @param status [Integer] HTTP status code
|
|
408
|
+
# @param error_type [String] Error type identifier
|
|
409
|
+
# @param message [String] Error message
|
|
410
|
+
# @return [Hash] Error response data
|
|
411
|
+
def execute_error_response(status, error_type, message)
|
|
412
|
+
{
|
|
413
|
+
status: status,
|
|
414
|
+
body: {
|
|
415
|
+
error: error_type,
|
|
416
|
+
message: message
|
|
417
|
+
}.to_json,
|
|
418
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
419
|
+
}
|
|
420
|
+
end
|
|
421
|
+
|
|
221
422
|
# Setup executor pool for connection reuse
|
|
222
423
|
#
|
|
223
424
|
# Creates a thread-safe queue pre-populated with executor instances
|
|
@@ -431,7 +632,7 @@ module LanguageOperator
|
|
|
431
632
|
# @param context [Hash] Request context
|
|
432
633
|
# @return [Array, Hash] Rack response or hash for streaming
|
|
433
634
|
def handle_chat_completion(context)
|
|
434
|
-
return error_response(StandardError.new('Chat endpoint not configured')) unless @
|
|
635
|
+
return error_response(StandardError.new('Chat endpoint not configured')) unless @chat_config
|
|
435
636
|
|
|
436
637
|
# Parse request body
|
|
437
638
|
request_data = JSON.parse(context[:body])
|
|
@@ -534,7 +735,7 @@ module LanguageOperator
|
|
|
534
735
|
prompt_parts << "User: #{content}"
|
|
535
736
|
when 'assistant'
|
|
536
737
|
prompt_parts << "Assistant: #{content}"
|
|
537
|
-
|
|
738
|
+
# Skip system messages - we handle them via PromptBuilder
|
|
538
739
|
end
|
|
539
740
|
end
|
|
540
741
|
|
|
@@ -548,8 +749,8 @@ module LanguageOperator
|
|
|
548
749
|
# Create prompt builder with identity awareness always enabled
|
|
549
750
|
builder = PromptBuilder.new(
|
|
550
751
|
@chat_agent,
|
|
551
|
-
nil,
|
|
552
|
-
template: :standard,
|
|
752
|
+
nil, # No chat config needed
|
|
753
|
+
template: :standard, # Good default
|
|
553
754
|
enable_identity_awareness: true
|
|
554
755
|
)
|
|
555
756
|
|
|
@@ -566,7 +767,7 @@ module LanguageOperator
|
|
|
566
767
|
def build_conversation_context
|
|
567
768
|
builder = PromptBuilder.new(
|
|
568
769
|
@chat_agent,
|
|
569
|
-
nil,
|
|
770
|
+
nil, # No chat config needed
|
|
570
771
|
enable_identity_awareness: true
|
|
571
772
|
)
|
|
572
773
|
|
|
@@ -764,6 +965,331 @@ module LanguageOperator
|
|
|
764
965
|
ensure
|
|
765
966
|
stream.close
|
|
766
967
|
end
|
|
968
|
+
|
|
969
|
+
# Workspace file management handlers
|
|
970
|
+
|
|
971
|
+
# Handle GET /api/v1/workspace/files - list directory contents
|
|
972
|
+
def handle_workspace_list(context)
|
|
973
|
+
request = context[:request]
|
|
974
|
+
path = request.params['path'] || '/'
|
|
975
|
+
|
|
976
|
+
# Sanitize path to prevent directory traversal
|
|
977
|
+
safe_path = sanitize_workspace_path(path)
|
|
978
|
+
full_path = File.join(@workspace_path, safe_path)
|
|
979
|
+
|
|
980
|
+
return workspace_error_response(404, 'NotFound', "Path not found: #{path}") unless File.exist?(full_path)
|
|
981
|
+
|
|
982
|
+
if File.directory?(full_path)
|
|
983
|
+
list_directory_contents(full_path, path)
|
|
984
|
+
else
|
|
985
|
+
# If it's a file, return file info
|
|
986
|
+
get_file_info(full_path, path)
|
|
987
|
+
end
|
|
988
|
+
rescue StandardError => e
|
|
989
|
+
workspace_error_response(500, 'InternalError', e.message)
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
# Handle GET /api/v1/workspace/files/view - view file contents
|
|
993
|
+
def handle_workspace_view(context)
|
|
994
|
+
request = context[:request]
|
|
995
|
+
path = request.params['path']
|
|
996
|
+
|
|
997
|
+
return workspace_error_response(400, 'BadRequest', 'path parameter required') unless path
|
|
998
|
+
|
|
999
|
+
# Sanitize path to prevent directory traversal
|
|
1000
|
+
safe_path = sanitize_workspace_path(path)
|
|
1001
|
+
full_path = File.join(@workspace_path, safe_path)
|
|
1002
|
+
|
|
1003
|
+
return workspace_error_response(404, 'NotFound', "File not found: #{path}") unless File.exist?(full_path)
|
|
1004
|
+
|
|
1005
|
+
return workspace_error_response(400, 'BadRequest', "Path is not a file: #{path}") unless File.file?(full_path)
|
|
1006
|
+
|
|
1007
|
+
# Check file size (limit to 10MB for API responses)
|
|
1008
|
+
file_size = File.size(full_path)
|
|
1009
|
+
return workspace_error_response(413, 'FileTooLarge', 'File too large for viewing (max 10MB)') if file_size > 10 * 1024 * 1024
|
|
1010
|
+
|
|
1011
|
+
# Read file contents
|
|
1012
|
+
contents = File.read(full_path)
|
|
1013
|
+
|
|
1014
|
+
{
|
|
1015
|
+
status: 200,
|
|
1016
|
+
body: {
|
|
1017
|
+
path: path,
|
|
1018
|
+
size: file_size,
|
|
1019
|
+
modified: File.mtime(full_path).iso8601,
|
|
1020
|
+
content: contents,
|
|
1021
|
+
content_type: detect_content_type(full_path)
|
|
1022
|
+
}.to_json,
|
|
1023
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
1024
|
+
}
|
|
1025
|
+
rescue StandardError => e
|
|
1026
|
+
workspace_error_response(500, 'InternalError', e.message)
|
|
1027
|
+
end
|
|
1028
|
+
|
|
1029
|
+
# Handle POST /api/v1/workspace/files - upload file
|
|
1030
|
+
def handle_workspace_upload(context)
|
|
1031
|
+
request = context[:request]
|
|
1032
|
+
|
|
1033
|
+
# Check for multipart form data
|
|
1034
|
+
content_type = request.env['CONTENT_TYPE']
|
|
1035
|
+
unless content_type&.start_with?('multipart/form-data')
|
|
1036
|
+
return workspace_error_response(400, 'BadRequest', 'Content-Type must be multipart/form-data')
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
# Parse multipart data
|
|
1040
|
+
begin
|
|
1041
|
+
# Get file from params (Rack automatically parses multipart data)
|
|
1042
|
+
file_param = request.params['file']
|
|
1043
|
+
path_param = request.params['path']
|
|
1044
|
+
|
|
1045
|
+
return workspace_error_response(400, 'BadRequest', 'file parameter required') unless file_param
|
|
1046
|
+
|
|
1047
|
+
return workspace_error_response(400, 'BadRequest', 'path parameter required') unless path_param
|
|
1048
|
+
|
|
1049
|
+
# Handle file upload object
|
|
1050
|
+
return workspace_error_response(400, 'BadRequest', 'Invalid file upload format') unless file_param.is_a?(Hash) && file_param[:tempfile]
|
|
1051
|
+
|
|
1052
|
+
# Rack file upload format
|
|
1053
|
+
tempfile = file_param[:tempfile]
|
|
1054
|
+
original_filename = file_param[:filename]
|
|
1055
|
+
content_type = file_param[:type]
|
|
1056
|
+
|
|
1057
|
+
# Sanitize target path
|
|
1058
|
+
safe_path = sanitize_workspace_path(path_param)
|
|
1059
|
+
full_path = File.join(@workspace_path, safe_path)
|
|
1060
|
+
|
|
1061
|
+
# Ensure target directory exists
|
|
1062
|
+
target_dir = File.dirname(full_path)
|
|
1063
|
+
FileUtils.mkdir_p(target_dir) unless File.directory?(target_dir)
|
|
1064
|
+
|
|
1065
|
+
# Check file size (limit to 100MB)
|
|
1066
|
+
file_size = tempfile.size
|
|
1067
|
+
return workspace_error_response(413, 'FileTooLarge', 'File too large for upload (max 100MB)') if file_size > 100 * 1024 * 1024
|
|
1068
|
+
|
|
1069
|
+
# Copy uploaded file to workspace
|
|
1070
|
+
tempfile.rewind
|
|
1071
|
+
File.open(full_path, 'wb') do |dest_file|
|
|
1072
|
+
IO.copy_stream(tempfile, dest_file)
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
# Set reasonable permissions
|
|
1076
|
+
File.chmod(0o644, full_path)
|
|
1077
|
+
|
|
1078
|
+
{
|
|
1079
|
+
status: 201,
|
|
1080
|
+
body: {
|
|
1081
|
+
path: path_param,
|
|
1082
|
+
size: file_size,
|
|
1083
|
+
original_filename: original_filename,
|
|
1084
|
+
content_type: content_type,
|
|
1085
|
+
message: 'File uploaded successfully'
|
|
1086
|
+
}.to_json,
|
|
1087
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
1088
|
+
}
|
|
1089
|
+
rescue StandardError => e
|
|
1090
|
+
workspace_error_response(500, 'UploadError', "Upload failed: #{e.message}")
|
|
1091
|
+
ensure
|
|
1092
|
+
# Clean up temporary file
|
|
1093
|
+
tempfile&.close if tempfile.respond_to?(:close)
|
|
1094
|
+
end
|
|
1095
|
+
end
|
|
1096
|
+
|
|
1097
|
+
# Handle DELETE /api/v1/workspace/files - delete file
|
|
1098
|
+
def handle_workspace_delete(context)
|
|
1099
|
+
request = context[:request]
|
|
1100
|
+
path = request.params['path']
|
|
1101
|
+
|
|
1102
|
+
return workspace_error_response(400, 'BadRequest', 'path parameter required') unless path
|
|
1103
|
+
|
|
1104
|
+
# Sanitize path to prevent directory traversal
|
|
1105
|
+
safe_path = sanitize_workspace_path(path)
|
|
1106
|
+
full_path = File.join(@workspace_path, safe_path)
|
|
1107
|
+
|
|
1108
|
+
return workspace_error_response(404, 'NotFound', "Path not found: #{path}") unless File.exist?(full_path)
|
|
1109
|
+
|
|
1110
|
+
begin
|
|
1111
|
+
deleted_info = {
|
|
1112
|
+
path: path,
|
|
1113
|
+
type: File.directory?(full_path) ? 'directory' : 'file',
|
|
1114
|
+
size: File.directory?(full_path) ? nil : File.size(full_path)
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
if File.directory?(full_path)
|
|
1118
|
+
# Delete directory and all contents
|
|
1119
|
+
FileUtils.rm_rf(full_path)
|
|
1120
|
+
deleted_info[:message] = 'Directory deleted successfully'
|
|
1121
|
+
else
|
|
1122
|
+
# Delete single file
|
|
1123
|
+
File.delete(full_path)
|
|
1124
|
+
deleted_info[:message] = 'File deleted successfully'
|
|
1125
|
+
end
|
|
1126
|
+
|
|
1127
|
+
{
|
|
1128
|
+
status: 200,
|
|
1129
|
+
body: deleted_info.to_json,
|
|
1130
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
1131
|
+
}
|
|
1132
|
+
rescue StandardError => e
|
|
1133
|
+
workspace_error_response(500, 'DeleteError', "Delete failed: #{e.message}")
|
|
1134
|
+
end
|
|
1135
|
+
end
|
|
1136
|
+
|
|
1137
|
+
# Handle GET /api/v1/workspace/files/download - download file/archive
|
|
1138
|
+
def handle_workspace_download(context)
|
|
1139
|
+
request = context[:request]
|
|
1140
|
+
path = request.params['path']
|
|
1141
|
+
|
|
1142
|
+
return workspace_error_response(400, 'BadRequest', 'path parameter required') unless path
|
|
1143
|
+
|
|
1144
|
+
# Sanitize path to prevent directory traversal
|
|
1145
|
+
safe_path = sanitize_workspace_path(path)
|
|
1146
|
+
full_path = File.join(@workspace_path, safe_path)
|
|
1147
|
+
|
|
1148
|
+
return workspace_error_response(404, 'NotFound', "Path not found: #{path}") unless File.exist?(full_path)
|
|
1149
|
+
|
|
1150
|
+
begin
|
|
1151
|
+
if File.directory?(full_path)
|
|
1152
|
+
# Create temporary tar.gz archive for directory
|
|
1153
|
+
archive_name = "#{File.basename(safe_path.empty? ? 'workspace' : safe_path)}.tar.gz"
|
|
1154
|
+
temp_archive = "/tmp/#{SecureRandom.hex(8)}_#{archive_name}"
|
|
1155
|
+
|
|
1156
|
+
# Create tar archive
|
|
1157
|
+
system('tar', '-czf', temp_archive, '-C', File.dirname(full_path), File.basename(full_path))
|
|
1158
|
+
|
|
1159
|
+
return workspace_error_response(500, 'ArchiveError', 'Failed to create archive') unless File.exist?(temp_archive)
|
|
1160
|
+
|
|
1161
|
+
# Read archive contents
|
|
1162
|
+
archive_data = File.binread(temp_archive)
|
|
1163
|
+
|
|
1164
|
+
# Clean up temporary file
|
|
1165
|
+
File.delete(temp_archive)
|
|
1166
|
+
|
|
1167
|
+
{
|
|
1168
|
+
status: 200,
|
|
1169
|
+
body: archive_data,
|
|
1170
|
+
headers: {
|
|
1171
|
+
'Content-Type' => 'application/gzip',
|
|
1172
|
+
'Content-Disposition' => "attachment; filename=\"#{archive_name}\"",
|
|
1173
|
+
'Content-Length' => archive_data.length.to_s
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
else
|
|
1177
|
+
# Direct file download
|
|
1178
|
+
file_data = File.binread(full_path)
|
|
1179
|
+
filename = File.basename(safe_path)
|
|
1180
|
+
content_type = detect_content_type(full_path)
|
|
1181
|
+
|
|
1182
|
+
# For binary files, use application/octet-stream
|
|
1183
|
+
content_disposition = if content_type == 'application/octet-stream'
|
|
1184
|
+
"attachment; filename=\"#{filename}\""
|
|
1185
|
+
else
|
|
1186
|
+
# For text files, allow inline viewing
|
|
1187
|
+
"inline; filename=\"#{filename}\""
|
|
1188
|
+
end
|
|
1189
|
+
|
|
1190
|
+
{
|
|
1191
|
+
status: 200,
|
|
1192
|
+
body: file_data,
|
|
1193
|
+
headers: {
|
|
1194
|
+
'Content-Type' => content_type,
|
|
1195
|
+
'Content-Disposition' => content_disposition,
|
|
1196
|
+
'Content-Length' => file_data.length.to_s
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
end
|
|
1200
|
+
rescue StandardError => e
|
|
1201
|
+
workspace_error_response(500, 'DownloadError', "Download failed: #{e.message}")
|
|
1202
|
+
end
|
|
1203
|
+
end
|
|
1204
|
+
|
|
1205
|
+
private
|
|
1206
|
+
|
|
1207
|
+
# Sanitize workspace path to prevent directory traversal attacks
|
|
1208
|
+
def sanitize_workspace_path(path)
|
|
1209
|
+
# Remove leading slash and resolve relative paths
|
|
1210
|
+
clean_path = path.sub(%r{^/+}, '')
|
|
1211
|
+
|
|
1212
|
+
# Resolve and normalize the path
|
|
1213
|
+
normalized = File.expand_path(clean_path, '/')
|
|
1214
|
+
|
|
1215
|
+
# Ensure it doesn't escape the root
|
|
1216
|
+
return '' if normalized == '/' || !normalized.start_with?('/')
|
|
1217
|
+
|
|
1218
|
+
# Remove leading slash for joining with workspace_path
|
|
1219
|
+
normalized[1..]
|
|
1220
|
+
end
|
|
1221
|
+
|
|
1222
|
+
# List directory contents
|
|
1223
|
+
def list_directory_contents(full_path, requested_path)
|
|
1224
|
+
entries = Dir.entries(full_path).reject { |entry| entry.start_with?('.') }
|
|
1225
|
+
|
|
1226
|
+
files = entries.map do |entry|
|
|
1227
|
+
entry_path = File.join(full_path, entry)
|
|
1228
|
+
relative_path = File.join(requested_path == '/' ? '' : requested_path, entry)
|
|
1229
|
+
|
|
1230
|
+
{
|
|
1231
|
+
name: entry,
|
|
1232
|
+
path: "/#{relative_path}".gsub(%r{/+}, '/'),
|
|
1233
|
+
type: File.directory?(entry_path) ? 'directory' : 'file',
|
|
1234
|
+
size: File.directory?(entry_path) ? nil : File.size(entry_path),
|
|
1235
|
+
modified: File.mtime(entry_path).iso8601
|
|
1236
|
+
}
|
|
1237
|
+
end
|
|
1238
|
+
|
|
1239
|
+
files.sort_by! { |f| [f[:type] == 'directory' ? 0 : 1, f[:name]] }
|
|
1240
|
+
|
|
1241
|
+
{
|
|
1242
|
+
status: 200,
|
|
1243
|
+
body: {
|
|
1244
|
+
path: requested_path,
|
|
1245
|
+
files: files,
|
|
1246
|
+
total: files.length
|
|
1247
|
+
}.to_json,
|
|
1248
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
1249
|
+
}
|
|
1250
|
+
end
|
|
1251
|
+
|
|
1252
|
+
# Get file info for a single file
|
|
1253
|
+
def get_file_info(full_path, requested_path)
|
|
1254
|
+
{
|
|
1255
|
+
status: 200,
|
|
1256
|
+
body: {
|
|
1257
|
+
path: requested_path,
|
|
1258
|
+
type: 'file',
|
|
1259
|
+
size: File.size(full_path),
|
|
1260
|
+
modified: File.mtime(full_path).iso8601
|
|
1261
|
+
}.to_json,
|
|
1262
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
1263
|
+
}
|
|
1264
|
+
end
|
|
1265
|
+
|
|
1266
|
+
# Detect content type based on file extension
|
|
1267
|
+
def detect_content_type(file_path)
|
|
1268
|
+
case File.extname(file_path).downcase
|
|
1269
|
+
when '.txt', '.log' then 'text/plain'
|
|
1270
|
+
when '.json' then 'application/json'
|
|
1271
|
+
when '.yml', '.yaml' then 'application/x-yaml'
|
|
1272
|
+
when '.md' then 'text/markdown'
|
|
1273
|
+
when '.html' then 'text/html'
|
|
1274
|
+
when '.css' then 'text/css'
|
|
1275
|
+
when '.js' then 'application/javascript'
|
|
1276
|
+
when '.py' then 'text/x-python'
|
|
1277
|
+
when '.rb' then 'text/x-ruby'
|
|
1278
|
+
else 'application/octet-stream'
|
|
1279
|
+
end
|
|
1280
|
+
end
|
|
1281
|
+
|
|
1282
|
+
# Generate workspace API error response
|
|
1283
|
+
def workspace_error_response(status, error_type, message)
|
|
1284
|
+
{
|
|
1285
|
+
status: status,
|
|
1286
|
+
body: {
|
|
1287
|
+
error: error_type,
|
|
1288
|
+
message: message
|
|
1289
|
+
}.to_json,
|
|
1290
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
1291
|
+
}
|
|
1292
|
+
end
|
|
767
1293
|
end
|
|
768
1294
|
end
|
|
769
1295
|
end
|
|
@@ -4,6 +4,7 @@ require_relative 'agent/base'
|
|
|
4
4
|
require_relative 'agent/executor'
|
|
5
5
|
require_relative 'agent/task_executor'
|
|
6
6
|
require_relative 'agent/web_server'
|
|
7
|
+
require_relative 'agent/execution_state'
|
|
7
8
|
require_relative 'agent/instrumentation'
|
|
8
9
|
require_relative 'dsl'
|
|
9
10
|
require_relative 'logger'
|
|
@@ -162,15 +163,14 @@ module LanguageOperator
|
|
|
162
163
|
# Hybrid mode: All agents now run main work AND web server (chat endpoints always enabled)
|
|
163
164
|
logger.info('Starting hybrid agent (autonomous + web server)',
|
|
164
165
|
agent_name: agent_def.name,
|
|
165
|
-
chat_endpoint_enabled: true,
|
|
166
|
+
chat_endpoint_enabled: true, # Always true now
|
|
166
167
|
has_webhooks: agent_def.webhooks.any?,
|
|
167
|
-
has_mcp_tools: !!
|
|
168
|
+
has_mcp_tools: !!agent_def.mcp_server&.tools?)
|
|
168
169
|
|
|
169
170
|
# Start web server in background thread
|
|
170
171
|
web_server = LanguageOperator::Agent::WebServer.new(agent)
|
|
171
172
|
agent_def.webhooks.each { |webhook_def| webhook_def.register(web_server) }
|
|
172
|
-
web_server
|
|
173
|
-
web_server.register_chat_endpoint(agent_def.chat_endpoint, agent) # Always register chat endpoint
|
|
173
|
+
register_standard_endpoints(web_server, agent, agent_def)
|
|
174
174
|
|
|
175
175
|
web_thread = Thread.new do
|
|
176
176
|
web_server.start
|
|
@@ -199,33 +199,30 @@ module LanguageOperator
|
|
|
199
199
|
raise 'Agent definition must have either main block (DSL v1) or workflow (DSL v0)'
|
|
200
200
|
end
|
|
201
201
|
when 'scheduled', 'event-driven'
|
|
202
|
-
#
|
|
203
|
-
logger.info('
|
|
202
|
+
# Standby mode - web server only, wait for /api/v1/execute triggers
|
|
203
|
+
logger.info('Starting agent in standby mode (scheduled) - waiting for HTTP triggers',
|
|
204
204
|
agent_name: agent_def.name,
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
# DSL v1: Execute main block once
|
|
209
|
-
execute_main_block(agent, agent_def)
|
|
210
|
-
elsif uses_dsl_v0
|
|
211
|
-
# DSL v0: Execute workflow once
|
|
212
|
-
executor = LanguageOperator::Agent::Executor.new(agent)
|
|
213
|
-
executor.execute_workflow(agent_def)
|
|
214
|
-
else
|
|
215
|
-
raise 'Agent definition must have either main block (DSL v1) or workflow (DSL v0)'
|
|
216
|
-
end
|
|
205
|
+
chat_endpoint_enabled: true,
|
|
206
|
+
has_webhooks: agent_def.webhooks.any?,
|
|
207
|
+
has_mcp_tools: !!agent_def.mcp_server&.tools?)
|
|
217
208
|
|
|
218
|
-
|
|
219
|
-
|
|
209
|
+
web_server = LanguageOperator::Agent::WebServer.new(agent)
|
|
210
|
+
agent_def.webhooks.each { |webhook_def| webhook_def.register(web_server) }
|
|
211
|
+
register_standard_endpoints(web_server, agent, agent_def)
|
|
212
|
+
web_server.register_execute_endpoint(agent, agent_def)
|
|
220
213
|
|
|
221
|
-
#
|
|
222
|
-
agent.send(:flush_telemetry)
|
|
214
|
+
web_server.start # Blocks here, waiting for requests
|
|
223
215
|
when 'reactive', 'http', 'webhook'
|
|
224
|
-
#
|
|
216
|
+
# Standby mode - web server with webhooks, MCP tools, chat, and execute endpoint
|
|
217
|
+
logger.info('Starting agent in standby mode (reactive)',
|
|
218
|
+
agent_name: agent_def.name,
|
|
219
|
+
has_webhooks: agent_def.webhooks.any?)
|
|
220
|
+
|
|
225
221
|
web_server = LanguageOperator::Agent::WebServer.new(agent)
|
|
226
222
|
agent_def.webhooks.each { |webhook_def| webhook_def.register(web_server) }
|
|
227
|
-
web_server
|
|
228
|
-
web_server.
|
|
223
|
+
register_standard_endpoints(web_server, agent, agent_def)
|
|
224
|
+
web_server.register_execute_endpoint(agent, agent_def)
|
|
225
|
+
|
|
229
226
|
web_server.start
|
|
230
227
|
else
|
|
231
228
|
raise "Unknown agent mode: #{agent.mode}"
|
|
@@ -233,6 +230,18 @@ module LanguageOperator
|
|
|
233
230
|
end
|
|
234
231
|
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
235
232
|
|
|
233
|
+
# Register standard endpoints for web server
|
|
234
|
+
#
|
|
235
|
+
# @param web_server [LanguageOperator::Agent::WebServer] The web server instance
|
|
236
|
+
# @param agent [LanguageOperator::Agent::Base] The agent instance
|
|
237
|
+
# @param agent_def [LanguageOperator::Dsl::AgentDefinition] The agent definition
|
|
238
|
+
# @return [void]
|
|
239
|
+
def self.register_standard_endpoints(web_server, agent, agent_def)
|
|
240
|
+
web_server.register_mcp_tools(agent_def.mcp_server) if agent_def.mcp_server&.tools?
|
|
241
|
+
web_server.register_chat_endpoint(agent)
|
|
242
|
+
web_server.register_workspace_endpoints(agent)
|
|
243
|
+
end
|
|
244
|
+
|
|
236
245
|
# Execute main block (DSL v1) in autonomous mode
|
|
237
246
|
#
|
|
238
247
|
# @param agent [LanguageOperator::Agent::Base] The agent instance
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"$id": "https://github.com/language-operator/language-operator-gem/schema/agent-dsl.json",
|
|
4
4
|
"title": "Language Operator Agent DSL",
|
|
5
5
|
"description": "Schema for defining autonomous AI agents using the Language Operator DSL",
|
|
6
|
-
"version": "0.1.
|
|
6
|
+
"version": "0.1.73",
|
|
7
7
|
"type": "object",
|
|
8
8
|
"properties": {
|
|
9
9
|
"name": {
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: language-operator
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.81
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- James Ryan
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 2025-12-26 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: mcp
|
|
@@ -533,6 +533,7 @@ files:
|
|
|
533
533
|
- lib/language_operator/agent.rb
|
|
534
534
|
- lib/language_operator/agent/base.rb
|
|
535
535
|
- lib/language_operator/agent/event_config.rb
|
|
536
|
+
- lib/language_operator/agent/execution_state.rb
|
|
536
537
|
- lib/language_operator/agent/executor.rb
|
|
537
538
|
- lib/language_operator/agent/instrumentation.rb
|
|
538
539
|
- lib/language_operator/agent/metadata_collector.rb
|
|
@@ -698,7 +699,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
698
699
|
- !ruby/object:Gem::Version
|
|
699
700
|
version: '0'
|
|
700
701
|
requirements: []
|
|
701
|
-
rubygems_version: 3.6.
|
|
702
|
+
rubygems_version: 3.6.6
|
|
702
703
|
specification_version: 4
|
|
703
704
|
summary: Ruby SDK for Language Operator
|
|
704
705
|
test_files: []
|