rubyn-code 0.5.1 → 0.7.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.
- checksums.yaml +4 -4
- data/README.md +120 -3
- data/db/migrations/014_multi_agent_upgrade.rb +79 -0
- data/lib/rubyn_code/agent/conversation.rb +89 -3
- data/lib/rubyn_code/agent/llm_caller.rb +2 -2
- data/lib/rubyn_code/agent/loop.rb +49 -9
- data/lib/rubyn_code/agent/system_prompt_builder.rb +37 -2
- data/lib/rubyn_code/agent/tool_processor.rb +3 -1
- data/lib/rubyn_code/auth/oauth.rb +1 -1
- data/lib/rubyn_code/auth/token_store.rb +49 -4
- data/lib/rubyn_code/checkpoint/hook.rb +26 -0
- data/lib/rubyn_code/checkpoint/manager.rb +109 -0
- data/lib/rubyn_code/chisel/debt.rb +65 -0
- data/lib/rubyn_code/chisel/inspection.rb +93 -0
- data/lib/rubyn_code/chisel.rb +127 -0
- data/lib/rubyn_code/cli/commands/agents.rb +31 -0
- data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
- data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
- data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
- data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
- data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
- data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
- data/lib/rubyn_code/cli/commands/context.rb +3 -1
- data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
- data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
- data/lib/rubyn_code/cli/commands/goal.rb +87 -0
- data/lib/rubyn_code/cli/commands/learning.rb +62 -0
- data/lib/rubyn_code/cli/commands/loop.rb +58 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
- data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
- data/lib/rubyn_code/cli/commands/registry.rb +14 -9
- data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
- data/lib/rubyn_code/cli/first_run.rb +1 -1
- data/lib/rubyn_code/cli/loop_runner.rb +98 -0
- data/lib/rubyn_code/cli/mention_expander.rb +92 -0
- data/lib/rubyn_code/cli/renderer.rb +3 -2
- data/lib/rubyn_code/cli/repl.rb +37 -14
- data/lib/rubyn_code/cli/repl_commands.rb +76 -2
- data/lib/rubyn_code/cli/repl_setup.rb +9 -1
- data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
- data/lib/rubyn_code/cli/version_check.rb +10 -3
- data/lib/rubyn_code/config/defaults.rb +13 -1
- data/lib/rubyn_code/config/schema.json +4 -0
- data/lib/rubyn_code/config/settings.rb +17 -2
- data/lib/rubyn_code/context/manager.rb +29 -12
- data/lib/rubyn_code/debug.rb +11 -5
- data/lib/rubyn_code/goal/evaluator.rb +95 -0
- data/lib/rubyn_code/hooks/event_map.rb +56 -0
- data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
- data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
- data/lib/rubyn_code/hooks/response.rb +83 -0
- data/lib/rubyn_code/hooks/runner.rb +61 -3
- data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
- data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
- data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
- data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
- data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
- data/lib/rubyn_code/index/codebase_index.rb +39 -1
- data/lib/rubyn_code/learning/porter.rb +129 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
- data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
- data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
- data/lib/rubyn_code/llm/model_router.rb +2 -2
- data/lib/rubyn_code/mcp/client.rb +59 -0
- data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
- data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
- data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
- data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
- data/lib/rubyn_code/memory/search.rb +9 -5
- data/lib/rubyn_code/memory/session_persistence.rb +159 -21
- data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
- data/lib/rubyn_code/output/diff_renderer.rb +62 -7
- data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
- data/lib/rubyn_code/skills/registry_client.rb +4 -3
- data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
- data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
- data/lib/rubyn_code/teams/agent_registry.rb +120 -0
- data/lib/rubyn_code/teams/mailbox.rb +99 -10
- data/lib/rubyn_code/teams/manager.rb +83 -5
- data/lib/rubyn_code/teams/teammate.rb +5 -1
- data/lib/rubyn_code/tools/ask_user.rb +15 -1
- data/lib/rubyn_code/tools/executor.rb +5 -3
- data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
- data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
- data/lib/rubyn_code/tools/web_fetch.rb +1 -1
- data/lib/rubyn_code/tools/web_search.rb +4 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +45 -2
- data/skills/rubyn_self_test.md +322 -14
- data/skills/self_test/chisel_smoke.rb +84 -0
- data/skills/self_test/fixtures/chisel_sample.rb +64 -0
- metadata +37 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f5cbf6f4737790408893a904d64412c2dd695e81a2cd2e5960b4da3196e48f4f
|
|
4
|
+
data.tar.gz: f0e9b80f10c77d6eb6c357db7518851344670653546d5eca2784d017d490608c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bc4553f04fbac43903e1515cec43cf595245d36223bca70342ddafb2a2920f38f502b356eb353b4b6e5284d128688e07c015e0a3ebb4aa06f7029fac0ba42a37
|
|
7
|
+
data.tar.gz: b9f8ab09a40e073bff1657ef9d829b7551a5ca56ce9f77b65bd59b835d079665d3663faf92ccf44fd84d4ff3674f835c09d102b4f449c175c77bcafb466dd934
|
data/README.md
CHANGED
|
@@ -138,6 +138,8 @@ rubyn-code -p "Refactor app/controllers/orders_controller.rb into service object
|
|
|
138
138
|
rubyn-code --ide
|
|
139
139
|
```
|
|
140
140
|
|
|
141
|
+
**Tip:** Reference files inline with `@` — `rubyn > explain @lib/foo.rb and @config/routes.rb`. Rubyn inlines their contents so you don't have to wait for it to read them.
|
|
142
|
+
|
|
141
143
|
## What Can Rubyn Do?
|
|
142
144
|
|
|
143
145
|
### Refactor code
|
|
@@ -227,7 +229,7 @@ The extension communicates over 19 RPC methods: `initialize`, `prompt`, `cancel`
|
|
|
227
229
|
|
|
228
230
|
## MCP — External Tool Servers
|
|
229
231
|
|
|
230
|
-
Connect external tool servers via the [Model Context Protocol](https://modelcontextprotocol.io). MCP tools are dynamically discovered and registered as native Rubyn tools, available in the REPL, IDE, and daemon.
|
|
232
|
+
Connect external tool servers via the [Model Context Protocol](https://modelcontextprotocol.io). MCP tools are dynamically discovered and registered as native Rubyn tools, available in the REPL, IDE, and daemon. Servers that expose **resources** and **prompts** are also bridged — Rubyn registers per-server `read_resource` and `get_prompt` tools, and `/mcp` reports the counts.
|
|
231
233
|
|
|
232
234
|
### Configuration
|
|
233
235
|
|
|
@@ -349,7 +351,7 @@ Rubyn automatically loads relevant context based on what you're working on:
|
|
|
349
351
|
- **Controllers** → includes models, routes, request specs, services
|
|
350
352
|
- **Models** → includes schema, associations, specs, factories
|
|
351
353
|
- **Service objects** → includes referenced models and their specs
|
|
352
|
-
- **Any file** → checks for `RUBYN.md`, `CLAUDE.md`, or `AGENT.md` instructions
|
|
354
|
+
- **Any file** → checks for `RUBYN.md`, `CLAUDE.md`, `AGENTS.md`, or `AGENT.md` instructions
|
|
353
355
|
|
|
354
356
|
The [codebase index](#codebase-indexing) enhances this with structural awareness — Rubyn knows which files depend on each other before it reads them.
|
|
355
357
|
|
|
@@ -367,7 +369,7 @@ Drop a `RUBYN.md` in your project root and Rubyn follows your conventions:
|
|
|
367
369
|
- Run rubocop before committing
|
|
368
370
|
```
|
|
369
371
|
|
|
370
|
-
Also reads `CLAUDE.md` and `AGENT.md` — no migration needed from other tools.
|
|
372
|
+
Also reads `CLAUDE.md`, `AGENTS.md`, and `AGENT.md` — no migration needed from other tools.
|
|
371
373
|
|
|
372
374
|
| Location | Scope |
|
|
373
375
|
|----------|-------|
|
|
@@ -390,6 +392,65 @@ Focus areas: `all`, `security`, `performance`, `style`, `testing`
|
|
|
390
392
|
|
|
391
393
|
Severity ratings: **[critical]** **[warning]** **[suggestion]** **[nitpick]**
|
|
392
394
|
|
|
395
|
+
## Chisel — Write the Minimum That Works
|
|
396
|
+
|
|
397
|
+
Chisel is an opt-in mode that makes Rubyn think like the laziest senior dev in
|
|
398
|
+
the room: the best code is the code you never wrote. It's **off by default** and
|
|
399
|
+
only changes the agent's behavior once you turn it on.
|
|
400
|
+
|
|
401
|
+
```
|
|
402
|
+
rubyn > /chisel # show current intensity
|
|
403
|
+
rubyn > /chisel full # turn it on
|
|
404
|
+
rubyn > /chisel off # back to normal
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Intensities: `off` (default) · `lite` · `full` · `ultra`. When on, Rubyn walks a
|
|
408
|
+
decision ladder before writing code — does this need to exist? does stdlib or an
|
|
409
|
+
installed gem already do it? is it one line? — and only then writes the smallest
|
|
410
|
+
change that solves the task. The safety floor (validation, error/data-loss
|
|
411
|
+
handling, security, accessibility) is never on the chopping block.
|
|
412
|
+
|
|
413
|
+
Set it permanently with `chisel_mode: full` in `~/.rubyn-code/config.yml`, or
|
|
414
|
+
per-shell with `RUBYN_CHISEL_MODE=full`.
|
|
415
|
+
|
|
416
|
+
**On-demand audits** (work whether or not the always-on mode is enabled):
|
|
417
|
+
|
|
418
|
+
```
|
|
419
|
+
rubyn > /chisel-review # over-engineering in your diff vs main
|
|
420
|
+
rubyn > /chisel-review develop # ...vs a different base
|
|
421
|
+
rubyn > /chisel-audit # sweep the whole repo
|
|
422
|
+
rubyn > /chisel-audit app/services # ...scoped to a path
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
Both return a ranked deletion/simplification list — each item with a location, the
|
|
426
|
+
ladder rung it skipped, and the concrete simpler form — and stay read-only (they
|
|
427
|
+
report, they don't edit).
|
|
428
|
+
|
|
429
|
+
**Debt ledger & status.** Leave a `# chisel: …` comment when you consciously defer a
|
|
430
|
+
simplification, then collect them later:
|
|
431
|
+
|
|
432
|
+
```
|
|
433
|
+
rubyn > /chisel-debt # list every `# chisel:` deferral, with file:line and note
|
|
434
|
+
rubyn > /chisel-gain # current mode, outstanding debt count, and reference impact
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
**Verifying Chisel.** A standalone smoke test runs the whole layer against a
|
|
438
|
+
committed, deliberately over-engineered fixture and gives the same result every
|
|
439
|
+
time — no LLM, no network:
|
|
440
|
+
|
|
441
|
+
```
|
|
442
|
+
$ bundle exec ruby skills/self_test/chisel_smoke.rb
|
|
443
|
+
CHISEL debt: PASS # scans the fixture → its exact planted `# chisel:` markers
|
|
444
|
+
CHISEL engine: PASS # off injects nothing; lite/full/ultra keep the safety floor
|
|
445
|
+
CHISEL inspection: PASS # review/audit prompts assemble; bad scope raises
|
|
446
|
+
CHISEL commands: PASS # all five slash commands register
|
|
447
|
+
CHISEL: PASS
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
The fixture lives at `skills/self_test/fixtures/chisel_sample.rb`, the same check is
|
|
451
|
+
Section 18 of the `/skill self-test` scorecard, and `spec/rubyn_code/chisel/self_test_fixture_spec.rb`
|
|
452
|
+
guards the fixture's exact scan output so it can't silently drift.
|
|
453
|
+
|
|
393
454
|
## Megaplan — Phased Planning
|
|
394
455
|
|
|
395
456
|
For work too big for a single PR — rewrites, migrations, multi-feature initiatives — Rubyn ships a planning workflow that breaks the feature into vertical-slice phases before any code gets written.
|
|
@@ -476,6 +537,19 @@ Rubyn gets smarter with every session:
|
|
|
476
537
|
3. **On next startup** — injects top instincts into the system prompt
|
|
477
538
|
4. **Over time** — reinforced instincts strengthen, unused ones decay and get pruned
|
|
478
539
|
|
|
540
|
+
### Take your learnings with you
|
|
541
|
+
|
|
542
|
+
Instincts live in `~/.rubyn-code`. Move them to another machine with `/learning`:
|
|
543
|
+
|
|
544
|
+
```
|
|
545
|
+
rubyn > /learning # how many instincts you've accumulated
|
|
546
|
+
rubyn > /learning export learnings.json # write them to a portable file
|
|
547
|
+
# ...on the new machine...
|
|
548
|
+
rubyn > /learning import learnings.json --here # load them (--here = apply to this project)
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
Import regenerates ids and skips duplicates, so it's safe to run repeatedly.
|
|
552
|
+
|
|
479
553
|
## Streaming Output
|
|
480
554
|
|
|
481
555
|
Real-time streaming with live syntax highlighting via Rouge/Monokai. Code blocks are buffered and highlighted when complete. No waiting for full responses.
|
|
@@ -537,6 +611,8 @@ rubyn-code daemon [OPTIONS] # Run GOLEM autonomous daemon
|
|
|
537
611
|
| `/new` | Save session and start a fresh conversation |
|
|
538
612
|
| `/review [base]` | PR review against best practices |
|
|
539
613
|
| `/spawn name role` | Spawn a persistent teammate |
|
|
614
|
+
| `/goal <condition>` | Set a goal Rubyn works toward until met (`/goal clear` to cancel) |
|
|
615
|
+
| `/loop [xN] [interval] <prompt-or-/cmd>` | Repeat a prompt or command on an interval (Ctrl-C to stop) |
|
|
540
616
|
| `/compact` | Compress conversation context |
|
|
541
617
|
| `/cost` | Show token usage and costs |
|
|
542
618
|
| `/tasks` | List all tasks |
|
|
@@ -547,6 +623,47 @@ rubyn-code daemon [OPTIONS] # Run GOLEM autonomous daemon
|
|
|
547
623
|
| `/model` | Show/switch model and provider |
|
|
548
624
|
| `/doctor` | Run environment health checks |
|
|
549
625
|
| `/mcp` | MCP server documentation and status |
|
|
626
|
+
| `/agents` | List sub-agent types (built-in + custom) |
|
|
627
|
+
| `/rewind [id] [code\|chat]` | Rewind code and/or conversation to a checkpoint |
|
|
628
|
+
|
|
629
|
+
### Custom Sub-Agents
|
|
630
|
+
|
|
631
|
+
Beyond the built-in `explore` (read-only) and `worker` (read/write) sub-agents, define your own in `.rubyn-code/agents/<name>.md` (project) or `~/.rubyn-code/agents/<name>.md` (global). `spawn_agent` can then target them by name, and `/agents` lists them.
|
|
632
|
+
|
|
633
|
+
```markdown
|
|
634
|
+
---
|
|
635
|
+
description: Reviews a diff for bugs
|
|
636
|
+
tools: read_file, grep, glob, bash # optional — omit for the access default
|
|
637
|
+
access: read # read | write (default: write)
|
|
638
|
+
---
|
|
639
|
+
You are a meticulous code reviewer. Find correctness bugs only.
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
### Custom Slash Commands
|
|
643
|
+
|
|
644
|
+
Drop a markdown file in `.rubyn-code/commands/` (project) or `~/.rubyn-code/commands/` (global) and it becomes a slash command — `deploy.md` → `/deploy`. Project commands override global ones; built-ins always win.
|
|
645
|
+
|
|
646
|
+
```markdown
|
|
647
|
+
---
|
|
648
|
+
description: Open a PR for the current branch
|
|
649
|
+
---
|
|
650
|
+
Open a pull request for the current branch.
|
|
651
|
+
Title: $ARGUMENTS
|
|
652
|
+
Current diff:
|
|
653
|
+
!`git diff main --stat`
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
Templating in the body:
|
|
657
|
+
|
|
658
|
+
| Token | Expands to |
|
|
659
|
+
|-------|-----------|
|
|
660
|
+
| `$ARGUMENTS` | everything typed after the command |
|
|
661
|
+
| `$1` … `$9` | individual positional arguments |
|
|
662
|
+
| `` !`shell cmd` `` | the command's output, inlined |
|
|
663
|
+
|
|
664
|
+
### Checkpoints & Rewind
|
|
665
|
+
|
|
666
|
+
Rubyn snapshots a checkpoint at the start of every turn — capturing the conversation and the original contents of any files it changes that turn. `/rewind` lists them; `/rewind <id>` rolls back both code and conversation (or just one with `code`/`chat`). Note: rewind restores files edited via Rubyn's `write_file`/`edit_file`; it does not touch your git history.
|
|
550
667
|
|
|
551
668
|
## Authentication
|
|
552
669
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Multi-agent upgrade: adds parent-child tracking to teammates and
|
|
4
|
+
# structured messaging to mailbox_messages.
|
|
5
|
+
#
|
|
6
|
+
# Teammates: adds parent_agent_id column, fixes status CHECK
|
|
7
|
+
# (DB had 'busy' but Ruby code uses 'active').
|
|
8
|
+
# Mailbox: adds correlation_id and data columns for structured messaging.
|
|
9
|
+
#
|
|
10
|
+
# Uses table-rebuild pattern because SQLite cannot ALTER CHECK constraints
|
|
11
|
+
# or ADD COLUMN with constraints reliably.
|
|
12
|
+
module Migration014MultiAgentUpgrade
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def up(db)
|
|
16
|
+
upgrade_teammates(db)
|
|
17
|
+
upgrade_mailbox(db)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def upgrade_teammates(db)
|
|
21
|
+
db.execute(<<~SQL)
|
|
22
|
+
CREATE TABLE teammates_new (
|
|
23
|
+
id TEXT PRIMARY KEY,
|
|
24
|
+
name TEXT NOT NULL UNIQUE,
|
|
25
|
+
role TEXT NOT NULL,
|
|
26
|
+
persona TEXT,
|
|
27
|
+
model TEXT NOT NULL DEFAULT 'claude-sonnet-4-20250514',
|
|
28
|
+
status TEXT NOT NULL DEFAULT 'idle' CHECK(status IN ('idle','active','offline')),
|
|
29
|
+
parent_agent_id TEXT,
|
|
30
|
+
metadata TEXT DEFAULT '{}',
|
|
31
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
32
|
+
)
|
|
33
|
+
SQL
|
|
34
|
+
|
|
35
|
+
db.execute(<<~SQL)
|
|
36
|
+
INSERT INTO teammates_new (id, name, role, persona, model, status, metadata, created_at)
|
|
37
|
+
SELECT id, name, role, persona, model,
|
|
38
|
+
CASE WHEN status = 'busy' THEN 'active' ELSE status END,
|
|
39
|
+
metadata, created_at
|
|
40
|
+
FROM teammates
|
|
41
|
+
SQL
|
|
42
|
+
|
|
43
|
+
db.execute('DROP TABLE teammates')
|
|
44
|
+
db.execute('ALTER TABLE teammates_new RENAME TO teammates')
|
|
45
|
+
db.execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_teammates_name ON teammates(name)')
|
|
46
|
+
db.execute('CREATE INDEX IF NOT EXISTS idx_teammates_status ON teammates(status)')
|
|
47
|
+
db.execute('CREATE INDEX IF NOT EXISTS idx_teammates_parent ON teammates(parent_agent_id)')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def upgrade_mailbox(db)
|
|
51
|
+
db.execute(<<~SQL)
|
|
52
|
+
CREATE TABLE mailbox_messages_new (
|
|
53
|
+
id TEXT PRIMARY KEY,
|
|
54
|
+
sender TEXT NOT NULL,
|
|
55
|
+
recipient TEXT NOT NULL,
|
|
56
|
+
message_type TEXT NOT NULL DEFAULT 'message'
|
|
57
|
+
CHECK(message_type IN ('message','task','result','error','broadcast','shutdown_request','shutdown_response','status_change')),
|
|
58
|
+
payload TEXT NOT NULL,
|
|
59
|
+
correlation_id TEXT,
|
|
60
|
+
data TEXT,
|
|
61
|
+
read INTEGER NOT NULL DEFAULT 0,
|
|
62
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
63
|
+
)
|
|
64
|
+
SQL
|
|
65
|
+
|
|
66
|
+
db.execute(<<~SQL)
|
|
67
|
+
INSERT INTO mailbox_messages_new (id, sender, recipient, message_type, payload, read, created_at)
|
|
68
|
+
SELECT id, sender, recipient, message_type, payload, read, created_at
|
|
69
|
+
FROM mailbox_messages
|
|
70
|
+
SQL
|
|
71
|
+
|
|
72
|
+
db.execute('DROP TABLE mailbox_messages')
|
|
73
|
+
db.execute('ALTER TABLE mailbox_messages_new RENAME TO mailbox_messages')
|
|
74
|
+
db.execute('CREATE INDEX IF NOT EXISTS idx_mailbox_recipient_read ON mailbox_messages(recipient, read)')
|
|
75
|
+
db.execute('CREATE INDEX IF NOT EXISTS idx_mailbox_sender ON mailbox_messages(sender)')
|
|
76
|
+
db.execute('CREATE INDEX IF NOT EXISTS idx_mailbox_created ON mailbox_messages(created_at)')
|
|
77
|
+
db.execute('CREATE INDEX IF NOT EXISTS idx_mailbox_correlation ON mailbox_messages(correlation_id)')
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
3
5
|
module RubynCode
|
|
4
6
|
module Agent
|
|
5
|
-
class Conversation
|
|
7
|
+
class Conversation # rubocop:disable Metrics/ClassLength -- message log + incremental token/tool-ID bookkeeping
|
|
6
8
|
attr_reader :messages
|
|
7
9
|
|
|
8
10
|
def initialize
|
|
9
11
|
@messages = []
|
|
12
|
+
reset_derived_state!
|
|
10
13
|
end
|
|
11
14
|
|
|
12
15
|
# Append a user turn to the conversation.
|
|
@@ -16,6 +19,7 @@ module RubynCode
|
|
|
16
19
|
def add_user_message(content)
|
|
17
20
|
message = { role: 'user', content: content }
|
|
18
21
|
@messages << message
|
|
22
|
+
track_added_message(message)
|
|
19
23
|
message
|
|
20
24
|
end
|
|
21
25
|
|
|
@@ -28,6 +32,7 @@ module RubynCode
|
|
|
28
32
|
blocks = normalize_content(content, tool_calls)
|
|
29
33
|
message = { role: 'assistant', content: blocks }
|
|
30
34
|
@messages << message
|
|
35
|
+
track_added_message(message)
|
|
31
36
|
message
|
|
32
37
|
end
|
|
33
38
|
|
|
@@ -52,8 +57,11 @@ module RubynCode
|
|
|
52
57
|
# tool results for the same assistant turn are batched together.
|
|
53
58
|
if @messages.last && @messages.last[:role] == 'user' && tool_result_message?(@messages.last)
|
|
54
59
|
@messages.last[:content] << result_block
|
|
60
|
+
track_appended_block(result_block)
|
|
55
61
|
else
|
|
56
|
-
|
|
62
|
+
message = { role: 'user', content: [result_block] }
|
|
63
|
+
@messages << message
|
|
64
|
+
track_added_message(message)
|
|
57
65
|
end
|
|
58
66
|
|
|
59
67
|
result_block
|
|
@@ -79,6 +87,29 @@ module RubynCode
|
|
|
79
87
|
# @return [void]
|
|
80
88
|
def clear!
|
|
81
89
|
@messages.clear
|
|
90
|
+
reset_derived_state!
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Character length of the JSON-serialized messages array, maintained
|
|
94
|
+
# incrementally on append so per-turn token estimation doesn't have to
|
|
95
|
+
# re-serialize the whole history. Matches JSON.generate(messages).length.
|
|
96
|
+
#
|
|
97
|
+
# @return [Integer]
|
|
98
|
+
def estimated_json_chars
|
|
99
|
+
@json_chars ||= @messages.sum { |msg| JSON.generate(msg).length }
|
|
100
|
+
return 2 if @messages.empty?
|
|
101
|
+
|
|
102
|
+
# "[" + per-message JSON joined by "," + "]"
|
|
103
|
+
@json_chars + @messages.length + 1
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Drops cached serialization/tool-ID bookkeeping. Must be called after
|
|
107
|
+
# messages are mutated in place from outside this class (e.g. by
|
|
108
|
+
# Context::MicroCompact); the caches rebuild lazily on next access.
|
|
109
|
+
#
|
|
110
|
+
# @return [void]
|
|
111
|
+
def refresh_derived_state!
|
|
112
|
+
reset_derived_state!
|
|
82
113
|
end
|
|
83
114
|
|
|
84
115
|
# Return the messages array formatted for the Claude API.
|
|
@@ -115,21 +146,76 @@ module RubynCode
|
|
|
115
146
|
@messages.pop
|
|
116
147
|
removed += 1
|
|
117
148
|
end
|
|
149
|
+
reset_derived_state!
|
|
118
150
|
end
|
|
119
151
|
|
|
120
152
|
# Replace messages with a new array (used after compaction).
|
|
121
153
|
def replace!(new_messages)
|
|
122
154
|
@messages.replace(new_messages)
|
|
155
|
+
reset_derived_state!
|
|
123
156
|
end
|
|
124
157
|
|
|
125
158
|
private
|
|
126
159
|
|
|
160
|
+
# Derived bookkeeping kept in sync with @messages so hot paths stay
|
|
161
|
+
# cheap: per-message JSON char total (token estimation) and
|
|
162
|
+
# tool_use/tool_result ID sets (orphan repair). nil means "rebuild
|
|
163
|
+
# lazily from @messages on next access".
|
|
164
|
+
def reset_derived_state!
|
|
165
|
+
@json_chars = nil
|
|
166
|
+
@tool_use_ids = nil
|
|
167
|
+
@tool_result_ids = nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def track_added_message(message)
|
|
171
|
+
register_tool_ids(message)
|
|
172
|
+
return if @json_chars.nil?
|
|
173
|
+
|
|
174
|
+
@json_chars += JSON.generate(message).length
|
|
175
|
+
rescue StandardError
|
|
176
|
+
@json_chars = nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# A tool_result block appended to an existing user message grows that
|
|
180
|
+
# message's JSON by the block plus one separator comma.
|
|
181
|
+
def track_appended_block(result_block)
|
|
182
|
+
@tool_result_ids << result_block[:tool_use_id] if @tool_result_ids
|
|
183
|
+
return if @json_chars.nil?
|
|
184
|
+
|
|
185
|
+
@json_chars += JSON.generate(result_block).length + 1
|
|
186
|
+
rescue StandardError
|
|
187
|
+
@json_chars = nil
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def register_tool_ids(message)
|
|
191
|
+
return unless @tool_use_ids && message[:content].is_a?(Array)
|
|
192
|
+
|
|
193
|
+
message[:content].each do |block|
|
|
194
|
+
next unless block.is_a?(Hash)
|
|
195
|
+
|
|
196
|
+
if message[:role] == 'assistant' && block_matches_type?(block, 'tool_use')
|
|
197
|
+
@tool_use_ids << (block[:id] || block['id'])
|
|
198
|
+
elsif message[:role] == 'user' && block_matches_type?(block, 'tool_result')
|
|
199
|
+
@tool_result_ids << (block[:tool_use_id] || block['tool_use_id'])
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def rebuild_tool_id_sets!
|
|
205
|
+
@tool_use_ids = collect_tool_use_ids(@messages)
|
|
206
|
+
@tool_result_ids = collect_tool_result_ids(@messages)
|
|
207
|
+
end
|
|
208
|
+
|
|
127
209
|
# Ensure every tool_use block has a matching tool_result.
|
|
128
210
|
# If a tool_use is orphaned (e.g. from Ctrl-C interruption),
|
|
129
211
|
# inject a synthetic tool_result immediately after the assistant
|
|
130
212
|
# message that contains the orphaned tool_use.
|
|
213
|
+
#
|
|
214
|
+
# The ID sets are tracked incrementally as messages are appended, so
|
|
215
|
+
# the common no-orphan case skips the full-history scans.
|
|
131
216
|
def repair_orphaned_tool_uses(formatted)
|
|
132
|
-
|
|
217
|
+
rebuild_tool_id_sets! if @tool_use_ids.nil?
|
|
218
|
+
orphaned = @tool_use_ids - @tool_result_ids
|
|
133
219
|
return formatted if orphaned.empty?
|
|
134
220
|
|
|
135
221
|
insert_orphan_results(formatted, orphaned)
|
|
@@ -114,7 +114,7 @@ module RubynCode
|
|
|
114
114
|
|
|
115
115
|
def run_compaction
|
|
116
116
|
before = @conversation.length
|
|
117
|
-
est = @context_manager.estimated_tokens(@conversation
|
|
117
|
+
est = @context_manager.estimated_tokens(@conversation)
|
|
118
118
|
RubynCode::Debug.token(
|
|
119
119
|
"context=#{est} tokens (~#{before} messages, " \
|
|
120
120
|
"threshold=#{Config::Defaults::CONTEXT_THRESHOLD_TOKENS})"
|
|
@@ -130,7 +130,7 @@ module RubynCode
|
|
|
130
130
|
after = @conversation.length
|
|
131
131
|
return unless after < before
|
|
132
132
|
|
|
133
|
-
new_est = @context_manager.estimated_tokens(@conversation
|
|
133
|
+
new_est = @context_manager.estimated_tokens(@conversation)
|
|
134
134
|
RubynCode::Debug.loop_tick(
|
|
135
135
|
"Compacted: #{before} -> #{after} messages " \
|
|
136
136
|
"(#{est} -> #{new_est} tokens)"
|
|
@@ -9,7 +9,7 @@ require_relative 'llm_caller'
|
|
|
9
9
|
|
|
10
10
|
module RubynCode
|
|
11
11
|
module Agent
|
|
12
|
-
class Loop
|
|
12
|
+
class Loop # rubocop:disable Metrics/ClassLength -- core agent loop: LLM calls, tool dispatch, recovery, hooks
|
|
13
13
|
include SystemPromptBuilder
|
|
14
14
|
include ResponseParser
|
|
15
15
|
include ToolProcessor
|
|
@@ -18,6 +18,7 @@ module RubynCode
|
|
|
18
18
|
include LlmCaller
|
|
19
19
|
|
|
20
20
|
MAX_ITERATIONS = Config::Defaults::MAX_ITERATIONS
|
|
21
|
+
GOAL_MAX_ITERATIONS = Config::Defaults::GOAL_MAX_ITERATIONS
|
|
21
22
|
|
|
22
23
|
# @param opts [Hash] keyword arguments for loop configuration
|
|
23
24
|
# @option opts [LLM::Client] :llm_client
|
|
@@ -39,6 +40,7 @@ module RubynCode
|
|
|
39
40
|
assign_dependencies(opts)
|
|
40
41
|
assign_callbacks(opts)
|
|
41
42
|
@plan_mode = false
|
|
43
|
+
@static_prompt_sections = nil
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
# @return [Boolean]
|
|
@@ -61,19 +63,36 @@ module RubynCode
|
|
|
61
63
|
@skill_ttl&.tick!
|
|
62
64
|
autoload_triggered_skills(user_input)
|
|
63
65
|
@conversation.add_user_message(user_input)
|
|
66
|
+
reset_system_prompt_cache!
|
|
64
67
|
reset_iteration_state
|
|
65
68
|
|
|
66
|
-
|
|
69
|
+
iteration = 0
|
|
70
|
+
loop do
|
|
67
71
|
result = run_iteration(iteration)
|
|
68
72
|
return result if result
|
|
73
|
+
|
|
74
|
+
iteration += 1
|
|
75
|
+
break unless keep_iterating?(iteration)
|
|
69
76
|
end
|
|
70
77
|
|
|
71
|
-
RubynCode::Debug.warn("Hit
|
|
72
|
-
max_iterations_warning
|
|
78
|
+
RubynCode::Debug.warn("Hit iteration limit (#{iteration})")
|
|
79
|
+
max_iterations_warning(iteration)
|
|
73
80
|
end
|
|
74
81
|
|
|
75
82
|
private
|
|
76
83
|
|
|
84
|
+
# Decide whether the loop should run another iteration after `iteration`
|
|
85
|
+
# turns. Normally capped at MAX_ITERATIONS, but while a Stop hook (e.g. an
|
|
86
|
+
# active /goal) is keeping the agent alive we extend up to a hard ceiling
|
|
87
|
+
# — a goal can need more tool turns than a single request. The GoalHook's
|
|
88
|
+
# own max-attempts valve terminates an unsatisfiable goal; the ceiling is
|
|
89
|
+
# only a runaway guard.
|
|
90
|
+
def keep_iterating?(iteration)
|
|
91
|
+
return true if iteration < MAX_ITERATIONS
|
|
92
|
+
|
|
93
|
+
@stop_block_active && iteration < GOAL_MAX_ITERATIONS
|
|
94
|
+
end
|
|
95
|
+
|
|
77
96
|
def assign_dependencies(opts)
|
|
78
97
|
assign_required_deps(opts)
|
|
79
98
|
assign_optional_deps(opts)
|
|
@@ -148,6 +167,7 @@ module RubynCode
|
|
|
148
167
|
@max_tokens_override = nil
|
|
149
168
|
@output_recovery_count = 0
|
|
150
169
|
@task_budget_remaining = nil
|
|
170
|
+
@stop_block_active = false # true while a Stop hook keeps us going
|
|
151
171
|
end
|
|
152
172
|
|
|
153
173
|
def run_iteration(iteration)
|
|
@@ -199,6 +219,13 @@ module RubynCode
|
|
|
199
219
|
|
|
200
220
|
@conversation.add_assistant_message(response_content(response))
|
|
201
221
|
|
|
222
|
+
# Stop hook: a hook may block stopping (e.g. an active /goal). When
|
|
223
|
+
# blocked, the reason is injected as user feedback and the loop keeps
|
|
224
|
+
# iterating instead of returning the final text. While blocked, the
|
|
225
|
+
# loop is allowed to run past MAX_ITERATIONS (see #keep_iterating?).
|
|
226
|
+
@stop_block_active = stop_blocked?(text)
|
|
227
|
+
return nil if @stop_block_active
|
|
228
|
+
|
|
202
229
|
# Decision-based compaction (topic switch, milestone)
|
|
203
230
|
@decision_compactor&.check!(@conversation)
|
|
204
231
|
|
|
@@ -208,6 +235,19 @@ module RubynCode
|
|
|
208
235
|
text
|
|
209
236
|
end
|
|
210
237
|
|
|
238
|
+
# Fires the :stop hook. If a hook blocks (returns { block: true }), the
|
|
239
|
+
# reason is appended as a user message so the next iteration acts on it.
|
|
240
|
+
#
|
|
241
|
+
# @return [Boolean] true if stopping was blocked (keep iterating)
|
|
242
|
+
def stop_blocked?(text)
|
|
243
|
+
decision = @hook_runner.fire(:stop, conversation: @conversation, response_text: text)
|
|
244
|
+
return false unless decision.is_a?(Hash) && decision[:block]
|
|
245
|
+
|
|
246
|
+
RubynCode::Debug.agent('Stop blocked by hook — continuing')
|
|
247
|
+
@conversation.add_user_message(decision[:reason])
|
|
248
|
+
true
|
|
249
|
+
end
|
|
250
|
+
|
|
211
251
|
# Empty LLM response (0 content blocks). Common after dispatching
|
|
212
252
|
# background_run — the LLM has nothing to say until results arrive.
|
|
213
253
|
# Wait briefly for jobs, then either continue or accept the empty response.
|
|
@@ -243,15 +283,15 @@ module RubynCode
|
|
|
243
283
|
# after text responses — mirrors Claude Code's "pause for compaction"
|
|
244
284
|
# behavior that keeps context manageable in long sessions.
|
|
245
285
|
def compact_if_needed
|
|
246
|
-
return unless @context_manager.needs_compaction?(@conversation
|
|
286
|
+
return unless @context_manager.needs_compaction?(@conversation)
|
|
247
287
|
|
|
248
|
-
est = @context_manager.estimated_tokens(@conversation
|
|
288
|
+
est = @context_manager.estimated_tokens(@conversation)
|
|
249
289
|
RubynCode::Debug.token(
|
|
250
290
|
"Context over threshold (#{est}) — running compaction"
|
|
251
291
|
)
|
|
252
292
|
@context_manager.check_compaction!(@conversation)
|
|
253
293
|
|
|
254
|
-
after = @context_manager.estimated_tokens(@conversation
|
|
294
|
+
after = @context_manager.estimated_tokens(@conversation)
|
|
255
295
|
RubynCode::Debug.token("Compacted: #{est} → #{after} tokens")
|
|
256
296
|
rescue StandardError => e
|
|
257
297
|
RubynCode::Debug.warn("Compaction failed: #{e.message}")
|
|
@@ -266,8 +306,8 @@ module RubynCode
|
|
|
266
306
|
@max_tokens_override = Config::Defaults::ESCALATED_MAX_OUTPUT_TOKENS
|
|
267
307
|
end
|
|
268
308
|
|
|
269
|
-
def max_iterations_warning
|
|
270
|
-
warning = "Reached maximum iteration limit (#{
|
|
309
|
+
def max_iterations_warning(limit = MAX_ITERATIONS)
|
|
310
|
+
warning = "Reached maximum iteration limit (#{limit}). " \
|
|
271
311
|
'The conversation may be incomplete. Please review the ' \
|
|
272
312
|
'current state and continue if needed.'
|
|
273
313
|
@conversation.add_assistant_message([{ type: 'text', text: warning }])
|
|
@@ -10,7 +10,7 @@ module RubynCode
|
|
|
10
10
|
module SystemPromptBuilder # rubocop:disable Metrics/ModuleLength -- heavily extracted, residual 3 lines over
|
|
11
11
|
include Prompts
|
|
12
12
|
|
|
13
|
-
INSTRUCTION_FILES = %w[RUBYN.md CLAUDE.md AGENT.md].freeze
|
|
13
|
+
INSTRUCTION_FILES = %w[RUBYN.md CLAUDE.md AGENTS.md AGENT.md].freeze
|
|
14
14
|
|
|
15
15
|
private
|
|
16
16
|
|
|
@@ -19,16 +19,49 @@ module RubynCode
|
|
|
19
19
|
parts << PLAN_MODE_PROMPT if @plan_mode
|
|
20
20
|
parts << "Working directory: #{@project_root}" if @project_root
|
|
21
21
|
append_response_mode(parts)
|
|
22
|
+
static = static_prompt_sections
|
|
23
|
+
parts << static unless static.empty?
|
|
24
|
+
parts.join("\n")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The static sections hit SQLite (memories, instincts) and walk the
|
|
28
|
+
# filesystem (instruction files, profile), so they're assembled once
|
|
29
|
+
# per user turn instead of on every iteration of the tool loop. Only
|
|
30
|
+
# the plan-mode flag and response-mode line vary mid-turn, and those
|
|
31
|
+
# stay in build_system_prompt.
|
|
32
|
+
def static_prompt_sections
|
|
33
|
+
@static_prompt_sections ||= build_static_prompt_sections
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build_static_prompt_sections
|
|
37
|
+
parts = []
|
|
22
38
|
append_project_profile(parts)
|
|
23
39
|
append_codebase_index(parts)
|
|
24
40
|
append_memories(parts)
|
|
25
41
|
append_project_instructions(parts)
|
|
26
42
|
append_instincts(parts)
|
|
27
43
|
append_skills(parts)
|
|
44
|
+
append_chisel_ruleset(parts)
|
|
28
45
|
append_deferred_tools(parts)
|
|
29
46
|
parts.join("\n")
|
|
30
47
|
end
|
|
31
48
|
|
|
49
|
+
# Chisel's "write the minimum that works" ruleset, injected only when the
|
|
50
|
+
# user has turned it on (chisel_mode != off). Guarded so a config or
|
|
51
|
+
# resolution error never breaks prompt assembly.
|
|
52
|
+
def append_chisel_ruleset(parts)
|
|
53
|
+
section = Chisel.prompt_section
|
|
54
|
+
parts << "\n#{section}" unless section.empty?
|
|
55
|
+
rescue StandardError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Called at the start of each user turn so memory, instruction, and
|
|
60
|
+
# tool changes made between turns show up in the next prompt.
|
|
61
|
+
def reset_system_prompt_cache!
|
|
62
|
+
@static_prompt_sections = nil
|
|
63
|
+
end
|
|
64
|
+
|
|
32
65
|
def append_response_mode(parts)
|
|
33
66
|
text = last_user_text
|
|
34
67
|
return if text.empty?
|
|
@@ -182,7 +215,9 @@ module RubynCode
|
|
|
182
215
|
|
|
183
216
|
db = DB::Connection.instance
|
|
184
217
|
search = Memory::Search.new(db, project_path: @project_root)
|
|
185
|
-
|
|
218
|
+
# touch: false — assembling the prompt is not a memory "access";
|
|
219
|
+
# touching here would issue a SQLite write and inflate access counts.
|
|
220
|
+
recent = search.recent(limit: 20, touch: false)
|
|
186
221
|
return '' if recent.empty?
|
|
187
222
|
|
|
188
223
|
recent.map { |m| format_memory(m) }.join("\n")
|
|
@@ -116,7 +116,9 @@ module RubynCode
|
|
|
116
116
|
|
|
117
117
|
def execute_tool(tool_name, tool_input)
|
|
118
118
|
discover_tool(tool_name)
|
|
119
|
-
@hook_runner.fire(:pre_tool_use, tool_name: tool_name, tool_input: tool_input)
|
|
119
|
+
pre_decision = @hook_runner.fire(:pre_tool_use, tool_name: tool_name, tool_input: tool_input)
|
|
120
|
+
raise RubynCode::UserDeniedError, pre_decision[:reason] if pre_decision.is_a?(Hash) && pre_decision[:deny]
|
|
121
|
+
|
|
120
122
|
result = dispatch_tool(tool_name, tool_input)
|
|
121
123
|
@hook_runner.fire(:post_tool_use, tool_name: tool_name, tool_input: tool_input, result: result)
|
|
122
124
|
signal_decision_compactor(tool_name, tool_input, result)
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
require 'securerandom'
|
|
4
4
|
require 'digest'
|
|
5
5
|
require 'base64'
|
|
6
|
-
require 'faraday'
|
|
7
6
|
require 'json'
|
|
8
7
|
|
|
9
8
|
module RubynCode
|
|
@@ -163,6 +162,7 @@ module RubynCode
|
|
|
163
162
|
end
|
|
164
163
|
|
|
165
164
|
def http_client
|
|
165
|
+
require 'faraday'
|
|
166
166
|
@http_client ||= Faraday.new do |f|
|
|
167
167
|
f.options.timeout = 30
|
|
168
168
|
f.options.open_timeout = 10
|