language-operator 0.0.1 → 0.1.30
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 +125 -0
- data/CHANGELOG.md +53 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +284 -0
- data/LICENSE +229 -21
- data/Makefile +77 -0
- data/README.md +3 -11
- data/Rakefile +34 -0
- data/bin/aictl +7 -0
- data/completions/_aictl +232 -0
- data/completions/aictl.bash +121 -0
- data/completions/aictl.fish +114 -0
- data/docs/architecture/agent-runtime.md +585 -0
- data/docs/dsl/agent-reference.md +591 -0
- data/docs/dsl/best-practices.md +1078 -0
- data/docs/dsl/chat-endpoints.md +895 -0
- data/docs/dsl/constraints.md +671 -0
- data/docs/dsl/mcp-integration.md +1177 -0
- data/docs/dsl/webhooks.md +932 -0
- data/docs/dsl/workflows.md +744 -0
- data/examples/README.md +569 -0
- data/examples/agent_example.rb +86 -0
- data/examples/chat_endpoint_agent.rb +118 -0
- data/examples/github_webhook_agent.rb +171 -0
- data/examples/mcp_agent.rb +158 -0
- data/examples/oauth_callback_agent.rb +296 -0
- data/examples/stripe_webhook_agent.rb +219 -0
- data/examples/webhook_agent.rb +80 -0
- data/lib/language_operator/agent/base.rb +110 -0
- data/lib/language_operator/agent/executor.rb +440 -0
- data/lib/language_operator/agent/instrumentation.rb +54 -0
- data/lib/language_operator/agent/metrics_tracker.rb +183 -0
- data/lib/language_operator/agent/safety/ast_validator.rb +272 -0
- data/lib/language_operator/agent/safety/audit_logger.rb +104 -0
- data/lib/language_operator/agent/safety/budget_tracker.rb +175 -0
- data/lib/language_operator/agent/safety/content_filter.rb +93 -0
- data/lib/language_operator/agent/safety/manager.rb +207 -0
- data/lib/language_operator/agent/safety/rate_limiter.rb +150 -0
- data/lib/language_operator/agent/safety/safe_executor.rb +115 -0
- data/lib/language_operator/agent/scheduler.rb +183 -0
- data/lib/language_operator/agent/telemetry.rb +116 -0
- data/lib/language_operator/agent/web_server.rb +610 -0
- data/lib/language_operator/agent/webhook_authenticator.rb +226 -0
- data/lib/language_operator/agent.rb +149 -0
- data/lib/language_operator/cli/commands/agent.rb +1252 -0
- data/lib/language_operator/cli/commands/cluster.rb +335 -0
- data/lib/language_operator/cli/commands/install.rb +404 -0
- data/lib/language_operator/cli/commands/model.rb +266 -0
- data/lib/language_operator/cli/commands/persona.rb +396 -0
- data/lib/language_operator/cli/commands/quickstart.rb +22 -0
- data/lib/language_operator/cli/commands/status.rb +156 -0
- data/lib/language_operator/cli/commands/tool.rb +537 -0
- data/lib/language_operator/cli/commands/use.rb +47 -0
- data/lib/language_operator/cli/errors/handler.rb +180 -0
- data/lib/language_operator/cli/errors/suggestions.rb +176 -0
- data/lib/language_operator/cli/formatters/code_formatter.rb +81 -0
- data/lib/language_operator/cli/formatters/log_formatter.rb +290 -0
- data/lib/language_operator/cli/formatters/progress_formatter.rb +53 -0
- data/lib/language_operator/cli/formatters/table_formatter.rb +179 -0
- data/lib/language_operator/cli/formatters/value_formatter.rb +113 -0
- data/lib/language_operator/cli/helpers/cluster_context.rb +62 -0
- data/lib/language_operator/cli/helpers/cluster_validator.rb +101 -0
- data/lib/language_operator/cli/helpers/editor_helper.rb +58 -0
- data/lib/language_operator/cli/helpers/kubeconfig_validator.rb +167 -0
- data/lib/language_operator/cli/helpers/resource_dependency_checker.rb +74 -0
- data/lib/language_operator/cli/helpers/schedule_builder.rb +108 -0
- data/lib/language_operator/cli/helpers/user_prompts.rb +69 -0
- data/lib/language_operator/cli/main.rb +232 -0
- data/lib/language_operator/cli/templates/tools/generic.yaml +66 -0
- data/lib/language_operator/cli/wizards/agent_wizard.rb +246 -0
- data/lib/language_operator/cli/wizards/quickstart_wizard.rb +588 -0
- data/lib/language_operator/client/base.rb +214 -0
- data/lib/language_operator/client/config.rb +136 -0
- data/lib/language_operator/client/cost_calculator.rb +37 -0
- data/lib/language_operator/client/mcp_connector.rb +123 -0
- data/lib/language_operator/client.rb +19 -0
- data/lib/language_operator/config/cluster_config.rb +101 -0
- data/lib/language_operator/config/tool_patterns.yaml +57 -0
- data/lib/language_operator/config/tool_registry.rb +96 -0
- data/lib/language_operator/config.rb +138 -0
- data/lib/language_operator/dsl/adapter.rb +124 -0
- data/lib/language_operator/dsl/agent_context.rb +90 -0
- data/lib/language_operator/dsl/agent_definition.rb +427 -0
- data/lib/language_operator/dsl/chat_endpoint_definition.rb +115 -0
- data/lib/language_operator/dsl/config.rb +119 -0
- data/lib/language_operator/dsl/context.rb +50 -0
- data/lib/language_operator/dsl/execution_context.rb +47 -0
- data/lib/language_operator/dsl/helpers.rb +109 -0
- data/lib/language_operator/dsl/http.rb +184 -0
- data/lib/language_operator/dsl/mcp_server_definition.rb +73 -0
- data/lib/language_operator/dsl/parameter_definition.rb +124 -0
- data/lib/language_operator/dsl/registry.rb +36 -0
- data/lib/language_operator/dsl/shell.rb +125 -0
- data/lib/language_operator/dsl/tool_definition.rb +112 -0
- data/lib/language_operator/dsl/webhook_authentication.rb +114 -0
- data/lib/language_operator/dsl/webhook_definition.rb +106 -0
- data/lib/language_operator/dsl/workflow_definition.rb +259 -0
- data/lib/language_operator/dsl.rb +160 -0
- data/lib/language_operator/errors.rb +60 -0
- data/lib/language_operator/kubernetes/client.rb +279 -0
- data/lib/language_operator/kubernetes/resource_builder.rb +194 -0
- data/lib/language_operator/loggable.rb +47 -0
- data/lib/language_operator/logger.rb +141 -0
- data/lib/language_operator/retry.rb +123 -0
- data/lib/language_operator/retryable.rb +132 -0
- data/lib/language_operator/tool_loader.rb +242 -0
- data/lib/language_operator/validators.rb +170 -0
- data/lib/language_operator/version.rb +1 -1
- data/lib/language_operator.rb +65 -3
- data/requirements/tasks/challenge.md +9 -0
- data/requirements/tasks/iterate.md +36 -0
- data/requirements/tasks/optimize.md +21 -0
- data/requirements/tasks/tag.md +5 -0
- data/test_agent_dsl.rb +108 -0
- metadata +503 -20
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../logger'
|
|
4
|
+
require_relative '../loggable'
|
|
5
|
+
require_relative 'metrics_tracker'
|
|
6
|
+
require_relative 'safety/manager'
|
|
7
|
+
require_relative 'instrumentation'
|
|
8
|
+
|
|
9
|
+
module LanguageOperator
|
|
10
|
+
module Agent
|
|
11
|
+
# Task Executor
|
|
12
|
+
#
|
|
13
|
+
# Handles autonomous task execution with retry logic and error handling.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# executor = Executor.new(agent)
|
|
17
|
+
# executor.execute("Complete the task")
|
|
18
|
+
class Executor
|
|
19
|
+
include LanguageOperator::Loggable
|
|
20
|
+
include Instrumentation
|
|
21
|
+
|
|
22
|
+
attr_reader :agent, :iteration_count, :metrics_tracker
|
|
23
|
+
|
|
24
|
+
# Initialize the executor
|
|
25
|
+
#
|
|
26
|
+
# @param agent [LanguageOperator::Agent::Base] The agent instance
|
|
27
|
+
# @param agent_definition [LanguageOperator::Dsl::AgentDefinition, nil] Optional agent definition
|
|
28
|
+
def initialize(agent, agent_definition: nil)
|
|
29
|
+
@agent = agent
|
|
30
|
+
@agent_definition = agent_definition
|
|
31
|
+
@iteration_count = 0
|
|
32
|
+
@max_iterations = 100
|
|
33
|
+
@show_full_responses = ENV.fetch('SHOW_FULL_RESPONSES', 'false') == 'true'
|
|
34
|
+
@metrics_tracker = MetricsTracker.new
|
|
35
|
+
|
|
36
|
+
# Initialize safety manager from agent definition or environment
|
|
37
|
+
@safety_manager = initialize_safety_manager(agent_definition)
|
|
38
|
+
|
|
39
|
+
logger.debug('Executor initialized',
|
|
40
|
+
max_iterations: @max_iterations,
|
|
41
|
+
show_full_responses: @show_full_responses,
|
|
42
|
+
workspace: @agent.workspace_path,
|
|
43
|
+
safety_enabled: @safety_manager&.enabled?)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Execute a task with additional context (for webhooks/HTTP requests)
|
|
47
|
+
#
|
|
48
|
+
# @param instruction [String] The instruction to execute
|
|
49
|
+
# @param context [Hash] Additional context (webhook payload, request data, etc.)
|
|
50
|
+
# @return [String] The result
|
|
51
|
+
def execute_with_context(instruction:, context: {})
|
|
52
|
+
# Build enriched instruction with context
|
|
53
|
+
enriched_instruction = build_instruction_with_context(instruction, context)
|
|
54
|
+
|
|
55
|
+
# Execute with standard logic
|
|
56
|
+
execute(enriched_instruction)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Execute a single task or workflow
|
|
60
|
+
#
|
|
61
|
+
# @param task [String] The task to execute
|
|
62
|
+
# @param agent_definition [LanguageOperator::Dsl::AgentDefinition, nil] Optional agent definition with workflow
|
|
63
|
+
# @return [String] The result
|
|
64
|
+
# rubocop:disable Metrics/BlockLength
|
|
65
|
+
def execute(task, agent_definition: nil)
|
|
66
|
+
with_span('agent.execute_goal', attributes: {
|
|
67
|
+
'agent.goal_description' => task[0...500]
|
|
68
|
+
}) do
|
|
69
|
+
@iteration_count += 1
|
|
70
|
+
|
|
71
|
+
# Route to workflow execution if agent has a workflow defined
|
|
72
|
+
return execute_workflow(agent_definition) if agent_definition&.workflow
|
|
73
|
+
|
|
74
|
+
# Standard instruction-based execution
|
|
75
|
+
logger.info('Starting iteration',
|
|
76
|
+
iteration: @iteration_count,
|
|
77
|
+
max_iterations: @max_iterations)
|
|
78
|
+
logger.debug('Prompt', prompt: task[0..200])
|
|
79
|
+
|
|
80
|
+
# Safety check before request
|
|
81
|
+
if @safety_manager&.enabled?
|
|
82
|
+
# Estimate cost and tokens (rough estimate)
|
|
83
|
+
estimated_tokens = estimate_tokens(task)
|
|
84
|
+
estimated_cost = estimate_cost(estimated_tokens)
|
|
85
|
+
|
|
86
|
+
@safety_manager.check_request!(
|
|
87
|
+
message: task,
|
|
88
|
+
estimated_cost: estimated_cost,
|
|
89
|
+
estimated_tokens: estimated_tokens
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
logger.info('🤖 LLM request')
|
|
94
|
+
result = logger.timed('LLM response received') do
|
|
95
|
+
@agent.send_message(task)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Record metrics
|
|
99
|
+
model_id = @agent.config.dig('llm', 'model')
|
|
100
|
+
@metrics_tracker.record_request(result, model_id) if model_id
|
|
101
|
+
|
|
102
|
+
# Safety check after response and record spending
|
|
103
|
+
result_text = result.is_a?(String) ? result : result.content
|
|
104
|
+
metrics = @metrics_tracker.cumulative_stats
|
|
105
|
+
|
|
106
|
+
if @safety_manager&.enabled?
|
|
107
|
+
@safety_manager.check_response!(result_text)
|
|
108
|
+
@safety_manager.record_request(
|
|
109
|
+
cost: metrics[:estimatedCost],
|
|
110
|
+
tokens: metrics[:totalTokens]
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
logger.info('✓ Iteration completed',
|
|
114
|
+
iteration: @iteration_count,
|
|
115
|
+
response_length: result_text.length,
|
|
116
|
+
total_tokens: metrics[:totalTokens],
|
|
117
|
+
estimated_cost: "$#{metrics[:estimatedCost]}")
|
|
118
|
+
logger.debug('Response preview', response: result_text[0..200])
|
|
119
|
+
|
|
120
|
+
result
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
handle_error(e)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
# rubocop:enable Metrics/BlockLength
|
|
126
|
+
|
|
127
|
+
# Run continuous execution loop
|
|
128
|
+
#
|
|
129
|
+
# @return [void]
|
|
130
|
+
def run_loop
|
|
131
|
+
start_time = Time.now
|
|
132
|
+
|
|
133
|
+
logger.info('▶ Starting execution')
|
|
134
|
+
logger.info('Configuration',
|
|
135
|
+
workspace: @agent.workspace_path,
|
|
136
|
+
mcp_servers: @agent.servers_info.length,
|
|
137
|
+
max_iterations: @max_iterations)
|
|
138
|
+
|
|
139
|
+
# Log persona loading
|
|
140
|
+
persona = @agent.config.dig('agent', 'persona') || 'default'
|
|
141
|
+
logger.info("👤 Loading persona: #{persona}")
|
|
142
|
+
|
|
143
|
+
# Log MCP server details
|
|
144
|
+
if @agent.servers_info.any?
|
|
145
|
+
@agent.servers_info.each do |server|
|
|
146
|
+
logger.info('◆ MCP server connected', name: server[:name], tool_count: server[:tool_count])
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Get initial instructions from config or environment
|
|
151
|
+
instructions = @agent.config.dig('agent', 'instructions') ||
|
|
152
|
+
ENV['AGENT_INSTRUCTIONS'] ||
|
|
153
|
+
'Monitor workspace and respond to changes'
|
|
154
|
+
|
|
155
|
+
logger.info('Instructions', instructions: instructions[0..200])
|
|
156
|
+
logger.info('Starting autonomous execution loop')
|
|
157
|
+
|
|
158
|
+
loop do
|
|
159
|
+
break if @iteration_count >= @max_iterations
|
|
160
|
+
|
|
161
|
+
progress_pct = ((@iteration_count.to_f / @max_iterations) * 100).round(1)
|
|
162
|
+
logger.debug('Loop progress',
|
|
163
|
+
iteration: @iteration_count,
|
|
164
|
+
max: @max_iterations,
|
|
165
|
+
progress: "#{progress_pct}%")
|
|
166
|
+
|
|
167
|
+
result = execute(instructions)
|
|
168
|
+
result_text = result.is_a?(String) ? result : result.content
|
|
169
|
+
|
|
170
|
+
# Log result based on verbosity settings
|
|
171
|
+
if @show_full_responses
|
|
172
|
+
logger.info('Full iteration result',
|
|
173
|
+
iteration: @iteration_count,
|
|
174
|
+
result: result_text)
|
|
175
|
+
else
|
|
176
|
+
preview = result_text[0..200]
|
|
177
|
+
preview += '...' if result_text.length > 200
|
|
178
|
+
logger.info('Iteration result',
|
|
179
|
+
iteration: @iteration_count,
|
|
180
|
+
preview: preview)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Rate limiting
|
|
184
|
+
logger.debug('Rate limit pause', duration: 5)
|
|
185
|
+
sleep 5
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Log execution summary
|
|
189
|
+
total_duration = Time.now - start_time
|
|
190
|
+
metrics = @metrics_tracker.cumulative_stats
|
|
191
|
+
logger.info('✅ Execution complete',
|
|
192
|
+
iterations: @iteration_count,
|
|
193
|
+
duration_s: total_duration.round(2),
|
|
194
|
+
total_requests: metrics[:requestCount],
|
|
195
|
+
total_tokens: metrics[:totalTokens],
|
|
196
|
+
estimated_cost: "$#{metrics[:estimatedCost]}",
|
|
197
|
+
reason: @iteration_count >= @max_iterations ? 'max_iterations' : 'completed')
|
|
198
|
+
|
|
199
|
+
return unless @iteration_count >= @max_iterations
|
|
200
|
+
|
|
201
|
+
logger.warn('Maximum iterations reached',
|
|
202
|
+
iterations: @max_iterations,
|
|
203
|
+
reason: 'Hit max_iterations limit')
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Execute a workflow-based agent
|
|
207
|
+
#
|
|
208
|
+
# @param agent_def [LanguageOperator::Dsl::AgentDefinition] The agent definition
|
|
209
|
+
# @return [RubyLLM::Message] The final response
|
|
210
|
+
def execute_workflow(agent_def)
|
|
211
|
+
start_time = Time.now
|
|
212
|
+
|
|
213
|
+
logger.info("▶ Starting workflow execution: #{agent_def.name}")
|
|
214
|
+
|
|
215
|
+
# Log persona if defined
|
|
216
|
+
logger.info("👤 Loading persona: #{agent_def.persona}") if agent_def.persona
|
|
217
|
+
|
|
218
|
+
# Build orchestration prompt from agent definition
|
|
219
|
+
prompt = build_workflow_prompt(agent_def)
|
|
220
|
+
logger.debug('Workflow prompt', prompt: prompt[0..300])
|
|
221
|
+
|
|
222
|
+
# Register workflow steps as tools (placeholder - will implement after tool converter)
|
|
223
|
+
# For now, just execute with instructions
|
|
224
|
+
result = logger.timed('🤖 LLM request') do
|
|
225
|
+
@agent.send_message(prompt)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Record metrics
|
|
229
|
+
model_id = @agent.config.dig('llm', 'model')
|
|
230
|
+
@metrics_tracker.record_request(result, model_id) if model_id
|
|
231
|
+
|
|
232
|
+
# Write output if configured
|
|
233
|
+
write_output(agent_def, result) if agent_def.output_config && result
|
|
234
|
+
|
|
235
|
+
# Log execution summary
|
|
236
|
+
total_duration = Time.now - start_time
|
|
237
|
+
metrics = @metrics_tracker.cumulative_stats
|
|
238
|
+
logger.info('✅ Workflow execution completed',
|
|
239
|
+
duration_s: total_duration.round(2),
|
|
240
|
+
total_tokens: metrics[:totalTokens],
|
|
241
|
+
estimated_cost: "$#{metrics[:estimatedCost]}")
|
|
242
|
+
result
|
|
243
|
+
rescue StandardError => e
|
|
244
|
+
logger.error('❌ Workflow execution failed', error: e.message)
|
|
245
|
+
handle_error(e)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Build orchestration prompt from agent definition
|
|
249
|
+
#
|
|
250
|
+
# @param agent_def [LanguageOperator::Dsl::AgentDefinition] The agent definition
|
|
251
|
+
# @return [String] The prompt
|
|
252
|
+
def build_workflow_prompt(agent_def)
|
|
253
|
+
prompt = "# Task: #{agent_def.description}\n\n"
|
|
254
|
+
|
|
255
|
+
if agent_def.objectives&.any?
|
|
256
|
+
prompt += "## Objectives:\n"
|
|
257
|
+
agent_def.objectives.each { |obj| prompt += "- #{obj}\n" }
|
|
258
|
+
prompt += "\n"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
if agent_def.workflow&.steps&.any?
|
|
262
|
+
prompt += "## Workflow Steps:\n"
|
|
263
|
+
agent_def.workflow.step_order.each do |step_name|
|
|
264
|
+
step = agent_def.workflow.steps[step_name]
|
|
265
|
+
prompt += step_name.to_s.tr('_', ' ').capitalize.to_s
|
|
266
|
+
prompt += " (using tool: #{step.tool_name})" if step.tool_name
|
|
267
|
+
prompt += " - depends on: #{step.dependencies.join(', ')}" if step.dependencies&.any?
|
|
268
|
+
prompt += "\n"
|
|
269
|
+
end
|
|
270
|
+
prompt += "\n"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
if agent_def.constraints
|
|
274
|
+
prompt += "## Constraints:\n"
|
|
275
|
+
prompt += "- Maximum iterations: #{agent_def.constraints[:max_iterations]}\n" if agent_def.constraints[:max_iterations]
|
|
276
|
+
prompt += "- Timeout: #{agent_def.constraints[:timeout]}\n" if agent_def.constraints[:timeout]
|
|
277
|
+
prompt += "\n"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
prompt += 'Please complete this task following the workflow steps.'
|
|
281
|
+
prompt
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Write output to configured destinations
|
|
285
|
+
#
|
|
286
|
+
# @param agent_def [LanguageOperator::Dsl::AgentDefinition] The agent definition
|
|
287
|
+
# @param result [RubyLLM::Message] The result to write
|
|
288
|
+
def write_output(agent_def, result)
|
|
289
|
+
return unless agent_def.output_config
|
|
290
|
+
|
|
291
|
+
content = result.is_a?(String) ? result : result.content
|
|
292
|
+
|
|
293
|
+
if (workspace_path = agent_def.output_config[:workspace])
|
|
294
|
+
full_path = File.join(@agent.workspace_path, workspace_path)
|
|
295
|
+
|
|
296
|
+
begin
|
|
297
|
+
FileUtils.mkdir_p(File.dirname(full_path))
|
|
298
|
+
File.write(full_path, content)
|
|
299
|
+
logger.info("📝 Wrote output to #{workspace_path}")
|
|
300
|
+
rescue Errno::EACCES, Errno::EPERM
|
|
301
|
+
# Permission denied - try writing to workspace root
|
|
302
|
+
fallback_path = File.join(@agent.workspace_path, 'output.txt')
|
|
303
|
+
begin
|
|
304
|
+
File.write(fallback_path, content)
|
|
305
|
+
logger.warn("⚠️ Could not write to #{workspace_path}, wrote to output.txt instead")
|
|
306
|
+
rescue StandardError => e2
|
|
307
|
+
logger.warn("⚠️ Could not write output to workspace: #{e2.message}")
|
|
308
|
+
logger.info("📄 Output (first 500 chars): #{content[0..500]}")
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Future: Handle Slack, email outputs
|
|
314
|
+
rescue StandardError => e
|
|
315
|
+
logger.warn('Output writing failed', error: e.message)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
private
|
|
319
|
+
|
|
320
|
+
def logger_component
|
|
321
|
+
'Agent::Executor'
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Build instruction enriched with request context
|
|
325
|
+
#
|
|
326
|
+
# @param instruction [String] Base instruction
|
|
327
|
+
# @param context [Hash] Request context
|
|
328
|
+
# @return [String] Enriched instruction
|
|
329
|
+
def build_instruction_with_context(instruction, context)
|
|
330
|
+
enriched = instruction.dup
|
|
331
|
+
enriched += "\n\n## Request Context\n"
|
|
332
|
+
enriched += "- Method: #{context[:method]}\n" if context[:method]
|
|
333
|
+
enriched += "- Path: #{context[:path]}\n" if context[:path]
|
|
334
|
+
|
|
335
|
+
if context[:params] && !context[:params].empty?
|
|
336
|
+
enriched += "\n### Parameters:\n"
|
|
337
|
+
enriched += "```json\n#{JSON.pretty_generate(context[:params])}\n```\n"
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
if context[:body] && !context[:body].empty?
|
|
341
|
+
enriched += "\n### Request Body:\n"
|
|
342
|
+
enriched += "```\n#{context[:body][0..1000]}\n```\n"
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
if context[:headers] && !context[:headers].empty?
|
|
346
|
+
enriched += "\n### Headers:\n"
|
|
347
|
+
context[:headers].each do |key, value|
|
|
348
|
+
enriched += "- #{key}: #{value}\n"
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
enriched
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def initialize_safety_manager(agent_definition)
|
|
356
|
+
# Get safety config from agent definition constraints
|
|
357
|
+
config = agent_definition&.constraints || {}
|
|
358
|
+
|
|
359
|
+
# Merge with environment variables
|
|
360
|
+
config = {
|
|
361
|
+
enabled: ENV.fetch('SAFETY_ENABLED', 'true') != 'false',
|
|
362
|
+
daily_budget: config[:daily_budget] || parse_float_env('DAILY_BUDGET'),
|
|
363
|
+
hourly_budget: config[:hourly_budget] || parse_float_env('HOURLY_BUDGET'),
|
|
364
|
+
token_budget: config[:token_budget] || parse_int_env('TOKEN_BUDGET'),
|
|
365
|
+
requests_per_minute: config[:requests_per_minute] || parse_int_env('REQUESTS_PER_MINUTE'),
|
|
366
|
+
requests_per_hour: config[:requests_per_hour] || parse_int_env('REQUESTS_PER_HOUR'),
|
|
367
|
+
requests_per_day: config[:requests_per_day] || parse_int_env('REQUESTS_PER_DAY'),
|
|
368
|
+
blocked_patterns: config[:blocked_patterns] || parse_array_env('BLOCKED_PATTERNS'),
|
|
369
|
+
blocked_topics: config[:blocked_topics] || parse_array_env('BLOCKED_TOPICS'),
|
|
370
|
+
case_sensitive: config[:case_sensitive] || ENV.fetch('CASE_SENSITIVE', 'false') == 'true',
|
|
371
|
+
audit_logging: config[:audit_logging] != false
|
|
372
|
+
}.compact
|
|
373
|
+
|
|
374
|
+
return nil if config[:enabled] == false
|
|
375
|
+
|
|
376
|
+
Safety::Manager.new(config)
|
|
377
|
+
rescue StandardError => e
|
|
378
|
+
logger.warn('Failed to initialize safety manager',
|
|
379
|
+
error: e.message,
|
|
380
|
+
fallback: 'Safety features disabled')
|
|
381
|
+
nil
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def parse_float_env(key)
|
|
385
|
+
val = ENV.fetch(key, nil)
|
|
386
|
+
return nil unless val
|
|
387
|
+
|
|
388
|
+
val.to_f
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def parse_int_env(key)
|
|
392
|
+
val = ENV.fetch(key, nil)
|
|
393
|
+
return nil unless val
|
|
394
|
+
|
|
395
|
+
val.to_i
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def parse_array_env(key)
|
|
399
|
+
val = ENV.fetch(key, nil)
|
|
400
|
+
return nil unless val
|
|
401
|
+
|
|
402
|
+
val.split(',').map(&:strip)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def estimate_tokens(text)
|
|
406
|
+
# Rough estimate: ~1.3 tokens per word
|
|
407
|
+
(text.split.length * 1.3).to_i
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def estimate_cost(tokens)
|
|
411
|
+
# Estimate based on common model pricing
|
|
412
|
+
# Average of ~$3-15 per 1M tokens (using $10 as middle ground)
|
|
413
|
+
# This is a rough estimate; actual cost varies by model
|
|
414
|
+
(tokens / 1_000_000.0) * 10.0
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def handle_error(error)
|
|
418
|
+
case error
|
|
419
|
+
when Timeout::Error, /timeout/i.match?(error.message)
|
|
420
|
+
logger.error('Request timeout',
|
|
421
|
+
error: error.class.name,
|
|
422
|
+
message: error.message,
|
|
423
|
+
iteration: @iteration_count)
|
|
424
|
+
when /connection refused|operation not permitted/i.match?(error.message)
|
|
425
|
+
logger.error('Connection failed',
|
|
426
|
+
error: error.class.name,
|
|
427
|
+
message: error.message,
|
|
428
|
+
hint: 'Check if model service is healthy and accessible')
|
|
429
|
+
else
|
|
430
|
+
logger.error('Task execution failed',
|
|
431
|
+
error: error.class.name,
|
|
432
|
+
message: error.message)
|
|
433
|
+
logger.debug('Backtrace', trace: error.backtrace[0..5].join("\n")) if error.backtrace
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
"Error executing task: #{error.message}"
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'opentelemetry/sdk'
|
|
4
|
+
|
|
5
|
+
module LanguageOperator
|
|
6
|
+
module Agent
|
|
7
|
+
# OpenTelemetry instrumentation helpers for agent methods
|
|
8
|
+
#
|
|
9
|
+
# Provides reusable patterns for tracing agent operations with
|
|
10
|
+
# automatic error handling and span status management.
|
|
11
|
+
#
|
|
12
|
+
# @example Instrument a method
|
|
13
|
+
# include LanguageOperator::Agent::Instrumentation
|
|
14
|
+
#
|
|
15
|
+
# def my_method
|
|
16
|
+
# with_span('my_method', attributes: { 'key' => 'value' }) do
|
|
17
|
+
# # Method implementation
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
module Instrumentation
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# Get the configured OpenTelemetry tracer
|
|
24
|
+
#
|
|
25
|
+
# @return [OpenTelemetry::Trace::Tracer]
|
|
26
|
+
def tracer
|
|
27
|
+
@tracer ||= OpenTelemetry.tracer_provider.tracer(
|
|
28
|
+
'language-operator-agent',
|
|
29
|
+
LanguageOperator::VERSION
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Execute block within a traced span with automatic error handling
|
|
34
|
+
#
|
|
35
|
+
# Creates a span with the given name and attributes, executes the block,
|
|
36
|
+
# and automatically records exceptions and sets error status if raised.
|
|
37
|
+
#
|
|
38
|
+
# @param name [String] Span name
|
|
39
|
+
# @param attributes [Hash] Span attributes
|
|
40
|
+
# @yield [OpenTelemetry::Trace::Span] The created span
|
|
41
|
+
# @return [Object] Result of the block
|
|
42
|
+
# @raise Re-raises any exception after recording it on the span
|
|
43
|
+
def with_span(name, attributes: {})
|
|
44
|
+
tracer.in_span(name, attributes: attributes) do |span|
|
|
45
|
+
yield span
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
span.record_exception(e)
|
|
48
|
+
span.status = OpenTelemetry::Trace::Status.error(e.message)
|
|
49
|
+
raise
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LanguageOperator
|
|
4
|
+
module Agent
|
|
5
|
+
class MetricsTracker
|
|
6
|
+
attr_reader :metrics
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@mutex = Mutex.new
|
|
10
|
+
@metrics = {
|
|
11
|
+
total_input_tokens: 0,
|
|
12
|
+
total_output_tokens: 0,
|
|
13
|
+
total_cached_tokens: 0,
|
|
14
|
+
total_cache_creation_tokens: 0,
|
|
15
|
+
request_count: 0,
|
|
16
|
+
total_cost: 0.0,
|
|
17
|
+
requests: []
|
|
18
|
+
}
|
|
19
|
+
@pricing_cache = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Record token usage from an LLM response
|
|
23
|
+
# @param response [Object] RubyLLM response object
|
|
24
|
+
# @param model_id [String] Model identifier
|
|
25
|
+
def record_request(response, model_id)
|
|
26
|
+
return unless response
|
|
27
|
+
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
# Extract token counts with defensive checks
|
|
30
|
+
input_tokens = extract_token_count(response, :input_tokens)
|
|
31
|
+
output_tokens = extract_token_count(response, :output_tokens)
|
|
32
|
+
cached_tokens = extract_token_count(response, :cached_tokens)
|
|
33
|
+
cache_creation_tokens = extract_token_count(response, :cache_creation_tokens)
|
|
34
|
+
|
|
35
|
+
# Calculate cost for this request
|
|
36
|
+
cost = calculate_cost(input_tokens, output_tokens, model_id)
|
|
37
|
+
|
|
38
|
+
# Update cumulative metrics
|
|
39
|
+
@metrics[:total_input_tokens] += input_tokens
|
|
40
|
+
@metrics[:total_output_tokens] += output_tokens
|
|
41
|
+
@metrics[:total_cached_tokens] += cached_tokens
|
|
42
|
+
@metrics[:total_cache_creation_tokens] += cache_creation_tokens
|
|
43
|
+
@metrics[:request_count] += 1
|
|
44
|
+
@metrics[:total_cost] += cost
|
|
45
|
+
|
|
46
|
+
# Store per-request history (limited to last 100 requests)
|
|
47
|
+
@metrics[:requests] << {
|
|
48
|
+
timestamp: Time.now.iso8601,
|
|
49
|
+
model: model_id,
|
|
50
|
+
input_tokens: input_tokens,
|
|
51
|
+
output_tokens: output_tokens,
|
|
52
|
+
cached_tokens: cached_tokens,
|
|
53
|
+
cache_creation_tokens: cache_creation_tokens,
|
|
54
|
+
cost: cost.round(6)
|
|
55
|
+
}
|
|
56
|
+
@metrics[:requests].shift if @metrics[:requests].size > 100
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Get cumulative statistics
|
|
61
|
+
# @return [Hash] Hash with totalTokens, estimatedCost, requestCount
|
|
62
|
+
def cumulative_stats
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
{
|
|
65
|
+
totalTokens: @metrics[:total_input_tokens] + @metrics[:total_output_tokens],
|
|
66
|
+
inputTokens: @metrics[:total_input_tokens],
|
|
67
|
+
outputTokens: @metrics[:total_output_tokens],
|
|
68
|
+
cachedTokens: @metrics[:total_cached_tokens],
|
|
69
|
+
cacheCreationTokens: @metrics[:total_cache_creation_tokens],
|
|
70
|
+
requestCount: @metrics[:request_count],
|
|
71
|
+
estimatedCost: @metrics[:total_cost].round(6)
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Get recent request history
|
|
77
|
+
# @param limit [Integer] Number of recent requests to return
|
|
78
|
+
# @return [Array<Hash>] Array of request details
|
|
79
|
+
def recent_requests(limit = 10)
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
@metrics[:requests].last(limit)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Reset all metrics (for testing)
|
|
86
|
+
def reset!
|
|
87
|
+
@mutex.synchronize do
|
|
88
|
+
@metrics = {
|
|
89
|
+
total_input_tokens: 0,
|
|
90
|
+
total_output_tokens: 0,
|
|
91
|
+
total_cached_tokens: 0,
|
|
92
|
+
total_cache_creation_tokens: 0,
|
|
93
|
+
request_count: 0,
|
|
94
|
+
total_cost: 0.0,
|
|
95
|
+
requests: []
|
|
96
|
+
}
|
|
97
|
+
@pricing_cache = {}
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Extract token count from response with defensive checks
|
|
104
|
+
# @param response [Object] Response object
|
|
105
|
+
# @param method [Symbol] Method name to call
|
|
106
|
+
# @return [Integer] Token count or 0 if unavailable
|
|
107
|
+
def extract_token_count(response, method)
|
|
108
|
+
return 0 unless response.respond_to?(method)
|
|
109
|
+
|
|
110
|
+
value = response.public_send(method)
|
|
111
|
+
value.is_a?(Integer) ? value : 0
|
|
112
|
+
rescue StandardError
|
|
113
|
+
0
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Calculate cost based on token usage and model pricing
|
|
117
|
+
# @param input_tokens [Integer] Number of input tokens
|
|
118
|
+
# @param output_tokens [Integer] Number of output tokens
|
|
119
|
+
# @param model_id [String] Model identifier
|
|
120
|
+
# @return [Float] Estimated cost in USD
|
|
121
|
+
def calculate_cost(input_tokens, output_tokens, model_id)
|
|
122
|
+
pricing = get_pricing(model_id)
|
|
123
|
+
return 0.0 unless pricing
|
|
124
|
+
|
|
125
|
+
input_cost = (input_tokens / 1_000_000.0) * pricing[:input]
|
|
126
|
+
output_cost = (output_tokens / 1_000_000.0) * pricing[:output]
|
|
127
|
+
input_cost + output_cost
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
LanguageOperator.logger.warn('Cost calculation failed',
|
|
130
|
+
model: model_id,
|
|
131
|
+
error: e.message)
|
|
132
|
+
0.0
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Get pricing for a model (with caching)
|
|
136
|
+
# @param model_id [String] Model identifier
|
|
137
|
+
# @return [Hash, nil] Hash with :input and :output prices per million tokens
|
|
138
|
+
def get_pricing(model_id)
|
|
139
|
+
# Return cached pricing if available
|
|
140
|
+
return @pricing_cache[model_id] if @pricing_cache.key?(model_id)
|
|
141
|
+
|
|
142
|
+
# Try to fetch from RubyLLM registry
|
|
143
|
+
pricing = fetch_ruby_llm_pricing(model_id)
|
|
144
|
+
|
|
145
|
+
# Cache and return
|
|
146
|
+
@pricing_cache[model_id] = pricing
|
|
147
|
+
pricing
|
|
148
|
+
rescue StandardError => e
|
|
149
|
+
LanguageOperator.logger.warn('Pricing lookup failed',
|
|
150
|
+
model: model_id,
|
|
151
|
+
error: e.message)
|
|
152
|
+
# Cache nil to avoid repeated failures
|
|
153
|
+
@pricing_cache[model_id] = nil
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Fetch pricing from RubyLLM registry
|
|
158
|
+
# @param model_id [String] Model identifier
|
|
159
|
+
# @return [Hash, nil] Pricing hash or nil
|
|
160
|
+
def fetch_ruby_llm_pricing(model_id)
|
|
161
|
+
# Check if RubyLLM is available
|
|
162
|
+
return nil unless defined?(RubyLLM)
|
|
163
|
+
|
|
164
|
+
# Try to find model in registry
|
|
165
|
+
model_info = RubyLLM.models.find(model_id)
|
|
166
|
+
return nil unless model_info
|
|
167
|
+
|
|
168
|
+
# Extract pricing (assuming RubyLLM provides these attributes)
|
|
169
|
+
if model_info.respond_to?(:input_price_per_million) &&
|
|
170
|
+
model_info.respond_to?(:output_price_per_million)
|
|
171
|
+
{
|
|
172
|
+
input: model_info.input_price_per_million,
|
|
173
|
+
output: model_info.output_price_per_million
|
|
174
|
+
}
|
|
175
|
+
else
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
rescue StandardError
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|