language-operator 0.1.59 → 0.1.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/persona.md +9 -0
  3. data/.claude/commands/task.md +46 -1
  4. data/.rubocop.yml +13 -0
  5. data/.rubocop_custom/use_ux_helper.rb +44 -0
  6. data/CHANGELOG.md +8 -0
  7. data/Gemfile.lock +12 -1
  8. data/Makefile +26 -7
  9. data/Makefile.common +50 -0
  10. data/bin/aictl +8 -1
  11. data/components/agent/Gemfile +1 -1
  12. data/components/agent/bin/langop-agent +7 -0
  13. data/docs/README.md +58 -0
  14. data/docs/{dsl/best-practices.md → best-practices.md} +4 -4
  15. data/docs/cli-reference.md +274 -0
  16. data/docs/{dsl/constraints.md → constraints.md} +5 -5
  17. data/docs/how-agents-work.md +156 -0
  18. data/docs/installation.md +218 -0
  19. data/docs/quickstart.md +299 -0
  20. data/docs/understanding-generated-code.md +265 -0
  21. data/docs/using-tools.md +457 -0
  22. data/docs/webhooks.md +509 -0
  23. data/examples/ux_helpers_demo.rb +296 -0
  24. data/lib/language_operator/agent/base.rb +14 -1
  25. data/lib/language_operator/agent/executor.rb +23 -6
  26. data/lib/language_operator/agent/safety/safe_executor.rb +41 -39
  27. data/lib/language_operator/agent/task_executor.rb +369 -68
  28. data/lib/language_operator/agent/web_server.rb +110 -14
  29. data/lib/language_operator/agent/webhook_authenticator.rb +39 -5
  30. data/lib/language_operator/agent.rb +88 -2
  31. data/lib/language_operator/cli/base_command.rb +17 -11
  32. data/lib/language_operator/cli/command_loader.rb +72 -0
  33. data/lib/language_operator/cli/commands/agent/base.rb +837 -0
  34. data/lib/language_operator/cli/commands/agent/code_operations.rb +102 -0
  35. data/lib/language_operator/cli/commands/agent/helpers/cluster_llm_client.rb +116 -0
  36. data/lib/language_operator/cli/commands/agent/helpers/code_parser.rb +115 -0
  37. data/lib/language_operator/cli/commands/agent/helpers/synthesis_watcher.rb +96 -0
  38. data/lib/language_operator/cli/commands/agent/learning.rb +289 -0
  39. data/lib/language_operator/cli/commands/agent/lifecycle.rb +102 -0
  40. data/lib/language_operator/cli/commands/agent/logs.rb +125 -0
  41. data/lib/language_operator/cli/commands/agent/workspace.rb +327 -0
  42. data/lib/language_operator/cli/commands/cluster.rb +129 -84
  43. data/lib/language_operator/cli/commands/install.rb +1 -1
  44. data/lib/language_operator/cli/commands/model/base.rb +215 -0
  45. data/lib/language_operator/cli/commands/model/test.rb +165 -0
  46. data/lib/language_operator/cli/commands/persona.rb +16 -34
  47. data/lib/language_operator/cli/commands/quickstart.rb +3 -2
  48. data/lib/language_operator/cli/commands/status.rb +40 -67
  49. data/lib/language_operator/cli/commands/system/base.rb +44 -0
  50. data/lib/language_operator/cli/commands/system/exec.rb +147 -0
  51. data/lib/language_operator/cli/commands/system/helpers/llm_synthesis.rb +183 -0
  52. data/lib/language_operator/cli/commands/system/helpers/pod_manager.rb +212 -0
  53. data/lib/language_operator/cli/commands/system/helpers/template_loader.rb +57 -0
  54. data/lib/language_operator/cli/commands/system/helpers/template_validator.rb +174 -0
  55. data/lib/language_operator/cli/commands/system/schema.rb +92 -0
  56. data/lib/language_operator/cli/commands/system/synthesis_template.rb +151 -0
  57. data/lib/language_operator/cli/commands/system/synthesize.rb +224 -0
  58. data/lib/language_operator/cli/commands/system/validate_template.rb +130 -0
  59. data/lib/language_operator/cli/commands/tool/base.rb +271 -0
  60. data/lib/language_operator/cli/commands/tool/install.rb +255 -0
  61. data/lib/language_operator/cli/commands/tool/search.rb +69 -0
  62. data/lib/language_operator/cli/commands/tool/test.rb +115 -0
  63. data/lib/language_operator/cli/commands/use.rb +29 -6
  64. data/lib/language_operator/cli/errors/handler.rb +20 -17
  65. data/lib/language_operator/cli/errors/suggestions.rb +3 -5
  66. data/lib/language_operator/cli/errors/thor_errors.rb +55 -0
  67. data/lib/language_operator/cli/formatters/code_formatter.rb +4 -11
  68. data/lib/language_operator/cli/formatters/log_formatter.rb +8 -15
  69. data/lib/language_operator/cli/formatters/progress_formatter.rb +6 -8
  70. data/lib/language_operator/cli/formatters/status_formatter.rb +26 -7
  71. data/lib/language_operator/cli/formatters/table_formatter.rb +47 -36
  72. data/lib/language_operator/cli/formatters/value_formatter.rb +75 -0
  73. data/lib/language_operator/cli/helpers/cluster_context.rb +5 -3
  74. data/lib/language_operator/cli/helpers/kubeconfig_validator.rb +2 -1
  75. data/lib/language_operator/cli/helpers/label_utils.rb +97 -0
  76. data/lib/language_operator/{ux/concerns/provider_helpers.rb → cli/helpers/provider_helper.rb} +10 -29
  77. data/lib/language_operator/cli/helpers/schedule_builder.rb +21 -1
  78. data/lib/language_operator/cli/helpers/user_prompts.rb +19 -11
  79. data/lib/language_operator/cli/helpers/ux_helper.rb +538 -0
  80. data/lib/language_operator/{ux/concerns/input_validation.rb → cli/helpers/validation_helper.rb} +13 -66
  81. data/lib/language_operator/cli/main.rb +50 -40
  82. data/lib/language_operator/cli/templates/tools/generic.yaml +3 -0
  83. data/lib/language_operator/cli/wizards/agent_wizard.rb +12 -20
  84. data/lib/language_operator/cli/wizards/model_wizard.rb +271 -0
  85. data/lib/language_operator/cli/wizards/quickstart_wizard.rb +8 -34
  86. data/lib/language_operator/client/base.rb +31 -1
  87. data/lib/language_operator/client/config.rb +4 -1
  88. data/lib/language_operator/client/mcp_connector.rb +1 -1
  89. data/lib/language_operator/config/cluster_config.rb +3 -2
  90. data/lib/language_operator/config.rb +38 -11
  91. data/lib/language_operator/constants/kubernetes_labels.rb +80 -0
  92. data/lib/language_operator/constants.rb +13 -0
  93. data/lib/language_operator/dsl/http.rb +127 -10
  94. data/lib/language_operator/dsl/task_definition.rb +7 -6
  95. data/lib/language_operator/dsl.rb +153 -6
  96. data/lib/language_operator/errors.rb +50 -0
  97. data/lib/language_operator/kubernetes/client.rb +11 -6
  98. data/lib/language_operator/kubernetes/resource_builder.rb +58 -84
  99. data/lib/language_operator/templates/schema/agent_dsl_openapi.yaml +1 -1
  100. data/lib/language_operator/templates/schema/agent_dsl_schema.json +1 -1
  101. data/lib/language_operator/type_coercion.rb +118 -34
  102. data/lib/language_operator/utils/secure_path.rb +74 -0
  103. data/lib/language_operator/utils.rb +7 -0
  104. data/lib/language_operator/validators.rb +54 -2
  105. data/lib/language_operator/version.rb +1 -1
  106. data/synth/001/Makefile +10 -2
  107. data/synth/001/agent.rb +16 -15
  108. data/synth/001/output.log +27 -10
  109. data/synth/002/Makefile +10 -2
  110. data/synth/003/Makefile +3 -3
  111. data/synth/003/README.md +205 -133
  112. data/synth/003/agent.optimized.rb +66 -0
  113. data/synth/003/agent.synthesized.rb +41 -0
  114. metadata +111 -35
  115. data/docs/dsl/agent-reference.md +0 -604
  116. data/docs/dsl/mcp-integration.md +0 -1177
  117. data/docs/dsl/webhooks.md +0 -932
  118. data/docs/dsl/workflows.md +0 -744
  119. data/lib/language_operator/cli/commands/agent.rb +0 -1712
  120. data/lib/language_operator/cli/commands/model.rb +0 -366
  121. data/lib/language_operator/cli/commands/system.rb +0 -1259
  122. data/lib/language_operator/cli/commands/tool.rb +0 -654
  123. data/lib/language_operator/cli/formatters/optimization_formatter.rb +0 -226
  124. data/lib/language_operator/cli/helpers/pastel_helper.rb +0 -24
  125. data/lib/language_operator/learning/adapters/base_adapter.rb +0 -147
  126. data/lib/language_operator/learning/adapters/jaeger_adapter.rb +0 -218
  127. data/lib/language_operator/learning/adapters/signoz_adapter.rb +0 -432
  128. data/lib/language_operator/learning/adapters/tempo_adapter.rb +0 -236
  129. data/lib/language_operator/learning/optimizer.rb +0 -318
  130. data/lib/language_operator/learning/pattern_detector.rb +0 -260
  131. data/lib/language_operator/learning/task_synthesizer.rb +0 -261
  132. data/lib/language_operator/learning/trace_analyzer.rb +0 -280
  133. data/lib/language_operator/templates/task_synthesis.tmpl +0 -97
  134. data/lib/language_operator/ux/base.rb +0 -81
  135. data/lib/language_operator/ux/concerns/README.md +0 -155
  136. data/lib/language_operator/ux/concerns/headings.rb +0 -90
  137. data/lib/language_operator/ux/create_agent.rb +0 -255
  138. data/lib/language_operator/ux/create_model.rb +0 -267
  139. data/lib/language_operator/ux/quickstart.rb +0 -594
  140. data/synth/003/agent.rb +0 -41
  141. data/synth/003/output.log +0 -68
  142. /data/docs/{architecture/agent-runtime.md → agent-internals.md} +0 -0
  143. /data/docs/{dsl/chat-endpoints.md → chat-endpoints.md} +0 -0
  144. /data/docs/{dsl/SCHEMA_VERSION.md → schema-versioning.md} +0 -0
