rubyn-code 0.2.2 → 0.4.0

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 (154) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +151 -5
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  5. data/lib/rubyn_code/agent/conversation.rb +84 -56
  6. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
  7. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  8. data/lib/rubyn_code/agent/llm_caller.rb +157 -0
  9. data/lib/rubyn_code/agent/loop.rb +182 -683
  10. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  11. data/lib/rubyn_code/agent/prompts.rb +109 -0
  12. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  13. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  14. data/lib/rubyn_code/agent/system_prompt_builder.rb +211 -0
  15. data/lib/rubyn_code/agent/tool_processor.rb +178 -0
  16. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  17. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  18. data/lib/rubyn_code/auth/oauth.rb +80 -64
  19. data/lib/rubyn_code/auth/server.rb +21 -24
  20. data/lib/rubyn_code/auth/token_store.rb +80 -52
  21. data/lib/rubyn_code/autonomous/daemon.rb +146 -32
  22. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
  23. data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
  24. data/lib/rubyn_code/background/worker.rb +64 -76
  25. data/lib/rubyn_code/cli/app.rb +159 -114
  26. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  27. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  28. data/lib/rubyn_code/cli/commands/model.rb +105 -18
  29. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  30. data/lib/rubyn_code/cli/commands/provider.rb +123 -0
  31. data/lib/rubyn_code/cli/commands/skill.rb +52 -3
  32. data/lib/rubyn_code/cli/daemon_runner.rb +64 -11
  33. data/lib/rubyn_code/cli/first_run.rb +159 -0
  34. data/lib/rubyn_code/cli/renderer.rb +109 -60
  35. data/lib/rubyn_code/cli/repl.rb +48 -374
  36. data/lib/rubyn_code/cli/repl_commands.rb +177 -0
  37. data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
  38. data/lib/rubyn_code/cli/repl_setup.rb +181 -0
  39. data/lib/rubyn_code/cli/setup.rb +6 -2
  40. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  41. data/lib/rubyn_code/cli/version_check.rb +28 -11
  42. data/lib/rubyn_code/config/defaults.rb +11 -0
  43. data/lib/rubyn_code/config/project_profile.rb +185 -0
  44. data/lib/rubyn_code/config/schema.json +49 -0
  45. data/lib/rubyn_code/config/settings.rb +103 -1
  46. data/lib/rubyn_code/config/validator.rb +63 -0
  47. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  48. data/lib/rubyn_code/context/context_budget.rb +182 -0
  49. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  50. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  51. data/lib/rubyn_code/context/manager.rb +44 -8
  52. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  53. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  54. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  55. data/lib/rubyn_code/db/connection.rb +31 -26
  56. data/lib/rubyn_code/db/migrator.rb +44 -28
  57. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  58. data/lib/rubyn_code/hooks/registry.rb +4 -0
  59. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  60. data/lib/rubyn_code/ide/client.rb +110 -0
  61. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  62. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  63. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  64. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  65. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  66. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  67. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  68. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
  69. data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
  70. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  71. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  72. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  73. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  74. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  75. data/lib/rubyn_code/ide/handlers.rb +76 -0
  76. data/lib/rubyn_code/ide/protocol.rb +111 -0
  77. data/lib/rubyn_code/ide/server.rb +186 -0
  78. data/lib/rubyn_code/index/codebase_index.rb +311 -0
  79. data/lib/rubyn_code/learning/extractor.rb +65 -82
  80. data/lib/rubyn_code/learning/injector.rb +22 -23
  81. data/lib/rubyn_code/learning/instinct.rb +71 -42
  82. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  83. data/lib/rubyn_code/llm/adapters/anthropic.rb +274 -0
  84. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  85. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  86. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  87. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  88. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  89. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +50 -0
  90. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  91. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  92. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  93. data/lib/rubyn_code/llm/client.rb +75 -247
  94. data/lib/rubyn_code/llm/model_router.rb +237 -0
  95. data/lib/rubyn_code/llm/streaming.rb +4 -227
  96. data/lib/rubyn_code/mcp/client.rb +1 -1
  97. data/lib/rubyn_code/mcp/config.rb +10 -12
  98. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  99. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  100. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  101. data/lib/rubyn_code/memory/search.rb +1 -0
  102. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  103. data/lib/rubyn_code/memory/store.rb +42 -55
  104. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  105. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  106. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  107. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  108. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  109. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  110. data/lib/rubyn_code/output/formatter.rb +11 -11
  111. data/lib/rubyn_code/permissions/policy.rb +11 -13
  112. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  113. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  114. data/lib/rubyn_code/self_test.rb +315 -0
  115. data/lib/rubyn_code/skills/catalog.rb +66 -0
  116. data/lib/rubyn_code/skills/document.rb +33 -29
  117. data/lib/rubyn_code/skills/loader.rb +43 -0
  118. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  119. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  120. data/lib/rubyn_code/tasks/dag.rb +25 -24
  121. data/lib/rubyn_code/tasks/models.rb +1 -0
  122. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  123. data/lib/rubyn_code/tools/background_run.rb +2 -1
  124. data/lib/rubyn_code/tools/base.rb +39 -32
  125. data/lib/rubyn_code/tools/bash.rb +7 -1
  126. data/lib/rubyn_code/tools/edit_file.rb +130 -17
  127. data/lib/rubyn_code/tools/executor.rb +130 -25
  128. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  129. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  130. data/lib/rubyn_code/tools/git_log.rb +12 -10
  131. data/lib/rubyn_code/tools/glob.rb +29 -7
  132. data/lib/rubyn_code/tools/grep.rb +8 -1
  133. data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
  134. data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
  135. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  136. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  137. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  138. data/lib/rubyn_code/tools/output_compressor.rb +190 -0
  139. data/lib/rubyn_code/tools/read_file.rb +17 -6
  140. data/lib/rubyn_code/tools/registry.rb +11 -0
  141. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  142. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  143. data/lib/rubyn_code/tools/schema.rb +4 -10
  144. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  145. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  146. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  147. data/lib/rubyn_code/tools/task.rb +17 -17
  148. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  149. data/lib/rubyn_code/tools/web_search.rb +66 -48
  150. data/lib/rubyn_code/tools/write_file.rb +76 -1
  151. data/lib/rubyn_code/version.rb +1 -1
  152. data/lib/rubyn_code.rb +62 -1
  153. data/skills/rubyn_self_test.md +133 -0
  154. metadata +83 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 17b949375b6599cc1899caffd006f644a7292244982103608bfe31ad9146f549
