Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 66 additions & 2 deletions rust/crates/api/src/providers/openai_compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -505,7 +512,7 @@ impl StreamState {
}
events.push(StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
index: 0,
delta: ContentBlockDelta::TextDelta { text: content },
delta: ContentBlockDelta::TextDelta { text },
}));
}

Expand Down Expand Up @@ -670,6 +677,7 @@ impl ToolCallState {

#[derive(Debug, Deserialize)]
struct ChatCompletionResponse {
#[serde(default)]
id: String,
model: String,
choices: Vec<ChatChoice>,
Expand Down Expand Up @@ -715,6 +723,7 @@ struct OpenAiUsage {

#[derive(Debug, Deserialize)]
struct ChatCompletionChunk {
#[serde(default)]
id: String,
#[serde(default)]
model: Option<String>,
Expand All @@ -726,6 +735,7 @@ struct ChatCompletionChunk {

#[derive(Debug, Deserialize)]
struct ChunkChoice {
#[serde(default)]
delta: ChunkDelta,
#[serde(default)]
finish_reason: Option<String>,
Expand All @@ -735,10 +745,21 @@ struct ChunkChoice {
struct ChunkDelta {
#[serde(default)]
content: Option<String>,
/// Some providers (GLM, DeepSeek) emit reasoning in `reasoning_content`
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default)]
thinking: Option<ThinkingDelta>,
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
tool_calls: Vec<DeltaToolCall>,
}

#[derive(Debug, Default, Deserialize)]
struct ThinkingDelta {
#[serde(default)]
content: Option<String>,
}

#[derive(Debug, Deserialize)]
struct DeltaToolCall {
#[serde(default)]
Expand Down Expand Up @@ -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::<serde_json::Value>(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("<!") {
return Err(ApiError::Api {
status: reqwest::StatusCode::BAD_REQUEST,
error_type: Some("invalid_response".to_string()),
message: Some("provider returned HTML instead of JSON (check endpoint URL)".to_string()),
request_id: None,
body: trimmed.chars().take(200).collect(),
retryable: false,
suggested_action: Some("verify the API endpoint URL is correct".to_string()),
retry_after: None,
});
}
return Ok(None);
}
let payload = data_lines.join("\n");
Expand Down
5 changes: 5 additions & 0 deletions rust/crates/commands/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1472,10 +1472,15 @@ pub fn validate_slash_command_input(
}
"plan" => 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 },
Expand Down
2 changes: 1 addition & 1 deletion rust/crates/runtime/src/compact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
157 changes: 157 additions & 0 deletions rust/crates/tools/GIT_TOOLS_README.md
Original file line number Diff line number Diff line change
@@ -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<String>` 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.
Loading