Skip to content
Merged
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
12 changes: 11 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
[workspace]
members = [".", "crates/sheesh-tools"]
resolver = "2"

[package]
name = "sheesh-rs"
version = "1.0.4"
version = "1.0.5"
edition = "2024"

[dependencies]
Expand All @@ -20,3 +24,5 @@ reqwest = { version = "0.12", features = ["blocking", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"

sheesh-tools = { path = "crates/sheesh-tools" }
9 changes: 9 additions & 0 deletions crates/sheesh-tools/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[package]
name = "sheesh-tools"
version = "0.1.0"
edition = "2024"

[dependencies]
anyhow = "1"
log = "0.4"
serde_json = "1"
142 changes: 142 additions & 0 deletions crates/sheesh-tools/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
use anyhow::Result;
use serde_json::{json, Value};

/// All tool definitions in Anthropic's input_schema format.
/// Providers targeting other APIs (OpenAI, Ollama) should convert as needed.
pub fn all_tools() -> Value {
json!([
{
"name": "run_command",
"description": "Execute an arbitrary shell command on the user's remote SSH session. \
The user will be shown the command and must approve before it runs.",
"input_schema": {
"type": "object",
"properties": {
"command": { "type": "string", "description": "The exact shell command to execute." },
"description": { "type": "string", "description": "One-sentence plain-English explanation of what this command does." }
},
"required": ["command"]
}
},
{
"name": "system_information",
"description": "Return the SSH connection settings for the current session (host, user, port, description, identity file, extra options). No PTY interaction needed.",
"input_schema": { "type": "object", "properties": {}, "required": [] }
},
{
"name": "make_dir",
"description": "Create a directory (and any missing parents) on the remote host using mkdir -p.",
"input_schema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Absolute or relative path of the directory to create." }
},
"required": ["path"]
}
},
{
"name": "touch_file",
"description": "Create an empty file (or update its timestamp) on the remote host using touch.",
"input_schema": {
"type": "object",
"properties": {
"file": { "type": "string", "description": "Path of the file to create or touch." }
},
"required": ["file"]
}
},
{
"name": "read_file",
"description": "Read and return the contents of a file on the remote host using cat.",
"input_schema": {
"type": "object",
"properties": {
"file": { "type": "string", "description": "Path of the file to read." }
},
"required": ["file"]
}
},
{
"name": "list_dir",
"description": "List the contents of a directory on the remote host using ls -la.",
"input_schema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Directory path to list. Defaults to current directory." }
},
"required": []
}
},
{
"name": "read_terminal",
"description": "Read the recent output from the user's terminal. Returns the last lines of captured terminal output. Use this to understand what is currently happening in the SSH session.",
"input_schema": { "type": "object", "properties": {}, "required": [] }
}
])
}

/// Wrap a path/filename in single quotes, escaping any embedded single quotes.
pub fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}

/// Provider-agnostic result of dispatching a tool call by name.
/// The caller (LLM provider) maps this to its own event type and appends
/// any provider-specific history blocks before forwarding upstream.
pub enum ToolResult {
/// Tool is resolved locally by the application (no PTY needed).
Local { id: String, name: String },
/// Tool maps to a shell command that should be run on the PTY.
Command { id: String, command: String, description: Option<String> },
}

/// Dispatch a tool call by `name` + `input` JSON to a [`ToolResult`].
pub fn dispatch(id: impl Into<String>, name: impl Into<String>, input: &Value) -> Result<ToolResult> {
let id = id.into();
let name = name.into();

match name.as_str() {
"system_information" | "read_terminal" => {
log::debug!("[sheesh-tools] local tool: {}", name);
Ok(ToolResult::Local { id, name })
}
"run_command" => {
let command = input["command"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("run_command missing 'command' field"))?
.to_string();
let description = input["description"].as_str().map(|s| s.to_string());
log::debug!("[sheesh-tools] run_command command={:?}", command);
Ok(ToolResult::Command { id, command, description })
}
"make_dir" => {
let path = input["path"].as_str().unwrap_or(".");
let command = format!("mkdir -p {}", shell_quote(path));
let description = Some(format!("Create directory {}", path));
log::debug!("[sheesh-tools] make_dir path={:?}", path);
Ok(ToolResult::Command { id, command, description })
}
"touch_file" => {
let file = input["file"].as_str().unwrap_or("");
let command = format!("touch {}", shell_quote(file));
let description = Some(format!("Create/touch file {}", file));
log::debug!("[sheesh-tools] touch_file file={:?}", file);
Ok(ToolResult::Command { id, command, description })
}
"read_file" => {
let file = input["file"].as_str().unwrap_or("");
let command = format!("cat {}", shell_quote(file));
let description = Some(format!("Read file {}", file));
log::debug!("[sheesh-tools] read_file file={:?}", file);
Ok(ToolResult::Command { id, command, description })
}
"list_dir" => {
let path = input["path"].as_str().unwrap_or(".");
let command = format!("ls -la {}", shell_quote(path));
let description = Some(format!("List directory {}", path));
log::debug!("[sheesh-tools] list_dir path={:?}", path);
Ok(ToolResult::Command { id, command, description })
}
other => Err(anyhow::anyhow!("unknown tool: {}", other)),
}
}
2 changes: 2 additions & 0 deletions src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ pub enum Action {
Disconnect,
/// Send a command string to the terminal PTY (no trailing newline).
SendToTerminal(String),
/// Cancel an in-progress tool call and return to the user prompt.
CancelToolCall,
/// No-op
None,
}
131 changes: 6 additions & 125 deletions src/llm/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use log::{debug, error, warn};
use serde_json::{json, Value};

