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 +4 -4
- data/.rubocop.yml +0 -3
- data/Gemfile.lock +1 -1
- data/lib/language_operator/cli/commands/system.rb +354 -2
- data/lib/language_operator/cli/formatters/log_formatter.rb +18 -22
- data/lib/language_operator/cli/formatters/progress_formatter.rb +9 -5
- 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
- data/synth/001/Makefile +4 -80
- data/synth/001/agent.rb +9 -15
- data/synth/001/agent.txt +1 -0
- data/synth/001/output.log +9 -38
- data/synth/README.md +4 -4
- metadata +2 -3
- data/lib/language_operator/synthesis_test_harness.rb +0 -324
- data/synth/001/agent.yaml +0 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7515f5597eb485e7053423816336675f70bb8967e96072a68d94dd0a613cfb53
|
|
4
|
+
data.tar.gz: 896319f5aecbc4b8ff5bb67717e48beec8a9aa72dd4337af6f0588f8794c9a35
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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
|
-
['
|
|
169
|
-
when /Loading persona|Persona
|
|
170
|
-
['
|
|
168
|
+
['☰', :cyan]
|
|
169
|
+
when /Loading persona|Persona:|Configuring|Configuration/i
|
|
170
|
+
['☰', :cyan]
|
|
171
171
|
when /Connecting to tool|Calling tool|MCP server/i
|
|
172
|
-
['
|
|
172
|
+
['☰', :blue]
|
|
173
173
|
when /LLM request|Prompt|🤖/i
|
|
174
|
-
['
|
|
174
|
+
['☰', :magenta]
|
|
175
175
|
when /Tool completed|result|response|found/i
|
|
176
|
-
['
|
|
176
|
+
['☰', :yellow]
|
|
177
177
|
when /Iteration completed|completed|finished/i
|
|
178
|
-
['
|
|
178
|
+
['✔', :green]
|
|
179
179
|
when /Execution complete|✅|workflow.*completed/i
|
|
180
|
-
['
|
|
180
|
+
['✔', :green]
|
|
181
181
|
when /error|fail|✗|❌/i
|
|
182
182
|
['✗', :red]
|
|
183
|
-
when /warn
|
|
184
|
-
['
|
|
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
|
-
['
|
|
191
|
+
['⚠', :yellow]
|
|
192
192
|
when 'DEBUG'
|
|
193
|
-
['
|
|
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("
|
|
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 "
|
|
31
|
+
puts "#{pastel.green('✔')} #{message}"
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def error(message)
|
|
35
|
-
puts "
|
|
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 "
|
|
47
|
+
puts "#{pastel.yellow.bold('⚠')} #{message}"
|
|
44
48
|
end
|
|
45
49
|
end
|
|
46
50
|
end
|
|
@@ -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.45",
|
|
7
7
|
"type": "object",
|
|
8
8
|
"properties": {
|
|
9
9
|
"name": {
|
data/synth/001/Makefile
CHANGED
|
@@ -1,90 +1,14 @@
|
|
|
1
|
-
.PHONY: synthesize
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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 '
|
|
6
|
-
description '
|
|
5
|
+
agent 'test-agent' do
|
|
6
|
+
description 'Log a message to stdout as per instructions'
|
|
7
7
|
|
|
8
|
-
task :
|
|
9
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
main do |_inputs|
|
|
13
|
+
result = execute_task(:generate_log_message)
|
|
14
|
+
result
|
|
21
15
|
end
|
|
22
16
|
|
|
23
|
-
output do
|
|
24
|
-
|
|
17
|
+
output do |outputs|
|
|
18
|
+
puts outputs[:message]
|
|
25
19
|
end
|
|
26
20
|
end
|
data/synth/001/agent.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Say something in your logs
|
data/synth/001/output.log
CHANGED
|
@@ -1,44 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
📁 /agent/agent.rb
|
|
1
|
+
📁 /etc/agent/code/agent.rb
|
|
3
2
|
[1;36m·[0m OpenTelemetry disabled
|
|
4
3
|
[1;36m·[0m Configuring LLM (provider=openai_compatible, model=mistralai/magistral-small-2509, timeout=300)
|
|
5
4
|
[1;36m·[0m LLM configuration complete
|
|
6
5
|
[1;36m·[0m No MCP servers configured, agent will run without tools
|
|
7
6
|
[1;36m·[0m Chat session initialized (with_tools=false)
|
|
8
|
-
[1;36m·[0m
|
|
9
|
-
[1;36m·[0m
|
|
10
|
-
[1;36m·[0m
|
|
11
|
-
[1;
|
|
12
|
-
|
|
7
|
+
[1;36m·[0m Executing main block (agent=test-agent, task_count=1)
|
|
8
|
+
[1;36m·[0m Executing main block (inputs_keys=[])
|
|
9
|
+
[1;36m·[0m Executing task (task=generate_log_message, type=symbolic, timeout=30.0, max_retries=3)
|
|
10
|
+
[1;36m·[0m Main execution (0.0s)
|
|
11
|
+
[1;36m·[0m Main block completed
|
|
12
|
+
[1;36m·[0m Main block execution completed (result={message: "Test agent is saying hello!"})
|
|
13
|
+
Test agent is saying hello!
|
|
14
|
+
[32m✔[0m 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
|
-
[1;36m·[0m LLM request (34.877s)
|
|
27
|
-
[1;35m·[0m [1mLLM Response:[0m
|
|
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
|
-
[1;33m·[0m Could not write output to workspace: No such file or directory @ rb_sysopen - /workspace/output.txt
|
|
39
|
-
[1;36m·[0m 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
|
-
[1;36m·[0m 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
|
|
162
|
+
The synthesis functionality is now integrated directly into the `aictl` CLI:
|
|
163
163
|
|
|
164
|
-
```
|
|
165
|
-
|
|
164
|
+
```bash
|
|
165
|
+
aictl system synthesize [INSTRUCTIONS]
|
|
166
166
|
```
|
|
167
167
|
|
|
168
|
-
|
|
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.
|
|
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.
|
|
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
|