1+ use std:: time:: Duration ;
2+
13use anyhow:: { Context , Result } ;
2- use log:: { debug, error} ;
4+ use log:: { debug, error, warn } ;
35use serde_json:: { json, Value } ;
46
57use super :: { ContentBlock , LLMEvent , LLMProvider , Message , RichMessage , Role } ;
68
9+ const RETRY_DELAYS : & [ Duration ] = & [
10+ Duration :: from_millis ( 500 ) ,
11+ Duration :: from_millis ( 2000 ) ,
12+ Duration :: from_millis ( 4000 ) ,
13+ ] ;
14+
715pub struct AnthropicProvider {
816 api_key : String ,
917 model : String ,
@@ -18,49 +26,131 @@ impl AnthropicProvider {
1826 debug ! ( "[Anthropic] POST /v1/messages model={} messages={}" , self . model, body[ "messages" ] . as_array( ) . map( |a| a. len( ) ) . unwrap_or( 0 ) ) ;
1927
2028 let client = reqwest:: blocking:: Client :: new ( ) ;
21- let resp = client
22- . post ( "https://api.anthropic.com/v1/messages" )
23- . header ( "x-api-key" , & self . api_key )
24- . header ( "anthropic-version" , "2023-06-01" )
25- . header ( "content-type" , "application/json" )
26- . json ( & body)
27- . send ( )
28- . context ( "sending request to Anthropic" ) ?;
29-
30- let status = resp. status ( ) ;
31- debug ! ( "[Anthropic] response status={}" , status) ;
32-
33- let json: Value = resp. json ( ) . context ( "parsing Anthropic response" ) ?;
34-
35- if !status. is_success ( ) {
36- error ! ( "[Anthropic] error response: {}" , json) ;
29+ let mut last_err: anyhow:: Error = anyhow:: anyhow!( "no attempts made" ) ;
30+
31+ for attempt in 0 ..=RETRY_DELAYS . len ( ) {
32+ if attempt > 0 {
33+ let delay = RETRY_DELAYS [ attempt - 1 ] ;
34+ warn ! ( "[Anthropic] retry {}/{} after {}ms" , attempt, RETRY_DELAYS . len( ) , delay. as_millis( ) ) ;
35+ std:: thread:: sleep ( delay) ;
36+ }
37+
38+ let resp = match client
39+ . post ( "https://api.anthropic.com/v1/messages" )
40+ . header ( "x-api-key" , & self . api_key )
41+ . header ( "anthropic-version" , "2023-06-01" )
42+ . header ( "content-type" , "application/json" )
43+ . json ( & body)
44+ . send ( )
45+ {
46+ Ok ( r) => r,
47+ Err ( e) => {
48+ warn ! ( "[Anthropic] request error (attempt {}): {}" , attempt + 1 , e) ;
49+ last_err = anyhow:: Error :: from ( e) . context ( "sending request to Anthropic" ) ;
50+ continue ;
51+ }
52+ } ;
53+
54+ let status = resp. status ( ) ;
55+ debug ! ( "[Anthropic] response status={}" , status) ;
56+
57+ let json: Value = match resp. json ( ) . context ( "parsing Anthropic response" ) {
58+ Ok ( v) => v,
59+ Err ( e) => {
60+ warn ! ( "[Anthropic] parse error (attempt {}): {}" , attempt + 1 , e) ;
61+ last_err = e;
62+ continue ;
63+ }
64+ } ;
65+
66+ if status. is_server_error ( ) || status. as_u16 ( ) == 429 {
67+ error ! ( "[Anthropic] retryable error response (attempt {}): {}" , attempt + 1 , json) ;
68+ last_err = anyhow:: anyhow!( "Anthropic error {}: {}" , status, json) ;
69+ continue ;
70+ }
71+
72+ if !status. is_success ( ) {
73+ error ! ( "[Anthropic] error response: {}" , json) ;
74+ }
75+
76+ return Ok ( json) ;
3777 }
3878
39- Ok ( json )
79+ Err ( last_err )
4080 }
4181}
4282
43- /// The `run_command` tool definition sent to Claude on every rich request.
44- fn run_command_tool ( ) -> Value {
45- json ! ( {
46- "name" : "run_command" ,
47- "description" : "Execute a shell command on the user's remote SSH session. \
48- The user will be shown the command and must approve before it runs." ,
49- "input_schema" : {
50- "type " : "object" ,
51- "properties ": {
52- "command " : {
53- "type" : "string" ,
54- "description" : "The exact shell command to execute."
83+ /// All tool definitions sent to Claude on every rich request.
84+ fn all_tools ( ) -> Value {
85+ json ! ( [
86+ {
87+ "name" : "run_command" ,
88+ "description" : "Execute an arbitrary shell command on the user's remote SSH session. \
89+ The user will be shown the command and must approve before it runs." ,
90+ "input_schema " : {
91+ "type ": "object" ,
92+ "properties " : {
93+ "command" : { " type": "string" , "description" : "The exact shell command to execute." } ,
94+ "description" : { "type" : "string" , "description" : "One-sentence plain-English explanation of what this command does." }
5595 } ,
56- "description" : {
57- "type" : "string" ,
58- "description" : "One-sentence plain-English explanation of what this command does."
59- }
60- } ,
61- "required" : [ "command" ]
96+ "required" : [ "command" ]
97+ }
98+ } ,
99+ {
100+ "name" : "system_information" ,
101+ "description" : "Return the SSH connection settings for the current session (host, user, port, description, identity file, extra options). No PTY interaction needed." ,
102+ "input_schema" : { "type" : "object" , "properties" : { } , "required" : [ ] }
103+ } ,
104+ {
105+ "name" : "make_dir" ,
106+ "description" : "Create a directory (and any missing parents) on the remote host using mkdir -p." ,
107+ "input_schema" : {
108+ "type" : "object" ,
109+ "properties" : {
110+ "path" : { "type" : "string" , "description" : "Absolute or relative path of the directory to create." }
111+ } ,
112+ "required" : [ "path" ]
113+ }
114+ } ,
115+ {
116+ "name" : "touch_file" ,
117+ "description" : "Create an empty file (or update its timestamp) on the remote host using touch." ,
118+ "input_schema" : {
119+ "type" : "object" ,
120+ "properties" : {
121+ "file" : { "type" : "string" , "description" : "Path of the file to create or touch." }
122+ } ,
123+ "required" : [ "file" ]
124+ }
125+ } ,
126+ {
127+ "name" : "read_file" ,
128+ "description" : "Read and return the contents of a file on the remote host using cat." ,
129+ "input_schema" : {
130+ "type" : "object" ,
131+ "properties" : {
132+ "file" : { "type" : "string" , "description" : "Path of the file to read." }
133+ } ,
134+ "required" : [ "file" ]
135+ }
136+ } ,
137+ {
138+ "name" : "list_dir" ,
139+ "description" : "List the contents of a directory on the remote host using ls -la." ,
140+ "input_schema" : {
141+ "type" : "object" ,
142+ "properties" : {
143+ "path" : { "type" : "string" , "description" : "Directory path to list. Defaults to current directory." }
144+ } ,
145+ "required" : [ ]
146+ }
62147 }
63- } )
148+ ] )
149+ }
150+
151+ /// Wrap a path/filename in single quotes, escaping any embedded single quotes.
152+ fn shell_quote ( s : & str ) -> String {
153+ format ! ( "'{}'" , s. replace( '\'' , "'\\ ''" ) )
64154}
65155
66156/// Convert a `RichMessage` to the JSON format Anthropic expects.
@@ -173,7 +263,7 @@ impl LLMProvider for AnthropicProvider {
173263 let mut body = json ! ( {
174264 "model" : self . model,
175265 "max_tokens" : 8096 ,
176- "tools" : [ run_command_tool ( ) ] ,
266+ "tools" : all_tools ( ) ,
177267 "messages" : msgs,
178268 } ) ;
179269
@@ -198,12 +288,6 @@ impl LLMProvider for AnthropicProvider {
198288 let name = tool_use[ "name" ] . as_str ( ) . unwrap_or ( "" ) . to_string ( ) ;
199289 let input = tool_use[ "input" ] . clone ( ) ;
200290
201- let command = input[ "command" ]
202- . as_str ( )
203- . ok_or_else ( || anyhow:: anyhow!( "run_command tool missing 'command' field" ) ) ?
204- . to_string ( ) ;
205- let description = input[ "description" ] . as_str ( ) . map ( |s| s. to_string ( ) ) ;
206-
207291 // Build the content blocks to append to rich history.
208292 let mut assistant_blocks: Vec < ContentBlock > = vec ! [ ] ;
209293 for block in & content {
@@ -226,13 +310,53 @@ impl LLMProvider for AnthropicProvider {
226310 }
227311 }
228312
229- debug ! ( "[Anthropic] tool_call: name={} command={:?}" , name, command) ;
230- return Ok ( LLMEvent :: ToolCall {
231- id,
232- command,
233- description,
234- assistant_blocks,
235- } ) ;
313+ // Dispatch by tool name.
314+ match name. as_str ( ) {
315+ "system_information" => {
316+ debug ! ( "[Anthropic] local tool: system_information" ) ;
317+ return Ok ( LLMEvent :: LocalTool { id, name, input, assistant_blocks } ) ;
318+ }
319+ "run_command" => {
320+ let command = input[ "command" ]
321+ . as_str ( )
322+ . ok_or_else ( || anyhow:: anyhow!( "run_command missing 'command' field" ) ) ?
323+ . to_string ( ) ;
324+ let description = input[ "description" ] . as_str ( ) . map ( |s| s. to_string ( ) ) ;
325+ debug ! ( "[Anthropic] tool_call: run_command command={:?}" , command) ;
326+ return Ok ( LLMEvent :: ToolCall { id, command, description, assistant_blocks } ) ;
327+ }
328+ "make_dir" => {
329+ let path = input[ "path" ] . as_str ( ) . unwrap_or ( "." ) ;
330+ let command = format ! ( "mkdir -p {}" , shell_quote( path) ) ;
331+ let description = Some ( format ! ( "Create directory {}" , path) ) ;
332+ debug ! ( "[Anthropic] tool_call: make_dir path={:?}" , path) ;
333+ return Ok ( LLMEvent :: ToolCall { id, command, description, assistant_blocks } ) ;
334+ }
335+ "touch_file" => {
336+ let file = input[ "file" ] . as_str ( ) . unwrap_or ( "" ) ;
337+ let command = format ! ( "touch {}" , shell_quote( file) ) ;
338+ let description = Some ( format ! ( "Create/touch file {}" , file) ) ;
339+ debug ! ( "[Anthropic] tool_call: touch_file file={:?}" , file) ;
340+ return Ok ( LLMEvent :: ToolCall { id, command, description, assistant_blocks } ) ;
341+ }
342+ "read_file" => {
343+ let file = input[ "file" ] . as_str ( ) . unwrap_or ( "" ) ;
344+ let command = format ! ( "cat {}" , shell_quote( file) ) ;
345+ let description = Some ( format ! ( "Read file {}" , file) ) ;
346+ debug ! ( "[Anthropic] tool_call: read_file file={:?}" , file) ;
347+ return Ok ( LLMEvent :: ToolCall { id, command, description, assistant_blocks } ) ;
348+ }
349+ "list_dir" => {
350+ let path = input[ "path" ] . as_str ( ) . unwrap_or ( "." ) ;
351+ let command = format ! ( "ls -la {}" , shell_quote( path) ) ;
352+ let description = Some ( format ! ( "List directory {}" , path) ) ;
353+ debug ! ( "[Anthropic] tool_call: list_dir path={:?}" , path) ;
354+ return Ok ( LLMEvent :: ToolCall { id, command, description, assistant_blocks } ) ;
355+ }
356+ other => {
357+ return Err ( anyhow:: anyhow!( "unknown tool: {}" , other) ) ;
358+ }
359+ }
236360 }
237361
238362 // Normal text response.
0 commit comments