language-operator 0.1.44 → 0.1.45

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: 588de63b620795a41863a1fe516368c01ea002290a87c49d3632839b0d8b190b
4
- data.tar.gz: d16c8648276ddfd5a4d4bced8f5bc3a263a51b8250f195858c5c459450785292
3
+ metadata.gz: 7515f5597eb485e7053423816336675f70bb8967e96072a68d94dd0a613cfb53
4
+ data.tar.gz: 896319f5aecbc4b8ff5bb67717e48beec8a9aa72dd4337af6f0588f8794c9a35
5
5
  SHA512:
6
- metadata.gz: 6e58ec25b437ee27932f0067e7404fc7fc44e8597e44771c6338fd5886cef30792c61fd01fd595db22bf03d40a9c661332807807b67fdfdeb84e4a16c922c6f6
7
- data.tar.gz: b84d6129fe0e7464d4ad620d7a82000298f5ec95345e9c451a0996b4fd8f976aa0981599942bbfdc9d0b711543fe8838725960bfcc09bf242e8da22dfaa4a047
6
+ metadata.gz: 49bf23ff4521a0b827d44b2176512d664af2eb895446114c23aa4fd8d3ab65d1c4bc32da07daf2dda74adbfee3453e41b4dfc5b82c3e4dbfa42431929bb3ac28
7
+ data.tar.gz: 5c258dad400d7790ccbf967862bf508796730382cf3a85463a3886d0d29ab27415706846ddefbdc6941d927705a118bb5cc5d24fa98d71d7caa55868e8531eed
data/.rubocop.yml CHANGED
@@ -40,7 +40,6 @@ Metrics/ClassLength:
40
40
  - 'lib/language_operator/agent/**/*'
41
41
  - 'lib/language_operator/kubernetes/**/*'
42
42
  - 'lib/language_operator/dsl/**/*'
43
- - 'lib/language_operator/synthesis_test_harness.rb'
44
43
 
45
44
  Metrics/ModuleLength:
46
45
  Max: 150
@@ -112,7 +111,6 @@ Naming/MethodParameterName:
112
111
  Naming/PredicateMethod:
113
112
  Exclude:
114
113
  - 'lib/language_operator/agent/webhook_authenticator.rb'
115
- - 'lib/language_operator/synthesis_test_harness.rb'
116
114
 
117
115
  # Layout
118
116
  Layout/LineLength:
@@ -121,4 +119,3 @@ Layout/LineLength:
121
119
  - 'spec/**/*'
122
120
  - '*.gemspec'
123
121
  - 'lib/language_operator/agent/executor.rb'
124
- - 'lib/language_operator/synthesis_test_harness.rb'
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- language-operator (0.1.44)
4
+ language-operator (0.1.45)
5
5
  faraday (~> 2.0)
6
6
  k8s-ruby (~> 0.17)
7
7
  mcp (~> 0.4)
@@ -198,7 +198,7 @@ module LanguageOperator
198
198
  end
199
199
  end
200
200
 
201
- desc 'synthesize INSTRUCTIONS', 'Synthesize agent code from natural language instructions'
201
+ desc 'synthesize [INSTRUCTIONS]', 'Synthesize agent code from natural language instructions'
202
202
  long_desc <<-DESC
203
203
  Synthesize agent code by converting natural language instructions
204
204
  into Ruby DSL code without creating an actual agent.
@@ -207,6 +207,9 @@ module LanguageOperator
207
207
  agent code. If --model is not specified, the first available model will
208
208
  be auto-selected.
209
209
 
210
+ Instructions can be provided either as a command argument or via STDIN.
211
+ If no argument is provided, the command will read from STDIN.
212
+
210
213
  This command helps you validate your instructions and understand how the
211
214
  synthesis engine interprets them. Use --dry-run to see the prompt that
212
215
  would be sent to the LLM, or run without it to generate actual code.
@@ -224,6 +227,12 @@ module LanguageOperator
224
227
  # Output raw code without formatting (useful for piping to files)
225
228
  aictl system synthesize "Monitor logs" --raw > agent.rb
226
229
 
230
+ # Read instructions from STDIN
231
+ cat instructions.txt | aictl system synthesize > agent.rb
232
+
233
+ # Read from STDIN with pipe
234
+ echo "Monitor GitHub issues" | aictl system synthesize --raw
235
+
227
236
  # Specify custom agent name and tools
228
237
  aictl system synthesize "Process webhooks from GitHub" \\
229
238
  --agent-name github-processor \\
@@ -236,8 +245,29 @@ module LanguageOperator
236
245
  option :model, type: :string, desc: 'Model to use for synthesis (defaults to first available in cluster)'
237
246
  option :dry_run, type: :boolean, default: false, desc: 'Show prompt without calling LLM'
238
247
  option :raw, type: :boolean, default: false, desc: 'Output only the raw code without formatting'
239
- def synthesize(instructions)
248
+ def synthesize(instructions = nil)
240
249
  handle_command_error('synthesize agent') do
250
+ # Read instructions from STDIN if not provided as argument
251
+ if instructions.nil? || instructions.strip.empty?
252
+ if $stdin.tty?
253
+ Formatters::ProgressFormatter.error('No instructions provided')
254
+ puts
255
+ puts 'Provide instructions either as an argument or via STDIN:'
256
+ puts ' aictl system synthesize "Your instructions here"'
257
+ puts ' cat instructions.txt | aictl system synthesize'
258
+ exit 1
259
+ else
260
+ instructions = $stdin.read.strip
261
+ if instructions.empty?
262
+ Formatters::ProgressFormatter.error('No instructions provided')
263
+ puts
264
+ puts 'Provide instructions either as an argument or via STDIN:'
265
+ puts ' aictl system synthesize "Your instructions here"'
266
+ puts ' cat instructions.txt | aictl system synthesize'
267
+ exit 1
268
+ end
269
+ end
270
+ end
241
271
  # Select model to use for synthesis
242
272
  selected_model = select_synthesis_model
243
273
 
@@ -306,6 +336,137 @@ module LanguageOperator
306
336
  end
307
337
  end