4
- data.tar.gz: 9c97ea6f5a1d73a91b220221b20f028833b86e7f6caa3904860e5000004f3fa9
3
+ metadata.gz: 1a3ef81b040a1c9a75050f2545f229ba69aa88808ced47ad5cc8634ea52f115c
4
+ data.tar.gz: 9020382400cdf70d332858a9b85c89e5aa4a90e07965709e8520a803273d43b2
5
5
  SHA512:
6
- metadata.gz: 33bd180490d02c55def1d138ee8c71607c639ddb7f66f5fc05392e31bd41feead7bbf42d6224e67e36651410aeac6b67e55afc9522f965affc2787d91cc54967
7
- data.tar.gz: a55628e9fb27d9de1c9822c6fc215820fa6b639d9f9fd6f4750b79f26ab31a09c59da8def39d391ba54f3bcbe92516973904b40260c81afe0b60017746eefa3b
6
+ metadata.gz: d7c2b95f2eec7e20e589c377784a2236f5ca7fa62402012358137a895f18e899e521ab2a3280854dda2376d515d906a40ad56dcc9c21d65062da6c9549a18912
7
+ data.tar.gz: cbb80d2749475054829c46aadd8ed5b7244d99f9d6ee05f73a0cd85618866e4e43881ae77e6718dbdcc6cced9e57222752b2a5c403185ae92a039a13da5105e1
data/README.md CHANGED
@@ -80,7 +80,7 @@ bundle exec ruby -Ilib exe/rubyn-code
80
80
 
81
81
  </details>
82
82
 
83
- **Authentication:** Rubyn Code reads your Claude Code OAuth token from the macOS Keychain automatically. Just make sure you've logged into Claude Code once (`claude` in your terminal). Also supports `ANTHROPIC_API_KEY` env var.
83
+ **Authentication:** Rubyn Code reads your Claude Code OAuth token from the macOS Keychain automatically. Just make sure you've logged into Claude Code once (`claude` in your terminal). Also supports `ANTHROPIC_API_KEY` env var. See [Authentication](#authentication) for OpenAI and other providers.
84
84
 
85
85
  ## Quick Start
86
86
 
@@ -142,7 +142,7 @@ Agent finished (23 tool calls).
142
142
  This is a Rails 7.1 e-commerce app with...
