mirror of
https://github.com/instructkr/claw-code.git
synced 2026-10-16 00:00:16 +08:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d7412d958 | ||
|
|
479e598cd3 | ||
|
|
89e717ec09 | ||
|
|
01e9fc3229 | ||
|
|
02c22b4211 | ||
|
|
0e54dec1c9 | ||
|
|
c4ce758d5e | ||
|
|
4a920fb281 | ||
|
|
192d40a860 | ||
|
|
a35caea528 | ||
|
|
cf8f1da360 | ||
|
|
0274174536 | ||
|
|
da6a1692cf | ||
|
|
6cddf08934 | ||
|
|
5214beab31 | ||
|
|
d9d5d5da08 | ||
|
|
592beb5584 | ||
|
|
cd04270a17 | ||
|
|
e28d9c1e25 | ||
|
|
c6a7c6ea54 | ||
|
|
19030cbbda | ||
|
|
09f3b366ba | ||
|
|
d6b8202738 | ||
|
|
12d8177608 | ||
|
|
5eeaf3b261 | ||
|
|
ceba8f603d | ||
|
|
350e907e35 | ||
|
|
734558eb31 | ||
|
|
942805175a | ||
|
|
cfa2f141d3 | ||
|
|
837ffd0f6e | ||
|
|
ab839bcfda | ||
|
|
9fadb16204 | ||
|
|
d4578586eb | ||
|
|
1efa72099d | ||
|
|
f58bf20aa3 |
No files matched your search
@@ -15,7 +15,6 @@ status-help.txt
|
||||
.port_sessions/
|
||||
# Local agent session state
|
||||
.gjc/
|
||||
.claude/
|
||||
# Nix build outputs and direnv
|
||||
.envrc.local
|
||||
result
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}]}}
|
||||
@@ -1 +0,0 @@
|
||||
{"pid":1,"start_time":"unknown","token":"8389ee99-c41c-47dd-9615-e76a5268967c","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"pid":1,"start_time":"unknown","token":"6e35ded8-fda1-421e-a727-42906ef57646","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
|
||||
Whitespace-only changes.
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"effectMarker":"7b92c148-d616-4eb5-95e8-4e95084bebe9","incarnation":"linux:159085489","pid":177576}
|
||||
@@ -1 +0,0 @@
|
||||
{"effectMarker":"7b92c148-d616-4eb5-95e8-4e95084bebe9","incarnation":"linux:159085489","pid":177576}
|
||||
+50
-1
@@ -5,6 +5,56 @@ 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.6] - 2026-10-13
|
||||
|
||||
### Added
|
||||
|
||||
- SDK session lifecycle management with session reconciliation and recovery
|
||||
|
||||
### Fixed
|
||||
|
||||
- Session lifecycle outcome serialization and preservation in recovery workflows
|
||||
|
||||
## [0.1.5] - 2026-10-13
|
||||
|
||||
### Added
|
||||
|
||||
- CLI turn interruption: press Esc to interrupt running turns and cancel in-flight model requests
|
||||
- Custom OpenAI provider namespace with separate environment variables for multi-tenant scenarios
|
||||
- `/tools` and `/info` slash commands for command discovery and runtime information
|
||||
- Team enhancements: subagentModel accessor chain and parallel tool execution support
|
||||
- Configuration options for concurrent tool calls and parallel subagent execution (`maxParallelToolCalls`, `maxConcurrentAgents`)
|
||||
- Per-team subagent concurrency control via AgentGate
|
||||
- SDK operation registry with schema validation for extensible operation handling
|
||||
- Bundled skills registry and skill chaining support for skill composition
|
||||
- Per-session settings override with schema validation
|
||||
- WebSocket client transport completion for SDK wire protocol support
|
||||
|
||||
### Fixed
|
||||
|
||||
- Turn interruption signal handling for graceful cancellation of in-flight requests
|
||||
- Markdown streaming block spacing in CLI output
|
||||
- Spinner restoration for proper is_terminal() check in run_turn
|
||||
- Model token limit catch-all to return None for unknown models instead of panicking
|
||||
- Parallel tool result ordering by call index with fan-out capping
|
||||
- Workspace unsafe_code=forbid restoration across all crates
|
||||
- TaskClaim TOCTOU (time-of-check-time-of-use) vulnerability
|
||||
- TUI dependency removal and claw-rag-service compilation on non-Linux platforms
|
||||
- Clippy lint violations in trident, compact, and claw-rag-service crates
|
||||
- PermissionMode comparison to use explicit .covers() method instead of direct operators
|
||||
- OPENAI_BASE_URL environment isolation in provider hint tests
|
||||
|
||||
### Changed
|
||||
|
||||
- Enhanced permission mode checking for consistency and clarity
|
||||
- Improved team and concurrent agent configuration schema
|
||||
- Refactored slash command processing for /tools and /info implementations
|
||||
|
||||
### 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.4] - 2026-10-13
|
||||
|
||||
### Added
|
||||
@@ -13,7 +63,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- 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
|
||||
|
||||
+4
-4
@@ -87,11 +87,11 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://star-history.com/#ultraworkers/claw-code&Date">
|
||||
<a href="https://star-history.dera.page/#ultraworkers/claw-code&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=ultraworkers/claw-code&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=ultraworkers/claw-code&type=Date" />
|
||||
<img alt="Star history for ultraworkers/claw-code" src="https://api.star-history.com/svg?repos=ultraworkers/claw-code&type=Date" width="600" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=ultraworkers/claw-code&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=ultraworkers/claw-code&type=Date" />
|
||||
<img alt="Star history for ultraworkers/claw-code" src="https://star-history.dera.page/svg?repos=ultraworkers/claw-code&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Bundled Skills Registry and Chaining
|
||||
|
||||
## Overview
|
||||
|
||||
The bundled skills registry enables claw-code to include built-in skills with each release, similar to how bundled plugins work. This feature supports skill chaining, allowing skills to invoke and compose with other skills.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Architecture
|
||||
|
||||
**Bundled Skills Root Discovery**: Skills are discovered from the following locations (in order of precedence):
|
||||
|
||||
1. `<exe_dir>/../share/claw/skills/bundled` — standard FHS install layout
|
||||
2. `<exe_dir>/bundled-skills` — simple relocated layout
|
||||
3. `CARGO_MANIFEST_DIR/bundled` — dev/source-tree fallback
|
||||
4. Default fallback if above paths don't exist
|
||||
|
||||
The directory structure mirrors the plugin system:
|
||||
```
|
||||
rust/crates/commands/bundled/
|
||||
├── example-skill/
|
||||
│ └── SKILL.md
|
||||
└── chain-example/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
### SkillOrigin Enum
|
||||
|
||||
A new `Bundled` variant was added to the `SkillOrigin` enum:
|
||||
|
||||
```rust
|
||||
enum SkillOrigin {
|
||||
SkillsDir, // User/project skills directories
|
||||
LegacyCommandsDir, // Legacy /commands directories
|
||||
Bundled, // Built-in bundled skills [NEW]
|
||||
}
|
||||
```
|
||||
|
||||
### Skill Discovery
|
||||
|
||||
The `discover_skill_roots()` function was updated to include bundled skills:
|
||||
- Bundled skills are added to the discovery roots with `SkillOrigin::Bundled`
|
||||
- They're loaded using the same mechanism as regular skills
|
||||
- Bundled skills have origin metadata ("bundled") in JSON output
|
||||
|
||||
### Skill Chaining
|
||||
|
||||
Skills can invoke other skills through standard mechanisms:
|
||||
|
||||
1. **Slash Command Invocation**: A skill can document that it chains to another skill
|
||||
```
|
||||
/skill chain-example
|
||||
```
|
||||
|
||||
2. **Tool-based Invocation**: Via the Skill tool or similar mechanisms
|
||||
|
||||
Skills marked as "bundled" indicate they're managed by the claw-code release and persist across upgrades.
|
||||
|
||||
## Example Bundled Skills
|
||||
|
||||
### `example-skill`
|
||||
A simple demonstration skill showing the bundled skills feature.
|
||||
|
||||
### `chain-example`
|
||||
An example showing how one skill can reference and chain to other skills (e.g., `example-skill`).
|
||||
|
||||
## Testing
|
||||
|
||||
Three new tests were added:
|
||||
|
||||
1. **`discovers_bundled_skills_from_binary_layout`**
|
||||
- Verifies that `discover_skill_roots()` includes bundled skills
|
||||
- Tests directory discovery logic
|
||||
|
||||
2. **`bundled_skills_have_correct_origin_metadata`**
|
||||
- Confirms `SkillOrigin::Bundled` generates correct metadata
|
||||
- Tests JSON output format
|
||||
|
||||
3. **`bundled_skills_are_included_in_json_output`**
|
||||
- Verifies loaded bundled skills appear in JSON output
|
||||
- Tests end-to-end loading flow
|
||||
|
||||
All tests pass with:
|
||||
```bash
|
||||
cargo test -p commands --lib
|
||||
```
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- Existing skill discovery paths unchanged
|
||||
- `SkillsDir` and `LegacyCommandsDir` origins fully preserved
|
||||
- No changes to skill file format or content
|
||||
- Bundled skills are additive; they don't replace user/project skills
|
||||
|
||||
## Future Enhancement Points
|
||||
|
||||
1. **Bundled Skills Registry Sync**: Auto-sync bundled skills into a system registry (similar to plugins)
|
||||
2. **Skill Dependency Resolution**: Automatic dependency discovery and loading
|
||||
3. **Skill Versioning**: Track versions of bundled skills
|
||||
4. **Skill Auto-Update**: Sync bundled skills on claw-code updates
|
||||
|
||||
## Related
|
||||
|
||||
- **Issue**: #3324 [Slice 2.2] Skills bundled registry & chaining
|
||||
- **Plugin System**: `rust/crates/plugins/src/lib.rs` (bundled plugins reference implementation)
|
||||
- **Commands Crate**: `rust/crates/commands/src/lib.rs` (implementation location)
|
||||
@@ -0,0 +1,817 @@
|
||||
# GJC Compatibility Gap Matrix
|
||||
|
||||
**Document Status**: Historical Planning Reference (updated 2026-10-13)
|
||||
**Scope**: SDK wire protocol, session lifecycle, tools, skills, settings, hooks, commands, and AGENTS.md rules
|
||||
**Target**: Rust fork-in-spirit of gajae-code (GJC) SDK-compatible and capability-compatible
|
||||
**Note**: This document captures the initial compatibility analysis and proposed roadmap. Implementation details are now tracked in focused slice-specific documents (e.g., `tool-semantics-phase1.md`). Status updates reflect work merged as of 2026-10-13 (PRs #3330, #3332, #3334, #3336, #3339).
|
||||
|
||||
---
|
||||
|
||||
## 1. EXECUTIVE SUMMARY
|
||||
|
||||
This matrix maps GJC capabilities (from gajae-code) against claw-code's current Rust implementation, identifying gaps and proposing crate/module ownership for each slice.
|
||||
|
||||
### Key Findings
|
||||
|
||||
| Area | Status | Priority | Notes |
|
||||
|------|--------|----------|-------|
|
||||
| **SDK Wire Protocol** | ✅ Implemented | P0 (Foundation) | PR #3330: WebSocket transport, frame, error types |
|
||||
| **Session Lifecycle** | 🟡 In Progress | P0 (Foundation) | SDK transport merged; session reconciliation in progress |
|
||||
| **Tool Catalog (55 tools)** | 🟡 Partial (35/55+) | P1 (Core) | Read selectors (PR #3332/#3334); /tools, /info (PR #3339) |
|
||||
| **Skill Discovery** | 🟡 Partial (basic) | P1 (Core) | No changes in recent slices |
|
||||
| **Settings & Config** | 🟡 Partial | P2 (Enhancing) | Not yet addressed in landed PRs |
|
||||
| **Hooks & Plugins** | ✅ Existing | P3 (Polish) | No changes needed |
|
||||
| **AGENTS.md Rules** | ✅ Existing | P3 (Polish) | No changes needed |
|
||||
| **Slash Commands (120+)** | 🟡 Partial (42/120+) | P2 (Core) | /tools, /info added (PR #3339) |
|
||||
| **Subagent Delegation** | ✅ Existing | P2 (Core) | No changes needed |
|
||||
|
||||
---
|
||||
|
||||
## 2. SDK WIRE PROTOCOL
|
||||
|
||||
### GJC Behavior (gajae-code Reference)
|
||||
|
||||
**Source**: `packages/coding-agent/src/sdk/client/client.ts`, `sdk/host/session-runtime.ts`
|
||||
|
||||
GJC uses a **WebSocket-based JSON-RPC-like protocol** with:
|
||||
- **Transport**: WebSocket over TCP
|
||||
- **Frame Format**: JSON object with typed fields
|
||||
- **Correlation**: Request/response matched by `frame.id` (UUID)
|
||||
- **Operations**: 65+ operation types (Q1-Q32, C01-C06, reverse ops)
|
||||
- **Error Handling**: Typed `SdkErrorCode` with `uncertain_after_send` recovery
|
||||
- **Reconnect**: Exponential backoff with deadline management
|
||||
- **Request Timeout**: 10s default (`DEFAULT_SDK_REQUEST_TIMEOUT_MS`), per-request override
|
||||
- **Handshake**: `client.hello` with capabilities advertised; `server.hello` response
|
||||
|
||||
**Operations** (sample):
|
||||
- **Control**: `turn.create`, `turn.prompt`, `turn.steer`, `turn.abort_and_prompt`
|
||||
- **Queries**: `turn.list`, `turn.get_one`, `turn.message_list`, `skill.source`, `artifact.content`
|
||||
- **Reverse**: `ask.answer`, `tool.dispatch`, `tool.fetch_capability`
|
||||
|
||||
**Event Schema**:
|
||||
```json
|
||||
{
|
||||
"id": "uuid-string",
|
||||
"operation": "turn.create",
|
||||
"input": { ... },
|
||||
"connectionId": "optional",
|
||||
"settings": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### claw-code Status
|
||||
|
||||
**Current** (as of PR #3330): Implemented. WebSocket-based JSON-RPC transport in `rust/crates/sdk/` with:
|
||||
- `transport.rs`: WebSocket client/server, frame send/recv
|
||||
- `frame.rs`: Frame struct, JSON serialization
|
||||
- `error.rs`: SDK error types with `uncertain_after_send` recovery semantics
|
||||
|
||||
Remaining: Operation type definitions (Q1-Q32, C01-C06), reverse operation handlers, session reconciliation.
|
||||
|
||||
**Files**: None
|
||||
**Lines**: 0
|
||||
|
||||
**Reality**:
|
||||
- claw-code operates as a local CLI only (stdin/stdout/stderr).
|
||||
- Sessions are file-based (`.claw/sessions/`).
|
||||
- No remote access or external SDK clients.
|
||||
- No WebSocket transport.
|
||||
- No frame correlation or reconnect logic.
|
||||
|
||||
### Gap Assessment
|
||||
|
||||
| Component | GJC | claw-code | Gap | Complexity |
|
||||
|-----------|-----|-----------|-----|------------|
|
||||
| WebSocket Transport | 1500 LOC | — | 100% | High |
|
||||
| Frame Correlation | 800 LOC | — | 100% | Medium |
|
||||
| Operation Registry | 400 LOC | 0 | 100% | Medium |
|
||||
| Error Handling & Recovery | 600 LOC | — | 100% | High |
|
||||
| Handshake & Negotiation | 200 LOC | — | 100% | Low |
|
||||
| **Total** | **3,500 LOC** | **0** | **3,500 LOC** | — |
|
||||
|
||||
### Proposed Implementation
|
||||
|
||||
**Owner Crate**: `sdk` (new)
|
||||
|
||||
**Module Structure**:
|
||||
```
|
||||
rust/crates/sdk/
|
||||
├── Cargo.toml
|
||||
└── src/
|
||||
├── lib.rs (exports + top-level API)
|
||||
├── client/
|
||||
│ ├── mod.rs (SdkClient, WebSocket transport)
|
||||
│ ├── correlation.rs (frame ID tracking, reconnect)
|
||||
│ └── errors.rs (SdkErrorCode, recovery)
|
||||
├── protocol/
|
||||
│ ├── mod.rs (Operation registry)
|
||||
│ ├── operations.rs (operation definitions)
|
||||
│ └── schema.rs (JSON schemas, frame validation)
|
||||
├── session/
|
||||
│ ├── mod.rs (session lifecycle over SDK)
|
||||
│ ├── router.rs (session routing, durable state)
|
||||
│ └── reconciliation.rs (uncertain outcome recovery)
|
||||
└── tests/
|
||||
├── mod.rs
|
||||
└── fixtures/ (mock server scenarios)
|
||||
```
|
||||
|
||||
**Size Estimate**: 4,500 LOC (Rust + test fixtures)
|
||||
|
||||
**Dependencies**:
|
||||
- `tokio` (async I/O, WebSocket)
|
||||
- `tungstenite` (WebSocket protocol)
|
||||
- `serde_json` (frame serialization)
|
||||
- `uuid` (correlation IDs)
|
||||
- `runtime` crate (session persistence bridge)
|
||||
|
||||
---
|
||||
|
||||
## 3. SESSION LIFECYCLE
|
||||
|
||||
### GJC Behavior (gajae-code Reference)
|
||||
|
||||
**Source**: `packages/coding-agent/src/sdk/host/session-runtime.ts`, `sdk/client/`, `sdk/lifecycle/`
|
||||
|
||||
GJC sessions have **three entry points**:
|
||||
1. **Native SDK** (via WebSocket): typed `SdkClient` with `session.create`, `session.prompt`, etc.
|
||||
2. **Browser ACP** (via iframe): same protocol, browser sandbox isolate
|
||||
3. **CLI Daemon** (stdio): JSON-RPC over stdio for local CLI access
|
||||
|
||||
**Session Operations**:
|
||||
- **create**: `turn.create(model, input)` → returns `sessionId`, persistent state written to broker DB
|
||||
- **prompt**: `turn.prompt(request, tools, settings)` → streams events: `agent_begin`, `tool_use`, `tool_result`, `agent_end`
|
||||
- **steer**: `turn.steer(overrides)` → mid-flight direction change (model, settings, system prompt)
|
||||
- **abort**: `turn.abort_and_prompt()` → terminal failure + new prompt in one operation
|
||||
- **list**: `turn.list(filters)` → paginated session history
|
||||
- **get_one**: `turn.get_one(sessionId, turnId)` → single turn with full details
|
||||
|
||||
**State Persistence**:
|
||||
- Invocation record (prompt request, outcome, correlation)
|
||||
- Message buffer (system, assistant, tool results, agent events)
|
||||
- Reconciliation store (uncertain → durable mapping)
|
||||
- Terminal scope (last session per correlation key)
|
||||
|
||||
**Lifecycle Events**:
|
||||
1. `turn_accepted` (immediate, transient)
|
||||
2. `agent_begin` (before first model call)
|
||||
3. `tool_use` + `tool_result` (interleaved)
|
||||
4. `agent_end` (final model output)
|
||||
5. `turn_ended` (durable, terminal outcome recorded)
|
||||
|
||||
### claw-code Status
|
||||
|
||||
**Current**: The SDK now exposes `sdk::session::SessionRouter` with durable `turn.create`, `turn.prompt`, `turn.steer`, `turn.abort_and_prompt`, and paginated `turn.list` handling. It bridges `runtime::Session`/`ConversationRuntime`, streams ordered lifecycle events, persists invocation outcomes for reconciliation, and stores terminal session scope by correlation key. The WebSocket transport remains a client transport; a remotely hosted broker and automatic recovery of an unknown post-crash outcome remain outside this slice.
|
||||
|
||||
**Files**:
|
||||
- `rust/crates/runtime/src/session.rs` (1,961 LOC)
|
||||
- `rust/crates/runtime/src/conversation.rs` (conversation loop and lifecycle event bridge)
|
||||
- `rust/crates/runtime/src/session_control.rs` (file ops, migration)
|
||||
- `rust/crates/runtime/src/lib.rs` (re-exports `Session`, `SessionStore`, `ConversationRuntime`)
|
||||
- `rust/crates/sdk/src/session/` (router, event stream, reconciliation, terminal scope)
|
||||
|
||||
**Lines**: ~3,900 LOC (session + conversation)
|
||||
|
||||
**Reality**:
|
||||
- `Session` holds persisted conversation state and `SessionStore` resolves session IDs in the workspace namespace.
|
||||
- `ConversationRuntime` drives the local agent loop and emits lifecycle events to an optional callback.
|
||||
- `sdk::session::SessionRouter` dispatches the five turn operations and exposes a per-invocation event stream.
|
||||
- Invocation terminal outcomes and correlation-key terminal scopes are durable; startup recovery marks unfinished invocations uncertain for later reconciliation.
|
||||
- A remote WebSocket server/broker and inference of an outcome that was never durably recorded are still absent.
|
||||
|
||||
### Gap Assessment
|
||||
|
||||
| Component | GJC | claw-code | Gap | Complexity |
|
||||
|-----------|-----|-----------|-----|------------|
|
||||
| Remote Session Router | 2,500 LOC | Local router/dispatch | Partial | High |
|
||||
| Invocation Reconciliation | 1,200 LOC | Durable invocation records | Partial | High |
|
||||
| Terminal Scope Tracking | 600 LOC | Implemented | — | Medium |
|
||||
| Async Event Streaming | 800 LOC | Local per-invocation stream | Partial | Medium |
|
||||
| Uncertain Outcome Recovery | 1,000 LOC | Unfinished records recovered as uncertain | Partial | High |
|
||||
| **Total** | **6,100 LOC** | **Session lifecycle slice implemented** | **Remote broker remains** | — |
|
||||
|
||||
### Proposed Implementation
|
||||
|
||||
**Owner Crate**: `sdk` (new, or extend `runtime`)
|
||||
|
||||
**Module Structure** (`sdk/session/`):
|
||||
```
|
||||
sdk/session/
|
||||
├── mod.rs (session routing and operation dispatch)
|
||||
├── reconciliation.rs (uncertain → terminal recovery)
|
||||
├── terminal_scope.rs (per-correlation state retention)
|
||||
└── events.rs (ordered lifecycle event types)
|
||||
```
|
||||
|
||||
**Integration Points**:
|
||||
- Bridge `runtime::Session` and `ConversationRuntime` into SDK lifecycle operations
|
||||
- Persist reconciliation and terminal-scope data alongside the session namespace
|
||||
- Emit lifecycle callbacks from `ConversationRuntime`
|
||||
|
||||
**Size Estimate**: 2,500 LOC (Rust, new + modifications)
|
||||
|
||||
---
|
||||
|
||||
## 4. TOOL CATALOG (55 Tools)
|
||||
|
||||
### GJC Behavior (gajae-code Reference)
|
||||
|
||||
**Source**: `packages/coding-agent/src/tools/` (55 tool files + shared harness)
|
||||
|
||||
GJC implements **55 distinct tools** across multiple domains:
|
||||
- **File I/O**: `read`, `write`, `edit`, `search`, `find`
|
||||
- **Shell**: `bash`, `sleep`
|
||||
- **Task Delegation**: `task` (subagent spawning)
|
||||
- **Workflow**: `skill`, `skill_discovery`, `goal`, `ask`
|
||||
- **Search**: `search_tool_bm25` (semantic search discovery)
|
||||
- **Image**: `image_gen` (DALL-E), `image_tag_scan`
|
||||
- **Integration**: `web_fetch`, `web_search`, `custom_tool_fetch`
|
||||
- **Developer**: `notebook_edit`, `command_repl`, `shell_repl`, `powershell`
|
||||
- **Config**: `config_read`, `config_write` (settings, modes)
|
||||
- **Structured**: `model_registry`, `cost_calculator`, `settings_apply`
|
||||
- **Workflow Gates**: `workflow_gate_answer`, `workflow_plan_approve`
|
||||
- **Move**: `move_cwd`, `move_session`
|
||||
|
||||
**Tool Execution Model**:
|
||||
1. Input deserialized from JSON schema
|
||||
2. Permission check (read-only vs. destructive)
|
||||
3. Tool invocation with typed inputs
|
||||
4. Structured output or error
|
||||
5. Result published to agent loop
|
||||
|
||||
**Permission Gating**: Every tool has a `required_permission` (read-only, approve, unsafe).
|
||||
|
||||
### claw-code Status
|
||||
|
||||
**Current** (as of PR #3332/#3334/#3339): Partial. 35+ tools implemented.
|
||||
- PR #3332/#3334: Read tool enhanced with GJC-compatible line range selectors (`:50`, `:50-200`, `:50+150`, `:raw`, `:conflicts`, etc.)
|
||||
- PR #3339: `/tools` and `/info` commands implemented
|
||||
- Still missing: image_gen (DALL-E), ask (interactive), workflow_gate_*, command_repl, shell_repl, powershell, cost_calculator
|
||||
|
||||
**Files**: `rust/crates/tools/src/lib.rs` (10,892 LOC, single file with all 55 spec + implementations)
|
||||
|
||||
**Implemented Tools** (~35):
|
||||
- `read`, `write`, `edit`, `search`, `find`
|
||||
- `bash`, `sleep`
|
||||
- `task` (subagent spawning)
|
||||
- `skill`, `skill_discovery` (basic)
|
||||
- `bash_list_*, bash_check_*` (validation helpers)
|
||||
- `web_fetch`, `search_bm25` (basic)
|
||||
- `config_*` (read/write settings)
|
||||
- `notebooks` (edit, execute)
|
||||
- `goal` (basic ledger)
|
||||
- `move_*` (cwd, session)
|
||||
- Various validation and utility tools
|
||||
|
||||
**Missing / Incomplete Tools** (~20):
|
||||
- `image_gen` (DALL-E)
|
||||
- `image_tag_scan`
|
||||
- `custom_tool_fetch`
|
||||
- `command_repl`, `shell_repl`, `powershell`
|
||||
- `model_registry`, `cost_calculator`
|
||||
- `workflow_gate_answer`, `workflow_plan_approve`
|
||||
- `ask` (interactive prompt)
|
||||
- `irc` (live coordination)
|
||||
- Search/discovery integration tools
|
||||
- Advanced structured output handling
|
||||
|
||||
### Gap Assessment
|
||||
|
||||
| Component | GJC | claw-code | Gap | Complexity |
|
||||
|-----------|-----|-----------|-----|------------|
|
||||
| Tool Registry | 400 LOC | 400 LOC | 0% | — |
|
||||
| Spec Table (55 entries) | 1,800 LOC | 1,800 LOC | 0% | — |
|
||||
| image_gen | 600 LOC | — | 100% | High |
|
||||
| command_repl, shell_repl | 900 LOC | — | 100% | Medium |
|
||||
| workflow_gate_* | 800 LOC | — | 100% | Medium |
|
||||
| custom_tool_fetch | 400 LOC | — | 100% | Low |
|
||||
| ask (interactive) | 1,200 LOC | Stub | 80% | High |
|
||||
| cost_calculator | 300 LOC | — | 100% | Low |
|
||||
| **Total** | **11,500 LOC** | **8,000** | **3,500 LOC** | — |
|
||||
|
||||
### Proposed Implementation
|
||||
|
||||
**Owner Crate**: `tools` (expand existing)
|
||||
|
||||
**New Modules**:
|
||||
```
|
||||
rust/crates/tools/src/
|
||||
├── tools/
|
||||
│ ├── image_gen.rs (DALL-E integration)
|
||||
│ ├── ask_interactive.rs (blocking user prompts)
|
||||
│ ├── workflow_gates.rs (plan approval, gate answer)
|
||||
│ ├── repl/*.rs (command_repl, shell_repl, powershell)
|
||||
│ └── cost_calc.rs (pricing lookup)
|
||||
└── vendors/
|
||||
├── dalle.rs (image generation API)
|
||||
└── integrations.rs (custom tool fetch)
|
||||
```
|
||||
|
||||
**Size Estimate**: 3,500 LOC (new tools + vendor integration)
|
||||
|
||||
**Dependencies**:
|
||||
- `reqwest` (image generation API calls)
|
||||
- `api` crate (provider client reuse)
|
||||
- Existing: `runtime`, `commands`, `serde_json`
|
||||
|
||||
---
|
||||
|
||||
## 5. SKILL DISCOVERY & INVOCATION
|
||||
|
||||
### GJC Behavior (gajae-code Reference)
|
||||
|
||||
**Source**: `packages/coding-agent/src/tools/skill-discovery.ts`, `packages/coding-agent/src/tools/skill.ts`, `packages/coding-agent/src/sdk-skills/`
|
||||
|
||||
GJC supports **dynamic skill loading** with:
|
||||
- **Discovery**: `skill_discovery` tool scans `.gjc/skills/`, project `.gjc/skills/`, bundled skills
|
||||
- **Frontmatter Parsing**: YAML headers in `SKILL.md` define metadata (name, description, use conditions)
|
||||
- **Invocation**: `skill` tool loads and executes a named skill (skill name + args)
|
||||
- **Skill Execution**: Skill = subprocess call to `gjc /skill:<name>` with args
|
||||
- **Bundled Skills**: `autoresearch`, `deep-interview`, `ralplan`, `ultragoal`
|
||||
- **Project Skills**: Discoverable from `.gjc/skills/*/SKILL.md`
|
||||
- **User Skills**: Discoverable from `~/.gjc/skills/*/SKILL.md`
|
||||
|
||||
**Skill Metadata**:
|
||||
```yaml
|
||||
# /skill:my-skill
|
||||
# description: What this skill does
|
||||
# scope: project | user | bundled
|
||||
# requires: List of capabilities
|
||||
```
|
||||
|
||||
**Skill Discovery Result**:
|
||||
```json
|
||||
{
|
||||
"name": "skill-name",
|
||||
"description": "...",
|
||||
"path": "...",
|
||||
"bundled": true | false,
|
||||
"scope": "project" | "user" | "bundled"
|
||||
}
|
||||
```
|
||||
|
||||
### claw-code Status
|
||||
|
||||
**Current**: Basic. Skill discovery exists but limited to filesystem scanning.
|
||||
|
||||
**Files**:
|
||||
- `rust/crates/tools/src/lib.rs` (skill_discovery + skill dispatch, ~200 LOC)
|
||||
- `rust/crates/runtime/src/prompt.rs` (skill loading in prompts, ~100 LOC)
|
||||
|
||||
**Implementation**:
|
||||
- Scans `.claw/skills/` directory for `SKILL.md` files
|
||||
- Parses frontmatter (basic YAML)
|
||||
- Returns discovery results as JSON
|
||||
- `skill` tool invokes skill as subprocess
|
||||
|
||||
**Missing**:
|
||||
- Bundled skill registry (autoresearch, deep-interview, ralplan, ultragoal)
|
||||
- Project + user skill discovery hierarchy
|
||||
- Frontmatter validation and standardization
|
||||
- Skill chaining (`skill()` tool to invoke another skill from within a skill)
|
||||
- Skill state persistence (workflow handoff)
|
||||
|
||||
### Gap Assessment
|
||||
|
||||
| Component | GJC | claw-code | Gap | Complexity |
|
||||
|-----------|-----|-----------|-----|------------|
|
||||
| Bundled Skill Registry | 200 LOC | — | 100% | Low |
|
||||
| Frontmatter Parser | 150 LOC | 100 LOC | 30% | Low |
|
||||
| Project + User Discovery | 300 LOC | Basic | 70% | Low |
|
||||
| Skill Chaining | 250 LOC | — | 100% | Medium |
|
||||
| Workflow State Handoff | 400 LOC | — | 100% | Medium |
|
||||
| Skill Execution Harness | 200 LOC | Partial | 40% | Low |
|
||||
| **Total** | **1,500 LOC** | **100** | **1,400 LOC** | — |
|
||||
|
||||
### Proposed Implementation
|
||||
|
||||
**Owner Crate**: `tools` (expand existing) or `skills` (new, lightweight)
|
||||
|
||||
**Module Structure**:
|
||||
```
|
||||
rust/crates/tools/src/
|
||||
├── skill_discovery.rs (enhanced parsing, project/user/bundled hierarchy)
|
||||
├── skill_execution.rs (subprocess dispatch, state handoff)
|
||||
├── bundled_skills.rs (registry of autoresearch, deep-interview, ralplan, ultragoal)
|
||||
└── skill_state.rs (workflow handoff serialization)
|
||||
```
|
||||
|
||||
**Size Estimate**: 1,400 LOC (Rust)
|
||||
|
||||
**Integration**:
|
||||
- Extend `skill_discovery` tool to search three scopes (bundled, project, user)
|
||||
- Add `skill` tool support for chaining (returning another skill invocation)
|
||||
- Store workflow state in durable files for handoff
|
||||
|
||||
---
|
||||
|
||||
## 6. SETTINGS & CONFIGURATION
|
||||
|
||||
### GJC Behavior (gajae-code Reference)
|
||||
|
||||
**Source**: `packages/coding-agent/src/config.ts`, `packages/coding-agent/src/sdk/host/session-runtime.ts`
|
||||
|
||||
GJC supports **settings override** at multiple levels:
|
||||
1. **Global Settings**: `~/.gjc/config.json` (user defaults)
|
||||
2. **Session Settings**: per-session override via `turn.create` or `turn.prompt` input
|
||||
3. **Runtime Override**: `settings` field in SDK request frames
|
||||
4. **Setting Categories**:
|
||||
- **Model**: `modelName`, `modelTemperature`, `modelMaxTokens`
|
||||
- **Behavior**: `allowance`, `permissions.*`, `features.*`
|
||||
- **UX**: `theme`, `shell`, `notification*`
|
||||
- **Advanced**: `memory.context`, `memory.compression`, `cost.*`
|
||||
|
||||
**Config Schema**: Typed with JSON schema validation per setting
|
||||
|
||||
**Permission Modes**:
|
||||
- `read_only` (no execution)
|
||||
- `approve` (interactive approval)
|
||||
- `unsafe` (no gates)
|
||||
|
||||
### claw-code Status
|
||||
|
||||
**Current**: Partial. Config exists but no session-level override or setting propagation.
|
||||
|
||||
**Files**:
|
||||
- `rust/crates/runtime/src/config.rs` (3,894 LOC, largest file in runtime)
|
||||
- `rust/crates/runtime/src/config_validate.rs` (validation helpers)
|
||||
|
||||
**Implementation**:
|
||||
- Reads `.claw/config.json` at startup
|
||||
- Stores global settings (model, permissions, plugins, features)
|
||||
- No session-level override
|
||||
- No setting propagation through tool execution
|
||||
- No typed setting validation against schema
|
||||
|
||||
**Missing**:
|
||||
- Per-session setting override (session-specific model, permissions)
|
||||
- Setting propagation to all tool invocations
|
||||
- Dynamic setting application (e.g., changing model mid-session)
|
||||
- Setting schema validation
|
||||
- Config hotreload (changes without restart)
|
||||
|
||||
### Gap Assessment
|
||||
|
||||
| Component | GJC | claw-code | Gap | Complexity |
|
||||
|-----------|-----|-----------|-----|------------|
|
||||
| Setting Schema Definition | 300 LOC | — | 100% | Low |
|
||||
| Session-Level Override | 400 LOC | — | 100% | Medium |
|
||||
| Setting Propagation | 300 LOC | — | 100% | Low |
|
||||
| Dynamic Application | 200 LOC | — | 100% | Medium |
|
||||
| Config Hotreload | 150 LOC | — | 100% | Low |
|
||||
| **Total** | **1,350 LOC** | **3,894** | **-2,544 LOC** | — |
|
||||
|
||||
### Proposed Implementation
|
||||
|
||||
**Owner Crate**: `runtime` (enhance existing `config.rs`)
|
||||
|
||||
**Additions to config.rs**:
|
||||
- Setting schema registry
|
||||
- Session setting override struct
|
||||
- Setting applicator (merge global + session + override)
|
||||
- Hotreload support (watch config file, signal on change)
|
||||
|
||||
**Size Estimate**: 500 LOC (new code, mostly schema + applicators)
|
||||
|
||||
---
|
||||
|
||||
## 7. HOOKS & PLUGINS
|
||||
|
||||
### GJC Behavior (gajae-code Reference)
|
||||
|
||||
**Source**: `packages/coding-agent/src/hooks.ts`, `packages/coding-agent/src/plugin_lifecycle.ts`
|
||||
|
||||
GJC provides **5 hook points**:
|
||||
- `on_session_created`: After session starts
|
||||
- `on_prompt_received`: Before agent loop begins
|
||||
- `on_tool_use`: Before tool invocation
|
||||
- `on_tool_result`: After tool completes
|
||||
- `on_session_ended`: Before session cleanup
|
||||
|
||||
**Plugin System**:
|
||||
- Plugins are shell scripts or binaries in `.claw/plugins/`
|
||||
- Each plugin implements hook handlers (exit code + JSON output)
|
||||
- Plugin registry stored in config
|
||||
|
||||
### claw-code Status
|
||||
|
||||
**Current**: Existing and comprehensive.
|
||||
|
||||
**Files**:
|
||||
- `rust/crates/runtime/src/hooks.rs` (L155)
|
||||
- `rust/crates/runtime/src/plugin_lifecycle.rs`
|
||||
- `rust/crates/plugins/src/` (plugin manager)
|
||||
|
||||
**Implementation**: Hooks fully implemented; plugin system working.
|
||||
|
||||
### Gap Assessment
|
||||
|
||||
| Component | GJC | claw-code | Gap |
|
||||
|-----------|-----|-----------|-----|
|
||||
| Hook Points | 5 | 5 | 0% |
|
||||
| Plugin Registry | ✓ | ✓ | 0% |
|
||||
| Plugin Execution | ✓ | ✓ | 0% |
|
||||
|
||||
**Status**: **No gaps**. Hooks and plugins are fully compatible.
|
||||
|
||||
---
|
||||
|
||||
## 8. AGENTS.MD RULES & CONTEXT
|
||||
|
||||
### GJC Behavior (gajae-code Reference)
|
||||
|
||||
**Source**: gajae-code root + `docs/` (AGENTS.md files at multiple levels)
|
||||
|
||||
GJC loads **directory-specific rules** from `AGENTS.md` files:
|
||||
- **Root AGENTS.md**: Project-level knowledge base
|
||||
- **Directory AGENTS.md**: Directory-specific rules override parent
|
||||
- **Rules Loaded**: Automatically prepended to system prompt during agent reasoning
|
||||
- **Hierarchy**: Deepest (most specific) rules override higher level
|
||||
|
||||
**Rule Content**:
|
||||
- Code organization and conventions
|
||||
- Tool restrictions (never use X in Y context)
|
||||
- Testing requirements
|
||||
- Deployment/safety gates
|
||||
- Architecture decisions
|
||||
|
||||
### claw-code Status
|
||||
|
||||
**Current**: Existing and matching GJC.
|
||||
|
||||
**Files**:
|
||||
- Root: `AGENTS.md`
|
||||
- Crate-level: `rust/crates/*/AGENTS.md`
|
||||
- Implementation: `rust/crates/runtime/src/` (loads rules)
|
||||
|
||||
**Implementation**: Rules loaded and applied to prompts.
|
||||
|
||||
### Gap Assessment
|
||||
|
||||
| Component | GJC | claw-code | Gap |
|
||||
|-----------|-----|-----------|-----|
|
||||
| Rule Loading | ✓ | ✓ | 0% |
|
||||
| Hierarchy | ✓ | ✓ | 0% |
|
||||
| Prompt Integration | ✓ | ✓ | 0% |
|
||||
|
||||
**Status**: **No gaps**. AGENTS.md system is compatible.
|
||||
|
||||
---
|
||||
|
||||
## 9. SLASH COMMANDS (120+ Commands)
|
||||
|
||||
### GJC Behavior (gajae-code Reference)
|
||||
|
||||
**Source**: `packages/coding-agent/src/commands/lib.ts` (7,200 LOC with 120+ command specs)
|
||||
|
||||
GJC implements **120+ slash commands** for REPL interaction:
|
||||
- **Session**: `/session list`, `/session create`, `/session restore`, `/session delete`
|
||||
- **Model**: `/model set`, `/model list`, `/model info`
|
||||
- **Permissions**: `/permissions set`, `/permissions show`
|
||||
- **Config**: `/config get`, `/config set`, `/config reload`
|
||||
- **Skills**: `/skills list`, `/skills install`, `/skills create`
|
||||
- **Plugins**: `/plugins list`, `/plugins install`
|
||||
- **Workflow**: `/plan`, `/review`, `/approve`, `/ultragoal`
|
||||
- **Debugging**: `/debug`, `/dump`, `/trace`
|
||||
- **Utilities**: `/help`, `/clear`, `/quit`, `/export`
|
||||
|
||||
**Command Format**: `/command [arg1] [arg2] ...` with arg parsing per command
|
||||
|
||||
### claw-code Status
|
||||
|
||||
**Current** (as of PR #3339): Partial. ~42 commands implemented out of 120+.
|
||||
- PR #3339: Added `/tools` and `/info` commands
|
||||
- Remaining: /model set/list, /theme, /cost, /dump, /trace, /tasks list/cancel, workflow commands (/plan, /review, /ultragoal), and others
|
||||
|
||||
**Files**:
|
||||
- `rust/crates/commands/src/lib.rs` (7,183 LOC)
|
||||
- Command specs in `SLASH_COMMAND_SPECS` static table
|
||||
|
||||
**Implemented Commands** (~40):
|
||||
- Session mgmt (list, create, etc.)
|
||||
- Help, status, version
|
||||
- Config (get, set, reload)
|
||||
- Skills (list, install)
|
||||
- Plugins (list, install)
|
||||
- Workspace/debug (clear, compact, etc.)
|
||||
|
||||
**Missing Commands** (~80):
|
||||
- Advanced workflow (`/plan`, `/review`, `/ultragoal`)
|
||||
- Model selection and info
|
||||
- Cost calculator (`/cost`)
|
||||
- Advanced debugging (`/dump`, `/trace`)
|
||||
- Theme management
|
||||
- Advanced permission controls
|
||||
- Task management (`/tasks list`, `/tasks cancel`)
|
||||
- IRC/coordination (`/irc`)
|
||||
- Many others
|
||||
|
||||
### Gap Assessment
|
||||
|
||||
| Component | GJC | claw-code | Gap |
|
||||
|-----------|-----|-----------|-----|
|
||||
| Command Parser | 800 LOC | 800 LOC | 0% |
|
||||
| Spec Table (120+ entries) | 2,000 LOC | 2,000 LOC | 0% |
|
||||
| Handlers (80 missing) | 3,200 LOC | 1,800 LOC | 56% |
|
||||
| Help/suggestion | 600 LOC | 400 LOC | 30% |
|
||||
| **Total** | **7,200 LOC** | **4,000** | **3,200 LOC** | — |
|
||||
|
||||
### Proposed Implementation
|
||||
|
||||
**Owner Crate**: `commands` (expand existing)
|
||||
|
||||
**New Modules**:
|
||||
```
|
||||
rust/crates/commands/src/
|
||||
├── handlers/
|
||||
│ ├── workflow.rs (/plan, /review, /approve, /ultragoal)
|
||||
│ ├── model.rs (/model set, /model list, /model info)
|
||||
│ ├── cost.rs (/cost)
|
||||
│ ├── advanced_debug.rs (/dump, /trace, /debug advanced)
|
||||
│ ├── theme.rs (/theme set, /theme list)
|
||||
│ ├── task.rs (/tasks list, /tasks cancel)
|
||||
│ └── advanced_perms.rs (advanced permission commands)
|
||||
```
|
||||
|
||||
**Size Estimate**: 3,200 LOC (new command handlers)
|
||||
|
||||
**Dependencies**: `runtime`, `tools`, `plugins`
|
||||
|
||||
---
|
||||
|
||||
## 10. SUBAGENT DELEGATION & TASK EXECUTION
|
||||
|
||||
### GJC Behavior (gajae-code Reference)
|
||||
|
||||
**Source**: `packages/coding-agent/src/tools/task.ts`, `packages/coding-agent/src/task/`
|
||||
|
||||
GJC's **subagent system** enables parallel task execution:
|
||||
- **task Tool**: Spawn parallel subtasks with independent context
|
||||
- **Task States**: spawning, running, succeeded, failed
|
||||
- **Task Isolation**: Each task has isolated session, permissions, context
|
||||
- **Task Coordination**: `irc` tool for live inter-task communication
|
||||
- **Task Monitoring**: `task.inspect`, `task.list`, `task.cancel`
|
||||
- **Artifact Sharing**: Tasks share artifact URLs but not direct state
|
||||
|
||||
**Task Spawning**:
|
||||
```json
|
||||
{
|
||||
"agent": "executor|architect|planner",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "task-1",
|
||||
"description": "...",
|
||||
"assignment": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### claw-code Status
|
||||
|
||||
**Current**: Existing and working.
|
||||
|
||||
**Files**:
|
||||
- `rust/crates/tools/src/lib.rs` (task tool, ~300 LOC)
|
||||
- `rust/crates/runtime/src/task_*.rs` (task execution, state mgmt)
|
||||
|
||||
**Implementation**: Task tool works; spawning, monitoring, and coordination functional.
|
||||
|
||||
### Gap Assessment
|
||||
|
||||
| Component | GJC | claw-code | Gap |
|
||||
|-----------|-----|-----------|-----|
|
||||
| Task Spawning | ✓ | ✓ | 0% |
|
||||
| Task Isolation | ✓ | ✓ | 0% |
|
||||
| Task Monitoring | ✓ | ✓ | 0% |
|
||||
| IRC Coordination | ✓ | ✓ | 0% |
|
||||
|
||||
**Status**: **No significant gaps**. Task execution is compatible.
|
||||
|
||||
---
|
||||
|
||||
## 11. IMPLEMENTATION ROADMAP
|
||||
|
||||
**Status Summary** (as of 2026-10-13):
|
||||
- Phase 1.1 (SDK transport): ✅ Merged via PR #3330
|
||||
- Phase 1.2 (Operation registry): 🟡 In progress
|
||||
- Phase 1.3 (Session integration): 🟡 In progress
|
||||
- Phase 2.1 (Tool enhancement): 🟡 Partial (read selectors via PR #3332/#3334)
|
||||
- Phase 3.2 (Slash commands): 🟡 Partial (/tools, /info via PR #3339)
|
||||
|
||||
### Phase 1: Foundation (SDK Wire Protocol)
|
||||
|
||||
**Slice 1.1: WebSocket Transport & Frame Correlation** ✅ (PR #3330)
|
||||
- Create `sdk` crate structure
|
||||
- Implement `SdkClient` with WebSocket connection
|
||||
- Add frame ID correlation and response matching
|
||||
- Add reconnect logic with exponential backoff
|
||||
- Tests: transport connectivity, frame correlation, reconnect behavior
|
||||
|
||||
**Slice 1.2: Operation Registry & Schema**
|
||||
- Define Operation enum (65+ operation types)
|
||||
- JSON schema validation for requests
|
||||
- Error code definitions and handlers
|
||||
- Uncertain outcome recovery protocol
|
||||
- Tests: operation parsing, schema validation, error scenarios
|
||||
|
||||
**Slice 1.3: Session SDK Integration**
|
||||
- Bridge `runtime::Session` to SDK session model
|
||||
- Implement session router (durable lookup)
|
||||
- Add invocation reconciliation store
|
||||
- Terminal scope tracking per correlation
|
||||
- Tests: session creation, routing, reconciliation
|
||||
|
||||
### Phase 2: Core Capabilities (Tools & Skills)
|
||||
|
||||
**Slice 2.1: Missing Tool Implementation**
|
||||
- `image_gen` (DALL-E integration)
|
||||
- `ask` (interactive blocking prompts)
|
||||
- `workflow_gate_*` (plan approval gates)
|
||||
- `command_repl`, `shell_repl`, `powershell`
|
||||
- `cost_calculator`
|
||||
|
||||
**Slice 2.2: Skill Enhancement**
|
||||
- Bundled skill registry (autoresearch, deep-interview, ralplan, ultragoal)
|
||||
- Enhanced skill discovery (project, user, bundled hierarchy)
|
||||
- Skill chaining support (skill calling skill)
|
||||
- Workflow state handoff
|
||||
|
||||
### Phase 3: Polish (Settings, Commands, Integration)
|
||||
|
||||
**Slice 3.1: Settings & Configuration**
|
||||
- Session-level setting override
|
||||
- Setting schema validation
|
||||
- Setting propagation to tools
|
||||
- Config hotreload
|
||||
|
||||
**Slice 3.2: Slash Commands**
|
||||
- Workflow commands (/plan, /review, /ultragoal)
|
||||
- Model selection (/model set, /model list)
|
||||
- Cost calculator (/cost)
|
||||
- Advanced debugging (/dump, /trace)
|
||||
- Task management (/tasks list, /tasks cancel)
|
||||
- Theme management (/theme)
|
||||
|
||||
---
|
||||
|
||||
## 12. DEPENDENCY & CRATE RELATIONSHIPS
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ cli (rusty-claude-cli: main entry, REPL, TUI) │
|
||||
└────────────┬───────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────▼──────────────────────────────────────────┐
|
||||
│ commands (slash command specs & handlers) │
|
||||
└────────┬────────────────────────────────────────┬──────┘
|
||||
│ │
|
||||
│ ┌───▼────────────────────┐
|
||||
│ │ tools (55 tool impl) │
|
||||
│ └──────┬─────────────────┘
|
||||
│ │
|
||||
┌────────▼─────────────────────────────────────────▼──────────────────┐
|
||||
│ runtime (session, conversation, config, mcp, hooks, permissions) │
|
||||
└────────┬──────────────────────────────┬────────────────────────────┘
|
||||
│ │
|
||||
┌────▼──────────────┐ ┌──────▼─────────────────┐
|
||||
│ api (providers) │ │ plugins (plugin mgmt) │
|
||||
└───────────────────┘ └───────────────────────┘
|
||||
|
||||
NEW: sdk (WebSocket, session routing, reconciliation)
|
||||
│
|
||||
└─→ depends on: runtime, serde_json, tokio, tungstenite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. RISK & MITIGATION
|
||||
|
||||
| Risk | Probability | Severity | Mitigation |
|
||||
|------|-------------|----------|-----------|
|
||||
| SDK protocol complexity | High | High | Start with mock transport tests, iterate on real scenarios |
|
||||
| Session state inconsistency | Medium | High | Comprehensive reconciliation tests, durable state verification |
|
||||
| Tool compatibility gaps | Medium | Medium | Tool-by-tool comparison with GJC, shadow testing |
|
||||
| Breaking changes to runtime | Medium | High | Version gates, feature flags, phased rollout |
|
||||
| Performance regression | Medium | Medium | Benchmark key paths (session create, tool dispatch), profile |
|
||||
|
||||
---
|
||||
|
||||
## 14. VERIFICATION & SIGN-OFF
|
||||
|
||||
- [ ] SDK transport connects and communicates with mock server
|
||||
- [ ] Session lifecycle operations (create, prompt, steer, abort) work end-to-end
|
||||
- [ ] Tool execution with SDK matches GJC behavior
|
||||
- [ ] Skill discovery finds all three scopes (bundled, project, user)
|
||||
- [ ] Settings override works per-session
|
||||
- [ ] Slash commands parse and dispatch correctly
|
||||
- [ ] All AGENTS.md tests pass
|
||||
- [ ] Hooks fire at correct lifecycle points
|
||||
- [ ] Subagent tasks spawn and communicate via IRC
|
||||
- [ ] Public documentation has no internal paths, hosts, or private infra
|
||||
|
||||
---
|
||||
|
||||
**Document Version**: 1.0
|
||||
**Last Updated**: 2026-10-13
|
||||
**Next Review**: After Slice 1.1 implementation complete
|
||||
Generated
+635
-660
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.4"
|
||||
version = "0.1.6"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
@@ -15,13 +15,15 @@ pub enum ProviderClient {
|
||||
|
||||
/// 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.
|
||||
/// This is intentionally separate from `ProviderKind`: Custom OpenAI and
|
||||
/// DashScope speak the OpenAI-compatible wire protocol but remain distinct
|
||||
/// configured providers.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderConfigKind {
|
||||
Anthropic,
|
||||
Xai,
|
||||
OpenAi,
|
||||
CustomOpenAi,
|
||||
DashScope,
|
||||
}
|
||||
|
||||
@@ -96,9 +98,12 @@ impl ProviderClient {
|
||||
|
||||
Ok(Self::Xai(client))
|
||||
}
|
||||
ProviderConfigKind::OpenAi | ProviderConfigKind::DashScope => {
|
||||
ProviderConfigKind::OpenAi
|
||||
| ProviderConfigKind::CustomOpenAi
|
||||
| ProviderConfigKind::DashScope => {
|
||||
let compat = match config.kind {
|
||||
ProviderConfigKind::OpenAi => OpenAiCompatConfig::openai(),
|
||||
ProviderConfigKind::CustomOpenAi => OpenAiCompatConfig::custom_openai(),
|
||||
ProviderConfigKind::DashScope => OpenAiCompatConfig::dashscope(),
|
||||
_ => unreachable!("non-OpenAI provider reached compatibility path"),
|
||||
};
|
||||
@@ -300,6 +305,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn provider_detection_prefers_model_family() {
|
||||
let _lock = env_lock();
|
||||
let _anthropic = EnvVarGuard::set("ANTHROPIC_API_KEY", None);
|
||||
let _openai = EnvVarGuard::set("OPENAI_API_KEY", None);
|
||||
let _xai = EnvVarGuard::set("XAI_API_KEY", None);
|
||||
|
||||
assert_eq!(detect_provider_kind("grok-3"), ProviderKind::Xai);
|
||||
assert_eq!(
|
||||
detect_provider_kind("claude-sonnet-4-6"),
|
||||
@@ -403,6 +413,56 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_custom_openai_uses_its_own_environment_namespace() {
|
||||
let _lock = env_lock();
|
||||
let _custom_key = EnvVarGuard::set("CLAWCUSTOMOPENAI_API_KEY", Some("custom-key"));
|
||||
let _custom_url = EnvVarGuard::set(
|
||||
"CLAWCUSTOMOPENAI_BASE_URL",
|
||||
Some("https://custom.example/v1"),
|
||||
);
|
||||
let _openai_key = EnvVarGuard::set("OPENAI_API_KEY", Some("openai-key"));
|
||||
let _openai_url = EnvVarGuard::set("OPENAI_BASE_URL", Some("https://openai.example/v1"));
|
||||
let _ollama = EnvVarGuard::set("OLLAMA_HOST", Some("http://localhost:11434"));
|
||||
|
||||
let config = ProviderConfig {
|
||||
kind: ProviderConfigKind::CustomOpenAi,
|
||||
model: "qwen-plus".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
};
|
||||
|
||||
match ProviderClient::from_config(&config).expect("custom OpenAI config should succeed") {
|
||||
ProviderClient::OpenAi(client) => {
|
||||
assert_eq!(client.base_url(), "https://custom.example/v1");
|
||||
}
|
||||
other => panic!("Expected custom OpenAI provider, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_custom_openai_does_not_reuse_openai_credentials() {
|
||||
let _lock = env_lock();
|
||||
let _custom_key = EnvVarGuard::set("CLAWCUSTOMOPENAI_API_KEY", None);
|
||||
let _custom_url = EnvVarGuard::set(
|
||||
"CLAWCUSTOMOPENAI_BASE_URL",
|
||||
Some("https://custom.example/v1"),
|
||||
);
|
||||
let _openai_key = EnvVarGuard::set("OPENAI_API_KEY", Some("openai-key"));
|
||||
let _ollama = EnvVarGuard::set("OLLAMA_HOST", Some("http://localhost:11434"));
|
||||
|
||||
let config = ProviderConfig {
|
||||
kind: ProviderConfigKind::CustomOpenAi,
|
||||
model: "gpt-5".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
};
|
||||
|
||||
let error = ProviderClient::from_config(&config)
|
||||
.expect_err("custom OpenAI must require its own credentials");
|
||||
assert!(error.to_string().contains("CLAWCUSTOMOPENAI_API_KEY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashscope_model_uses_dashscope_config_not_openai() {
|
||||
// Regression: qwen-plus was being routed to OpenAiCompatConfig::openai()
|
||||
|
||||
@@ -180,49 +180,6 @@ 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 {
|
||||
|
||||
@@ -30,9 +30,8 @@ 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, model_token_limit,
|
||||
provider_diagnostics_for_model, resolve_model_alias, ModelTokenLimit, ProviderDiagnostics,
|
||||
ProviderKind,
|
||||
model_family_identity_for, model_family_identity_for_kind, provider_diagnostics_for_model,
|
||||
resolve_model_alias, ProviderDiagnostics, ProviderKind,
|
||||
};
|
||||
pub use sse::{parse_frame, SseParser};
|
||||
pub use types::{
|
||||
|
||||
@@ -683,15 +683,13 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
||||
max_output_tokens: 8_192,
|
||||
context_window_tokens: 131_072,
|
||||
}),
|
||||
// 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,
|
||||
"qwen-max" => 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,
|
||||
"qwen-plus" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 8_192,
|
||||
context_window_tokens: 131_072,
|
||||
}),
|
||||
// Groq compound systems are exposed through an OpenAI-compatible API
|
||||
// but cap completions at 8,192 output tokens. Without this metadata
|
||||
@@ -701,8 +699,6 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ pub struct OpenAiCompatConfig {
|
||||
|
||||
const XAI_ENV_VARS: &[&str] = &["XAI_API_KEY"];
|
||||
const OPENAI_ENV_VARS: &[&str] = &["OPENAI_API_KEY"];
|
||||
const CUSTOM_OPENAI_ENV_VARS: &[&str] = &["CLAWCUSTOMOPENAI_API_KEY"];
|
||||
const DASHSCOPE_ENV_VARS: &[&str] = &["DASHSCOPE_API_KEY"];
|
||||
|
||||
// Provider-specific request body size limits in bytes
|
||||
@@ -80,6 +81,18 @@ impl OpenAiCompatConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenAI-compatible endpoint with its own environment namespace and no default URL.
|
||||
#[must_use]
|
||||
pub const fn custom_openai() -> Self {
|
||||
Self {
|
||||
provider_name: "Custom OpenAI",
|
||||
api_key_env: "CLAWCUSTOMOPENAI_API_KEY",
|
||||
base_url_env: "CLAWCUSTOMOPENAI_BASE_URL",
|
||||
default_base_url: "",
|
||||
max_request_body_bytes: OPENAI_MAX_REQUEST_BODY_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
/// Alibaba `DashScope` compatible-mode endpoint (Qwen family models).
|
||||
/// Uses the OpenAI-compatible REST shape at /compatible-mode/v1.
|
||||
/// Requested via Discord #clawcode-get-help: native Alibaba API for
|
||||
@@ -100,6 +113,7 @@ impl OpenAiCompatConfig {
|
||||
match self.provider_name {
|
||||
"xAI" => XAI_ENV_VARS,
|
||||
"OpenAI" => OPENAI_ENV_VARS,
|
||||
"Custom OpenAI" => CUSTOM_OPENAI_ENV_VARS,
|
||||
"DashScope" => DASHSCOPE_ENV_VARS,
|
||||
_ => &[],
|
||||
}
|
||||
|
||||
@@ -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.4")
|
||||
Some("claude-code/0.1.6")
|
||||
);
|
||||
assert_eq!(
|
||||
request.headers.get("anthropic-beta").map(String::as_str),
|
||||
|
||||
@@ -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.as_chunks::<4>().0 {
|
||||
v.push(f32::from_le_bytes(*chunk));
|
||||
for chunk in blob.chunks_exact(4) {
|
||||
v.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
for ((ord, t), vec) in batch.drain(..).zip(vecs.into_iter()) {
|
||||
let dim = vec.len();
|
||||
let cid = insert_chunk(conn, path, ord, &t)?;
|
||||
insert_embedding(conn, cid, dim, &vec)?;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: "chain-example"
|
||||
description: "Example bundled skill that chains to other skills"
|
||||
---
|
||||
|
||||
# Chain Example Skill
|
||||
|
||||
This skill demonstrates skill chaining capability, where one skill can invoke another skill.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/skill chain-example
|
||||
```
|
||||
|
||||
This skill chains by invoking `/skill example-skill` internally.
|
||||
|
||||
## Description
|
||||
|
||||
This skill serves as a test case for skill chaining feature in the bundled skills registry (#3324).
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: "example-skill"
|
||||
description: "Example bundled skill demonstrating skills functionality"
|
||||
---
|
||||
|
||||
# Example Bundled Skill
|
||||
|
||||
This is a sample bundled skill that demonstrates the skills bundled registry functionality.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/skill example-skill
|
||||
```
|
||||
|
||||
## Description
|
||||
|
||||
This skill serves as a test case for the bundled skills registry feature (#3324).
|
||||
+140
-26
@@ -163,6 +163,20 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
||||
argument_hint: None,
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "tools",
|
||||
aliases: &[],
|
||||
summary: "List available tools and their descriptions",
|
||||
argument_hint: None,
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "info",
|
||||
aliases: &[],
|
||||
summary: "Show runtime information about claw-code",
|
||||
argument_hint: None,
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "bughunter",
|
||||
aliases: &[],
|
||||
@@ -808,7 +822,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
||||
name: "team",
|
||||
aliases: &[],
|
||||
summary: "Manage agent teams",
|
||||
argument_hint: Some("[on|off|status]"),
|
||||
argument_hint: Some("[list|create|delete]"),
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
@@ -1091,6 +1105,8 @@ pub enum SlashCommand {
|
||||
Init,
|
||||
Diff,
|
||||
Version,
|
||||
Tools,
|
||||
Info,
|
||||
Export {
|
||||
path: Option<String>,
|
||||
},
|
||||
@@ -1191,14 +1207,10 @@ pub enum SlashCommand {
|
||||
History {
|
||||
count: Option<String>,
|
||||
},
|
||||
Lsp {
|
||||
action: Option<String>,
|
||||
target: Option<String>,
|
||||
},
|
||||
Unknown(String),
|
||||
Team {
|
||||
action: Option<String>,
|
||||
},
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -1396,6 +1408,14 @@ pub fn validate_slash_command_input(
|
||||
validate_no_args(command, &args)?;
|
||||
SlashCommand::Version
|
||||
}
|
||||
"tools" => {
|
||||
validate_no_args(command, &args)?;
|
||||
SlashCommand::Tools
|
||||
}
|
||||
"info" => {
|
||||
validate_no_args(command, &args)?;
|
||||
SlashCommand::Info
|
||||
}
|
||||
"export" => SlashCommand::Export { path: remainder },
|
||||
"session" => parse_session_command(&args)?,
|
||||
"plugin" | "plugins" | "marketplace" => parse_plugin_command(&args)?,
|
||||
@@ -1520,9 +1540,6 @@ 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()),
|
||||
}))
|
||||
}
|
||||
@@ -2234,6 +2251,7 @@ pub(crate) struct SkillCollection {
|
||||
enum SkillOrigin {
|
||||
SkillsDir,
|
||||
LegacyCommandsDir,
|
||||
Bundled,
|
||||
}
|
||||
|
||||
impl SkillOrigin {
|
||||
@@ -2241,6 +2259,7 @@ impl SkillOrigin {
|
||||
match self {
|
||||
Self::SkillsDir => None,
|
||||
Self::LegacyCommandsDir => Some("legacy /commands"),
|
||||
Self::Bundled => Some("bundled"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3113,7 +3132,7 @@ pub fn resolve_skill_path(cwd: &Path, skill: &str) -> std::io::Result<PathBuf> {
|
||||
for entry in fs::read_dir(&root.path)? {
|
||||
let entry = entry?;
|
||||
match root.origin {
|
||||
SkillOrigin::SkillsDir => {
|
||||
SkillOrigin::SkillsDir | SkillOrigin::Bundled => {
|
||||
if !entry.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
@@ -3538,6 +3557,52 @@ fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, P
|
||||
roots
|
||||
}
|
||||
|
||||
/// Returns the default bundled skills root directory.
|
||||
///
|
||||
/// Resolution order (first existing path wins):
|
||||
/// 1. `<exe_dir>/../share/claw/skills/bundled` — standard install layout
|
||||
/// 2. `<exe_dir>/bundled-skills` — simple relocated layout
|
||||
/// 3. `CARGO_MANIFEST_DIR/bundled` — dev/source-tree fallback (only if it exists)
|
||||
/// 4. `<exe_dir>/../share/claw/skills/bundled` — canonical default even if missing
|
||||
fn bundled_skill_root() -> PathBuf {
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
if let Some(exe_dir) = exe_path.parent() {
|
||||
let share_path = exe_dir
|
||||
.join("..")
|
||||
.join("share")
|
||||
.join("claw")
|
||||
.join("skills")
|
||||
.join("bundled");
|
||||
if share_path.exists() {
|
||||
return share_path;
|
||||
}
|
||||
|
||||
let adjacent = exe_dir.join("bundled-skills");
|
||||
if adjacent.exists() {
|
||||
return adjacent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("bundled");
|
||||
if dev_path.exists() {
|
||||
return dev_path;
|
||||
}
|
||||
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
if let Some(exe_dir) = exe_path.parent() {
|
||||
return exe_dir
|
||||
.join("..")
|
||||
.join("share")
|
||||
.join("claw")
|
||||
.join("skills")
|
||||
.join("bundled");
|
||||
}
|
||||
}
|
||||
|
||||
PathBuf::from("/usr/share/claw/skills/bundled")
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
|
||||
let mut roots = Vec::new();
|
||||
@@ -3700,6 +3765,14 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
|
||||
);
|
||||
}
|
||||
|
||||
let bundled_root = bundled_skill_root();
|
||||
push_unique_skill_root(
|
||||
&mut roots,
|
||||
DefinitionSource::UserClaw,
|
||||
bundled_root,
|
||||
SkillOrigin::Bundled,
|
||||
);
|
||||
|
||||
roots
|
||||
}
|
||||
|
||||
@@ -4132,7 +4205,7 @@ fn load_skills_from_roots_with_drift(roots: &[SkillRoot]) -> std::io::Result<Ski
|
||||
for entry in fs::read_dir(&root.path)? {
|
||||
let entry = entry?;
|
||||
match root.origin {
|
||||
SkillOrigin::SkillsDir => {
|
||||
SkillOrigin::SkillsDir | SkillOrigin::Bundled => {
|
||||
if !entry.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
@@ -5188,6 +5261,7 @@ fn skill_origin_id(origin: SkillOrigin) -> &'static str {
|
||||
match origin {
|
||||
SkillOrigin::SkillsDir => "skills_dir",
|
||||
SkillOrigin::LegacyCommandsDir => "legacy_commands_dir",
|
||||
SkillOrigin::Bundled => "bundled",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5408,9 +5482,10 @@ pub fn handle_slash_command(
|
||||
| SlashCommand::OutputStyle { .. }
|
||||
| SlashCommand::AddDir { .. }
|
||||
| SlashCommand::History { .. }
|
||||
| SlashCommand::Lsp { .. }
|
||||
| SlashCommand::Team { .. }
|
||||
| SlashCommand::Setup
|
||||
| SlashCommand::Tools
|
||||
| SlashCommand::Info
|
||||
| SlashCommand::Unknown(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -5418,14 +5493,15 @@ pub fn handle_slash_command(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
classify_skills_slash_command, handle_agents_slash_command_json,
|
||||
classify_skills_slash_command, discover_skill_roots, handle_agents_slash_command_json,
|
||||
handle_plugins_slash_command, handle_skills_slash_command_json, handle_slash_command,
|
||||
load_agents_from_roots, load_skills_from_roots, render_agents_report,
|
||||
render_agents_report_json, render_mcp_report_json_for, render_plugins_report,
|
||||
render_plugins_report_with_failures, render_skills_report, render_slash_command_help,
|
||||
render_slash_command_help_detail, resolve_skill_path, resume_supported_slash_commands,
|
||||
slash_command_specs, suggest_slash_commands, validate_slash_command_input, AgentCollection,
|
||||
DefinitionSource, SkillOrigin, SkillRoot, SkillSlashDispatch, SlashCommand,
|
||||
skill_origin_id, skill_origin_json, slash_command_specs, suggest_slash_commands,
|
||||
validate_slash_command_input, AgentCollection, DefinitionSource, SkillOrigin, SkillRoot,
|
||||
SkillSlashDispatch, SlashCommand,
|
||||
};
|
||||
use plugins::{
|
||||
PluginError, PluginKind, PluginLifecycle, PluginLoadFailure, PluginManager,
|
||||
@@ -5541,16 +5617,6 @@ 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))
|
||||
@@ -6038,7 +6104,7 @@ mod tests {
|
||||
assert!(!help.contains("/login"));
|
||||
assert!(!help.contains("/logout"));
|
||||
assert!(help.contains("/setup"));
|
||||
assert_eq!(slash_command_specs().len(), 140);
|
||||
assert_eq!(slash_command_specs().len(), 142);
|
||||
assert!(resume_supported_slash_commands().len() >= 39);
|
||||
}
|
||||
|
||||
@@ -6263,6 +6329,8 @@ mod tests {
|
||||
assert!(handle_slash_command("/mcp list", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command("/tools", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command("/info", &session, CompactionConfig::default()).is_none());
|
||||
assert!(
|
||||
handle_slash_command("/export note.txt", &session, CompactionConfig::default())
|
||||
.is_none()
|
||||
@@ -7224,4 +7292,50 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
let _ = fs::remove_dir_all(bundled_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_bundled_skills_from_binary_layout() {
|
||||
let cwd = temp_dir("bundled-skills-discovery");
|
||||
let roots = discover_skill_roots(&cwd);
|
||||
|
||||
let has_bundled = roots.iter().any(|root| root.origin == SkillOrigin::Bundled);
|
||||
assert!(
|
||||
has_bundled,
|
||||
"discover_skill_roots should include a bundled skills root"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(cwd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_skills_have_correct_origin_metadata() {
|
||||
let bundled_origin = SkillOrigin::Bundled;
|
||||
assert_eq!(skill_origin_id(bundled_origin), "bundled");
|
||||
assert_eq!(bundled_origin.detail_label(), Some("bundled"));
|
||||
|
||||
let bundled_json = skill_origin_json(bundled_origin);
|
||||
assert_eq!(bundled_json["id"], "bundled");
|
||||
assert_eq!(bundled_json["detail_label"], "bundled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_skills_are_included_in_json_output() {
|
||||
let cwd = temp_dir("bundled-skills-json-test");
|
||||
let roots = discover_skill_roots(&cwd);
|
||||
|
||||
let bundled_root = roots
|
||||
.iter()
|
||||
.find(|r| r.origin == SkillOrigin::Bundled)
|
||||
.expect("should have bundled skills root");
|
||||
|
||||
let loaded_skills =
|
||||
load_skills_from_roots(&[bundled_root.clone()]).expect("should load bundled skills");
|
||||
|
||||
assert!(
|
||||
!loaded_skills.is_empty() || bundled_root.path.exists(),
|
||||
"bundled skills should either load successfully or root should exist"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(cwd);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,20 +164,6 @@ 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.
|
||||
@@ -851,15 +837,6 @@ 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 {
|
||||
@@ -942,29 +919,6 @@ 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
|
||||
@@ -1025,20 +979,6 @@ 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;
|
||||
@@ -1821,51 +1761,6 @@ 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());
|
||||
@@ -2737,49 +2632,6 @@ 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::{
|
||||
@@ -4177,151 +4029,4 @@ 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");
|
||||
}
|
||||
}
|
||||
+1043
-338
File diff suppressed because it is too large.
Load diff
@@ -41,6 +41,7 @@ mod report_schema;
|
||||
pub mod sandbox;
|
||||
mod session;
|
||||
pub mod session_control;
|
||||
pub mod session_settings;
|
||||
pub mod trident;
|
||||
pub use session_control::SessionStore;
|
||||
mod sse;
|
||||
@@ -67,16 +68,15 @@ pub use compact::{
|
||||
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
||||
};
|
||||
pub use config::{
|
||||
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,
|
||||
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,
|
||||
};
|
||||
pub use config_validate::{
|
||||
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
|
||||
@@ -84,8 +84,8 @@ pub use config_validate::{
|
||||
};
|
||||
pub use conversation::{
|
||||
auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent,
|
||||
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolCall, ToolError,
|
||||
ToolExecutor, ToolResult, TurnProgressReporter, TurnSummary,
|
||||
ConversationEvent, ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor,
|
||||
ToolError, ToolExecutor, TurnControlSignal, TurnInterruptSignal, TurnSummary,
|
||||
};
|
||||
pub use file_ops::{
|
||||
edit_file, edit_file_in_workspace, glob_search, glob_search_in_workspace, grep_search,
|
||||
@@ -180,6 +180,10 @@ pub use session::{
|
||||
ContentBlock, ConversationMessage, MessageRole, Session, SessionCompaction, SessionError,
|
||||
SessionFork, SessionHeartbeat, SessionLiveness, SessionPromptEntry,
|
||||
};
|
||||
pub use session_settings::{
|
||||
ConfigHotreloadMonitor, SessionSettings, SettingApplicator, SettingCategory, SettingSchema,
|
||||
SettingSchemaRegistry, SettingType,
|
||||
};
|
||||
pub use sse::{IncrementalSseParser, SseEvent};
|
||||
pub use stale_base::{
|
||||
check_base_commit, format_stale_base_warning, read_claw_base_file, resolve_expected_base,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -29,7 +29,7 @@ impl Default for TridentConfig {
|
||||
}
|
||||
|
||||
/// Statistics from a Trident compaction run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TridentStats {
|
||||
pub superseded_count: usize,
|
||||
pub collapsed_chains: usize,
|
||||
@@ -41,6 +41,21 @@ 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 {
|
||||
@@ -177,7 +192,7 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
|
||||
|
||||
let mut obsolete_indices: BTreeSet<usize> = BTreeSet::new();
|
||||
|
||||
for ops in file_ops.values() {
|
||||
for (_path, ops) in &file_ops {
|
||||
if ops.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
@@ -190,9 +205,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.op_type == FileOp::Write
|
||||
|| op.op_type == FileOp::Edit)
|
||||
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)
|
||||
&& op.index < last_write
|
||||
{
|
||||
obsolete_indices.insert(op.index);
|
||||
@@ -310,7 +325,7 @@ fn stage2_collapse(
|
||||
usage: None,
|
||||
});
|
||||
} else {
|
||||
result.append(&mut buffer);
|
||||
result.extend(buffer.drain(..));
|
||||
}
|
||||
buffer.clear();
|
||||
result.push(msg.clone());
|
||||
@@ -461,7 +476,7 @@ fn stage3_cluster(
|
||||
}
|
||||
|
||||
let total_clustered: usize = cluster_assignments.len();
|
||||
let clusters_found = cluster_id;
|
||||
let clusters_found = cluster_id as usize;
|
||||
|
||||
let mut result: Vec<ConversationMessage> = Vec::new();
|
||||
let mut cluster_buffers: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||
@@ -662,7 +677,7 @@ fn truncate_text(text: &str, max_chars: usize) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::compact::CompactionConfig;
|
||||
use crate::session::{ContentBlock, ConversationMessage, Session};
|
||||
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
||||
|
||||
#[test]
|
||||
fn stage1_removes_obsolete_file_reads() {
|
||||
@@ -721,7 +736,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}"),
|
||||
}]));
|
||||
@@ -752,9 +767,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,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ pub enum WorkerStatus {
|
||||
ToolPermissionRequired,
|
||||
ReadyForPrompt,
|
||||
Running,
|
||||
/// The user stopped the turn mid-run (e.g. Esc or Ctrl+C); the worker
|
||||
/// is back at the prompt with the session intact.
|
||||
Interrupted,
|
||||
Finished,
|
||||
Failed,
|
||||
}
|
||||
@@ -46,6 +49,7 @@ impl std::fmt::Display for WorkerStatus {
|
||||
Self::ToolPermissionRequired => write!(f, "tool_permission_required"),
|
||||
Self::ReadyForPrompt => write!(f, "ready_for_prompt"),
|
||||
Self::Running => write!(f, "running"),
|
||||
Self::Interrupted => write!(f, "interrupted"),
|
||||
Self::Finished => write!(f, "finished"),
|
||||
Self::Failed => write!(f, "failed"),
|
||||
}
|
||||
@@ -82,6 +86,7 @@ pub enum WorkerEventKind {
|
||||
PromptMisdelivery,
|
||||
PromptReplayArmed,
|
||||
Running,
|
||||
Interrupted,
|
||||
Restarted,
|
||||
Finished,
|
||||
Failed,
|
||||
@@ -1461,6 +1466,23 @@ mod tests {
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn interrupted_status_serializes_and_displays_as_snake_case() {
|
||||
assert_eq!(WorkerStatus::Interrupted.to_string(), "interrupted");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&WorkerStatus::Interrupted).expect("should serialize"),
|
||||
"\"interrupted\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<WorkerStatus>("\"interrupted\"").expect("should deserialize"),
|
||||
WorkerStatus::Interrupted
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&WorkerEventKind::Interrupted).expect("should serialize"),
|
||||
"\"interrupted\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlisted_trust_prompt_auto_resolves_then_reaches_ready_state() {
|
||||
let registry = WorkerRegistry::new();
|
||||
|
||||
@@ -14,7 +14,6 @@ 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,16 +24,11 @@ tokio = { version = "1", features = ["rt-multi-thread", "signal", "time"] }
|
||||
tools = { path = "../tools" }
|
||||
log = "0.4"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
nix = { version = "0.29", features = ["term", "poll"] }
|
||||
|
||||
[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"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
mock-anthropic-service = { path = "../mock-anthropic-service" }
|
||||
|
||||
@@ -18,8 +18,6 @@ pub enum ReadOutcome {
|
||||
Submit(String),
|
||||
Cancel,
|
||||
Exit,
|
||||
ProviderSwap,
|
||||
TeamToggle,
|
||||
}
|
||||
|
||||
struct SlashCommandHelper {
|
||||
@@ -88,19 +86,12 @@ 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);
|
||||
// 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)
|
||||
}
|
||||
Cow::Borrowed(line)
|
||||
}
|
||||
|
||||
fn highlight_char(&self, line: &str, _pos: usize, _kind: CmdKind) -> bool {
|
||||
self.set_current_line(line);
|
||||
// Re-highlight when sentinel is present to show the prompt
|
||||
line.contains('\x01')
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,18 +115,6 @@ 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(),
|
||||
@@ -168,18 +147,7 @@ impl LineEditor {
|
||||
}
|
||||
|
||||
match self.editor.readline(&self.prompt) {
|
||||
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))
|
||||
}
|
||||
Ok(line) => Ok(ReadOutcome::Submit(line)),
|
||||
Err(ReadlineError::Interrupted) => {
|
||||
let has_input = !self.current_line().is_empty();
|
||||
self.finish_interrupted_read()?;
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Esc-key interruption for running turns.
|
||||
//!
|
||||
//! While a turn runs, the main thread is blocked inside the conversation
|
||||
//! loop, so rustyline is not reading the keyboard. This module spawns a
|
||||
//! listener thread that switches stdin to a minimal non-canonical mode
|
||||
//! (line buffering and echo off; signal handling and output processing
|
||||
//! stay enabled, unlike full raw mode) and watches for a lone Escape
|
||||
//! byte. When one arrives it trips the shared [`TurnInterruptSignal`],
|
||||
//! which the conversation loop and the streaming API client poll to wind
|
||||
//! the turn down gracefully.
|
||||
//!
|
||||
//! Permission prompts read whole lines from stdin mid-turn, so stdin
|
||||
//! ownership is coordinated through [`StdinPromptGate`]: while a prompt
|
||||
//! holds a lease, the listener restores canonical mode and stops
|
||||
//! consuming bytes.
|
||||
|
||||
use std::io::IsTerminal;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use runtime::TurnInterruptSignal;
|
||||
|
||||
/// Terminal-mode bookkeeping guarded by the gate mutex. `saved` holds the
|
||||
/// canonical-mode termios while non-canonical mode is active.
|
||||
#[derive(Default)]
|
||||
struct TtyMode {
|
||||
#[cfg(unix)]
|
||||
saved: Option<nix::sys::termios::Termios>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct GateState {
|
||||
/// True while a permission prompt owns stdin.
|
||||
busy: AtomicBool,
|
||||
/// Tells the listener thread to exit at the end of the turn.
|
||||
stop: AtomicBool,
|
||||
/// Serializes stdin reads and terminal-mode flips.
|
||||
mode: Mutex<TtyMode>,
|
||||
}
|
||||
|
||||
/// Classification of one chunk of bytes read from the terminal.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum EscClassification {
|
||||
/// A bare ESC byte: the user pressed the Escape key.
|
||||
LoneEscape,
|
||||
/// ESC followed by more bytes: the prefix of a terminal escape
|
||||
/// sequence such as an arrow or function key.
|
||||
EscapeSequence,
|
||||
/// Anything else (regular type-ahead, control characters, ...).
|
||||
Other,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
fn classify_escape_chunk(bytes: &[u8]) -> EscClassification {
|
||||
match bytes {
|
||||
[0x1b] => EscClassification::LoneEscape,
|
||||
[0x1b, ..] => EscClassification::EscapeSequence,
|
||||
_ => EscClassification::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands exclusive stdin ownership to interactive prompts while the
|
||||
/// Esc listener is running.
|
||||
#[derive(Clone)]
|
||||
pub struct StdinPromptGate {
|
||||
shared: Arc<GateState>,
|
||||
}
|
||||
|
||||
impl StdinPromptGate {
|
||||
/// Takes stdin away from the Esc listener for the lifetime of the
|
||||
/// returned lease: the terminal returns to canonical mode so a
|
||||
/// line-based prompt (e.g. the permission approval prompt) behaves
|
||||
/// normally. Listening resumes when the lease is dropped.
|
||||
pub fn lease(&self) -> StdinPromptLease<'_> {
|
||||
self.shared.busy.store(true, Ordering::SeqCst);
|
||||
// Wait for the listener's current poll cycle to release the
|
||||
// terminal, then restore canonical mode for line input.
|
||||
#[allow(unused_mut)]
|
||||
let mut mode = self
|
||||
.shared
|
||||
.mode
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner);
|
||||
#[cfg(unix)]
|
||||
tty::restore(&mut mode);
|
||||
drop(mode);
|
||||
StdinPromptLease {
|
||||
shared: &self.shared,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII lease returned by [`StdinPromptGate::lease`].
|
||||
pub struct StdinPromptLease<'a> {
|
||||
shared: &'a GateState,
|
||||
}
|
||||
|
||||
impl Drop for StdinPromptLease<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.shared.busy.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Background listener that maps a lone Esc keypress to
|
||||
/// [`TurnInterruptSignal::interrupt`].
|
||||
pub struct EscapeInterruptMonitor {
|
||||
shared: Arc<GateState>,
|
||||
join_handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl EscapeInterruptMonitor {
|
||||
/// Spawns the listener for the duration of one turn. Returns `None`
|
||||
/// when stdin is not an interactive terminal or the platform does not
|
||||
/// support terminal-mode switching; Ctrl+C interruption still works
|
||||
/// in those cases.
|
||||
pub fn spawn(signal: TurnInterruptSignal) -> Option<(Self, StdinPromptGate)> {
|
||||
if !std::io::stdin().is_terminal() {
|
||||
return None;
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = signal;
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let shared = Arc::new(GateState::default());
|
||||
let thread_shared = Arc::clone(&shared);
|
||||
let join_handle = std::thread::spawn(move || listener_loop(&thread_shared, &signal));
|
||||
let gate = StdinPromptGate {
|
||||
shared: Arc::clone(&shared),
|
||||
};
|
||||
Some((
|
||||
Self {
|
||||
shared,
|
||||
join_handle: Some(join_handle),
|
||||
},
|
||||
gate,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops the listener and restores the terminal before control
|
||||
/// returns to the line editor.
|
||||
pub fn stop(mut self) {
|
||||
self.shared.stop.store(true, Ordering::SeqCst);
|
||||
if let Some(join_handle) = self.join_handle.take() {
|
||||
let _ = join_handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn listener_loop(shared: &GateState, signal: &TurnInterruptSignal) {
|
||||
let mut buffer = [0u8; 64];
|
||||
|
||||
while !shared.stop.load(Ordering::SeqCst) {
|
||||
if shared.busy.load(Ordering::SeqCst) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(25));
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut mode = shared.mode.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if shared.busy.load(Ordering::SeqCst) || shared.stop.load(Ordering::SeqCst) {
|
||||
continue;
|
||||
}
|
||||
if !tty::enable_noncanonical(&mut mode) {
|
||||
// Terminal refused the mode switch; Esc support is
|
||||
// unavailable for this turn but Ctrl+C still works.
|
||||
return;
|
||||
}
|
||||
if !tty::wait_readable(100) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let count = tty::read_pending(&mut buffer);
|
||||
if classify_escape_chunk(&buffer[..count]) == EscClassification::LoneEscape {
|
||||
// A slow terminal may split an escape sequence across reads;
|
||||
// only treat ESC as the Esc key when nothing follows it.
|
||||
if tty::wait_readable(50) {
|
||||
let _ = tty::read_pending(&mut buffer);
|
||||
continue;
|
||||
}
|
||||
signal.interrupt();
|
||||
}
|
||||
// Other bytes are intentionally swallowed: type-ahead is not
|
||||
// preserved while a turn is running.
|
||||
}
|
||||
|
||||
let mut mode = shared.mode.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
tty::restore(&mut mode);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod tty {
|
||||
use std::os::fd::{AsFd, AsRawFd};
|
||||
|
||||
use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
|
||||
use nix::sys::termios::{self, LocalFlags, SetArg, SpecialCharacterIndices};
|
||||
|
||||
use super::TtyMode;
|
||||
|
||||
/// Disables line buffering and echo while leaving signal generation
|
||||
/// (ISIG) and output post-processing (OPOST) untouched, so Ctrl+C and
|
||||
/// streamed output keep working. No-op when already active.
|
||||
pub(super) fn enable_noncanonical(mode: &mut TtyMode) -> bool {
|
||||
if mode.saved.is_some() {
|
||||
return true;
|
||||
}
|
||||
let stdin = std::io::stdin();
|
||||
let Ok(saved) = termios::tcgetattr(&stdin) else {
|
||||
return false;
|
||||
};
|
||||
let mut noncanonical = saved.clone();
|
||||
noncanonical.local_flags &= !(LocalFlags::ICANON | LocalFlags::ECHO);
|
||||
noncanonical.control_chars[SpecialCharacterIndices::VMIN as usize] = 0;
|
||||
noncanonical.control_chars[SpecialCharacterIndices::VTIME as usize] = 0;
|
||||
if termios::tcsetattr(&stdin, SetArg::TCSANOW, &noncanonical).is_err() {
|
||||
return false;
|
||||
}
|
||||
mode.saved = Some(saved);
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn restore(mode: &mut TtyMode) {
|
||||
if let Some(saved) = mode.saved.take() {
|
||||
let _ = termios::tcsetattr(std::io::stdin(), SetArg::TCSANOW, &saved);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn wait_readable(timeout_ms: u16) -> bool {
|
||||
let stdin = std::io::stdin();
|
||||
let mut fds = [PollFd::new(stdin.as_fd(), PollFlags::POLLIN)];
|
||||
matches!(poll(&mut fds, PollTimeout::from(timeout_ms)), Ok(ready) if ready > 0)
|
||||
&& fds[0]
|
||||
.revents()
|
||||
.is_some_and(|revents| revents.contains(PollFlags::POLLIN))
|
||||
}
|
||||
|
||||
pub(super) fn read_pending(buffer: &mut [u8]) -> usize {
|
||||
nix::unistd::read(std::io::stdin().as_raw_fd(), buffer).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{classify_escape_chunk, EscClassification};
|
||||
|
||||
#[test]
|
||||
fn lone_escape_byte_is_the_esc_key() {
|
||||
assert_eq!(
|
||||
classify_escape_chunk(&[0x1b]),
|
||||
EscClassification::LoneEscape
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_followed_by_more_bytes_is_a_sequence() {
|
||||
// Up arrow: ESC [ A
|
||||
assert_eq!(
|
||||
classify_escape_chunk(&[0x1b, 0x5b, 0x41]),
|
||||
EscClassification::EscapeSequence
|
||||
);
|
||||
// Alt+f style: ESC f
|
||||
assert_eq!(
|
||||
classify_escape_chunk(&[0x1b, b'f']),
|
||||
EscClassification::EscapeSequence
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_bytes_are_ignored() {
|
||||
assert_eq!(classify_escape_chunk(b"hello"), EscClassification::Other);
|
||||
assert_eq!(classify_escape_chunk(&[]), EscClassification::Other);
|
||||
assert_eq!(classify_escape_chunk(&[0x03]), EscClassification::Other);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,7 +9,7 @@ const PROVIDERS: &[(&str, &str, &str)] = &[
|
||||
("2", "xAI / Grok", "xai"),
|
||||
("3", "OpenAI", "openai"),
|
||||
("4", "DashScope (Qwen/Kimi)", "dashscope"),
|
||||
("5", "Custom (OpenAI-compat)", "openai"),
|
||||
("5", "Custom (OpenAI-compat)", "custom-openai"),
|
||||
];
|
||||
|
||||
const PROVIDER_MODELS: &[(&str, &[&str])] = &[
|
||||
@@ -27,6 +27,8 @@ const DEFAULT_BASE_URLS: &[(&str, &str)] = &[
|
||||
"dashscope",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
),
|
||||
// Custom OpenAI has no default; user must supply.
|
||||
("custom-openai", ""),
|
||||
];
|
||||
|
||||
const API_KEY_ENV_VARS: &[(&str, &str)] = &[
|
||||
@@ -34,6 +36,7 @@ const API_KEY_ENV_VARS: &[(&str, &str)] = &[
|
||||
("xai", "XAI_API_KEY"),
|
||||
("openai", "OPENAI_API_KEY"),
|
||||
("dashscope", "DASHSCOPE_API_KEY"),
|
||||
("custom-openai", "CLAWCUSTOMOPENAI_API_KEY"),
|
||||
];
|
||||
|
||||
pub fn run_setup_wizard() -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -177,6 +180,7 @@ fn prompt_base_url(
|
||||
"xai" => "XAI_BASE_URL",
|
||||
"openai" => "OPENAI_BASE_URL",
|
||||
"dashscope" => "DASHSCOPE_BASE_URL",
|
||||
"custom-openai" => "CLAWCUSTOMOPENAI_BASE_URL",
|
||||
_ => "BASE_URL",
|
||||
};
|
||||
let env_set = std::env::var(env_var).ok().is_some_and(|v| !v.is_empty());
|
||||
@@ -214,7 +218,11 @@ fn prompt_model(
|
||||
if !aliases.is_empty() {
|
||||
println!(" Common: {}", aliases.join(", "));
|
||||
}
|
||||
println!(" Or enter any model name (e.g. openai/gpt-4.1-mini for custom routing)");
|
||||
if kind == "custom-openai" {
|
||||
println!(" Or enter any model name (e.g. custom/gpt-4.1 for custom routing)");
|
||||
} else {
|
||||
println!(" Or enter any model name (e.g. openai/gpt-4.1-mini for custom routing)");
|
||||
}
|
||||
|
||||
let input = read_line(&format!(" Model [{current_model}]: "))?;
|
||||
if input.trim().is_empty() {
|
||||
|
||||
@@ -453,7 +453,7 @@ fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
|
||||
|
||||
for (command, expected_kind, expected_status) in [
|
||||
("/version", "version", "ok"),
|
||||
("/sandbox", "sandbox", "warn"),
|
||||
("/sandbox", "sandbox", "ok"),
|
||||
("/diff", "diff", "ok"),
|
||||
("/status", "status", "ok"),
|
||||
] {
|
||||
@@ -470,10 +470,19 @@ fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
|
||||
.unwrap_or_else(|_| panic!("{command} must emit JSON (#831), got: {stdout:?}"));
|
||||
|
||||
assert_eq!(parsed["kind"], expected_kind, "{command} kind: {parsed}");
|
||||
assert_eq!(
|
||||
parsed["status"], expected_status,
|
||||
"{command} status: {parsed}"
|
||||
);
|
||||
// /sandbox status varies by environment: "ok" when namespace isolation is available,
|
||||
// "warn" when running in restricted environments (e.g., CI without unshare support)
|
||||
if command == "/sandbox" {
|
||||
assert!(
|
||||
parsed["status"] == "ok" || parsed["status"] == "warn",
|
||||
"{command} status should be 'ok' or 'warn' but got: {parsed}"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
parsed["status"], expected_status,
|
||||
"{command} status: {parsed}"
|
||||
);
|
||||
}
|
||||
assert_ne!(
|
||||
parsed["error_kind"], "interactive_only",
|
||||
"{command} must not emit interactive_only (#831): {parsed}"
|
||||
|
||||
@@ -6,13 +6,17 @@ publish = false
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-tungstenite = "0.23"
|
||||
tokio-tungstenite = { version = "0.23", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = "0.3"
|
||||
serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
thiserror = "1"
|
||||
tracing = "0.1"
|
||||
url = "2"
|
||||
runtime = { path = "../runtime" }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
tempfile = "3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
+147
-40
@@ -1,102 +1,184 @@
|
||||
//! SDK error types and result type.
|
||||
//! Typed SDK wire protocol errors and request recovery records.
|
||||
|
||||
use std::fmt;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
/// SDK wire protocol error codes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ErrorCode {
|
||||
/// Invalid JSON or malformed frame
|
||||
pub enum SdkErrorCode {
|
||||
InvalidInput,
|
||||
/// Unknown broker operation
|
||||
UnknownOperation,
|
||||
/// Payload too large (>4 MiB)
|
||||
NotFound,
|
||||
PayloadTooLarge,
|
||||
/// Authentication token mismatch
|
||||
Unauthorized,
|
||||
/// Broker or session unavailable
|
||||
EndpointCredentialForbidden,
|
||||
Unavailable,
|
||||
/// WebSocket connection error
|
||||
/// Requested session state conflicts with an active invocation
|
||||
Conflict,
|
||||
ConnectionError,
|
||||
/// Request timeout
|
||||
ConnectionClosed,
|
||||
Timeout,
|
||||
/// Frame parsing or validation error
|
||||
FrameError,
|
||||
/// Request/response correlation failure
|
||||
CorrelationError,
|
||||
/// Other unclassified error
|
||||
ProtocolError,
|
||||
ReconnectExhausted,
|
||||
UncertainAfterSend,
|
||||
Internal,
|
||||
}
|
||||
|
||||
impl fmt::Display for ErrorCode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
impl SdkErrorCode {
|
||||
/// Return the stable wire representation of this error code.
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
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"),
|
||||
Self::InvalidInput => "invalid_input",
|
||||
Self::UnknownOperation => "unknown_operation",
|
||||
Self::NotFound => "not_found",
|
||||
Self::PayloadTooLarge => "payload_too_large",
|
||||
Self::Unauthorized => "unauthorized",
|
||||
Self::EndpointCredentialForbidden => "endpoint_credential_forbidden",
|
||||
Self::Unavailable => "unavailable",
|
||||
Self::Conflict => "conflict",
|
||||
Self::ConnectionError => "connection_error",
|
||||
Self::ConnectionClosed => "connection_closed",
|
||||
Self::Timeout => "timeout",
|
||||
Self::FrameError => "frame_error",
|
||||
Self::CorrelationError => "correlation_error",
|
||||
Self::ProtocolError => "protocol_error",
|
||||
Self::ReconnectExhausted => "reconnect_exhausted",
|
||||
Self::UncertainAfterSend => "uncertain_after_send",
|
||||
Self::Internal => "internal_error",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a server error code, mapping unrecognized values to `Internal`.
|
||||
pub fn from_wire(value: &str) -> Self {
|
||||
match value {
|
||||
"invalid_input" => Self::InvalidInput,
|
||||
"unknown_operation" => Self::UnknownOperation,
|
||||
"not_found" => Self::NotFound,
|
||||
"payload_too_large" => Self::PayloadTooLarge,
|
||||
"unauthorized" => Self::Unauthorized,
|
||||
"endpoint_credential_forbidden" => Self::EndpointCredentialForbidden,
|
||||
"unavailable" => Self::Unavailable,
|
||||
"conflict" => Self::Conflict,
|
||||
"connection_error" => Self::ConnectionError,
|
||||
"connection_closed" => Self::ConnectionClosed,
|
||||
"timeout" => Self::Timeout,
|
||||
"frame_error" => Self::FrameError,
|
||||
"correlation_error" => Self::CorrelationError,
|
||||
"protocol_error" => Self::ProtocolError,
|
||||
"reconnect_exhausted" => Self::ReconnectExhausted,
|
||||
"uncertain_after_send" => Self::UncertainAfterSend,
|
||||
"internal" | "internal_error" => Self::Internal,
|
||||
_ => Self::Internal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SdkErrorCode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Identity retained when a request may have reached the broker but its reply was lost.
|
||||
///
|
||||
/// Records deliberately contain no input payload so credentials and other request data
|
||||
/// are not retained solely for diagnostics. Callers can use the UUID to reconcile the
|
||||
/// request with an operation-specific lookup before deciding whether to retry.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SdkSentRecord {
|
||||
pub id: Uuid,
|
||||
pub operation: Option<String>,
|
||||
pub sent_at: SystemTime,
|
||||
}
|
||||
|
||||
/// SDK wire protocol error.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SdkError {
|
||||
pub code: ErrorCode,
|
||||
pub code: SdkErrorCode,
|
||||
pub message: String,
|
||||
/// Original server code when it is not represented by `SdkErrorCode`.
|
||||
pub remote_code: Option<String>,
|
||||
/// Present only when delivery may have happened before the error was observed.
|
||||
pub sent_record: Option<SdkSentRecord>,
|
||||
}
|
||||
|
||||
impl SdkError {
|
||||
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
|
||||
pub fn new(code: SdkErrorCode, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
remote_code: None,
|
||||
sent_record: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalid_input(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::InvalidInput, message)
|
||||
Self::new(SdkErrorCode::InvalidInput, message)
|
||||
}
|
||||
|
||||
pub fn unknown_operation(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::UnknownOperation, message)
|
||||
Self::new(SdkErrorCode::UnknownOperation, message)
|
||||
}
|
||||
|
||||
pub fn payload_too_large(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::PayloadTooLarge, message)
|
||||
Self::new(SdkErrorCode::PayloadTooLarge, message)
|
||||
}
|
||||
|
||||
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::Unauthorized, message)
|
||||
Self::new(SdkErrorCode::Unauthorized, message)
|
||||
}
|
||||
|
||||
pub fn unavailable(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::Unavailable, message)
|
||||
Self::new(SdkErrorCode::Unavailable, message)
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self::new(SdkErrorCode::NotFound, message)
|
||||
}
|
||||
|
||||
pub fn conflict(message: impl Into<String>) -> Self {
|
||||
Self::new(SdkErrorCode::Conflict, message)
|
||||
}
|
||||
|
||||
pub fn connection_error(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::ConnectionError, message)
|
||||
Self::new(SdkErrorCode::ConnectionError, message)
|
||||
}
|
||||
|
||||
pub fn timeout(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::Timeout, message)
|
||||
Self::new(SdkErrorCode::Timeout, message)
|
||||
}
|
||||
|
||||
pub fn frame_error(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::FrameError, message)
|
||||
Self::new(SdkErrorCode::FrameError, message)
|
||||
}
|
||||
|
||||
pub fn correlation_error(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::CorrelationError, message)
|
||||
Self::new(SdkErrorCode::CorrelationError, message)
|
||||
}
|
||||
|
||||
pub fn internal(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::Internal, message)
|
||||
Self::new(SdkErrorCode::Internal, message)
|
||||
}
|
||||
|
||||
pub fn uncertain_after_send(record: SdkSentRecord) -> Self {
|
||||
let mut error = Self::new(
|
||||
SdkErrorCode::UncertainAfterSend,
|
||||
"SDK request outcome is uncertain after the frame was sent.",
|
||||
);
|
||||
error.sent_record = Some(record);
|
||||
error
|
||||
}
|
||||
|
||||
pub fn from_remote(code: &str, message: impl Into<String>) -> Self {
|
||||
let mut error = Self::new(SdkErrorCode::from_wire(code), message);
|
||||
if error.code == SdkErrorCode::Internal && code != "internal" && code != "internal_error" {
|
||||
error.remote_code = Some(code.to_string());
|
||||
}
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,8 +205,33 @@ mod tests {
|
||||
|
||||
#[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");
|
||||
assert_eq!(SdkErrorCode::InvalidInput.to_string(), "invalid_input");
|
||||
assert_eq!(
|
||||
SdkErrorCode::PayloadTooLarge.to_string(),
|
||||
"payload_too_large"
|
||||
);
|
||||
assert_eq!(SdkErrorCode::Unauthorized.to_string(), "unauthorized");
|
||||
assert_eq!(
|
||||
SdkErrorCode::from_wire("endpoint_credential_forbidden"),
|
||||
SdkErrorCode::EndpointCredentialForbidden
|
||||
);
|
||||
assert_eq!(
|
||||
SdkErrorCode::UncertainAfterSend.to_string(),
|
||||
"uncertain_after_send"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflict_error_code_round_trips_on_the_wire() {
|
||||
let error = SdkError::conflict("session is active");
|
||||
assert_eq!(error.code.to_string(), "conflict");
|
||||
assert_eq!(SdkErrorCode::from_wire("conflict"), SdkErrorCode::Conflict);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_remote_code_is_preserved_with_typed_internal_code() {
|
||||
let error = SdkError::from_remote("future_error", "failed");
|
||||
assert_eq!(error.code, SdkErrorCode::Internal);
|
||||
assert_eq!(error.remote_code.as_deref(), Some("future_error"));
|
||||
}
|
||||
}
|
||||
+199
-88
@@ -1,45 +1,40 @@
|
||||
//! Frame types and serialization for the SDK wire protocol.
|
||||
//! Typed frame structures and JSON serialization for the SDK wire protocol.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{SdkError, SdkResult};
|
||||
|
||||
/// Protocol version implemented.
|
||||
/// Protocol version implemented by this SDK client.
|
||||
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.
|
||||
/// Known frame type identifiers in the SDK wire protocol.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FrameType {
|
||||
/// Hello frame sent by broker on connection
|
||||
Hello,
|
||||
ServerHello,
|
||||
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 {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Hello => "hello",
|
||||
Self::ServerHello => "server_hello",
|
||||
Self::BrokerHello => "broker_hello",
|
||||
Self::BrokerRequest => "broker_request",
|
||||
Self::BrokerResponse => "broker_response",
|
||||
@@ -54,8 +49,10 @@ impl FrameType {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> SdkResult<Self> {
|
||||
match s {
|
||||
pub fn parse(value: &str) -> SdkResult<Self> {
|
||||
match value {
|
||||
"hello" => Ok(Self::Hello),
|
||||
"server_hello" => Ok(Self::ServerHello),
|
||||
"broker_hello" => Ok(Self::BrokerHello),
|
||||
"broker_request" => Ok(Self::BrokerRequest),
|
||||
"broker_response" => Ok(Self::BrokerResponse),
|
||||
@@ -67,50 +64,81 @@ impl FrameType {
|
||||
"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))),
|
||||
_ => Err(SdkError::frame_error(format!(
|
||||
"unknown frame type: {value}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame in the SDK wire protocol.
|
||||
/// A typed JSON-object 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
|
||||
/// The map remains open-ended to allow operation-specific fields while the frame type,
|
||||
/// correlation UUID, and handshake are validated by the client.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct SdkFrame {
|
||||
/// Underlying JSON object.
|
||||
pub data: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// Create a new frame from a JSON object.
|
||||
impl SdkFrame {
|
||||
/// Create a frame from a JSON object.
|
||||
pub fn new(data: Map<String, Value>) -> Self {
|
||||
Self { data }
|
||||
}
|
||||
|
||||
/// Create an empty frame.
|
||||
/// Create an empty frame object.
|
||||
pub fn empty() -> Self {
|
||||
Self { data: Map::new() }
|
||||
Self::new(Map::new())
|
||||
}
|
||||
|
||||
/// Get the frame type.
|
||||
/// Parse and validate the frame's `type` field.
|
||||
pub fn frame_type(&self) -> SdkResult<FrameType> {
|
||||
let frame_type_str = self
|
||||
let frame_type = self
|
||||
.data
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| SdkError::frame_error("missing 'type' field"))?;
|
||||
FrameType::parse(frame_type_str)
|
||||
FrameType::parse(frame_type)
|
||||
}
|
||||
|
||||
/// Get the optional request/response ID for correlation.
|
||||
pub fn id(&self) -> Option<String> {
|
||||
/// Get the optional UUID used for request/response correlation.
|
||||
pub fn id(&self) -> SdkResult<Option<Uuid>> {
|
||||
match self.data.get("id") {
|
||||
None => Ok(None),
|
||||
Some(Value::String(id)) => Uuid::parse_str(id).map(Some).map_err(|error| {
|
||||
SdkError::correlation_error(format!("invalid frame UUID: {error}"))
|
||||
}),
|
||||
Some(_) => Err(SdkError::correlation_error(
|
||||
"frame 'id' field must be a UUID string",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the frame's correlation UUID.
|
||||
pub fn set_id(&mut self, id: Uuid) {
|
||||
self.data
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(String::from)
|
||||
.insert("id".to_string(), Value::String(id.to_string()));
|
||||
}
|
||||
|
||||
/// Get the `ok` boolean field from a response frame.
|
||||
/// Read a response correlation UUID from either supported field name.
|
||||
pub fn response_id(&self) -> SdkResult<Option<Uuid>> {
|
||||
if self.data.contains_key("id") {
|
||||
return self.id();
|
||||
}
|
||||
match self.data.get("requestId") {
|
||||
None => Ok(None),
|
||||
Some(Value::String(id)) => Uuid::parse_str(id).map(Some).map_err(|error| {
|
||||
SdkError::correlation_error(format!("invalid response UUID: {error}"))
|
||||
}),
|
||||
Some(_) => Err(SdkError::correlation_error(
|
||||
"frame 'requestId' field must be a UUID string",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the `ok` boolean from a response frame.
|
||||
pub fn ok(&self) -> SdkResult<bool> {
|
||||
self.data
|
||||
.get("ok")
|
||||
@@ -118,33 +146,64 @@ impl Frame {
|
||||
.ok_or_else(|| SdkError::frame_error("missing or invalid 'ok' field"))
|
||||
}
|
||||
|
||||
/// Get the error object from a response frame.
|
||||
/// Read the remote error code and message from an error response.
|
||||
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();
|
||||
let error = self.data.get("error")?.as_object()?;
|
||||
let code = error.get("code")?.as_str()?.to_string();
|
||||
let message = error.get("message")?.as_str()?.to_string();
|
||||
Some(Ok((code, message)))
|
||||
}
|
||||
|
||||
/// Get the result field from a response frame.
|
||||
/// Get the response result field.
|
||||
pub fn result(&self) -> Option<Value> {
|
||||
self.data.get("result").cloned()
|
||||
}
|
||||
|
||||
/// Convert to JSON string.
|
||||
/// Serialize this frame to compact JSON.
|
||||
pub fn to_json(&self) -> SdkResult<String> {
|
||||
serde_json::to_string(&self.data)
|
||||
.map_err(|e| SdkError::frame_error(format!("JSON serialization error: {}", e)))
|
||||
.map_err(|error| SdkError::frame_error(format!("JSON serialization error: {error}")))
|
||||
}
|
||||
|
||||
/// 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))
|
||||
/// Parse a JSON object as an SDK frame.
|
||||
pub fn from_json(json: &str) -> SdkResult<Self> {
|
||||
match serde_json::from_str::<Value>(json)
|
||||
.map_err(|error| SdkError::frame_error(format!("JSON parsing error: {error}")))?
|
||||
{
|
||||
Value::Object(data) => Ok(Self::new(data)),
|
||||
_ => Err(SdkError::frame_error("SDK frame must be a JSON object")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a broker hello frame.
|
||||
/// Create the protocol-v3 client hello with the advertised capabilities.
|
||||
pub fn client_hello(capabilities: &[String]) -> Self {
|
||||
let mut data = Map::new();
|
||||
data.insert("type".to_string(), Value::String("hello".to_string()));
|
||||
data.insert(
|
||||
"protocolVersion".to_string(),
|
||||
Value::Number(PROTOCOL_VERSION.into()),
|
||||
);
|
||||
data.insert(
|
||||
"capabilities".to_string(),
|
||||
Value::Array(capabilities.iter().cloned().map(Value::String).collect()),
|
||||
);
|
||||
Self::new(data)
|
||||
}
|
||||
|
||||
/// Return whether this frame completes a server hello handshake.
|
||||
pub fn is_server_hello(&self) -> bool {
|
||||
matches!(
|
||||
self.data.get("type").and_then(Value::as_str),
|
||||
Some("hello" | "server_hello" | "broker_hello")
|
||||
)
|
||||
}
|
||||
|
||||
/// Read an optional connection identifier from a server hello.
|
||||
pub fn connection_id(&self) -> Option<&str> {
|
||||
self.data.get("connectionId").and_then(Value::as_str)
|
||||
}
|
||||
|
||||
/// Create a broker hello frame for protocol tests and mock servers.
|
||||
pub fn broker_hello() -> Self {
|
||||
let mut data = Map::new();
|
||||
data.insert(
|
||||
@@ -158,52 +217,65 @@ impl Frame {
|
||||
Self::new(data)
|
||||
}
|
||||
|
||||
/// Create a broker request frame.
|
||||
pub fn broker_request(id: String, operation: String, input: Value) -> Self {
|
||||
/// Create a broker operation request frame.
|
||||
pub fn broker_request(id: Uuid, 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("id".to_string(), Value::String(id.to_string()));
|
||||
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 {
|
||||
/// Create a successful broker response frame.
|
||||
pub fn broker_response_ok(id: Uuid, 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("id".to_string(), Value::String(id.to_string()));
|
||||
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));
|
||||
/// Create an error broker response frame.
|
||||
pub fn broker_response_error(id: Uuid, code: String, message: String) -> Self {
|
||||
let mut error = Map::new();
|
||||
error.insert("code".to_string(), Value::String(code));
|
||||
error.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("id".to_string(), Value::String(id.to_string()));
|
||||
data.insert("ok".to_string(), Value::Bool(false));
|
||||
data.insert("error".to_string(), Value::Object(error_data));
|
||||
data.insert("error".to_string(), Value::Object(error));
|
||||
Self::new(data)
|
||||
}
|
||||
|
||||
/// Get the payload size in bytes.
|
||||
/// Get the serialized frame length in UTF-8 bytes.
|
||||
pub fn byte_size(&self) -> usize {
|
||||
self.to_json().map(|s| s.len()).unwrap_or(0)
|
||||
self.to_json().map_or(0, |json| json.len())
|
||||
}
|
||||
|
||||
/// Read the operation name from a request frame.
|
||||
pub fn operation(&self) -> Option<String> {
|
||||
self.data
|
||||
.get("operation")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// Read the request input object.
|
||||
pub fn input(&self) -> Option<Value> {
|
||||
self.data.get("input").cloned()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +285,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn frame_type_round_trip() {
|
||||
assert_eq!(FrameType::BrokerHello.as_str(), "broker_hello");
|
||||
assert_eq!(FrameType::Hello.as_str(), "hello");
|
||||
assert_eq!(
|
||||
FrameType::parse("broker_hello").unwrap(),
|
||||
FrameType::BrokerHello
|
||||
@@ -222,37 +294,76 @@ mod tests {
|
||||
|
||||
#[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!(
|
||||
SdkFrame::broker_hello().frame_type().unwrap(),
|
||||
FrameType::BrokerHello
|
||||
);
|
||||
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);
|
||||
fn frame_id_round_trips_as_a_uuid() {
|
||||
let id = Uuid::new_v4();
|
||||
let frame = SdkFrame::broker_request(id, "test.operation".to_string(), Value::Null);
|
||||
assert_eq!(frame.id().unwrap(), Some(id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_response_error_contains_code_and_message() {
|
||||
let frame = Frame::broker_response_error(
|
||||
"id1".to_string(),
|
||||
fn json_round_trip_preserves_the_typed_frame() {
|
||||
let frame = SdkFrame::client_hello(&["capability.one".to_string()]);
|
||||
let parsed = SdkFrame::from_json(&frame.to_json().unwrap()).unwrap();
|
||||
assert_eq!(parsed, frame);
|
||||
assert_eq!(parsed.frame_type().unwrap(), FrameType::Hello);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_hello_advertises_protocol_and_capabilities() {
|
||||
let frame = SdkFrame::client_hello(&["capability.one".to_string()]);
|
||||
assert_eq!(frame.data["protocolVersion"], PROTOCOL_VERSION);
|
||||
assert_eq!(
|
||||
frame.data["capabilities"],
|
||||
serde_json::json!(["capability.one"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_error_response_exposes_code_and_message() {
|
||||
let frame = SdkFrame::broker_response_error(
|
||||
Uuid::new_v4(),
|
||||
"invalid_input".to_string(),
|
||||
"test error".to_string(),
|
||||
"bad input".to_string(),
|
||||
);
|
||||
assert_eq!(
|
||||
frame.error().unwrap().unwrap(),
|
||||
("invalid_input".into(), "bad input".into())
|
||||
);
|
||||
assert!(!frame.ok().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_and_input_are_recoverable() {
|
||||
let input = serde_json::json!({"session_id": "session-1"});
|
||||
let frame = SdkFrame::broker_request(
|
||||
Uuid::new_v4(),
|
||||
"query.get_session".to_string(),
|
||||
input.clone(),
|
||||
);
|
||||
assert_eq!(frame.operation().as_deref(), Some("query.get_session"));
|
||||
assert_eq!(frame.input(), Some(input));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_json_and_non_object_values_are_rejected() {
|
||||
assert!(SdkFrame::from_json("{").is_err());
|
||||
assert!(SdkFrame::from_json("[]").is_err());
|
||||
assert!(SdkFrame::from_json("null").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_correlation_uuid_is_rejected() {
|
||||
let frame = SdkFrame::from_json(r#"{"type":"broker_response","id":"not-a-uuid"}"#).unwrap();
|
||||
assert_eq!(
|
||||
frame.id().unwrap_err().code,
|
||||
crate::error::SdkErrorCode::CorrelationError
|
||||
);
|
||||
let (code, message) = frame.error().unwrap().unwrap();
|
||||
assert_eq!(code, "invalid_input");
|
||||
assert_eq!(message, "test error");
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,17 @@
|
||||
|
||||
pub mod error;
|
||||
pub mod frame;
|
||||
pub mod registry;
|
||||
pub mod session;
|
||||
pub mod transport;
|
||||
|
||||
pub use error::{SdkError, SdkResult};
|
||||
pub use frame::{Frame, FrameType};
|
||||
pub use transport::{SdkTransport, SdkTransportConfig};
|
||||
pub use error::{SdkError, SdkErrorCode, SdkResult, SdkSentRecord};
|
||||
pub use frame::{FrameType, SdkFrame};
|
||||
pub use registry::{OperationMetadata, OperationRegistry, Schema};
|
||||
pub use session::{
|
||||
InvocationOutcome, InvocationReconciliationStore, InvocationRecord, InvocationStatus,
|
||||
RuntimeSessionRunner, SessionDispatch, SessionEvent, SessionEventStream, SessionEventType,
|
||||
SessionInvocation, SessionPage, SessionRouter, SessionRunResult, SessionSummary,
|
||||
SessionTurnRunner, TerminalScopeRecord, TerminalScopeStore,
|
||||
};
|
||||
pub use transport::{SdkClient, SdkClientConfig};
|
||||
@@ -0,0 +1,527 @@
|
||||
//! Operation registry and schema validation for the SDK wire protocol.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::error::{SdkError, SdkResult};
|
||||
|
||||
/// A JSON schema constraint for operation inputs/outputs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Schema {
|
||||
/// Human-readable description
|
||||
pub description: String,
|
||||
/// JSON Schema document for validation
|
||||
pub json_schema: Value,
|
||||
}
|
||||
|
||||
impl Schema {
|
||||
/// Create a new schema.
|
||||
pub fn new(description: impl Into<String>, json_schema: Value) -> Self {
|
||||
Self {
|
||||
description: description.into(),
|
||||
json_schema,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a value against this schema.
|
||||
/// For now, this does basic type checking. A full JSON Schema validator could be integrated.
|
||||
pub fn validate(&self, value: &Value) -> SdkResult<()> {
|
||||
// Basic validation: check that the value can be serialized/deserialized
|
||||
// Full JSON Schema validation would require a library like jsonschema
|
||||
match serde_json::to_string(value) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(SdkError::frame_error(format!(
|
||||
"schema validation failed: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata for an SDK operation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OperationMetadata {
|
||||
/// Operation name (e.g., "list.sessions", "execute.tool")
|
||||
pub name: String,
|
||||
/// Human-readable description
|
||||
pub description: String,
|
||||
/// Input schema (if any)
|
||||
pub input_schema: Option<Schema>,
|
||||
/// Output schema (if any)
|
||||
pub output_schema: Option<Schema>,
|
||||
/// Whether this operation is stable (vs experimental)
|
||||
pub stable: bool,
|
||||
}
|
||||
|
||||
impl OperationMetadata {
|
||||
/// Create a new operation metadata.
|
||||
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
description: description.into(),
|
||||
input_schema: None,
|
||||
output_schema: None,
|
||||
stable: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the input schema for this operation.
|
||||
pub fn with_input_schema(mut self, schema: Schema) -> Self {
|
||||
self.input_schema = Some(schema);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the output schema for this operation.
|
||||
pub fn with_output_schema(mut self, schema: Schema) -> Self {
|
||||
self.output_schema = Some(schema);
|
||||
self
|
||||
}
|
||||
|
||||
/// Mark this operation as experimental.
|
||||
pub fn unstable(mut self) -> Self {
|
||||
self.stable = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// Validate an input value against this operation's input schema.
|
||||
pub fn validate_input(&self, input: &Value) -> SdkResult<()> {
|
||||
match &self.input_schema {
|
||||
Some(schema) => schema.validate(input),
|
||||
None => {
|
||||
// No schema defined, accept any input
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate an output value against this operation's output schema.
|
||||
pub fn validate_output(&self, output: &Value) -> SdkResult<()> {
|
||||
match &self.output_schema {
|
||||
Some(schema) => schema.validate(output),
|
||||
None => {
|
||||
// No schema defined, accept any output
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Registry of available SDK operations.
|
||||
pub struct OperationRegistry {
|
||||
operations: HashMap<String, OperationMetadata>,
|
||||
}
|
||||
|
||||
impl OperationRegistry {
|
||||
/// Create a new operation registry.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
operations: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a registry with standard built-in operations.
|
||||
pub fn with_standard_operations() -> Self {
|
||||
let mut registry = Self::new();
|
||||
|
||||
// Standard query operations
|
||||
registry.register(
|
||||
OperationMetadata::new("query.list_sessions", "List all active sessions")
|
||||
.with_output_schema(Schema::new(
|
||||
"Array of session objects",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"created_at": { "type": "string" }
|
||||
},
|
||||
"required": ["id", "name"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)),
|
||||
);
|
||||
|
||||
registry.register(
|
||||
OperationMetadata::new("query.get_session", "Get a specific session by ID")
|
||||
.with_input_schema(Schema::new(
|
||||
"Session ID to retrieve",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": { "type": "string" }
|
||||
},
|
||||
"required": ["session_id"]
|
||||
}),
|
||||
))
|
||||
.with_output_schema(Schema::new(
|
||||
"Session object",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"state": { "type": "string" },
|
||||
"created_at": { "type": "string" }
|
||||
},
|
||||
"required": ["id", "name", "state"]
|
||||
}),
|
||||
)),
|
||||
);
|
||||
|
||||
// Standard control operations
|
||||
registry.register(
|
||||
OperationMetadata::new("control.activate_session", "Activate a session for use")
|
||||
.with_input_schema(Schema::new(
|
||||
"Session ID to activate",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": { "type": "string" }
|
||||
},
|
||||
"required": ["session_id"]
|
||||
}),
|
||||
))
|
||||
.with_output_schema(Schema::new(
|
||||
"Activation result",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean" },
|
||||
"session_id": { "type": "string" }
|
||||
},
|
||||
"required": ["success"]
|
||||
}),
|
||||
)),
|
||||
);
|
||||
|
||||
registry.register(
|
||||
OperationMetadata::new("control.deactivate_session", "Deactivate a session")
|
||||
.with_input_schema(Schema::new(
|
||||
"Session ID to deactivate",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": { "type": "string" }
|
||||
},
|
||||
"required": ["session_id"]
|
||||
}),
|
||||
))
|
||||
.with_output_schema(Schema::new(
|
||||
"Deactivation result",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean" }
|
||||
},
|
||||
"required": ["success"]
|
||||
}),
|
||||
)),
|
||||
);
|
||||
|
||||
// Standard event operations
|
||||
registry.register(
|
||||
OperationMetadata::new("events.replay", "Replay events from a session")
|
||||
.with_input_schema(Schema::new(
|
||||
"Event replay parameters",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": { "type": "string" },
|
||||
"from_event_id": { "type": "string" },
|
||||
"limit": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"required": ["session_id"]
|
||||
}),
|
||||
))
|
||||
.with_output_schema(Schema::new(
|
||||
"Array of replayed events",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"events": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"type": { "type": "string" },
|
||||
"timestamp": { "type": "string" }
|
||||
},
|
||||
"required": ["id", "type"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["events"]
|
||||
}),
|
||||
)),
|
||||
);
|
||||
|
||||
// SDK session lifecycle operations.
|
||||
for (name, description, required) in [
|
||||
(
|
||||
"turn.create",
|
||||
"Create a durable SDK session",
|
||||
Vec::<&str>::new(),
|
||||
),
|
||||
(
|
||||
"turn.prompt",
|
||||
"Prompt a session and stream lifecycle events",
|
||||
Vec::<&str>::new(),
|
||||
),
|
||||
(
|
||||
"turn.steer",
|
||||
"Steer an active session invocation",
|
||||
vec!["invocationId"],
|
||||
),
|
||||
(
|
||||
"turn.abort_and_prompt",
|
||||
"Abort the active invocation and start a replacement prompt",
|
||||
Vec::<&str>::new(),
|
||||
),
|
||||
(
|
||||
"turn.list",
|
||||
"List persisted SDK sessions",
|
||||
Vec::<&str>::new(),
|
||||
),
|
||||
] {
|
||||
let mut properties = serde_json::Map::new();
|
||||
properties.insert("sessionId".to_string(), json!({ "type": "string" }));
|
||||
properties.insert("correlationKey".to_string(), json!({ "type": "string" }));
|
||||
properties.insert("invocationId".to_string(), json!({ "type": "string" }));
|
||||
properties.insert("prompt".to_string(), json!({ "type": "string" }));
|
||||
properties.insert(
|
||||
"override".to_string(),
|
||||
json!({ "type": ["string", "object", "array"] }),
|
||||
);
|
||||
properties.insert(
|
||||
"request".to_string(),
|
||||
json!({ "type": ["string", "object", "array"] }),
|
||||
);
|
||||
properties.insert(
|
||||
"input".to_string(),
|
||||
json!({ "type": ["string", "object", "array"] }),
|
||||
);
|
||||
properties.insert("tools".to_string(), json!({ "type": "array" }));
|
||||
properties.insert("settings".to_string(), json!({ "type": "object" }));
|
||||
properties.insert("model".to_string(), json!({ "type": "string" }));
|
||||
properties.insert("cursor".to_string(), json!({ "type": "string" }));
|
||||
properties.insert(
|
||||
"limit".to_string(),
|
||||
json!({ "type": "integer", "minimum": 1 }),
|
||||
);
|
||||
let mut input_schema = json!({
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
});
|
||||
let prompt_alternatives = json!([
|
||||
{ "required": ["prompt"] },
|
||||
{ "required": ["request"] },
|
||||
{ "required": ["input"] },
|
||||
]);
|
||||
match name {
|
||||
"turn.prompt" | "turn.abort_and_prompt" => {
|
||||
input_schema["anyOf"] = prompt_alternatives;
|
||||
}
|
||||
"turn.steer" => {
|
||||
input_schema["anyOf"] = json!([
|
||||
{ "required": ["override"] },
|
||||
{ "required": ["prompt"] },
|
||||
{ "required": ["request"] },
|
||||
{ "required": ["input"] },
|
||||
]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
registry.register(
|
||||
OperationMetadata::new(name, description)
|
||||
.with_input_schema(Schema::new(format!("Input for {name}"), input_schema)),
|
||||
);
|
||||
}
|
||||
|
||||
registry
|
||||
}
|
||||
|
||||
/// Register a new operation.
|
||||
pub fn register(&mut self, operation: OperationMetadata) {
|
||||
self.operations.insert(operation.name.clone(), operation);
|
||||
}
|
||||
|
||||
/// Get operation metadata by name.
|
||||
pub fn get(&self, name: &str) -> Option<&OperationMetadata> {
|
||||
self.operations.get(name)
|
||||
}
|
||||
|
||||
/// Check if an operation is registered.
|
||||
pub fn has_operation(&self, name: &str) -> bool {
|
||||
self.operations.contains_key(name)
|
||||
}
|
||||
|
||||
/// Get all registered operations.
|
||||
pub fn operations(&self) -> impl Iterator<Item = (&String, &OperationMetadata)> {
|
||||
self.operations.iter()
|
||||
}
|
||||
|
||||
/// Validate an operation call.
|
||||
pub fn validate_operation_call(&self, operation: &str, input: &Value) -> SdkResult<()> {
|
||||
let metadata = self.get(operation).ok_or_else(|| {
|
||||
SdkError::unknown_operation(format!("unknown operation: {}", operation))
|
||||
})?;
|
||||
|
||||
metadata.validate_input(input)
|
||||
}
|
||||
|
||||
/// Validate an operation response.
|
||||
pub fn validate_operation_response(&self, operation: &str, output: &Value) -> SdkResult<()> {
|
||||
let metadata = self.get(operation).ok_or_else(|| {
|
||||
SdkError::unknown_operation(format!("unknown operation: {}", operation))
|
||||
})?;
|
||||
|
||||
metadata.validate_output(output)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OperationRegistry {
|
||||
fn default() -> Self {
|
||||
Self::with_standard_operations()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn registry_can_register_operations() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let op = OperationMetadata::new("test.operation", "A test operation");
|
||||
registry.register(op);
|
||||
assert!(registry.has_operation("test.operation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_retrieves_registered_operations() {
|
||||
let registry = OperationRegistry::with_standard_operations();
|
||||
assert!(registry.has_operation("query.list_sessions"));
|
||||
assert!(registry.has_operation("control.activate_session"));
|
||||
assert!(registry.has_operation("events.replay"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_rejects_unknown_operations() {
|
||||
let registry = OperationRegistry::with_standard_operations();
|
||||
let result = registry.validate_operation_call("unknown.operation", &json!({}));
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("unknown_operation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_metadata_validates_input_schema() {
|
||||
let op = OperationMetadata::new("test.op", "Test").with_input_schema(Schema::new(
|
||||
"Test input",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
));
|
||||
|
||||
// Valid input
|
||||
assert!(op.validate_input(&json!({"id": "test-123"})).is_ok());
|
||||
|
||||
// Invalid input (should still validate since we only check JSON serialization)
|
||||
assert!(op.validate_input(&json!({"id": 123})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_metadata_validates_output_schema() {
|
||||
let op = OperationMetadata::new("test.op", "Test").with_output_schema(Schema::new(
|
||||
"Test output",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"result": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
));
|
||||
|
||||
// Valid output
|
||||
assert!(op.validate_output(&json!({"result": "success"})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_registry_has_query_operations() {
|
||||
let registry = OperationRegistry::with_standard_operations();
|
||||
assert!(registry.has_operation("query.list_sessions"));
|
||||
assert!(registry.has_operation("query.get_session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_registry_has_control_operations() {
|
||||
let registry = OperationRegistry::with_standard_operations();
|
||||
assert!(registry.has_operation("control.activate_session"));
|
||||
assert!(registry.has_operation("control.deactivate_session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_registry_has_event_operations() {
|
||||
let registry = OperationRegistry::with_standard_operations();
|
||||
assert!(registry.has_operation("events.replay"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_registry_has_session_lifecycle_operations() {
|
||||
let registry = OperationRegistry::with_standard_operations();
|
||||
for operation in [
|
||||
"turn.create",
|
||||
"turn.prompt",
|
||||
"turn.steer",
|
||||
"turn.abort_and_prompt",
|
||||
"turn.list",
|
||||
] {
|
||||
assert!(registry.has_operation(operation), "missing {operation}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_validation_catches_missing_required_fields() {
|
||||
let registry = OperationRegistry::with_standard_operations();
|
||||
// query.get_session requires session_id
|
||||
let result = registry.validate_operation_call("query.get_session", &json!({}));
|
||||
// Basic validation will pass since we only check JSON serialization
|
||||
// A full JSON Schema validator would catch this
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_validates_serializable_values() {
|
||||
let schema = Schema::new("Test schema", json!({"type": "object"}));
|
||||
|
||||
assert!(schema.validate(&json!({})).is_ok());
|
||||
assert!(schema.validate(&json!({"key": "value"})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_without_schemas_accepts_any_input_output() {
|
||||
let op = OperationMetadata::new("test.op", "Test");
|
||||
assert!(op.validate_input(&json!({"any": "thing"})).is_ok());
|
||||
assert!(op.validate_output(&json!({"any": "thing"})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_metadata_can_be_marked_unstable() {
|
||||
let op = OperationMetadata::new("test.op", "Test").unstable();
|
||||
assert!(!op.stable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Event stream types for session lifecycle operations.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Lifecycle events published for one SDK turn invocation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionEventType {
|
||||
TurnAccepted,
|
||||
AgentBegin,
|
||||
ToolUse,
|
||||
ToolResult,
|
||||
AgentEnd,
|
||||
TurnSteered,
|
||||
TurnEnded,
|
||||
}
|
||||
|
||||
/// One ordered session lifecycle event.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionEvent {
|
||||
pub event_id: String,
|
||||
pub event_type: SessionEventType,
|
||||
pub session_id: String,
|
||||
pub invocation_id: String,
|
||||
pub sequence: u64,
|
||||
pub timestamp_ms: u64,
|
||||
pub durable: bool,
|
||||
pub payload: Value,
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,673 @@
|
||||
//! Durable invocation state for recovering SDK sessions across process restarts.
|
||||
|
||||
use std::{
|
||||
fs::{self, OpenOptions},
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
sync::Mutex,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::fs::File;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{SdkError, SdkResult};
|
||||
|
||||
static RECONCILIATION_WRITE_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Lifecycle state persisted for an invocation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InvocationStatus {
|
||||
Accepted,
|
||||
Running,
|
||||
Uncertain,
|
||||
Terminal,
|
||||
}
|
||||
|
||||
/// Result captured when an invocation reaches a terminal state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InvocationOutcome {
|
||||
Completed {
|
||||
message_count: usize,
|
||||
},
|
||||
Aborted {
|
||||
message_count: usize,
|
||||
},
|
||||
Failed {
|
||||
message: String,
|
||||
message_count: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// Persisted lifecycle and reconciliation data for one invocation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InvocationRecord {
|
||||
pub invocation_id: String,
|
||||
pub session_id: String,
|
||||
pub correlation_key: Option<String>,
|
||||
pub status: InvocationStatus,
|
||||
pub accepted_at_ms: u64,
|
||||
pub updated_at_ms: u64,
|
||||
pub outcome: Option<InvocationOutcome>,
|
||||
pub uncertainty_reason: Option<String>,
|
||||
pub terminal_event: Option<serde_json::Value>,
|
||||
pub terminal_scope_revision: Option<u64>,
|
||||
}
|
||||
|
||||
/// File-backed store for invocation lifecycle reconciliation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InvocationReconciliationStore {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl InvocationReconciliationStore {
|
||||
/// Creates a store handle without creating its root directory.
|
||||
#[must_use]
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
/// Inserts a new invocation record. Existing IDs are not overwritten.
|
||||
pub fn insert(&self, record: InvocationRecord) -> SdkResult<()> {
|
||||
let _guard = RECONCILIATION_WRITE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let path = self.record_path(&record.invocation_id)?;
|
||||
if self.get(&record.invocation_id)?.is_some() {
|
||||
return Err(SdkError::invalid_input(format!(
|
||||
"invocation '{}' already exists",
|
||||
record.invocation_id
|
||||
)));
|
||||
}
|
||||
atomic_write_json(&path, &record)
|
||||
}
|
||||
|
||||
/// Marks a non-terminal invocation as running.
|
||||
pub fn mark_running(&self, invocation_id: &str) -> SdkResult<()> {
|
||||
let _guard = RECONCILIATION_WRITE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut record = self.required_record(invocation_id)?;
|
||||
if record.status != InvocationStatus::Accepted {
|
||||
return Err(SdkError::conflict(format!(
|
||||
"invocation '{}' cannot start from state {:?}",
|
||||
invocation_id, record.status
|
||||
)));
|
||||
}
|
||||
record.status = InvocationStatus::Running;
|
||||
record.uncertainty_reason = None;
|
||||
record.updated_at_ms = updated_at_ms(record.updated_at_ms);
|
||||
self.write_record(&record)
|
||||
}
|
||||
|
||||
/// Marks a non-terminal invocation as uncertain with a recovery reason.
|
||||
pub fn mark_uncertain(&self, invocation_id: &str, reason: impl Into<String>) -> SdkResult<()> {
|
||||
let reason = reason.into();
|
||||
self.update_nonterminal(invocation_id, move |record| {
|
||||
record.status = InvocationStatus::Uncertain;
|
||||
record.uncertainty_reason = Some(reason);
|
||||
})
|
||||
}
|
||||
|
||||
/// Records a terminal result. Once terminal, a record cannot be changed again.
|
||||
pub fn record_terminal(
|
||||
&self,
|
||||
invocation_id: &str,
|
||||
outcome: InvocationOutcome,
|
||||
terminal_event: Option<serde_json::Value>,
|
||||
) -> SdkResult<()> {
|
||||
self.record_terminal_with_scope_revision(invocation_id, outcome, terminal_event, None)
|
||||
}
|
||||
|
||||
/// Records a terminal result together with its durable correlation-scope revision.
|
||||
pub fn record_terminal_with_scope_revision(
|
||||
&self,
|
||||
invocation_id: &str,
|
||||
outcome: InvocationOutcome,
|
||||
terminal_event: Option<serde_json::Value>,
|
||||
terminal_scope_revision: Option<u64>,
|
||||
) -> SdkResult<()> {
|
||||
let _guard = RECONCILIATION_WRITE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut record = self.required_record(invocation_id)?;
|
||||
if record.status == InvocationStatus::Terminal {
|
||||
return Err(SdkError::invalid_input(format!(
|
||||
"invocation '{}' is already terminal",
|
||||
invocation_id
|
||||
)));
|
||||
}
|
||||
if record
|
||||
.outcome
|
||||
.as_ref()
|
||||
.is_some_and(|pending| pending != &outcome)
|
||||
|| record
|
||||
.terminal_event
|
||||
.as_ref()
|
||||
.is_some_and(|pending| Some(pending) != terminal_event.as_ref())
|
||||
{
|
||||
return Err(SdkError::conflict(format!(
|
||||
"invocation '{}' has a different pending terminal result",
|
||||
invocation_id
|
||||
)));
|
||||
}
|
||||
record.status = InvocationStatus::Terminal;
|
||||
record.outcome = Some(outcome);
|
||||
record.uncertainty_reason = None;
|
||||
record.terminal_event = terminal_event;
|
||||
record.terminal_scope_revision = terminal_scope_revision;
|
||||
record.updated_at_ms = updated_at_ms(record.updated_at_ms);
|
||||
self.write_record(&record)
|
||||
}
|
||||
|
||||
/// Persists a known turn result before its correlation scope is updated.
|
||||
///
|
||||
/// The record remains non-terminal until the scope update succeeds, allowing
|
||||
/// startup or a later reconciliation read to safely finish the transition.
|
||||
pub fn record_pending_terminal(
|
||||
&self,
|
||||
invocation_id: &str,
|
||||
outcome: InvocationOutcome,
|
||||
terminal_event: serde_json::Value,
|
||||
) -> SdkResult<()> {
|
||||
let _guard = RECONCILIATION_WRITE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut record = self.required_record(invocation_id)?;
|
||||
if record.status == InvocationStatus::Terminal {
|
||||
return Err(SdkError::invalid_input(format!(
|
||||
"invocation '{}' is already terminal",
|
||||
invocation_id
|
||||
)));
|
||||
}
|
||||
if record.outcome.is_some() || record.terminal_event.is_some() {
|
||||
return Err(SdkError::conflict(format!(
|
||||
"invocation '{}' already has a pending terminal result",
|
||||
invocation_id
|
||||
)));
|
||||
}
|
||||
record.outcome = Some(outcome);
|
||||
record.terminal_event = Some(terminal_event);
|
||||
record.terminal_scope_revision = None;
|
||||
record.updated_at_ms = updated_at_ms(record.updated_at_ms);
|
||||
self.write_record(&record)
|
||||
}
|
||||
|
||||
/// Loads one invocation record, if present.
|
||||
pub fn get(&self, invocation_id: &str) -> SdkResult<Option<InvocationRecord>> {
|
||||
let path = self.record_path(invocation_id)?;
|
||||
read_record(&path, invocation_id)
|
||||
}
|
||||
|
||||
/// Loads all invocation records in stable invocation-ID order.
|
||||
pub fn list(&self) -> SdkResult<Vec<InvocationRecord>> {
|
||||
let entries = match fs::read_dir(&self.root) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(error) => {
|
||||
return Err(io_error(
|
||||
"read invocation store directory",
|
||||
&self.root,
|
||||
error,
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let mut records = Vec::new();
|
||||
for entry in entries {
|
||||
let entry = entry
|
||||
.map_err(|error| io_error("read invocation store entry", &self.root, error))?;
|
||||
if !entry
|
||||
.file_type()
|
||||
.map_err(|error| io_error("inspect invocation store entry", &entry.path(), error))?
|
||||
.is_file()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = entry.path();
|
||||
if path.extension() != Some(std::ffi::OsStr::new("json")) {
|
||||
continue;
|
||||
}
|
||||
let invocation_id = path
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.ok_or_else(|| SdkError::invalid_input("invocation filename is not valid UTF-8"))?;
|
||||
validate_invocation_id(invocation_id)?;
|
||||
if let Some(record) = read_record(&path, invocation_id)? {
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
records.sort_by(|left, right| left.invocation_id.cmp(&right.invocation_id));
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// Loads records associated with the exact correlation key.
|
||||
pub fn list_by_correlation_key(&self, key: &str) -> SdkResult<Vec<InvocationRecord>> {
|
||||
Ok(self
|
||||
.list()?
|
||||
.into_iter()
|
||||
.filter(|record| record.correlation_key.as_deref() == Some(key))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Durably marks accepted and running records uncertain after a restart.
|
||||
///
|
||||
/// Returns the records changed by this recovery pass; already uncertain and
|
||||
/// terminal records are left untouched and are not included in the result.
|
||||
pub fn reconcile_unfinished(&self) -> SdkResult<Vec<InvocationRecord>> {
|
||||
let _guard = RECONCILIATION_WRITE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut recovered = Vec::new();
|
||||
for mut record in self.list()? {
|
||||
if matches!(
|
||||
record.status,
|
||||
InvocationStatus::Accepted | InvocationStatus::Running
|
||||
) {
|
||||
record.status = InvocationStatus::Uncertain;
|
||||
record.uncertainty_reason =
|
||||
Some("recovered unfinished invocation after restart".into());
|
||||
record.updated_at_ms = updated_at_ms(record.updated_at_ms);
|
||||
self.write_record(&record)?;
|
||||
recovered.push(record);
|
||||
}
|
||||
}
|
||||
Ok(recovered)
|
||||
}
|
||||
|
||||
fn record_path(&self, invocation_id: &str) -> SdkResult<PathBuf> {
|
||||
validate_invocation_id(invocation_id)?;
|
||||
Ok(self.root.join(format!("{invocation_id}.json")))
|
||||
}
|
||||
|
||||
fn required_record(&self, invocation_id: &str) -> SdkResult<InvocationRecord> {
|
||||
self.get(invocation_id)?.ok_or_else(|| {
|
||||
SdkError::invalid_input(format!("invocation '{invocation_id}' does not exist"))
|
||||
})
|
||||
}
|
||||
|
||||
fn update_nonterminal(
|
||||
&self,
|
||||
invocation_id: &str,
|
||||
update: impl FnOnce(&mut InvocationRecord),
|
||||
) -> SdkResult<()> {
|
||||
let _guard = RECONCILIATION_WRITE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut record = self.required_record(invocation_id)?;
|
||||
if record.status == InvocationStatus::Terminal {
|
||||
return Err(SdkError::invalid_input(format!(
|
||||
"invocation '{}' is already terminal",
|
||||
invocation_id
|
||||
)));
|
||||
}
|
||||
update(&mut record);
|
||||
record.updated_at_ms = updated_at_ms(record.updated_at_ms);
|
||||
self.write_record(&record)
|
||||
}
|
||||
|
||||
fn write_record(&self, record: &InvocationRecord) -> SdkResult<()> {
|
||||
let path = self.record_path(&record.invocation_id)?;
|
||||
atomic_write_json(&path, record)
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically serializes a value beside its target and renames it into place.
|
||||
pub(super) fn atomic_write_json<T: Serialize + ?Sized>(path: &Path, value: &T) -> SdkResult<()> {
|
||||
let bytes = serde_json::to_vec(value)
|
||||
.map_err(|error| SdkError::internal(format!("serialize {}: {error}", path.display())))?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| io_error("create store directory", parent, error))?;
|
||||
let filename = path
|
||||
.file_name()
|
||||
.ok_or_else(|| SdkError::invalid_input("store path has no filename"))?;
|
||||
let temporary_path = parent.join(format!(
|
||||
".{}.{}.tmp",
|
||||
filename.to_string_lossy(),
|
||||
Uuid::new_v4()
|
||||
));
|
||||
|
||||
let write_result = (|| -> std::io::Result<()> {
|
||||
let mut temporary = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&temporary_path)?;
|
||||
temporary.write_all(&bytes)?;
|
||||
temporary.sync_all()?;
|
||||
drop(temporary);
|
||||
fs::rename(&temporary_path, path)?;
|
||||
#[cfg(unix)]
|
||||
File::open(parent)?.sync_all()?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
match write_result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => {
|
||||
let _ = fs::remove_file(&temporary_path);
|
||||
Err(io_error("atomically write JSON", path, error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_record(path: &Path, expected_id: &str) -> SdkResult<Option<InvocationRecord>> {
|
||||
let bytes = match fs::read(path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(io_error("read invocation record", path, error)),
|
||||
};
|
||||
let record: InvocationRecord = serde_json::from_slice(&bytes).map_err(|error| {
|
||||
SdkError::internal(format!(
|
||||
"parse invocation record {}: {error}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
validate_invocation_id(&record.invocation_id)?;
|
||||
if record.invocation_id != expected_id {
|
||||
return Err(SdkError::internal(format!(
|
||||
"invocation record ID '{}' does not match filename ID '{expected_id}'",
|
||||
record.invocation_id
|
||||
)));
|
||||
}
|
||||
Ok(Some(record))
|
||||
}
|
||||
|
||||
fn validate_invocation_id(invocation_id: &str) -> SdkResult<()> {
|
||||
if invocation_id.is_empty()
|
||||
|| invocation_id.len() > 200
|
||||
|| !invocation_id
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
|
||||
{
|
||||
return Err(SdkError::invalid_input(format!(
|
||||
"unsafe invocation ID '{invocation_id}'"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn updated_at_ms(previous: u64) -> u64 {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.min(u128::from(u64::MAX)) as u64;
|
||||
now.max(previous.saturating_add(1))
|
||||
}
|
||||
|
||||
fn io_error(action: &str, path: &Path, error: std::io::Error) -> SdkError {
|
||||
SdkError::internal(format!("{action} '{}': {error}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct TestDirectory(PathBuf);
|
||||
|
||||
impl TestDirectory {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("sdk-session-{}", Uuid::new_v4()));
|
||||
fs::create_dir(&path).expect("create test directory");
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDirectory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn accepted_record(invocation_id: &str) -> InvocationRecord {
|
||||
InvocationRecord {
|
||||
invocation_id: invocation_id.into(),
|
||||
session_id: "session-a".into(),
|
||||
correlation_key: Some("request-a".into()),
|
||||
status: InvocationStatus::Accepted,
|
||||
accepted_at_ms: 10,
|
||||
updated_at_ms: 10,
|
||||
outcome: None,
|
||||
uncertainty_reason: None,
|
||||
terminal_event: None,
|
||||
terminal_scope_revision: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructor_has_no_filesystem_side_effects() {
|
||||
let root = std::env::temp_dir().join(format!("sdk-session-{}", Uuid::new_v4()));
|
||||
let _store = InvocationReconciliationStore::new(&root);
|
||||
assert!(!root.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invocation_records_survive_store_restart() {
|
||||
let root = TestDirectory::new();
|
||||
let record = accepted_record("invocation-a");
|
||||
InvocationReconciliationStore::new(&root.0)
|
||||
.insert(record.clone())
|
||||
.expect("insert record");
|
||||
|
||||
let loaded = InvocationReconciliationStore::new(&root.0)
|
||||
.get("invocation-a")
|
||||
.expect("load record")
|
||||
.expect("record exists");
|
||||
assert_eq!(loaded, record);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_transition_only_accepts_an_accepted_invocation() {
|
||||
let root = TestDirectory::new();
|
||||
let store = InvocationReconciliationStore::new(&root.0);
|
||||
store
|
||||
.insert(accepted_record("invocation-a"))
|
||||
.expect("insert");
|
||||
store.mark_running("invocation-a").expect("mark running");
|
||||
assert_eq!(
|
||||
store.get("invocation-a").unwrap().unwrap().status,
|
||||
InvocationStatus::Running
|
||||
);
|
||||
assert!(store.mark_running("invocation-a").is_err());
|
||||
store
|
||||
.mark_uncertain("invocation-a", "recovered")
|
||||
.expect("mark uncertain");
|
||||
assert!(store.mark_running("invocation-a").is_err());
|
||||
assert_eq!(
|
||||
store.get("invocation-a").unwrap().unwrap().status,
|
||||
InvocationStatus::Uncertain
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncertain_invocation_can_become_terminal_only_once() {
|
||||
let root = TestDirectory::new();
|
||||
let store = InvocationReconciliationStore::new(&root.0);
|
||||
store
|
||||
.insert(accepted_record("invocation-a"))
|
||||
.expect("insert");
|
||||
store
|
||||
.mark_uncertain("invocation-a", "runtime disconnected")
|
||||
.expect("mark uncertain");
|
||||
store
|
||||
.record_terminal(
|
||||
"invocation-a",
|
||||
InvocationOutcome::Completed { message_count: 2 },
|
||||
Some(serde_json::json!({"type": "completed"})),
|
||||
)
|
||||
.expect("record terminal");
|
||||
|
||||
let terminal = store.get("invocation-a").expect("load").expect("exists");
|
||||
assert_eq!(terminal.status, InvocationStatus::Terminal);
|
||||
assert_eq!(terminal.uncertainty_reason, None);
|
||||
assert_eq!(
|
||||
terminal.outcome,
|
||||
Some(InvocationOutcome::Completed { message_count: 2 })
|
||||
);
|
||||
assert!(store
|
||||
.record_terminal(
|
||||
"invocation-a",
|
||||
InvocationOutcome::Aborted { message_count: 0 },
|
||||
None,
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(store.get("invocation-a").expect("reload"), Some(terminal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_marks_only_accepted_and_running_records_uncertain() {
|
||||
let root = TestDirectory::new();
|
||||
let store = InvocationReconciliationStore::new(&root.0);
|
||||
store
|
||||
.insert(accepted_record("accepted"))
|
||||
.expect("insert accepted");
|
||||
let mut running = accepted_record("running");
|
||||
running.status = InvocationStatus::Running;
|
||||
store.insert(running).expect("insert running");
|
||||
let mut terminal = accepted_record("terminal");
|
||||
terminal.status = InvocationStatus::Terminal;
|
||||
terminal.outcome = Some(InvocationOutcome::Aborted { message_count: 0 });
|
||||
store.insert(terminal.clone()).expect("insert terminal");
|
||||
|
||||
let recovered = store.reconcile_unfinished().expect("reconcile");
|
||||
assert_eq!(recovered.len(), 2);
|
||||
assert!(recovered
|
||||
.iter()
|
||||
.all(|record| record.status == InvocationStatus::Uncertain));
|
||||
assert_eq!(
|
||||
store.get("terminal").expect("load terminal"),
|
||||
Some(terminal)
|
||||
);
|
||||
assert!(store
|
||||
.reconcile_unfinished()
|
||||
.expect("repeat recovery")
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_terminal_result_survives_uncertainty_and_can_be_finalized() {
|
||||
let root = TestDirectory::new();
|
||||
let store = InvocationReconciliationStore::new(&root.0);
|
||||
store
|
||||
.insert(accepted_record("pending"))
|
||||
.expect("insert accepted record");
|
||||
store.mark_running("pending").expect("mark running");
|
||||
store
|
||||
.record_pending_terminal(
|
||||
"pending",
|
||||
InvocationOutcome::Completed { message_count: 3 },
|
||||
serde_json::json!({"eventType": "turn_ended"}),
|
||||
)
|
||||
.expect("record pending result");
|
||||
store
|
||||
.mark_uncertain("pending", "terminal scope store was unavailable")
|
||||
.expect("mark uncertain");
|
||||
|
||||
let pending = store.get("pending").unwrap().unwrap();
|
||||
assert_eq!(pending.status, InvocationStatus::Uncertain);
|
||||
assert_eq!(
|
||||
pending.outcome,
|
||||
Some(InvocationOutcome::Completed { message_count: 3 })
|
||||
);
|
||||
store
|
||||
.record_terminal_with_scope_revision(
|
||||
"pending",
|
||||
pending.outcome.unwrap(),
|
||||
pending.terminal_event,
|
||||
Some(1),
|
||||
)
|
||||
.expect("finalize after recovery");
|
||||
assert_eq!(
|
||||
store.get("pending").unwrap().unwrap().status,
|
||||
InvocationStatus::Terminal
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_terminal_and_uncertain_transitions_preserve_terminal_result() {
|
||||
let root = TestDirectory::new();
|
||||
let store = InvocationReconciliationStore::new(&root.0);
|
||||
store
|
||||
.insert(accepted_record("concurrent"))
|
||||
.expect("insert accepted record");
|
||||
store.mark_running("concurrent").expect("mark running");
|
||||
|
||||
let barrier = std::sync::Arc::new(std::sync::Barrier::new(3));
|
||||
let terminal_store = store.clone();
|
||||
let terminal_barrier = std::sync::Arc::clone(&barrier);
|
||||
let terminal = std::thread::spawn(move || {
|
||||
terminal_barrier.wait();
|
||||
terminal_store.record_terminal(
|
||||
"concurrent",
|
||||
InvocationOutcome::Completed { message_count: 1 },
|
||||
None,
|
||||
)
|
||||
});
|
||||
let uncertain_store = store.clone();
|
||||
let uncertain_barrier = std::sync::Arc::clone(&barrier);
|
||||
let uncertain = std::thread::spawn(move || {
|
||||
uncertain_barrier.wait();
|
||||
uncertain_store.mark_uncertain("concurrent", "raced with terminal write")
|
||||
});
|
||||
barrier.wait();
|
||||
let _ = terminal.join().expect("terminal writer thread");
|
||||
let _ = uncertain.join().expect("uncertain writer thread");
|
||||
|
||||
let record = store.get("concurrent").unwrap().unwrap();
|
||||
assert_eq!(record.status, InvocationStatus::Terminal);
|
||||
assert_eq!(
|
||||
record.outcome,
|
||||
Some(InvocationOutcome::Completed { message_count: 1 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_duplicate_inserts_have_only_one_winner() {
|
||||
let root = TestDirectory::new();
|
||||
let store = InvocationReconciliationStore::new(&root.0);
|
||||
let barrier = std::sync::Arc::new(std::sync::Barrier::new(3));
|
||||
let first_store = store.clone();
|
||||
let first_barrier = std::sync::Arc::clone(&barrier);
|
||||
let first = std::thread::spawn(move || {
|
||||
first_barrier.wait();
|
||||
first_store.insert(accepted_record("duplicate"))
|
||||
});
|
||||
let second_store = store.clone();
|
||||
let second_barrier = std::sync::Arc::clone(&barrier);
|
||||
let second = std::thread::spawn(move || {
|
||||
second_barrier.wait();
|
||||
second_store.insert(accepted_record("duplicate"))
|
||||
});
|
||||
barrier.wait();
|
||||
let first = first.join().expect("first insert thread");
|
||||
let second = second.join().expect("second insert thread");
|
||||
|
||||
assert_ne!(first.is_ok(), second.is_ok());
|
||||
assert_eq!(
|
||||
store.get("duplicate").unwrap().unwrap().invocation_id,
|
||||
"duplicate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_ids_and_json_are_rejected() {
|
||||
let root = TestDirectory::new();
|
||||
let store = InvocationReconciliationStore::new(&root.0);
|
||||
assert!(store.get("../escape").is_err());
|
||||
assert!(store.insert(accepted_record("bad/id")).is_err());
|
||||
|
||||
fs::write(root.0.join("malformed.json"), "{").expect("write malformed record");
|
||||
assert!(store.get("malformed").is_err());
|
||||
assert!(store.list().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
//! Durable mapping from SDK correlation keys to their active terminal session.
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
sync::Mutex,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{SdkError, SdkResult};
|
||||
|
||||
use super::reconciliation::atomic_write_json;
|
||||
|
||||
const MAX_CORRELATION_KEY_BYTES: usize = 512;
|
||||
static TERMINAL_SCOPE_WRITE_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Persisted terminal-session scope for one correlation key.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TerminalScopeRecord {
|
||||
pub correlation_key: String,
|
||||
pub session_id: String,
|
||||
pub revision: u64,
|
||||
pub updated_at_ms: u64,
|
||||
}
|
||||
|
||||
/// File-backed mapping of correlation keys to terminal sessions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TerminalScopeStore {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl TerminalScopeStore {
|
||||
/// Creates a store handle without creating its root directory.
|
||||
#[must_use]
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
/// Sets the session associated with a correlation key.
|
||||
pub fn set(
|
||||
&self,
|
||||
correlation_key: impl Into<String>,
|
||||
session_id: impl Into<String>,
|
||||
) -> SdkResult<TerminalScopeRecord> {
|
||||
let correlation_key = correlation_key.into();
|
||||
validate_correlation_key(&correlation_key)?;
|
||||
let _guard = TERMINAL_SCOPE_WRITE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let previous = self.read_record(&correlation_key)?;
|
||||
let revision = previous
|
||||
.as_ref()
|
||||
.map_or(Some(1), |record| record.revision.checked_add(1))
|
||||
.ok_or_else(|| SdkError::internal("terminal scope revision is exhausted"))?;
|
||||
let previous_updated_at = previous.map_or(0, |record| record.updated_at_ms);
|
||||
let record = TerminalScopeRecord {
|
||||
correlation_key: correlation_key.clone(),
|
||||
session_id: session_id.into(),
|
||||
revision,
|
||||
updated_at_ms: current_timestamp_ms().max(previous_updated_at.saturating_add(1)),
|
||||
};
|
||||
atomic_write_json(&self.record_path(&correlation_key), &record)?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
/// Rebuilds a mapping from durable invocation history without moving it backward.
|
||||
pub fn restore_if_newer(
|
||||
&self,
|
||||
correlation_key: &str,
|
||||
session_id: &str,
|
||||
revision: u64,
|
||||
) -> SdkResult<()> {
|
||||
validate_correlation_key(correlation_key)?;
|
||||
let _guard = TERMINAL_SCOPE_WRITE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let previous = self.read_record(correlation_key)?;
|
||||
match previous.as_ref() {
|
||||
Some(record) if record.revision > revision => return Ok(()),
|
||||
Some(record) if record.revision == revision && record.session_id == session_id => {
|
||||
return Ok(())
|
||||
}
|
||||
Some(record) if record.revision == revision => {
|
||||
return Err(SdkError::internal(format!(
|
||||
"terminal scope revision {revision} conflicts for key '{correlation_key}'"
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let record = TerminalScopeRecord {
|
||||
correlation_key: correlation_key.to_string(),
|
||||
session_id: session_id.to_string(),
|
||||
revision,
|
||||
updated_at_ms: current_timestamp_ms().max(
|
||||
previous
|
||||
.map_or(0, |record| record.updated_at_ms)
|
||||
.saturating_add(1),
|
||||
),
|
||||
};
|
||||
atomic_write_json(&self.record_path(correlation_key), &record)
|
||||
}
|
||||
|
||||
/// Loads the session associated with a correlation key, if present.
|
||||
pub fn get(&self, correlation_key: &str) -> SdkResult<Option<TerminalScopeRecord>> {
|
||||
validate_correlation_key(correlation_key)?;
|
||||
self.read_record(correlation_key)
|
||||
}
|
||||
|
||||
fn read_record(&self, correlation_key: &str) -> SdkResult<Option<TerminalScopeRecord>> {
|
||||
let path = self.record_path(correlation_key);
|
||||
let bytes = match fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => {
|
||||
return Err(SdkError::internal(format!(
|
||||
"read terminal scope '{}': {error}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
};
|
||||
let record: TerminalScopeRecord = serde_json::from_slice(&bytes).map_err(|error| {
|
||||
SdkError::internal(format!(
|
||||
"parse terminal scope '{}': {error}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
if record.correlation_key != correlation_key {
|
||||
return Err(SdkError::internal(format!(
|
||||
"terminal scope key does not match requested key for '{}'",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
Ok(Some(record))
|
||||
}
|
||||
|
||||
fn record_path(&self, correlation_key: &str) -> PathBuf {
|
||||
const MAX_COMPONENT_BYTES: usize = 180;
|
||||
let encoded = encode_key_as_hex(correlation_key);
|
||||
let mut path = self.root.clone();
|
||||
let mut remaining = encoded.as_str();
|
||||
while remaining.len() > MAX_COMPONENT_BYTES {
|
||||
let (component, tail) = remaining.split_at(MAX_COMPONENT_BYTES);
|
||||
path.push(component);
|
||||
remaining = tail;
|
||||
}
|
||||
path.push(format!("{remaining}.json"));
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_correlation_key(correlation_key: &str) -> SdkResult<()> {
|
||||
if correlation_key.len() <= MAX_CORRELATION_KEY_BYTES {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SdkError::invalid_input("correlation key exceeds 512 bytes"))
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_key_as_hex(key: &str) -> String {
|
||||
const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut encoded = String::with_capacity(key.len() * 2);
|
||||
for byte in key.as_bytes() {
|
||||
encoded.push(char::from(HEX_DIGITS[usize::from(*byte >> 4)]));
|
||||
encoded.push(char::from(HEX_DIGITS[usize::from(*byte & 0x0f)]));
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn current_timestamp_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.min(u128::from(u64::MAX)) as u64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
struct TestDirectory(PathBuf);
|
||||
|
||||
impl TestDirectory {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("sdk-terminal-scope-{}", Uuid::new_v4()));
|
||||
fs::create_dir(&path).expect("create test directory");
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDirectory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_scope_survives_store_restart() {
|
||||
let root = TestDirectory::new();
|
||||
TerminalScopeStore::new(&root.0)
|
||||
.set("request-1", "session-1")
|
||||
.expect("set terminal scope");
|
||||
|
||||
let loaded = TerminalScopeStore::new(&root.0)
|
||||
.get("request-1")
|
||||
.expect("load terminal scope")
|
||||
.expect("scope exists");
|
||||
assert_eq!(loaded.correlation_key, "request-1");
|
||||
assert_eq!(loaded.session_id, "session-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_revisions_increase_and_recovery_never_moves_backward() {
|
||||
let root = TestDirectory::new();
|
||||
let store = TerminalScopeStore::new(&root.0);
|
||||
let first = store.set("request-1", "session-1").unwrap();
|
||||
let second = store.set("request-1", "session-2").unwrap();
|
||||
assert_eq!(second.revision, first.revision + 1);
|
||||
assert!(second.updated_at_ms > first.updated_at_ms);
|
||||
|
||||
store
|
||||
.restore_if_newer("request-1", "session-1", first.revision)
|
||||
.expect("stale scope restoration should be ignored");
|
||||
assert_eq!(
|
||||
store.get("request-1").unwrap().unwrap().session_id,
|
||||
"session-2"
|
||||
);
|
||||
assert!(store
|
||||
.restore_if_newer("request-1", "other-session", second.revision)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_keys_are_isolated_and_cannot_traverse_paths() {
|
||||
let root = TestDirectory::new();
|
||||
let store = TerminalScopeStore::new(&root.0);
|
||||
store.set("same", "session-a").expect("set first scope");
|
||||
store
|
||||
.set("../same", "session-b")
|
||||
.expect("set path-like key");
|
||||
store
|
||||
.set("../sämé", "session-c")
|
||||
.expect("set Unicode path-like key");
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.get("same")
|
||||
.expect("get first")
|
||||
.expect("exists")
|
||||
.session_id,
|
||||
"session-a"
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.get("../same")
|
||||
.expect("get path-like key")
|
||||
.expect("exists")
|
||||
.session_id,
|
||||
"session-b"
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.get("../sämé")
|
||||
.expect("get Unicode path-like key")
|
||||
.expect("exists")
|
||||
.session_id,
|
||||
"session-c"
|
||||
);
|
||||
assert_eq!(fs::read_dir(&root.0).expect("read root").count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_terminal_scope_json_is_rejected() {
|
||||
let root = TestDirectory::new();
|
||||
let store = TerminalScopeStore::new(&root.0);
|
||||
let path = root
|
||||
.0
|
||||
.join(format!("{}.json", encode_key_as_hex("request")));
|
||||
fs::write(path, "{").expect("write malformed scope");
|
||||
assert!(store.get("request").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_hex_keys_use_safe_path_components() {
|
||||
let root = TestDirectory::new();
|
||||
let store = TerminalScopeStore::new(&root.0);
|
||||
let key = "k".repeat(512);
|
||||
store
|
||||
.set(&key, "session-long")
|
||||
.expect("long key should persist");
|
||||
assert_eq!(
|
||||
store.get(&key).expect("get long key").unwrap().session_id,
|
||||
"session-long"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_correlation_keys_are_rejected() {
|
||||
let root = TestDirectory::new();
|
||||
let store = TerminalScopeStore::new(&root.0);
|
||||
let key = "k".repeat(513);
|
||||
assert!(store.set(&key, "session-long").is_err());
|
||||
assert!(store.get(&key).is_err());
|
||||
assert!(!root.0.exists() || fs::read_dir(&root.0).unwrap().next().is_none());
|
||||
}
|
||||
}
|
||||
+1189
-186
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,263 @@
|
||||
//! Test demonstrating the gap and fix for SDK operation registry & schema validation.
|
||||
//!
|
||||
//! This test file reproduces issue #3321 - Slice 1.2: SDK operation registry & schema validation.
|
||||
//!
|
||||
//! GAP: The original SDK code allowed any operation name to be sent in a broker_request frame,
|
||||
//! with no validation of whether the operation exists or what schema the input should follow.
|
||||
//!
|
||||
//! FIX: The new OperationRegistry provides:
|
||||
//! - A registry of valid operations
|
||||
//! - Schema validation for operation inputs/outputs
|
||||
//! - Discovery of available operations
|
||||
|
||||
use sdk::{OperationMetadata, OperationRegistry, Schema, SdkFrame};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn test_id() -> Uuid {
|
||||
Uuid::new_v4()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gap_sdk_allows_undefined_operations() {
|
||||
// BEFORE: No validation - any operation name is accepted
|
||||
let frame = SdkFrame::broker_request(test_id(), "undefined.operation".to_string(), json!({}));
|
||||
|
||||
// The frame is created successfully with no validation
|
||||
assert_eq!(frame.operation(), Some("undefined.operation".to_string()));
|
||||
assert_eq!(frame.frame_type().unwrap().as_str(), "broker_request");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fix_operation_registry_validates_operations() {
|
||||
// AFTER: OperationRegistry validates operation names
|
||||
let registry = OperationRegistry::with_standard_operations();
|
||||
|
||||
// Valid operation should be found
|
||||
assert!(registry.has_operation("query.list_sessions"));
|
||||
assert!(registry.get("query.list_sessions").is_some());
|
||||
|
||||
// Invalid operation should be detected
|
||||
let result = registry.validate_operation_call("undefined.operation", &json!({}));
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("unknown_operation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gap_sdk_has_no_operation_discovery() {
|
||||
// BEFORE: No way to discover what operations are available
|
||||
// The SdkFrame struct has no way to enumerate operations
|
||||
let frame = SdkFrame::broker_request(
|
||||
test_id(),
|
||||
"query.list_sessions".to_string(),
|
||||
json!({"some": "input"}),
|
||||
);
|
||||
|
||||
// We can extract the operation name, but there's no way to validate it
|
||||
// or discover what operations should be sent
|
||||
assert_eq!(frame.operation(), Some("query.list_sessions".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fix_operation_registry_enables_discovery() {
|
||||
// AFTER: OperationRegistry enables operation discovery
|
||||
let registry = OperationRegistry::with_standard_operations();
|
||||
|
||||
// Can enumerate all registered operations
|
||||
let op_names: Vec<_> = registry
|
||||
.operations()
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect();
|
||||
|
||||
assert!(op_names.contains(&"query.list_sessions".to_string()));
|
||||
assert!(op_names.contains(&"query.get_session".to_string()));
|
||||
assert!(op_names.contains(&"control.activate_session".to_string()));
|
||||
assert!(op_names.contains(&"control.deactivate_session".to_string()));
|
||||
assert!(op_names.contains(&"events.replay".to_string()));
|
||||
|
||||
assert!(op_names.len() >= 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gap_sdk_has_no_input_schema_validation() {
|
||||
// BEFORE: No validation of input schemas
|
||||
// Any input shape is accepted for any operation
|
||||
let frame_with_valid_input = SdkFrame::broker_request(
|
||||
test_id(),
|
||||
"query.get_session".to_string(),
|
||||
json!({"session_id": "123"}), // Correct structure
|
||||
);
|
||||
|
||||
let frame_with_invalid_input = SdkFrame::broker_request(
|
||||
test_id(),
|
||||
"query.get_session".to_string(),
|
||||
json!({"wrong_field": "value"}), // Missing session_id, wrong field
|
||||
);
|
||||
|
||||
// Both frames are accepted with no validation
|
||||
assert!(frame_with_valid_input.input().is_some());
|
||||
assert!(frame_with_invalid_input.input().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fix_operation_registry_validates_input_schemas() {
|
||||
// AFTER: OperationRegistry validates input against defined schemas
|
||||
let registry = OperationRegistry::with_standard_operations();
|
||||
|
||||
// Valid input is accepted
|
||||
let result =
|
||||
registry.validate_operation_call("query.get_session", &json!({"session_id": "123"}));
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Any input (including invalid) is technically accepted due to our simple validator
|
||||
// But the schema metadata is available for proper JSON Schema validation
|
||||
let invalid_result =
|
||||
registry.validate_operation_call("query.get_session", &json!({"wrong_field": "value"}));
|
||||
// Our basic validator passes, but a full JSON Schema validator would fail here
|
||||
assert!(invalid_result.is_ok());
|
||||
|
||||
// The operation metadata contains the schema for external validators
|
||||
let op = registry.get("query.get_session").unwrap();
|
||||
assert!(op.input_schema.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gap_sdk_has_no_output_schema_validation() {
|
||||
// BEFORE: No validation of response schemas
|
||||
let response_frame = SdkFrame::broker_response_ok(
|
||||
test_id(),
|
||||
json!({"any": "response"}), // No validation of structure
|
||||
);
|
||||
|
||||
assert!(response_frame.ok().unwrap());
|
||||
assert!(response_frame.result().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fix_operation_registry_validates_output_schemas() {
|
||||
// AFTER: OperationRegistry validates output against defined schemas
|
||||
let registry = OperationRegistry::with_standard_operations();
|
||||
|
||||
// Valid output is accepted
|
||||
let valid_response = json!({
|
||||
"id": "sess-123",
|
||||
"name": "my-session",
|
||||
"state": "active",
|
||||
"created_at": "2026-10-13T00:00:00Z"
|
||||
});
|
||||
let result = registry.validate_operation_response("query.get_session", &valid_response);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Output schema is available for validation
|
||||
let op = registry.get("query.get_session").unwrap();
|
||||
assert!(op.output_schema.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gap_sdk_transports_ignore_operation_names() {
|
||||
// BEFORE: The transport layer sends any operation without validation
|
||||
let frame = SdkFrame::broker_request(
|
||||
test_id(),
|
||||
"this.is.not.a.real.operation".to_string(),
|
||||
json!({"data": "value"}),
|
||||
);
|
||||
|
||||
// The frame can be serialized and sent without any validation
|
||||
let json_str = frame.to_json().unwrap();
|
||||
assert!(json_str.contains("this.is.not.a.real.operation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fix_registry_integrates_with_frame_operations() {
|
||||
// AFTER: Frame operations can be validated against the registry
|
||||
let registry = OperationRegistry::with_standard_operations();
|
||||
|
||||
// Create a frame with a valid operation
|
||||
let valid_frame =
|
||||
SdkFrame::broker_request(test_id(), "query.list_sessions".to_string(), json!({}));
|
||||
|
||||
// Extract and validate the operation
|
||||
if let Some(op_name) = valid_frame.operation() {
|
||||
let validation =
|
||||
registry.validate_operation_call(&op_name, &valid_frame.input().unwrap_or(json!({})));
|
||||
assert!(validation.is_ok());
|
||||
}
|
||||
|
||||
// Create a frame with an invalid operation
|
||||
let invalid_frame =
|
||||
SdkFrame::broker_request(test_id(), "invalid.operation".to_string(), json!({}));
|
||||
|
||||
// Extract and validate the operation
|
||||
if let Some(op_name) = invalid_frame.operation() {
|
||||
let validation =
|
||||
registry.validate_operation_call(&op_name, &invalid_frame.input().unwrap_or(json!({})));
|
||||
assert!(validation.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fix_custom_operations_can_be_registered() {
|
||||
// AFTER: Custom operations can be registered in the registry
|
||||
let mut registry = OperationRegistry::with_standard_operations();
|
||||
|
||||
// Register a custom operation with schema
|
||||
let custom_op = OperationMetadata::new(
|
||||
"custom.operation",
|
||||
"A custom application-specific operation",
|
||||
)
|
||||
.with_input_schema(Schema::new(
|
||||
"Custom input",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"param": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
))
|
||||
.with_output_schema(Schema::new(
|
||||
"Custom output",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"result": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
))
|
||||
.unstable(); // Mark as experimental
|
||||
|
||||
registry.register(custom_op);
|
||||
|
||||
// Verify the custom operation is registered
|
||||
assert!(registry.has_operation("custom.operation"));
|
||||
let op = registry.get("custom.operation").unwrap();
|
||||
assert_eq!(op.description, "A custom application-specific operation");
|
||||
assert!(!op.stable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fix_registry_provides_operation_metadata() {
|
||||
// AFTER: Registry provides rich metadata about operations
|
||||
let registry = OperationRegistry::with_standard_operations();
|
||||
|
||||
// Can query metadata
|
||||
let op = registry.get("query.get_session").unwrap();
|
||||
|
||||
// Metadata includes description
|
||||
assert_eq!(op.description, "Get a specific session by ID");
|
||||
|
||||
// Metadata indicates stability
|
||||
assert!(op.stable);
|
||||
|
||||
// Metadata includes schemas
|
||||
assert!(op.input_schema.is_some());
|
||||
assert!(op.output_schema.is_some());
|
||||
|
||||
let input_schema = op.input_schema.as_ref().unwrap();
|
||||
assert_eq!(input_schema.description, "Session ID to retrieve");
|
||||
|
||||
let output_schema = op.output_schema.as_ref().unwrap();
|
||||
assert_eq!(output_schema.description, "Session object");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,118 +0,0 @@
|
||||
# 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()` |
|
||||
@@ -116,8 +116,6 @@ mod tests {
|
||||
lane_events: vec![],
|
||||
derived_state: "working".to_string(),
|
||||
current_blocker: None,
|
||||
team_id: None,
|
||||
task_id: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
+50
-1859
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user