308
338
 
339
+ desc 'exec [AGENT_FILE]', 'Execute an agent file in a test pod on the cluster'
340
+ long_desc <<-DESC
341
+ Deploy and execute an agent file in a temporary test pod on the Kubernetes cluster.
342
+
343
+ This command creates a ConfigMap with the agent code, deploys a test pod,
344
+ streams the logs until completion, and cleans up all resources.
345
+
346
+ The agent code is mounted at /etc/agent/code/agent.rb as expected by the agent runtime.
347
+
348
+ Agent code can be provided either as a file path or via STDIN.
349
+ If no file path is provided, the command will read from STDIN.
350
+
351
+ Examples:
352
+ # Execute a synthesized agent file
353
+ aictl system exec agent.rb
354
+
355
+ # Execute with a custom agent name
356
+ aictl system exec agent.rb --agent-name my-test
357
+
358
+ # Keep the pod after execution for debugging
359
+ aictl system exec agent.rb --keep-pod
360
+
361
+ # Use a different agent image
362
+ aictl system exec agent.rb --image ghcr.io/language-operator/agent:v0.1.0
363
+
364
+ # Read agent code from STDIN
365
+ cat agent.rb | aictl system exec
366
+
367
+ # Pipe synthesized code directly to execution
368
+ cat agent.txt | aictl system synthesize | aictl system exec
369
+ DESC
370
+ option :agent_name, type: :string, default: 'test-agent', desc: 'Name for the test agent pod'
371
+ option :keep_pod, type: :boolean, default: false, desc: 'Keep the pod after execution (for debugging)'
372
+ option :image, type: :string, default: 'ghcr.io/language-operator/agent:latest', desc: 'Agent container image'
373
+ option :timeout, type: :numeric, default: 300, desc: 'Timeout in seconds for agent execution'
374
+ def exec(agent_file = nil)
375
+ handle_command_error('exec agent') do
376
+ # Verify cluster is selected
377
+ unless ctx.client
378
+ Formatters::ProgressFormatter.error('No cluster context available')
379
+ puts
380
+ puts 'Please configure kubectl with a valid cluster context:'
381
+ puts ' kubectl config get-contexts'
382
+ puts ' kubectl config use-context <context-name>'
383
+ exit 1
384
+ end
385
+
386
+ # Read agent code from file or STDIN
387
+ agent_code = if agent_file && !agent_file.strip.empty?
388
+ # Read from file
389
+ unless File.exist?(agent_file)
390
+ Formatters::ProgressFormatter.error("Agent file not found: #{agent_file}")
391
+ exit 1
392
+ end
393
+ File.read(agent_file)
394
+ elsif $stdin.tty?
395
+ # Read from STDIN
396
+ Formatters::ProgressFormatter.error('No agent code provided')
397
+ puts
398
+ puts 'Provide agent code either as a file or via STDIN:'
399
+ puts ' aictl system exec agent.rb'
400
+ puts ' cat agent.rb | aictl system exec'
401
+ exit 1
402
+ else
403
+ code = $stdin.read.strip
404
+ if code.empty?
405
+ Formatters::ProgressFormatter.error('No agent code provided')
406
+ puts
407
+ puts 'Provide agent code either as a file or via STDIN:'
408
+ puts ' aictl system exec agent.rb'
409
+ puts ' cat agent.rb | aictl system exec'
410
+ exit 1
411
+ end
412
+ code
413
+ end
414
+
415
+ # Generate unique names
416
+ timestamp = Time.now.to_i
417
+ configmap_name = "#{options[:agent_name]}-code-#{timestamp}"
418
+ pod_name = "#{options[:agent_name]}-#{timestamp}"
419
+
420
+ begin
421
+ # Create ConfigMap with agent code
422
+ Formatters::ProgressFormatter.with_spinner('Creating ConfigMap with agent code') do
423
+ create_agent_configmap(configmap_name, agent_code)
424
+ end
425
+
426
+ # Create test pod
427
+ Formatters::ProgressFormatter.with_spinner('Creating test pod') do
428
+ create_test_pod(pod_name, configmap_name, options[:image])
429
+ end
430
+
431
+ # Wait for pod to be ready or running
432
+ Formatters::ProgressFormatter.with_spinner('Waiting for pod to start') do
433
+ wait_for_pod_start(pod_name, timeout: 60)
434
+ end
435
+
436
+ # Stream logs until pod completes
437
+ stream_pod_logs(pod_name, timeout: options[:timeout])
438
+
439
+ # Wait for pod to fully terminate and get final status
440
+ exit_code = wait_for_pod_termination(pod_name)
441
+
442
+ if exit_code&.zero?
443
+ Formatters::ProgressFormatter.success('Agent completed successfully')
444
+ elsif exit_code
445
+ Formatters::ProgressFormatter.error("Agent failed with exit code: #{exit_code}")
446
+ else
447
+ Formatters::ProgressFormatter.warn('Unable to determine pod exit status')
448
+ end
449
+ ensure
450
+ # Clean up resources unless --keep-pod
451
+ puts
452
+ puts
453
+ if options[:keep_pod]
454
+ Formatters::ProgressFormatter.info('Resources kept for debugging:')
455
+ puts " Pod: #{pod_name}"
456
+ puts " ConfigMap: #{configmap_name}"
457
+ puts
458
+ puts "To view logs: kubectl logs -n #{ctx.namespace} #{pod_name}"
459
+ puts "To delete: kubectl delete pod,configmap -n #{ctx.namespace} #{pod_name} #{configmap_name}"
460
+ else
461
+ Formatters::ProgressFormatter.with_spinner('Cleaning up resources') do
462
+ delete_pod(pod_name)
463
+ delete_configmap(configmap_name)
464
+ end
465
+ end
466
+ end
467
+ end
468
+ end
469
+
309
470
  desc 'synthesis-template', 'Export synthesis templates for agent code generation'
310
471
  long_desc <<-DESC
311
472
  Export the synthesis templates used by the Language Operator to generate
@@ -894,6 +1055,197 @@ module LanguageOperator
894
1055
 
895
1056
  puts YAML.dump(data)