@@ -25,6 +25,9 @@ module LanguageOperator
25
25
  class TaskTimeoutError < TaskExecutionError
26
26
  end
27
27
 
28
+ class TaskNetworkError < TaskExecutionError
29
+ end
30
+
28
31
  # Task Executor for DSL v1 organic functions
29
32
  #
30
33
  # Executes both neural (LLM-based) and symbolic (code-based) tasks.
@@ -74,6 +77,11 @@ module LanguageOperator
74
77
  @agent = agent
75
78
  @tasks = tasks
76
79
  @config = default_config.merge(config)
80
+
81
+ # Pre-cache task lookup and timeout information for performance
82
+ @task_cache = build_task_cache
83
+ @task_timeouts = build_timeout_cache
84
+
77
85
  logger.debug('TaskExecutor initialized',
78
86
  task_count: @tasks.size,
79
87
  timeout_symbolic: @config[:timeout_symbolic],
@@ -104,21 +112,26 @@ module LanguageOperator
104
112
  'task.inputs' => inputs.keys.map(&:to_s).join(','),
105
113
  'task.max_retries' => max_retries
106
114
  }) do
107
- # Find task definition
108
- task = @tasks[task_name.to_sym]
109
- raise ArgumentError, "Task not found: #{task_name}. Available tasks: #{@tasks.keys.join(', ')}" unless task
115
+ # Fast task lookup using pre-built cache
116
+ task_name_sym = task_name.to_sym
117
+ task_info = @task_cache[task_name_sym]
118
+ raise ArgumentError, "Task not found: #{task_name}. Available tasks: #{@tasks.keys.join(', ')}" unless task_info
110
119
 
