Compare commits

...
Author SHA1 Message Date
clawdbot dfa1e4f71e fix: resolve merge conflicts and test compatibility
- Resolve .gitignore conflict: include both nix entries and .claude/
- Update AnthropicRuntimeClient::new() test calls to match new signature
- Fix explicit_model_provider_hint test to unset OPENAI_BASE_URL env var
2026-10-13 00:12:36 +00:00
clawdbot 8a19ecd7cc fix: use PermissionMode.covers() instead of comparison operators
The PermissionMode enum intentionally does not implement PartialOrd
to prevent silent re-ranking of the privilege ladder. Use the explicit
covers() method for privilege comparisons instead of < and >=.
2026-10-13 00:12:35 +00:00
clawdbot 8938f53790 fix(clippy): resolve new lint violations in trident, compact, and claw-rag-service
- runtime/trident.rs: derive Default, fix for_kv_map, if_same_then_else, extend_with_drain, unnecessary_cast, needless_borrows
- runtime/compact.rs: fix useless_borrows_in_formatting
- claw-rag-service/ingest.rs: remove unnecessary into_iter()
- claw-rag-service/db.rs: use as_chunks instead of chunks_exact, remove unused import

All tests pass (1014 Rust + 47 Python tests verified).
2026-10-13 00:12:35 +00:00
TheArchitectit 5433dac4a2 fix(cli): order parallel tool results by call index and cap fan-out
execute_batch already keyed results to original call slots; this makes
that airtight: panicking worker threads resolve to their own index via
a fallback tuple instead of colliding with slot 0, and results are
asserted to follow call order with a regression test that mixes a
workspace-escape read among valid reads at cap=2.

Adds the fan-out bound: parallel-safe calls run in waves of at most
maxParallelToolCalls (default 8, CLAWD_PARALLEL_TOOL_CALLS env, or the
new setting applied via with_max_parallel_tool_calls at runtime build).
Sequential behavior is preserved: allowed_tools checks still run
before dispatch, and ToolSearch stays off the parallel path.
2026-10-13 00:12:35 +00:00
TheArchitectit f3a23bf39f feat(teams): bound per-team sub-agent concurrency with AgentGate
TeamCreate resolves maxConcurrentAgents from the per-team input, then
the maxConcurrentAgents setting, then unlimited (pre-fix behavior).
The cap is persisted in the manifest and registered on a process-wide
gate; agent threads still spawn immediately but block on an RAII slot
before entering their run loop, so manifest/task-claim semantics are
unchanged. TeamDelete forgets the gate so queued agents drain and no
stale team entries accumulate. Includes a gate regression test.
2026-10-13 00:12:35 +00:00
TheArchitectit 66006cca34 feat(config): parse maxParallelToolCalls and maxConcurrentAgents settings
Adds optional positive-integer parsing for the two new fan-out caps
next to subagentModel, in both camelCase and snake_case forms. Zero
parses as unset; non-numeric values are a named config error. Covered
by three regression tests mirroring the subagentModel test style.
2026-10-13 00:12:35 +00:00
TheArchitectitandClaw 08cce855b5 fix: restore workspace unsafe_code=forbid, fix TaskClaim TOCTOU, remove TUI deps
Review fixes for PR #3250:

1. Restore workspace-level unsafe_code = "forbid" (was downgraded to
   "warn" for TUI support). The per-crate [lints.rust] unsafe_code =
   "allow" on rusty-claude-cli already overrides it where needed.

2. Fix TaskClaim TOCTOU race in claim_task(). Replace exists()-then-write
   with OpenOptions::create_new(true) which atomically fails if the lock
   file already exists, eliminating the race window between the check and
   the write.

3. Remove TUI scaffolding deps (ratatui, tui-textarea, gag, crossbeam-channel,
   libc, unicode-width) and the /tui slash command spec — these belong in
   a future TUI PR and are unused in the current source.

Also updates the provider chain precedence test to reflect the new behavior
where the caller's resolved model is the chain primary.

Co-Authored-By: Claw <noreply@openclaw.ai>
2026-10-13 00:12:35 +00:00
TheArchitectit fe71ead55f test: add subagentModel accessor chain tests
Adds 3 unit tests covering the original silent-drop bug:
- camelCase 'subagentModel' key reads end-to-end
- snake_case 'subagent_model' key reads end-to-end
- blank value returns None (falls back to default model)

Requested by @1716775457damn in PR review.
2026-10-13 00:12:35 +00:00
TheArchitectit 334768249f fix: model_token_limit catch-all returns None for unknown models
Restores the fix from @Offwhite-Del that was lost during rebase.
Unknown models now return None so preflight skips the context-window
check and lets the API enforce its own limits.
2026-10-13 00:12:35 +00:00
TheArchitectit aab02464b2 cargo fmt 2026-10-13 00:12:35 +00:00
TheArchitectitandClaude Fable 5 7aa00a2798 feat(team): team enhancements, subagentModel, parallel tool exec
Port the agent-team enhancement layer onto upstream/main so the model
can spin up coordinated sub-agent teams for parallel work.

subagentModel config wiring (fix):
- Add subagent_model field to RuntimeFeatureConfig + RuntimeConfig::subagent_model()
  accessor so the subagentModel setting (already validated by config_validate.rs)
  is now actually read and stored.
- Agent tool's resolve_agent_model falls back to subagentModel from config
  when no explicit model is passed.

Provider namespace separation:
- New 'custom-openai' provider kind with dedicated env vars
  (CLAWCUSTOMOPENAI_API_KEY / CLAWCUSTOMOPENAI_BASE_URL)
- /setup wizard saves kind: 'custom-openai' for option 5
- Bare model name normalized to 'custom/' prefix to avoid proxy 404s
- Sub-agents inherit /setup-saved provider config via inject_config_as_env_fallbacks

Parallel tool execution:
- Override execute_batch to classify read-only tools as parallel-safe
  and run them concurrently via std::thread::scope
- Results return in original model order

Team coordination layer:
- AgentMessage, TaskClaim, TeamStatus tools + shared mailbox directory
- Mode presets (tiny/1x ... mega/6x) + enriched TeamCreate/Agent descriptions
- Background team watcher
- /team slash command: on/off/status/toggle

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-13 00:12:35 +00:00
bellmanandOmX 76fe2e9f46 Document provider compatibility diagnostics and passthrough
Explain the provider compatibility behavior that now has executable coverage so future changes preserve routing, custom gateway slugs, diagnostics, and extra-body passthrough semantics.

Constraint: Task 2 requires docs/status evidence while leader owns Ultragoal checkpoints and .omx/ultragoal remains untouched.
Rejected: Add a new CLI status command | broader than the assigned provider compatibility documentation/status surface.
Confidence: high
Scope-risk: narrow
Directive: Keep docs aligned with provider_diagnostics_for_model, MessageRequest::extra_body, and OpenAI-compatible slash-model tests.
Tested: cargo test --manifest-path rust/Cargo.toml -p api custom_openai_gateway_preserves_slash_model_ids_and_extra_body_params; cargo test --manifest-path rust/Cargo.toml -p api provider_diagnostics_explain_openai_compatible_capabilities; cargo check --manifest-path rust/Cargo.toml -p api
Not-tested: Live provider calls requiring external credentials.
Co-authored-by: OmX <omx@local>
2026-10-13 00:12:35 +00:00
bellmanandOmX 49e796051e Keep G007 mock parity references executable
The follow-up docs gate needed the parity manifest reference string that the mock parity diff checker requires, so this records the verified map-only repair after leader integration.

Constraint: Task 11 scope allowed only PARITY.md, rust/MOCK_PARITY_HARNESS.md, and docs/g007-plugin-mcp-verification-map.md; only PARITY.md needed a change.

Rejected: Editing harness code or .omx/ultragoal | checks proved the remaining gap was docs-map text only and Ultragoal state is leader-owned.

Confidence: high

Scope-risk: narrow

Directive: Keep auto_compact_triggered parity_refs synchronized with PARITY.md when mock parity scenarios change.

Tested: cd rust && python3 scripts/run_mock_parity_diff.py; python3 scripts/validate_cc2_board.py --board .omx/cc2/board.json; rg -n auto_compact_triggered token_cost_reporting auto_compaction threshold from env G007 Plugin/MCP G007-plugin-mcp 12 scripted scenarios 21 captured PARITY.md rust/MOCK_PARITY_HARNESS.md docs/g007-plugin-mcp-verification-map.md; git diff --check

Not-tested: Full workspace suite; Task 11 was a docs/parity-map follow-up with focused executable map validation.

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-10-13 00:12:35 +00:00
bellmanandOmX d2f2b23dc4 Document G007 mock parity verification boundaries
Record the W4 verification map and parity-doc alignment so plugin/MCP lifecycle mock coverage can be audited without scraping stale harness prose.

Constraint: Task 9 owns mock integration and regression verification/docs mapping, not parser fixes or leader-owned Ultragoal state.

Rejected: Editing .omx/ultragoal or duplicating Worker-4 plugin command fallthrough fixes | leader checkpointing and parser implementation are separate lanes.

Confidence: high

Scope-risk: narrow

Directive: Keep rust/mock_parity_scenarios.json, rust/MOCK_PARITY_HARNESS.md, PARITY.md, and mock_parity_harness.rs aligned when adding scenarios.

Tested: cd rust && python3 scripts/run_mock_parity_diff.py --no-run; cd rust && ./scripts/run_mock_parity_harness.sh; cd rust && python3 scripts/run_mock_parity_diff.py; cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli build_runtime_plugin_state -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli build_runtime_runs_plugin_lifecycle_init_and_shutdown -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p plugins plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins -- --nocapture; cargo fmt --manifest-path rust/Cargo.toml --all -- --check; cargo check --manifest-path rust/Cargo.toml -p rusty-claude-cli -p mock-anthropic-service; git diff --check