896
1057
  end
1058
+
1059
+ # Create a ConfigMap with agent code
1060
+ def create_agent_configmap(name, code)
1061
+ configmap = {
1062
+ 'apiVersion' => 'v1',
1063
+ 'kind' => 'ConfigMap',
1064
+ 'metadata' => {
1065
+ 'name' => name,
1066
+ 'namespace' => ctx.namespace
1067
+ },
1068
+ 'data' => {
1069
+ 'agent.rb' => code
1070
+ }
1071
+ }
1072
+
1073
+ ctx.client.create_resource(configmap)
1074
+ end
1075
+
1076
+ # Create a test pod for running the agent
1077
+ def create_test_pod(name, configmap_name, image)
1078
+ # Detect available models in the cluster
1079
+ model_env = detect_model_config
1080
+
1081
+ env_vars = [
1082
+ { 'name' => 'AGENT_NAME', 'value' => name },
1083
+ { 'name' => 'AGENT_MODE', 'value' => 'autonomous' },
1084
+ { 'name' => 'AGENT_CODE_PATH', 'value' => '/etc/agent/code/agent.rb' },
1085
+ { 'name' => 'CONFIG_PATH', 'value' => '/nonexistent/config.yaml' }
1086
+ ]
1087
+
1088
+ # Add model configuration if available
1089
+ env_vars += model_env if model_env
1090
+
1091
+ pod = {
1092
+ 'apiVersion' => 'v1',
1093
+ 'kind' => 'Pod',
1094
+ 'metadata' => {
1095
+ 'name' => name,
1096
+ 'namespace' => ctx.namespace,
1097
+ 'labels' => {
1098
+ 'app.kubernetes.io/name' => name,
1099
+ 'app.kubernetes.io/component' => 'test-agent'
1100
+ }
1101
+ },
1102
+ 'spec' => {
1103
+ 'restartPolicy' => 'Never',
1104
+ 'containers' => [
1105
+ {
1106
+ 'name' => 'agent',
1107
+ 'image' => image,
1108
+ 'env' => env_vars,
1109
+ 'volumeMounts' => [
1110
+ {
1111
+ 'name' => 'agent-code',
1112
+ 'mountPath' => '/etc/agent/code',
1113
+ 'readOnly' => true
1114
+ }
1115
+ ]
1116
+ }
1117
+ ],
1118
+ 'volumes' => [
1119
+ {
1120
+ 'name' => 'agent-code',
1121
+ 'configMap' => {
1122
+ 'name' => configmap_name
1123
+ }
1124
+ }
1125
+ ]
1126
+ }
1127
+ }
1128
+
1129
+ ctx.client.create_resource(pod)
1130
+ end
1131
+
1132
+ # Detect model configuration from the cluster
1133
+ def detect_model_config
1134
+ models = ctx.client.list_resources('LanguageModel', namespace: ctx.namespace)
1135
+ return nil if models.empty?
1136
+
1137
+ # Use first available model
1138
+ model = models.first
1139
+ model_name = model.dig('metadata', 'name')
1140
+ model_id = model.dig('spec', 'modelName')
1141
+
1142
+ # Build endpoint URL (port 8000 is the model service port)
1143
+ endpoint = "http://#{model_name}.#{ctx.namespace}.svc.cluster.local:8000"
1144
+
1145
+ [
1146
+ { 'name' => 'MODEL_ENDPOINTS', 'value' => endpoint },
1147
+ { 'name' => 'LLM_MODEL', 'value' => model_id },
1148
+ { 'name' => 'OPENAI_API_KEY', 'value' => 'sk-dummy-key-for-local-proxy' }
1149
+ ]
1150
+ rescue StandardError
1151
+ # If we can't detect models, return nil and let the agent handle it
1152
+ nil
1153
+ end
1154
+
1155
+ # Wait for pod to start (running or terminated)
1156
+ def wait_for_pod_start(name, timeout: 60)
1157
+ start_time = Time.now
1158
+ loop do
1159
+ pod = ctx.client.get_resource('Pod', name, ctx.namespace)
1160
+ phase = pod.dig('status', 'phase')
1161
+
1162
+ return if %w[Running Succeeded Failed].include?(phase)
1163
+
1164
+ raise "Pod #{name} did not start within #{timeout} seconds" if Time.now - start_time > timeout
1165
+
1166
+ sleep 1
1167
+ end
1168
+ end
1169
+
1170
+ # Stream pod logs until completion
1171
+ def stream_pod_logs(name, timeout: 300)
1172
+ require 'open3'
1173
+
1174
+ cmd = "kubectl logs -f -n #{ctx.namespace} #{name} 2>&1"
1175
+ Open3.popen3(cmd) do |_stdin, stdout, _stderr, wait_thr|
1176
+ # Set up timeout
1177
+ start_time = Time.now
1178
+
1179
+ # Stream logs
1180
+ stdout.each_line do |line|
1181
+ puts line
1182
+
1183
+ # Check timeout
1184
+ if Time.now - start_time > timeout
1185
+ Process.kill('TERM', wait_thr.pid)
1186
+ raise "Log streaming timed out after #{timeout} seconds"
1187
+ end
1188
+ end
1189
+
1190
+ # Wait for process to complete
1191
+ wait_thr.value
1192
+ end
1193
+ rescue Errno::EPIPE
1194
+ # Pod terminated, logs finished
1195
+ end
1196
+
1197
+ # Wait for pod to terminate and get exit code
1198
+ def wait_for_pod_termination(name, timeout: 10)
1199
+ # Give the pod a moment to fully transition after logs complete
1200
+ sleep 2
1201
+
1202
+ start_time = Time.now
1203
+ loop do
1204
+ pod = ctx.client.get_resource('Pod', name, ctx.namespace)
1205
+ phase = pod.dig('status', 'phase')
1206
+ container_status = pod.dig('status', 'containerStatuses', 0)
1207
+
1208
+ # Pod completed successfully or failed
1209
+ if %w[Succeeded Failed].include?(phase) && container_status && (terminated = container_status.dig('state', 'terminated'))
1210
+ return terminated['exitCode']
1211
+ end
1212
+
1213
+ # Check timeout
1214
+ if Time.now - start_time > timeout
1215
+ # Try one last time
1216
+ if container_status && (terminated = container_status.dig('state', 'terminated'))
1217
+ return terminated['exitCode']
1218
+ end
1219
+
1220
+ return nil
1221
+ end
1222
+
1223
+ sleep 0.5
1224
+ rescue K8s::Error::NotFound
1225
+ # Pod was deleted before we could get status
1226
+ return nil
1227
+ end
1228
+ end
1229
+
1230
+ # Get pod status
1231
+ def get_pod_status(name)
1232
+ pod = ctx.client.get_resource('Pod', name, ctx.namespace)
1233
+ pod.to_h.fetch('status', {})
1234
+ end
1235
+
1236
+ # Delete a pod
1237
+ def delete_pod(name)
1238
+ ctx.client.delete_resource('Pod', name, ctx.namespace)
1239
+ rescue K8s::Error::NotFound
1240
+ # Already deleted
1241
+ end
1242
+
1243
+ # Delete a ConfigMap
1244
+ def delete_configmap(name)
1245
+ ctx.client.delete_resource('ConfigMap', name, ctx.namespace)
1246
+ rescue K8s::Error::NotFound
1247
+ # Already deleted
1248
+ end
897
1249
  end