143
143
  ```
144
144
 
145
- ## 28 Built-in Tools
145
+ ## 29 Built-in Tools
146
146
 
147
147
  | Category | Tools |
148
148
  |----------|-------|
@@ -157,6 +157,7 @@ This is a Rails 7.1 e-commerce app with...
157
157
  | **Context** | `compact`, `load_skill`, `task` |
158
158
  | **Memory** | `memory_search`, `memory_write` |
159
159
  | **Teams** | `send_message`, `read_inbox` |
160
+ | **Interactive** | `ask_user` (ask clarifying questions mid-task) |
160
161
 
161
162
  ## 112 Best Practice Skills
162
163
 
@@ -323,6 +324,7 @@ rubyn-code --help # Show help
323
324
  |---------|---------|
324
325
  | `/help` | Show help |
325
326
  | `/quit` | Exit (saves session + extracts learnings) |
327
+ | `/new` | Save session and start a fresh conversation |
326
328
  | `/review [base]` | PR review against best practices |
327
329
  | `/spawn name role` | Spawn a persistent teammate |
328
330
  | `/compact` | Compress conversation context |
@@ -331,9 +333,13 @@ rubyn-code --help # Show help
331
333
  | `/budget [amt]` | Show or set session budget |
332
334
  | `/skill [name]` | Load or list available skills |
333
335
  | `/resume [id]` | Resume or list sessions |
336
+ | `/provider` | Add or list providers |
337
+ | `/model` | Show/switch model and provider |
334
338
 
335
339
  ## Authentication
336
340
 
341
+ ### Anthropic (default)
342
+
337
343
  | Priority | Source | Setup |
338
344
  |----------|--------|-------|
339
345
  | 1 | macOS Keychain | Log into Claude Code once: `claude` |
@@ -342,6 +348,60 @@ rubyn-code --help # Show help
342
348
 
343
349
  Works with Claude Pro, Max, Team, and Enterprise. Default model: **Claude Opus 4.6**.
344
350
 
351
+ ### OpenAI
352
+
353
+ ```bash
354
+ export OPENAI_API_KEY=sk-...
355
+ ```
356
+
357
+ Available models: `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-4o`, `gpt-4o-mini`, `o3`, `o4-mini`
358
+
359
+ ### Other Providers (Groq, Together, Ollama, etc.)
360
+
361
+ Add a provider and its API key in one command:
362
+
363
+ ```bash
364
+ /provider add groq https://api.groq.com/openai/v1 --key gsk-xxx --models llama-3.3-70b
365
+
366
+ # For Anthropic-format proxies (e.g., Bedrock, custom gateways)
367
+ /provider add my-proxy https://proxy.example.com/v1 --format anthropic --key sk-xxx --models claude-sonnet-4-6
368
+
369
+ # Update a key later
370
+ /provider set-key groq gsk-new-key
371
+
372
+ # List configured providers
373
+ /provider list
374
+ ```
375
+
376
+ API keys are **encrypted at rest** using AES-256-GCM. The encryption key is derived from
377
+ your machine identity (username, hostname, home directory) via PBKDF2, so keys are only
378
+ decryptable on the same machine by the same user. Rubyn decrypts them automatically at
379
+ runtime and re-encrypts on save — no manual steps required.
380
+
381
+ Keys stored via environment variables (`GROQ_API_KEY`, `TOGETHER_API_KEY`, etc.) also work
382
+ as a fallback if you prefer that approach.
383
+
384
+ Or add directly to `~/.rubyn-code/config.yml`:
385
+
386
+ ```yaml
387
+ providers:
388
+ groq:
389
+ base_url: https://api.groq.com/openai/v1
390
+ env_key: GROQ_API_KEY
391
+ models:
392
+ top: llama-3.3-70b
393
+ my-proxy:
394
+ api_format: anthropic # 'openai' (default) or 'anthropic'
395
+ base_url: https://proxy.example.com/v1
396
+ env_key: PROXY_API_KEY
397
+ models:
398
+ top: claude-sonnet-4-6
399
+ ```
400
+
401
+ Then switch with `/model groq:llama-3.3-70b`.
402
+
403
+ Local providers (Ollama, LM Studio) running on `localhost`/`127.0.0.1` don't require an API key.
404
+
345
405
  ## Architecture
346
406
 
347
407
  16-layer agentic architecture:
@@ -362,25 +422,111 @@ Works with Claude Pro, Max, Team, and Enterprise. Default model: **Claude Opus 4
362
422
  │ Layer 5: Skills (112 best practice docs, on-demand loading) │
363
423
  │ Layer 4: Context Management (3-layer compression pipeline) │
364
424
  │ Layer 3: Permissions (tiered access + deny lists + hooks) │
365
- │ Layer 2: Tool System (28 tools, dispatch map registry) │
425
+ │ Layer 2: Tool System (29 tools, dispatch map registry) │
366
426
  │ Layer 1: THE AGENT LOOP (while tool_use → execute → repeat) │
367
427
  └──────────────────────────────────────────────────────────────┘
368
428
  ```