111
- task_type = determine_task_type(task)
120
+ task = task_info[:definition]
121
+ task_type = task_info[:type]
112
122
 
113
- # Determine timeout based on task type if not explicitly provided
114
- timeout ||= task_timeout_for_type(task)
123
+ # Use cached timeout if not explicitly provided
124
+ timeout ||= @task_timeouts[task_name_sym]
115
125
 
116
- logger.info('Executing task',
117
- task: task_name,
118
- type: task_type,
119
- timeout: timeout,
120
- max_retries: max_retries,
121
- inputs: summarize_values(inputs))
126
+ # Optimize logging - only log if debug level enabled or log_executions is true
127
+ if logger.logger.level <= ::Logger::DEBUG || @config[:log_executions]
128
+ logger.info('Executing task',
129
+ task: task_name,
130
+ type: task_type,
131
+ timeout: timeout,
132
+ max_retries: max_retries,
133
+ inputs: summarize_values(inputs))
134
+ end
122
135
 
123
136
  # Add timeout to span attributes after it's determined
124
137
  OpenTelemetry::Trace.current_span&.set_attribute('task.timeout', timeout)
@@ -190,10 +203,46 @@ module LanguageOperator
190
203
  logger.info('Parsing neural task response',
191
204
  task: task.name)
192
205
 
193
- # Parse response within child span
206
+ # Parse response within child span with retry logic
194
207
  parsed_outputs = tracer.in_span('task_executor.parse_response') do |parse_span|
195
208
  record_parse_metadata(response_text, parse_span)