use super::{ContentBlock, LLMEvent, LLMProvider, Message, RichMessage, Role};
use sheesh_tools::{ToolResult, all_tools, dispatch};

const RETRY_DELAYS: &[Duration] = &[
Duration::from_millis(500),
Expand Down Expand Up @@ -80,84 +81,6 @@ impl AnthropicProvider {
}
}

/// All tool definitions sent to Claude on every rich request.
fn all_tools() -> Value {
json!([
{
"name": "run_command",
"description": "Execute an arbitrary shell command on the user's remote SSH session. \
The user will be shown the command and must approve before it runs.",
"input_schema": {
"type": "object",
"properties": {
"command": { "type": "string", "description": "The exact shell command to execute." },
"description": { "type": "string", "description": "One-sentence plain-English explanation of what this command does." }
},
"required": ["command"]
}
},
{
"name": "system_information",
"description": "Return the SSH connection settings for the current session (host, user, port, description, identity file, extra options). No PTY interaction needed.",
"input_schema": { "type": "object", "properties": {}, "required": [] }
},
{
"name": "make_dir",
"description": "Create a directory (and any missing parents) on the remote host using mkdir -p.",
"input_schema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Absolute or relative path of the directory to create." }
},
"required": ["path"]
}
},
{
"name": "touch_file",
"description": "Create an empty file (or update its timestamp) on the remote host using touch.",
"input_schema": {
"type": "object",
"properties": {
"file": { "type": "string", "description": "Path of the file to create or touch." }
},
"required": ["file"]
}
},
{
"name": "read_file",
"description": "Read and return the contents of a file on the remote host using cat.",
"input_schema": {
"type": "object",
"properties": {
"file": { "type": "string", "description": "Path of the file to read." }
},
"required": ["file"]
}
},
{
"name": "list_dir",
"description": "List the contents of a directory on the remote host using ls -la.",
"input_schema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Directory path to list. Defaults to current directory." }
},
"required": []
}
},
{
"name": "read_terminal",
"description": "Read the recent output from the user's terminal. Returns the last lines of captured terminal output. Use this to understand what is currently happening in the SSH session.",
"input_schema": { "type": "object", "properties": {}, "required": [] }
}
])
}

/// Wrap a path/filename in single quotes, escaping any embedded single quotes.
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}

/// Convert a `RichMessage` to the JSON format Anthropic expects.
fn rich_to_json(m: &RichMessage) -> Value {
let role = match m.role {
Expand Down Expand Up @@ -320,53 +243,11 @@ impl LLMProvider for AnthropicProvider {
}
}

// Dispatch by tool name.
match name.as_str() {
"system_information" | "read_terminal" => {
debug!("[Anthropic] local tool: {}", name);
return Ok(LLMEvent::LocalTool { id, name, assistant_blocks });
}
"run_command" => {
let command = input["command"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("run_command missing 'command' field"))?
.to_string();
let description = input["description"].as_str().map(|s| s.to_string());
debug!("[Anthropic] tool_call: run_command command={:?}", command);
return Ok(LLMEvent::ToolCall { id, command, description, assistant_blocks });
}
"make_dir" => {
let path = input["path"].as_str().unwrap_or(".");
let command = format!("mkdir -p {}", shell_quote(path));
let description = Some(format!("Create directory {}", path));
debug!("[Anthropic] tool_call: make_dir path={:?}", path);
return Ok(LLMEvent::ToolCall { id, command, description, assistant_blocks });
}
"touch_file" => {
let file = input["file"].as_str().unwrap_or("");
let command = format!("touch {}", shell_quote(file));
let description = Some(format!("Create/touch file {}", file));
debug!("[Anthropic] tool_call: touch_file file={:?}", file);
return Ok(LLMEvent::ToolCall { id, command, description, assistant_blocks });
}
"read_file" => {
let file = input["file"].as_str().unwrap_or("");
let command = format!("cat {}", shell_quote(file));
let description = Some(format!("Read file {}", file));
debug!("[Anthropic] tool_call: read_file file={:?}", file);
return Ok(LLMEvent::ToolCall { id, command, description, assistant_blocks });
}
"list_dir" => {
let path = input["path"].as_str().unwrap_or(".");
let command = format!("ls -la {}", shell_quote(path));
let description = Some(format!("List directory {}", path));
debug!("[Anthropic] tool_call: list_dir path={:?}", path);
return Ok(LLMEvent::ToolCall { id, command, description, assistant_blocks });
}
other => {
return Err(anyhow::anyhow!("unknown tool: {}", other));
}
}
// Dispatch by tool name via shared sheesh-tools crate.
return match dispatch(id, name, &input)? {
ToolResult::Local { id, name } => Ok(LLMEvent::LocalTool { id, name, assistant_blocks }),
ToolResult::Command { id, command, description } => Ok(LLMEvent::ToolCall { id, command, description, assistant_blocks }),
};
}

// Normal text response.
Expand Down
Loading