369
429
 
370
430
  ## Configuration
371
431
 
432
+ The `provider` and `model` keys at the top set the **default provider and model** used at startup.
433
+ These must match a provider defined in the `providers` section (or a built-in like `anthropic`/`openai`).
434
+
372
435
  ```yaml
373
436
  # ~/.rubyn-code/config.yml (global)
374
- model: claude-opus-4-6
437
+ provider: anthropic # default provider on startup
438
+ model: claude-opus-4-6 # default model on startup
375
439
  permission_mode: allow_read
376
440
  session_budget: 5.00
377
441
  daily_budget: 10.00
378
442
 
379
443
  # .rubyn-code/config.yml (project — overrides global)
380
- model: claude-sonnet-4-6
444
+ provider: minimax # this project uses MiniMax by default
445
+ model: MiniMax-M2.7-highspeed
381
446
  permission_mode: autonomous
382
447
  ```
383
448
 
449
+ ### Multi-Provider Model Routing
450
+
451
+ Rubyn can automatically route tasks to different AI models based on complexity. Simple tasks (file search, git ops) use cheap, fast models. Complex tasks (architecture, security review) use the most capable model. Configure per-provider model tiers in `config.yml`:
452
+
453
+ ```yaml
454
+ # ~/.rubyn-code/config.yml
455
+ provider: anthropic
456
+ model: claude-opus-4-6
457
+
458
+ providers:
459
+ anthropic:
460
+ env_key: ANTHROPIC_API_KEY
461
+ models:
462
+ cheap: claude-haiku-4-5 # file search, git ops, formatting
463
+ mid: claude-sonnet-4-6 # code gen, specs, refactors, reviews
464
+ top: claude-opus-4-6 # architecture, security, complex work
465
+
466
+ openai:
467
+ env_key: OPENAI_API_KEY
468
+ models:
469
+ cheap: gpt-5.4-nano # lightweight tasks
470
+ mid: gpt-5.4-mini # regular coding
471
+ top: gpt-5.4 # complex reasoning
472
+
473
+ groq:
474
+ base_url: https://api.groq.com/openai/v1
475
+ env_key: GROQ_API_KEY
476
+ models:
477
+ cheap: llama-3-8b
478
+ mid: llama-3-70b
479
+ pricing:
480
+ llama-3-8b: [0.05, 0.08] # [input_rate, output_rate] per million tokens
481
+ llama-3-70b: [0.59, 0.79]
482
+
483
+ ollama:
484
+ base_url: http://localhost:11434/v1
485
+ models:
486
+ cheap: llama3
487
+ mid: llama3
488
+ top: llama3
489
+ ```
490
+
491
+ **How it works:** When you ask Rubyn to do something, the Model Router detects the task type and picks the right tier. If you've configured model tiers for a provider, those are used first. Otherwise it falls back to the built-in defaults (Anthropic for all tiers).
492
+
493
+ | Tier | Task types | Default model |
494
+ |------|-----------|---------------|
495
+ | **cheap** | File search, git ops, formatting, summaries | `claude-haiku-4-5` |
496
+ | **mid** | Code generation, specs, refactors, code review, bug fixes | `claude-sonnet-4-6` |
497
+ | **top** | Architecture, security review, complex refactors, planning | `claude-opus-4-6` |
498
+
499
+ You can also set custom pricing per model so `/cost` reports accurate spending for third-party providers.
500
+
501
+ ## Security
502
+
503
+ ### Credential Storage
504
+
505
+ All provider API keys are encrypted at rest using **AES-256-GCM** (authenticated encryption).
506
+ Keys are never stored as plaintext on disk.
507
+
508
+ | Layer | Detail |
509
+ |-------|--------|
510
+ | **Cipher** | AES-256-GCM (authenticated — detects tampering) |
511
+ | **Key derivation** | PBKDF2-HMAC-SHA256, 100,000 iterations |
512
+ | **Machine binding** | Key derived from username + hostname + home directory |
513
+ | **Salt** | Random 32-byte salt, generated once, stored in `~/.rubyn-code/.encryption_salt` |
514
+ | **File permissions** | `tokens.yml` and `.encryption_salt` are `0600` (owner read/write only) |
515
+
516
+ This means:
517
+ - Keys copied to another machine or user account cannot be decrypted
518
+ - The encryption key is never stored — it is derived at runtime
519
+ - Plaintext keys from older versions are automatically encrypted on first read
520
+
521
+ ### File Permissions
522
+
523
+ | File | Permissions | Contents |
524
+ |------|------------|----------|
525
+ | `~/.rubyn-code/` | `0700` | Home directory |
526
+ | `~/.rubyn-code/tokens.yml` | `0600` | Encrypted API keys, OAuth tokens |
527
+ | `~/.rubyn-code/.encryption_salt` | `0600` | PBKDF2 salt (not secret alone, but protected) |
528
+ | `~/.rubyn-code/config.yml` | `0600` | Provider config (no secrets) |
529
+
384
530
  ## Development