196
- parse_neural_response(response_text, task)
209
+
210
+ begin
211
+ parse_neural_response(response_text, task)
212
+ rescue RuntimeError => e
213
+ # If parsing fails and this is a JSON parsing error, try one more time with clarified prompt
214
+ raise e unless e.message.include?('returned invalid JSON') && !defined?(@parsing_retry_attempted)
215
+
216
+ @parsing_retry_attempted = true
217
+
218
+ logger.warn('JSON parsing failed, retrying with clarified prompt',
219
+ task: task.name,
220
+ original_error: e.message,
221
+ response_preview: response_text[0..300])
222
+
223
+ # Build retry prompt with clearer instructions
224
+ retry_prompt = build_parsing_retry_prompt(task, validated_inputs, response_text, e.message)
225
+
226
+ logger.info('Retrying LLM call with clarified prompt',
227
+ task: task.name,
228
+ retry_prompt_length: retry_prompt.length)
229
+
230
+ # Retry LLM call
231
+ retry_response = @agent.send_message(retry_prompt)
232
+ retry_response_text = retry_response.is_a?(String) ? retry_response : retry_response.content
233
+
234
+ logger.info('Parsing retry response',
235
+ task: task.name,
236
+ retry_response_length: retry_response_text.length)
237
+
238
+ # Try parsing the retry response
239
+ parse_neural_response(retry_response_text, task)
240
+
241
+ # Re-raise original error if not a JSON parsing error or already retried
242
+ ensure
243
+ # Reset retry flag for next task
244
+ @parsing_retry_attempted = nil
245
+ end
197
246
  end
198
247
 
199
248
  logger.info('Response parsed successfully',
@@ -234,19 +283,28 @@ module LanguageOperator
234
283
  # Execute the tool (it's a Proc/lambda wrapped by RubyLLM)
235
284
  result = tool.call(**params)
236
285
 
286
+ # Extract text from MCP Content objects
287
+ text_result = if result.is_a?(RubyLLM::MCP::Content)
288
+ result.text
289
+ elsif result.respond_to?(:map) && result.first.is_a?(RubyLLM::MCP::Content)
290
+ result.map(&:text).join
291
+ else
292
+ result
293
+ end
294
+
237
295
  logger.debug('Tool call completed',
238
296
  tool: tool_name_str,
239
- result_preview: result.is_a?(String) ? result[0..200] : result.class.name)
297
+ result_preview: text_result.is_a?(String) ? text_result[0..200] : text_result.class.name)
240
298
 
241
299
  # Try to parse JSON response if it looks like JSON
242
- if result.is_a?(String) && (result.strip.start_with?('{') || result.strip.start_with?('['))
243
- JSON.parse(result, symbolize_names: true)
300
+ if text_result.is_a?(String) && (text_result.strip.start_with?('{') || text_result.strip.start_with?('['))
301
+ JSON.parse(text_result, symbolize_names: true)
244
302
  else
245
- result
303
+ text_result
246
304
  end
247
305
  rescue JSON::ParserError
248
306
  # Not JSON, return as-is
249
- result
307
+ text_result
250
308
  end
251
309
 
252
310
  # Helper method for symbolic tasks to call LLM directly
@@ -284,13 +342,19 @@ module LanguageOperator
284
342
  def execute_parallel(tasks, in_threads: 4)
285
343
  require 'parallel'
286
344
 
345
+ # Capture current OpenTelemetry context before parallel execution
346
+ current_context = OpenTelemetry::Context.current
347
+
287
348
  logger.info('Executing tasks in parallel', count: tasks.size, threads: in_threads)
288
349
 
289
350
  results = Parallel.map(tasks, in_threads: in_threads) do |task_spec|
290
- task_name = task_spec[:name]
291
- task_inputs = task_spec[:inputs] || {}
351
+ # Restore OpenTelemetry context in worker thread
352
+ OpenTelemetry::Context.with_current(current_context) do
353
+ task_name = task_spec[:name]
354
+ task_inputs = task_spec[:inputs] || {}
292
355
 
293
- execute_task(task_name, inputs: task_inputs)
356
+ execute_task(task_name, inputs: task_inputs)
357
+ end
294
358
  end
295
359
 
296
360
  logger.info('Parallel execution complete', results_count: results.size)
@@ -310,22 +374,41 @@ module LanguageOperator
310
374
  end
311
375
 
312
376
  # Summarize hash values for logging (truncate long strings)
377
+ # Optimized for performance with lazy computation
313
378
  #
314
379
  # @param hash [Hash] Hash to summarize
315
380
  # @return [Hash] Summarized hash with truncated values
316
381
  def summarize_values(hash)
317
382
  return {} unless hash.is_a?(Hash)
318
383
 