898
1250
  end
899
1251
  end
@@ -108,8 +108,8 @@ module LanguageOperator
108
108
  emoji_or_text = Regexp.last_match(1)
109
109
  rest = Regexp.last_match(2)
110
110
 
111
- # Check if first part is an emoji (common log emojis)
112
- if emoji_or_text =~ /[▶👤◆🤖→✓✅✗❌⚠️🔍ℹ🔄]/
111
+ # Check if first part is an emoji (common log emojis - ProgressFormatter standard)
112
+ if emoji_or_text =~ /[☰☢⚠✗✔✅]/
113
113
  level = emoji_to_level(emoji_or_text)
114
114
  # Message already has emoji, just format rest without adding another icon
115
115
  message_text, metadata = extract_metadata(rest)
@@ -165,34 +165,34 @@ module LanguageOperator
165
165
  def determine_icon_and_color(message, level)
166
166
  case message
167
167
  when /Starting execution|Starting iteration|Starting autonomous/i
168
- ['', :cyan]
169
- when /Loading persona|Persona:/i
170
- ['👤', :cyan]
168
+ ['', :cyan]
169
+ when /Loading persona|Persona:|Configuring|Configuration/i
170
+ ['', :cyan]
171
171
  when /Connecting to tool|Calling tool|MCP server/i
172
- ['', :blue]
172
+ ['', :blue]
173
173
  when /LLM request|Prompt|🤖/i
174
- ['🤖', :magenta]
174
+ ['', :magenta]
175
175
  when /Tool completed|result|response|found/i
176
- ['', :yellow]
176
+ ['', :yellow]
177
177
  when /Iteration completed|completed|finished/i
178
- ['', :green]
178
+ ['', :green]
179
179
  when /Execution complete|✅|workflow.*completed/i
180
- ['', :green]
180
+ ['', :green]
181
181
  when /error|fail|✗|❌/i
182
182
  ['✗', :red]
183
- when /warn|⚠️/i
184
- ['⚠️', :yellow]
183
+ when /warn|⚠/i
184
+ ['', :yellow]
185
185
  else
186
186
  # Default based on level
187
187
  case level&.upcase
188
188
  when 'ERROR'
189
189
  ['✗', :red]
190
190
  when 'WARN'
191
- ['⚠️', :yellow]
191
+ ['', :yellow]
192
192
  when 'DEBUG'
193
- ['🔍', :dim]
193
+ ['', :dim]
194
194
  else
195
- ['', :white]
195
+ ['', :white]
196
196
  end
197
197
  end
198
198
  end
@@ -249,19 +249,15 @@ module LanguageOperator
249
249
  # Convert emoji to log level
250
250
  def emoji_to_level(emoji)
251
251
  case emoji
252
- when 'ℹ️', 'ℹ'
252
+ when 'ℹ️', 'ℹ', '☰'
253
253
  'INFO'
254
- when '🔍'
254
+ when '🔍', '☢'
255
255
  'DEBUG'
256
256
  when '⚠️', '⚠'
257
257
  'WARN'
258
258
  when '❌', '✗'
259
259
  'ERROR'
260
- when '', '👤', ''
261
- 'INFO'
262
- when '🤖'
263
- 'INFO'
264
- when '→', '✓', '✅'
260
+ when '', '', ''
265
261
  'INFO'
266
262
  else
267
263
  'INFO'
@@ -12,7 +12,7 @@ module LanguageOperator
12
12
  include Helpers::PastelHelper
13
13
 
14
14
  def with_spinner(message, success_msg: nil, &block)
15
- spinner = TTY::Spinner.new("[:spinner] #{message}...", format: :dots, success_mark: pastel.green('✔'))
15
+ spinner = TTY::Spinner.new(":spinner #{message}...", format: :dots, success_mark: pastel.green('✔'))
16
16
  spinner.auto_spin
17
17
 
18
18
  result = block.call
@@ -28,19 +28,23 @@ module LanguageOperator
28
28
  end
29
29
 
30
30
  def success(message)
31
- puts "[#{pastel.green('✔')}] #{message}"
31
+ puts "#{pastel.green('✔')} #{message}"
32
32
  end
33
33
 
34
34
  def error(message)
35
- puts "[#{pastel.red('✗')}] #{message}"
35
+ puts "#{pastel.red('✗')} #{message}"
36
36
  end
37
37
 
38
38
  def info(message)
39
- puts pastel.dim(message)
39
+ puts "#{pastel.white('☰')} #{pastel.dim(message)}"
40
+ end
41
+
42
+ def debug(message)
43
+ puts "#{pastel.white('☢')} #{pastel.dim(message)}"
40
44
  end
41
45
 
42
46
  def warn(message)
