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.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +125 -0
  3. data/CHANGELOG.md +53 -0
  4. data/Gemfile +8 -0
  5. data/Gemfile.lock +284 -0
  6. data/LICENSE +229 -21
  7. data/Makefile +77 -0
  8. data/README.md +3 -11
  9. data/Rakefile +34 -0
  10. data/bin/aictl +7 -0
  11. data/completions/_aictl +232 -0
  12. data/completions/aictl.bash +121 -0
  13. data/completions/aictl.fish +114 -0
  14. data/docs/architecture/agent-runtime.md +585 -0
  15. data/docs/dsl/agent-reference.md +591 -0
  16. data/docs/dsl/best-practices.md +1078 -0
  17. data/docs/dsl/chat-endpoints.md +895 -0
  18. data/docs/dsl/constraints.md +671 -0
  19. data/docs/dsl/mcp-integration.md +1177 -0
  20. data/docs/dsl/webhooks.md +932 -0
  21. data/docs/dsl/workflows.md +744 -0
  22. data/examples/README.md +569 -0
  23. data/examples/agent_example.rb +86 -0
  24. data/examples/chat_endpoint_agent.rb +118 -0
  25. data/examples/github_webhook_agent.rb +171 -0
  26. data/examples/mcp_agent.rb +158 -0
  27. data/examples/oauth_callback_agent.rb +296 -0
  28. data/examples/stripe_webhook_agent.rb +219 -0
  29. data/examples/webhook_agent.rb +80 -0
  30. data/lib/language_operator/agent/base.rb +110 -0
  31. data/lib/language_operator/agent/executor.rb +440 -0
  32. data/lib/language_operator/agent/instrumentation.rb +54 -0
  33. data/lib/language_operator/agent/metrics_tracker.rb +183 -0
  34. data/lib/language_operator/agent/safety/ast_validator.rb +272 -0
  35. data/lib/language_operator/agent/safety/audit_logger.rb +104 -0
  36. data/lib/language_operator/agent/safety/budget_tracker.rb +175 -0
  37. data/lib/language_operator/agent/safety/content_filter.rb +93 -0
  38. data/lib/language_operator/agent/safety/manager.rb +207 -0
  39. data/lib/language_operator/agent/safety/rate_limiter.rb +150 -0
  40. data/lib/language_operator/agent/safety/safe_executor.rb +115 -0
  41. data/lib/language_operator/agent/scheduler.rb +183 -0
  42. data/lib/language_operator/agent/telemetry.rb +116 -0
  43. data/lib/language_operator/agent/web_server.rb +610 -0
  44. data/lib/language_operator/agent/webhook_authenticator.rb +226 -0
  45. data/lib/language_operator/agent.rb +149 -0
  46. data/lib/language_operator/cli/commands/agent.rb +1252 -0
  47. data/lib/language_operator/cli/commands/cluster.rb +335 -0
  48. data/lib/language_operator/cli/commands/install.rb +404 -0
  49. data/lib/language_operator/cli/commands/model.rb +266 -0
  50. data/lib/language_operator/cli/commands/persona.rb +396 -0
  51. data/lib/language_operator/cli/commands/quickstart.rb +22 -0
  52. data/lib/language_operator/cli/commands/status.rb +156 -0
  53. data/lib/language_operator/cli/commands/tool.rb +537 -0
  54. data/lib/language_operator/cli/commands/use.rb +47 -0
  55. data/lib/language_operator/cli/errors/handler.rb +180 -0
  56. data/lib/language_operator/cli/errors/suggestions.rb +176 -0
  57. data/lib/language_operator/cli/formatters/code_formatter.rb +81 -0
  58. data/lib/language_operator/cli/formatters/log_formatter.rb +290 -0
  59. data/lib/language_operator/cli/formatters/progress_formatter.rb +53 -0
  60. data/lib/language_operator/cli/formatters/table_formatter.rb +179 -0
  61. data/lib/language_operator/cli/formatters/value_formatter.rb +113 -0
  62. data/lib/language_operator/cli/helpers/cluster_context.rb +62 -0
  63. data/lib/language_operator/cli/helpers/cluster_validator.rb +101 -0
  64. data/lib/language_operator/cli/helpers/editor_helper.rb +58 -0
  65. data/lib/language_operator/cli/helpers/kubeconfig_validator.rb +167 -0
  66. data/lib/language_operator/cli/helpers/resource_dependency_checker.rb +74 -0
  67. data/lib/language_operator/cli/helpers/schedule_builder.rb +108 -0
  68. data/lib/language_operator/cli/helpers/user_prompts.rb +69 -0
  69. data/lib/language_operator/cli/main.rb +232 -0
  70. data/lib/language_operator/cli/templates/tools/generic.yaml +66 -0
  71. data/lib/language_operator/cli/wizards/agent_wizard.rb +246 -0
  72. data/lib/language_operator/cli/wizards/quickstart_wizard.rb +588 -0
  73. data/lib/language_operator/client/base.rb +214 -0
  74. data/lib/language_operator/client/config.rb +136 -0
  75. data/lib/language_operator/client/cost_calculator.rb +37 -0
  76. data/lib/language_operator/client/mcp_connector.rb +123 -0
  77. data/lib/language_operator/client.rb +19 -0
  78. data/lib/language_operator/config/cluster_config.rb +101 -0
  79. data/lib/language_operator/config/tool_patterns.yaml +57 -0
  80. data/lib/language_operator/config/tool_registry.rb +96 -0
  81. data/lib/language_operator/config.rb +138 -0
  82. data/lib/language_operator/dsl/adapter.rb +124 -0
  83. data/lib/language_operator/dsl/agent_context.rb +90 -0
  84. data/lib/language_operator/dsl/agent_definition.rb +427 -0
  85. data/lib/language_operator/dsl/chat_endpoint_definition.rb +115 -0
  86. data/lib/language_operator/dsl/config.rb +119 -0
  87. data/lib/language_operator/dsl/context.rb +50 -0
  88. data/lib/language_operator/dsl/execution_context.rb +47 -0
  89. data/lib/language_operator/dsl/helpers.rb +109 -0
  90. data/lib/language_operator/dsl/http.rb +184 -0
  91. data/lib/language_operator/dsl/mcp_server_definition.rb +73 -0
  92. data/lib/language_operator/dsl/parameter_definition.rb +124 -0
  93. data/lib/language_operator/dsl/registry.rb +36 -0
  94. data/lib/language_operator/dsl/shell.rb +125 -0
  95. data/lib/language_operator/dsl/tool_definition.rb +112 -0
  96. data/lib/language_operator/dsl/webhook_authentication.rb +114 -0
  97. data/lib/language_operator/dsl/webhook_definition.rb +106 -0
  98. data/lib/language_operator/dsl/workflow_definition.rb +259 -0
  99. data/lib/language_operator/dsl.rb +160 -0
  100. data/lib/language_operator/errors.rb +60 -0
  101. data/lib/language_operator/kubernetes/client.rb +279 -0
  102. data/lib/language_operator/kubernetes/resource_builder.rb +194 -0
  103. data/lib/language_operator/loggable.rb +47 -0
  104. data/lib/language_operator/logger.rb +141 -0
  105. data/lib/language_operator/retry.rb +123 -0
  106. data/lib/language_operator/retryable.rb +132 -0
  107. data/lib/language_operator/tool_loader.rb +242 -0
  108. data/lib/language_operator/validators.rb +170 -0
  109. data/lib/language_operator/version.rb +1 -1
  110. data/lib/language_operator.rb +65 -3
  111. data/requirements/tasks/challenge.md +9 -0
  112. data/requirements/tasks/iterate.md +36 -0
  113. data/requirements/tasks/optimize.md +21 -0
  114. data/requirements/tasks/tag.md +5 -0
  115. data/test_agent_dsl.rb +108 -0
  116. metadata +503 -20