319
- hash.transform_values do |v|
320
- case v
321
- when String
322
- v.length > 100 ? "#{v[0..97]}... (#{v.length} chars)" : v
323
- when Array
324
- v.length > 5 ? "#{v.first(3).inspect}... (#{v.length} items)" : v.inspect
325
- else
326
- v.inspect
327
- end
384
+ # OPTIMIZE: only create new hash if values need summarization
385
+ needs_summarization = false
386
+ result = {}
387
+
388
+ hash.each do |key, v|
389
+ summarized_value = case v
390
+ when String
391
+ if v.length > 100
392
+ needs_summarization = true
393
+ "#{v[0, 97]}... (#{v.length} chars)"
394
+ else
395
+ v
396
+ end
397
+ when Array
398
+ if v.length > 5
399
+ needs_summarization = true
400
+ "#{v.first(3).inspect}... (#{v.length} items)"
401
+ else
402
+ v.inspect
403
+ end
404
+ else
405
+ v.inspect
406
+ end
407
+ result[key] = summarized_value
328
408
  end
409
+
410
+ # Return original if no summarization was needed (rare optimization)
411
+ needs_summarization ? result : hash
329
412
  end
330
413
 
331
414
  # Build prompt for neural task execution
@@ -362,6 +445,63 @@ module LanguageOperator
362
445
  prompt
363
446
  end
364
447
 
448
+ # Build retry prompt when JSON parsing fails
449
+ #
450
+ # @param task [TaskDefinition] The task definition
451
+ # @param inputs [Hash] Validated input parameters
452
+ # @param failed_response [String] The previous response that failed to parse
453
+ # @param error_message [String] The parsing error message
454
+ # @return [String] Prompt for LLM retry
455
+ def build_parsing_retry_prompt(task, inputs, failed_response, error_message)
456
+ prompt = "# Task: #{task.name} (RETRY - JSON Parsing Failed)\n\n"
457
+ prompt += "## Instructions\n#{task.instructions_text}\n\n"
458
+
459
+ if inputs.any?
460
+ prompt += "## Inputs\n"
461
+ inputs.each do |key, value|
462
+ prompt += "- #{key}: #{value.inspect}\n"
463
+ end
464
+ prompt += "\n"
465
+ end
466
+
467
+ prompt += "## Previous Response (Failed to Parse)\n"
468
+ prompt += "Your previous response caused a parsing error: #{error_message}\n"
469
+ prompt += "Previous response preview:\n```\n#{failed_response[0..500]}#{'...' if failed_response.length > 500}\n```\n\n"
470
+
471
+ prompt += "## Output Schema (CRITICAL)\n"
472
+ prompt += "You MUST return valid JSON with exactly these fields:\n"
473
+ task.outputs_schema.each do |key, type|
474
+ prompt += "- #{key} (#{type})\n"
475
+ end
476
+ prompt += "\n"
477
+
478
+ prompt += "## Response Format (CRITICAL)\n"
479
+ prompt += "IMPORTANT: Your response must be ONLY valid JSON. No other text.\n"
480
+ prompt += "Do NOT use [THINK] tags or any other text.\n"
481
+ prompt += "Do NOT include code blocks like ```json.\n"
482
+ prompt += "Return ONLY the JSON object, nothing else.\n"
483
+ prompt += "The JSON must match the output schema exactly.\n\n"
484
+
485
+ prompt += "Example correct format:\n"
486
+ prompt += "{\n"
487
+ task.outputs_schema.each_with_index do |(key, type), index|
488
+ value_example = case type
489
+ when 'string' then '"example"'
490
+ when 'integer' then '42'
491
+ when 'number' then '3.14'
492
+ when 'boolean' then 'true'
493
+ when 'array' then '[]'
494
+ when 'hash' then '{}'
495
+ else '"value"'
496
+ end
497
+ comma = index < task.outputs_schema.length - 1 ? ',' : ''
498
+ prompt += " \"#{key}\": #{value_example}#{comma}\n"
499
+ end
500
+ prompt += "}\n"
501
+
502
+ prompt
503
+ end
504
+
365
505
  # Parse LLM response to extract output values
366
506
  #
367
507
  # @param response_text [String] LLM response
@@ -380,8 +520,32 @@ module LanguageOperator
380
520
  thinking_preview: thinking_blocks.first&.[](0..500))
381
521
  end
382
522
 