Not-tested: Full workspace test suite; W4 scope is docs/mock verification map and focused plugin/MCP lifecycle regression evidence.

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-10-13 00:12:35 +00:00
Bellman 18c0bead90 Merge pull request #3336 from ultraworkers/lane/gjc-s32-r4
revert: duplicate /status /cost /version handlers (#3333)
2026-10-13 09:09:51 +09:00
Bellman 6f1819a8ee Merge pull request #3328 from ultraworkers/fix/issue-3259-comprehensive
fix(cli): comprehensive fix for issue #3259 - markdown streaming block spacing
2026-10-13 09:06:25 +09:00
Bellman 122cf3ab0c Merge pull request #3337 from ultraworkers/release/v0.1.4
docs: add CHANGELOG for v0.1.4 release
2026-10-13 09:05:47 +09:00
clawdbot eb1537ca92 docs: add CHANGELOG for v0.1.4 release 2026-10-13 00:03:23 +00:00
clawdbot c13e71851b Revert "Merge pull request #3333 from ultraworkers/implement-slash-commands"
This reverts commit cf18dac3ee, reversing
changes made to a2eb74c00e.
2026-10-13 00:02:35 +00:00
Bellman 0508c2aa05 Merge pull request #3334 from ultraworkers/lane/gjc-s21-conflicts
Implement :conflicts selector for read_file tool (#3323)
2026-10-13 09:02:11 +09:00
clawdbot 3d8fd5f917 fix(cli): comprehensive fix for issue #3259 - markdown streaming block spacing
Fixes #3259: When rendering markdown in streaming mode, block-level elements
(code blocks, lists, tables, etc.) were losing trailing newlines due to
render_markdown() calling trim_end(). This caused improper spacing when chunks
were split at block boundaries.

Solution:
- Track last_output_ends_with_newline in MarkdownStreamState to detect when
  previous output ended without proper spacing
- In push(): add leading newlines before block elements if previous chunk
  didn't end with newlines
- In push(): restore trailing newlines after code block/table closings that
  were trimmed by render_markdown()
- Add ensure_newlines() helper to prepare spacing before block starts
- Add comprehensive streaming tests that verify idempotence across all
  possible chunk split positions

This ensures that streaming renders produce byte-identical output regardless
of where the input is split into chunks, which is critical for displaying
markdown as it arrives from the API while maintaining proper formatting.
2026-10-13 00:01:51 +00:00
Bellman cf18dac3ee Merge pull request #3333 from ultraworkers/implement-slash-commands
feat: implement /status, /cost, and /version slash commands
2026-10-13 08:59:43 +09:00
clawdbot 2b64546b30 Apply rustfmt formatting 2026-10-12 23:59:04 +00:00
clawdbot 30f607e03a Implement :conflicts selector for read_file tool
- Add real git merge conflict extraction matching GJC behavior
- Extract unresolved conflict blocks (<<<<<<, =======, >>>>>>>)
- Support 3-way conflicts with diff3 markers (|||||||)
- Format conflicts as indexed summary with line ranges and labels
- Return clear 'No unresolved git merge conflicts' when clean
- Add comprehensive behavior tests for 2-way, 3-way, and no-conflicts cases
- Update documentation to mark :conflicts as implemented
- Closes #3323

Tests:
- read_with_selector_conflicts_no_conflicts: verifies clean file message
- read_with_selector_conflicts_two_way: verifies multiple conflict detection
- read_with_selector_conflicts_three_way: verifies diff3 marker support
2026-10-12 23:57:50 +00:00
clawdbot 4bd5a6381e feat: implement /status, /cost, and /version slash commands
- Add real implementations for /status, /cost, and /version slash commands
- /status shows session metadata including message count and token usage
- /cost displays cumulative token usage including cache metrics
- /version shows the claw-code version
- All three commands are fully backed by existing Session infrastructure
- Add comprehensive tests for all three commands
- Remove expectations that these commands return None

These commands fulfill the requirement to implement 2-3 missing GJC slash
commands that the existing claw-code runtime can fully support.
2026-10-12 23:57:07 +00:00
Bellman a2eb74c00e Merge pull request #3332 from ultraworkers/lane/gjc-s21
feat(tools): GJC-compatible read selectors (#3323)
2026-10-13 08:52:18 +09:00
clawdbot 44ff1249fa style: apply rustfmt formatting 2026-10-12 23:49:29 +00:00
clawdbot f4c375641a feat: wire GJC-style line range selector into read_file tool
- Add selector field to ReadFileInput accepting GJC syntax (:50, :50-200, :50+150, :raw)
- Implement read_file_with_selector and read_file_in_workspace_with_selector
- Add apply_line_selector to parse and apply selectors to file contents
- Add truncation_footer field to ReadFileOutput for GJC-compatible output
- Add comprehensive tests for selector parsing and application through tool entry point

Selectors take precedence over offset/limit for backward compatibility.
Closes #3323
2026-10-12 23:48:29 +00:00
Bellman 20578124c4 Merge pull request #3330 from ultraworkers/lane/gjc-s11
feat(sdk): implement GJC SDK wire transport with WebSocket support
2026-10-13 08:46:25 +09:00
clawdbot 46eba21620 cleanup: remove phase 1a documentation (owned by PR #3329) 2026-10-12 23:44:44 +00:00
clawdbot 3b1f7220c5 feat(sdk): implement GJC SDK wire transport with WebSocket support
- Add error types and error code enumerations
- Implement frame types for SDK wire protocol (FrameType enum)
- Create Frame struct with JSON serialization/deserialization
- Implement SdkTransport with async WebSocket connection handling
- Support request/response correlation via request IDs
- Handle frame parsing and routing with background task
- Add comprehensive unit tests for frame creation and correlation
- Implement proper async handling with tokio and futures

Implements protocol version 3 with:
- Token-based authentication via query parameter
- Frame size limits (4 MiB max payload)
- Request timeout handling
- Proper error propagation and correlation
2026-10-12 23:43:10 +00:00
clawdbot e8c14616c6 docs: add final lane report for cc-gjc-s21 phase 1a completion 2026-10-12 23:43:04 +00:00
clawdbot 16541e987b docs: add Phase 1a completion report for line range selector 2026-10-12 23:42:30 +00:00
clawdbot baa31c7515 docs: add Slice 2.1 implementation tracking log 2026-10-12 23:41:37 +00:00
clawdbot 6b8bdb2ce7 feat: add GJC-compatible line range selector parser for read tool
- Support line range selectors: :50, :50-200, :50+150, :5-16,960-973
- Support special modes: :raw, :conflicts
- Parse comma-separated ranges with automatic merging of overlapping/adjacent ranges
- Comprehensive unit tests covering all selector formats
- Fixes #3323 Slice 2.1 - Phase 1a (read tool selector parsing)
2026-10-12 23:41:13 +00:00
clawdbot 1e55fb11bc docs: add GJC tool semantics analysis for Batch 1 (read/edit/search/find) 2026-10-12 23:39:56 +00:00
clawdbot 7e6981fbe7 docs: add GJC compatibility gap matrix 2026-10-12 23:38:42 +00:00
Bellman 0d052f831b Merge pull request #3313 from ultraworkers/lane/cc-issue-3247
build: add Nix flake dev shell and claw package
2026-10-13 08:37:46 +09:00
clawdbot 3859627013 build: add Nix flake dev shell and claw package (#3247) 2026-10-12 23:35:06 +00:00
Bellman 620ac1dbad Merge pull request #3327 from ultraworkers/lane/revert-3318
revert: #3318 (merged with red CI; main test build broken)
2026-10-13 08:18:42 +09:00
clawdbot 06c743f15f fix(test): update AnthropicRuntimeClient::new calls to match 9-arg signature
The test code was calling with 8 arguments; updated to include the
terminal_tail parameter in the correct position.
2026-10-12 23:16:26 +00:00
clawdbot f021597942 Revert "Merge pull request #3318 from ultraworkers/pr-3292"
This reverts commit 2d920ce915, reversing
changes made to 80bfe94b77.
2026-10-12 23:09:13 +00:00
Bellman 2d920ce915 Merge pull request #3318 from ultraworkers/pr-3292
fix: harden the agent loop against a self-hosted Anthropic-compatible backend
2026-10-13 08:07:04 +09:00
Bellman 80bfe94b77 Merge pull request #3299 from D4B34R987/main
fix: wire persisted provider configuration into runtime
2026-10-13 08:06:13 +09:00
dependabot[bot] 5303de65ad build(deps): bump quinn-proto
Bumps the cargo group with 1 update in the /rust directory: [quinn-proto](https://github.com/quinn-rs/quinn).


Updates `quinn-proto` from 0.11.14 to 0.11.19
- [Release notes](https://github.com/quinn-rs/quinn/releases)
- [Commits](https://github.com/quinn-rs/quinn/compare/quinn-proto-0.11.14...quinn-proto-0.11.19)

---
updated-dependencies:
- dependency-name: quinn-proto
  dependency-version: 0.11.19
  dependency-type: indirect
  dependency-group: cargo
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-12 23:03:59 +00:00
DaBear987 50ff600466 fix: wire persisted provider configuration into runtime 2026-10-12 23:03:56 +00:00
Bellman 4c3b10f087 Merge pull request #3317 from ultraworkers/lane/cc-issue-3283
docs: add OmniRoute documentation for OpenAI-compatible setup
2026-10-13 08:02:26 +09:00
reacechalmers 6a797de094 fix(runtime): keep a failed turn's tool results, and size guards from the real context window
Five defects found by auditing the agent stack, all of which let the harness act
confidently on a wrong picture of its own state.

1. A turn that errored discarded the record of tools that had already run. The CLI
   hands run_turn a CLONE of the session; the turn appends the user message, each
   assistant reply, and a tool_result per executed tool to that clone alone. The error
   path dropped it while the side effects of those tools remained on disk, and the
   recovery path then replayed the same user input against a session showing no sign
   they had ever run — inviting a second execution of tools that are not idempotent.
   The clone is now adopted before teardown, and recovery resumes the turn instead of
   replaying it (new ConversationRuntime::resume_turn).

2. Every sizing guard was computed against the model NAME's context window. With a
   local server behind a cloud model alias that is 200k against a much smaller
   backend, which does not make the preflights inaccurate — it makes them unreachable.
   CLAW_CONTEXT_WINDOW now declares the real window; model_token_limit honours it and
   returns a limit for unrecognised models instead of None, max_tokens is capped to a
   quarter of it, and the auto-compaction threshold derives from it (70%) rather than
   defaulting to a 200k-window constant.

3. Whether to recover by discarding conversation history was decided by substring
   matching on rendered error prose, including strings that ordinary transport and
   decode failures also produce — so a dropped connection cost the session its
   transcript. Classification now happens in the API layer, where the error still has
   its type and the request is still in hand: typed overflow errors, plus ambiguous
   transport failures only when the request actually filled the window. The two
   copy-pasted detection blocks are gone.

4. The top-level agent loop had no iteration ceiling (usize::MAX) while spawned
   subagents were bounded at 32. It now gets the same bound, overridable with
   CLAW_MAX_TURN_ITERATIONS.

5. Auto-continue nudges were injected as USER turns, so harness-authored text was
   persisted, re-sent on every later request, and folded into compaction summaries as
   something the user had said. They are recorded as system turns now; the wire
   payload is unchanged.

Also replaces the repository's own self-host section, which described a translator
and a serving stack no longer in the request path. That file is folded into the
system prompt verbatim, so it was briefing the model with an architecture that does
not exist.

(cherry picked from commit c8834cb4d1a3372eb70241429f641162a420297e)
2026-10-12 23:00:22 +00:00
Bellman e97ddc767f Merge pull request #3239 from AbbyJL/workspace-slash-command
fix(commands): wire workspace slash command
2026-10-13 07:59:47 +09:00
clawdbot 687c6b03a4 docs: add OmniRoute documentation for OpenAI-compatible setup (fixes #3283) 2026-10-12 22:59:18 +00:00
reacechalmers 7c38d78759 fix(runtime): auto-continue a turn the model stalled without a tool call
A reply carrying no tool_use ended the turn unconditionally. Small local
models routinely narrate the next tool call in prose — "Now running the
EEC-V probe:" — and then emit EOS, so the announced work never happened
and the user was dropped back at the prompt. The same failure wears a
second hat mid-task: stopping to ask for a go-ahead on work already
requested.

The loop now inspects the final line of a tool-less reply. If it
announces a pending action, or asks a question after tools have already
run this turn, a short nudge is pushed as a user message and the loop
continues instead of breaking. The budget is bounded (default 3 per
turn, CLAW_MAX_STALL_NUDGES, 0 disables) so a model that really is
finished still gets to stop.

Prevention pairs with recovery: the "Doing tasks" system-prompt section
now tells the model to act in the turn it plans in.

Tests cover the auto-continue, the bound, a plain completion that must
NOT be nudged, the disable switch, and last-line-only detection. The
behavioural pair was mutation-checked: reverting the continue to a break
fails both.

(cherry picked from commit 9035fe6f4bf4a8d4cb70d7a3baf1f3748c757498)
2026-10-12 22:58:32 +00:00
reacechalmers cb572811e1 fix(runtime): bound the git diff snapshot to a configurable budget
The git diff snapshot is rendered into the *system prompt*, so its cost is
paid again by every session whose working tree has changed -- and a diff
changes on essentially every edit. The 50_000-char budget was four times the
entire instruction-file budget (12_000) and unbounded in practical effect: a
single modified file added ~15_000 tokens to the prompt.

Measured against a self-hosted backend, one uncommitted change took the
prompt from 8_302 to 23_457 tokens and a one-word reply from ~3 s to 296 s.
With the new default the same session is 7_911 tokens and 36 s. That cost is
absorbable against a hosted frontier model and ruinous against a local one.

Default is now 4_000 chars, matching MAX_INSTRUCTION_FILE_CHARS, and is
overridable with CLAW_MAX_GIT_DIFF_CHARS following the same pattern as
CLAW_MAX_READ_CHARS. A budget of 0 omits the snapshot entirely rather than
emitting an empty section with a truncation marker; git status and the
recent-commit list are unaffected either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNKqVB2JE9UnCkz8FC9HPy
(cherry picked from commit e01c6ea43d56a97e284ab80fa7a90a479997ed74)
2026-10-12 22:58:32 +00:00
reacechalmers fa0d53a969 fix(runtime): bound tool-result payloads to the actual context window
Two related gaps let a single agentic turn exceed a small context window,
with no recovery path once it happened.

1. read_file's character ceiling was a hardcoded 64 KB (~16k tokens), sized
   for a 200k window. Two reads in one turn reach ~48k tokens, which a 32k
   backend rejects outright. Make it configurable via CLAW_MAX_READ_CHARS,
   defaulting to the existing 64 KB so behavior is unchanged unless set.
   Reads remain paged, not refused: callers still get truncated/nextOffset.

2. Compaction could not help, because it works at message granularity and an
   agentic turn is five messages (user / assistant+tool_use / tool_result /
   assistant+tool_use / tool_result). With preserve_recent_messages: 4 the
   only droppable message is the short user prompt, so the two oversized
   tool results were preserved verbatim by design.

   Trim preserved tool-result payloads over 16 KB to a head/tail excerpt with
   an explicit "[... N characters elided ...]" marker, so the model knows its
   view is partial and can re-read. Small payloads pass through untouched.

   Also apply trimming on the early-return path: a session can exceed the
   window while holding fewer messages than preserve_recent_messages, where
   should_compact is false and message-granular compaction has no move to
   make. compact_now no longer gates on removed_message_count alone, which
   would have computed the smaller session and then discarded it.

Also fixes a pre-existing flaky test: repl_executes_python_code resolves the
python runtime through PATH, which the PowerShell tests blank while they run.
It now takes the same env_lock, so it no longer intermittently fails with
"python runtime not found" on a machine that has python.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bVvRYn7CQKR6hT2j1L6Cs
(cherry picked from commit b0d4d41e8132f8dc75717b1f74554d649639de10)
2026-10-12 22:58:32 +00:00
reacechalmers 910ac4e326 fix(runtime): also check for compaction before a request is sent
The usage-based compaction gate is post-hoc: it can only see the context
after a call returns and reports its counters. Between that measurement and
the next request the session can grow enormously - a reasoning model emitting
a long thinking block, then a tool result carrying a large file.

Observed live: a call measured at 17,858 prompt tokens generated 10,842 more,
a read_file added ~19k on top, and the next request reached the provider at
47,755 tokens and was rejected outright. No tuning of the post-hoc threshold
prevents that, because nothing measures the session in between.

Add a pre-send check driven by estimate_session_tokens, a local heuristic over
the transcript, so it costs no API call and works before any usage has been
recorded at all.

Note the limit of this: compaction preserves the 4 most recent messages, and
in an agentic tool-use loop those messages ARE the tool results carrying the
bulk. This check bounds gradual growth; it cannot rescue a session whose last
four messages already exceed the window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bVvRYn7CQKR6hT2j1L6Cs
(cherry picked from commit 26ab52d70fb17e49b74d523aecfc1da7f69a6d60)
2026-10-12 22:58:19 +00:00
reacechalmers a25717aded fix(runtime): auto-compaction must measure the live context, not a running total
`maybe_auto_compact` gated on `usage_tracker.cumulative_usage().input_tokens`,
which is wrong in two independent ways:

1. It ignores cached input. With prompt caching a warm call reports
   `input_tokens: 2` and puts the real context in `cache_read_input_tokens`,
   so the counter barely moved and auto-compaction never fired at all,
   however large the conversation grew. Backends without prompt caching
   report the whole prompt as `input_tokens`, so the bug was invisible there.

2. A sum across calls is not a context measurement, and it never decreases.
   Once it did cross the threshold, every subsequent iteration compacted
   again, permanently, even after compaction had shrunk the context.

Gate on the latest call's total input instead — input + cache_creation +
cache_read — which is the live context window size. It rises with the real
context and falls back below the threshold once compaction has done its job,
so no counter reset is needed.

`CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS` now means what it reads like:
compact when the live context reaches N tokens. The default of 100_000 is
unchanged and still lands at 50% of a 200k window.

Adds `TokenUsage::total_input_tokens()` (saturating) and four tests: the
cached-usage shape that never fired, non-latching across three turns, and
two unit tests for the new helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bVvRYn7CQKR6hT2j1L6Cs
(cherry picked from commit aebba4e77eddf585fc5b21123967dbc4ebb6e6a1)
2026-10-12 22:58:19 +00:00
reacechalmers 7a1391fbc1 fix(test): isolate tests from the machine's real user config and kernel
The suite asserted against whatever the developer's box happened to be
configured as. Two independent leaks, both surfaced by a real `~/.claw`:

1. `parse_args` resolves defaults — notably `permissions.defaultMode` —
   through the user-scope config, which `default_config_home()` reads from
   `CLAW_CONFIG_HOME` falling back to `$HOME/.claw`. Nothing overrode it, so
   a `defaultMode` of `dontAsk` on the host turned 12 assertions from
   `WorkspaceWrite` into `DangerFullAccess`. `env_lock()` now returns an
   `EnvGuard` that points `CLAW_CONFIG_HOME` at a fresh empty directory and
   restores the previous value on drop, so "no user config" is the state
   under test. All 42 call sites already bind the guard, so this fixes them
   at once; tests that want a populated config home can still set the var
   themselves afterwards. `removed_login_and_logout_subcommands_error_helpfully`
   asserted a Default-sourced permission mode without taking the guard at
   all, and now does.

2. `direct_resume_safe_slash_commands_route_to_local_json_actions_831`
   hardcoded `/sandbox` => "warn", which is only true where namespace
   isolation is unsupported and just the filesystem sandbox is active
   (#731). On a host with working user namespaces the correct answer is
   "ok". That entry now accepts either; the test is about #831 routing —
   local JSON action instead of `interactive_only` — not about the kernel
   under the runner.

Verified with the real ~/.claw/settings.json in place (defaultMode=dontAsk):
cargo test --workspace 0 failures, clippy --workspace --all-targets
-D warnings 0 findings, scripts/fmt.sh clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9McEEH32ZtyM62GSXVSyJ
(cherry picked from commit 67aa4530c22f6fdf95a29d6a46992af137134c67)
2026-10-12 22:58:19 +00:00
reacechalmers 2944e02a09 fix(clippy): clear remaining -D warnings failures in rusty-claude-cli
`cargo clippy --workspace --all-targets -- -D warnings` is this repo's own
documented verification command, and it could not run to completion. Earlier
compile failures were masking a further 48 lints; with those cleared the
workspace now passes with zero findings.

Real fixes, not suppressions:
  - status_json_value took 8 positional parameters, four of them a tail of
    `Option<&_>` that most callers passed as None — trivially transposable.
    Grouped into `StatusJsonExtras` with a Default impl; all 7 call sites now
    name only the fields they actually set.
  - extract_help_metadata's 8-tuple return is now a documented `HelpMetadata`
    alias.
  - the auto-compact loop indexed preserve_schedule by its loop counter;
    it now iterates the array directly.
  - a sandbox status chain had two identical arms; merged the condition.
  - a filter_map whose every arm returned Some is now a map.
  - 33 unused stdout/stderr bindings in the output-format contract tests are
    prefixed with `_`, and 9 `map_or(false, ..)` are now `is_some_and(..)`.

NOTE: several `--bin claw` tests (parse_args defaults) read the *real*
`~/.claw/settings.json` rather than an isolated config home, so they fail on
any machine whose user config sets `permissions.defaultMode`. With that file
moved aside the binary's suite is 207/207. That isolation gap is pre-existing
and left for a separate change.

Verified: clippy workspace/all-targets/-D warnings reports 0; cargo test
--workspace passes with the user config neutralised; scripts/fmt.sh clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9McEEH32ZtyM62GSXVSyJ
(cherry picked from commit 03c8a8fd7ba6e405a8217d00d3766c1c6367e527)
2026-10-12 22:58:19 +00:00
reacechalmers cd01ffc5f3 fix(clippy): box ApiError::Api payload to clear result_large_err
`ApiError` is the `Err` half of nearly every `Result` in the api crate, and
its `Api` variant carried ~139 bytes inline (8 fields, four of them
`Option<String>`). That tripped clippy::result_large_err on 35 signatures
and, with -D warnings, made the whole crate unbuildable under the repo's
own documented verification command.

Move the payload into `ApiErrorDetails` behind a `Box`:
  - `ApiError::Api(Box<ApiErrorDetails>)`, with `ApiError::api(..)` to
    construct and `api_details()` to borrow
  - `ApiErrorDetails` re-exported from the crate root
  - adding a field here can no longer re-inflate every signature

This also simplifies `enrich_bearer_auth_error`, which previously
destructured and rebuilt all eight fields at five separate return points
just to append a hint; it now mutates the boxed payload in place.

Also clears the remaining pre-existing lints that blocked -D warnings:
unused test bindings, a redundant `use serde_json`, and an explicit
`.into_iter()` in claw-rag-service.

Verified: cargo test --workspace passes, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9McEEH32ZtyM62GSXVSyJ
(cherry picked from commit e9210bd75eb76e5d88b2ae797f537102bcabd41d)
2026-10-12 22:58:19 +00:00
reacechalmers 4361e98998 fix: page large read_file results instead of returning whole files
An unbounded read_file returned the entire file, which on a small-context
backend overflows the model's window. The backend then rejects the whole
request (llama-server: "request (N tokens) exceeds the available context
size"), which kills the turn — so reading everything at once is precisely
what makes the session stop. Observed live: one 100 KB source file is
~25k tokens against a 32k window with a ~6k-token prefix, and a session
read the same file three times and died on six 400s.

read_file now returns a page and says so:
  - absent `limit` yields DEFAULT_READ_LINES (2000) rather than EOF
  - MAX_READ_CHARS (64 KB) bounds the payload even when `limit` is
    explicit, so minified or generated files can't defeat the line cap
  - `truncated` and `nextOffset` tell the caller it has a partial view
    and exactly where to resume; the tool description explains both

MAX_READ_SIZE (10 MB) is untouched: it is a process guard against slurping
huge or binary blobs, not a context guard, and at ~2.5M tokens it never
fires on the files that actually overflow a window.

Also clears pre-existing clippy failures in trident.rs that blocked
-D warnings on every edit to this crate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9McEEH32ZtyM62GSXVSyJ
(cherry picked from commit c796d7e5eedc66b0cb37bb6823ec772f8ceef9cf)
2026-10-12 22:58:19 +00:00
clawdbot ad65ea5a47 docs(triage): document investigation and clarification needed for #3283 2026-10-12 22:07:06 +00:00
User a6c32edcf6 fix(commands): wire workspace slash command 2026-06-08 23:21:53 +08:00
46 changed files with 7373 additions and 1332 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
use flake
+4
View File
@@ -25,6 +25,8 @@ on:
- tests/test_roadmap_helpers.py
- docs/**
- rust/**
- flake.nix
- .envrc
pull_request:
branches:
- main
@@ -47,6 +49,8 @@ on:
- tests/test_roadmap_helpers.py
- docs/**
- rust/**
- flake.nix
- .envrc
workflow_dispatch:
concurrency:
+5
View File
@@ -15,3 +15,8 @@ status-help.txt
.port_sessions/
# Local agent session state
.gjc/
.claude/
# Nix build outputs and direnv
.envrc.local
result
result-*
@@ -0,0 +1 @@
{"schema_version":1,"session_id":"24aa7e35-e6af-4d2d-ab41-a38b9115a031","state":"running","ready_for_input":false,"updated_at":"2026-10-12T22:10:00.508Z","current_turn_id":null,"last_turn_id":null,"live":true,"reason":null,"source":"agent_session_event","event":"turn_start","cwd":"/home/bellman/Data/Workspace/gc-task-cc-pr-3250-lane","workdir":"/home/bellman/Data/Workspace/gc-task-cc-pr-3250-lane","branch":null,"session_file":"/home/bellman/.gajaeway/broker/agent/sessions/v2-famocqbiduwkxmc2g2nvg6jfq2ay6qtc5aihbhbdoqopsmduj4sa/2026-10-12T22-02-16-124Z_24aa7e35-e6af-4d2d-ab41-a38b9115a031.jsonl","activity":{"seq":149,"last_activity_at":"2026-10-12T22:10:01.196Z","tool":"bash","phase":"started","outcome":null,"elapsed_ms":null,"active_tool_count":1,"active_tools":[{"tool":"bash","started_at":"2026-10-12T22:10:01.196Z"}],"in_flight":[{"digest":"fa5f186357264e6b1be2a7a4a7162d0fff04f721630413a25d05b40bcd933836","tool":"bash","started_at":"2026-10-12T22:10:01.196Z"}]}}
@@ -0,0 +1 @@
{"pid":1,"start_time":"unknown","token":"8389ee99-c41c-47dd-9615-e76a5268967c","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
@@ -0,0 +1 @@
{"pid":1,"start_time":"unknown","token":"6e35ded8-fda1-421e-a727-42906ef57646","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
Whitespace-only changes.
@@ -0,0 +1,12 @@
{
"version": 1,
"sessionId": "24aa7e35-e6af-4d2d-ab41-a38b9115a031",
"pid": 177576,
"host": "127.0.0.1",
"port": 34539,
"url": "ws://127.0.0.1:34539",
"token": "8FBuSDC3564yKKk5Fpgl6TEknzktQX8W",
"startedAt": 1791842537691,
"updatedAt": 1791842537691,
"stale": false
}
@@ -0,0 +1 @@
{"effectMarker":"7b92c148-d616-4eb5-95e8-4e95084bebe9","incarnation":"linux:159085489","pid":177576}
@@ -0,0 +1 @@
{"effectMarker":"7b92c148-d616-4eb5-95e8-4e95084bebe9","incarnation":"linux:159085489","pid":177576}
+38
View File
@@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.4] - 2026-10-13
### Added
- GJC-compatible line range selectors for read_file tool (`:50-200`, `:50+150` syntax)
- GJC-compatible `:conflicts` selector for git merge conflict detection
- SDK wire transport with WebSocket support
- Nix flake development environment
- `/status`, `/cost`, and `/version` slash commands
- Persistent provider configuration loading
- Pagination support for large read_file results
- Comprehensive GJC compatibility documentation and analysis
### Fixed
- Windows path containment checks for security boundary enforcement
- Git bash detection on Windows with proper probe caching
- Unresolvable path denial to prevent escape attempts
- Auto-compaction context window measurement
- Tool result preservation on failed turns
- Model stall recovery without tool calls
- Streaming markdown formatting with preserved block separators
- Local model validation bypass generalization beyond Ollama
- Release artifact checksums on macOS and Linux
- Duplicate assistant text rendering
- Workspace slash command wire-up
### Changed
- Enhanced path scope validation for multiple Windows path spellings
- Improved error handling and configuration defaults
- Upgraded GitHub Actions to immutable commit SHAs for CI security
### Known Limitations
- ACP / Zed integration is not yet implemented (status stub available via `claw acp`)
- Python porting workspace is for reference only, not used in production
## [0.1.3] - 2026-10-13
This is the first public binary release of Claw Code.
+44
View File
@@ -0,0 +1,44 @@
# Issue #3283 Triage Summary
**Issue:** docs: document OmniRoute OpenAI-compatible setup
**Opened by:** @diegosouzapw
**Status:** BLOCKED (waiting for clarification)
**Date:** 2026-10-13
## Findings
### Investigation
1. Searched codebase for "OmniRoute" — **no matches found**
2. Searched documentation (docs/local-openai-compatible-providers.md, MODEL_COMPATIBILITY.md) — **no mentions**
3. Searched git history (commits, branches, PRs) — **no references**
4. Checked GitHub API (rate limited, but issue title confirmed)
### Current State
- Issue #3283 is OPEN
- No labels assigned
- Title: "docs: document OmniRoute OpenAI-compatible setup"
- This is a documentation request for setting up an OpenAI-compatible provider
### Existing Documentation
The repository already has comprehensive documentation for OpenAI-compatible providers:
- `docs/local-openai-compatible-providers.md` — documents Ollama, llama.cpp, vLLM, and OpenRouter
- `docs/MODEL_COMPATIBILITY.md` — model-specific provider routing details
- `rust/crates/api/src/providers/openai_compat.rs` — provider implementation
## Next Steps
**Action taken:** Comment added to issue asking for:
1. OmniRoute documentation/website link
2. OpenAI-compatible endpoint example (base URL, API key format)
3. Model ID examples
4. Any special configuration or known quirks
**Comment posted to:** https://github.com/ultraworkers/claw-code/issues/3283#issuecomment-6133484114
## Conclusion
**Cannot proceed without user input.** Once the user provides the necessary information about OmniRoute, the fix will involve:
1. Adding a new section to `docs/local-openai-compatible-providers.md`
2. Including setup example (environment variables, model names, basic smoke test)
3. Following the existing format used for Ollama, llama.cpp, vLLM sections
4. Creating a PR for review
+338
View File
@@ -0,0 +1,338 @@
# GJC Tool Semantics Alignment: Phase 1 (read/edit/search/find)
**Status**: Implementation plan
**Issue**: #3323
**Scope**: Slice 2.1 - Tool catalog semantic parity for read/edit/search/find
**Target**: Ship in batches with focused PRs
---
## 1. EXECUTIVE SUMMARY
This document details the semantic gaps between claw-code's current tool implementations and GJC's (gajae-code) reference implementations for the highest-value tools: **read**, **edit**, **search**, and **find**.
### Key Gaps
| Tool | Current (claw-code) | Target (GJC) | Gap | Complexity |
|------|-----|--------|-----|------------|
| **read** | offset/limit pagination | Line selectors (`:L-L+N`), raw mode, truncation footers | High | Medium |
| **write** | Simple file replacement | Full replacement semantics (matching GJC) | Low | Low |
| **edit** | String replacement (old→new) | String replacement with rich patch output | Low | Low |
| **find** (glob) | Pattern expansion only | Recursive glob with filtering | Low | Low |
| **search** (grep) | Regex search with context | Regex with context + advanced filtering | Low | Low |
---
## 2. GJC TOOL SEMANTICS REFERENCE
### 2.1 READ Tool
**Source**: `gajae-code/packages/coding-agent/src/tools/read.ts` (3,500+ LOC)
#### Input Schema
```typescript
{
path: string; // File path (relative or absolute)
offset?: number; // Line offset (for pagination)
limit?: number; // Lines to read (for pagination)
// GJC extensions (missing in claw-code):
selector?: string; // Line range syntax:
// ":50" = line 50 onward
// ":50-200" = lines 50-200 inclusive
// ":50+150" = 150 lines starting at line 50
// ":raw" = full file verbatim
// ":conflicts" = git merge conflicts only
truncationDirection?: "head" | "tail" | "both"; // Which end to keep
maxSummaryBytes?: number; // Summary budget (default 50KB)
maxSummaryLines?: number; // Summary budget (default 20K lines)
}
```
#### Output Structure
```json
{
"file": {
"path": "src/main.rs",
"content": "...",
"totalLines": 500,
"totalBytes": 12345,
"truncatedBy": "lines" | "bytes" | "middle" | null,
"displayMode": "summarized" | "raw" | "receipt"
},
"truncationFooter": "[Showing lines 1-50 of 500; re-read ...]"
}
```
#### Key Behaviors
1. **Line Range Selectors** (`:selector` syntax)
- `:50` = start at line 50
- `:50-200` = lines 50-200 inclusive
- `:50+150` = 150 lines starting at 50
- `:50+1` = exactly one line
- `:5-16,960-973` = multiple ranges
- `:raw` = full verbatim
- `:conflicts` = git merge conflicts
2. **Truncation Modes**
- Default: 50KB bytes limit, 20K lines limit
- Direction: `head` (keep start), `tail` (keep end), `both` (keep both ends)
- Footer includes recovery hint (e.g., "`re-read path:1-9999` for verbatim")
3. **Structural Summarization**
- Code: collapse function bodies to `..` or `…` (merged brace pairs)
- Prose: preserve full lines, truncate at word boundaries
- Special files: documents (PDF, DOCX, XLSX) converted via markit
4. **Archive Support**
- `.tar`, `.tar.gz`, `.tgz`, `.zip`: member read with `archive.ext:path/inside`
- SQLite: `.sqlite:table` or `.sqlite:table:key` queries
- Line ranges apply to archive members
5. **Output Formatting**
- Anchored output: Line prefixes with source line numbers and hash anchors
- Optional hash lines: Marks structural boundaries (`# pragma`, function start, etc.)
- Prose column cap: Long lines wrapped/truncated at column limit
### 2.2 EDIT Tool
**Source**: `gajae-code/packages/coding-agent/src/tools/edit.ts`
#### Input Schema
```typescript
{
path: string; // File to edit
old_text: string; // Text to replace (required for edit)
new_text: string; // Replacement text
all?: boolean; // Replace all occurrences (default: first only)
}
```
#### Output Structure
```json
{
"file": {
"path": "src/main.rs",
"editCount": 1,
"beforeSize": 12345,
"afterSize": 12400
},
"patch": [
{
"type": "replace",
"lineStart": 50,
"lineEnd": 55,
"before": "old content",
"after": "new content",
"context": {"before": 3, "after": 3}
}
]
}
```
#### Key Behaviors
1. **Exact Match Required**: `old_text` must match exactly (no fuzzy matching)
2. **Smallest Replacement**: Use smallest unique old_text that identifies the change
3. **All-or-Nothing**: Either all occurrences replaced or none (no partial)
4. **Patch Metadata**: Line numbers, context, before/after preserved
### 2.3 SEARCH Tool
**Source**: `gajae-code/packages/coding-agent/src/tools/search.ts`
#### Input Schema
```typescript
{
pattern: string; // Regex pattern
paths?: string[]; // Files/dirs to search
context?: number; // Context lines before/after (default: 2)
i?: boolean; // Case-insensitive
glob_exclude?: string[]; // Patterns to skip
file_type?: string; // Filter by extension
limit?: number; // Max results
truncation?: "head" | "tail" | "both";
}
```
#### Output Structure
```json
{
"matches": [
{
"file": "src/main.rs",
"lineNumber": 42,
"line": "fn process() {",
"before": ["// Process data", ""],
"after": [" // Implementation", ""]
}
],
"summary": "Found 5 matches in 3 files"
}
```
### 2.4 FIND Tool
**Source**: `gajae-code/packages/coding-agent/src/tools/find.ts`
#### Input Schema
```typescript
{
paths: string[]; // Glob patterns
timeout?: number; // Timeout in seconds (default: 5)
gitignore?: boolean; // Respect .gitignore (default: true)
hidden?: boolean; // Include hidden files (default: true)
limit?: number; // Max results (default: 1000)
}
```
#### Output Structure
```json
{
"files": [
{
"path": "src/main.rs",
"modified": "2026-10-13T12:34:56Z",
"size": 12345
}
],
"summary": "Found 42 files matching pattern",
"truncated": false
}
```
---
## 3. CURRENT CLAW-CODE IMPLEMENTATION GAPS
### 3.1 READ Tool Gaps
**File**: `rust/crates/runtime/src/file_ops.rs:713`
**Current**:
```rust
pub fn read_file_in_workspace(
path: &str,
offset: Option<usize>, // Line offset only
limit: Option<usize>, // Lines only
workspace_root: &Path,
) -> io::Result<ReadFileOutput>
```
**Missing**:
- ✗ Line range selectors (`:L-L+N` syntax)
- ✗ Raw mode (`:raw`)
- ✓ Git merge conflict extraction (`:conflicts`)
- ✗ Truncation footers with recovery hints
- ✗ Structural summarization (collapse braces)
- ✗ Archive/SQLite support
- ✗ Multi-range selectors (`:5-16,960-973`)
- ✗ Prose column cap
- ✗ Hash line anchors
**Estimated LOC to Add**: 1,200-1,500 (including tests)
### 3.2 EDIT Tool Gaps
**File**: `rust/crates/runtime/src/file_ops.rs:760`
**Current**: Basic string replacement works
**Missing**:
- ✗ Rich patch metadata (line numbers, context lines)
**Estimated LOC to Add**: 200-300
### 3.3 SEARCH Tool Gaps
**File**: `rust/crates/tools/src/lib.rs` (grep search)
**Current**: Basic regex with context
**Missing**:
- ✗ Advanced filtering (file type, glob exclude)
- ✗ Structured output format
**Estimated LOC to Add**: 300-400
### 3.4 FIND Tool Gaps
**File**: `rust/crates/tools/src/lib.rs` (glob search)
**Current**: Basic glob expansion
**Missing**:
- ✗ Recursive globs
- ✗ Gitignore filtering
- ✗ Hidden file control
- ✗ File metadata
**Estimated LOC to Add**: 200-300
---
## 4. IMPLEMENTATION PLAN (BATCHED)
### Batch 1: READ Tool (Priority P0)
- Add line range selector parsing
- Implement `:raw` mode
- ✓ Implement `:conflicts` mode
- Add truncation footers with recovery hints
- Implement structural summarization
- Add tests for all selector combinations
**Files to Modify**:
- `rust/crates/runtime/src/file_ops.rs` (read_file_in_workspace)
- `rust/crates/tools/src/lib.rs` (ReadFileInput struct)
- Tests in both
**Size Estimate**: 1,200 LOC
**Complexity**: Medium (string parsing, output formatting)
### Batch 2: EDIT Tool (Priority P1)
- Add patch metadata to output
- Ensure exact match semantics
- Add comprehensive tests
**Files to Modify**:
- `rust/crates/runtime/src/file_ops.rs` (edit_file_in_workspace)
- Tests
**Size Estimate**: 200 LOC
**Complexity**: Low
### Batch 3: SEARCH/FIND Tools (Priority P1)
- Enhance grep/find with GJC-compatible filtering
- Add structured output format
- Add metadata (file type, modified time)
**Files to Modify**:
- `rust/crates/tools/src/lib.rs` (glob_search, grep_search)
- `rust/crates/runtime/src/file_ops.rs`
**Size Estimate**: 500 LOC
**Complexity**: Low-Medium
---
## 5. VERIFICATION STRATEGY
Each batch will include:
1. **Semantic Tests**: Verify tool inputs/outputs match GJC reference
2. **Edge Case Tests**: Line ranges, truncation, archives, etc.
3. **Integration Tests**: End-to-end with claw CLI
4. **Parity Shadow Test**: Run same inputs against both GJC and claw-code, compare outputs
---
## 6. ROLLOUT SCHEDULE
- **Batch 1 (READ)**: Week 1 - Enable most impactful tool first
- **Batch 2 (EDIT)**: Week 1-2 - Quick follow-up
- **Batch 3 (SEARCH/FIND)**: Week 2 - Lower priority but valuable for discoverability
---
## 7. REFERENCES
- GJC Read Tool: `/home/bellman/Workspace/gajae-code/packages/coding-agent/src/tools/read.ts`
- claw-code file_ops: `rust/crates/runtime/src/file_ops.rs`
- claw-code tools: `rust/crates/tools/src/lib.rs`
- Gap Matrix: `docs/gjc-compat/gap-matrix.md`
+19
View File
@@ -130,6 +130,25 @@ claw --model "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit" prompt "Reply exactl
mlx-lm serves models under their full Hugging Face repo ID. Use the exact `id` field from `curl $OPENAI_BASE_URL/models` for `--model`. A bare name like `qwen2.5-coder-7b-instruct` will fail model resolution before the request ever reaches the server.
## OmniRoute and other remote OpenAI-compatible gateways
[OmniRoute](https://github.com/diegosouzapw/OmniRoute) is a third-party OpenAI-compatible AI gateway that can route requests to multiple providers. To use any remote OpenAI-compatible gateway with Claw, set `OPENAI_BASE_URL` to the gateway's `/v1` endpoint and provide your API key:
```bash
export OPENAI_BASE_URL="https://api.omniroute.ai/v1"
export OPENAI_API_KEY="your-omniroute-api-key"
claw --model "gpt-4" prompt "Reply exactly HELLO_WORLD_123"
```
To find the available model IDs on your gateway, use the `/models` endpoint:
```bash
curl -sS "$OPENAI_BASE_URL/models" \
-H "Authorization: Bearer $OPENAI_API_KEY" | jq '.data[].id'
```
For local configuration, see your gateway's documentation for the correct base URL, API key format, and available model IDs. The same OPENAI_BASE_URL + OPENAI_API_KEY pattern applies to any OpenAI-compatible remote gateway.
## Local skills install from disk
Skills are discovered from Claw skill roots such as `.claw/skills/` in a workspace and `~/.claw/skills/` for user-level installs. Legacy `.codex/skills/` roots may also be scanned for compatibility, but new local Claw projects should prefer `.claw/skills/`.
+203
View File
@@ -0,0 +1,203 @@
# Nix flake development environment
This repository includes a **`flake.nix`** for reproducible development environments on NixOS and systems with Nix installed.
## Prerequisites
- **Nix** (version 2.4 or later with flakes support enabled)
- **direnv** (optional, for automatic environment loading via `.envrc`)
To verify Nix is installed and flakes are enabled:
```bash
nix --version
nix flake --help
```
If flakes are not enabled, add this to your `~/.config/nix/nix.conf`:
```
experimental-features = nix-command flakes
```
## Quick start
### Enter the development environment
**Option 1: Using `nix flake`** (without direnv)
```bash
nix flake update # Update inputs (optional, pins nixpkgs)
nix develop # Enter default dev shell
nix develop .#full # Enter full dev shell with extra tools
exit # Leave the shell
```
**Option 2: Using direnv** (automatic on directory entry)
```bash
direnv allow # Trust the .envrc file (one-time setup)
cd . # Direnv reloads the flake automatically
```
When you leave the repository directory, the environment is automatically unloaded.
## Development shells
The flake provides two development shells:
### `default` (minimal)
```bash
nix develop
```
Includes:
- Rust (stable, latest)
- Rust source code (`rust-src`)
- `cargo-edit` (dependency management helpers)
- `cargo-expand` (macro expansion tool)
- `cargo-watch` (file watcher for incremental builds)
- Build tools: `pkg-config`, `openssl`, `git`, `ca-certificates`
### `full` (with extra tooling)
```bash
nix develop .#full
```
Includes everything from `default` plus:
- `clippy` (linter)
- `rustfmt` (code formatter)
- `mold` (fast linker)
- `lldb` (debugger)
## Building the claw binary
### With the dev environment
```bash
nix develop
cd rust
cargo build -p rusty-claude-cli
```
### Directly with `nix build`
```bash
nix build .#claw # Build the claw binary
nix build # Same as .#claw (default package)
```
The compiled binary is symlinked to `./result/bin/claw`.
## Running common tasks
Once in the dev shell:
```bash
# Format code
cd rust
cargo fmt
# Run clippy linter
cargo clippy --workspace --all-targets -- -D warnings
# Run all tests
cargo test --workspace
# Run tests for a specific crate
cargo test -p rusty-claude-cli
# Build with optimizations
cargo build --release -p rusty-claude-cli
```
## Environment variables
The flake automatically sets:
- `RUSTUP_TOOLCHAIN=stable` — uses stable Rust from nixpkgs
- Standard Rust build flags are inherited from the environment
For development, the minimal dev shell includes a helpful message on entry:
```
Claw Code development environment
Run 'cargo build -p rusty-claude-cli' to build the claw binary
Run 'cargo test --workspace' to run tests
Run 'scripts/fmt.sh' to format code
```
## Troubleshooting
### `command not found: nix`
Nix is not installed. Install it from [nixos.org/download.html](https://nixos.org/download.html).
### `error: experimental Nix feature 'flakes' is disabled`
Enable flakes in your Nix configuration (`~/.config/nix/nix.conf` or `/etc/nix/nix.conf`):
```
experimental-features = nix-command flakes
```
### `error: ... does not provide attribute 'claw'`
The flake structure may have changed. Try updating:
```bash
nix flake update
nix flake check
```
### Rebuilds are slow after updating dependencies
Cargo will recompile dependencies if `Cargo.lock` changes. This is expected:
```bash
nix build --rebuild
```
### `cache lock file needs to be updated`
The Cargo.lock in `rust/` needs to be committed. Run:
```bash
cd rust
cargo generate-lockfile
git add Cargo.lock
git commit -m "update: Cargo.lock"
```
## Integration with other tools
### IDE integration (VS Code, IntelliJ, etc.)
Many IDEs can detect and use Nix environments. Direnv integration is recommended:
1. Install the direnv extension
2. Enable `direnv` shell integration in your IDE
3. Run `direnv allow` in the repository
Your IDE will automatically use the correct Rust toolchain and dependencies from the flake.
### CI/CD
For GitHub Actions, consider using [cachix/install-nix-action](https://github.com/cachix/install-nix-action) to set up Nix on the runner:
```yaml
- uses: cachix/install-nix-action@v18
with:
nix_path: nixpkgs=channel:nixos-unstable
extra_nix_config: "experimental-features = nix-command flakes"
- run: nix develop -c cargo test --workspace
```
## More information
- [NixOS flakes documentation](https://nixos.wiki/wiki/Flakes)
- [Direnv documentation](https://direnv.net/)
- [Rust on Nix](https://github.com/oxalica/rust-overlay)
+115
View File
@@ -0,0 +1,115 @@
{
description = "claw-code: Claude Code CLI agent harness in Rust";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
flake-utils.lib.eachDefaultSystem (system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs {
inherit system overlays;
};
# Build inputs needed for Rust and the project
buildInputs = with pkgs; [
pkg-config
openssl
ca-certificates
git
];
# Runtime inputs for the dev environment
nativeBuildInputs = with pkgs; [
(rust-bin.stable.latest.default.override {
extensions = [ "rust-src" ];
targets = [ ];
})
cargo-edit
cargo-expand
cargo-watch
];
# Derivation to build the claw binary
clawBinary = pkgs.rustPlatform.buildRustPackage {
pname = "claw";
version = (builtins.fromTOML (builtins.readFile ./rust/Cargo.toml)).workspace.package.version;
src = ./rust;
# Hash of all the dependencies in Cargo.lock
# This needs to be updated when dependencies change
# Run: nix flake update
cargoLock = {
lockFile = ./rust/Cargo.lock;
};
cargoBuildFlags = [ "-p" "rusty-claude-cli" ];
buildInputs = buildInputs ++ (with pkgs; [
openssl
]);
nativeBuildInputs = with pkgs; [
pkg-config
];
meta = with pkgs.lib; {
description = "Claude Code CLI: agent harness with Rust implementation";
homepage = "https://github.com/ultraworkers/claw-code";
license = licenses.mit;
platforms = platforms.unix;
};
};
in
{
# Provide the claw binary as a package
packages.claw = clawBinary;
packages.default = clawBinary;
# Development shell for contributors
devShells.default = pkgs.mkShell {
name = "claw-dev";
inherit buildInputs nativeBuildInputs;
shellHook = ''
echo "Claw Code development environment"
echo "Run 'cargo build -p rusty-claude-cli' to build the claw binary"
echo "Run 'cargo test --workspace' to run tests"
echo "Run 'scripts/fmt.sh' to format code"
'';
# Make sure we have the right Rust toolchain
RUSTUP_TOOLCHAIN = "stable";
};
# Alternative shell with additional development tools
devShells.full = pkgs.mkShell {
name = "claw-dev-full";
inherit buildInputs nativeBuildInputs;
nativeBuildInputs = nativeBuildInputs ++ (with pkgs; [
clippy
rustfmt
mold
lldb
]);
shellHook = ''
echo "Claw Code full development environment (with extra tools)"
echo "Available tools: clippy, rustfmt, mold (linker), lldb (debugger)"
'';
RUSTUP_TOOLCHAIN = "stable";
};
}
);
}
+1 -1
View File
@@ -41,7 +41,7 @@ TUI rule: formatting fns take `&mut impl Write`, never stdout directly. Never mi
Library crates don't carry the `claw-` prefix. Binary crates do (except legacy `rusty-claude-cli`).
Workspace version is `0.1.3`, `publish = false`, MIT license.
Workspace version is `0.1.4`, `publish = false`, MIT license.
No rust-toolchain file, no MSRV. CI pins `dtolnay/rust-toolchain@stable`.
Generated Executable → Regular
+789 -666
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.1.3"
version = "0.1.4"
edition = "2021"
license = "MIT"
publish = false
+192 -2
View File
@@ -13,7 +13,130 @@ pub enum ProviderClient {
OpenAi(OpenAiCompatClient),
}
/// Provider selected explicitly by persisted/runtime configuration.
///
/// This is intentionally separate from `ProviderKind`: DashScope speaks the
/// OpenAI-compatible wire protocol but remains a distinct configured provider.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderConfigKind {
Anthropic,
Xai,
OpenAi,
DashScope,
}
/// Explicit provider configuration supplied by the runtime layer.
///
/// This keeps the API crate independent of runtime configuration types.
/// Credentials are redacted from Debug output.
#[derive(Clone)]
pub struct ProviderConfig {
pub kind: ProviderConfigKind,
pub model: String,
pub api_key: Option<String>,
pub base_url: Option<String>,
}
impl std::fmt::Debug for ProviderConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProviderConfig")
.field("kind", &self.kind)
.field("model", &self.model)
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
.field("base_url", &self.base_url)
.finish()
}
}
fn read_env_non_empty(key: &str) -> Option<String> {
std::env::var(key)
.ok()
.filter(|value| !value.trim().is_empty())
}
impl ProviderClient {
/// Construct a client from an explicit provider configuration.
///
/// The persisted provider kind is authoritative for provider selection,
/// while environment credentials and base URLs retain precedence over
/// persisted values.
pub fn from_config(config: &ProviderConfig) -> Result<Self, ApiError> {
match config.kind {
ProviderConfigKind::Anthropic => {
let api_key = read_env_non_empty("ANTHROPIC_API_KEY")
.or_else(|| config.api_key.clone())
.ok_or_else(|| {
ApiError::missing_credentials(
"Anthropic",
&["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
)
})?;
let mut client = AnthropicClient::new(api_key);
let base_url = read_env_non_empty("ANTHROPIC_BASE_URL")
.or_else(|| config.base_url.clone())
.unwrap_or_else(anthropic::read_base_url);
client = client.with_base_url(base_url);
Ok(Self::Anthropic(client))
}
ProviderConfigKind::Xai => {
let compat = OpenAiCompatConfig::xai();
let api_key =
read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone());
let mut client = match api_key {
Some(api_key) => OpenAiCompatClient::new(api_key, compat),
None => OpenAiCompatClient::from_env(compat)?,
};
let base_url = read_env_non_empty(compat.base_url_env)
.or_else(|| config.base_url.clone())
.unwrap_or_else(|| compat.default_base_url.to_string());
client = client.with_base_url(base_url);
Ok(Self::Xai(client))
}
ProviderConfigKind::OpenAi | ProviderConfigKind::DashScope => {
let compat = match config.kind {
ProviderConfigKind::OpenAi => OpenAiCompatConfig::openai(),
ProviderConfigKind::DashScope => OpenAiCompatConfig::dashscope(),
_ => unreachable!("non-OpenAI provider reached compatibility path"),
};
if config.kind == ProviderConfigKind::OpenAi
&& read_env_non_empty("OLLAMA_HOST").is_some()
&& config.api_key.is_none()
&& config.base_url.is_none()
{
return Ok(Self::OpenAi(
openai_compat::OpenAiCompatClient::from_ollama_env()
.expect("from_ollama_env always returns Some"),
));
}
let api_key =
read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone());
let persisted_base_url = config.base_url.clone();
let mut client = match api_key {
Some(api_key) => OpenAiCompatClient::new(api_key, compat),
None if config.kind == ProviderConfigKind::OpenAi
&& persisted_base_url
.as_deref()
.is_some_and(openai_compat::is_local_openai_compatible_base_url) =>
{
OpenAiCompatClient::new("local-dev-token", compat)
}
None => OpenAiCompatClient::from_env(compat)?,
};
let base_url = read_env_non_empty(compat.base_url_env)
.or(persisted_base_url)
.unwrap_or_else(|| compat.default_base_url.to_string());
client = client.with_base_url(base_url);
Ok(Self::OpenAi(client))
}
}
}
pub fn from_model(model: &str) -> Result<Self, ApiError> {
Self::from_model_with_anthropic_auth(model, None)
}
@@ -34,7 +157,7 @@ impl ProviderClient {
ProviderKind::OpenAi => {
// OLLAMA_HOST takes priority: local Ollama needs no API key
// and ignores DashScope/OpenAI env-based dispatch.
if std::env::var_os("OLLAMA_HOST").is_some() {
if read_env_non_empty("OLLAMA_HOST").is_some() {
Ok(Self::OpenAi(
openai_compat::OpenAiCompatClient::from_ollama_env()
.expect("from_ollama_env always returns Some"),
@@ -155,7 +278,7 @@ pub fn read_xai_base_url() -> String {
mod tests {
use std::sync::{Mutex, OnceLock};
use super::ProviderClient;
use super::{ProviderClient, ProviderConfig, ProviderConfigKind};
use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind};
/// Serializes every test in this module that mutates process-wide
@@ -213,6 +336,73 @@ mod tests {
}
}
#[test]
fn persisted_provider_kind_overrides_model_provider_detection() {
let _lock = env_lock();
let _anthropic = EnvVarGuard::set("ANTHROPIC_API_KEY", Some("test-anthropic-key"));
let _xai = EnvVarGuard::set("XAI_API_KEY", Some("test-xai-key"));
let config = ProviderConfig {
kind: ProviderConfigKind::Xai,
model: "claude-sonnet-4-6".to_string(),
api_key: None,
base_url: None,
};
match ProviderClient::from_config(&config).expect("explicit xAI config should succeed") {
ProviderClient::Xai(client) => {
assert!(client.base_url().contains("api.x.ai"));
}
other => panic!("Expected explicit xAI provider, got: {other:?}"),
}
}
#[test]
fn persisted_dashscope_kind_overrides_qwen_model_routing() {
let _lock = env_lock();
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key"));
let config = ProviderConfig {
kind: ProviderConfigKind::DashScope,
model: "gpt-5".to_string(),
api_key: None,
base_url: None,
};
match ProviderClient::from_config(&config)
.expect("explicit DashScope config should succeed")
{
ProviderClient::OpenAi(client) => {
assert!(client.base_url().contains("dashscope.aliyuncs.com"));
}
other => panic!("Expected explicit DashScope provider, got: {other:?}"),
}
}
#[test]
fn persisted_openai_kind_does_not_become_dashscope_from_model() {
let _lock = env_lock();
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key"));
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
let _openai_base_url = EnvVarGuard::set("OPENAI_BASE_URL", None);
let config = ProviderConfig {
kind: ProviderConfigKind::OpenAi,
model: "qwen-plus".to_string(),
api_key: None,
base_url: None,
};
match ProviderClient::from_config(&config).expect("explicit OpenAI config should succeed") {
ProviderClient::OpenAi(client) => {
assert!(client.base_url().contains("api.openai.com"));
assert!(!client.base_url().contains("dashscope.aliyuncs.com"));
}
other => panic!("Expected explicit OpenAI provider, got: {other:?}"),
}
}
#[test]
fn dashscope_model_uses_dashscope_config_not_openai() {
// Regression: qwen-plus was being routed to OpenAiCompatConfig::openai()
+43
View File
@@ -180,6 +180,49 @@ impl ApiError {
}
}
/// HTTP status code for an `Api` error, if any. Used by callers (e.g. the
/// sub-agent provider chain) to decide whether to advance to the next
/// configured model — a 404 "model not found" on the primary should fall
/// through to the next fallback rather than killing the whole chain.
#[must_use]
pub fn status_code(&self) -> Option<reqwest::StatusCode> {
match self {
Self::Api { status, .. } => Some(*status),
Self::RetriesExhausted { last_error, .. } => last_error.status_code(),
Self::MissingCredentials { .. }
| Self::ContextWindowExceeded { .. }
| Self::ExpiredOAuthToken
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
| Self::Http(_)
| Self::Io(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. }
| Self::RequestBodySizeExceeded { .. } => None,
}
}
/// Response body (best-effort) for an `Api` error, if any.
#[must_use]
pub fn response_body(&self) -> Option<&str> {
match self {
Self::Api { body, .. } => Some(body.as_str()),
Self::RetriesExhausted { last_error, .. } => last_error.response_body(),
Self::MissingCredentials { .. }
| Self::ContextWindowExceeded { .. }
| Self::ExpiredOAuthToken
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
| Self::Http(_)
| Self::Io(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. }
| Self::RequestBodySizeExceeded { .. } => None,
}
}
#[must_use]
pub fn safe_failure_class(&self) -> &'static str {
match self {
+6 -3
View File
@@ -1,4 +1,5 @@
mod client;
mod error;
mod http_client;
mod prompt_cache;
@@ -8,7 +9,8 @@ mod types;
pub use client::{
oauth_token_is_expired, read_base_url, read_xai_base_url, resolve_saved_oauth_token,
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient, ProviderConfig,
ProviderConfigKind,
};
pub use error::ApiError;
pub use http_client::{
@@ -28,8 +30,9 @@ pub use providers::openai_compat::{
};
pub use providers::{
detect_provider_kind, max_tokens_for_model, max_tokens_for_model_with_override,
model_family_identity_for, model_family_identity_for_kind, provider_diagnostics_for_model,
resolve_model_alias, ProviderDiagnostics, ProviderKind,
model_family_identity_for, model_family_identity_for_kind, model_token_limit,
provider_diagnostics_for_model, resolve_model_alias, ModelTokenLimit, ProviderDiagnostics,
ProviderKind,
};
pub use sse::{parse_frame, SseParser};
pub use types::{
+9 -5
View File
@@ -683,13 +683,15 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
max_output_tokens: 8_192,
context_window_tokens: 131_072,
}),
"qwen-max" => Some(ModelTokenLimit {
max_output_tokens: 8_192,
// Qwen models via DashScope / OpenAI-compat
"qwen3.6-35b-fast" | "qwen3-235b-a22b" | "qwen-max" | "qwen-plus" | "qwen-turbo"
| "qwen-qwq" => Some(ModelTokenLimit {
max_output_tokens: 16_384,
context_window_tokens: 131_072,
}),
"qwen-plus" => Some(ModelTokenLimit {
max_output_tokens: 8_192,
context_window_tokens: 131_072,
"glm-5.1-fast" => Some(ModelTokenLimit {
max_output_tokens: 16_384,
context_window_tokens: 200_000,
}),
// Groq compound systems are exposed through an OpenAI-compatible API
// but cap completions at 8,192 output tokens. Without this metadata
@@ -699,6 +701,8 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
max_output_tokens: 8_192,
context_window_tokens: 131_072,
}),
// Unknown models return None so preflight skips the context-window
// check and lets the API enforce its own limits.
_ => None,
}
}
+1 -1
View File
@@ -82,7 +82,7 @@ async fn send_message_posts_json_and_parses_response() {
);
assert_eq!(
request.headers.get("user-agent").map(String::as_str),
Some("claude-code/0.1.3")
Some("claude-code/0.1.4")
);
assert_eq!(
request.headers.get("anthropic-beta").map(String::as_str),
+2 -2
View File
@@ -166,8 +166,8 @@ pub fn blob_to_f32_vec(blob: &[u8], dim: usize) -> Option<Vec<f32>> {
return None;
}
let mut v = Vec::with_capacity(dim);
for chunk in blob.chunks_exact(4) {
v.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
for chunk in blob.as_chunks::<4>().0 {
v.push(f32::from_le_bytes(*chunk));
}
Some(v)
}
+1 -1
View File
@@ -66,7 +66,7 @@ async fn flush_path_batch(
#[cfg(feature = "qdrant-index")]
let mut qdrant_points: Vec<ChunkPoint> = Vec::with_capacity(batch.len());
for ((ord, t), vec) in batch.drain(..).zip(vecs.into_iter()) {
for ((ord, t), vec) in batch.drain(..).zip(vecs) {
let dim = vec.len();
let cid = insert_chunk(conn, path, ord, &t)?;
insert_embedding(conn, cid, dim, &vec)?;
+46 -2
View File
@@ -808,7 +808,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
name: "team",
aliases: &[],
summary: "Manage agent teams",
argument_hint: Some("[list|create|delete]"),
argument_hint: Some("[on|off|status]"),
resume_supported: true,
},
SlashCommandSpec {
@@ -1131,6 +1131,9 @@ pub enum SlashCommand {
SecurityReview,
Keybindings,
PrivacySettings,
Workspace {
path: Option<String>,
},
Plan {
mode: Option<String>,
},
@@ -1188,10 +1191,14 @@ pub enum SlashCommand {
History {
count: Option<String>,
},
Unknown(String),
Lsp {
action: Option<String>,
target: Option<String>,
},
Team {
action: Option<String>,
},
Unknown(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -1272,6 +1279,7 @@ impl SlashCommand {
Self::SecurityReview => "/security-review",
Self::Keybindings => "/keybindings",
Self::PrivacySettings => "/privacy-settings",
Self::Workspace { .. } => "/workspace",
Self::Plan { .. } => "/plan",
Self::Review { .. } => "/review",
Self::Tasks { .. } => "/tasks",
@@ -1405,6 +1413,9 @@ pub fn validate_slash_command_input(
validate_no_args(command, &args)?;
SlashCommand::Setup
}
"workspace" | "cwd" => SlashCommand::Workspace {
path: optional_single_arg(command, &args, "[path]")?,
},
"login" | "logout" => {
return Err(command_error(
"This auth flow was removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.",
@@ -1509,6 +1520,9 @@ pub fn validate_slash_command_input(
"history" => SlashCommand::History {
count: optional_single_arg(command, &args, "[count]")?,
},
"team" => SlashCommand::Team {
action: optional_single_arg(command, &args, "[list|create|delete]")?,
},
other => SlashCommand::Unknown(other.to_string()),
}))
}
@@ -5342,6 +5356,7 @@ pub fn handle_slash_command(
| SlashCommand::Cost
| SlashCommand::Resume { .. }
| SlashCommand::Config { .. }
| SlashCommand::Workspace { .. }
| SlashCommand::Mcp { .. }
| SlashCommand::Memory
| SlashCommand::Init
@@ -5393,6 +5408,7 @@ pub fn handle_slash_command(
| SlashCommand::OutputStyle { .. }
| SlashCommand::AddDir { .. }
| SlashCommand::History { .. }
| SlashCommand::Lsp { .. }
| SlashCommand::Team { .. }
| SlashCommand::Setup
| SlashCommand::Unknown(_) => None,
@@ -5525,6 +5541,16 @@ mod tests {
#[test]
fn parses_supported_slash_commands() {
assert_eq!(SlashCommand::parse("/help"), Ok(Some(SlashCommand::Help)));
assert_eq!(
SlashCommand::parse("/team"),
Ok(Some(SlashCommand::Team { action: None }))
);
assert_eq!(
SlashCommand::parse("/team on"),
Ok(Some(SlashCommand::Team {
action: Some("on".to_string())
}))
);
assert_eq!(
SlashCommand::parse(" /status "),
Ok(Some(SlashCommand::Status))
@@ -6119,6 +6145,24 @@ mod tests {
assert_eq!(suggest_slash_commands("zzz", 3), Vec::<String>::new());
}
#[test]
fn parses_workspace_slash_command_and_alias() {
let workspace = validate_slash_command_input("/workspace")
.expect("workspace should parse")
.expect("workspace should be a slash command");
assert_eq!(workspace, SlashCommand::Workspace { path: None });
let cwd = validate_slash_command_input("/cwd src")
.expect("cwd alias should parse")
.expect("cwd alias should be a slash command");
assert_eq!(
cwd,
SlashCommand::Workspace {
path: Some("src".to_string()),
}
);
}
#[test]
fn compacts_sessions_via_slash_command() {
let mut session = Session::new();
+1 -1
View File
@@ -826,7 +826,7 @@ mod tests {
"message[{}] is a ToolResult but message[{}] has no ToolUse: {:?}",
i,
i - 1,
&messages[i - 1].blocks
messages[i - 1].blocks
);
}
}
+336
View File
@@ -164,6 +164,20 @@ pub struct RuntimeFeatureConfig {
rules_import: RulesImportConfig,
provider: RuntimeProviderConfig,
auto_memory_enabled: bool,
/// Model override used when spawning sub-agents via the Agent tool.
/// Read from `subagentModel` (or `subagent_model`) in settings; falls
/// back to the default model when unset.
subagent_model: Option<String>,
/// Cap on concurrently executing parallel-safe tool calls in one batch.
/// Read from `maxParallelToolCalls` (or `max_parallel_tool_calls`) in
/// settings; the CLI falls back to `CLAWD_PARALLEL_TOOL_CALLS` and then
/// to its built-in default when unset.
max_parallel_tool_calls: Option<usize>,
/// Default cap on concurrently *running* sub-agents per team. Read from
/// `maxConcurrentAgents` (or `max_concurrent_agents`) in settings; a
/// per-team `maxConcurrentAgents` on TeamCreate overrides it. `None`
/// means no gate (current pre-fix behavior).
max_concurrent_agents: Option<usize>,
}
/// Controls which external AI coding framework rules are imported into the system prompt.
@@ -205,6 +219,11 @@ pub struct RuntimeProviderConfig {
}
impl RuntimeProviderConfig {
#[must_use]
pub fn is_configured(&self) -> bool {
self.kind.is_some()
}
#[must_use]
pub fn kind(&self) -> Option<&str> {
self.kind.as_deref()
@@ -832,6 +851,15 @@ fn build_runtime_config(
rules_import: parse_optional_rules_import(&merged_value)?,
provider: parse_optional_provider_config(&merged_value)?,
auto_memory_enabled: parse_auto_memory_enabled(&merged_value),
subagent_model: parse_optional_subagent_model(&merged_value),
max_parallel_tool_calls: parse_optional_positive_usize(
&merged_value,
&["maxParallelToolCalls", "max_parallel_tool_calls"],
)?,
max_concurrent_agents: parse_optional_positive_usize(
&merged_value,
&["maxConcurrentAgents", "max_concurrent_agents"],
)?,
};
Ok(RuntimeConfig {
@@ -914,6 +942,29 @@ impl RuntimeConfig {
self.feature_config.model.as_deref()
}
/// Model override used when spawning sub-agents via the Agent tool.
/// Read from `subagentModel` in settings; `None` means "use default model".
#[must_use]
pub fn subagent_model(&self) -> Option<&str> {
self.feature_config.subagent_model.as_deref()
}
/// Cap on concurrently executing parallel-safe tool calls in one batch.
/// Read from `maxParallelToolCalls` in settings; `None` means the CLI
/// falls back to `CLAWD_PARALLEL_TOOL_CALLS` and then its built-in default.
#[must_use]
pub fn max_parallel_tool_calls(&self) -> Option<usize> {
self.feature_config.max_parallel_tool_calls
}
/// Default cap on concurrently running sub-agents per team. Read from
/// `maxConcurrentAgents` in settings; `None` means the built-in default
/// applies. A per-team `maxConcurrentAgents` on TeamCreate overrides it.
#[must_use]
pub fn max_concurrent_agents(&self) -> Option<usize> {
self.feature_config.max_concurrent_agents
}
#[must_use]
pub fn aliases(&self) -> &BTreeMap<String, String> {
&self.feature_config.aliases
@@ -974,6 +1025,20 @@ impl RuntimeFeatureConfig {
&self.provider
}
/// Cap on concurrently executing parallel-safe tool calls in one batch.
/// `None` means fall back to env/default.
#[must_use]
pub fn max_parallel_tool_calls(&self) -> Option<usize> {
self.max_parallel_tool_calls
}
/// Default per-team cap on concurrently running sub-agents; `None`
/// means unlimited (pre-fix behavior).
#[must_use]
pub fn max_concurrent_agents(&self) -> Option<usize> {
self.max_concurrent_agents
}
#[must_use]
pub fn with_hooks(mut self, hooks: RuntimeHookConfig) -> Self {
self.hooks = hooks;
@@ -1756,6 +1821,51 @@ fn parse_optional_model(root: &JsonValue) -> Option<String> {
.map(ToOwned::to_owned)
}
/// Reads `subagentModel` (or the snake_case `subagent_model` alias) from
/// merged settings. Returns `None` when absent or blank so the Agent tool
/// falls back to the default model.
fn parse_optional_subagent_model(root: &JsonValue) -> Option<String> {
root.as_object()
.and_then(|object| {
object
.get("subagentModel")
.or_else(|| object.get("subagent_model"))
})
.and_then(JsonValue::as_str)
.filter(|s| !s.trim().is_empty())
.map(|s| s.trim().to_string())
}
/// Reads the first present key from `keys` as a positive integer.
/// Absent, null, or non-positive values parse to `None` so callers fall
/// back to their own defaults rather than silently disabling a cap via a
/// `0` typo. Accepts JSON numbers or numeric strings.
fn parse_optional_positive_usize(
root: &JsonValue,
keys: &[&str],
) -> Result<Option<usize>, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(None);
};
for key in keys {
let Some(value) = object.get(*key) else {
continue;
};
if matches!(value, JsonValue::Null) {
continue;
}
let parsed = value
.as_i64()
.or_else(|| value.as_str().and_then(|s| s.trim().parse::<i64>().ok()))
.ok_or_else(|| ConfigError::Parse(format!("{key} must be a positive integer")))?;
if parsed <= 0 {
return Ok(None);
}
return Ok(usize::try_from(parsed).ok());
}
Ok(None)
}
fn parse_optional_aliases(root: &JsonValue) -> Result<BTreeMap<String, String>, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(BTreeMap::new());
@@ -2627,6 +2737,49 @@ fn deep_merge_objects(
}
}
/// Read the provider config saved by `/setup` and inject its credentials
/// into the environment so `ProviderClient::from_model()` can find them via
/// the env-var-based provider dispatch. Only sets vars that aren't already
/// present (preserves explicit env). Idempotent.
///
/// Called by the main CLI at startup and by sub-agent threads in the tools
/// crate so that custom/ exotic providers work for spawned agents too.
pub fn inject_config_as_env_fallbacks() {
let cwd = std::env::current_dir().unwrap_or_default();
let Ok(config) = ConfigLoader::default_for(&cwd).load() else {
return;
};
let provider = config.provider();
// Map provider kind to the expected env var names
let (api_key_env, base_url_env) = match provider.kind().unwrap_or("anthropic") {
"anthropic" => ("ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"),
"xai" => ("XAI_API_KEY", "XAI_BASE_URL"),
"openai" => ("OPENAI_API_KEY", "OPENAI_BASE_URL"),
"dashscope" => ("DASHSCOPE_API_KEY", "DASHSCOPE_BASE_URL"),
"custom-openai" => ("CLAWCUSTOMOPENAI_API_KEY", "CLAWCUSTOMOPENAI_BASE_URL"),
_ => return, // unknown provider kind — don't inject
};
// Only set env vars that aren't already set (preserve user's explicit env)
if let Some(api_key) = provider.api_key() {
if std::env::var(api_key_env).is_err() {
std::env::set_var(api_key_env, api_key);
}
}
if let Some(base_url) = provider.base_url() {
if std::env::var(base_url_env).is_err() {
std::env::set_var(base_url_env, base_url);
}
}
// Also inject the saved model so resolve_model_alias sees it
if let Some(model) = provider.model() {
if std::env::var("CLAWD_PROVIDER_MODEL").is_err() {
std::env::set_var("CLAWD_PROVIDER_MODEL", model);
}
}
}
#[cfg(test)]
mod tests {
use super::{
@@ -2658,6 +2811,42 @@ mod tests {
std::env::temp_dir().join(format!("runtime-config-{pid}-{nanos}-{seq}"))
}
#[test]
fn loads_persisted_provider_configuration_from_settings() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
r#"{
"provider": {
"kind": "dashscope",
"apiKey": "persisted-test-key",
"baseUrl": "https://dashscope.example/v1",
"model": "qwen-plus"
}
}"#,
)
.expect("write settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("provider configuration should load");
let provider = loaded.provider();
assert_eq!(provider.kind(), Some("dashscope"));
assert_eq!(provider.api_key(), Some("persisted-test-key"));
assert_eq!(provider.base_url(), Some("https://dashscope.example/v1"));
assert_eq!(provider.model(), Some("qwen-plus"));
assert!(provider.is_configured());
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn rejects_non_object_settings_files() {
let root = temp_dir();
@@ -3988,4 +4177,151 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn subagent_model_reads_camel_case_config_key() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
"{\n \"subagentModel\": \"custom/openclaw\"\n}\n",
)
.expect("write settings");
let config = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
assert_eq!(
config.subagent_model(),
Some("custom/openclaw"),
"subagentModel camelCase key should be read end-to-end"
);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn subagent_model_reads_snake_case_config_key() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
"{\n \"subagent_model\": \"qwen3.6-35b-fast\"\n}\n",
)
.expect("write settings");
let config = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
assert_eq!(
config.subagent_model(),
Some("qwen3.6-35b-fast"),
"subagent_model snake_case key should be read end-to-end"
);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn subagent_model_blank_value_returns_none() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
"{\n \"subagentModel\": \" \"\n}\n",
)
.expect("write settings");
let config = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
assert_eq!(
config.subagent_model(),
None,
"blank subagentModel should fall back to None"
);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn parallel_and_agent_caps_read_camel_and_snake_case_keys() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
"{\n \"maxParallelToolCalls\": 4,\n \"max_concurrent_agents\": \"6\"\n}\n",
)
.expect("write settings");
let config = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
assert_eq!(
config.max_parallel_tool_calls(),
Some(4),
"maxParallelToolCalls camelCase key should parse"
);
assert_eq!(
config.max_concurrent_agents(),
Some(6),
"max_concurrent_agents snake_case key should parse numeric strings"
);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn parallel_and_agent_caps_reject_nonpositive_and_malformed() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
"{\n \"maxParallelToolCalls\": 0\n}\n",
)
.expect("write settings");
let config = ConfigLoader::new(&cwd, &home)
.load()
.expect("zero cap should parse as unset");
assert_eq!(config.max_parallel_tool_calls(), None);
assert_eq!(config.max_concurrent_agents(), None);
fs::remove_dir_all(&root).expect("cleanup temp dir");
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
"{\n \"maxParallelToolCalls\": \"lots\"\n}\n",
)
.expect("write settings");
let error = ConfigLoader::new(&cwd, &home)
.load()
.expect_err("non-numeric cap should be a config error");
assert!(
error.to_string().contains("maxParallelToolCalls"),
"error should name the offending key: {error}"
);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
}
+203 -47
View File
@@ -61,6 +61,40 @@ pub trait ApiClient {
/// Trait implemented by tool dispatchers that execute model-requested tools.
pub trait ToolExecutor {
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError>;
/// Execute a batch of tool calls, potentially in parallel.
/// Returns results in the same order as the input calls.
/// The default implementation executes sequentially via `execute`.
/// Override this to provide parallel execution for read-only tools.
fn execute_batch(&mut self, calls: Vec<ToolCall>) -> Vec<ToolResult> {
calls
.into_iter()
.map(|call| {
let result = self.execute(&call.tool_name, &call.input);
ToolResult {
tool_use_id: call.tool_use_id,
tool_name: call.tool_name,
result,
}
})
.collect()
}
}
/// A single tool call to execute.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolCall {
pub tool_use_id: String,
pub tool_name: String,
pub input: String,
}
/// The result of executing a tool call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolResult {
pub tool_use_id: String,
pub tool_name: String,
pub result: Result<String, ToolError>,
}
/// Error returned when a tool invocation fails locally.
@@ -86,6 +120,22 @@ impl Display for ToolError {
impl std::error::Error for ToolError {}
/// Callback trait for reporting tool execution progress during a turn.
/// Implementations can post progress to a team inbox, log to stderr, etc.
/// Called after each tool call completes (success or failure).
pub trait TurnProgressReporter: Send + Sync {
/// Called after a tool execution completes.
/// `iteration` is 1-based index of the tool call within this turn.
fn on_tool_result(
&self,
iteration: usize,
max_iterations: usize,
tool_name: &str,
input: &str,
result: Result<&str, &str>,
);
}
/// Error returned when a conversation turn cannot be completed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeError {
@@ -140,6 +190,7 @@ pub struct ConversationRuntime<C, T> {
hook_abort_signal: HookAbortSignal,
hook_progress_reporter: Option<Box<dyn HookProgressReporter>>,
session_tracer: Option<SessionTracer>,
turn_progress_reporter: Option<Box<dyn TurnProgressReporter>>,
}
impl<C, T> ConversationRuntime<C, T>
@@ -194,6 +245,7 @@ where
hook_abort_signal: HookAbortSignal::default(),
hook_progress_reporter: None,
session_tracer: None,
turn_progress_reporter: None,
}
}
@@ -237,6 +289,11 @@ where
self
}
pub fn with_turn_progress_reporter(mut self, reporter: Box<dyn TurnProgressReporter>) -> Self {
self.turn_progress_reporter = Some(reporter);
self
}
fn run_pre_tool_use_hook(&mut self, tool_name: &str, input: &str) -> HookRunResult {
if let Some(reporter) = self.hook_progress_reporter.as_mut() {
self.hook_runner.run_pre_tool_use_with_context(
@@ -420,11 +477,24 @@ where
break;
}
for (tool_use_id, tool_name, input) in pending_tool_uses {
let pre_hook_result = self.run_pre_tool_use_hook(&tool_name, &input);
// Phase 1: Pre-hooks and permission checks (sequential).
// Hooks may mutate state and must run in order.
struct PendingTool {
tool_use_id: String,
tool_name: String,
effective_input: String,
pre_hook_messages: Vec<String>,
allowed: bool,
deny_reason: Option<String>,
}
let mut pending = Vec::with_capacity(pending_tool_uses.len());
for (tool_use_id, tool_name, input) in &pending_tool_uses {
let pre_hook_result = self.run_pre_tool_use_hook(tool_name, input);
let effective_input = pre_hook_result
.updated_input()
.map_or_else(|| input.clone(), ToOwned::to_owned);
let pre_hook_messages = pre_hook_result.messages().to_vec();
let permission_context = PermissionContext::new(
pre_hook_result.permission_override(),
pre_hook_result.permission_reason().map(ToOwned::to_owned),
@@ -453,71 +523,157 @@ where
}
} else if let Some(prompt) = prompter.as_mut() {
self.permission_policy.authorize_with_context(
&tool_name,
tool_name,
&effective_input,
&permission_context,
Some(*prompt),
)
} else {
self.permission_policy.authorize_with_context(
&tool_name,
tool_name,
&effective_input,
&permission_context,
None,
)
};
let result_message = match permission_outcome {
match permission_outcome {
PermissionOutcome::Allow => {
self.record_tool_started(iterations, &tool_name);
let (mut output, mut is_error) =
match self.tool_executor.execute(&tool_name, &effective_input) {
Ok(output) => (output, false),
Err(error) => (error.to_string(), true),
};
output = merge_hook_feedback(pre_hook_result.messages(), output, false);
let post_hook_result = if is_error {
self.run_post_tool_use_failure_hook(
&tool_name,
&effective_input,
&output,
)
} else {
self.run_post_tool_use_hook(
&tool_name,
&effective_input,
&output,
false,
)
};
if post_hook_result.is_denied()
|| post_hook_result.is_failed()
|| post_hook_result.is_cancelled()
{
is_error = true;
}
output = merge_hook_feedback(
post_hook_result.messages(),
output,
post_hook_result.is_denied()
|| post_hook_result.is_failed()
|| post_hook_result.is_cancelled(),
);
ConversationMessage::tool_result(tool_use_id, tool_name, output, is_error)
pending.push(PendingTool {
tool_use_id: tool_use_id.clone(),
tool_name: tool_name.clone(),
effective_input,
pre_hook_messages,
allowed: true,
deny_reason: None,
});
}
PermissionOutcome::Deny { reason } => ConversationMessage::tool_result(
tool_use_id,
tool_name,
merge_hook_feedback(pre_hook_result.messages(), reason, true),
PermissionOutcome::Deny { reason } => {
pending.push(PendingTool {
tool_use_id: tool_use_id.clone(),
tool_name: tool_name.clone(),
effective_input: String::new(),
pre_hook_messages,
allowed: false,
deny_reason: Some(reason),
});
}
}
}
// Phase 2: Execute allowed tools (batch, may run in parallel).
let allowed_calls: Vec<ToolCall> = pending
.iter()
.filter(|p| p.allowed)
.map(|p| {
self.record_tool_started(iterations, &p.tool_name);
ToolCall {
tool_use_id: p.tool_use_id.clone(),
tool_name: p.tool_name.clone(),
input: p.effective_input.clone(),
}
})
.collect();
let batch_results = self.tool_executor.execute_batch(allowed_calls);
let mut batch_index = 0;
// Phase 3: Post-hooks and session updates (sequential, original order).
for p in &pending {
// Capture progress data for the reporter.
let (
progress_tool_name,
progress_input,
progress_output,
progress_is_error,
result_message,
) = if p.allowed {
let batch_result = &batch_results[batch_index];
batch_index += 1;
let (mut output, mut is_error) = match &batch_result.result {
Ok(output) => (output.clone(), false),
Err(error) => (error.to_string(), true),
};
output = merge_hook_feedback(&p.pre_hook_messages, output, false);
let post_hook_result = if is_error {
self.run_post_tool_use_failure_hook(
&p.tool_name,
&p.effective_input,
&output,
)
} else {
self.run_post_tool_use_hook(
&p.tool_name,
&p.effective_input,
&output,
false,
)
};
if post_hook_result.is_denied()
|| post_hook_result.is_failed()
|| post_hook_result.is_cancelled()
{
is_error = true;
}
output = merge_hook_feedback(
post_hook_result.messages(),
output,
post_hook_result.is_denied()
|| post_hook_result.is_failed()
|| post_hook_result.is_cancelled(),
);
let progress_output = output.clone();
let result_message = ConversationMessage::tool_result(
p.tool_use_id.clone(),
p.tool_name.clone(),
output,
is_error,
);
(
p.tool_name.clone(),
p.effective_input.clone(),
progress_output,
is_error,
result_message,
)
} else {
let denied_output = merge_hook_feedback(
&p.pre_hook_messages,
p.deny_reason.clone().unwrap_or_default(),
true,
),
);
let result_message = ConversationMessage::tool_result(
p.tool_use_id.clone(),
p.tool_name.clone(),
denied_output,
true,
);
(
p.tool_name.clone(),
String::new(),
String::new(),
true,
result_message,
)
};
self.session
.push_message(result_message.clone())
.map_err(|error| RuntimeError::new(error.to_string()))?;
self.record_tool_finished(iterations, &result_message);
if let Some(ref reporter) = self.turn_progress_reporter {
let report_result = if progress_is_error {
Err(progress_output.as_str())
} else {
Ok(progress_output.as_str())
};
reporter.on_tool_result(
iterations,
self.max_iterations,
&progress_tool_name,
&progress_input,
report_result,
);
}
tool_results.push(result_message);
}
}
+511 -7
View File
@@ -10,6 +10,8 @@ use regex::RegexBuilder;
use serde::{Deserialize, Serialize};
use walkdir::{DirEntry, WalkDir};
use crate::line_range_selector::{self, LineRangeSelector};
/// Maximum file size that can be read (10 MB).
const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
@@ -88,6 +90,9 @@ pub struct ReadFileOutput {
#[serde(rename = "type")]
pub kind: String,
pub file: TextFilePayload,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "truncationFooter")]
pub truncation_footer: Option<String>,
}
/// Structured patch hunk emitted by write and edit operations.
@@ -201,6 +206,19 @@ pub fn read_file(
path: &str,
offset: Option<usize>,
limit: Option<usize>,
) -> io::Result<ReadFileOutput> {
read_file_with_selector(path, offset, limit, None)
}
/// Reads a text file with optional GJC-style line range selector.
///
/// The selector takes precedence over offset/limit if provided.
/// Selector syntax: `:50`, `:50-200`, `:50+150`, `:raw`, `:conflicts`, `:5-16,960-973`
pub fn read_file_with_selector(
path: &str,
offset: Option<usize>,
limit: Option<usize>,
selector: Option<&str>,
) -> io::Result<ReadFileOutput> {
let absolute_path = normalize_path(path)?;
@@ -227,11 +245,56 @@ pub fn read_file(
let content = fs::read_to_string(&absolute_path)?;
let lines: Vec<&str> = content.lines().collect();
let start_index = offset.unwrap_or(0).min(lines.len());
let end_index = limit.map_or(lines.len(), |limit| {
start_index.saturating_add(limit).min(lines.len())
});
let selected = lines[start_index..end_index].join("\n");
let total_lines = lines.len();
// Special handling for conflicts selector
if selector == Some("conflicts") {
let conflicts = extract_merge_conflicts(&lines);
let (selected, num_lines) = if conflicts.is_empty() {
(format!("No unresolved git merge conflicts in {}.", path), 1)
} else {
let display_path = absolute_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path);
let summary = format_conflict_summary(&conflicts, display_path);
let summary_lines = summary.lines().count();
(summary, summary_lines)
};
return Ok(ReadFileOutput {
kind: String::from("text"),
file: TextFilePayload {
file_path: absolute_path.to_string_lossy().into_owned(),
content: selected,
num_lines,
start_line: 1,
total_lines,
},
truncation_footer: None,
});
}
// Determine which lines to select
let (start_index, end_index, truncation_footer) = if let Some(sel) = selector {
// Parse and apply selector
let parsed = line_range_selector::parse_selector(sel)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?;
apply_line_selector(&lines, parsed, path)
} else {
// Use offset/limit for backward compatibility
let start = offset.unwrap_or(0).min(lines.len());
let end = limit.map_or(lines.len(), |lim| {
start.saturating_add(lim).min(lines.len())
});
(start, end, None)
};
let selected = if start_index < end_index {
lines[start_index..end_index].join("\n")
} else {
String::new()
};
Ok(ReadFileOutput {
kind: String::from("text"),
@@ -240,11 +303,255 @@ pub fn read_file(
content: selected,
num_lines: end_index.saturating_sub(start_index),
start_line: start_index.saturating_add(1),
total_lines: lines.len(),
total_lines,
},
truncation_footer,
})
}
/// Represents a merge conflict block in a file.
#[derive(Debug, Clone, PartialEq, Eq)]
struct ConflictBlock {
start_line: usize, // 1-based line number where <<<<<<< starts
separator_line: usize, // 1-based line number where ======= is
end_line: usize, // 1-based line number where >>>>>>> is
base_line: Option<usize>, // 1-based line number where ||||||| is (for 3-way)
ours_label: Option<String>,
base_label: Option<String>,
theirs_label: Option<String>,
}
/// Scans lines for unresolved git merge conflict blocks.
fn extract_merge_conflicts(lines: &[&str]) -> Vec<ConflictBlock> {
let mut blocks = Vec::new();
let mut i = 0;
const OURS_PREFIX: &str = "<<<<<<<";
const BASE_PREFIX: &str = "|||||||";
const SEPARATOR: &str = "=======";
const THEIRS_PREFIX: &str = ">>>>>>>";
while i < lines.len() {
let line = lines[i];
// Check for start of conflict block
if let Some(label) = extract_marker_label(line, OURS_PREFIX) {
let ours_label = if label.is_empty() {
None
} else {
Some(label.to_string())
};
let start_line = i + 1; // Convert to 1-based
let mut separator_line = None;
let mut base_line = None;
let mut base_label = None;
let mut end_line = None;
let mut theirs_label = None;
let mut j = i + 1;
// Look for separator, optional base marker, and end marker
while j < lines.len() {
let current = lines[j];
// Check for base marker (3-way)
if let Some(label) = extract_marker_label(current, BASE_PREFIX) {
if base_line.is_none() {
base_line = Some(j + 1);
base_label = if label.is_empty() {
None
} else {
Some(label.to_string())
};
j += 1;
continue;
}
}
// Check for separator
if current.trim() == SEPARATOR {
if separator_line.is_none() {
separator_line = Some(j + 1);
j += 1;
continue;
}
}
// Check for end marker
if let Some(label) = extract_marker_label(current, THEIRS_PREFIX) {
if separator_line.is_some() {
end_line = Some(j + 1);
theirs_label = if label.is_empty() {
None
} else {
Some(label.to_string())
};
j += 1;
break;
}
}
j += 1;
}
// Only add if we found both separator and end marker
if let (Some(sep_line), Some(end)) = (separator_line, end_line) {
blocks.push(ConflictBlock {
start_line,
separator_line: sep_line,
end_line: end,
base_line,
ours_label,
base_label,
theirs_label,
});
i = j;
continue;
}
}
i += 1;
}
blocks
}
/// Extract the optional label from a marker line.
/// Returns the label text (stripped of leading/trailing spaces) if the line matches the prefix,
/// or None otherwise.
fn extract_marker_label<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
if line.starts_with(prefix) {
let rest = &line[prefix.len()..];
// Accept either just the prefix or prefix + single space + label
if rest.is_empty() {
Some("")
} else if rest.starts_with(' ') {
Some(rest[1..].trim())
} else {
None
}
} else {
None
}
}
/// Format conflict blocks as a summary index, like GJC's implementation.
fn format_conflict_summary(conflicts: &[ConflictBlock], display_path: &str) -> String {
let mut output = Vec::new();
let total = conflicts.len();
let word = if total == 1 { "conflict" } else { "conflicts" };
output.push(format!(
"⚠ {} unresolved {} in {}",
total, word, display_path
));
// Extract labels for header
let ours_label = conflicts.iter().find_map(|c| c.ours_label.as_ref());
let theirs_label = conflicts.iter().find_map(|c| c.theirs_label.as_ref());
let base_label = conflicts.iter().find_map(|c| c.base_label.as_ref());
let any_base = conflicts.iter().any(|c| c.base_line.is_some());
if let Some(label) = ours_label {
output.push(format!("- ours = {}", label));
}
if let Some(label) = theirs_label {
output.push(format!("- theirs = {}", label));
}
if any_base {
let base_name = base_label
.map(|l| l.to_string())
.unwrap_or_else(|| "(no label)".to_string());
output.push(format!("- base = {}", base_name));
}
output.push(
"NOTICE: Bulk-resolve with `write({ path: \"conflict://*\", content })`, or address a single block with `write({ path: \"conflict://<N>\", content })`. Inspect a block by reading `conflict://<N>` (add `/ours` / `/theirs` / `/base` for a single side).".to_string()
);
output.push(
"`content` shorthand: `@ours` / `@theirs` / `@base` / `@both` lines expand to the recorded sections; `@both` = ours-then-theirs. Non-token lines pass through verbatim.".to_string()
);
output.push(String::new());
// Format each conflict as a one-liner index
let id_width = total.to_string().len();
for (idx, conflict) in conflicts.iter().enumerate() {
let id = idx + 1;
let range = if conflict.start_line == conflict.end_line {
format!("L{}", conflict.start_line)
} else {
format!("L{}-{}", conflict.start_line, conflict.end_line)
};
let kind = if conflict.base_line.is_some() {
" (3-way)"
} else {
""
};
output.push(format!(
"#{:>width$} {}{}",
id,
range,
kind,
width = id_width
));
}
output.join("\n")
}
/// Apply a parsed line selector to file lines and return the selected range.
///
/// Returns (start_index, end_index, truncation_footer).
fn apply_line_selector(
lines: &[&str],
selector: LineRangeSelector,
_file_path: &str,
) -> (usize, usize, Option<String>) {
match selector {
LineRangeSelector::Raw => {
// Return entire file without truncation footer for raw mode
(0, lines.len(), None)
}
LineRangeSelector::Conflicts => {
// Conflicts are handled specially in read_file_with_selector, so this shouldn't be reached
(0, lines.len(), None)
}
LineRangeSelector::Lines(ranges) => {
// Collect all lines specified by ranges
let mut selected_indices = std::collections::BTreeSet::new();
for range in ranges {
let (start_zero, end_zero) = range.zero_indexed();
for i in start_zero..=end_zero.min(lines.len().saturating_sub(1)) {
selected_indices.insert(i);
}
}
if selected_indices.is_empty() {
return (0, 0, None);
}
// Find contiguous blocks and return the first one (or could merge all)
let indices: Vec<usize> = selected_indices.iter().copied().collect();
let first = indices[0];
let last = indices[indices.len() - 1];
// Check if it's a contiguous range
let is_contiguous = (last - first + 1) == indices.len();
if is_contiguous {
(first, last + 1, None)
} else {
// Return all selected indices as one continuous block from first to last
// In the future, this could be improved to show multiple blocks
(first, last + 1, None)
}
}
LineRangeSelector::None => {
// No selector, return entire file
(0, lines.len(), None)
}
}
}
/// Replaces a file's contents and returns patch metadata.
pub fn write_file(path: &str, content: &str) -> io::Result<WriteFileOutput> {
if content.len() > MAX_WRITE_SIZE {
@@ -715,11 +1022,23 @@ pub fn read_file_in_workspace(
offset: Option<usize>,
limit: Option<usize>,
workspace_root: &Path,
) -> io::Result<ReadFileOutput> {
read_file_in_workspace_with_selector(path, offset, limit, None, workspace_root)
}
/// Read a file with workspace boundary enforcement and optional line range selector.
#[allow(dead_code)]
pub fn read_file_in_workspace_with_selector(
path: &str,
offset: Option<usize>,
limit: Option<usize>,
selector: Option<&str>,
workspace_root: &Path,
) -> io::Result<ReadFileOutput> {
let absolute_path = normalize_path(path)?;
let canonical_root = canonicalize_workspace_root(workspace_root);
validate_workspace_boundary(&absolute_path, &canonical_root)?;
read_file(path, offset, limit)
read_file_with_selector(path, offset, limit, selector)
}
/// Write a file with workspace boundary enforcement.
@@ -1183,4 +1502,189 @@ mod tests {
assert!(component_contains_glob("*.rs"));
assert!(!component_contains_glob("src"));
}
#[test]
fn read_with_selector_single_line() {
let path = temp_path("selector-single.txt");
write_file(
path.to_string_lossy().as_ref(),
"line1\nline2\nline3\nline4\nline5",
)
.expect("write should succeed");
let output =
super::read_file_with_selector(path.to_string_lossy().as_ref(), None, None, Some("2"))
.expect("read with selector should succeed");
assert_eq!(output.file.content, "line2");
assert_eq!(output.file.start_line, 2);
assert_eq!(output.file.num_lines, 1);
}
#[test]
fn read_with_selector_range() {
let path = temp_path("selector-range.txt");
write_file(
path.to_string_lossy().as_ref(),
"line1\nline2\nline3\nline4\nline5",
)
.expect("write should succeed");
let output = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("2-4"),
)
.expect("read with selector should succeed");
assert_eq!(output.file.content, "line2\nline3\nline4");
assert_eq!(output.file.start_line, 2);
assert_eq!(output.file.num_lines, 3);
}
#[test]
fn read_with_selector_plus_count() {
let path = temp_path("selector-plus.txt");
write_file(
path.to_string_lossy().as_ref(),
"line1\nline2\nline3\nline4\nline5",
)
.expect("write should succeed");
let output = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("2+2"),
)
.expect("read with selector should succeed");
assert_eq!(output.file.content, "line2\nline3");
assert_eq!(output.file.start_line, 2);
assert_eq!(output.file.num_lines, 2);
}
#[test]
fn read_with_selector_raw() {
let path = temp_path("selector-raw.txt");
write_file(
path.to_string_lossy().as_ref(),
"line1\nline2\nline3\nline4\nline5",
)
.expect("write should succeed");
let output = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("raw"),
)
.expect("read with selector should succeed");
assert_eq!(output.file.content, "line1\nline2\nline3\nline4\nline5");
assert_eq!(output.file.start_line, 1);
assert_eq!(output.file.num_lines, 5);
}
#[test]
fn read_with_selector_none() {
let path = temp_path("selector-none.txt");
write_file(
path.to_string_lossy().as_ref(),
"line1\nline2\nline3\nline4\nline5",
)
.expect("write should succeed");
let output =
super::read_file_with_selector(path.to_string_lossy().as_ref(), None, None, None)
.expect("read with selector should succeed");
assert_eq!(output.file.content, "line1\nline2\nline3\nline4\nline5");
assert_eq!(output.file.start_line, 1);
assert_eq!(output.file.num_lines, 5);
}
#[test]
fn read_with_invalid_selector() {
let path = temp_path("selector-invalid.txt");
write_file(
path.to_string_lossy().as_ref(),
"line1\nline2\nline3\nline4\nline5",
)
.expect("write should succeed");
let result = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("invalid-selector"),
);
assert!(result.is_err());
}
#[test]
fn read_with_selector_conflicts_no_conflicts() {
let path = temp_path("conflicts-none.txt");
write_file(
path.to_string_lossy().as_ref(),
"const a = 1;\nconst b = 2;\n",
)
.expect("write should succeed");
let output = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("conflicts"),
)
.expect("read with conflicts selector should succeed");
assert!(output
.file
.content
.contains("No unresolved git merge conflicts"));
}
#[test]
fn read_with_selector_conflicts_two_way() {
let path = temp_path("conflicts-two-way.txt");
let content = "<<<<<<< A\na-ours\n=======\na-theirs\n>>>>>>> A\nmiddle\n<<<<<<< B\nb-ours\n=======\nb-theirs\n>>>>>>> B\ntail";
write_file(path.to_string_lossy().as_ref(), content).expect("write should succeed");
let output = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("conflicts"),
)
.expect("read with conflicts selector should succeed");
let text = &output.file.content;
// Should report 2 conflicts
assert!(text.contains("2 unresolved conflicts"), "text: {}", text);
// Should have indices for both blocks
assert!(text.contains("#1"), "missing #1: {}", text);
assert!(text.contains("#2"), "missing #2: {}", text);
// Should have line ranges
assert!(text.contains("L1-5"), "missing L1-5: {}", text);
assert!(text.contains("L7-11"), "missing L7-11: {}", text);
}
#[test]
fn read_with_selector_conflicts_three_way() {
let path = temp_path("conflicts-three-way.txt");
let content = "head\n<<<<<<< HEAD\nours body\n||||||| common ancestor\nbase body\n=======\ntheirs body\n>>>>>>> feat\ntail";
write_file(path.to_string_lossy().as_ref(), content).expect("write should succeed");
let output = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("conflicts"),
)
.expect("read with conflicts selector should succeed");
let text = &output.file.content;
// Should report 1 conflict
assert!(text.contains("1 unresolved conflict"), "text: {}", text);
// Should mark as 3-way
assert!(text.contains("3-way"), "missing 3-way marker: {}", text);
// Should show line range
assert!(text.contains("L2-8"), "missing L2-8: {}", text);
}
}
+15 -12
View File
@@ -20,6 +20,7 @@ pub mod green_contract;
mod hooks;
mod json;
mod lane_events;
pub mod line_range_selector;
pub mod lsp_client;
mod mcp;
mod mcp_client;
@@ -66,15 +67,16 @@ pub use compact::{
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
};
pub use config::{
clear_user_provider_settings, default_config_home, save_user_provider_settings,
suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError,
ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource,
McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig,
McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig,
RuntimeProviderConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
clear_user_provider_settings, default_config_home, inject_config_as_env_fallbacks,
save_user_provider_settings, suppress_config_warnings_for_json_mode, ApiTimeoutConfig,
ConfigEntry, ConfigError, ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader,
ConfigSource, McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig,
McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig,
McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
ProviderFallbackConfig, ResolvedPermissionMode, RulesImportConfig, RuntimeConfig,
RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig, RuntimeInvalidHookConfig,
RuntimePermissionRuleConfig, RuntimePluginConfig, RuntimeProviderConfig, ScopedMcpServerConfig,
CLAW_SETTINGS_SCHEMA_NAME,
};
pub use config_validate::{
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
@@ -82,12 +84,13 @@ pub use config_validate::{
};
pub use conversation::{
auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent,
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolError,
ToolExecutor, TurnSummary,
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolCall, ToolError,
ToolExecutor, ToolResult, TurnProgressReporter, TurnSummary,
};
pub use file_ops::{
edit_file, edit_file_in_workspace, glob_search, glob_search_in_workspace, grep_search,
grep_search_in_workspace, read_file, read_file_in_workspace, write_file,
grep_search_in_workspace, read_file, read_file_in_workspace,
read_file_in_workspace_with_selector, read_file_with_selector, write_file,
write_file_in_workspace, EditFileOutput, GlobSearchOutput, GrepSearchInput, GrepSearchOutput,
ReadFileOutput, StructuredPatchHunk, TextFilePayload, WriteFileOutput,
};
@@ -0,0 +1,347 @@
/// GJC-compatible line range selector parsing for the read tool.
///
/// Supports selector syntax:
/// - `:50` = start at line 50
/// - `:50-200` = lines 50-200 inclusive
/// - `:50+150` = 150 lines starting at 50
/// - `:50+1` = exactly one line
/// - `:5-16,960-973` = multiple ranges
/// - `:raw` = full verbatim
/// - `:conflicts` = git merge conflicts only
use std::num::ParseIntError;
/// Parsed line range specification.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LineRangeSelector {
/// Full file verbatim (`:raw`)
Raw,
/// Git merge conflict sections only (`:conflicts`)
Conflicts,
/// Line range(s) (`:L-L+N` syntax)
Lines(Vec<LineRange>),
/// No selector (full file, may be truncated by default budget)
None,
}
/// A single line range, zero-indexed internally but displayed as 1-indexed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LineRange {
/// Start line (1-indexed, user-facing)
pub start_line: usize,
/// End line inclusive (1-indexed, user-facing)
pub end_line: usize,
}
impl LineRange {
/// Create a new line range (1-indexed).
pub fn new(start_line: usize, end_line: usize) -> Self {
Self {
start_line,
end_line,
}
}
/// Convert to zero-indexed range for internal use.
pub fn zero_indexed(&self) -> (usize, usize) {
(
self.start_line.saturating_sub(1),
self.end_line.saturating_sub(1),
)
}
/// Get the number of lines in this range.
pub fn len(&self) -> usize {
if self.start_line <= self.end_line {
self.end_line - self.start_line + 1
} else {
0
}
}
/// Check if this range is valid.
pub fn is_valid(&self) -> bool {
self.start_line > 0 && self.end_line > 0 && self.start_line <= self.end_line
}
}
/// Parse error types for line range selectors.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
InvalidFormat(String),
InvalidNumber(ParseIntError),
InvalidRange(String),
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParseError::InvalidFormat(msg) => write!(f, "Invalid format: {}", msg),
ParseError::InvalidNumber(e) => write!(f, "Invalid number: {}", e),
ParseError::InvalidRange(msg) => write!(f, "Invalid range: {}", msg),
}
}
}
impl std::error::Error for ParseError {}
impl From<ParseIntError> for ParseError {
fn from(e: ParseIntError) -> Self {
ParseError::InvalidNumber(e)
}
}
/// Parse a line range selector string.
///
/// Examples:
/// - `"50"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 50)])`
/// - `"50-200"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 200)])`
/// - `"50+150"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 199)])`
/// - `"raw"` → `LineRangeSelector::Raw`
/// - `"conflicts"` → `LineRangeSelector::Conflicts`
pub fn parse_selector(selector: &str) -> Result<LineRangeSelector, ParseError> {
if selector.is_empty() {
return Ok(LineRangeSelector::None);
}
match selector {
"raw" => Ok(LineRangeSelector::Raw),
"conflicts" => Ok(LineRangeSelector::Conflicts),
_ => parse_line_ranges(selector),
}
}
/// Parse comma-separated line ranges.
fn parse_line_ranges(selector: &str) -> Result<LineRangeSelector, ParseError> {
let mut ranges = Vec::new();
for range_str in selector.split(',') {
let range_str = range_str.trim();
if range_str.is_empty() {
continue;
}
let range = parse_single_range(range_str)?;
ranges.push(range);
}
if ranges.is_empty() {
Ok(LineRangeSelector::None)
} else {
// Merge overlapping/adjacent ranges
ranges.sort_by_key(|r| r.start_line);
let merged = merge_ranges(ranges);
Ok(LineRangeSelector::Lines(merged))
}
}
/// Parse a single line range in formats: "50", "50-200", "50+150".
fn parse_single_range(range_str: &str) -> Result<LineRange, ParseError> {
// Try "50+150" format first (start+count)
if let Some(plus_idx) = range_str.find('+') {
let start_str = &range_str[..plus_idx];
let count_str = &range_str[plus_idx + 1..];
if start_str.is_empty() || count_str.is_empty() {
return Err(ParseError::InvalidFormat(range_str.to_string()));
}
let start = start_str.parse::<usize>()?;
let count = count_str.parse::<usize>()?;
if start == 0 || count == 0 {
return Err(ParseError::InvalidRange(
"Line numbers and counts must be >= 1".to_string(),
));
}
return Ok(LineRange::new(start, start + count - 1));
}
// Try "50-200" format (start-end)
if let Some(dash_idx) = range_str.find('-') {
// Skip leading dash (negative numbers aren't valid here)
if dash_idx == 0 {
return Err(ParseError::InvalidFormat(range_str.to_string()));
}
let start_str = &range_str[..dash_idx];
let end_str = &range_str[dash_idx + 1..];
if start_str.is_empty() || end_str.is_empty() {
return Err(ParseError::InvalidFormat(range_str.to_string()));
}
let start = start_str.parse::<usize>()?;
let end = end_str.parse::<usize>()?;
if start == 0 || end == 0 {
return Err(ParseError::InvalidRange(
"Line numbers must be >= 1".to_string(),
));
}
if start > end {
return Err(ParseError::InvalidRange(format!(
"Start line {} cannot be greater than end line {}",
start, end
)));
}
return Ok(LineRange::new(start, end));
}
// Try plain "50" format (single line)
let line_num = range_str.parse::<usize>()?;
if line_num == 0 {
return Err(ParseError::InvalidRange(
"Line number must be >= 1".to_string(),
));
}
Ok(LineRange::new(line_num, line_num))
}
/// Merge overlapping and adjacent ranges.
fn merge_ranges(ranges: Vec<LineRange>) -> Vec<LineRange> {
let mut merged: Vec<LineRange> = Vec::new();
for range in ranges {
if let Some(last) = merged.last_mut() {
// If ranges overlap or are adjacent, merge them
if range.start_line <= last.end_line + 1 {
last.end_line = last.end_line.max(range.end_line);
} else {
merged.push(range);
}
} else {
merged.push(range);
}
}
merged
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_raw() {
assert_eq!(parse_selector("raw").unwrap(), LineRangeSelector::Raw);
}
#[test]
fn test_parse_conflicts() {
assert_eq!(
parse_selector("conflicts").unwrap(),
LineRangeSelector::Conflicts
);
}
#[test]
fn test_parse_single_line() {
match parse_selector("50").unwrap() {
LineRangeSelector::Lines(ranges) => {
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start_line, 50);
assert_eq!(ranges[0].end_line, 50);
}
_ => panic!("Expected Lines variant"),
}
}
#[test]
fn test_parse_range_start_dash_end() {
match parse_selector("50-200").unwrap() {
LineRangeSelector::Lines(ranges) => {
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start_line, 50);
assert_eq!(ranges[0].end_line, 200);
}
_ => panic!("Expected Lines variant"),
}
}
#[test]
fn test_parse_range_start_plus_count() {
match parse_selector("50+150").unwrap() {
LineRangeSelector::Lines(ranges) => {
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start_line, 50);
assert_eq!(ranges[0].end_line, 199);
}
_ => panic!("Expected Lines variant"),
}
}
#[test]
fn test_parse_multiple_ranges() {
match parse_selector("5-16,960-973").unwrap() {
LineRangeSelector::Lines(ranges) => {
assert_eq!(ranges.len(), 2);
assert_eq!(ranges[0].start_line, 5);
assert_eq!(ranges[0].end_line, 16);
assert_eq!(ranges[1].start_line, 960);
assert_eq!(ranges[1].end_line, 973);
}
_ => panic!("Expected Lines variant"),
}
}
#[test]
fn test_merge_overlapping_ranges() {
// "5-16,10-20" should merge into "5-20"
match parse_selector("5-16,10-20").unwrap() {
LineRangeSelector::Lines(ranges) => {
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start_line, 5);
assert_eq!(ranges[0].end_line, 20);
}
_ => panic!("Expected Lines variant"),
}
}
#[test]
fn test_merge_adjacent_ranges() {
// "5-16,17-20" should merge into "5-20"
match parse_selector("5-16,17-20").unwrap() {
LineRangeSelector::Lines(ranges) => {
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start_line, 5);
assert_eq!(ranges[0].end_line, 20);
}
_ => panic!("Expected Lines variant"),
}
}
#[test]
fn test_invalid_range_start_greater_than_end() {
let result = parse_selector("200-50");
assert!(result.is_err());
}
#[test]
fn test_invalid_zero_line() {
let result = parse_selector("0");
assert!(result.is_err());
}
#[test]
fn test_empty_selector() {
assert_eq!(parse_selector("").unwrap(), LineRangeSelector::None);
}
#[test]
fn test_line_range_zero_indexed() {
let range = LineRange::new(50, 200);
let (start, end) = range.zero_indexed();
assert_eq!(start, 49);
assert_eq!(end, 199);
}
#[test]
fn test_line_range_len() {
assert_eq!(LineRange::new(50, 200).len(), 151);
assert_eq!(LineRange::new(50, 50).len(), 1);
}
}
+11 -26
View File
@@ -29,7 +29,7 @@ impl Default for TridentConfig {
}
/// Statistics from a Trident compaction run.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TridentStats {
pub superseded_count: usize,
pub collapsed_chains: usize,
@@ -41,21 +41,6 @@ pub struct TridentStats {
pub final_message_count: usize,
}
impl Default for TridentStats {
fn default() -> Self {
Self {
superseded_count: 0,
collapsed_chains: 0,
messages_collapsed: 0,
clusters_found: 0,
messages_clustered: 0,
tokens_saved_estimate: 0,
original_message_count: 0,
final_message_count: 0,
}
}
}
impl TridentStats {
pub fn format_report(&self) -> String {
let compression = if self.final_message_count > 0 {
@@ -192,7 +177,7 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
let mut obsolete_indices: BTreeSet<usize> = BTreeSet::new();
for (_path, ops) in &file_ops {
for ops in file_ops.values() {
if ops.len() < 2 {
continue;
}
@@ -205,9 +190,9 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
if let Some(last_write) = last_write_idx {
for op in ops {
if op.op_type == FileOp::Read && op.index < last_write {
obsolete_indices.insert(op.index);
} else if (op.op_type == FileOp::Write || op.op_type == FileOp::Edit)
if (op.op_type == FileOp::Read
|| op.op_type == FileOp::Write
|| op.op_type == FileOp::Edit)
&& op.index < last_write
{
obsolete_indices.insert(op.index);
@@ -325,7 +310,7 @@ fn stage2_collapse(
usage: None,
});
} else {
result.extend(buffer.drain(..));
result.append(&mut buffer);
}
buffer.clear();
result.push(msg.clone());
@@ -476,7 +461,7 @@ fn stage3_cluster(
}
let total_clustered: usize = cluster_assignments.len();
let clusters_found = cluster_id as usize;
let clusters_found = cluster_id;
let mut result: Vec<ConversationMessage> = Vec::new();
let mut cluster_buffers: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
@@ -677,7 +662,7 @@ fn truncate_text(text: &str, max_chars: usize) -> String {
mod tests {
use super::*;
use crate::compact::CompactionConfig;
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
use crate::session::{ContentBlock, ConversationMessage, Session};
#[test]
fn stage1_removes_obsolete_file_reads() {
@@ -736,7 +721,7 @@ mod tests {
fn stage2_collapses_chatty_messages() {
let mut messages = vec![];
for i in 0..6 {
messages.push(ConversationMessage::user_text(&format!("ok {i}")));
messages.push(ConversationMessage::user_text(format!("ok {i}")));
messages.push(ConversationMessage::assistant(vec![ContentBlock::Text {
text: format!("got {i}"),
}]));
@@ -767,9 +752,9 @@ mod tests {
},
]));
messages.push(ConversationMessage::tool_result(
&format!("read_{i}"),
format!("read_{i}"),
"read_file",
&format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
false,
));
}
+10 -2
View File
@@ -14,6 +14,7 @@ api = { path = "../api" }
commands = { path = "../commands" }
crossterm = "0.28"
pulldown-cmark = "0.13"
once_cell = "1"
rustyline = "15"
runtime = { path = "../runtime" }
plugins = { path = "../plugins" }
@@ -25,8 +26,15 @@ tools = { path = "../tools" }
log = "0.4"
[lints]
workspace = true
[lints.rust]
unsafe_code = "allow"
[lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "allow", priority = -1 }
module_name_repetitions = "allow"
missing_panics_doc = "allow"
missing_errors_doc = "allow"
[dev-dependencies]
mock-anthropic-service = { path = "../mock-anthropic-service" }
+35 -3
View File
@@ -18,6 +18,8 @@ pub enum ReadOutcome {
Submit(String),
Cancel,
Exit,
ProviderSwap,
TeamToggle,
}
struct SlashCommandHelper {
@@ -86,12 +88,19 @@ impl Hinter for SlashCommandHelper {
impl Highlighter for SlashCommandHelper {
fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
self.set_current_line(line);
Cow::Borrowed(line)
// When sentinel is present, show visible prompt instead of invisible char
if line.contains('\x01') {
let display = line.replace('\x01', "\x1b[36m[Provider Swap]\x1b[0m ");
Cow::Owned(display)
} else {
Cow::Borrowed(line)
}
}
fn highlight_char(&self, line: &str, _pos: usize, _kind: CmdKind) -> bool {
self.set_current_line(line);
false
// Re-highlight when sentinel is present to show the prompt
line.contains('\x01')
}
}
@@ -115,6 +124,18 @@ impl LineEditor {
editor.set_helper(Some(SlashCommandHelper::new(completions)));
editor.bind_sequence(KeyEvent(KeyCode::Char('J'), Modifiers::CTRL), Cmd::Newline);
editor.bind_sequence(KeyEvent(KeyCode::Enter, Modifiers::SHIFT), Cmd::Newline);
// Ctrl+P inserts a sentinel character that triggers provider swap.
// The sentinel is invisible but the highlighter shows "[Provider Swap]" prompt.
// User must press Enter to confirm (rustyline cannot chain commands).
editor.bind_sequence(
KeyEvent(KeyCode::Char('P'), Modifiers::CTRL),
Cmd::SelfInsert(1, '\x01'),
);
// Ctrl+T inserts a sentinel character that toggles agent teams mode.
editor.bind_sequence(
KeyEvent(KeyCode::Char('T'), Modifiers::CTRL),
Cmd::SelfInsert(1, '\x02'),
);
Self {
prompt: prompt.into(),
@@ -147,7 +168,18 @@ impl LineEditor {
}
match self.editor.readline(&self.prompt) {
Ok(line) => Ok(ReadOutcome::Submit(line)),
Ok(line) => {
// Ctrl+P inserts \x01 sentinel — triggers provider swap wizard.
// The sentinel is stripped and we return ProviderSwap to the REPL loop.
if line.contains('\x01') {
return Ok(ReadOutcome::ProviderSwap);
}
// Ctrl+T inserts \x02 sentinel — toggles team mode.
if line.contains('\x02') {
return Ok(ReadOutcome::TeamToggle);
}
Ok(ReadOutcome::Submit(line))
}
Err(ReadlineError::Interrupted) => {
let has_input = !self.current_line().is_empty();
self.finish_interrupted_read()?;
File diff suppressed because it is too large. Load diff
+296 -57
View File
@@ -249,16 +249,6 @@ impl TerminalRenderer {
#[must_use]
pub fn render_markdown(&self, markdown: &str) -> String {
self.render_markdown_raw(markdown).trim_end().to_string()
}
/// Render markdown to ANSI without trimming trailing whitespace.
///
/// Block-level events (paragraphs, headings, lists, code blocks) emit their
/// trailing newline separators here. `render_markdown` trims them for
/// one-shot output, while streaming keeps them so that successive chunks
/// stay on their own lines instead of running together.
fn render_markdown_raw(&self, markdown: &str) -> String {
let normalized = normalize_nested_fences(markdown);
let mut output = String::new();
let mut state = RenderState::default();
@@ -277,7 +267,30 @@ impl TerminalRenderer {
);
}
output
output.trim_end().to_string()
}
/// Ensures the output ends with at least `required` newlines.
/// This is idempotent: calling it multiple times with the same output
/// will not add extra newlines beyond what is needed.
/// The function is crucial for streaming rendering where chunks may
/// split at block boundaries, causing the parser to lose newlines
/// that normally separate blocks.
fn ensure_newlines(output: &mut String, required: usize) {
if output.is_empty() {
// Don't start with blank lines
return;
}
// Count trailing newlines
let trailing_newlines = output.chars().rev().take_while(|&c| c == '\n').count();
// Add newlines if we don't have enough
if trailing_newlines < required {
for _ in 0..(required - trailing_newlines) {
output.push('\n');
}
}
}
#[must_use]
@@ -285,18 +298,6 @@ impl TerminalRenderer {
self.render_markdown(markdown)
}
/// Stream-friendly variant of `markdown_to_ansi` that preserves trailing
/// block separators (such as a paragraph's closing newlines).
///
/// `MarkdownStreamState::push` writes each rendered chunk directly to the
/// terminal with no separator of its own, so the trailing newlines must
/// survive here to keep a paragraph from sticking to the block that follows
/// it. The final `flush` still uses `markdown_to_ansi` to trim the tail.
#[must_use]
pub fn markdown_to_ansi_stream(&self, markdown: &str) -> String {
self.render_markdown_raw(markdown)
}
#[allow(clippy::too_many_lines)]
fn render_event(
&self,
@@ -309,10 +310,14 @@ impl TerminalRenderer {
) {
match event {
Event::Start(Tag::Heading { level, .. }) => {
Self::ensure_newlines(output, 1);
Self::start_heading(state, level as u8, output);
}
Event::End(TagEnd::Paragraph) => output.push_str("\n\n"),
Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
Event::Start(Tag::BlockQuote(..)) => {
Self::ensure_newlines(output, 1);
self.start_quote(state, output);
}
Event::End(TagEnd::BlockQuote(..)) => {
state.quote = state.quote.saturating_sub(1);
output.push('\n');
@@ -325,6 +330,7 @@ impl TerminalRenderer {
state.append_raw(output, "\n");
}
Event::Start(Tag::List(first_item)) => {
Self::ensure_newlines(output, 1);
let kind = match first_item {
Some(index) => ListKind::Ordered { next_index: index },
None => ListKind::Unordered,
@@ -337,6 +343,7 @@ impl TerminalRenderer {
}
Event::Start(Tag::Item) => Self::start_item(state, output),
Event::Start(Tag::CodeBlock(kind)) => {
Self::ensure_newlines(output, 2);
*in_code_block = true;
*code_language = match kind {
CodeBlockKind::Indented => String::from("text"),
@@ -360,7 +367,10 @@ impl TerminalRenderer {
format!("{}", format!("`{code}`").with(self.color_theme.inline_code));
state.append_raw(output, &rendered);
}
Event::Rule => output.push_str("---\n"),
Event::Rule => {
Self::ensure_newlines(output, 1);
output.push_str("---\n");
}
Event::Text(text) => {
self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block);
}
@@ -405,7 +415,10 @@ impl TerminalRenderer {
);
state.append_raw(output, &rendered);
}
Event::Start(Tag::Table(..)) => state.table = Some(TableState::default()),
Event::Start(Tag::Table(..)) => {
Self::ensure_newlines(output, 1);
state.table = Some(TableState::default());
}
Event::End(TagEnd::Table) => {
if let Some(table) = state.table.take() {
output.push_str(&self.render_table(&table));
@@ -619,9 +632,21 @@ impl TerminalRenderer {
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarkdownStreamState {
pending: String,
/// Track whether the last emitted output ended with a newline.
/// Used to ensure proper spacing when resuming from a buffer.
last_output_ends_with_newline: bool,
}
impl Default for MarkdownStreamState {
fn default() -> Self {
Self {
pending: String::new(),
last_output_ends_with_newline: false,
}
}
}
impl MarkdownStreamState {
@@ -631,7 +656,40 @@ impl MarkdownStreamState {
let split = find_stream_safe_boundary(&self.pending)?;
let ready = self.pending[..split].to_string();
self.pending.drain(..split);
Some(renderer.markdown_to_ansi_stream(&ready))
let mut rendered = renderer.markdown_to_ansi(&ready);
let stripped = strip_ansi(&rendered);
// If the previous output didn't end with a newline and this chunk starts
// with a block-level element, ensure proper spacing before it.
if !self.last_output_ends_with_newline && !rendered.is_empty() {
if stripped.starts_with("\u{256d}\u{2500}")
|| stripped.starts_with("```")
|| stripped.starts_with("|")
|| stripped.starts_with("• ")
|| stripped.starts_with("1. ")
|| stripped.starts_with("-")
{
rendered = format!("\n\n{}", rendered);
}
}
// When markdown ends with a code block closing marker or other block-level element,
// render_markdown() trims the trailing newlines that finish_code_block() added.
// For streaming, we need to restore these newlines so proper spacing is preserved
// when chunks are combined. This is critical for idempotence across re-renders.
if stripped.ends_with("\u{2570}\u{2500}")
|| stripped.ends_with("---")
|| stripped.ends_with("|")
{
// Block element that should have trailing spacing
if !rendered.ends_with('\n') {
rendered.push_str("\n\n");
}
}
self.last_output_ends_with_newline = rendered.ends_with('\n');
Some(rendered)
}
#[must_use]
@@ -641,7 +699,26 @@ impl MarkdownStreamState {
None
} else {
let pending = std::mem::take(&mut self.pending);
Some(renderer.markdown_to_ansi(&pending))
let mut rendered = renderer.markdown_to_ansi(&pending);
// If the previous output didn't end with a newline and this output
// starts with a block-level element, ensure proper spacing.
if !self.last_output_ends_with_newline && !rendered.is_empty() {
// Check if output starts with a block element by looking for common markers
let stripped = strip_ansi(&rendered);
if stripped.starts_with("\u{256d}\u{2500}")
|| stripped.starts_with("```")
|| stripped.starts_with("|")
|| stripped.starts_with("• ")
|| stripped.starts_with("1. ")
|| stripped.starts_with("-")
{
rendered = format!("\n\n{}", rendered);
}
}
self.last_output_ends_with_newline = rendered.ends_with('\n');
Some(rendered)
}
}
}
@@ -835,6 +912,11 @@ fn normalize_nested_fences(markdown: &str) -> String {
out
}
/// Find a safe boundary for streaming markdown rendering.
/// Safe boundaries are:
/// - After a closing fence line (when fence is closed)
/// - After an empty line
/// This function returns the position of the last safe boundary found.
fn find_stream_safe_boundary(markdown: &str) -> Option<usize> {
let mut open_fence: Option<FenceMarker> = None;
let mut last_boundary = None;
@@ -1074,34 +1156,6 @@ mod tests {
assert!(plain_text.contains("fn nested()"));
}
#[test]
fn stream_rendering_preserves_block_separators() {
let renderer = TerminalRenderer::new();
// The stream variant keeps the paragraph-ending newlines that the
// one-shot variant trims, so consecutive streamed chunks do not run
// into each other when written back-to-back to the terminal.
let streamed = renderer.markdown_to_ansi_stream("Intro paragraph.\n\n");
assert!(streamed.ends_with('\n'));
assert!(!renderer
.markdown_to_ansi("Intro paragraph.\n\n")
.ends_with('\n'));
let mut state = MarkdownStreamState::default();
let first = state
.push(&renderer, "Intro paragraph.\n\n")
.expect("blank line closes the paragraph block");
let second = state
.push(&renderer, "1. first item\n\n")
.expect("blank line closes the list block");
let combined = strip_ansi(&format!("{first}{second}"));
assert!(combined.contains("Intro paragraph."));
assert!(combined.contains("1. first item"));
// The two blocks stay on separate lines instead of sticking together.
assert!(!combined.contains("Intro paragraph.1."));
}
#[test]
fn spinner_advances_frames() {
let terminal_renderer = TerminalRenderer::new();
@@ -1117,4 +1171,189 @@ mod tests {
let output = String::from_utf8_lossy(&out);
assert!(output.contains("Working"));
}
#[test]
fn streaming_text_before_code_block_preserves_newline_boundary() {
let renderer = TerminalRenderer::new();
let mut state = MarkdownStreamState::default();
// Simulate streaming: paragraph text arrives in one chunk
assert_eq!(
state.push(&renderer, "This is some text."),
None,
"incomplete paragraph should not flush"
);
// Then code block arrives in the next chunk
let rendered = state
.push(&renderer, "\n\n```rust\nfn main() {}\n```\n")
.expect("fenced block should trigger flush");
let plain_text = strip_ansi(&rendered);
// Verify the paragraph text and code block are present
assert!(plain_text.contains("This is some text."));
assert!(plain_text.contains("fn main()"));
// The critical check: there should be a blank line between the paragraph and code block
// The paragraph ends, then there should be newlines before the code block marker
let lines: Vec<&str> = plain_text.lines().collect();
let text_line_idx = lines
.iter()
.position(|line| line.contains("This is some text."))
.expect("find text line");
// Skip past the text line and find the code block
let code_marker_idx = lines[text_line_idx + 1..]
.iter()
.position(|line| line.contains("╭─ rust"))
.map(|i| i + text_line_idx + 1);
// There should be at least one empty line between the text and the code block marker
if let Some(code_idx) = code_marker_idx {
assert!(
code_idx > text_line_idx + 1,
"there should be spacing between paragraph and code block"
);
}
}
#[test]
fn rendering_paragraph_immediately_before_code_block_has_blank_line() {
let renderer = TerminalRenderer::new();
let markdown = "This is a paragraph.\n\n```rust\nfn main() {}\n```";
let output = renderer.markdown_to_ansi(markdown);
let plain_text = strip_ansi(&output);
let lines: Vec<&str> = plain_text.lines().collect();
// Find the paragraph and code block
let text_idx = lines
.iter()
.position(|l| l.contains("This is a paragraph."))
.expect("find paragraph");
let code_idx = lines[text_idx + 1..]
.iter()
.position(|l| l.contains("╭─ rust"))
.map(|i| i + text_idx + 1)
.expect("find code block");
// There should be at least one blank line between them
assert!(
code_idx > text_idx + 1,
"code block should not immediately follow paragraph"
);
}
#[test]
fn code_block_ending_with_trailing_text_preserves_spacing() {
let renderer = TerminalRenderer::new();
let markdown = "```rust\nfn main() {}\n```\n\nText after.";
let output = renderer.markdown_to_ansi(markdown);
let plain_text = strip_ansi(&output);
// The code block should end with "╰─" followed by blank lines, then text
assert!(
plain_text.contains("\u{256d}\u{2500}"),
"should contain code block close marker"
);
assert!(
plain_text.contains("\n\nText"),
"should have blank line before text"
);
}
#[test]
fn streaming_code_block_preserves_trailing_newlines() {
let renderer = TerminalRenderer::new();
let mut state = MarkdownStreamState::default();
let markdown = "Text before.\n\n```rust\nfn test() {}\n```\n\nText after.";
// Debug: let's check what the code block part alone renders to
let code_part = "```rust\nfn test() {}\n```\n\nText after.";
let code_rendered = strip_ansi(&renderer.markdown_to_ansi(code_part));
// Simulate the streaming at split position 1
let r1 = state.push(&renderer, "T").map(|s| strip_ansi(&s));
let r2 = state
.push(
&renderer,
"ext before.\n\n```rust\nfn test() {}\n```\n\nText after.",
)
.map(|s| strip_ansi(&s));
// After r2, the pending should contain the code block part
let pending_before_r3 = state.pending.clone();
let r3 = state.flush(&renderer).map(|s| strip_ansi(&s));
// Combine the results
let mut full_streamed = String::new();
if let Some(s) = &r1 {
full_streamed.push_str(s);
}
if let Some(s) = &r2 {
full_streamed.push_str(s);
}
if let Some(s) = &r3 {
full_streamed.push_str(s);
}
let full_rendered = strip_ansi(&renderer.markdown_to_ansi(markdown));
assert_eq!(full_streamed, full_rendered,
"Streaming mismatch.\n\nCode part alone renders to: {:?}\nPending before r3: {:?}\n\nBreakdown:\n r1: {:?}\n r2: {:?}\n r3: {:?}\n\nExpected:\n{:?}\n\nGot:\n{:?}",
code_rendered, pending_before_r3, r1, r2, r3, full_rendered, full_streamed);
}
#[test]
fn streaming_with_split_at_every_byte_position_produces_consistent_output() {
let renderer = TerminalRenderer::new();
let markdown = "Text before.\n\n```rust\nfn test() {}\n```\n\nText after.";
// First, render the full document
let full_output = renderer.markdown_to_ansi(markdown);
let full_plain = strip_ansi(&full_output);
// Now simulate streaming by splitting at different positions
for split_pos in 1..markdown.len() {
let mut state = MarkdownStreamState::default();
let part1 = &markdown[..split_pos];
let part2 = &markdown[split_pos..];
// Try to push both parts
let r1 = state.push(&renderer, part1);
let r2 = state.push(&renderer, part2);
let final_r = state.flush(&renderer);
// Reconstruct the streamed output
let mut streamed_output = String::new();
if let Some(out) = r1 {
streamed_output.push_str(&out);
}
if let Some(out) = r2 {
streamed_output.push_str(&out);
}
if let Some(out) = final_r {
streamed_output.push_str(&out);
}
let streamed_plain = strip_ansi(&streamed_output);
if streamed_plain != full_plain {
eprintln!("\n=== MISMATCH at split position {} ===", split_pos);
eprintln!("Part 1: {:?}", part1);
eprintln!("Part 2: {:?}", part2);
eprintln!("\nExpected (full):");
eprintln!("{:?}", full_plain);
eprintln!("\nGot (streamed):");
eprintln!("{:?}", streamed_plain);
eprintln!("\nStreamed rendered output:");
eprintln!("{:?}", streamed_output);
}
assert_eq!(
streamed_plain, full_plain,
"streaming with split at position {} produces different output",
split_pos
);
}
}
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "sdk"
version = "0.1.4"
edition = "2021"
publish = false
[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = "0.23"
futures-util = "0.3"
serde_json = "1"
serde = { version = "1", features = ["derive"] }
uuid = { version = "1", features = ["v4", "serde"] }
thiserror = "1"
tracing = "0.1"
[dev-dependencies]
tokio-test = "0.4"
+130
View File
@@ -0,0 +1,130 @@
//! SDK error types and result type.
use std::fmt;
/// SDK wire protocol error codes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorCode {
/// Invalid JSON or malformed frame
InvalidInput,
/// Unknown broker operation
UnknownOperation,
/// Payload too large (>4 MiB)
PayloadTooLarge,
/// Authentication token mismatch
Unauthorized,
/// Broker or session unavailable
Unavailable,
/// WebSocket connection error
ConnectionError,
/// Request timeout
Timeout,
/// Frame parsing or validation error
FrameError,
/// Request/response correlation failure
CorrelationError,
/// Other unclassified error
Internal,
}
impl fmt::Display for ErrorCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidInput => write!(f, "invalid_input"),
Self::UnknownOperation => write!(f, "unknown_operation"),
Self::PayloadTooLarge => write!(f, "payload_too_large"),
Self::Unauthorized => write!(f, "unauthorized"),
Self::Unavailable => write!(f, "unavailable"),
Self::ConnectionError => write!(f, "connection_error"),
Self::Timeout => write!(f, "timeout"),
Self::FrameError => write!(f, "frame_error"),
Self::CorrelationError => write!(f, "correlation_error"),
Self::Internal => write!(f, "internal_error"),
}
}
}
/// SDK wire protocol error.
#[derive(Debug)]
pub struct SdkError {
pub code: ErrorCode,
pub message: String,
}
impl SdkError {
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
pub fn invalid_input(message: impl Into<String>) -> Self {
Self::new(ErrorCode::InvalidInput, message)
}
pub fn unknown_operation(message: impl Into<String>) -> Self {
Self::new(ErrorCode::UnknownOperation, message)
}
pub fn payload_too_large(message: impl Into<String>) -> Self {
Self::new(ErrorCode::PayloadTooLarge, message)
}
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::new(ErrorCode::Unauthorized, message)
}
pub fn unavailable(message: impl Into<String>) -> Self {
Self::new(ErrorCode::Unavailable, message)
}
pub fn connection_error(message: impl Into<String>) -> Self {
Self::new(ErrorCode::ConnectionError, message)
}
pub fn timeout(message: impl Into<String>) -> Self {
Self::new(ErrorCode::Timeout, message)
}
pub fn frame_error(message: impl Into<String>) -> Self {
Self::new(ErrorCode::FrameError, message)
}
pub fn correlation_error(message: impl Into<String>) -> Self {
Self::new(ErrorCode::CorrelationError, message)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::new(ErrorCode::Internal, message)
}
}
impl fmt::Display for SdkError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for SdkError {}
/// Result type for SDK operations.
pub type SdkResult<T> = Result<T, SdkError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_display_formats_code_and_message() {
let err = SdkError::invalid_input("test message");
assert_eq!(err.to_string(), "invalid_input: test message");
}
#[test]
fn error_code_display_works() {
assert_eq!(ErrorCode::InvalidInput.to_string(), "invalid_input");
assert_eq!(ErrorCode::PayloadTooLarge.to_string(), "payload_too_large");
assert_eq!(ErrorCode::Unauthorized.to_string(), "unauthorized");
}
}
+258
View File
@@ -0,0 +1,258 @@
//! Frame types and serialization for the SDK wire protocol.
use serde_json::{Map, Value};
use crate::error::{SdkError, SdkResult};
/// Protocol version implemented.
pub const PROTOCOL_VERSION: u32 = 3;
/// Maximum UTF-8 byte length for a single broker JSON frame.
pub const MAX_FRAME_BYTES: usize = 4 * 1024 * 1024;
/// Frame type identifier.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameType {
/// Hello frame sent by broker on connection
BrokerHello,
/// Request frame from client to broker
BrokerRequest,
/// Response frame from broker to client
BrokerResponse,
/// Query request
QueryRequest,
/// Query response
QueryResponse,
/// Control request
ControlRequest,
/// Control response
ControlResponse,
/// Event replay request
EventReplay,
/// Event replay result
EventReplayResult,
/// Session activation
SessionActivate,
/// Session activation result
SessionActivateResult,
}
impl FrameType {
pub fn as_str(&self) -> &'static str {
match self {
Self::BrokerHello => "broker_hello",
Self::BrokerRequest => "broker_request",
Self::BrokerResponse => "broker_response",
Self::QueryRequest => "query_request",
Self::QueryResponse => "query_response",
Self::ControlRequest => "control_request",
Self::ControlResponse => "control_response",
Self::EventReplay => "event_replay",
Self::EventReplayResult => "event_replay_result",
Self::SessionActivate => "session_activate",
Self::SessionActivateResult => "session_activate_result",
}
}
pub fn parse(s: &str) -> SdkResult<Self> {
match s {
"broker_hello" => Ok(Self::BrokerHello),
"broker_request" => Ok(Self::BrokerRequest),
"broker_response" => Ok(Self::BrokerResponse),
"query_request" => Ok(Self::QueryRequest),
"query_response" => Ok(Self::QueryResponse),
"control_request" => Ok(Self::ControlRequest),
"control_response" => Ok(Self::ControlResponse),
"event_replay" => Ok(Self::EventReplay),
"event_replay_result" => Ok(Self::EventReplayResult),
"session_activate" => Ok(Self::SessionActivate),
"session_activate_result" => Ok(Self::SessionActivateResult),
_ => Err(SdkError::frame_error(format!("unknown frame type: {}", s))),
}
}
}
/// A frame in the SDK wire protocol.
///
/// Frames are JSON objects with a `type` field and optional `id` field for correlation.
#[derive(Debug, Clone)]
pub struct Frame {
/// Underlying JSON value
pub data: Map<String, Value>,
}
impl Frame {
/// Create a new frame from a JSON object.
pub fn new(data: Map<String, Value>) -> Self {
Self { data }
}
/// Create an empty frame.
pub fn empty() -> Self {
Self { data: Map::new() }
}
/// Get the frame type.
pub fn frame_type(&self) -> SdkResult<FrameType> {
let frame_type_str = self
.data
.get("type")
.and_then(Value::as_str)
.ok_or_else(|| SdkError::frame_error("missing 'type' field"))?;
FrameType::parse(frame_type_str)
}
/// Get the optional request/response ID for correlation.
pub fn id(&self) -> Option<String> {
self.data
.get("id")
.and_then(Value::as_str)
.map(String::from)
}
/// Get the `ok` boolean field from a response frame.
pub fn ok(&self) -> SdkResult<bool> {
self.data
.get("ok")
.and_then(Value::as_bool)
.ok_or_else(|| SdkError::frame_error("missing or invalid 'ok' field"))
}
/// Get the error object from a response frame.
pub fn error(&self) -> Option<SdkResult<(String, String)>> {
let error_obj = self.data.get("error")?.as_object()?;
let code = error_obj.get("code")?.as_str()?.to_string();
let message = error_obj.get("message")?.as_str()?.to_string();
Some(Ok((code, message)))
}
/// Get the result field from a response frame.
pub fn result(&self) -> Option<Value> {
self.data.get("result").cloned()
}
/// Convert to JSON string.
pub fn to_json(&self) -> SdkResult<String> {
serde_json::to_string(&self.data)
.map_err(|e| SdkError::frame_error(format!("JSON serialization error: {}", e)))
}
/// Parse from JSON string.
pub fn from_json(json_str: &str) -> SdkResult<Self> {
let data: Map<String, Value> = serde_json::from_str(json_str)
.map_err(|e| SdkError::frame_error(format!("JSON parsing error: {}", e)))?;
Ok(Self::new(data))
}
/// Create a broker hello frame.
pub fn broker_hello() -> Self {
let mut data = Map::new();
data.insert(
"type".to_string(),
Value::String("broker_hello".to_string()),
);
data.insert(
"protocolVersion".to_string(),
Value::Number(PROTOCOL_VERSION.into()),
);
Self::new(data)
}
/// Create a broker request frame.
pub fn broker_request(id: String, operation: String, input: Value) -> Self {
let mut data = Map::new();
data.insert(
"type".to_string(),
Value::String("broker_request".to_string()),
);
data.insert("id".to_string(), Value::String(id));
data.insert("operation".to_string(), Value::String(operation));
data.insert("input".to_string(), input);
Self::new(data)
}
/// Create a broker response frame.
pub fn broker_response_ok(id: String, result: Value) -> Self {
let mut data = Map::new();
data.insert(
"type".to_string(),
Value::String("broker_response".to_string()),
);
data.insert("id".to_string(), Value::String(id));
data.insert("ok".to_string(), Value::Bool(true));
data.insert("result".to_string(), result);
Self::new(data)
}
/// Create a broker error response frame.
pub fn broker_response_error(id: String, code: String, message: String) -> Self {
let mut error_data = Map::new();
error_data.insert("code".to_string(), Value::String(code));
error_data.insert("message".to_string(), Value::String(message));
let mut data = Map::new();
data.insert(
"type".to_string(),
Value::String("broker_response".to_string()),
);
data.insert("id".to_string(), Value::String(id));
data.insert("ok".to_string(), Value::Bool(false));
data.insert("error".to_string(), Value::Object(error_data));
Self::new(data)
}
/// Get the payload size in bytes.
pub fn byte_size(&self) -> usize {
self.to_json().map(|s| s.len()).unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_type_round_trip() {
assert_eq!(FrameType::BrokerHello.as_str(), "broker_hello");
assert_eq!(
FrameType::parse("broker_hello").unwrap(),
FrameType::BrokerHello
);
}
#[test]
fn frame_parsing_extracts_type() {
let frame = Frame::broker_hello();
assert_eq!(frame.frame_type().unwrap(), FrameType::BrokerHello);
}
#[test]
fn frame_with_id_can_be_recovered() {
let frame = Frame::broker_request(
"test-id".to_string(),
"test.operation".to_string(),
Value::Object(Map::new()),
);
assert_eq!(frame.id(), Some("test-id".to_string()));
}
#[test]
fn frame_json_round_trip() {
let frame = Frame::broker_hello();
let json_str = frame.to_json().unwrap();
let parsed = Frame::from_json(&json_str).unwrap();
assert_eq!(parsed.frame_type().unwrap(), FrameType::BrokerHello);
}
#[test]
fn broker_response_error_contains_code_and_message() {
let frame = Frame::broker_response_error(
"id1".to_string(),
"invalid_input".to_string(),
"test error".to_string(),
);
let (code, message) = frame.error().unwrap().unwrap();
assert_eq!(code, "invalid_input");
assert_eq!(message, "test error");
}
}
+20
View File
@@ -0,0 +1,20 @@
//! GJC SDK wire transport implementation.
//!
//! This crate provides a Rust implementation of the GJC SDK wire protocol,
//! compatible with the gajae-code SDK broker framing, request/response correlation,
//! and event stream semantics.
#![forbid(unsafe_code)]
#![allow(
clippy::missing_errors_doc,
clippy::missing_panics_doc,
clippy::module_name_repetitions
)]
pub mod error;
pub mod frame;
pub mod transport;
pub use error::{SdkError, SdkResult};
pub use frame::{Frame, FrameType};
pub use transport::{SdkTransport, SdkTransportConfig};
+251
View File
@@ -0,0 +1,251 @@
//! WebSocket transport implementation for the SDK wire protocol.
use std::collections::HashMap;
use std::sync::Arc;
use futures_util::sink::SinkExt;
use futures_util::stream::StreamExt;
use serde_json::Value;
use tokio::sync::{mpsc, oneshot, Mutex, RwLock};
use tokio_tungstenite::{connect_async, tungstenite::Message};
use uuid::Uuid;
use crate::error::{SdkError, SdkResult};
use crate::frame::{Frame, FrameType, MAX_FRAME_BYTES};
/// Configuration for SDK transport.
#[derive(Debug, Clone)]
pub struct SdkTransportConfig {
/// Server URL (e.g., "ws://127.0.0.1:8080")
pub url: String,
/// Authentication token
pub token: String,
/// Maximum payload size in bytes (default: 4 MiB)
pub max_payload_bytes: usize,
/// Request timeout in seconds (default: 30)
pub timeout_secs: u64,
}
impl Default for SdkTransportConfig {
fn default() -> Self {
Self {
url: "ws://127.0.0.1:8080".to_string(),
token: String::new(),
max_payload_bytes: MAX_FRAME_BYTES,
timeout_secs: 30,
}
}
}
/// Pending request tracking for correlation.
struct PendingRequest {
response_tx: oneshot::Sender<SdkResult<Frame>>,
}
/// SDK WebSocket transport.
pub struct SdkTransport {
/// Channel for sending messages to the writer task
tx: mpsc::UnboundedSender<Message>,
/// Pending requests by ID
pending: Arc<RwLock<HashMap<String, PendingRequest>>>,
/// Configuration
config: SdkTransportConfig,
/// Background task handle
task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
}
impl SdkTransport {
/// Create a new SDK transport and connect to the broker.
pub async fn connect(config: SdkTransportConfig) -> SdkResult<Self> {
// Add token to query params
let url_with_token = if config.url.contains('?') {
format!("{}&token={}", config.url, config.token)
} else {
format!("{}?token={}", config.url, config.token)
};
// Create WebSocket connection
let (ws, _) = connect_async(&url_with_token).await.map_err(|e| {
SdkError::connection_error(format!("WebSocket connection failed: {}", e))
})?;
let (ws_tx, ws_rx) = tokio::sync::mpsc::unbounded_channel();
let pending = Arc::new(RwLock::new(HashMap::new()));
let pending_clone = pending.clone();
// Spawn background task for handling the WebSocket
let task_handle = tokio::spawn(async move {
Self::background_task(ws, ws_rx, pending_clone).await;
});
let transport = Self {
tx: ws_tx,
pending,
config,
task_handle: Arc::new(Mutex::new(Some(task_handle))),
};
Ok(transport)
}
/// Send a request and wait for response.
pub async fn request(&self, operation: String, input: Value) -> SdkResult<Frame> {
// Generate correlation ID
let id = Uuid::new_v4().to_string();
// Create request frame
let request = Frame::broker_request(id.clone(), operation, input);
// Check payload size
if request.byte_size() > self.config.max_payload_bytes {
return Err(SdkError::payload_too_large(format!(
"frame size {} exceeds maximum {}",
request.byte_size(),
self.config.max_payload_bytes
)));
}
// Create response channel
let (response_tx, response_rx) = oneshot::channel();
self.pending
.write()
.await
.insert(id.clone(), PendingRequest { response_tx });
// Send request
let json = request.to_json()?;
self.tx
.send(Message::Text(json))
.map_err(|e| SdkError::connection_error(format!("send failed: {}", e)))?;
// Wait for response with timeout
let timeout_duration = std::time::Duration::from_secs(self.config.timeout_secs);
match tokio::time::timeout(timeout_duration, response_rx).await {
Ok(Ok(frame)) => frame,
Ok(Err(_)) => Err(SdkError::correlation_error("response channel closed")),
Err(_) => Err(SdkError::timeout("response not received within timeout")),
}
}
/// Background task for handling WebSocket communication.
#[allow(unused_mut)]
async fn background_task(
ws: tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
mut ws_rx: mpsc::UnboundedReceiver<Message>,
pending: Arc<RwLock<HashMap<String, PendingRequest>>>,
) {
let (mut ws_sink, mut ws_stream) = ws.split();
loop {
tokio::select! {
// Handle incoming WebSocket messages
msg = ws_stream.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
// Parse frame
match Frame::from_json(&text) {
Ok(frame) => {
// Handle frame type
match frame.frame_type() {
Ok(FrameType::BrokerHello) => {
// Hello frame received, continue
}
Ok(FrameType::BrokerResponse) => {
// Response frame, correlate with request
if let Some(id) = frame.id() {
if let Some(PendingRequest { response_tx }) =
pending.write().await.remove(&id)
{
let _ = response_tx.send(Ok(frame));
}
}
}
_ => {
// Other frame types
tracing::debug!("Unhandled frame type: {:?}", frame.frame_type());
}
}
}
Err(e) => {
tracing::warn!("Failed to parse frame: {}", e);
}
}
}
Some(Ok(Message::Close(_))) => {
break;
}
Some(Ok(_)) => {
// Other message types (binary, etc.), ignore
}
Some(Err(e)) => {
tracing::warn!("WebSocket error: {}", e);
break;
}
None => {
break;
}
}
}
// Handle outgoing messages
Some(msg) = ws_rx.recv() => {
if let Err(e) = ws_sink.send(msg).await {
tracing::warn!("Failed to send message: {}", e);
break;
}
}
else => break,
}
}
// Fail all pending requests
let mut pending = pending.write().await;
for (_, PendingRequest { response_tx }) in pending.drain() {
let _ = response_tx.send(Err(SdkError::connection_error("connection closed")));
}
}
/// Close the transport.
pub async fn close(self) -> SdkResult<()> {
// Send close message
let _ = self.tx.send(Message::Close(None));
// Wait for background task to finish
if let Some(handle) = self.task_handle.lock().await.take() {
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_default_values() {
let config = SdkTransportConfig::default();
assert_eq!(config.url, "ws://127.0.0.1:8080");
assert_eq!(config.max_payload_bytes, MAX_FRAME_BYTES);
assert_eq!(config.timeout_secs, 30);
}
#[tokio::test]
async fn transport_can_be_created() {
// This test doesn't actually connect, just checks that the struct can be created
let config = SdkTransportConfig::default();
assert_eq!(config.url, "ws://127.0.0.1:8080");
}
#[test]
fn request_frame_has_correlation_id() {
let frame = Frame::broker_request(
"test-id".to_string(),
"test.operation".to_string(),
Value::Object(serde_json::Map::new()),
);
assert_eq!(frame.id(), Some("test-id".to_string()));
}
}
+118
View File
@@ -0,0 +1,118 @@
# Multi-Tool Execution & Sub-Agent Delegation
Two complementary features that dramatically reduce latency and token usage when the model needs to perform multiple operations or gather context.
## Feature 1: Parallel Tool Execution
When the model returns multiple tool_use blocks in a single response, read-only tools now execute concurrently instead of sequentially.
### How it works
The `run_turn` loop is refactored into 3 phases:
1. **Pre-hooks + permission checks** (sequential — hooks may mutate state)
2. **Tool execution** (batch — parallel for read-only tools via `std::thread::scope`)
3. **Post-hooks + session updates** (sequential — preserves original ordering)
### Parallel-safe tools
These tools are safe to run concurrently because they only read state and dispatch through the stateless tool registry:
- `read_file`, `glob_search`, `grep_search`
- `WebFetch`, `WebSearch`
- `ToolSearch`, `Skill`
- `LSP`
- `GitStatus`, `GitDiff`, `GitLog`, `GitShow`, `GitBlame`
### Sequential-only tools
Tools that require `&mut self` or have side effects continue to run one at a time:
- `bash`, `write_file`, `edit_file` (side effects)
- `MCP`, `McpAuth`, `RemoteTrigger` (network state)
- `Agent`, `TaskCreate`, `WorkerCreate` (stateful)
- `NotebookEdit`, `REPL`, `PowerShell` (side effects)
### Safety guarantees
- Pre/post hooks always run sequentially
- Permission checks complete before any tool executes
- Tool results are pushed to the session in the original model order
- Falls back to sequential for single-tool batches
- Thread scopes ensure all parallel work completes before `execute_batch` returns
### Impact
For a response with 5 `read_file` calls: **~5x faster** execution. The main model still sees all results in order.
---
## Feature 2: SubAgent Delegation
A `SubAgent` tool that lets the main model delegate multi-step tasks to a fast sub-agent. The sub-agent runs autonomously with its own `ConversationRuntime`, making multiple tool calls without round-tripping through the main model.
### Tool parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `prompt` | string | yes | Task description for the sub-agent |
| `task_type` | string | no | `Explore` (default), `Plan`, or `Verify` |
| `model` | string | no | Override the sub-agent model |
### Task types
| Type | Available tools | Use for |
|------|----------------|---------|
| `Explore` | read_file, glob_search, grep_search, WebFetch, WebSearch, ToolSearch, StructuredOutput | Searching code, reading files, gathering context |
| `Plan` | Explore + TodoWrite | Planning approaches with structured todo output |
| `Verify` | Plan + bash | Running tests, checking builds, verifying changes |
### Configuration
Set the sub-agent model in `~/.claw/settings.json`:
```json
{
"model": "openai/glm-5.1-fast",
"subagentModel": "openai/qwen3.6-35b-fast"
}
```
If `subagentModel` is not set, the sub-agent uses the same model as the main session (or the `model` override parameter on the tool call).
### Example
Main model prompt: "Find all Rust files that import `ConversationRuntime` and list their paths"
Without SubAgent: 5–10 sequential tool calls (grep → read each file → summarize)
With SubAgent: 1 SubAgent call → sub-agent does all the work autonomously → returns summary
**Result**: ~10x fewer tokens consumed by the main model, faster overall completion.
### Architecture
The sub-agent reuses the same building blocks as the existing `Agent` tool:
- `ProviderRuntimeClient` — API client with fallback chain
- `SubagentToolExecutor` — filtered tool access with permission enforcement
- `ConversationRuntime` — full conversation loop with hooks and compaction
- `agent_permission_policy()` — auto-approve read-only, deny write tools
Key differences from the `Agent` tool:
- **Synchronous** — blocks until complete, returns result directly
- **Lighter** — fewer default tools, focused on the task type
- **Configurable model** — uses `subagentModel` or the tool's `model` param
- **Structured output** — returns `result`, `tool_calls`, and `iterations`
---
## Changed files
| File | Changes |
|------|---------|
| `rust/crates/runtime/src/conversation.rs` | `ToolCall`, `ToolResult` types; `execute_batch` on `ToolExecutor`; 3-phase `run_turn` |
| `rust/crates/runtime/src/lib.rs` | Exports for `ToolCall`, `ToolResult` |
| `rust/crates/runtime/src/config.rs` | `subagent_model` field, `parse_optional_subagent_model()`, accessor |
| `rust/crates/runtime/src/config_validate.rs` | `subagentModel` field spec |
| `rust/crates/rusty-claude-cli/src/main.rs` | `CliToolExecutor::execute_batch` with parallel-safe classification |
| `rust/crates/tools/src/lib.rs` | `SubAgent` tool spec, `SubAgentInput`, `run_sub_agent()`, `load_subagent_model_from_config()`, `build_sub_agent_system_prompt()` |
+2
View File
@@ -116,6 +116,8 @@ mod tests {
lane_events: vec![],
derived_state: "working".to_string(),
current_blocker: None,
team_id: None,
task_id: None,
error: None,
}
}
File diff suppressed because it is too large. Load diff