@@ -0,0 +1,610 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack'
4
+ require 'rackup'
5
+ require 'mcp'
6
+
7
+ module LanguageOperator
8
+ module Agent
9
+ # Web Server for Reactive Agents
10
+ #
11
+ # Enables agents to receive HTTP requests (webhooks, API calls) and respond
12
+ # to them. Agents in :reactive mode run an HTTP server that listens for
13
+ # incoming requests and triggers agent execution.
14
+ #
15
+ # @example Starting a web server for an agent
16
+ # server = LanguageOperator::Agent::WebServer.new(agent)
17
+ # server.start
18
+ class WebServer
19
+ attr_reader :agent, :port
20
+
21
+ # Initialize the web server
22
+ #
23
+ # @param agent [LanguageOperator::Agent::Base] The agent instance
24
+ # @param port [Integer] Port to listen on (default: ENV['PORT'] || 8080)
25
+ def initialize(agent, port: nil)
26
+ @agent = agent
27
+ @port = port || ENV.fetch('PORT', '8080').to_i
28
+ @routes = {}
29
+ @executor = Executor.new(agent)
30
+ @mcp_server = nil
31
+ @mcp_transport = nil
32
+
33
+ setup_default_routes
34
+ end
35
+
36
+ # Start the HTTP server
37
+ #
38
+ # @return [void]
39
+ def start
40
+ puts "Starting Agent HTTP server on http://0.0.0.0:#{@port}"
41
+ puts "Agent: #{@agent.class.name}"
42
+ puts 'Mode: reactive'
43
+
44
+ # Start the server with Puma
45
+ Rackup::Handler.get('puma').run(rack_app, Port: @port, Host: '0.0.0.0')
46
+ end
47
+
48
+ # Register a webhook route
49
+ #
50
+ # @param path [String] The URL path
51
+ # @param method [Symbol] HTTP method (:get, :post, :put, :delete, :patch)
52
+ # @param authentication [LanguageOperator::Dsl::WebhookAuthentication, nil] Authentication configuration
53
+ # @param validations [Array<Hash>, nil] Validation rules
54
+ # @param handler [Proc] Request handler block
55
+ # @return [void]
56
+ def register_route(path, method: :post, authentication: nil, validations: nil, &handler)
57
+ @routes[normalize_route_key(path, method)] = {
58
+ handler: handler,
59
+ authentication: authentication,
60
+ validations: validations || []
61
+ }
62
+ end
63
+
64
+ # Check if a route exists
65
+ #
66
+ # @param path [String] The URL path
67
+ # @param method [Symbol] HTTP method
68
+ # @return [Boolean]
69
+ def route_exists?(path, method)
70
+ @routes.key?(normalize_route_key(path, method))
71
+ end
72
+
73
+ # Register MCP tools
74
+ #
75
+ # Sets up MCP protocol endpoints for tool discovery and execution.
76
+ # Tools defined in the agent will be exposed via MCP protocol.
77
+ #
78
+ # @param mcp_server_def [LanguageOperator::Dsl::McpServerDefinition] MCP server definition
79
+ # @return [void]
80
+ def register_mcp_tools(mcp_server_def)
81
+ require_relative '../dsl/adapter'
82
+
83
+ # Convert tool definitions to MCP::Tool classes
84
+ mcp_tools = mcp_server_def.all_tools.map do |tool_def|
85
+ Dsl::Adapter.tool_definition_to_mcp_tool(tool_def)
86
+ end
87
+
88
+ # Create MCP server
89
+ @mcp_server = MCP::Server.new(
90
+ name: mcp_server_def.server_name,
91
+ version: LanguageOperator::VERSION,
92
+ tools: mcp_tools
93
+ )
94
+
95
+ # Create the Streamable HTTP transport
96
+ @mcp_transport = MCP::Server::Transports::StreamableHTTPTransport.new(@mcp_server)
97
+ @mcp_server.transport = @mcp_transport
98
+
99
+ # Register MCP endpoint
100
+ register_route('/mcp', method: :post) do |context|
101
+ handle_mcp_request(context[:request])
102
+ end
103
+
104
+ puts "Registered #{mcp_tools.size} MCP tools"
105
+ end
106
+
107
+ # Register chat completion endpoint
108
+ #
109
+ # Sets up OpenAI-compatible chat completion endpoint.
110
+ # Agents can be used as drop-in LLM replacements.
111
+ #
112
+ # @param chat_endpoint_def [LanguageOperator::Dsl::ChatEndpointDefinition] Chat endpoint definition
113
+ # @param agent [LanguageOperator::Agent::Base] The agent instance
114
+ # @return [void]
115
+ def register_chat_endpoint(chat_endpoint_def, agent)
116
+ @chat_endpoint = chat_endpoint_def
117
+ @chat_agent = agent
118
+
119
+ # Register OpenAI-compatible endpoint
120
+ register_route('/v1/chat/completions', method: :post) do |context|
121
+ handle_chat_completion(context)
122
+ end
123
+
124
+ # Also register models endpoint for compatibility
125
+ register_route('/v1/models', method: :get) do |_context|
126
+ {
127
+ object: 'list',
128
+ data: [
129
+ {
130
+ id: chat_endpoint_def.model_name,
131
+ object: 'model',
132
+ created: Time.now.to_i,
133
+ owned_by: 'language-operator',
134
+ permission: [],
135
+ root: chat_endpoint_def.model_name,
136
+ parent: nil
137
+ }
138
+ ]
139
+ }
140
+ end
141
+
142
+ puts "Registered chat completion endpoint as model: #{chat_endpoint_def.model_name}"
143
+ end
144
+
145
+ # Handle incoming HTTP request
146
+ #
147
+ # @param env [Hash] Rack environment
148
+ # @return [Array] Rack response [status, headers, body]
149
+ def handle_request(env)
150
+ request = Rack::Request.new(env)
151
+ path = request.path
152
+ method = request.request_method.downcase.to_sym
153
+
154
+ # Try to find a matching route
155
+ route_key = normalize_route_key(path, method)
156
+ route_config = @routes[route_key]
157
+
158
+ if route_config
159
+ execute_handler(route_config, request)
160
+ else
161
+ not_found_response(path, method)
162
+ end
163
+ rescue StandardError => e
164
+ error_response(e)
165
+ end
166
+
167
+ private
168
+
169
+ # Build the Rack application
170
+ #
171
+ # @return [Rack::Builder]
172
+ def rack_app
173
+ server = self
174
+
175
+ Rack::Builder.new do
176
+ use Rack::CommonLogger
177
+ use Rack::ShowExceptions
178
+ use Rack::ContentLength
179
+
180
+ run ->(env) { server.handle_request(env) }
181
+ end
182
+ end
183
+
184
+ # Execute a route handler
185
+ #
186
+ # @param route_config [Hash, Proc] Route configuration or legacy handler proc
187
+ # @param request [Rack::Request] The request
188
+ # @return [Array] Rack response
189
+ def execute_handler(route_config, request)
190
+ require_relative 'webhook_authenticator'
191
+
192
+ # Support legacy handler-only format
193
+ route_config = { handler: route_config, authentication: nil, validations: [] } if route_config.is_a?(Proc)
194
+
195
+ handler = route_config[:handler]
196
+ authentication = route_config[:authentication]
197
+ validations = route_config[:validations]
198
+
199
+ # Build request context
200
+ context = build_request_context(request)
201
+
202
+ # Perform authentication
203
+ if authentication
204
+ authenticated = WebhookAuthenticator.authenticate(authentication, context)
205
+ unless authenticated
206
+ return [
207
+ 401,
208
+ { 'Content-Type' => 'application/json' },
209
+ [JSON.generate({ error: 'Unauthorized', message: 'Authentication failed' })]
210
+ ]
211
+ end
212
+ end
213
+
214
+ # Perform validations
215
+ unless validations.empty?
216
+ validation_errors = WebhookAuthenticator.validate(validations, context)
217
+ unless validation_errors.empty?
218
+ return [
219
+ 400,
220
+ { 'Content-Type' => 'application/json' },
221
+ [JSON.generate({ error: 'Bad Request', message: 'Validation failed', errors: validation_errors })]
222
+ ]
223
+ end
224
+ end
225
+
226
+ # Execute handler (could be async)
227
+ result = handler.call(context)
228
+
229
+ # Build response
230
+ success_response(result)
231
+ end
232
+
233
+ # Build request context for handlers
234
+ #
235
+ # @param request [Rack::Request] The request
236
+ # @return [Hash] Request context
237
+ def build_request_context(request)
238
+ # Read body, handling nil case
239
+ body_content = if request.body
240
+ request.body.read
241
+ else
242
+ ''
243
+ end
244
+
245
+ {
246
+ path: request.path,
247
+ method: request.request_method,
248
+ headers: extract_headers(request),
249
+ params: request.params,
250
+ body: body_content,
251
+ request: request
252
+ }
253
+ end
254
+
255
+ # Extract relevant headers from request
256
+ #
257
+ # @param request [Rack::Request] The request
258
+ # @return [Hash] Headers hash
259
+ def extract_headers(request)
260
+ headers = {}
261
+ request.each_header do |key, value|
262
+ # Convert HTTP_HEADER_NAME to Header-Name
263
+ if key.start_with?('HTTP_')
264
+ # Strip HTTP_ prefix and normalize
265
+ header_name = key[5..].split('_').map(&:capitalize).join('-')
266
+ headers[header_name] = value
267
+ # Also include standard CGI headers like CONTENT_TYPE, CONTENT_LENGTH
268
+ # But only if not already set by HTTP_ version (HTTP_CONTENT_TYPE takes precedence)
269
+ elsif %w[CONTENT_TYPE CONTENT_LENGTH].include?(key)
270
+ header_name = key.split('_').map(&:capitalize).join('-')
271
+ headers[header_name] ||= value # Only set if not already present
272
+ # In test environment (Rack::Test), headers may come through as-is
273
+ elsif key.include?('-') || key.start_with?('X-', 'Authorization')
274
+ headers[key] = value
275
+ end
276
+ end
277
+ headers
278
+ end
279
+
280
+ # Setup default routes
281
+ #
282
+ # @return [void]
283
+ def setup_default_routes
284
+ # Health check endpoint
285
+ register_route('/health', method: :get) do |_context|
286
+ { status: 'healthy', agent: @agent.class.name }
287
+ end
288
+
289
+ # Ready check endpoint
290
+ register_route('/ready', method: :get) do |_context|
291
+ {
292
+ status: @agent.workspace_available? ? 'ready' : 'not_ready',
293
+ workspace: @agent.workspace_path
294
+ }
295
+ end
296
+
297
+ # Default webhook endpoint (fallback)
298
+ register_route('/webhook', method: :post) do |context|
299
+ handle_webhook(context)
300
+ end
301
+ end
302
+
303
+ # Handle webhook request by executing agent
304
+ #
305
+ # @param context [Hash] Request context
306
+ # @return [Hash] Response data
307
+ def handle_webhook(context)
308
+ # Execute agent with webhook context
309
+ result = @executor.execute_with_context(
310
+ instruction: 'Process incoming webhook request',
311
+ context: context
312
+ )
313
+
314
+ {
315
+ status: 'processed',
316
+ result: result,
317
+ timestamp: Time.now.iso8601
318
+ }
319
+ end
320
+
321
+ # Handle MCP protocol request
322
+ #
323
+ # @param request [Rack::Request] The request
324
+ # @return [Hash] Response data (will be converted to Rack response by transport)
325
+ def handle_mcp_request(request)
326
+ return { error: 'MCP server not initialized' } unless @mcp_transport
327
+
328
+ # The transport handles the MCP protocol
329
+ @mcp_transport.handle_request(request)
330
+ end
331
+
332
+ # Handle chat completion request
333
+ #
334
+ # @param context [Hash] Request context
335
+ # @return [Array, Hash] Rack response or hash for streaming
336
+ def handle_chat_completion(context)
337
+ return error_response(StandardError.new('Chat endpoint not configured')) unless @chat_endpoint
338
+
339
+ # Parse request body
340
+ request_data = JSON.parse(context[:body])
341
+
342
+ # Check if streaming is requested
343
+ if request_data['stream']
344
+ handle_streaming_chat(request_data, context[:request])
345
+ else
346
+ handle_non_streaming_chat(request_data)
347
+ end
348
+ rescue JSON::ParserError => e
349
+ error_response(StandardError.new("Invalid JSON: #{e.message}"))
350
+ rescue StandardError => e
351
+ error_response(e)
352
+ end
353
+
354
+ # Handle non-streaming chat completion
355
+ #
356
+ # @param request_data [Hash] Parsed request data
357
+ # @return [Hash] Chat completion response
358
+ def handle_non_streaming_chat(request_data)
359
+ messages = request_data['messages'] || []
360
+
361
+ # Build prompt from messages
362
+ prompt = build_prompt_from_messages(messages)
363
+
364
+ # Execute agent
365
+ result = @chat_agent.execute(prompt)
366
+
367
+ # Build OpenAI-compatible response
368
+ {
369
+ id: "chatcmpl-#{SecureRandom.hex(12)}",
370
+ object: 'chat.completion',
371
+ created: Time.now.to_i,
372
+ model: @chat_endpoint.model_name,
373
+ choices: [
374
+ {
375
+ index: 0,
376
+ message: {
377
+ role: 'assistant',
378
+ content: result
379
+ },
380
+ finish_reason: 'stop'
381
+ }
382
+ ],
383
+ usage: {
384
+ prompt_tokens: estimate_tokens(prompt),
385
+ completion_tokens: estimate_tokens(result),
386
+ total_tokens: estimate_tokens(prompt) + estimate_tokens(result)
387
+ }
388
+ }
389
+ end
390
+
391
+ # Handle streaming chat completion
392
+ #
393
+ # @param request_data [Hash] Parsed request data
394
+ # @param request [Rack::Request] The Rack request
395
+ # @return [Array] Rack streaming response
396
+ def handle_streaming_chat(request_data, _request)
397
+ messages = request_data['messages'] || []
398
+ prompt = build_prompt_from_messages(messages)
399
+
400
+ # Return a streaming response
401
+ [
402
+ 200,
403
+ {
404
+ 'Content-Type' => 'text/event-stream',
405
+ 'Cache-Control' => 'no-cache',
406
+ 'Connection' => 'keep-alive'
407
+ },
408
+ StreamingBody.new(@chat_agent, prompt, @chat_endpoint.model_name)
409
+ ]
410
+ end
411
+
412
+ # Build prompt from OpenAI message format
413
+ #
414
+ # @param messages [Array<Hash>] Array of message objects
415
+ # @return [String] Combined prompt
416
+ def build_prompt_from_messages(messages)
417
+ # Combine all messages into a single prompt
418
+ # System messages become instructions
419
+ # User/assistant messages become conversation
420
+ prompt_parts = []
421
+
422
+ # Add system prompt if configured
423
+ prompt_parts << "System: #{@chat_endpoint.system_prompt}" if @chat_endpoint.system_prompt
424
+
425
+ # Add conversation history
426
+ messages.each do |msg|
427
+ role = msg['role']
428
+ content = msg['content']
429
+
430
+ case role
431
+ when 'system'
432
+ prompt_parts << "System: #{content}"
433
+ when 'user'
434
+ prompt_parts << "User: #{content}"
435
+ when 'assistant'
436
+ prompt_parts << "Assistant: #{content}"
437
+ end
438
+ end
439
+
440
+ prompt_parts.join("\n\n")
441
+ end
442
+
443
+ # Estimate token count (rough approximation)
444
+ #
445
+ # @param text [String] Text to estimate
446
+ # @return [Integer] Estimated token count
447
+ def estimate_tokens(text)
448
+ # Rough approximation: 1 token ≈ 4 characters
449
+ (text.length / 4.0).ceil
450
+ end
451
+
452
+ # Build success response
453
+ #
454
+ # @param data [Hash, String] Response data
455
+ # @return [Array] Rack response
456
+ def success_response(data)
457
+ # If data is already a Rack response tuple, return as-is
458
+ return data if data.is_a?(Array) && data.length == 3 && data[0].is_a?(Integer)
459
+
460
+ body = data.is_a?(Hash) ? JSON.generate(data) : data.to_s
461
+
462
+ [
463
+ 200,
464
+ { 'Content-Type' => 'application/json' },
465
+ [body]
466
+ ]
467
+ end
468
+
469
+ # Build not found response
470
+ #
471
+ # @param path [String] Request path
472
+ # @param method [Symbol] HTTP method
473
+ # @return [Array] Rack response
474
+ def not_found_response(path, method)
475
+ [
476
+ 404,
477
+ { 'Content-Type' => 'application/json' },
478
+ [JSON.generate({
479
+ error: 'Not Found',
480
+ message: "No route for #{method.upcase} #{path}",
481
+ available_routes: @routes.keys
482
+ })]
483
+ ]
484
+ end
485
+
486
+ # Build error response
487
+ #
488
+ # @param error [Exception] The error
489
+ # @return [Array] Rack response
490
+ def error_response(error)
491
+ [
492
+ 500,
493
+ { 'Content-Type' => 'application/json' },
494
+ [JSON.generate({
495
+ error: error.class.name,
496
+ message: error.message,
497
+ backtrace: error.backtrace&.first(5)
498
+ })]
499
+ ]
500
+ end
501
+
502
+ # Normalize route key for storage/lookup
503
+ #
504
+ # @param path [String] URL path
505
+ # @param method [Symbol] HTTP method
506
+ # @return [String] Normalized key
507
+ def normalize_route_key(path, method)
508
+ "#{method.to_s.upcase} #{path}"
509
+ end
510
+ end
511
+
512
+ # Streaming body for Server-Sent Events (SSE)
513
+ #
514
+ # Implements the Rack streaming protocol for chat completion responses.
515
+ # Streams agent output as it's generated.
516
+ class StreamingBody
517
+ def initialize(agent, prompt, model_name)
518
+ @agent = agent
519
+ @prompt = prompt
520
+ @model_name = model_name
521
+ @id = "chatcmpl-#{SecureRandom.hex(12)}"
522
+ end
523
+
524
+ # Implement each for Rack::Test compatibility
525
+ #
526
+ # @yield [String] Each chunk of data
527
+ # @return [void]
528
+ def each
529
+ buffer = StringIO.new
530
+ stream = MockStream.new(buffer)
531
+ call(stream)
532
+ yield buffer.string
533
+ end
534
+
535
+ # Mock stream for testing
536
+ class MockStream
537
+ def initialize(buffer)
538
+ @buffer = buffer
539
+ end
540
+
541
+ def write(data)
542
+ @buffer.write(data)
543
+ end
544
+
545
+ def close
546
+ # No-op
547
+ end
548
+ end
549
+
550
+ # Called by Rack to stream the response
551
+ #
552
+ # @param stream [Object] The stream object
553
+ # @return [void]
554
+ def call(stream)
555
+ # Execute agent and stream response
556
+ result = @agent.execute(@prompt)
557
+
558
+ # Send the result as a single chunk (for simplicity)
559
+ # In a real implementation, this could stream token-by-token
560
+ chunk = {
561
+ id: @id,
562
+ object: 'chat.completion.chunk',
563
+ created: Time.now.to_i,
564
+ model: @model_name,
565
+ choices: [
566
+ {
567
+ index: 0,
568
+ delta: {
569
+ role: 'assistant',
570
+ content: result
571
+ },
572
+ finish_reason: nil
573
+ }
574
+ ]
575
+ }
576
+
577
+ stream.write("data: #{JSON.generate(chunk)}\n\n")
578
+
579
+ # Send final chunk with finish_reason
580
+ final_chunk = {
581
+ id: @id,
582
+ object: 'chat.completion.chunk',
583
+ created: Time.now.to_i,
584
+ model: @model_name,
585
+ choices: [
586
+ {
587
+ index: 0,
588
+ delta: {},
589
+ finish_reason: 'stop'
590
+ }
591
+ ]
592
+ }
593
+
594
+ stream.write("data: #{JSON.generate(final_chunk)}\n\n")
595
+ stream.write("data: [DONE]\n\n")
596
+ rescue StandardError => e
597
+ error_chunk = {
598
+ error: {
599
+ message: e.message,
600
+ type: 'server_error',
601
+ code: nil
602
+ }
603
+ }
604
+ stream.write("data: #{JSON.generate(error_chunk)}\n\n")
605
+ ensure
606
+ stream.close
607
+ end
608
+ end
609
+ end
610
+ end