383
- # Strip thinking tags that some models add (e.g., [THINK]...[/THINK])
384
- cleaned_text = response_text.gsub(%r{\[THINK\].*?\[/THINK\]}m, '').strip
523
+ # Strip thinking tags that some models add (e.g., [THINK]...[/THINK] or unclosed [THINK]...)
524
+ # First try to strip matched pairs, then strip unclosed [THINK] only if there's JSON after it
525
+ logger.debug('Parsing neural response', task: task.name, response_length: response_text.length, response_start: response_text[0..100])
526
+
527
+ cleaned_text = response_text.gsub(%r{\[THINK\].*?\[/THINK\]}m, '')
528
+ .gsub(/\[THINK\].*?(?=\{)/m, '')
529
+ .strip
530
+
531
+ # If cleaned text is empty or still contains unclosed [THINK], try more aggressive cleaning
532
+ if cleaned_text.empty? || cleaned_text.start_with?('[THINK]')
533
+ # Strip everything from [THINK] to end if no [/THINK] found
534
+ cleaned_text = response_text.gsub(/\[THINK\].*$/m, '').strip
535
+
536
+ # If still no JSON found, extract everything after the last [THINK] block
537
+ if cleaned_text.empty? && response_text.include?('{')
538
+ last_think = response_text.rindex('[THINK]')
539
+ if last_think
540
+ after_think = response_text[last_think..]
541
+ # Find first JSON-like structure after [THINK]
542
+ json_start = after_think.index('{')
543
+ cleaned_text = after_think[json_start..] if json_start
544
+ end
545
+ end
546
+ end
547
+
548
+ logger.debug('After stripping THINK tags', cleaned_length: cleaned_text.length, cleaned_start: cleaned_text[0..100])
385
549
 
386
550
  # Try to extract JSON from response
387
551
  # Look for JSON code blocks first
@@ -389,11 +553,21 @@ module LanguageOperator
389
553
  json_text = if json_match
390
554
  json_match[1]
391
555
  else
392
- # Try to find raw JSON object
556
+ # Try to find raw JSON object - be more aggressive about finding JSON
393
557
  json_object_match = cleaned_text.match(/\{.*\}/m)
394
- json_object_match ? json_object_match[0] : cleaned_text
558
+ if json_object_match
559
+ json_object_match[0]
560
+ elsif cleaned_text.include?('{')
561
+ # Extract from first { to end of string (handles incomplete responses)
562
+ json_start = cleaned_text.index('{')
563
+ cleaned_text[json_start..]
564
+ else
565
+ cleaned_text
566
+ end
395
567
  end
396
568
 
569
+ logger.debug('Extracted JSON text', json_length: json_text.length, json_start: json_text[0..100])
570
+
397
571
  # Parse JSON
398
572
  parsed = JSON.parse(json_text)
399
573
 
@@ -407,13 +581,23 @@ module LanguageOperator
407
581
  raise "Neural task '#{task.name}' returned invalid JSON: #{e.message}"
408
582
  end
409
583
 
410
- # Recursively convert all hash keys to symbols
584
+ # Recursively convert all hash keys to symbols (optimized for performance)
411
585
  def deep_symbolize_keys(obj)
412
586
  case obj
413
587
  when Hash
414
- obj.transform_keys(&:to_sym).transform_values { |v| deep_symbolize_keys(v) }
588
+ # OPTIMIZE: pre-allocate hash with correct size and avoid double iteration
589
+ result = {}
590
+ obj.each do |key, value|
591
+ result[key.to_sym] = deep_symbolize_keys(value)
592
+ end
593
+ result
415
594
  when Array
416
- obj.map { |item| deep_symbolize_keys(item) }
595
+ # OPTIMIZE: pre-allocate array with correct size
596
+ result = Array.new(obj.size)
597
+ obj.each_with_index do |item, index|
598
+ result[index] = deep_symbolize_keys(item)
599
+ end
600
+ result
417
601
  else
418
602
  obj
419
603
  end
@@ -520,7 +704,11 @@ module LanguageOperator
520
704
  raise create_appropriate_error(task_name, last_error)
521
705
  end
522
706
 
523
- # Execute a single attempt of a task with timeout
707
+ # Execute a single attempt of a task with timeout and error context preservation
708
+ #
709
+ # This method implements timeout handling that preserves original error context
710
+ # while maintaining error precedence hierarchy. Timeout errors always take
711
+ # precedence over any nested errors (including network errors).
524
712
  #
525
713
  # @param task [TaskDefinition] The task definition
526
714
  # @param task_name [Symbol] Name of the task
@@ -533,29 +721,63 @@ module LanguageOperator
533
721
  attempt_start = Time.now
534
722
 
535
723
  result = if timeout.positive?
536
- Timeout.timeout(timeout) do
537
- execute_task_implementation(task, inputs)
538
- end
724
+ execute_with_timeout(task, task_name, inputs, timeout)
539
725
  else
540
726
  execute_task_implementation(task, inputs)
541
727
  end
542
728
 
543
729
  execution_time = Time.now - attempt_start
544
- logger.info('Task completed',
545
- task: task_name,
546
- attempt: attempt + 1,
547
- execution_time: execution_time.round(3),
548
- outputs: summarize_values(result))
730
+
731
+ # Optimize logging - only log if debug level enabled or log_executions is true
732
+ if logger.logger.level <= ::Logger::DEBUG || @config[:log_executions]
733
+ logger.info('Task completed',
734
+ task: task_name,
735
+ attempt: attempt + 1,
736
+ execution_time: execution_time.round(3),
737
+ outputs: summarize_values(result))
738
+ end
549
739
 
550
740
  result
741
+ end
742
+
743
+ # Execute task with timeout wrapper that preserves error context
744
+ #
745
+ # This method ensures that timeout errors always take precedence over
746
+ # any nested errors (e.g., network errors), solving the race condition
747
+ # between timeout detection and error classification.
748
+ #
749
+ # @param task [TaskDefinition] The task definition
750
+ # @param task_name [Symbol] Name of the task
751
+ # @param inputs [Hash] Input parameters
752
+ # @param timeout [Numeric] Timeout in seconds
753
+ # @return [Hash] Task outputs
754
+ # @raise [TaskTimeoutError] If execution times out (always takes precedence)
755
+ def execute_with_timeout(task, task_name, inputs, timeout)
756
+ attempt_start = Time.now
757
+
758
+ Timeout.timeout(timeout) do
759
+ execute_task_implementation(task, inputs)
760
+ end
551
761
  rescue Timeout::Error => e
762
+ # Timeout always wins - this solves the race condition
552
763
  execution_time = Time.now - attempt_start
764
+
553
765
  logger.warn('Task execution timed out',
554
766
  task: task_name,
555
- attempt: attempt + 1,
556
767
  timeout: timeout,
557
- execution_time: execution_time.round(3))
558
- raise TaskTimeoutError.new(task_name, "timed out after #{timeout}s", e)
768
+ execution_time: execution_time.round(3),
769
+ timeout_precedence: 'timeout error takes precedence over any nested errors')
770
+
771
+ # Always wrap as TaskTimeoutError, preserving original timeout context
772
+ raise TaskTimeoutError.new(task_name, "timed out after #{timeout}s (execution_time: #{execution_time.round(3)}s)", e)
773
+ rescue *RETRYABLE_ERRORS => e
774
+ # Network errors that escape timeout handling (very rare)
775
+ # These occur outside the timeout window, so they're genuine network errors
776
+ logger.debug('Network error outside timeout window',
777
+ task: task_name,
778
+ error: e.class.name,
779
+ message: e.message)
780
+ raise TaskNetworkError.new(task_name, "network error: #{e.message}", e)
559
781
  end