385
531
 
386
532
  Requires Ruby 4.0+.
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Expands the tasks CHECK constraint on status to include 'failed',
4
+ # used by the GOLEM daemon to mark tasks that have exceeded max retries.
5
+ #
6
+ # SQLite does not support ALTER CONSTRAINT, so we rebuild the table.
7
+ # The Migrator already wraps .up in a transaction — no manual BEGIN/COMMIT here.
8
+ module Migration013AddFailedStatusToTasks
9
+ module_function
10
+
11
+ def up(db)
12
+ create_new_tasks_table(db)
13
+ migrate_data(db)
14
+ swap_tables(db)
15
+ end
16
+
17
+ def create_new_tasks_table(db)
18
+ db.execute(<<~SQL)
19
+ CREATE TABLE tasks_new (
20
+ id TEXT PRIMARY KEY,
21
+ session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL,
22
+ title TEXT NOT NULL,
23
+ description TEXT,
24
+ status TEXT NOT NULL DEFAULT 'pending'
25
+ CHECK(status IN ('pending','in_progress','blocked','completed','cancelled','failed')),
26
+ priority INTEGER NOT NULL DEFAULT 0,
27
+ owner TEXT,
28
+ result TEXT,
29
+ metadata TEXT DEFAULT '{}',
30
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
31
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
32
+ )
33
+ SQL
34
+ end
35
+
36
+ def migrate_data(db)
37
+ db.execute(<<~SQL)
38
+ INSERT INTO tasks_new (id, session_id, title, description, status, priority, owner, result, metadata, created_at, updated_at)
39
+ SELECT id, session_id, title, description, status, priority, owner, result, metadata, created_at, updated_at
40
+ FROM tasks
41
+ SQL
42
+ end
43
+
44
+ def swap_tables(db)
45
+ db.execute('DROP TABLE tasks')
46
+ db.execute('ALTER TABLE tasks_new RENAME TO tasks')
47
+ db.execute('CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_id)')
48
+ db.execute('CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)')
49
+ db.execute('CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner)')
50
+ end
51
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Agent
5
+ # Manages background job polling, waiting, and notification draining
6
+ # for the agent loop.
7
+ module BackgroundJobHandler
8
+ private
9
+
10
+ def wait_for_background_jobs
11
+ max_wait = 300 # 5 minutes max
12
+ poll_interval = 3
13
+
14
+ RubynCode::Debug.agent(
15
+ 'Waiting for background jobs to finish ' \
16
+ "(polling every #{poll_interval}s, max #{max_wait}s)"
17
+ )
18
+
19
+ elapsed = poll_until_done(max_wait, poll_interval)
20
+ drain_background_notifications
21
+ RubynCode::Debug.agent("Background wait done (#{elapsed}s)")
22
+ end
23
+
24
+ def poll_until_done(max_wait, poll_interval)
25
+ elapsed = 0
26
+ while elapsed < max_wait && pending_background_jobs?
27
+ sleep poll_interval
28
+ elapsed += poll_interval
29
+ drain_background_notifications
30
+ end
31
+ elapsed
32
+ end
33
+
34
+ def drain_background_notifications
35
+ return unless @background_manager
36
+
37
+ notifications = @background_manager.drain_notifications
38
+ return if notifications.nil? || notifications.empty?
39
+
40
+ summary = notifications.map { |n| format_background_notification(n) }.join("\n\n")
41
+ @conversation.add_user_message("[Background job results]\n#{summary}")
42
+ rescue NoMethodError
43
+ # background_manager does not support drain_notifications yet
44
+ end
45
+
46
+ def pending_background_jobs?
47
+ return false unless @background_manager
48
+
49
+ @background_manager.active_count.positive?
50
+ rescue NoMethodError
51
+ false
52
+ end
53
+
54
+ def format_background_notification(notification)
55
+ return notification.to_s unless notification.is_a?(Hash)
56
+
57
+ status = notification[:status] || 'unknown'
58
+ job_id = notification[:job_id]&.[](0..7) || 'unknown'
59
+ duration = format_duration(notification[:duration])
60
+ result = notification[:result] || '(no output)'
61
+ "Job #{job_id} [#{status}] (#{duration}):\n#{result}"
62
+ end
63
+
64
+ def format_duration(dur)
65
+ return 'unknown' unless dur
66
+
67
+ format('%.1fs', dur)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -126,66 +126,89 @@ module RubynCode
126
126
 