43
- puts "[#{pastel.yellow('⚠')}] #{message}"
47
+ puts "#{pastel.yellow.bold('⚠')} #{message}"
44
48
  end
45
49
  end
46
50
  end
@@ -2,7 +2,7 @@
2
2
  :openapi: 3.0.3
3
3
  :info:
4
4
  :title: Language Operator Agent API
5
- :version: 0.1.44
5
+ :version: 0.1.45
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.44",
6
+ "version": "0.1.45",
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.44'
4
+ VERSION = '0.1.45'
5
5
  end
data/synth/001/Makefile CHANGED
@@ -1,90 +1,14 @@
1
- .PHONY: synthesize synthesize-all synthesize-sonnet synthesize-gpt-4 run run-docker validate clean compare help
2
-
3
- # Default model - inherit from SYNTHESIS_MODEL env var, no hardcoded fallback
4
- MODEL ?= $(SYNTHESIS_MODEL)
5
-
6
- # Model-specific names
7
- SONNET_MODEL = claude-3-7-sonnet-20250219
8
- GPT4_MODEL = gpt-4-turbo
1
+ .PHONY: synthesize exec
9
2
 
10
3
  help:
11
4
  @echo "Synthesis Test Targets:"
12
5
  @echo " make synthesize - Generate agent.rb using default model"
13
- @echo " make synthesize-all - Generate for all configured models"
14
- @echo " make synthesize-sonnet - Generate agent.sonnet.rb"
15
- @echo " make synthesize-gpt-4 - Generate agent.gpt-4.rb"
16
6
  @echo " make run - Execute agent.rb locally with bundle exec"
17
- @echo " make run-docker - Execute agent.rb in Docker container"
18
7
  @echo " make validate - Validate agent.rb syntax"
19
8
  @echo " make clean - Remove all generated .rb files"
20
- @echo " make compare - Compare outputs from different models"
21
9
 
22
10
  synthesize:
23
- @echo "Synthesizing agent.rb with model: $(MODEL)..."
24
- @bundle exec ruby -I ../../lib -e "\
25
- require 'language_operator/synthesis_test_harness'; \
26
- harness = LanguageOperator::SynthesisTestHarness.new(model: '$(MODEL)'); \
27
- code = harness.synthesize('agent.yaml'); \
28
- File.write('agent.rb', code); \
29
- puts 'Generated: agent.rb'"
30
-
31
- synthesize-sonnet:
32
- @echo "Synthesizing with Claude Sonnet..."
33
- @bundle exec ruby -I ../../lib -e "\
34
- require 'language_operator/synthesis_test_harness'; \
35
- harness = LanguageOperator::SynthesisTestHarness.new(model: '$(SONNET_MODEL)'); \
36
- code = harness.synthesize('agent.yaml'); \
37
- File.write('agent.sonnet.rb', code); \
38
- puts 'Generated: agent.sonnet.rb'"
39
-
40
- synthesize-gpt-4:
41
- @echo "Synthesizing with GPT-4..."
42
- @bundle exec ruby -I ../../lib -e "\
43
- require 'language_operator/synthesis_test_harness'; \
44
- harness = LanguageOperator::SynthesisTestHarness.new(model: '$(GPT4_MODEL)'); \
45
- code = harness.synthesize('agent.yaml'); \
46
- File.write('agent.gpt-4.rb', code); \
47
- puts 'Generated: agent.gpt-4.rb'"
48
-
49
- synthesize-all: synthesize-sonnet synthesize-gpt-4
50
- @echo "All synthesis complete!"
51
-
52
- run:
53
- @if [ ! -f agent.rb ]; then \
54
- echo "Error: agent.rb not found. Run 'make synthesize' first."; \
55
- exit 1; \
56
- fi
57
- @echo "Executing agent.rb in Docker container..."
58
- @docker run --rm -i \
59
- --network host \
60
- -e AGENT_NAME=hello-world \
61
- -e AGENT_MODE=autonomous \
62
- -e AGENT_CODE_PATH=/agent/agent.rb \
63
- -e LLM_MODEL=$(SYNTHESIS_MODEL) \
64
- -e MODEL_ENDPOINTS=$(SYNTHESIS_ENDPOINT) \
65
- -e OPENAI_API_KEY=$(SYNTHESIS_API_KEY) \
66
- -e ANTHROPIC_API_KEY=$(ANTHROPIC_API_KEY) \
67
- -v $(PWD)/agent.rb:/agent/agent.rb:ro \
68
- ghcr.io/language-operator/agent:dev
69
-
70
- validate:
71
- @if [ ! -f agent.rb ]; then \
72
- echo "Error: agent.rb not found. Run 'make synthesize' first."; \
73
- exit 1; \
74
- fi
75
- @echo "Validating agent.rb..."
76
- @ruby -c agent.rb && echo "Syntax: OK"
77
-
78
- clean:
79
- @echo "Cleaning generated files..."
80
- @rm -f agent.rb agent.*.rb
81
- @echo "Clean complete!"
11
+ cat agent.txt | bundle exec ../../bin/aictl system synthesize --raw | tee agent.rb
82
12
 
83
- compare:
84
- @echo "Comparing model outputs..."
85
- @if [ -f agent.sonnet.rb ] && [ -f agent.gpt-4.rb ]; then \
86
- echo "=== Claude Sonnet vs GPT-4 ==="; \
87
- diff -u agent.sonnet.rb agent.gpt-4.rb || true; \
88
- else \
89
- echo "Error: Run 'make synthesize-all' first to generate comparison files"; \
90
- fi
13
+ exec:
14
+ cat agent.rb | bundle exec ../../bin/aictl system exec | tee output.log
data/synth/001/agent.rb CHANGED
@@ -2,25 +2,19 @@
2
2
 
3
3
  require 'language_operator'
4
4
 
5
- agent 'hello-world' do
6
- description 'Logs a message to stdout'
5
+ agent 'test-agent' do
6
+ description 'Log a message to stdout as per instructions'
7
7
 