560
782
 
561
783
  # Execute the actual task implementation (neural or symbolic)
@@ -595,27 +817,51 @@ module LanguageOperator
595
817
  # @param error [Exception] The error that occurred
596
818
  # @return [Boolean] Whether the error should be retried
597
819
  def retryable_error?(error)
598
- RETRYABLE_ERRORS.any? { |error_class| error.is_a?(error_class) }
820
+ case error
821
+ when TaskNetworkError
822
+ # Network errors wrapped in TaskNetworkError are retryable
823
+ true
824
+ when TaskTimeoutError, TaskValidationError
825
+ # Timeout and validation errors are never retryable
826
+ false
827
+ when TaskExecutionError
828
+ # Check the original error for retryability
829
+ error.original_error ? retryable_error?(error.original_error) : false
830
+ when RuntimeError
831
+ # JSON parsing errors from neural tasks are retryable
832
+ error.message.include?('returned invalid JSON')
833
+ else
834
+ # Check against the standard retryable error list
835
+ RETRYABLE_ERRORS.any? { |error_class| error.is_a?(error_class) }
836
+ end
599
837
  end
600
838
 
601
- # Categorize error for logging and operator integration
839
+ # Categorize error for logging and operator integration with precedence hierarchy
840
+ #
841
+ # Error precedence (highest to lowest):
842
+ # 1. Timeout errors (always win, even if wrapping network errors)
843
+ # 2. Validation errors (argument/input validation failures)
844
+ # 3. Network errors (connection, socket, DNS issues)
845
+ # 4. Execution errors (general task execution failures)
846
+ # 5. System errors (unexpected/unknown errors)
602
847
  #
603
848
  # @param error [Exception] The error that occurred
604
849
  # @return [Symbol] Error category
605
850
  def categorize_error(error)