127
127
  # Ensure every tool_use block has a matching tool_result.
128
128
  # If a tool_use is orphaned (e.g. from Ctrl-C interruption),
129
- # inject a synthetic tool_result so the API doesn't reject the request.
129
+ # inject a synthetic tool_result immediately after the assistant
130
+ # message that contains the orphaned tool_use.
130
131
  def repair_orphaned_tool_uses(formatted)
131
- # Collect all tool_use IDs from assistant messages
132
- tool_use_ids = Set.new
133
- formatted.each do |msg|
132
+ orphaned = collect_tool_use_ids(formatted) - collect_tool_result_ids(formatted)
133
+ return formatted if orphaned.empty?
134
+
135
+ insert_orphan_results(formatted, orphaned)
136
+ end
137
+
138
+ # Walk backwards to find the assistant message containing each orphaned
139
+ # tool_use and insert a user/tool_result message right after it.
140
+ # -- walks messages to find insertion point
141
+ def insert_orphan_results(formatted, orphaned)
142
+ orphan_set = orphaned.to_a.to_set
143
+ insert_idx = find_orphan_insert_index(formatted, orphan_set)
144
+
145
+ results = orphaned.map do |id|
146
+ { type: 'tool_result', tool_use_id: id, content: '[interrupted]', is_error: true }
147
+ end
148
+
149
+ formatted.insert(insert_idx, { role: 'user', content: results })
150
+ formatted
151
+ end
152
+
153
+ # Find the index right after the last assistant message that contains
154
+ # any of the orphaned tool_use IDs.
155
+ def find_orphan_insert_index(formatted, orphan_set)
156
+ formatted.each_with_index.reverse_each do |msg, idx|
134
157
  next unless msg[:role] == 'assistant' && msg[:content].is_a?(Array)
158
+ return idx + 1 if assistant_has_orphan?(msg, orphan_set)
159
+ end
135
160
 
136
- msg[:content].each do |block|
137
- if block.is_a?(Hash) && (block[:type] == 'tool_use' || block['type'] == 'tool_use')
138
- tool_use_ids << (block[:id] || block['id'])
139
- end
140
- end
161
+ formatted.length # fallback: append at end
162
+ end
163
+
164
+ def assistant_has_orphan?(msg, orphan_set)
165
+ msg[:content].any? do |block|
166
+ block.is_a?(Hash) && block_matches_type?(block, 'tool_use') &&
167
+ orphan_set.include?(block[:id] || block['id'])
141
168
  end
169
+ end
170
+
171
+ def collect_tool_use_ids(formatted)
172
+ collect_block_ids(formatted, role: 'assistant', type: 'tool_use', id_key: :id, id_str_key: 'id')
173
+ end
174
+
175
+ def collect_tool_result_ids(formatted)
176
+ collect_block_ids(formatted, role: 'user', type: 'tool_result', id_key: :tool_use_id,
177
+ id_str_key: 'tool_use_id')
178
+ end
142
179
 
