diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index a810502e66..d2ff75dd84 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -493,7 +493,14 @@ impl StreamState { } for choice in chunk.choices { - if let Some(content) = choice.delta.content.filter(|value| !value.is_empty()) { + // Handle content from various fields (content, reasoning_content, thinking.content) + let content = choice + .delta + .content + .or(choice.delta.reasoning_content) + .or(choice.delta.thinking.and_then(|t| t.content)) + .filter(|value| !value.is_empty()); + if let Some(text) = content { if !self.text_started { self.text_started = true; events.push(StreamEvent::ContentBlockStart(ContentBlockStartEvent { @@ -505,7 +512,7 @@ impl StreamState { } events.push(StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent { index: 0, - delta: ContentBlockDelta::TextDelta { text: content }, + delta: ContentBlockDelta::TextDelta { text }, })); } @@ -670,6 +677,7 @@ impl ToolCallState { #[derive(Debug, Deserialize)] struct ChatCompletionResponse { + #[serde(default)] id: String, model: String, choices: Vec, @@ -715,6 +723,7 @@ struct OpenAiUsage { #[derive(Debug, Deserialize)] struct ChatCompletionChunk { + #[serde(default)] id: String, #[serde(default)] model: Option, @@ -726,6 +735,7 @@ struct ChatCompletionChunk { #[derive(Debug, Deserialize)] struct ChunkChoice { + #[serde(default)] delta: ChunkDelta, #[serde(default)] finish_reason: Option, @@ -735,10 +745,21 @@ struct ChunkChoice { struct ChunkDelta { #[serde(default)] content: Option, + /// Some providers (GLM, DeepSeek) emit reasoning in `reasoning_content` + #[serde(default)] + reasoning_content: Option, + #[serde(default)] + thinking: Option, #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")] tool_calls: Vec, } +#[derive(Debug, Default, Deserialize)] +struct ThinkingDelta { + #[serde(default)] + content: Option, +} + #[derive(Debug, Deserialize)] struct DeltaToolCall { #[serde(default)] @@ -1260,7 +1281,50 @@ fn parse_sse_frame( data_lines.push(data.trim_start()); } } + // If no SSE data lines found, check if the entire frame is raw JSON (error or otherwise) if data_lines.is_empty() { + // Detect raw JSON error response (not SSE-framed) + if let Ok(raw) = serde_json::from_str::(trimmed) { + if let Some(err_obj) = raw.get("error") { + let msg = err_obj + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("provider returned an error") + .to_string(); + let code = err_obj + .get("code") + .and_then(serde_json::Value::as_u64) + .map(|c| c as u16); + let status = reqwest::StatusCode::from_u16(code.unwrap_or(500)) + .unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR); + return Err(ApiError::Api { + status, + error_type: err_obj + .get("type") + .and_then(|t| t.as_str()) + .map(str::to_owned), + message: Some(msg), + request_id: None, + body: trimmed.chars().take(500).collect(), + retryable: false, + suggested_action: suggested_action_for_status(status), + retry_after: None, + }); + } + } + // Detect HTML responses + if trimmed.starts_with('<') || trimmed.starts_with(" SlashCommand::Plan { mode: remainder }, "review" => SlashCommand::Review { scope: remainder }, + "team" => SlashCommand::Team { action: remainder }, "tasks" => SlashCommand::Tasks { args: remainder }, "theme" => SlashCommand::Theme { name: remainder }, "voice" => SlashCommand::Voice { mode: remainder }, "usage" => SlashCommand::Usage { scope: remainder }, +<<<<<<< HEAD +======= + "setup" => SlashCommand::Setup, +>>>>>>> 2f6a225 (fix: make id field optional in OpenAI response parsing) "rename" => SlashCommand::Rename { name: remainder }, "copy" => SlashCommand::Copy { target: remainder }, "hooks" => SlashCommand::Hooks { args: remainder }, diff --git a/rust/crates/runtime/src/compact.rs b/rust/crates/runtime/src/compact.rs index 3e805dda96..0048845667 100644 --- a/rust/crates/runtime/src/compact.rs +++ b/rust/crates/runtime/src/compact.rs @@ -128,7 +128,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio // is NOT an assistant message that contains a ToolUse block (i.e. the // pair is actually broken at the boundary). loop { - if k == 0 || k <= compacted_prefix_len { + if k == 0 || k <= compacted_prefix_len || k >= session.messages.len() { break; } let first_preserved = &session.messages[k]; diff --git a/rust/crates/tools/GIT_TOOLS_README.md b/rust/crates/tools/GIT_TOOLS_README.md new file mode 100644 index 0000000000..c937daab00 --- /dev/null +++ b/rust/crates/tools/GIT_TOOLS_README.md @@ -0,0 +1,157 @@ +# Git-Aware Context Tools + +Adds five native git tools to claw-code that provide structured, read-only access to repository state. These replace ad-hoc `git` commands via bash with purpose-built tool definitions the model can discover and invoke directly. + +## Tools + +### GitStatus + +Show the working tree status (branch, staged, unstaged, untracked). Equivalent to `git status --short --branch`. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `short` | boolean | no | `true` | Use `--short --branch` format for concise output | + +**Example input:** +```json +{} +``` + +**Example output:** +```json +{ + "output": "## feat/git-aware-tools...upstream/main [ahead 1]\nM rust/crates/tools/src/lib.rs" +} +``` + +--- + +### GitDiff + +Show changes between commits, the index, and the working tree. Supports staged changes, specific paths, commit ranges, and comparing two commits. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `staged` | boolean | no | `false` | Show staged changes (`git diff --cached`) | +| `commit` | string | no | — | Commit hash, tag, or branch to diff against | +| `commit2` | string | no | — | Second commit for range diff (`commit...commit2`) | +| `path` | string | no | — | File path to restrict the diff to | + +**Example inputs:** +```json +{} +``` +```json +{ "staged": true } +``` +```json +{ "commit": "HEAD~3", "path": "rust/crates/tools/src/lib.rs" } +``` +```json +{ "commit": "main", "commit2": "feat/git-aware-tools" } +``` + +--- + +### GitLog + +Show commit history. Supports limiting count, filtering by author/date/path, and oneline format. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `count` | integer | no | `20` | Maximum number of commits to return | +| `oneline` | boolean | no | `false` | Use `--oneline` format (hash + subject only) | +| `author` | string | no | — | Filter commits by author pattern | +| `since` | string | no | — | Filter commits since date (e.g. `"2024-01-01"` or `"2.weeks"`) | +| `until` | string | no | — | Filter commits until date | +| `path` | string | no | — | File or directory path to filter commits by | + +**Example inputs:** +```json +{ "count": 5, "oneline": true } +``` +```json +{ "author": "alice", "since": "1.week", "path": "src/main.rs" } +``` + +--- + +### GitShow + +Show a commit, tag, or tree object with its diff. Supports showing a specific file at a commit and stat-only mode. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `commit` | string | **yes** | — | Commit hash, tag, or branch ref to show | +| `path` | string | no | — | Show only this file at the given commit (`commit:path` syntax) | +| `stat` | boolean | no | `false` | Show diffstat summary instead of full diff | + +**Example inputs:** +```json +{ "commit": "HEAD" } +``` +```json +{ "commit": "abc1234", "stat": true } +``` +```json +{ "commit": "main", "path": "src/lib.rs" } +``` + +--- + +### GitBlame + +Show what revision and author last modified each line of a file. Supports line range filtering. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `path` | string | **yes** | — | File path to blame | +| `start_line` | integer | no | — | Start of line range (1-based) | +| `end_line` | integer | no | — | End of line range (1-based) | + +**Example inputs:** +```json +{ "path": "src/main.rs" } +``` +```json +{ "path": "src/main.rs", "start_line": 100, "end_line": 150 } +``` + +--- + +## Architecture + +All five tools follow the same pattern: + +1. **ToolSpec** — Defines the tool name, description, JSON input schema, and `PermissionMode::ReadOnly` +2. **Input struct** — Derives `Deserialize` with `#[serde(default)]` on optional fields +3. **Run function** — Builds git arguments, calls `git_stdout()`, wraps result in JSON via `to_pretty_json()` +4. **Dispatch** — Matched in `execute_tool_with_enforcer()` like all other tools + +The existing `git_stdout(args: &[&str]) -> Option` helper (at `tools/src/lib.rs`) handles running the `git` subprocess and returning trimmed stdout. Git tools simply construct the right arguments and delegate to this helper. + +## Why native git tools? + +Before this PR, the model had to use the `bash` tool for git operations, which has several drawbacks: + +- **No structured output** — Bash returns raw text that the model must parse +- **Over-permissioned** — Bash requires `DangerFullAccess` even for read-only git commands +- **No discoverability** — The model can't search for git-capable tools via `ToolSearch` +- **Inconsistent** — Each invocation may use different flags or formatting + +With native git tools: + +- All five are `ReadOnly` — safe in restricted permission modes +- Structured JSON output — consistent, parseable results +- Discoverable via `ToolSearch` with keywords like "git", "diff", "blame" +- Model-friendly descriptions explain when to use each tool vs bash + +## Testing + +```bash +cd rust +cargo build --release +cargo test -p tools +``` + +The 3 pre-existing test failures (agent_fake_runner, agent_persists_handoff, worker_create_merges_config) are unrelated to this change — they fail due to local settings.json incompatibilities. diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index f3d1849ac1..4b0bbe2291 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -1175,6 +1175,80 @@ pub fn mvp_tool_specs() -> Vec { }), required_permission: PermissionMode::DangerFullAccess, }, + ToolSpec { + name: "GitStatus", + description: "Show the working tree status (branch, staged, unstaged, untracked). Equivalent to 'git status --short --branch'. Use this instead of running git status via bash to get structured, parseable output.", + input_schema: json!({ + "type": "object", + "properties": { + "short": { "type": "boolean" } + }, + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ToolSpec { + name: "GitDiff", + description: "Show changes between commits, the index, and the working tree. Supports staged changes ('git diff --cached'), specific paths, commit ranges, and comparing two commits. Use this instead of running git diff via bash to get structured output.", + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string" }, + "staged": { "type": "boolean" }, + "commit": { "type": "string" }, + "commit2": { "type": "string" } + }, + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ToolSpec { + name: "GitLog", + description: "Show commit history. Supports limiting count, filtering by author/date/path, and oneline format. Defaults to the last 20 commits. Use this instead of running git log via bash to get structured output.", + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string" }, + "count": { "type": "integer", "minimum": 1 }, + "oneline": { "type": "boolean" }, + "author": { "type": "string" }, + "since": { "type": "string" }, + "until": { "type": "string" } + }, + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ToolSpec { + name: "GitShow", + description: "Show a commit, tag, or tree object with its diff. Supports showing a specific file at a commit (commit:path) and stat-only mode. Use this instead of running git show via bash to get structured output.", + input_schema: json!({ + "type": "object", + "properties": { + "commit": { "type": "string" }, + "path": { "type": "string" }, + "stat": { "type": "boolean" } + }, + "required": ["commit"], + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ToolSpec { + name: "GitBlame", + description: "Show what revision and author last modified each line of a file. Supports line range filtering (start_line, end_line). Use this instead of running git blame via bash to get structured output.", + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string" }, + "start_line": { "type": "integer", "minimum": 1 }, + "end_line": { "type": "integer", "minimum": 1 } + }, + "required": ["path"], + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, ] } @@ -1293,6 +1367,11 @@ fn execute_tool_with_enforcer( "TestingPermission" => { from_value::(input).and_then(run_testing_permission) } + "GitStatus" => from_value::(input).and_then(run_git_status), + "GitDiff" => from_value::(input).and_then(run_git_diff), + "GitLog" => from_value::(input).and_then(run_git_log), + "GitShow" => from_value::(input).and_then(run_git_show), + "GitBlame" => from_value::(input).and_then(run_git_blame), _ => Err(format!("unsupported tool: {name}")), } } @@ -1841,6 +1920,123 @@ fn run_testing_permission(input: TestingPermissionInput) -> Result Result { + let mut args: Vec<&str> = vec!["status"]; + if input.short.unwrap_or(true) { + args.push("--short"); + args.push("--branch"); + } + match git_stdout(&args) { + Some(output) => to_pretty_json(json!({ + "output": output + })), + None => Err("git status failed. Ensure the current directory is inside a git repository.".to_string()), + } +} + +#[allow(clippy::needless_pass_by_value)] +/// Execute `git diff` with optional --cached, commit, and path filters. +/// Returns the diff output wrapped in a JSON object. +fn run_git_diff(input: GitDiffInput) -> Result { + let mut args: Vec = vec!["diff".to_string()]; + if input.staged.unwrap_or(false) { + args.push("--cached".to_string()); + } + if let Some(ref commit) = input.commit { + if let Some(ref commit2) = input.commit2 { + args.push(format!("{commit}...{commit2}")); + } else { + args.push(commit.clone()); + } + } + if let Some(ref path) = input.path { + args.push("--".to_string()); + args.push(path.clone()); + } + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + match git_stdout(&arg_refs) { + Some(output) => to_pretty_json(json!({ + "output": output + })), + None => Err("git diff failed. Ensure the current directory is inside a git repository.".to_string()), + } +} + +#[allow(clippy::needless_pass_by_value)] +/// Execute `git log` with count, author, date, and path filters. +/// Defaults to the last 20 commits. +fn run_git_log(input: GitLogInput) -> Result { + let mut args: Vec = vec!["log".to_string()]; + let count = input.count.unwrap_or(20); + args.push(format!("-n{count}")); + if input.oneline.unwrap_or(false) { + args.push("--oneline".to_string()); + } + if let Some(ref author) = input.author { + args.push(format!("--author={author}")); + } + if let Some(ref since) = input.since { + args.push(format!("--since={since}")); + } + if let Some(ref until) = input.until { + args.push(format!("--until={until}")); + } + if let Some(ref path) = input.path { + args.push("--".to_string()); + args.push(path.clone()); + } + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + match git_stdout(&arg_refs) { + Some(output) => to_pretty_json(json!({ + "output": output + })), + None => Err("git log failed. Ensure the current directory is inside a git repository.".to_string()), + } +} + +#[allow(clippy::needless_pass_by_value)] +/// Execute `git show` for a given commit, optionally with --stat or a file path. +/// Uses the `commit:path` syntax when a path is specified. +fn run_git_show(input: GitShowInput) -> Result { + let mut args: Vec = vec!["show".to_string()]; + if input.stat.unwrap_or(false) { + args.push("--stat".to_string()); + } + if let Some(ref path) = input.path { + args.push(format!("{}:{}", input.commit, path)); + } else { + args.push(input.commit.clone()); + } + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + match git_stdout(&arg_refs) { + Some(output) => to_pretty_json(json!({ + "output": output + })), + None => Err(format!("git show {} failed. Ensure the commit exists.", input.commit)), + } +} + +#[allow(clippy::needless_pass_by_value)] +/// Execute `git blame` on a file, optionally restricted to a line range. +fn run_git_blame(input: GitBlameInput) -> Result { + let mut args: Vec = vec!["blame".to_string()]; + if let (Some(start), Some(end)) = (input.start_line, input.end_line) { + args.push(format!("-L{start},{end}")); + } + args.push(input.path.clone()); + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + match git_stdout(&arg_refs) { + Some(output) => to_pretty_json(json!({ + "output": output + })), + None => Err(format!("git blame {} failed. Ensure the file exists and the directory is inside a git repository.", input.path)), + } +} + fn from_value Deserialize<'de>>(input: &Value) -> Result { serde_json::from_value(input.clone()).map_err(|error| error.to_string()) } @@ -2547,6 +2743,85 @@ struct TestingPermissionInput { action: String, } +/// Input for the GitStatus tool: shows working tree status. +/// Defaults to --short --branch mode for concise, parseable output. +#[derive(Debug, Deserialize)] +struct GitStatusInput { + #[serde(default)] + /// If true, use --short --branch format. Defaults to true. + short: Option, +} + +/// Input for the GitDiff tool: shows changes between commits, index, and working tree. +/// All fields are optional - calling with no options is equivalent to `git diff`. +#[derive(Debug, Deserialize)] +struct GitDiffInput { + #[serde(default)] + /// File path to diff. Prepends `--` before the path. + path: Option, + #[serde(default)] + /// If true, show staged changes (`git diff --cached`). + staged: Option, + #[serde(default)] + /// A commit hash, tag, or branch to diff against. + commit: Option, + #[serde(default)] + /// A second commit for range diffs (commit...commit2). + commit2: Option, +} + +/// Input for the GitLog tool: shows commit history. +/// Defaults to the last 20 commits in full format. +#[derive(Debug, Deserialize)] +struct GitLogInput { + #[serde(default)] + /// File or directory path to filter commits by. + path: Option, + #[serde(default)] + /// Maximum number of commits to return. Defaults to 20. + count: Option, + #[serde(default)] + /// If true, use --oneline format (hash + subject only). + oneline: Option, + #[serde(default)] + /// Filter commits by author pattern. + author: Option, + #[serde(default)] + /// Filter commits since date (e.g. "2024-01-01" or "2.weeks"). + since: Option, + #[serde(default)] + /// Filter commits until date. + until: Option, +} + +/// Input for the GitShow tool: shows a commit, tag, or tree object. +#[derive(Debug, Deserialize)] +struct GitShowInput { + /// Commit hash, tag, or branch ref to show. Required. + commit: String, + #[serde(default)] + /// If set, show only this file at the given commit (commit:path syntax). + path: Option, + #[serde(default)] + /// If true, show diffstat summary instead of full diff. + stat: Option, +} + +/// Input for the GitBlame tool: shows per-line author/revision info for a file. +#[derive(Debug, Deserialize)] +struct GitBlameInput { + /// File path to blame. Required. + path: String, + #[serde(rename = "start_line")] + #[serde(default)] + /// Start of line range (1-based). Only used if end_line is also set. + start_line: Option, + #[serde(rename = "end_line")] + #[serde(default)] + /// End of line range (1-based). Only used if start_line is also set. + end_line: Option, +} + #[derive(Debug, Serialize)] struct WebFetchOutput { bytes: usize, diff --git a/rust/scripts/install.sh b/rust/scripts/install.sh new file mode 100755 index 0000000000..344a7b5c62 --- /dev/null +++ b/rust/scripts/install.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -e + +# Build the release binary +cargo build --release + +# Link to ~/.local/bin +mkdir -p "$HOME/.local/bin" +ln -sf "$(pwd)/target/release/claw" "$HOME/.local/bin/claw" + +echo "✓ Claw installed to ~/.local/bin/claw"