2026-09-13 07:57:57 +00:00
//! Built-in tools: shell, files, completion, and optional Playwright browser (P3).
2026-09-13 07:42:59 +00:00
use anyhow ::{ Context , Result , anyhow } ;
use serde_json ::{ Value , json } ;
use std ::path ::{ Component , Path , PathBuf } ;
use std ::process ::Stdio ;
use std ::time ::Duration ;
use tokio ::process ::Command ;
2026-09-13 07:57:57 +00:00
use crate ::browser ::{ self , LastUrlSlot } ;
2026-09-13 07:42:59 +00:00
pub const MAX_READ_BYTES : usize = 256 * 1024 ;
pub const SHELL_TIMEOUT_SECS : u64 = 30 ;
/// Runtime context for tool execution.
#[ derive(Debug, Clone) ]
pub struct ToolContext {
/// Default working directory for relative paths / shell.
pub cwd : PathBuf ,
/// Optional workspace root; paths outside it are rejected when set.
pub workspace_root : Option < PathBuf > ,
2026-09-13 07:57:57 +00:00
/// Last navigated browser URL (shared across clones).
pub last_browser_url : LastUrlSlot ,
2026-09-13 07:42:59 +00:00
}
impl ToolContext {
pub fn new ( cwd : impl Into < PathBuf > ) -> Self {
let cwd = cwd . into ( ) ;
Self {
cwd : cwd . clone ( ) ,
workspace_root : Some ( cwd ) ,
2026-09-13 07:57:57 +00:00
last_browser_url : std ::sync ::Arc ::new ( std ::sync ::Mutex ::new ( None ) ) ,
2026-09-13 07:42:59 +00:00
}
}
pub fn with_workspace ( mut self , root : Option < PathBuf > ) -> Self {
self . workspace_root = root ;
self
}
2026-09-13 07:57:57 +00:00
pub fn last_browser_url_value ( & self ) -> Option < String > {
self . last_browser_url . lock ( ) . ok ( ) . and_then ( | g | g . clone ( ) )
}
2026-09-13 07:42:59 +00:00
}
/// OpenAI-compatible tool definitions for chat completions.
pub fn tool_definitions ( ) -> Value {
2026-09-13 07:57:57 +00:00
let mut defs = json! ( [
2026-09-13 07:42:59 +00:00
{
" type " : " function " ,
" function " : {
" name " : " shell " ,
" description " : " Run a shell command. Captures stdout, stderr, and exit code. Timeout 30s. cwd defaults to the session working directory. " ,
" parameters " : {
" type " : " object " ,
" properties " : {
" command " : { " type " : " string " , " description " : " Shell command to run " } ,
" cwd " : { " type " : " string " , " description " : " Optional working directory " }
} ,
" required " : [ " command " ]
}
}
} ,
{
" type " : " function " ,
" function " : {
" name " : " list_dir " ,
" description " : " List entries in a directory (names only, sorted). " ,
" parameters " : {
" type " : " object " ,
" properties " : {
" path " : { " type " : " string " , " description " : " Directory path (relative to cwd or absolute) " }
} ,
" required " : [ " path " ]
}
}
} ,
{
" type " : " function " ,
" function " : {
" name " : " read_file " ,
" description " : " Read a text file (max 256KB). Returns contents as UTF-8 (lossy). " ,
" parameters " : {
" type " : " object " ,
" properties " : {
" path " : { " type " : " string " , " description " : " File path " }
} ,
" required " : [ " path " ]
}
}
} ,
{
" type " : " function " ,
" function " : {
" name " : " write_file " ,
" description " : " Write text to a file, creating parent directories as needed. " ,
" parameters " : {
" type " : " object " ,
" properties " : {
" path " : { " type " : " string " , " description " : " File path " } ,
" content " : { " type " : " string " , " description " : " File contents " }
} ,
" required " : [ " path " , " content " ]
}
}
2026-09-13 07:51:13 +00:00
} ,
{
" type " : " function " ,
" function " : {
" name " : " report_done " ,
" description " : " Signal that the task is complete. Stops the agent loop with a done verdict. Call once when finished; include a short summary in message. " ,
" parameters " : {
" type " : " object " ,
" properties " : {
" message " : { " type " : " string " , " description " : " Final summary for the user " }
} ,
" required " : [ " message " ]
}
}
} ,
{
" type " : " function " ,
" function " : {
" name " : " report_blocked " ,
" description " : " Signal that the task cannot proceed. Stops the agent loop with a blocked verdict and reason. Prefer this over spinning or inventing results. " ,
" parameters " : {
" type " : " object " ,
" properties " : {
" reason " : { " type " : " string " , " description " : " Why the agent is blocked " }
} ,
" required " : [ " reason " ]
}
}
2026-09-13 07:42:59 +00:00
}
2026-09-13 07:57:57 +00:00
] ) ;
// Optional Playwright DOM tools (always registered; fail closed with install hint if missing).
if let Some ( arr ) = defs . as_array_mut ( ) {
arr . extend ( browser ::browser_tool_definitions ( ) ) ;
}
defs
2026-09-13 07:42:59 +00:00
}
2026-09-13 07:51:13 +00:00
/// True if the tool name ends the ReAct loop (completion contract).
pub fn is_completion_tool ( name : & str ) -> bool {
matches! ( name , " report_done " | " report_blocked " )
}
2026-09-13 07:42:59 +00:00
/// Resolve a user-supplied path against cwd and optionally enforce workspace_root.
pub fn resolve_path ( ctx : & ToolContext , path : & str ) -> Result < PathBuf > {
let raw = Path ::new ( path ) ;
let joined = if raw . is_absolute ( ) {
raw . to_path_buf ( )
} else {
ctx . cwd . join ( raw )
} ;
// Normalize without requiring the path to exist (for write_file parents).
let resolved = normalize_path ( & joined ) ;
if let Some ( root ) = & ctx . workspace_root {
let root_norm = normalize_path ( root ) ;
if ! resolved . starts_with ( & root_norm ) {
return Err ( anyhow! (
" path {:?} is outside workspace root {:?} " ,
resolved ,
root_norm
) ) ;
}
}
Ok ( resolved )
}
fn normalize_path ( path : & Path ) -> PathBuf {
let mut out = PathBuf ::new ( ) ;
for comp in path . components ( ) {
match comp {
Component ::Prefix ( p ) = > out . push ( p . as_os_str ( ) ) ,
Component ::RootDir = > out . push ( Component ::RootDir . as_os_str ( ) ) ,
Component ::CurDir = > { }
Component ::ParentDir = > {
out . pop ( ) ;
}
Component ::Normal ( c ) = > out . push ( c ) ,
}
}
out
}
pub async fn execute_tool ( ctx : & ToolContext , name : & str , arguments_json : & str ) -> String {
match execute_tool_inner ( ctx , name , arguments_json ) . await {
Ok ( v ) = > v . to_string ( ) ,
Err ( e ) = > json! ( { " error " : format ! ( " {e:#} " ) } ) . to_string ( ) ,
}
}
async fn execute_tool_inner ( ctx : & ToolContext , name : & str , arguments_json : & str ) -> Result < Value > {
let args : Value = serde_json ::from_str ( arguments_json )
. with_context ( | | format! ( " invalid tool arguments JSON for {name} " ) ) ? ;
match name {
" shell " = > tool_shell ( ctx , & args ) . await ,
" list_dir " = > tool_list_dir ( ctx , & args ) . await ,
" read_file " = > tool_read_file ( ctx , & args ) . await ,
" write_file " = > tool_write_file ( ctx , & args ) . await ,
2026-09-13 07:51:13 +00:00
" report_done " = > tool_report_done ( & args ) ,
" report_blocked " = > tool_report_blocked ( & args ) ,
2026-09-13 07:57:57 +00:00
name if browser ::is_browser_tool ( name ) = > {
browser ::execute_browser_tool ( & ctx . cwd , & ctx . last_browser_url , name , & args ) . await
}
2026-09-13 07:42:59 +00:00
other = > Err ( anyhow! ( " unknown tool: {other} " ) ) ,
}
}
2026-09-13 07:51:13 +00:00
fn tool_report_done ( args : & Value ) -> Result < Value > {
let message = args
. get ( " message " )
. and_then ( | v | v . as_str ( ) )
. ok_or_else ( | | anyhow! ( " report_done: missing 'message' " ) ) ? ;
Ok ( json! ( {
" status " : " done " ,
" message " : message ,
} ) )
}
fn tool_report_blocked ( args : & Value ) -> Result < Value > {
let reason = args
. get ( " reason " )
. and_then ( | v | v . as_str ( ) )
. ok_or_else ( | | anyhow! ( " report_blocked: missing 'reason' " ) ) ? ;
Ok ( json! ( {
" status " : " blocked " ,
" reason " : reason ,
} ) )
}
2026-09-13 07:42:59 +00:00
async fn tool_shell ( ctx : & ToolContext , args : & Value ) -> Result < Value > {
let command = args
. get ( " command " )
. and_then ( | v | v . as_str ( ) )
. ok_or_else ( | | anyhow! ( " shell: missing 'command' " ) ) ? ;
let cwd = if let Some ( c ) = args . get ( " cwd " ) . and_then ( | v | v . as_str ( ) ) {
resolve_path ( ctx , c ) ?
} else {
ctx . cwd . clone ( )
} ;
if ! cwd . is_dir ( ) {
return Err ( anyhow! ( " shell cwd is not a directory: {} " , cwd . display ( ) ) ) ;
}
let child = Command ::new ( " sh " )
. arg ( " -c " )
. arg ( command )
. current_dir ( & cwd )
. stdout ( Stdio ::piped ( ) )
. stderr ( Stdio ::piped ( ) )
. kill_on_drop ( true )
. spawn ( )
. with_context ( | | format! ( " failed to spawn shell for: {command} " ) ) ? ;
let timeout = Duration ::from_secs ( SHELL_TIMEOUT_SECS ) ;
let output = match tokio ::time ::timeout ( timeout , child . wait_with_output ( ) ) . await {
Ok ( Ok ( out ) ) = > out ,
Ok ( Err ( e ) ) = > return Err ( anyhow! ( " shell wait failed: {e} " ) ) ,
Err ( _ ) = > {
return Ok ( json! ( {
" error " : format ! ( " command timed out after {SHELL_TIMEOUT_SECS}s " ) ,
" timed_out " : true ,
" command " : command ,
} ) ) ;
}
} ;
let stdout = String ::from_utf8_lossy ( & output . stdout ) . to_string ( ) ;
let stderr = String ::from_utf8_lossy ( & output . stderr ) . to_string ( ) ;
let exit_code = output . status . code ( ) . unwrap_or ( - 1 ) ;
Ok ( json! ( {
" command " : command ,
" cwd " : cwd . display ( ) . to_string ( ) ,
" exit_code " : exit_code ,
" stdout " : truncate_output ( & stdout , 64 * 1024 ) ,
" stderr " : truncate_output ( & stderr , 32 * 1024 ) ,
} ) )
}
async fn tool_list_dir ( ctx : & ToolContext , args : & Value ) -> Result < Value > {
let path = args
. get ( " path " )
. and_then ( | v | v . as_str ( ) )
. ok_or_else ( | | anyhow! ( " list_dir: missing 'path' " ) ) ? ;
let dir = resolve_path ( ctx , path ) ? ;
let mut rd = tokio ::fs ::read_dir ( & dir )
. await
. with_context ( | | format! ( " list_dir: {} " , dir . display ( ) ) ) ? ;
let mut entries = Vec ::new ( ) ;
while let Some ( ent ) = rd . next_entry ( ) . await ? {
let name = ent . file_name ( ) . to_string_lossy ( ) . to_string ( ) ;
let file_type = ent . file_type ( ) . await ? ;
let kind = if file_type . is_dir ( ) {
" dir "
} else if file_type . is_symlink ( ) {
" symlink "
} else {
" file "
} ;
entries . push ( json! ( { " name " : name , " kind " : kind } ) ) ;
}
entries . sort_by ( | a , b | {
a [ " name " ]
. as_str ( )
. unwrap_or ( " " )
. cmp ( b [ " name " ] . as_str ( ) . unwrap_or ( " " ) )
} ) ;
Ok ( json! ( {
" path " : dir . display ( ) . to_string ( ) ,
" entries " : entries ,
} ) )
}
async fn tool_read_file ( ctx : & ToolContext , args : & Value ) -> Result < Value > {
let path = args
. get ( " path " )
. and_then ( | v | v . as_str ( ) )
. ok_or_else ( | | anyhow! ( " read_file: missing 'path' " ) ) ? ;
let file = resolve_path ( ctx , path ) ? ;
let meta = tokio ::fs ::metadata ( & file )
. await
. with_context ( | | format! ( " read_file: {} " , file . display ( ) ) ) ? ;
if ! meta . is_file ( ) {
return Err ( anyhow! ( " read_file: not a file: {} " , file . display ( ) ) ) ;
}
if meta . len ( ) as usize > MAX_READ_BYTES {
return Err ( anyhow! (
" read_file: file too large ({} bytes > {} cap) " ,
meta . len ( ) ,
MAX_READ_BYTES
) ) ;
}
let bytes = tokio ::fs ::read ( & file )
. await
. with_context ( | | format! ( " read_file: {} " , file . display ( ) ) ) ? ;
let content = String ::from_utf8_lossy ( & bytes ) . to_string ( ) ;
Ok ( json! ( {
" path " : file . display ( ) . to_string ( ) ,
" bytes " : bytes . len ( ) ,
" content " : content ,
} ) )
}
async fn tool_write_file ( ctx : & ToolContext , args : & Value ) -> Result < Value > {
let path = args
. get ( " path " )
. and_then ( | v | v . as_str ( ) )
. ok_or_else ( | | anyhow! ( " write_file: missing 'path' " ) ) ? ;
let content = args
. get ( " content " )
. and_then ( | v | v . as_str ( ) )
. ok_or_else ( | | anyhow! ( " write_file: missing 'content' " ) ) ? ;
let file = resolve_path ( ctx , path ) ? ;
if let Some ( parent ) = file . parent ( ) {
tokio ::fs ::create_dir_all ( parent )
. await
. with_context ( | | format! ( " write_file create_dir_all: {} " , parent . display ( ) ) ) ? ;
}
tokio ::fs ::write ( & file , content . as_bytes ( ) )
. await
. with_context ( | | format! ( " write_file: {} " , file . display ( ) ) ) ? ;
Ok ( json! ( {
" path " : file . display ( ) . to_string ( ) ,
" bytes_written " : content . len ( ) ,
} ) )
}
fn truncate_output ( s : & str , max : usize ) -> String {
if s . len ( ) < = max {
s . to_string ( )
} else {
format! (
" {}… \n [truncated {} bytes] " ,
& s [ .. max ] ,
s . len ( ) . saturating_sub ( max )
)
}
}
#[ cfg(test) ]
mod tests {
use super ::* ;
use std ::time ::{ SystemTime , UNIX_EPOCH } ;
fn temp_ctx ( ) -> ( ToolContext , PathBuf ) {
let stamp = SystemTime ::now ( )
. duration_since ( UNIX_EPOCH )
. unwrap ( )
. as_nanos ( ) ;
let dir = std ::env ::temp_dir ( ) . join ( format! ( " grokboy-tools- {stamp} " ) ) ;
std ::fs ::create_dir_all ( & dir ) . unwrap ( ) ;
( ToolContext ::new ( dir . clone ( ) ) , dir )
}
#[ tokio::test ]
async fn write_read_list_shell ( ) {
let ( ctx , dir ) = temp_ctx ( ) ;
let w = execute_tool (
& ctx ,
" write_file " ,
& json! ( { " path " : " hello.txt " , " content " : " 你好 GrokBoy " } ) . to_string ( ) ,
)
. await ;
let w : Value = serde_json ::from_str ( & w ) . unwrap ( ) ;
assert! ( w . get ( " error " ) . is_none ( ) , " {w} " ) ;
assert_eq! ( w [ " bytes_written " ] , " 你好 GrokBoy " . len ( ) ) ;
let r = execute_tool (
& ctx ,
" read_file " ,
& json! ( { " path " : " hello.txt " } ) . to_string ( ) ,
)
. await ;
let r : Value = serde_json ::from_str ( & r ) . unwrap ( ) ;
assert_eq! ( r [ " content " ] , " 你好 GrokBoy " ) ;
let l = execute_tool ( & ctx , " list_dir " , & json! ( { " path " : " . " } ) . to_string ( ) ) . await ;
let l : Value = serde_json ::from_str ( & l ) . unwrap ( ) ;
let names : Vec < & str > = l [ " entries " ]
. as_array ( )
. unwrap ( )
. iter ( )
. map ( | e | e [ " name " ] . as_str ( ) . unwrap ( ) )
. collect ( ) ;
assert! ( names . contains ( & " hello.txt " ) ) ;
let s = execute_tool (
& ctx ,
" shell " ,
& json! ( { " command " : " echo hi && ls hello.txt " } ) . to_string ( ) ,
)
. await ;
let s : Value = serde_json ::from_str ( & s ) . unwrap ( ) ;
assert_eq! ( s [ " exit_code " ] , 0 ) ;
assert! ( s [ " stdout " ] . as_str ( ) . unwrap ( ) . contains ( " hi " ) ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
}
#[ tokio::test ]
async fn rejects_path_traversal ( ) {
let ( ctx , dir ) = temp_ctx ( ) ;
let out = execute_tool (
& ctx ,
" read_file " ,
& json! ( { " path " : " ../outside.txt " } ) . to_string ( ) ,
)
. await ;
let v : Value = serde_json ::from_str ( & out ) . unwrap ( ) ;
assert! ( v . get ( " error " ) . is_some ( ) , " {v} " ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
}
2026-09-13 07:51:13 +00:00
#[ tokio::test ]
async fn report_done_and_blocked ( ) {
let ( ctx , dir ) = temp_ctx ( ) ;
let done = execute_tool (
& ctx ,
" report_done " ,
& json! ( { " message " : " all good " } ) . to_string ( ) ,
)
. await ;
let done : Value = serde_json ::from_str ( & done ) . unwrap ( ) ;
assert_eq! ( done [ " status " ] , " done " ) ;
assert_eq! ( done [ " message " ] , " all good " ) ;
let blocked = execute_tool (
& ctx ,
" report_blocked " ,
& json! ( { " reason " : " no access " } ) . to_string ( ) ,
)
. await ;
let blocked : Value = serde_json ::from_str ( & blocked ) . unwrap ( ) ;
assert_eq! ( blocked [ " status " ] , " blocked " ) ;
assert_eq! ( blocked [ " reason " ] , " no access " ) ;
assert! ( is_completion_tool ( " report_done " ) ) ;
assert! ( is_completion_tool ( " report_blocked " ) ) ;
assert! ( ! is_completion_tool ( " shell " ) ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
}
2026-09-13 07:42:59 +00:00
#[ test ]
2026-09-13 07:57:57 +00:00
fn tool_defs_include_core_and_browser ( ) {
2026-09-13 07:42:59 +00:00
let defs = tool_definitions ( ) ;
let arr = defs . as_array ( ) . unwrap ( ) ;
2026-09-13 07:57:57 +00:00
assert_eq! ( arr . len ( ) , 11 ) ; // 6 core + 5 browser
2026-09-13 07:42:59 +00:00
let names : Vec < & str > = arr
. iter ( )
. map ( | t | t [ " function " ] [ " name " ] . as_str ( ) . unwrap ( ) )
. collect ( ) ;
assert! ( names . contains ( & " shell " ) ) ;
assert! ( names . contains ( & " list_dir " ) ) ;
assert! ( names . contains ( & " read_file " ) ) ;
assert! ( names . contains ( & " write_file " ) ) ;
2026-09-13 07:51:13 +00:00
assert! ( names . contains ( & " report_done " ) ) ;
assert! ( names . contains ( & " report_blocked " ) ) ;
2026-09-13 07:57:57 +00:00
assert! ( names . contains ( & " browser_navigate " ) ) ;
assert! ( names . contains ( & " browser_snapshot " ) ) ;
assert! ( names . contains ( & " browser_click " ) ) ;
assert! ( names . contains ( & " browser_type " ) ) ;
assert! ( names . contains ( & " browser_eval " ) ) ;
}
#[ tokio::test ]
async fn browser_tool_fails_closed_without_chromium_ok_with_helper ( ) {
// Missing required args should fail closed via helper protocol (no Chromium needed).
let ( ctx , dir ) = temp_ctx ( ) ;
// Point cwd at repo root so helper is found when running under cargo test.
let root = PathBuf ::from ( env! ( " CARGO_MANIFEST_DIR " ) ) . join ( " ../.. " ) ;
let root = root . canonicalize ( ) . unwrap_or ( root ) ;
let ctx = ToolContext {
cwd : root . clone ( ) ,
workspace_root : ctx . workspace_root ,
last_browser_url : ctx . last_browser_url ,
} ;
let out = execute_tool (
& ctx ,
" browser_type " ,
& json! ( { " selector " : " #x " } ) . to_string ( ) ,
)
. await ;
let v : Value = serde_json ::from_str ( & out ) . unwrap ( ) ;
assert! ( v . get ( " error " ) . is_some ( ) , " {v} " ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
2026-09-13 07:42:59 +00:00
}
}