language-operator 0.1.71 → 0.1.80

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 11da2ddaf7396a49d94451edc45cd07a05b8ac2d9239b587f46cf7b5a938714f
4
- data.tar.gz: 998d0061d7a4a7fee8a61de4b034766bceb4421363cf177b4e6e38ada37c04b0
3
+ metadata.gz: 2e6aa13623ea7f320f5be1ffbf252aa2320fdcb951c658905c43476f63f30a2a
4
+ data.tar.gz: a2136ce8803e111628610981f33b3ae5c9332662b97aaf36f483b0a144403e2a
5
5
  SHA512:
6
- metadata.gz: 44f424c8f7ff4a5d147f86027a7e68e4bfacbda5ed3a0747793c35e5967d58e339b001dc421eb3826c6d1cd359343fc74ea9de96cc0e1d5eeefd45d101ca3d1e
7
- data.tar.gz: b74bcaf2d2762cccb6c282041b3cd40d6c1ffd6635dfc1920855f68ffbf9bbe8c46e5e8bb052e77d4807d0ba2c28eaf92b516649be4891cd64ca1a04ceaf476f
6
+ metadata.gz: f68216fc87b0d38c29ac06d2d133dba4f5cbef23a4a8a848a91205c3545e4757123fccbc7a51f7e2e7940d6e7168c76af3cd6ab380f52ae7cc520256d4d1d352
7
+ data.tar.gz: 0c4c40b6e2c813e9b0dd4b2dcc2cd9a8083ae56cbbd9b26512298f640f176e8d28ff872a4479a100ff7be58b80c3b949f1dd40925691573dc3a1ed11a834f7c7
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- language-operator (0.1.71)
4
+ language-operator (0.1.80)
5
5
  faraday (~> 2.0)
6
6
  json-schema (~> 5.0)
7
7
  k8s-ruby (~> 0.17)
@@ -2,7 +2,7 @@
2
2
 
3
3
  source 'https://rubygems.org'
4
4
 
5
- gem 'language-operator', '~> 0.1.70'
5
+ gem 'language-operator', '~> 0.1.71'
6
6
 
7
7
  # Agent-specific dependencies for autonomous execution
8
8
  gem 'concurrent-ruby', '~> 1.3'
@@ -2,7 +2,7 @@
2
2
 
3
3
  source 'https://rubygems.org'
4
4
 
5
- gem 'language-operator', '~> 0.1.46'
5
+ gem 'language-operator', '~> 0.1.71'
6
6
 
7
7
  # Tool server dependencies
8
8
  gem 'json', '~> 2.7'
@@ -107,24 +107,29 @@ module LanguageOperator
107
107
  @executor.run_loop
108
108
  end
109
109
 
110
- # Run in scheduled mode (execute once - Kubernetes CronJob handles scheduling)
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 without definition - executing goal once')
114
+ logger.info('Agent running in scheduled mode (standby) - waiting for HTTP triggers')
115
115
 
116
- goal = ENV.fetch('AGENT_INSTRUCTIONS', 'Complete the assigned task')
117
- execute_goal(goal)
118
-
119
- logger.info('Scheduled execution completed - exiting')
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
- agent.config&.dig('agent', 'description') ||
212
- "AI assistant"
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 @chat_endpoint
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
- # Skip system messages - we handle them via PromptBuilder
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, # No chat config needed
552
- template: :standard, # Good default
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, # No chat config needed
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, # Always true now
166
+ chat_endpoint_enabled: true, # Always true now
166
167
  has_webhooks: agent_def.webhooks.any?,
167
- has_mcp_tools: !!(agent_def.mcp_server&.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.register_mcp_tools(agent_def.mcp_server) if agent_def.mcp_server&.tools?
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
- # Scheduled mode: Execute once and exit (Kubernetes CronJob handles scheduling)
203
- logger.info('Agent running in scheduled mode - executing once',
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
- dsl_version: uses_dsl_v1 ? 'v1' : 'v0')
206
-
207
- if uses_dsl_v1
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
- logger.info('Scheduled execution completed - exiting',
219
- agent_name: agent_def.name)
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
- # Flush telemetry for short-lived processes
222
- agent.send(:flush_telemetry)
214
+ web_server.start # Blocks here, waiting for requests
223
215
  when 'reactive', 'http', 'webhook'
224
- # Start web server with webhooks, MCP tools, and chat endpoint
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.register_mcp_tools(agent_def.mcp_server) if agent_def.mcp_server&.tools?
228
- web_server.register_chat_endpoint(agent_def.chat_endpoint, agent) if agent_def.chat_endpoint
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
@@ -2,7 +2,7 @@
2
2
  :openapi: 3.0.3
3
3
  :info:
4
4
  :title: Language Operator Agent API
5
- :version: 0.1.71
5
+ :version: 0.1.73
6
6
  :description: HTTP API endpoints exposed by Language Operator reactive agents
7
7
  :contact:
8
8
  :name: Language Operator
@@ -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.71",
6
+ "version": "0.1.73",
7
7
  "type": "object",
8
8
  "properties": {
9
9
  "name": {
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LanguageOperator
4
- VERSION = '0.1.71'
4
+ VERSION = '0.1.80'
5
5
  end
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.71
4
+ version: 0.1.80
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Ryan
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
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.9
702
+ rubygems_version: 3.6.6
702
703
  specification_version: 4
703
704
  summary: Ruby SDK for Language Operator
704
705
  test_files: []