143
- # Collect all tool_result IDs from user messages
144
- tool_result_ids = Set.new
180
+ def collect_block_ids(formatted, role:, type:, id_key:, id_str_key:) # rubocop:disable Metrics/CyclomaticComplexity -- iterates blocks with type+role guards
181
+ ids = Set.new
145
182
  formatted.each do |msg|
146
- next unless msg[:role] == 'user' && msg[:content].is_a?(Array)
183
+ next unless msg[:role] == role && msg[:content].is_a?(Array)
147
184
 
148
185
  msg[:content].each do |block|
149
- if block.is_a?(Hash) && (block[:type] == 'tool_result' || block['type'] == 'tool_result')
150
- tool_result_ids << (block[:tool_use_id] || block['tool_use_id'])
151
- end
152
- end
153
- end
154
-
155
- # Find orphans
156
- orphaned = tool_use_ids - tool_result_ids
157
- return formatted if orphaned.empty?
186
+ next unless block.is_a?(Hash) && block_matches_type?(block, type)
158
187
 
159
- # Inject synthetic tool_results for orphans
160
- orphan_results = orphaned.map do |id|
161
- { type: 'tool_result', tool_use_id: id, content: '[interrupted]', is_error: true }
188
+ ids << (block[id_key] || block[id_str_key])
189
+ end
162
190
  end
191
+ ids
192
+ end
163
193
 
164
- # Append as a user message after the last assistant message
165
- formatted << { role: 'user', content: orphan_results }
166
- formatted
194
+ def block_matches_type?(block, type)
195
+ block[:type] == type || block['type'] == type
167
196
  end
168
197
 
169
198
  # Normalize content and tool_calls into a single array of content blocks.
170
199
  def normalize_content(content, tool_calls)
171
- blocks = []
200
+ blocks = content_to_blocks(content)
201
+ tool_calls.each { |tc| blocks << block_to_hash(tc) }
202
+ blocks
203
+ end
172
204
 
205
+ def content_to_blocks(content)
173
206
  case content
174
- when Array
175
- content.each { |b| blocks << block_to_hash(b) }
176
- when String
177
- blocks << { type: 'text', text: content } unless content.empty?
178
- when Hash
179
- blocks << content
180
- else
181
- blocks << block_to_hash(content) if content.respond_to?(:type)
182
- end
183
-
184
- tool_calls.each do |tc|
185
- blocks << block_to_hash(tc)
207
+ when Array then content.map { |b| block_to_hash(b) }
208
+ when String then content.empty? ? [] : [{ type: 'text', text: content }]
209
+ when Hash then [content]
210
+ else content.respond_to?(:type) ? [block_to_hash(content)] : []
186
211
  end
187
-
188
- blocks
189
212
  end
190
213
 
191
214
  # Format message content for the API. Converts Data objects to hashes.
@@ -200,25 +223,30 @@ module RubynCode
200
223
 
201
224
  def block_to_hash(block)
202
225
  return block if block.is_a?(Hash)
226
+ return block unless block.respond_to?(:type)
203
227
 
204
- if block.respond_to?(:type)
205
- case block.type.to_s
206
- when 'text'
207
- { type: 'text', text: block.text }
208
- when 'tool_use'
209
- { type: 'tool_use', id: block.id, name: block.name, input: block.input }
210
- when 'tool_result'
211
- h = { type: 'tool_result', tool_use_id: block.tool_use_id, content: block.content.to_s }
212
- h[:is_error] = true if block.respond_to?(:is_error) && block.is_error
213
- h
214
- else
215
- block.respond_to?(:to_h) ? block.to_h : block
216
- end
228
+ typed_block_to_hash(block)
229
+ end
230
+
231
+ def typed_block_to_hash(block)
232
+ case block.type.to_s
233
+ when 'text'
234
+ { type: 'text', text: block.text }
235
+ when 'tool_use'
236
+ { type: 'tool_use', id: block.id, name: block.name, input: block.input }
237
+ when 'tool_result'
238
+ tool_result_block_to_hash(block)
217
239
  else
218
- block
240
+ block.respond_to?(:to_h) ? block.to_h : block
219
241
  end
220
242
  end
221
243
 
244
+ def tool_result_block_to_hash(block)
245
+ h = { type: 'tool_result', tool_use_id: block.tool_use_id, content: block.content.to_s }
246
+ h[:is_error] = true if block.respond_to?(:is_error) && block.is_error
247
+ h
248
+ end
249
+
222
250
  # Extract text from content blocks.
223
251
  def extract_text(content)
224
252
  case content