8
- task :log_message,
9
- instructions: "log the message 'Hello, world!' to agent logs",
10
- inputs: {},
11
- outputs: { result: 'string' }
12
-
13
- main do |_inputs|
14
- puts 'Hello, world!'
15
- { result: 'message logged' }
8
+ task :generate_log_message do |_inputs|
9
+ { message: 'Test agent is saying hello!' }
16
10
  end
17
11
 
18
- constraints do
19
- max_iterations 999_999
20
- timeout '10m'
12
+ main do |_inputs|
13
+ result = execute_task(:generate_log_message)
14
+ result
21
15
  end
22
16
 
23
- output do
24
- workspace 'results/output.txt'
17
+ output do |outputs|
18
+ puts outputs[:message]
25
19
  end
26
20
  end
@@ -0,0 +1 @@
1
+ Say something in your logs
data/synth/001/output.log CHANGED
@@ -1,44 +1,15 @@
1
- Executing agent.rb in Docker container...
2
- 📁 /agent/agent.rb
1
+ 📁 /etc/agent/code/agent.rb
3
2
  · OpenTelemetry disabled
4
3
  · Configuring LLM (provider=openai_compatible, model=mistralai/magistral-small-2509, timeout=300)
5
4
  · LLM configuration complete
6
5
  · No MCP servers configured, agent will run without tools
7
6
  · Chat session initialized (with_tools=false)
8
- · Audit logger initialized (log_path=/tmp/langop-audit.jsonl)
9
- · Safety manager initialized (enabled=true, budget_tracking=false, rate_limiting=false, content_filtering=false, audit_logging=true)
10
- · Starting workflow execution: hello-world
11
- · Prompt sent to LLM:
12
- # Task: Logs a message to stdout
7
+ · Executing main block (agent=test-agent, task_count=1)
8
+ · Executing main block (inputs_keys=[])
9
+ · Executing task (task=generate_log_message, type=symbolic, timeout=30.0, max_retries=3)
10
+ · Main execution (0.0s)
11
+ · Main block completed
12
+ · Main block execution completed (result={message: "Test agent is saying hello!"})
13
+ Test agent is saying hello!
14
+ ✔ Agent completed successfully
13
15
 
14
- ## Objectives:
15
- - Log the message 'Hello, world!' to agent logs
16
-
17
- ## Workflow Steps:
18
- Log message
19
-
20
- ## Constraints:
21
- - Maximum iterations: 999999
22
- - Timeout: 10m
23
-
24
- Please complete this task following the workflow steps.
25
-
26
- · LLM request (34.877s)
27
- · LLM Response:
28
- To solve the task of logging 'Hello, world!' to stdout, the most straightforward and universally applicable solution is to use a print statement. Given that the task does not specify a programming language, Python's `print` function is chosen as it is widely used and easily understandable. The constraints of maximum iterations and timeout do not affect this simple task, as it is a one-time operation.
29
-
30
- Here is the solution:
31
-
32
- ```python
33
- print('Hello, world!')
34
- ```
35
-
36
- This code snippet will output 'Hello, world!' to the standard output, fulfilling the task's requirement. The simplicity of this solution ensures that it is both correct and appropriate for the given instructions.
37
-
38
- · Could not write output to workspace: No such file or directory @ rb_sysopen - /workspace/output.txt
39
- · Output (first 500 chars): [THINK]Alright, I need to log the message 'Hello, world!' to stdout. The task is straightforward, but let's make sure I understand all the requirements.
40
-
41
- First, the objective is clear: log 'Hello, world!' to agent logs. The workflow step says "Log message," which seems to be the action required.
42
-
43
- Constraints mention a maximum of 999999 iterations and a timeout of 10 minutes. But since this is just logging a message, it's likely a one-time action, so iterations might not be relevant here unless th
44
- · Workflow execution completed (34.9s, total_tokens=1020, estimated_cost=$0.0)
data/synth/README.md CHANGED
@@ -159,13 +159,13 @@ end
159
159
 
160
160
  ### Implementation
161
161
 
162
- The test harness is implemented in:
162
+ The synthesis functionality is now integrated directly into the `aictl` CLI:
163
163
 