606
- case error
607
- when ArgumentError, TaskValidationError
608
- :validation
609
- when Timeout::Error, TaskTimeoutError
610
- :timeout
611
- when TaskExecutionError
612
- # Check the original error for categorization
613
- error.original_error ? categorize_error(error.original_error) : :execution
614
- when *RETRYABLE_ERRORS
615
- :network
616
- else
617
- :execution
618
- end
851
+ # Precedence Level 1: Timeout errors always win
852
+ return :timeout if error.is_a?(Timeout::Error) || error.is_a?(TaskTimeoutError)
853
+
854
+ # Precedence Level 2: Validation errors
855
+ return :validation if error.is_a?(ArgumentError) || error.is_a?(TaskValidationError)
856
+
857
+ # For wrapped errors, check original error but preserve timeout precedence
858
+ return categorize_error(error.original_error) if error.is_a?(TaskExecutionError) && error.original_error
859
+
860
+ # Precedence Level 3: Network errors
861
+ return :network if error.is_a?(TaskNetworkError) || RETRYABLE_ERRORS.any? { |err_class| error.is_a?(err_class) }
862
+
863
+ # Precedence Level 4: General execution errors
864
+ :execution
619
865
  end
620
866
 
621
867
  # Calculate retry delay with exponential backoff
@@ -627,19 +873,24 @@ module LanguageOperator
627
873
  [delay, @config[:retry_delay_max]].min
628
874
  end
629
875
 
630
- # Create appropriate error type based on original error
876
+ # Create appropriate error type based on original error with precedence hierarchy
631
877
  #
632
878
  # @param task_name [Symbol] Name of the task
633
879
  # @param original_error [Exception] The original error
634
880
  # @return [TaskExecutionError] Appropriate error type
635
881
  def create_appropriate_error(task_name, original_error)
636
882
  case original_error
637
- when TaskTimeoutError
883
+ when TaskTimeoutError, TaskValidationError, TaskNetworkError
884
+ # Already wrapped in appropriate type
638
885
  original_error
639
886
  when Timeout::Error
640
- TaskTimeoutError.new(task_name, 'timed out', original_error)
887
+ # Always wrap timeout errors, preserving original context
888
+ TaskTimeoutError.new(task_name, "timed out after timeout (original: #{original_error.message})", original_error)
641
889
  when ArgumentError
642
890
  TaskValidationError.new(task_name, original_error.message, original_error)
891
+ when *RETRYABLE_ERRORS
892
+ # Wrap network errors for clear categorization
893
+ TaskNetworkError.new(task_name, "network error: #{original_error.message}", original_error)
643
894
  else
644
895
  TaskExecutionError.new(task_name, original_error.message, original_error)
645
896
  end
@@ -665,6 +916,56 @@ module LanguageOperator
665
916
  retryable: retryable_error?(error),
666
917
  backtrace: error.backtrace&.first(5))
667
918
  end
919
+
920
+ # Build task lookup cache for O(1) task resolution
921
+ #
922
+ # Pre-computes task metadata to avoid repeated type determinations
923
+ # and provide fast hash-based lookup instead of linear search.
924
+ #
925
+ # @return [Hash] Cache mapping task names to metadata
926
+ def build_task_cache
927
+ cache = {}
928
+ @tasks.each do |name, task|
929
+ # Guard against test doubles that don't respond to task methods
930
+ cache[name] = if task.respond_to?(:neural?) && task.respond_to?(:symbolic?)
931
+ {
932
+ definition: task,
933
+ type: determine_task_type(task),
934
+ neural: task.neural?,
935
+ symbolic: task.symbolic?
936
+ }
937
+ else
938
+ # Fallback for test doubles or invalid task objects
939
+ {
940
+ definition: task,
941
+ type: 'unknown',
942
+ neural: false,
943
+ symbolic: false
944
+ }
945
+ end
946
+ end
947
+ cache
948
+ end
949
+
950
+ # Build timeout cache for O(1) timeout resolution
951
+ #
952
+ # Pre-computes timeouts for all tasks to avoid repeated calculations
953
+ # during task execution hot path.
954
+ #
955
+ # @return [Hash] Cache mapping task names to timeout values
956
+ def build_timeout_cache
957
+ cache = {}
958
+ @tasks.each do |name, task|
959
+ # Guard against test doubles that don't respond to task methods
960
+ cache[name] = if task.respond_to?(:neural?) && task.respond_to?(:symbolic?)
961
+ task_timeout_for_type(task)
962
+ else
963
+ # Fallback timeout for test doubles or invalid task objects
964
+ @config[:timeout_symbolic]
965
+ end
966
+ end
967
+ cache
968
+ end
668
969
  end
669
970
  end
670
971
  end