164
- ```
165
- lib/language_operator/synthesis_test_harness.rb
164
+ ```bash
165
+ aictl system synthesize [INSTRUCTIONS]
166
166
  ```
167
167
 
168
- It replicates the Go operator's synthesis logic but runs locally in Ruby.
168
+ This command uses LanguageModel resources from your cluster to generate agent code.
169
169
 
170
170
  ## Adding New Test Cases
171
171
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: language-operator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.44
4
+ version: 0.1.45
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Ryan
@@ -517,7 +517,6 @@ files:
517
517
  - lib/language_operator/logger.rb
518
518
  - lib/language_operator/retry.rb
519
519
  - lib/language_operator/retryable.rb
520
- - lib/language_operator/synthesis_test_harness.rb
521
520
  - lib/language_operator/templates/README.md
522
521
  - lib/language_operator/templates/agent_synthesis.tmpl
523
522
  - lib/language_operator/templates/persona_distillation.tmpl
@@ -539,7 +538,7 @@ files:
539
538
  - lib/language_operator/version.rb
540
539
  - synth/001/Makefile
541
540
  - synth/001/agent.rb
542
- - synth/001/agent.yaml
541
+ - synth/001/agent.txt
543
542
  - synth/001/output.log
544
543
  - synth/Makefile
545
544
  - synth/README.md
@@ -1,324 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'yaml'
4
- require 'json'
5
- require 'ruby_llm'
6
- require_relative 'agent/safety/ast_validator'
7
-
8
- module LanguageOperator
9
- # SynthesisTestHarness replicates the Go operator's synthesis logic for local testing.
10
- # This allows testing agent code generation without requiring a Kubernetes cluster.
11
- #
12
- # Usage:
13
- # harness = LanguageOperator::SynthesisTestHarness.new
14
- # code = harness.synthesize('synth/001/agent.yaml', model: 'claude-3-5-sonnet-20241022')
15
- # File.write('agent.rb', code)
16
- #
17
- class SynthesisTestHarness
18
- attr_reader :template_content, :model
19
-
20
- def initialize(model: nil)
21
- @model = model || detect_default_model
22
- @synthesis_endpoint = ENV.fetch('SYNTHESIS_ENDPOINT', nil)
23
- @synthesis_api_key = ENV['SYNTHESIS_API_KEY'] || 'dummy'
24
- @template_path = File.join(__dir__, 'templates', 'examples', 'agent_synthesis.tmpl')
25
- load_template
26
- end
27
-
28
- # Synthesize agent code from a LanguageAgent YAML file
29
- #
30
- # @param yaml_path [String] Path to LanguageAgent YAML file
31
- # @param model [String, nil] LLM model to use (overrides default)
32
- # @return [String] Generated Ruby DSL code
33
- def synthesize(yaml_path, model: nil)
34
- agent_spec = load_agent_spec(yaml_path)
35
-
36
- # Build synthesis request
37
- request = build_synthesis_request(agent_spec)
38
-
39
- # Build prompt from template
40
- prompt = build_prompt(request)
41
-
42
- # Call LLM
43
- response = call_llm(prompt, model: model || @model)
44
-
45
- # Extract code from markdown
46
- code = extract_code_from_markdown(response)
47
-
48
- # Validate code
49
- validate_code(code)
50
-
51
- code
52
- end
53
-
54
- private
55
-
56
- def load_template
57
- raise "Synthesis template not found at: #{@template_path}" unless File.exist?(@template_path)
58
-
59
- @template_content = File.read(@template_path)
60
- end
61
-
62
- def load_agent_spec(yaml_path)
63
- raise "Agent YAML file not found: #{yaml_path}" unless File.exist?(yaml_path)
64
-
65
- yaml_content = File.read(yaml_path)
66
- full_spec = YAML.safe_load(yaml_content, permitted_classes: [Symbol])
67
-
68
- raise "Invalid kind: expected LanguageAgent, got #{full_spec['kind']}" unless full_spec['kind'] == 'LanguageAgent'
69
-
70
- # Extract agent name from metadata and merge into spec
71
- agent_spec = full_spec['spec'].dup
72
- agent_spec['agentName'] = full_spec.dig('metadata', 'name') if full_spec.dig('metadata', 'name')
73
-
74
- agent_spec
75
- end
76
-
77
- def build_synthesis_request(agent_spec)
78
- # NOTE: agent_spec is the 'spec' section from the YAML
79
- # The agent name comes from metadata.name, which we need to extract from the full YAML
80
- {
81
- instructions: agent_spec['instructions'],
82
- agent_name: agent_spec['agentName'] || 'test-agent', # Will be overridden by metadata.name
83
- tools: agent_spec['toolRefs'] || [],
84
- models: agent_spec['modelRefs'] || [],
85
- persona: agent_spec['personaRefs']&.first || nil
86
- }
87
- end
88
-
89
- def build_prompt(request)
90
- # Detect temporal intent from instructions
91
- temporal_intent = detect_temporal_intent(request[:instructions])
92
-
93
- # Format tools list
94
- tools_list = format_list(request[:tools], 'No tools specified')
95
-
96
- # Format models list
97
- models_list = format_list(request[:models], 'No models specified')
98
-
99
- # Build persona section
100
- persona_section = ''
101
- persona_section = " persona <<~PERSONA\n #{request[:persona]}\n PERSONA\n" if request[:persona]
102
-
103
- # Build schedule section
104
- schedule_section = ''
105
- schedule_rules = ''
106
-
107
- # Build constraints section
108
- constraints_section = build_constraints_section(temporal_intent)
109
-
110
- case temporal_intent
111
- when :scheduled
112
- schedule_section = "\n # Extract schedule from instructions (e.g., \"daily at noon\" -> \"0 12 * * *\")\n schedule \"CRON_EXPRESSION\""
113
- schedule_rules = "2. Schedule detected - extract cron expression from instructions\n3. Set schedule block with appropriate cron expression\n4. Use high max_iterations for continuous scheduled operation"
114
- when :oneshot
115
- schedule_rules = "2. One-shot execution detected - agent will run a limited number of times\n3. Do NOT include a schedule block for one-shot agents"
116
- when :continuous
117
- schedule_rules = "2. No temporal intent detected - defaulting to continuous execution\n3. Do NOT include a schedule block unless explicitly mentioned\n4. Use high max_iterations for continuous operation"
118
- end
119
-
120
- # Render template with variable substitution
121
- rendered = @template_content.dup
122
-
123
- # Handle conditional sections (simple implementation for {{if .ErrorContext}})
124
- rendered.gsub!(/\{\{if \.ErrorContext\}\}.*?\{\{else\}\}/m, '')
125
- rendered.gsub!('{{end}}', '')
126
-
127
- # Replace variables
128
- rendered.gsub!('{{.Instructions}}', request[:instructions])
129
- rendered.gsub!('{{.ToolsList}}', tools_list)
130
- rendered.gsub!('{{.ModelsList}}', models_list)
131
- rendered.gsub!('{{.AgentName}}', request[:agent_name])
132
- rendered.gsub!('{{.TemporalIntent}}', temporal_intent.to_s.capitalize)
133
- rendered.gsub!('{{.PersonaSection}}', persona_section)
134
- rendered.gsub!('{{.ScheduleSection}}', schedule_section)
135
- rendered.gsub!('{{.ConstraintsSection}}', constraints_section)
136
- rendered.gsub!('{{.ScheduleRules}}', schedule_rules)
137
-
138
- rendered
139
- end
140
-
141
- def detect_temporal_intent(instructions)
142
- return :continuous if instructions.nil? || instructions.strip.empty?
143
-
144
- lower = instructions.downcase
145
-
146
- # One-shot indicators
147
- oneshot_keywords = ['run once', 'one time', 'single time', 'execute once', 'just once']
148
- return :oneshot if oneshot_keywords.any? { |keyword| lower.include?(keyword) }
149
-
150
- # Schedule indicators
151
- schedule_keywords = %w[every daily hourly weekly monthly cron schedule periodically]
152
- return :scheduled if schedule_keywords.any? { |keyword| lower.include?(keyword) }
153
-
154
- # Default to continuous
155
- :continuous
156
- end
157
-
158
- def build_constraints_section(temporal_intent)
159
- case temporal_intent
160
- when :oneshot
161
- <<~CONSTRAINTS.chomp
162
- # One-shot execution detected from instructions
163
- constraints do
164
- max_iterations 10
165
- timeout "10m"
166
- end
167
- CONSTRAINTS
168
- when :scheduled
169
- <<~CONSTRAINTS.chomp
170
- # Scheduled execution - high iteration limit for continuous operation
171
- constraints do
172
- max_iterations 999999
173
- timeout "10m"
174
- end
175
- CONSTRAINTS
176
- when :continuous
177
- <<~CONSTRAINTS.chomp
178
- # Continuous execution - no specific schedule or one-shot indicator found
179
- constraints do
180
- max_iterations 999999
181
- timeout "10m"
182
- end
183
- CONSTRAINTS
184
- end
185
- end
186
-
187
- def format_list(items, default_text)
188
- return default_text if items.nil? || items.empty?
189
-
190
- items.map { |item| " - #{item}" }.join("\n")
191
- end
192
-
193
- def call_llm(prompt, model:)
194
- # Priority 1: Use SYNTHESIS_ENDPOINT if configured (OpenAI-compatible)
195
- return call_openai_compatible(prompt, model) if @synthesis_endpoint
196
-
197
- # Priority 2: Detect provider from model name
198
- provider, api_key = detect_provider(model)
199
-
200
- unless api_key
201
- raise "No API key found. Set either:\n " \
202
- "SYNTHESIS_ENDPOINT (for local/OpenAI-compatible)\n " \
203
- "ANTHROPIC_API_KEY (for Claude)\n " \
204
- 'OPENAI_API_KEY (for GPT)'
205
- end
206
-
207
- # Configure RubyLLM for the provider
208
- RubyLLM.configure do |config|
209
- case provider
210
- when :anthropic
211
- config.anthropic_api_key = api_key
212
- when :openai
213
- config.openai_api_key = api_key
214
- end
215
- end
216
-
217
- # Create chat and send message
218
- chat = RubyLLM.chat(model: model, provider: provider)
219
- response = chat.ask(prompt)
220
-
221
- # Extract content
222
- if response.respond_to?(:content)
223
- response.content
224
- elsif response.is_a?(Hash) && response.key?('content')
225
- response['content']
226
- elsif response.is_a?(String)
227
- response
228
- else
229
- response.to_s
230
- end
231
- rescue StandardError => e
232
- raise "LLM call failed: #{e.message}"
233
- end
234
-
235
- def call_openai_compatible(prompt, model)
236
- # Configure RubyLLM for OpenAI-compatible endpoint
237
- RubyLLM.configure do |config|
238
- config.openai_api_key = @synthesis_api_key
239
- config.openai_api_base = @synthesis_endpoint
240
- config.openai_use_system_role = true # Better compatibility with local models
241
- end
242
-
243
- # Create chat with OpenAI provider (will use configured endpoint)
244
- chat = RubyLLM.chat(model: model, provider: :openai, assume_model_exists: true)
245
-
246
- # Send message
247
- response = chat.ask(prompt)
248
-
249
- # Extract content
250
- if response.respond_to?(:content)
251
- response.content
252
- elsif response.is_a?(Hash) && response.key?('content')
253
- response['content']
254
- elsif response.is_a?(String)
255
- response
256
- else
257
- response.to_s
258
- end
259
- rescue StandardError => e
260
- raise "OpenAI-compatible endpoint call failed: #{e.message}"
261
- end
262
-
263
- def detect_provider(model)
264
- if model.start_with?('claude')
265
- [:anthropic, ENV.fetch('ANTHROPIC_API_KEY', nil)]
266
- elsif model.start_with?('gpt')
267
- [:openai, ENV.fetch('OPENAI_API_KEY', nil)]
268
- else
269
- # Default to Anthropic
270
- [:anthropic, ENV.fetch('ANTHROPIC_API_KEY', nil)]
271
- end
272
- end
273
-
274
- def detect_default_model
275
- # Priority 1: Use SYNTHESIS_MODEL if configured
276
- return ENV['SYNTHESIS_MODEL'] if ENV['SYNTHESIS_MODEL']
277
-
278
- # Priority 2: Use cloud providers
279
- if ENV['ANTHROPIC_API_KEY']
280
- 'claude-3-5-sonnet-20241022'
281
- elsif ENV['OPENAI_API_KEY']
282
- 'gpt-4-turbo'
283
- else
284
- # Default to a reasonable model name for local endpoints
285
- 'mistralai/magistral-small-2509'
286
- end
287
- end
288
-
289
- def extract_code_from_markdown(content)
290
- content = content.strip
291
-
292
- # Try ```ruby first
293
- if (match = content.match(/```ruby\n(.*?)```/m))
294
- return match[1].strip
295
- end
296
-
297
- # Try generic ``` blocks
298
- if (match = content.match(/```\n(.*?)```/m))
299
- return match[1].strip
300
- end
301
-
302
- # If no code blocks, return as-is and let validation catch it
303
- content
304
- end
305
-
306
- def validate_code(code)
307
- # Basic checks
308
- raise 'Empty code generated' if code.strip.empty?
309
- raise "Code does not contain 'agent' definition" unless code.include?('agent ')
310
- raise "Code does not require 'language_operator'" unless code.match?(/require ['"]language_operator['"]/)
311
-
312
- # AST validation for security
313
- validator = LanguageOperator::Agent::Safety::ASTValidator.new
314
- violations = validator.validate(code, '(generated)')
315
-
316
- unless violations.empty?
317
- error_msgs = violations.map { |v| v[:message] }.join("\n")
318
- raise "Security validation failed:\n#{error_msgs}"
319
- end
320
-
321
- true
322
- end
323
- end
324
- end
data/synth/001/agent.yaml DELETED
@@ -1,7 +0,0 @@
1
- apiVersion: langop.io/v1alpha1
2
- kind: LanguageAgent
3
- metadata:
4
- name: hello-world
5
- spec:
6
- instructions: |
7
- Say something in your logs