| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879488048814882488348844885488648874888488948904891489248934894489548964897489848994900490149024903490449054906490749084909491049114912491349144915491649174918491949204921492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014501550165017501850195020502150225023502450255026502750285029503050315032503350345035503650375038503950405041504250435044504550465047504850495050505150525053505450555056505750585059506050615062506350645065506650675068506950705071507250735074507550765077507850795080508150825083508450855086508750885089509050915092509350945095509650975098509951005101510251035104510551065107510851095110511151125113511451155116511751185119512051215122512351245125512651275128512951305131513251335134513551365137513851395140514151425143514451455146514751485149515051515152515351545155515651575158515951605161516251635164516551665167516851695170517151725173517451755176517751785179518051815182518351845185518651875188518951905191519251935194519551965197519851995200520152025203520452055206520752085209521052115212521352145215521652175218521952205221522252235224522552265227522852295230523152325233523452355236523752385239524052415242524352445245524652475248524952505251525252535254525552565257525852595260526152625263526452655266526752685269527052715272527352745275527652775278527952805281528252835284528552865287528852895290529152925293529452955296529752985299530053015302530353045305530653075308530953105311531253135314531553165317531853195320532153225323532453255326532753285329533053315332533353345335533653375338533953405341534253435344534553465347534853495350535153525353535453555356535753585359536053615362536353645365536653675368536953705371537253735374537553765377537853795380538153825383538453855386538753885389539053915392539353945395539653975398539954005401540254035404540554065407540854095410541154125413541454155416541754185419542054215422542354245425542654275428542954305431543254335434543554365437543854395440544154425443544454455446544754485449545054515452545354545455545654575458545954605461546254635464546554665467546854695470547154725473547454755476547754785479548054815482548354845485548654875488548954905491549254935494549554965497549854995500550155025503550455055506550755085509551055115512551355145515551655175518551955205521552255235524552555265527552855295530553155325533553455355536553755385539554055415542554355445545554655475548554955505551555255535554555555565557555855595560556155625563556455655566556755685569557055715572557355745575557655775578557955805581558255835584558555865587558855895590559155925593559455955596559755985599560056015602560356045605560656075608560956105611561256135614561556165617561856195620562156225623562456255626562756285629563056315632563356345635563656375638563956405641564256435644564556465647564856495650565156525653565456555656565756585659566056615662566356645665566656675668566956705671567256735674567556765677567856795680568156825683568456855686568756885689569056915692569356945695569656975698569957005701570257035704570557065707570857095710571157125713571457155716 |
- use std::collections::{BTreeMap, BTreeSet};
- use std::path::{Path, PathBuf};
- use std::process::Command;
- use std::time::{Duration, Instant};
- use api::{
- max_tokens_for_model, resolve_model_alias, ContentBlockDelta, InputContentBlock, InputMessage,
- MessageRequest, MessageResponse, OutputContentBlock, ProviderClient,
- StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
- };
- use plugins::PluginTool;
- use reqwest::blocking::Client;
- use runtime::{
- edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file,
- task_registry::TaskRegistry,
- team_cron_registry::{CronRegistry, TeamRegistry},
- write_file, ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ContentBlock,
- ConversationMessage, ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode,
- PermissionPolicy, PromptCacheEvent, RuntimeError, Session, ToolError, ToolExecutor,
- };
- use serde::{Deserialize, Serialize};
- use serde_json::{json, Value};
- /// Global task registry shared across tool invocations within a session.
- fn global_team_registry() -> &'static TeamRegistry {
- use std::sync::OnceLock;
- static REGISTRY: OnceLock<TeamRegistry> = OnceLock::new();
- REGISTRY.get_or_init(TeamRegistry::new)
- }
- fn global_cron_registry() -> &'static CronRegistry {
- use std::sync::OnceLock;
- static REGISTRY: OnceLock<CronRegistry> = OnceLock::new();
- REGISTRY.get_or_init(CronRegistry::new)
- }
- fn global_task_registry() -> &'static TaskRegistry {
- use std::sync::OnceLock;
- static REGISTRY: OnceLock<TaskRegistry> = OnceLock::new();
- REGISTRY.get_or_init(TaskRegistry::new)
- }
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub struct ToolManifestEntry {
- pub name: String,
- pub source: ToolSource,
- }
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
- pub enum ToolSource {
- Base,
- Conditional,
- }
- #[derive(Debug, Clone, Default, PartialEq, Eq)]
- pub struct ToolRegistry {
- entries: Vec<ToolManifestEntry>,
- }
- impl ToolRegistry {
- #[must_use]
- pub fn new(entries: Vec<ToolManifestEntry>) -> Self {
- Self { entries }
- }
- #[must_use]
- pub fn entries(&self) -> &[ToolManifestEntry] {
- &self.entries
- }
- }
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub struct ToolSpec {
- pub name: &'static str,
- pub description: &'static str,
- pub input_schema: Value,
- pub required_permission: PermissionMode,
- }
- #[derive(Debug, Clone, PartialEq)]
- pub struct GlobalToolRegistry {
- plugin_tools: Vec<PluginTool>,
- }
- impl GlobalToolRegistry {
- #[must_use]
- pub fn builtin() -> Self {
- Self {
- plugin_tools: Vec::new(),
- }
- }
- pub fn with_plugin_tools(plugin_tools: Vec<PluginTool>) -> Result<Self, String> {
- let builtin_names = mvp_tool_specs()
- .into_iter()
- .map(|spec| spec.name.to_string())
- .collect::<BTreeSet<_>>();
- let mut seen_plugin_names = BTreeSet::new();
- for tool in &plugin_tools {
- let name = tool.definition().name.clone();
- if builtin_names.contains(&name) {
- return Err(format!(
- "plugin tool `{name}` conflicts with a built-in tool name"
- ));
- }
- if !seen_plugin_names.insert(name.clone()) {
- return Err(format!("duplicate plugin tool name `{name}`"));
- }
- }
- Ok(Self { plugin_tools })
- }
- pub fn normalize_allowed_tools(
- &self,
- values: &[String],
- ) -> Result<Option<BTreeSet<String>>, String> {
- if values.is_empty() {
- return Ok(None);
- }
- let builtin_specs = mvp_tool_specs();
- let canonical_names = builtin_specs
- .iter()
- .map(|spec| spec.name.to_string())
- .chain(
- self.plugin_tools
- .iter()
- .map(|tool| tool.definition().name.clone()),
- )
- .collect::<Vec<_>>();
- let mut name_map = canonical_names
- .iter()
- .map(|name| (normalize_tool_name(name), name.clone()))
- .collect::<BTreeMap<_, _>>();
- for (alias, canonical) in [
- ("read", "read_file"),
- ("write", "write_file"),
- ("edit", "edit_file"),
- ("glob", "glob_search"),
- ("grep", "grep_search"),
- ] {
- name_map.insert(alias.to_string(), canonical.to_string());
- }
- let mut allowed = BTreeSet::new();
- for value in values {
- for token in value
- .split(|ch: char| ch == ',' || ch.is_whitespace())
- .filter(|token| !token.is_empty())
- {
- let normalized = normalize_tool_name(token);
- let canonical = name_map.get(&normalized).ok_or_else(|| {
- format!(
- "unsupported tool in --allowedTools: {token} (expected one of: {})",
- canonical_names.join(", ")
- )
- })?;
- allowed.insert(canonical.clone());
- }
- }
- Ok(Some(allowed))
- }
- #[must_use]
- pub fn definitions(&self, allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolDefinition> {
- let builtin = mvp_tool_specs()
- .into_iter()
- .filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
- .map(|spec| ToolDefinition {
- name: spec.name.to_string(),
- description: Some(spec.description.to_string()),
- input_schema: spec.input_schema,
- });
- let plugin = self
- .plugin_tools
- .iter()
- .filter(|tool| {
- allowed_tools
- .is_none_or(|allowed| allowed.contains(tool.definition().name.as_str()))
- })
- .map(|tool| ToolDefinition {
- name: tool.definition().name.clone(),
- description: tool.definition().description.clone(),
- input_schema: tool.definition().input_schema.clone(),
- });
- builtin.chain(plugin).collect()
- }
- pub fn permission_specs(
- &self,
- allowed_tools: Option<&BTreeSet<String>>,
- ) -> Result<Vec<(String, PermissionMode)>, String> {
- let builtin = mvp_tool_specs()
- .into_iter()
- .filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
- .map(|spec| (spec.name.to_string(), spec.required_permission));
- let plugin = self
- .plugin_tools
- .iter()
- .filter(|tool| {
- allowed_tools
- .is_none_or(|allowed| allowed.contains(tool.definition().name.as_str()))
- })
- .map(|tool| {
- permission_mode_from_plugin(tool.required_permission())
- .map(|permission| (tool.definition().name.clone(), permission))
- })
- .collect::<Result<Vec<_>, _>>()?;
- Ok(builtin.chain(plugin).collect())
- }
- pub fn execute(&self, name: &str, input: &Value) -> Result<String, String> {
- if mvp_tool_specs().iter().any(|spec| spec.name == name) {
- return execute_tool(name, input);
- }
- self.plugin_tools
- .iter()
- .find(|tool| tool.definition().name == name)
- .ok_or_else(|| format!("unsupported tool: {name}"))?
- .execute(input)
- .map_err(|error| error.to_string())
- }
- }
- fn normalize_tool_name(value: &str) -> String {
- value.trim().replace('-', "_").to_ascii_lowercase()
- }
- fn permission_mode_from_plugin(value: &str) -> Result<PermissionMode, String> {
- match value {
- "read-only" => Ok(PermissionMode::ReadOnly),
- "workspace-write" => Ok(PermissionMode::WorkspaceWrite),
- "danger-full-access" => Ok(PermissionMode::DangerFullAccess),
- other => Err(format!("unsupported plugin permission: {other}")),
- }
- }
- #[must_use]
- #[allow(clippy::too_many_lines)]
- pub fn mvp_tool_specs() -> Vec<ToolSpec> {
- vec![
- ToolSpec {
- name: "bash",
- description: "Execute a shell command in the current workspace.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "command": { "type": "string" },
- "timeout": { "type": "integer", "minimum": 1 },
- "description": { "type": "string" },
- "run_in_background": { "type": "boolean" },
- "dangerouslyDisableSandbox": { "type": "boolean" },
- "namespaceRestrictions": { "type": "boolean" },
- "isolateNetwork": { "type": "boolean" },
- "filesystemMode": { "type": "string", "enum": ["off", "workspace-only", "allow-list"] },
- "allowedMounts": { "type": "array", "items": { "type": "string" } }
- },
- "required": ["command"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::DangerFullAccess,
- },
- ToolSpec {
- name: "read_file",
- description: "Read a text file from the workspace.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "path": { "type": "string" },
- "offset": { "type": "integer", "minimum": 0 },
- "limit": { "type": "integer", "minimum": 1 }
- },
- "required": ["path"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::ReadOnly,
- },
- ToolSpec {
- name: "write_file",
- description: "Write a text file in the workspace.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "path": { "type": "string" },
- "content": { "type": "string" }
- },
- "required": ["path", "content"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::WorkspaceWrite,
- },
- ToolSpec {
- name: "edit_file",
- description: "Replace text in a workspace file.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "path": { "type": "string" },
- "old_string": { "type": "string" },
- "new_string": { "type": "string" },
- "replace_all": { "type": "boolean" }
- },
- "required": ["path", "old_string", "new_string"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::WorkspaceWrite,
- },
- ToolSpec {
- name: "glob_search",
- description: "Find files by glob pattern.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "pattern": { "type": "string" },
- "path": { "type": "string" }
- },
- "required": ["pattern"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::ReadOnly,
- },
- ToolSpec {
- name: "grep_search",
- description: "Search file contents with a regex pattern.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "pattern": { "type": "string" },
- "path": { "type": "string" },
- "glob": { "type": "string" },
- "output_mode": { "type": "string" },
- "-B": { "type": "integer", "minimum": 0 },
- "-A": { "type": "integer", "minimum": 0 },
- "-C": { "type": "integer", "minimum": 0 },
- "context": { "type": "integer", "minimum": 0 },
- "-n": { "type": "boolean" },
- "-i": { "type": "boolean" },
- "type": { "type": "string" },
- "head_limit": { "type": "integer", "minimum": 1 },
- "offset": { "type": "integer", "minimum": 0 },
- "multiline": { "type": "boolean" }
- },
- "required": ["pattern"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::ReadOnly,
- },
- ToolSpec {
- name: "WebFetch",
- description:
- "Fetch a URL, convert it into readable text, and answer a prompt about it.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "url": { "type": "string", "format": "uri" },
- "prompt": { "type": "string" }
- },
- "required": ["url", "prompt"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::ReadOnly,
- },
- ToolSpec {
- name: "WebSearch",
- description: "Search the web for current information and return cited results.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "query": { "type": "string", "minLength": 2 },
- "allowed_domains": {
- "type": "array",
- "items": { "type": "string" }
- },
- "blocked_domains": {
- "type": "array",
- "items": { "type": "string" }
- }
- },
- "required": ["query"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::ReadOnly,
- },
- ToolSpec {
- name: "TodoWrite",
- description: "Update the structured task list for the current session.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "todos": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "content": { "type": "string" },
- "activeForm": { "type": "string" },
- "status": {
- "type": "string",
- "enum": ["pending", "in_progress", "completed"]
- }
- },
- "required": ["content", "activeForm", "status"],
- "additionalProperties": false
- }
- }
- },
- "required": ["todos"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::WorkspaceWrite,
- },
- ToolSpec {
- name: "Skill",
- description: "Load a local skill definition and its instructions.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "skill": { "type": "string" },
- "args": { "type": "string" }
- },
- "required": ["skill"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::ReadOnly,
- },
- ToolSpec {
- name: "Agent",
- description: "Launch a specialized agent task and persist its handoff metadata.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "description": { "type": "string" },
- "prompt": { "type": "string" },
- "subagent_type": { "type": "string" },
- "name": { "type": "string" },
- "model": { "type": "string" }
- },
- "required": ["description", "prompt"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::DangerFullAccess,
- },
- ToolSpec {
- name: "ToolSearch",
- description: "Search for deferred or specialized tools by exact name or keywords.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "query": { "type": "string" },
- "max_results": { "type": "integer", "minimum": 1 }
- },
- "required": ["query"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::ReadOnly,
- },
- ToolSpec {
- name: "NotebookEdit",
- description: "Replace, insert, or delete a cell in a Jupyter notebook.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "notebook_path": { "type": "string" },
- "cell_id": { "type": "string" },
- "new_source": { "type": "string" },
- "cell_type": { "type": "string", "enum": ["code", "markdown"] },
- "edit_mode": { "type": "string", "enum": ["replace", "insert", "delete"] }
- },
- "required": ["notebook_path"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::WorkspaceWrite,
- },
- ToolSpec {
- name: "Sleep",
- description: "Wait for a specified duration without holding a shell process.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "duration_ms": { "type": "integer", "minimum": 0 }
- },
- "required": ["duration_ms"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::ReadOnly,
- },
- ToolSpec {
- name: "SendUserMessage",
- description: "Send a message to the user.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "message": { "type": "string" },
- "attachments": {
- "type": "array",
- "items": { "type": "string" }
- },
- "status": {
- "type": "string",
- "enum": ["normal", "proactive"]
- }
- },
- "required": ["message", "status"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::ReadOnly,
- },
- ToolSpec {
- name: "Config",
- description: "Get or set Claude Code settings.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "setting": { "type": "string" },
- "value": {
- "type": ["string", "boolean", "number"]
- }
- },
- "required": ["setting"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::WorkspaceWrite,
- },
- ToolSpec {
- name: "EnterPlanMode",
- description: "Enable a worktree-local planning mode override and remember the previous local setting for ExitPlanMode.",
- input_schema: json!({
- "type": "object",
- "properties": {},
- "additionalProperties": false
- }),
- required_permission: PermissionMode::WorkspaceWrite,
- },
- ToolSpec {
- name: "ExitPlanMode",
- description: "Restore or clear the worktree-local planning mode override created by EnterPlanMode.",
- input_schema: json!({
- "type": "object",
- "properties": {},
- "additionalProperties": false
- }),
- required_permission: PermissionMode::WorkspaceWrite,
- },
- ToolSpec {
- name: "StructuredOutput",
- description: "Return structured output in the requested format.",
- input_schema: json!({
- "type": "object",
- "additionalProperties": true
- }),
- required_permission: PermissionMode::ReadOnly,
- },
- ToolSpec {
- name: "REPL",
- description: "Execute code in a REPL-like subprocess.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "code": { "type": "string" },
- "language": { "type": "string" },
- "timeout_ms": { "type": "integer", "minimum": 1 }
- },
- "required": ["code", "language"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::DangerFullAccess,
- },
- ToolSpec {
- name: "PowerShell",
- description: "Execute a PowerShell command with optional timeout.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "command": { "type": "string" },
- "timeout": { "type": "integer", "minimum": 1 },
- "description": { "type": "string" },
- "run_in_background": { "type": "boolean" }
- },
- "required": ["command"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::DangerFullAccess,
- },
- ToolSpec {
- name: "AskUserQuestion",
- description: "Ask the user a question and wait for their response.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "question": { "type": "string" },
- "options": {
- "type": "array",
- "items": { "type": "string" }
- }
- },
- "required": ["question"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::ReadOnly,
- },
- ToolSpec {
- name: "TaskCreate",
- description: "Create a background task that runs in a separate subprocess.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "prompt": { "type": "string" },
- "description": { "type": "string" }
- },
- "required": ["prompt"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::DangerFullAccess,
- },
- ToolSpec {
- name: "TaskGet",
- description: "Get the status and details of a background task by ID.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "task_id": { "type": "string" }
- },
- "required": ["task_id"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::ReadOnly,
- },
- ToolSpec {
- name: "TaskList",
- description: "List all background tasks and their current status.",
- input_schema: json!({
- "type": "object",
- "properties": {},
- "additionalProperties": false
- }),
- required_permission: PermissionMode::ReadOnly,
- },
- ToolSpec {
- name: "TaskStop",
- description: "Stop a running background task by ID.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "task_id": { "type": "string" }
- },
- "required": ["task_id"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::DangerFullAccess,
- },
- ToolSpec {
- name: "TaskUpdate",
- description: "Send a message or update to a running background task.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "task_id": { "type": "string" },
- "message": { "type": "string" }
- },
- "required": ["task_id", "message"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::DangerFullAccess,
- },
- ToolSpec {
- name: "TaskOutput",
- description: "Retrieve the output produced by a background task.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "task_id": { "type": "string" }
- },
- "required": ["task_id"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::ReadOnly,
- },
- ToolSpec {
- name: "TeamCreate",
- description: "Create a team of sub-agents for parallel task execution.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "name": { "type": "string" },
- "tasks": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "prompt": { "type": "string" },
- "description": { "type": "string" }
- },
- "required": ["prompt"]
- }
- }
- },
- "required": ["name", "tasks"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::DangerFullAccess,
- },
- ToolSpec {
- name: "TeamDelete",
- description: "Delete a team and stop all its running tasks.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "team_id": { "type": "string" }
- },
- "required": ["team_id"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::DangerFullAccess,
- },
- ToolSpec {
- name: "CronCreate",
- description: "Create a scheduled recurring task.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "schedule": { "type": "string" },
- "prompt": { "type": "string" },
- "description": { "type": "string" }
- },
- "required": ["schedule", "prompt"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::DangerFullAccess,
- },
- ToolSpec {
- name: "CronDelete",
- description: "Delete a scheduled recurring task by ID.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "cron_id": { "type": "string" }
- },
- "required": ["cron_id"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::DangerFullAccess,
- },
- ToolSpec {
- name: "CronList",
- description: "List all scheduled recurring tasks.",
- input_schema: json!({
- "type": "object",
- "properties": {},
- "additionalProperties": false
- }),
- required_permission: PermissionMode::ReadOnly,
- },
- ToolSpec {
- name: "LSP",
- description: "Query Language Server Protocol for code intelligence (symbols, references, diagnostics).",
- input_schema: json!({
- "type": "object",
- "properties": {
- "action": { "type": "string", "enum": ["symbols", "references", "diagnostics", "definition", "hover"] },
- "path": { "type": "string" },
- "line": { "type": "integer", "minimum": 0 },
- "character": { "type": "integer", "minimum": 0 },
- "query": { "type": "string" }
- },
- "required": ["action"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::ReadOnly,
- },
- ToolSpec {
- name: "ListMcpResources",
- description: "List available resources from connected MCP servers.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "server": { "type": "string" }
- },
- "additionalProperties": false
- }),
- required_permission: PermissionMode::ReadOnly,
- },
- ToolSpec {
- name: "ReadMcpResource",
- description: "Read a specific resource from an MCP server by URI.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "server": { "type": "string" },
- "uri": { "type": "string" }
- },
- "required": ["uri"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::ReadOnly,
- },
- ToolSpec {
- name: "McpAuth",
- description: "Authenticate with an MCP server that requires OAuth or credentials.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "server": { "type": "string" }
- },
- "required": ["server"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::DangerFullAccess,
- },
- ToolSpec {
- name: "RemoteTrigger",
- description: "Trigger a remote action or webhook endpoint.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "url": { "type": "string" },
- "method": { "type": "string", "enum": ["GET", "POST", "PUT", "DELETE"] },
- "headers": { "type": "object" },
- "body": { "type": "string" }
- },
- "required": ["url"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::DangerFullAccess,
- },
- ToolSpec {
- name: "MCP",
- description: "Execute a tool provided by a connected MCP server.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "server": { "type": "string" },
- "tool": { "type": "string" },
- "arguments": { "type": "object" }
- },
- "required": ["server", "tool"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::DangerFullAccess,
- },
- ToolSpec {
- name: "TestingPermission",
- description: "Test-only tool for verifying permission enforcement behavior.",
- input_schema: json!({
- "type": "object",
- "properties": {
- "action": { "type": "string" }
- },
- "required": ["action"],
- "additionalProperties": false
- }),
- required_permission: PermissionMode::DangerFullAccess,
- },
- ]
- }
- pub fn execute_tool(name: &str, input: &Value) -> Result<String, String> {
- match name {
- "bash" => from_value::<BashCommandInput>(input).and_then(run_bash),
- "read_file" => from_value::<ReadFileInput>(input).and_then(run_read_file),
- "write_file" => from_value::<WriteFileInput>(input).and_then(run_write_file),
- "edit_file" => from_value::<EditFileInput>(input).and_then(run_edit_file),
- "glob_search" => from_value::<GlobSearchInputValue>(input).and_then(run_glob_search),
- "grep_search" => from_value::<GrepSearchInput>(input).and_then(run_grep_search),
- "WebFetch" => from_value::<WebFetchInput>(input).and_then(run_web_fetch),
- "WebSearch" => from_value::<WebSearchInput>(input).and_then(run_web_search),
- "TodoWrite" => from_value::<TodoWriteInput>(input).and_then(run_todo_write),
- "Skill" => from_value::<SkillInput>(input).and_then(run_skill),
- "Agent" => from_value::<AgentInput>(input).and_then(run_agent),
- "ToolSearch" => from_value::<ToolSearchInput>(input).and_then(run_tool_search),
- "NotebookEdit" => from_value::<NotebookEditInput>(input).and_then(run_notebook_edit),
- "Sleep" => from_value::<SleepInput>(input).and_then(run_sleep),
- "SendUserMessage" | "Brief" => from_value::<BriefInput>(input).and_then(run_brief),
- "Config" => from_value::<ConfigInput>(input).and_then(run_config),
- "EnterPlanMode" => from_value::<EnterPlanModeInput>(input).and_then(run_enter_plan_mode),
- "ExitPlanMode" => from_value::<ExitPlanModeInput>(input).and_then(run_exit_plan_mode),
- "StructuredOutput" => {
- from_value::<StructuredOutputInput>(input).and_then(run_structured_output)
- }
- "REPL" => from_value::<ReplInput>(input).and_then(run_repl),
- "PowerShell" => from_value::<PowerShellInput>(input).and_then(run_powershell),
- "AskUserQuestion" => {
- from_value::<AskUserQuestionInput>(input).and_then(run_ask_user_question)
- }
- "TaskCreate" => from_value::<TaskCreateInput>(input).and_then(run_task_create),
- "TaskGet" => from_value::<TaskIdInput>(input).and_then(run_task_get),
- "TaskList" => run_task_list(input.clone()),
- "TaskStop" => from_value::<TaskIdInput>(input).and_then(run_task_stop),
- "TaskUpdate" => from_value::<TaskUpdateInput>(input).and_then(run_task_update),
- "TaskOutput" => from_value::<TaskIdInput>(input).and_then(run_task_output),
- "TeamCreate" => from_value::<TeamCreateInput>(input).and_then(run_team_create),
- "TeamDelete" => from_value::<TeamDeleteInput>(input).and_then(run_team_delete),
- "CronCreate" => from_value::<CronCreateInput>(input).and_then(run_cron_create),
- "CronDelete" => from_value::<CronDeleteInput>(input).and_then(run_cron_delete),
- "CronList" => run_cron_list(input.clone()),
- "LSP" => from_value::<LspInput>(input).and_then(run_lsp),
- "ListMcpResources" => {
- from_value::<McpResourceInput>(input).and_then(run_list_mcp_resources)
- }
- "ReadMcpResource" => from_value::<McpResourceInput>(input).and_then(run_read_mcp_resource),
- "McpAuth" => from_value::<McpAuthInput>(input).and_then(run_mcp_auth),
- "RemoteTrigger" => from_value::<RemoteTriggerInput>(input).and_then(run_remote_trigger),
- "MCP" => from_value::<McpToolInput>(input).and_then(run_mcp_tool),
- "TestingPermission" => {
- from_value::<TestingPermissionInput>(input).and_then(run_testing_permission)
- }
- _ => Err(format!("unsupported tool: {name}")),
- }
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_ask_user_question(input: AskUserQuestionInput) -> Result<String, String> {
- let mut result = json!({
- "question": input.question,
- "status": "pending",
- "message": "Waiting for user response"
- });
- if let Some(options) = &input.options {
- result["options"] = json!(options);
- }
- to_pretty_json(result)
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_task_create(input: TaskCreateInput) -> Result<String, String> {
- let registry = global_task_registry();
- let task = registry.create(&input.prompt, input.description.as_deref());
- to_pretty_json(json!({
- "task_id": task.task_id,
- "status": task.status,
- "prompt": task.prompt,
- "description": task.description,
- "created_at": task.created_at
- }))
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_task_get(input: TaskIdInput) -> Result<String, String> {
- let registry = global_task_registry();
- match registry.get(&input.task_id) {
- Some(task) => to_pretty_json(json!({
- "task_id": task.task_id,
- "status": task.status,
- "prompt": task.prompt,
- "description": task.description,
- "created_at": task.created_at,
- "updated_at": task.updated_at,
- "messages": task.messages,
- "team_id": task.team_id
- })),
- None => Err(format!("task not found: {}", input.task_id)),
- }
- }
- fn run_task_list(_input: Value) -> Result<String, String> {
- let registry = global_task_registry();
- let tasks: Vec<_> = registry
- .list(None)
- .into_iter()
- .map(|t| {
- json!({
- "task_id": t.task_id,
- "status": t.status,
- "prompt": t.prompt,
- "description": t.description,
- "created_at": t.created_at,
- "updated_at": t.updated_at,
- "team_id": t.team_id
- })
- })
- .collect();
- to_pretty_json(json!({
- "tasks": tasks,
- "count": tasks.len()
- }))
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_task_stop(input: TaskIdInput) -> Result<String, String> {
- let registry = global_task_registry();
- match registry.stop(&input.task_id) {
- Ok(task) => to_pretty_json(json!({
- "task_id": task.task_id,
- "status": task.status,
- "message": "Task stopped"
- })),
- Err(e) => Err(e),
- }
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_task_update(input: TaskUpdateInput) -> Result<String, String> {
- let registry = global_task_registry();
- match registry.update(&input.task_id, &input.message) {
- Ok(task) => to_pretty_json(json!({
- "task_id": task.task_id,
- "status": task.status,
- "message_count": task.messages.len(),
- "last_message": input.message
- })),
- Err(e) => Err(e),
- }
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_task_output(input: TaskIdInput) -> Result<String, String> {
- let registry = global_task_registry();
- match registry.output(&input.task_id) {
- Ok(output) => to_pretty_json(json!({
- "task_id": input.task_id,
- "output": output,
- "has_output": !output.is_empty()
- })),
- Err(e) => Err(e),
- }
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_team_create(input: TeamCreateInput) -> Result<String, String> {
- let task_ids: Vec<String> = input
- .tasks
- .iter()
- .filter_map(|t| t.get("task_id").and_then(|v| v.as_str()).map(str::to_owned))
- .collect();
- let team = global_team_registry().create(&input.name, task_ids);
- // Register team assignment on each task
- for task_id in &team.task_ids {
- let _ = global_task_registry().assign_team(task_id, &team.team_id);
- }
- to_pretty_json(json!({
- "team_id": team.team_id,
- "name": team.name,
- "task_count": team.task_ids.len(),
- "task_ids": team.task_ids,
- "status": team.status,
- "created_at": team.created_at
- }))
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_team_delete(input: TeamDeleteInput) -> Result<String, String> {
- match global_team_registry().delete(&input.team_id) {
- Ok(team) => to_pretty_json(json!({
- "team_id": team.team_id,
- "name": team.name,
- "status": team.status,
- "message": "Team deleted"
- })),
- Err(e) => Err(e),
- }
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_cron_create(input: CronCreateInput) -> Result<String, String> {
- let entry =
- global_cron_registry().create(&input.schedule, &input.prompt, input.description.as_deref());
- to_pretty_json(json!({
- "cron_id": entry.cron_id,
- "schedule": entry.schedule,
- "prompt": entry.prompt,
- "description": entry.description,
- "enabled": entry.enabled,
- "created_at": entry.created_at
- }))
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_cron_delete(input: CronDeleteInput) -> Result<String, String> {
- match global_cron_registry().delete(&input.cron_id) {
- Ok(entry) => to_pretty_json(json!({
- "cron_id": entry.cron_id,
- "schedule": entry.schedule,
- "status": "deleted",
- "message": "Cron entry removed"
- })),
- Err(e) => Err(e),
- }
- }
- fn run_cron_list(_input: Value) -> Result<String, String> {
- let entries: Vec<_> = global_cron_registry()
- .list(false)
- .into_iter()
- .map(|e| {
- json!({
- "cron_id": e.cron_id,
- "schedule": e.schedule,
- "prompt": e.prompt,
- "description": e.description,
- "enabled": e.enabled,
- "run_count": e.run_count,
- "last_run_at": e.last_run_at,
- "created_at": e.created_at
- })
- })
- .collect();
- to_pretty_json(json!({
- "crons": entries,
- "count": entries.len()
- }))
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_lsp(input: LspInput) -> Result<String, String> {
- to_pretty_json(json!({
- "action": input.action,
- "path": input.path,
- "line": input.line,
- "character": input.character,
- "query": input.query,
- "results": [],
- "message": "LSP server not connected"
- }))
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_list_mcp_resources(input: McpResourceInput) -> Result<String, String> {
- to_pretty_json(json!({
- "server": input.server,
- "resources": [],
- "message": "No MCP resources available"
- }))
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_read_mcp_resource(input: McpResourceInput) -> Result<String, String> {
- to_pretty_json(json!({
- "server": input.server,
- "uri": input.uri,
- "content": "",
- "message": "Resource not available"
- }))
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_mcp_auth(input: McpAuthInput) -> Result<String, String> {
- to_pretty_json(json!({
- "server": input.server,
- "status": "auth_required",
- "message": "MCP authentication not yet implemented"
- }))
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_remote_trigger(input: RemoteTriggerInput) -> Result<String, String> {
- to_pretty_json(json!({
- "url": input.url,
- "method": input.method.unwrap_or_else(|| "GET".to_string()),
- "headers": input.headers,
- "body": input.body,
- "status": "triggered",
- "message": "Remote trigger stub response"
- }))
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_mcp_tool(input: McpToolInput) -> Result<String, String> {
- to_pretty_json(json!({
- "server": input.server,
- "tool": input.tool,
- "arguments": input.arguments,
- "result": null,
- "message": "MCP tool proxy not yet connected"
- }))
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_testing_permission(input: TestingPermissionInput) -> Result<String, String> {
- to_pretty_json(json!({
- "action": input.action,
- "permitted": true,
- "message": "Testing permission tool stub"
- }))
- }
- fn from_value<T: for<'de> Deserialize<'de>>(input: &Value) -> Result<T, String> {
- serde_json::from_value(input.clone()).map_err(|error| error.to_string())
- }
- fn run_bash(input: BashCommandInput) -> Result<String, String> {
- serde_json::to_string_pretty(&execute_bash(input).map_err(|error| error.to_string())?)
- .map_err(|error| error.to_string())
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_read_file(input: ReadFileInput) -> Result<String, String> {
- to_pretty_json(read_file(&input.path, input.offset, input.limit).map_err(io_to_string)?)
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_write_file(input: WriteFileInput) -> Result<String, String> {
- to_pretty_json(write_file(&input.path, &input.content).map_err(io_to_string)?)
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_edit_file(input: EditFileInput) -> Result<String, String> {
- to_pretty_json(
- edit_file(
- &input.path,
- &input.old_string,
- &input.new_string,
- input.replace_all.unwrap_or(false),
- )
- .map_err(io_to_string)?,
- )
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_glob_search(input: GlobSearchInputValue) -> Result<String, String> {
- to_pretty_json(glob_search(&input.pattern, input.path.as_deref()).map_err(io_to_string)?)
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_grep_search(input: GrepSearchInput) -> Result<String, String> {
- to_pretty_json(grep_search(&input).map_err(io_to_string)?)
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_web_fetch(input: WebFetchInput) -> Result<String, String> {
- to_pretty_json(execute_web_fetch(&input)?)
- }
- #[allow(clippy::needless_pass_by_value)]
- fn run_web_search(input: WebSearchInput) -> Result<String, String> {
- to_pretty_json(execute_web_search(&input)?)
- }
- fn run_todo_write(input: TodoWriteInput) -> Result<String, String> {
- to_pretty_json(execute_todo_write(input)?)
- }
- fn run_skill(input: SkillInput) -> Result<String, String> {
- to_pretty_json(execute_skill(input)?)
- }
- fn run_agent(input: AgentInput) -> Result<String, String> {
- to_pretty_json(execute_agent(input)?)
- }
- fn run_tool_search(input: ToolSearchInput) -> Result<String, String> {
- to_pretty_json(execute_tool_search(input))
- }
- fn run_notebook_edit(input: NotebookEditInput) -> Result<String, String> {
- to_pretty_json(execute_notebook_edit(input)?)
- }
- fn run_sleep(input: SleepInput) -> Result<String, String> {
- to_pretty_json(execute_sleep(input)?)
- }
- fn run_brief(input: BriefInput) -> Result<String, String> {
- to_pretty_json(execute_brief(input)?)
- }
- fn run_config(input: ConfigInput) -> Result<String, String> {
- to_pretty_json(execute_config(input)?)
- }
- fn run_enter_plan_mode(input: EnterPlanModeInput) -> Result<String, String> {
- to_pretty_json(execute_enter_plan_mode(input)?)
- }
- fn run_exit_plan_mode(input: ExitPlanModeInput) -> Result<String, String> {
- to_pretty_json(execute_exit_plan_mode(input)?)
- }
- fn run_structured_output(input: StructuredOutputInput) -> Result<String, String> {
- to_pretty_json(execute_structured_output(input)?)
- }
- fn run_repl(input: ReplInput) -> Result<String, String> {
- to_pretty_json(execute_repl(input)?)
- }
- fn run_powershell(input: PowerShellInput) -> Result<String, String> {
- to_pretty_json(execute_powershell(input).map_err(|error| error.to_string())?)
- }
- fn to_pretty_json<T: serde::Serialize>(value: T) -> Result<String, String> {
- serde_json::to_string_pretty(&value).map_err(|error| error.to_string())
- }
- #[allow(clippy::needless_pass_by_value)]
- fn io_to_string(error: std::io::Error) -> String {
- error.to_string()
- }
- #[derive(Debug, Deserialize)]
- struct ReadFileInput {
- path: String,
- offset: Option<usize>,
- limit: Option<usize>,
- }
- #[derive(Debug, Deserialize)]
- struct WriteFileInput {
- path: String,
- content: String,
- }
- #[derive(Debug, Deserialize)]
- struct EditFileInput {
- path: String,
- old_string: String,
- new_string: String,
- replace_all: Option<bool>,
- }
- #[derive(Debug, Deserialize)]
- struct GlobSearchInputValue {
- pattern: String,
- path: Option<String>,
- }
- #[derive(Debug, Deserialize)]
- struct WebFetchInput {
- url: String,
- prompt: String,
- }
- #[derive(Debug, Deserialize)]
- struct WebSearchInput {
- query: String,
- allowed_domains: Option<Vec<String>>,
- blocked_domains: Option<Vec<String>>,
- }
- #[derive(Debug, Deserialize)]
- struct TodoWriteInput {
- todos: Vec<TodoItem>,
- }
- #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
- struct TodoItem {
- content: String,
- #[serde(rename = "activeForm")]
- active_form: String,
- status: TodoStatus,
- }
- #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
- #[serde(rename_all = "snake_case")]
- enum TodoStatus {
- Pending,
- InProgress,
- Completed,
- }
- #[derive(Debug, Deserialize)]
- struct SkillInput {
- skill: String,
- args: Option<String>,
- }
- #[derive(Debug, Deserialize)]
- struct AgentInput {
- description: String,
- prompt: String,
- subagent_type: Option<String>,
- name: Option<String>,
- model: Option<String>,
- }
- #[derive(Debug, Deserialize)]
- struct ToolSearchInput {
- query: String,
- max_results: Option<usize>,
- }
- #[derive(Debug, Deserialize)]
- struct NotebookEditInput {
- notebook_path: String,
- cell_id: Option<String>,
- new_source: Option<String>,
- cell_type: Option<NotebookCellType>,
- edit_mode: Option<NotebookEditMode>,
- }
- #[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
- #[serde(rename_all = "lowercase")]
- enum NotebookCellType {
- Code,
- Markdown,
- }
- #[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
- #[serde(rename_all = "lowercase")]
- enum NotebookEditMode {
- Replace,
- Insert,
- Delete,
- }
- #[derive(Debug, Deserialize)]
- struct SleepInput {
- duration_ms: u64,
- }
- #[derive(Debug, Deserialize)]
- struct BriefInput {
- message: String,
- attachments: Option<Vec<String>>,
- status: BriefStatus,
- }
- #[derive(Debug, Deserialize)]
- #[serde(rename_all = "lowercase")]
- enum BriefStatus {
- Normal,
- Proactive,
- }
- #[derive(Debug, Deserialize)]
- struct ConfigInput {
- setting: String,
- value: Option<ConfigValue>,
- }
- #[derive(Debug, Default, Deserialize)]
- #[serde(default)]
- struct EnterPlanModeInput {}
- #[derive(Debug, Default, Deserialize)]
- #[serde(default)]
- struct ExitPlanModeInput {}
- #[derive(Debug, Deserialize)]
- #[serde(untagged)]
- enum ConfigValue {
- String(String),
- Bool(bool),
- Number(f64),
- }
- #[derive(Debug, Deserialize)]
- #[serde(transparent)]
- struct StructuredOutputInput(BTreeMap<String, Value>);
- #[derive(Debug, Deserialize)]
- struct ReplInput {
- code: String,
- language: String,
- timeout_ms: Option<u64>,
- }
- #[derive(Debug, Deserialize)]
- struct PowerShellInput {
- command: String,
- timeout: Option<u64>,
- description: Option<String>,
- run_in_background: Option<bool>,
- }
- #[derive(Debug, Deserialize)]
- struct AskUserQuestionInput {
- question: String,
- #[serde(default)]
- options: Option<Vec<String>>,
- }
- #[derive(Debug, Deserialize)]
- struct TaskCreateInput {
- prompt: String,
- #[serde(default)]
- description: Option<String>,
- }
- #[derive(Debug, Deserialize)]
- struct TaskIdInput {
- task_id: String,
- }
- #[derive(Debug, Deserialize)]
- struct TaskUpdateInput {
- task_id: String,
- message: String,
- }
- #[derive(Debug, Deserialize)]
- struct TeamCreateInput {
- name: String,
- tasks: Vec<Value>,
- }
- #[derive(Debug, Deserialize)]
- struct TeamDeleteInput {
- team_id: String,
- }
- #[derive(Debug, Deserialize)]
- struct CronCreateInput {
- schedule: String,
- prompt: String,
- #[serde(default)]
- description: Option<String>,
- }
- #[derive(Debug, Deserialize)]
- struct CronDeleteInput {
- cron_id: String,
- }
- #[derive(Debug, Deserialize)]
- struct LspInput {
- action: String,
- #[serde(default)]
- path: Option<String>,
- #[serde(default)]
- line: Option<u32>,
- #[serde(default)]
- character: Option<u32>,
- #[serde(default)]
- query: Option<String>,
- }
- #[derive(Debug, Deserialize)]
- struct McpResourceInput {
- #[serde(default)]
- server: Option<String>,
- #[serde(default)]
- uri: Option<String>,
- }
- #[derive(Debug, Deserialize)]
- struct McpAuthInput {
- server: String,
- }
- #[derive(Debug, Deserialize)]
- struct RemoteTriggerInput {
- url: String,
- #[serde(default)]
- method: Option<String>,
- #[serde(default)]
- headers: Option<Value>,
- #[serde(default)]
- body: Option<String>,
- }
- #[derive(Debug, Deserialize)]
- struct McpToolInput {
- server: String,
- tool: String,
- #[serde(default)]
- arguments: Option<Value>,
- }
- #[derive(Debug, Deserialize)]
- struct TestingPermissionInput {
- action: String,
- }
- #[derive(Debug, Serialize)]
- struct WebFetchOutput {
- bytes: usize,
- code: u16,
- #[serde(rename = "codeText")]
- code_text: String,
- result: String,
- #[serde(rename = "durationMs")]
- duration_ms: u128,
- url: String,
- }
- #[derive(Debug, Serialize)]
- struct WebSearchOutput {
- query: String,
- results: Vec<WebSearchResultItem>,
- #[serde(rename = "durationSeconds")]
- duration_seconds: f64,
- }
- #[derive(Debug, Serialize)]
- struct TodoWriteOutput {
- #[serde(rename = "oldTodos")]
- old_todos: Vec<TodoItem>,
- #[serde(rename = "newTodos")]
- new_todos: Vec<TodoItem>,
- #[serde(rename = "verificationNudgeNeeded")]
- verification_nudge_needed: Option<bool>,
- }
- #[derive(Debug, Serialize)]
- struct SkillOutput {
- skill: String,
- path: String,
- args: Option<String>,
- description: Option<String>,
- prompt: String,
- }
- #[derive(Debug, Clone, Serialize, Deserialize)]
- struct AgentOutput {
- #[serde(rename = "agentId")]
- agent_id: String,
- name: String,
- description: String,
- #[serde(rename = "subagentType")]
- subagent_type: Option<String>,
- model: Option<String>,
- status: String,
- #[serde(rename = "outputFile")]
- output_file: String,
- #[serde(rename = "manifestFile")]
- manifest_file: String,
- #[serde(rename = "createdAt")]
- created_at: String,
- #[serde(rename = "startedAt", skip_serializing_if = "Option::is_none")]
- started_at: Option<String>,
- #[serde(rename = "completedAt", skip_serializing_if = "Option::is_none")]
- completed_at: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- error: Option<String>,
- }
- #[derive(Debug, Clone)]
- struct AgentJob {
- manifest: AgentOutput,
- prompt: String,
- system_prompt: Vec<String>,
- allowed_tools: BTreeSet<String>,
- }
- #[derive(Debug, Serialize)]
- struct ToolSearchOutput {
- matches: Vec<String>,
- query: String,
- normalized_query: String,
- #[serde(rename = "total_deferred_tools")]
- total_deferred_tools: usize,
- #[serde(rename = "pending_mcp_servers")]
- pending_mcp_servers: Option<Vec<String>>,
- }
- #[derive(Debug, Serialize)]
- struct NotebookEditOutput {
- new_source: String,
- cell_id: Option<String>,
- cell_type: Option<NotebookCellType>,
- language: String,
- edit_mode: String,
- error: Option<String>,
- notebook_path: String,
- original_file: String,
- updated_file: String,
- }
- #[derive(Debug, Serialize)]
- struct SleepOutput {
- duration_ms: u64,
- message: String,
- }
- #[derive(Debug, Serialize)]
- struct BriefOutput {
- message: String,
- attachments: Option<Vec<ResolvedAttachment>>,
- #[serde(rename = "sentAt")]
- sent_at: String,
- }
- #[derive(Debug, Serialize)]
- struct ResolvedAttachment {
- path: String,
- size: u64,
- #[serde(rename = "isImage")]
- is_image: bool,
- }
- #[derive(Debug, Serialize)]
- struct ConfigOutput {
- success: bool,
- operation: Option<String>,
- setting: Option<String>,
- value: Option<Value>,
- #[serde(rename = "previousValue")]
- previous_value: Option<Value>,
- #[serde(rename = "newValue")]
- new_value: Option<Value>,
- error: Option<String>,
- }
- #[derive(Debug, Clone, Serialize, Deserialize)]
- struct PlanModeState {
- #[serde(rename = "hadLocalOverride")]
- had_local_override: bool,
- #[serde(rename = "previousLocalMode")]
- previous_local_mode: Option<Value>,
- }
- #[derive(Debug, Serialize)]
- #[allow(clippy::struct_excessive_bools)]
- struct PlanModeOutput {
- success: bool,
- operation: String,
- changed: bool,
- active: bool,
- managed: bool,
- message: String,
- #[serde(rename = "settingsPath")]
- settings_path: String,
- #[serde(rename = "statePath")]
- state_path: String,
- #[serde(rename = "previousLocalMode")]
- previous_local_mode: Option<Value>,
- #[serde(rename = "currentLocalMode")]
- current_local_mode: Option<Value>,
- }
- #[derive(Debug, Serialize)]
- struct StructuredOutputResult {
- data: String,
- structured_output: BTreeMap<String, Value>,
- }
- #[derive(Debug, Serialize)]
- struct ReplOutput {
- language: String,
- stdout: String,
- stderr: String,
- #[serde(rename = "exitCode")]
- exit_code: i32,
- #[serde(rename = "durationMs")]
- duration_ms: u128,
- }
- #[derive(Debug, Serialize)]
- #[serde(untagged)]
- enum WebSearchResultItem {
- SearchResult {
- tool_use_id: String,
- content: Vec<SearchHit>,
- },
- Commentary(String),
- }
- #[derive(Debug, Serialize)]
- struct SearchHit {
- title: String,
- url: String,
- }
- fn execute_web_fetch(input: &WebFetchInput) -> Result<WebFetchOutput, String> {
- let started = Instant::now();
- let client = build_http_client()?;
- let request_url = normalize_fetch_url(&input.url)?;
- let response = client
- .get(request_url.clone())
- .send()
- .map_err(|error| error.to_string())?;
- let status = response.status();
- let final_url = response.url().to_string();
- let code = status.as_u16();
- let code_text = status.canonical_reason().unwrap_or("Unknown").to_string();
- let content_type = response
- .headers()
- .get(reqwest::header::CONTENT_TYPE)
- .and_then(|value| value.to_str().ok())
- .unwrap_or_default()
- .to_string();
- let body = response.text().map_err(|error| error.to_string())?;
- let bytes = body.len();
- let normalized = normalize_fetched_content(&body, &content_type);
- let result = summarize_web_fetch(&final_url, &input.prompt, &normalized, &body, &content_type);
- Ok(WebFetchOutput {
- bytes,
- code,
- code_text,
- result,
- duration_ms: started.elapsed().as_millis(),
- url: final_url,
- })
- }
- fn execute_web_search(input: &WebSearchInput) -> Result<WebSearchOutput, String> {
- let started = Instant::now();
- let client = build_http_client()?;
- let search_url = build_search_url(&input.query)?;
- let response = client
- .get(search_url)
- .send()
- .map_err(|error| error.to_string())?;
- let final_url = response.url().clone();
- let html = response.text().map_err(|error| error.to_string())?;
- let mut hits = extract_search_hits(&html);
- if hits.is_empty() && final_url.host_str().is_some() {
- hits = extract_search_hits_from_generic_links(&html);
- }
- if let Some(allowed) = input.allowed_domains.as_ref() {
- hits.retain(|hit| host_matches_list(&hit.url, allowed));
- }
- if let Some(blocked) = input.blocked_domains.as_ref() {
- hits.retain(|hit| !host_matches_list(&hit.url, blocked));
- }
- dedupe_hits(&mut hits);
- hits.truncate(8);
- let summary = if hits.is_empty() {
- format!("No web search results matched the query {:?}.", input.query)
- } else {
- let rendered_hits = hits
- .iter()
- .map(|hit| format!("- [{}]({})", hit.title, hit.url))
- .collect::<Vec<_>>()
- .join("\n");
- format!(
- "Search results for {:?}. Include a Sources section in the final answer.\n{}",
- input.query, rendered_hits
- )
- };
- Ok(WebSearchOutput {
- query: input.query.clone(),
- results: vec![
- WebSearchResultItem::Commentary(summary),
- WebSearchResultItem::SearchResult {
- tool_use_id: String::from("web_search_1"),
- content: hits,
- },
- ],
- duration_seconds: started.elapsed().as_secs_f64(),
- })
- }
- fn build_http_client() -> Result<Client, String> {
- Client::builder()
- .timeout(Duration::from_secs(20))
- .redirect(reqwest::redirect::Policy::limited(10))
- .user_agent("clawd-rust-tools/0.1")
- .build()
- .map_err(|error| error.to_string())
- }
- fn normalize_fetch_url(url: &str) -> Result<String, String> {
- let parsed = reqwest::Url::parse(url).map_err(|error| error.to_string())?;
- if parsed.scheme() == "http" {
- let host = parsed.host_str().unwrap_or_default();
- if host != "localhost" && host != "127.0.0.1" && host != "::1" {
- let mut upgraded = parsed;
- upgraded
- .set_scheme("https")
- .map_err(|()| String::from("failed to upgrade URL to https"))?;
- return Ok(upgraded.to_string());
- }
- }
- Ok(parsed.to_string())
- }
- fn build_search_url(query: &str) -> Result<reqwest::Url, String> {
- if let Ok(base) = std::env::var("CLAWD_WEB_SEARCH_BASE_URL") {
- let mut url = reqwest::Url::parse(&base).map_err(|error| error.to_string())?;
- url.query_pairs_mut().append_pair("q", query);
- return Ok(url);
- }
- let mut url = reqwest::Url::parse("https://html.duckduckgo.com/html/")
- .map_err(|error| error.to_string())?;
- url.query_pairs_mut().append_pair("q", query);
- Ok(url)
- }
- fn normalize_fetched_content(body: &str, content_type: &str) -> String {
- if content_type.contains("html") {
- html_to_text(body)
- } else {
- body.trim().to_string()
- }
- }
- fn summarize_web_fetch(
- url: &str,
- prompt: &str,
- content: &str,
- raw_body: &str,
- content_type: &str,
- ) -> String {
- let lower_prompt = prompt.to_lowercase();
- let compact = collapse_whitespace(content);
- let detail = if lower_prompt.contains("title") {
- extract_title(content, raw_body, content_type).map_or_else(
- || preview_text(&compact, 600),
- |title| format!("Title: {title}"),
- )
- } else if lower_prompt.contains("summary") || lower_prompt.contains("summarize") {
- preview_text(&compact, 900)
- } else {
- let preview = preview_text(&compact, 900);
- format!("Prompt: {prompt}\nContent preview:\n{preview}")
- };
- format!("Fetched {url}\n{detail}")
- }
- fn extract_title(content: &str, raw_body: &str, content_type: &str) -> Option<String> {
- if content_type.contains("html") {
- let lowered = raw_body.to_lowercase();
- if let Some(start) = lowered.find("<title>") {
- let after = start + "<title>".len();
- if let Some(end_rel) = lowered[after..].find("</title>") {
- let title =
- collapse_whitespace(&decode_html_entities(&raw_body[after..after + end_rel]));
- if !title.is_empty() {
- return Some(title);
- }
- }
- }
- }
- for line in content.lines() {
- let trimmed = line.trim();
- if !trimmed.is_empty() {
- return Some(trimmed.to_string());
- }
- }
- None
- }
- fn html_to_text(html: &str) -> String {
- let mut text = String::with_capacity(html.len());
- let mut in_tag = false;
- let mut previous_was_space = false;
- for ch in html.chars() {
- match ch {
- '<' => in_tag = true,
- '>' => in_tag = false,
- _ if in_tag => {}
- '&' => {
- text.push('&');
- previous_was_space = false;
- }
- ch if ch.is_whitespace() => {
- if !previous_was_space {
- text.push(' ');
- previous_was_space = true;
- }
- }
- _ => {
- text.push(ch);
- previous_was_space = false;
- }
- }
- }
- collapse_whitespace(&decode_html_entities(&text))
- }
- fn decode_html_entities(input: &str) -> String {
- input
- .replace("&", "&")
- .replace("<", "<")
- .replace(">", ">")
- .replace(""", "\"")
- .replace("'", "'")
- .replace(" ", " ")
- }
- fn collapse_whitespace(input: &str) -> String {
- input.split_whitespace().collect::<Vec<_>>().join(" ")
- }
- fn preview_text(input: &str, max_chars: usize) -> String {
- if input.chars().count() <= max_chars {
- return input.to_string();
- }
- let shortened = input.chars().take(max_chars).collect::<String>();
- format!("{}…", shortened.trim_end())
- }
- fn extract_search_hits(html: &str) -> Vec<SearchHit> {
- let mut hits = Vec::new();
- let mut remaining = html;
- while let Some(anchor_start) = remaining.find("result__a") {
- let after_class = &remaining[anchor_start..];
- let Some(href_idx) = after_class.find("href=") else {
- remaining = &after_class[1..];
- continue;
- };
- let href_slice = &after_class[href_idx + 5..];
- let Some((url, rest)) = extract_quoted_value(href_slice) else {
- remaining = &after_class[1..];
- continue;
- };
- let Some(close_tag_idx) = rest.find('>') else {
- remaining = &after_class[1..];
- continue;
- };
- let after_tag = &rest[close_tag_idx + 1..];
- let Some(end_anchor_idx) = after_tag.find("</a>") else {
- remaining = &after_tag[1..];
- continue;
- };
- let title = html_to_text(&after_tag[..end_anchor_idx]);
- if let Some(decoded_url) = decode_duckduckgo_redirect(&url) {
- hits.push(SearchHit {
- title: title.trim().to_string(),
- url: decoded_url,
- });
- }
- remaining = &after_tag[end_anchor_idx + 4..];
- }
- hits
- }
- fn extract_search_hits_from_generic_links(html: &str) -> Vec<SearchHit> {
- let mut hits = Vec::new();
- let mut remaining = html;
- while let Some(anchor_start) = remaining.find("<a") {
- let after_anchor = &remaining[anchor_start..];
- let Some(href_idx) = after_anchor.find("href=") else {
- remaining = &after_anchor[2..];
- continue;
- };
- let href_slice = &after_anchor[href_idx + 5..];
- let Some((url, rest)) = extract_quoted_value(href_slice) else {
- remaining = &after_anchor[2..];
- continue;
- };
- let Some(close_tag_idx) = rest.find('>') else {
- remaining = &after_anchor[2..];
- continue;
- };
- let after_tag = &rest[close_tag_idx + 1..];
- let Some(end_anchor_idx) = after_tag.find("</a>") else {
- remaining = &after_anchor[2..];
- continue;
- };
- let title = html_to_text(&after_tag[..end_anchor_idx]);
- if title.trim().is_empty() {
- remaining = &after_tag[end_anchor_idx + 4..];
- continue;
- }
- let decoded_url = decode_duckduckgo_redirect(&url).unwrap_or(url);
- if decoded_url.starts_with("http://") || decoded_url.starts_with("https://") {
- hits.push(SearchHit {
- title: title.trim().to_string(),
- url: decoded_url,
- });
- }
- remaining = &after_tag[end_anchor_idx + 4..];
- }
- hits
- }
- fn extract_quoted_value(input: &str) -> Option<(String, &str)> {
- let quote = input.chars().next()?;
- if quote != '"' && quote != '\'' {
- return None;
- }
- let rest = &input[quote.len_utf8()..];
- let end = rest.find(quote)?;
- Some((rest[..end].to_string(), &rest[end + quote.len_utf8()..]))
- }
- fn decode_duckduckgo_redirect(url: &str) -> Option<String> {
- if url.starts_with("http://") || url.starts_with("https://") {
- return Some(html_entity_decode_url(url));
- }
- let joined = if url.starts_with("//") {
- format!("https:{url}")
- } else if url.starts_with('/') {
- format!("https://duckduckgo.com{url}")
- } else {
- return None;
- };
- let parsed = reqwest::Url::parse(&joined).ok()?;
- if parsed.path() == "/l/" || parsed.path() == "/l" {
- for (key, value) in parsed.query_pairs() {
- if key == "uddg" {
- return Some(html_entity_decode_url(value.as_ref()));
- }
- }
- }
- Some(joined)
- }
- fn html_entity_decode_url(url: &str) -> String {
- decode_html_entities(url)
- }
- fn host_matches_list(url: &str, domains: &[String]) -> bool {
- let Ok(parsed) = reqwest::Url::parse(url) else {
- return false;
- };
- let Some(host) = parsed.host_str() else {
- return false;
- };
- let host = host.to_ascii_lowercase();
- domains.iter().any(|domain| {
- let normalized = normalize_domain_filter(domain);
- !normalized.is_empty() && (host == normalized || host.ends_with(&format!(".{normalized}")))
- })
- }
- fn normalize_domain_filter(domain: &str) -> String {
- let trimmed = domain.trim();
- let candidate = reqwest::Url::parse(trimmed)
- .ok()
- .and_then(|url| url.host_str().map(str::to_string))
- .unwrap_or_else(|| trimmed.to_string());
- candidate
- .trim()
- .trim_start_matches('.')
- .trim_end_matches('/')
- .to_ascii_lowercase()
- }
- fn dedupe_hits(hits: &mut Vec<SearchHit>) {
- let mut seen = BTreeSet::new();
- hits.retain(|hit| seen.insert(hit.url.clone()));
- }
- fn execute_todo_write(input: TodoWriteInput) -> Result<TodoWriteOutput, String> {
- validate_todos(&input.todos)?;
- let store_path = todo_store_path()?;
- let old_todos = if store_path.exists() {
- serde_json::from_str::<Vec<TodoItem>>(
- &std::fs::read_to_string(&store_path).map_err(|error| error.to_string())?,
- )
- .map_err(|error| error.to_string())?
- } else {
- Vec::new()
- };
- let all_done = input
- .todos
- .iter()
- .all(|todo| matches!(todo.status, TodoStatus::Completed));
- let persisted = if all_done {
- Vec::new()
- } else {
- input.todos.clone()
- };
- if let Some(parent) = store_path.parent() {
- std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
- }
- std::fs::write(
- &store_path,
- serde_json::to_string_pretty(&persisted).map_err(|error| error.to_string())?,
- )
- .map_err(|error| error.to_string())?;
- let verification_nudge_needed = (all_done
- && input.todos.len() >= 3
- && !input
- .todos
- .iter()
- .any(|todo| todo.content.to_lowercase().contains("verif")))
- .then_some(true);
- Ok(TodoWriteOutput {
- old_todos,
- new_todos: input.todos,
- verification_nudge_needed,
- })
- }
- fn execute_skill(input: SkillInput) -> Result<SkillOutput, String> {
- let skill_path = resolve_skill_path(&input.skill)?;
- let prompt = std::fs::read_to_string(&skill_path).map_err(|error| error.to_string())?;
- let description = parse_skill_description(&prompt);
- Ok(SkillOutput {
- skill: input.skill,
- path: skill_path.display().to_string(),
- args: input.args,
- description,
- prompt,
- })
- }
- fn validate_todos(todos: &[TodoItem]) -> Result<(), String> {
- if todos.is_empty() {
- return Err(String::from("todos must not be empty"));
- }
- // Allow multiple in_progress items for parallel workflows
- if todos.iter().any(|todo| todo.content.trim().is_empty()) {
- return Err(String::from("todo content must not be empty"));
- }
- if todos.iter().any(|todo| todo.active_form.trim().is_empty()) {
- return Err(String::from("todo activeForm must not be empty"));
- }
- Ok(())
- }
- fn todo_store_path() -> Result<std::path::PathBuf, String> {
- if let Ok(path) = std::env::var("CLAWD_TODO_STORE") {
- return Ok(std::path::PathBuf::from(path));
- }
- let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
- Ok(cwd.join(".clawd-todos.json"))
- }
- fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
- let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
- if requested.is_empty() {
- return Err(String::from("skill must not be empty"));
- }
- let mut candidates = Vec::new();
- if let Ok(codex_home) = std::env::var("CODEX_HOME") {
- candidates.push(std::path::PathBuf::from(codex_home).join("skills"));
- }
- if let Ok(home) = std::env::var("HOME") {
- let home = std::path::PathBuf::from(home);
- candidates.push(home.join(".agents").join("skills"));
- candidates.push(home.join(".config").join("opencode").join("skills"));
- candidates.push(home.join(".codex").join("skills"));
- }
- candidates.push(std::path::PathBuf::from("/home/bellman/.codex/skills"));
- for root in candidates {
- let direct = root.join(requested).join("SKILL.md");
- if direct.exists() {
- return Ok(direct);
- }
- if let Ok(entries) = std::fs::read_dir(&root) {
- for entry in entries.flatten() {
- let path = entry.path().join("SKILL.md");
- if !path.exists() {
- continue;
- }
- if entry
- .file_name()
- .to_string_lossy()
- .eq_ignore_ascii_case(requested)
- {
- return Ok(path);
- }
- }
- }
- }
- Err(format!("unknown skill: {requested}"))
- }
- const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";
- const DEFAULT_AGENT_SYSTEM_DATE: &str = "2026-03-31";
- const DEFAULT_AGENT_MAX_ITERATIONS: usize = 32;
- fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
- execute_agent_with_spawn(input, spawn_agent_job)
- }
- fn execute_agent_with_spawn<F>(input: AgentInput, spawn_fn: F) -> Result<AgentOutput, String>
- where
- F: FnOnce(AgentJob) -> Result<(), String>,
- {
- if input.description.trim().is_empty() {
- return Err(String::from("description must not be empty"));
- }
- if input.prompt.trim().is_empty() {
- return Err(String::from("prompt must not be empty"));
- }
- let agent_id = make_agent_id();
- let output_dir = agent_store_dir()?;
- std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?;
- let output_file = output_dir.join(format!("{agent_id}.md"));
- let manifest_file = output_dir.join(format!("{agent_id}.json"));
- let normalized_subagent_type = normalize_subagent_type(input.subagent_type.as_deref());
- let model = resolve_agent_model(input.model.as_deref());
- let agent_name = input
- .name
- .as_deref()
- .map(slugify_agent_name)
- .filter(|name| !name.is_empty())
- .unwrap_or_else(|| slugify_agent_name(&input.description));
- let created_at = iso8601_now();
- let system_prompt = build_agent_system_prompt(&normalized_subagent_type)?;
- let allowed_tools = allowed_tools_for_subagent(&normalized_subagent_type);
- let output_contents = format!(
- "# Agent Task
- - id: {}
- - name: {}
- - description: {}
- - subagent_type: {}
- - created_at: {}
- ## Prompt
- {}
- ",
- agent_id, agent_name, input.description, normalized_subagent_type, created_at, input.prompt
- );
- std::fs::write(&output_file, output_contents).map_err(|error| error.to_string())?;
- let manifest = AgentOutput {
- agent_id,
- name: agent_name,
- description: input.description,
- subagent_type: Some(normalized_subagent_type),
- model: Some(model),
- status: String::from("running"),
- output_file: output_file.display().to_string(),
- manifest_file: manifest_file.display().to_string(),
- created_at: created_at.clone(),
- started_at: Some(created_at),
- completed_at: None,
- error: None,
- };
- write_agent_manifest(&manifest)?;
- let manifest_for_spawn = manifest.clone();
- let job = AgentJob {
- manifest: manifest_for_spawn,
- prompt: input.prompt,
- system_prompt,
- allowed_tools,
- };
- if let Err(error) = spawn_fn(job) {
- let error = format!("failed to spawn sub-agent: {error}");
- persist_agent_terminal_state(&manifest, "failed", None, Some(error.clone()))?;
- return Err(error);
- }
- Ok(manifest)
- }
- fn spawn_agent_job(job: AgentJob) -> Result<(), String> {
- let thread_name = format!("clawd-agent-{}", job.manifest.agent_id);
- std::thread::Builder::new()
- .name(thread_name)
- .spawn(move || {
- let result =
- std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run_agent_job(&job)));
- match result {
- Ok(Ok(())) => {}
- Ok(Err(error)) => {
- let _ =
- persist_agent_terminal_state(&job.manifest, "failed", None, Some(error));
- }
- Err(_) => {
- let _ = persist_agent_terminal_state(
- &job.manifest,
- "failed",
- None,
- Some(String::from("sub-agent thread panicked")),
- );
- }
- }
- })
- .map(|_| ())
- .map_err(|error| error.to_string())
- }
- fn run_agent_job(job: &AgentJob) -> Result<(), String> {
- let mut runtime = build_agent_runtime(job)?.with_max_iterations(DEFAULT_AGENT_MAX_ITERATIONS);
- let summary = runtime
- .run_turn(job.prompt.clone(), None)
- .map_err(|error| error.to_string())?;
- let final_text = final_assistant_text(&summary);
- persist_agent_terminal_state(&job.manifest, "completed", Some(final_text.as_str()), None)
- }
- fn build_agent_runtime(
- job: &AgentJob,
- ) -> Result<ConversationRuntime<ProviderRuntimeClient, SubagentToolExecutor>, String> {
- let model = job
- .manifest
- .model
- .clone()
- .unwrap_or_else(|| DEFAULT_AGENT_MODEL.to_string());
- let allowed_tools = job.allowed_tools.clone();
- let api_client = ProviderRuntimeClient::new(model, allowed_tools.clone())?;
- let tool_executor = SubagentToolExecutor::new(allowed_tools);
- Ok(ConversationRuntime::new(
- Session::new(),
- api_client,
- tool_executor,
- agent_permission_policy(),
- job.system_prompt.clone(),
- ))
- }
- fn build_agent_system_prompt(subagent_type: &str) -> Result<Vec<String>, String> {
- let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
- let mut prompt = load_system_prompt(
- cwd,
- DEFAULT_AGENT_SYSTEM_DATE.to_string(),
- std::env::consts::OS,
- "unknown",
- )
- .map_err(|error| error.to_string())?;
- prompt.push(format!(
- "You are a background sub-agent of type `{subagent_type}`. Work only on the delegated task, use only the tools available to you, do not ask the user questions, and finish with a concise result."
- ));
- Ok(prompt)
- }
- fn resolve_agent_model(model: Option<&str>) -> String {
- model
- .map(str::trim)
- .filter(|model| !model.is_empty())
- .unwrap_or(DEFAULT_AGENT_MODEL)
- .to_string()
- }
- fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
- let tools = match subagent_type {
- "Explore" => vec![
- "read_file",
- "glob_search",
- "grep_search",
- "WebFetch",
- "WebSearch",
- "ToolSearch",
- "Skill",
- "StructuredOutput",
- ],
- "Plan" => vec![
- "read_file",
- "glob_search",
- "grep_search",
- "WebFetch",
- "WebSearch",
- "ToolSearch",
- "Skill",
- "TodoWrite",
- "StructuredOutput",
- "SendUserMessage",
- ],
- "Verification" => vec![
- "bash",
- "read_file",
- "glob_search",
- "grep_search",
- "WebFetch",
- "WebSearch",
- "ToolSearch",
- "TodoWrite",
- "StructuredOutput",
- "SendUserMessage",
- "PowerShell",
- ],
- "claw-guide" => vec![
- "read_file",
- "glob_search",
- "grep_search",
- "WebFetch",
- "WebSearch",
- "ToolSearch",
- "Skill",
- "StructuredOutput",
- "SendUserMessage",
- ],
- "statusline-setup" => vec![
- "bash",
- "read_file",
- "write_file",
- "edit_file",
- "glob_search",
- "grep_search",
- "ToolSearch",
- ],
- _ => vec![
- "bash",
- "read_file",
- "write_file",
- "edit_file",
- "glob_search",
- "grep_search",
- "WebFetch",
- "WebSearch",
- "TodoWrite",
- "Skill",
- "ToolSearch",
- "NotebookEdit",
- "Sleep",
- "SendUserMessage",
- "Config",
- "StructuredOutput",
- "REPL",
- "PowerShell",
- ],
- };
- tools.into_iter().map(str::to_string).collect()
- }
- fn agent_permission_policy() -> PermissionPolicy {
- mvp_tool_specs().into_iter().fold(
- PermissionPolicy::new(PermissionMode::DangerFullAccess),
- |policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission),
- )
- }
- fn write_agent_manifest(manifest: &AgentOutput) -> Result<(), String> {
- std::fs::write(
- &manifest.manifest_file,
- serde_json::to_string_pretty(manifest).map_err(|error| error.to_string())?,
- )
- .map_err(|error| error.to_string())
- }
- fn persist_agent_terminal_state(
- manifest: &AgentOutput,
- status: &str,
- result: Option<&str>,
- error: Option<String>,
- ) -> Result<(), String> {
- append_agent_output(
- &manifest.output_file,
- &format_agent_terminal_output(status, result, error.as_deref()),
- )?;
- let mut next_manifest = manifest.clone();
- next_manifest.status = status.to_string();
- next_manifest.completed_at = Some(iso8601_now());
- next_manifest.error = error;
- write_agent_manifest(&next_manifest)
- }
- fn append_agent_output(path: &str, suffix: &str) -> Result<(), String> {
- use std::io::Write as _;
- let mut file = std::fs::OpenOptions::new()
- .append(true)
- .open(path)
- .map_err(|error| error.to_string())?;
- file.write_all(suffix.as_bytes())
- .map_err(|error| error.to_string())
- }
- fn format_agent_terminal_output(status: &str, result: Option<&str>, error: Option<&str>) -> String {
- let mut sections = vec![format!("\n## Result\n\n- status: {status}\n")];
- if let Some(result) = result.filter(|value| !value.trim().is_empty()) {
- sections.push(format!("\n### Final response\n\n{}\n", result.trim()));
- }
- if let Some(error) = error.filter(|value| !value.trim().is_empty()) {
- sections.push(format!("\n### Error\n\n{}\n", error.trim()));
- }
- sections.join("")
- }
- struct ProviderRuntimeClient {
- runtime: tokio::runtime::Runtime,
- client: ProviderClient,
- model: String,
- allowed_tools: BTreeSet<String>,
- }
- impl ProviderRuntimeClient {
- #[allow(clippy::needless_pass_by_value)]
- fn new(model: String, allowed_tools: BTreeSet<String>) -> Result<Self, String> {
- let model = resolve_model_alias(&model).clone();
- let client = ProviderClient::from_model(&model).map_err(|error| error.to_string())?;
- Ok(Self {
- runtime: tokio::runtime::Runtime::new().map_err(|error| error.to_string())?,
- client,
- model,
- allowed_tools,
- })
- }
- }
- impl ApiClient for ProviderRuntimeClient {
- #[allow(clippy::too_many_lines)]
- fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
- let tools = tool_specs_for_allowed_tools(Some(&self.allowed_tools))
- .into_iter()
- .map(|spec| ToolDefinition {
- name: spec.name.to_string(),
- description: Some(spec.description.to_string()),
- input_schema: spec.input_schema,
- })
- .collect::<Vec<_>>();
- let message_request = MessageRequest {
- model: self.model.clone(),
- max_tokens: max_tokens_for_model(&self.model),
- messages: convert_messages(&request.messages),
- system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
- tools: (!tools.is_empty()).then_some(tools),
- tool_choice: (!self.allowed_tools.is_empty()).then_some(ToolChoice::Auto),
- stream: true,
- };
- self.runtime.block_on(async {
- let mut stream = self
- .client
- .stream_message(&message_request)
- .await
- .map_err(|error| RuntimeError::new(error.to_string()))?;
- let mut events = Vec::new();
- let mut pending_tools: BTreeMap<u32, (String, String, String)> = BTreeMap::new();
- let mut saw_stop = false;
- while let Some(event) = stream
- .next_event()
- .await
- .map_err(|error| RuntimeError::new(error.to_string()))?
- {
- match event {
- ApiStreamEvent::MessageStart(start) => {
- for block in start.message.content {
- push_output_block(block, 0, &mut events, &mut pending_tools, true);
- }
- }
- ApiStreamEvent::ContentBlockStart(start) => {
- push_output_block(
- start.content_block,
- start.index,
- &mut events,
- &mut pending_tools,
- true,
- );
- }
- ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
- ContentBlockDelta::TextDelta { text } => {
- if !text.is_empty() {
- events.push(AssistantEvent::TextDelta(text));
- }
- }
- ContentBlockDelta::InputJsonDelta { partial_json } => {
- if let Some((_, _, input)) = pending_tools.get_mut(&delta.index) {
- input.push_str(&partial_json);
- }
- }
- ContentBlockDelta::ThinkingDelta { .. }
- | ContentBlockDelta::SignatureDelta { .. } => {}
- },
- ApiStreamEvent::ContentBlockStop(stop) => {
- if let Some((id, name, input)) = pending_tools.remove(&stop.index) {
- events.push(AssistantEvent::ToolUse { id, name, input });
- }
- }
- ApiStreamEvent::MessageDelta(delta) => {
- events.push(AssistantEvent::Usage(delta.usage.token_usage()));
- }
- ApiStreamEvent::MessageStop(_) => {
- saw_stop = true;
- events.push(AssistantEvent::MessageStop);
- }
- }
- }
- push_prompt_cache_record(&self.client, &mut events);
- if !saw_stop
- && events.iter().any(|event| {
- matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
- || matches!(event, AssistantEvent::ToolUse { .. })
- })
- {
- events.push(AssistantEvent::MessageStop);
- }
- if events
- .iter()
- .any(|event| matches!(event, AssistantEvent::MessageStop))
- {
- return Ok(events);
- }
- let response = self
- .client
- .send_message(&MessageRequest {
- stream: false,
- ..message_request.clone()
- })
- .await
- .map_err(|error| RuntimeError::new(error.to_string()))?;
- let mut events = response_to_events(response);
- push_prompt_cache_record(&self.client, &mut events);
- Ok(events)
- })
- }
- }
- struct SubagentToolExecutor {
- allowed_tools: BTreeSet<String>,
- }
- impl SubagentToolExecutor {
- fn new(allowed_tools: BTreeSet<String>) -> Self {
- Self { allowed_tools }
- }
- }
- impl ToolExecutor for SubagentToolExecutor {
- fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
- if !self.allowed_tools.contains(tool_name) {
- return Err(ToolError::new(format!(
- "tool `{tool_name}` is not enabled for this sub-agent"
- )));
- }
- let value = serde_json::from_str(input)
- .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
- execute_tool(tool_name, &value).map_err(ToolError::new)
- }
- }
- fn tool_specs_for_allowed_tools(allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolSpec> {
- mvp_tool_specs()
- .into_iter()
- .filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
- .collect()
- }
- fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
- messages
- .iter()
- .filter_map(|message| {
- let role = match message.role {
- MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
- MessageRole::Assistant => "assistant",
- };
- let content = message
- .blocks
- .iter()
- .map(|block| match block {
- ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
- ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
- id: id.clone(),
- name: name.clone(),
- input: serde_json::from_str(input)
- .unwrap_or_else(|_| serde_json::json!({ "raw": input })),
- },
- ContentBlock::ToolResult {
- tool_use_id,
- output,
- is_error,
- ..
- } => InputContentBlock::ToolResult {
- tool_use_id: tool_use_id.clone(),
- content: vec![ToolResultContentBlock::Text {
- text: output.clone(),
- }],
- is_error: *is_error,
- },
- })
- .collect::<Vec<_>>();
- (!content.is_empty()).then(|| InputMessage {
- role: role.to_string(),
- content,
- })
- })
- .collect()
- }
- fn push_output_block(
- block: OutputContentBlock,
- block_index: u32,
- events: &mut Vec<AssistantEvent>,
- pending_tools: &mut BTreeMap<u32, (String, String, String)>,
- streaming_tool_input: bool,
- ) {
- match block {
- OutputContentBlock::Text { text } => {
- if !text.is_empty() {
- events.push(AssistantEvent::TextDelta(text));
- }
- }
- OutputContentBlock::ToolUse { id, name, input } => {
- let initial_input = if streaming_tool_input
- && input.is_object()
- && input.as_object().is_some_and(serde_json::Map::is_empty)
- {
- String::new()
- } else {
- input.to_string()
- };
- pending_tools.insert(block_index, (id, name, initial_input));
- }
- OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => {}
- }
- }
- fn response_to_events(response: MessageResponse) -> Vec<AssistantEvent> {
- let mut events = Vec::new();
- let mut pending_tools = BTreeMap::new();
- for (index, block) in response.content.into_iter().enumerate() {
- let index = u32::try_from(index).expect("response block index overflow");
- push_output_block(block, index, &mut events, &mut pending_tools, false);
- if let Some((id, name, input)) = pending_tools.remove(&index) {
- events.push(AssistantEvent::ToolUse { id, name, input });
- }
- }
- events.push(AssistantEvent::Usage(response.usage.token_usage()));
- events.push(AssistantEvent::MessageStop);
- events
- }
- fn push_prompt_cache_record(client: &ProviderClient, events: &mut Vec<AssistantEvent>) {
- if let Some(record) = client.take_last_prompt_cache_record() {
- if let Some(event) = prompt_cache_record_to_runtime_event(record) {
- events.push(AssistantEvent::PromptCache(event));
- }
- }
- }
- fn prompt_cache_record_to_runtime_event(
- record: api::PromptCacheRecord,
- ) -> Option<PromptCacheEvent> {
- let cache_break = record.cache_break?;
- Some(PromptCacheEvent {
- unexpected: cache_break.unexpected,
- reason: cache_break.reason,
- previous_cache_read_input_tokens: cache_break.previous_cache_read_input_tokens,
- current_cache_read_input_tokens: cache_break.current_cache_read_input_tokens,
- token_drop: cache_break.token_drop,
- })
- }
- fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
- summary
- .assistant_messages
- .last()
- .map(|message| {
- message
- .blocks
- .iter()
- .filter_map(|block| match block {
- ContentBlock::Text { text } => Some(text.as_str()),
- _ => None,
- })
- .collect::<Vec<_>>()
- .join("")
- })
- .unwrap_or_default()
- }
- #[allow(clippy::needless_pass_by_value)]
- fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput {
- let deferred = deferred_tool_specs();
- let max_results = input.max_results.unwrap_or(5).max(1);
- let query = input.query.trim().to_string();
- let normalized_query = normalize_tool_search_query(&query);
- let matches = search_tool_specs(&query, max_results, &deferred);
- ToolSearchOutput {
- matches,
- query,
- normalized_query,
- total_deferred_tools: deferred.len(),
- pending_mcp_servers: None,
- }
- }
- fn deferred_tool_specs() -> Vec<ToolSpec> {
- mvp_tool_specs()
- .into_iter()
- .filter(|spec| {
- !matches!(
- spec.name,
- "bash" | "read_file" | "write_file" | "edit_file" | "glob_search" | "grep_search"
- )
- })
- .collect()
- }
- fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec<String> {
- let lowered = query.to_lowercase();
- if let Some(selection) = lowered.strip_prefix("select:") {
- return selection
- .split(',')
- .map(str::trim)
- .filter(|part| !part.is_empty())
- .filter_map(|wanted| {
- let wanted = canonical_tool_token(wanted);
- specs
- .iter()
- .find(|spec| canonical_tool_token(spec.name) == wanted)
- .map(|spec| spec.name.to_string())
- })
- .take(max_results)
- .collect();
- }
- let mut required = Vec::new();
- let mut optional = Vec::new();
- for term in lowered.split_whitespace() {
- if let Some(rest) = term.strip_prefix('+') {
- if !rest.is_empty() {
- required.push(rest);
- }
- } else {
- optional.push(term);
- }
- }
- let terms = if required.is_empty() {
- optional.clone()
- } else {
- required.iter().chain(optional.iter()).copied().collect()
- };
- let mut scored = specs
- .iter()
- .filter_map(|spec| {
- let name = spec.name.to_lowercase();
- let canonical_name = canonical_tool_token(spec.name);
- let normalized_description = normalize_tool_search_query(spec.description);
- let haystack = format!(
- "{name} {} {canonical_name}",
- spec.description.to_lowercase()
- );
- let normalized_haystack = format!("{canonical_name} {normalized_description}");
- if required.iter().any(|term| !haystack.contains(term)) {
- return None;
- }
- let mut score = 0_i32;
- for term in &terms {
- let canonical_term = canonical_tool_token(term);
- if haystack.contains(term) {
- score += 2;
- }
- if name == *term {
- score += 8;
- }
- if name.contains(term) {
- score += 4;
- }
- if canonical_name == canonical_term {
- score += 12;
- }
- if normalized_haystack.contains(&canonical_term) {
- score += 3;
- }
- }
- if score == 0 && !lowered.is_empty() {
- return None;
- }
- Some((score, spec.name.to_string()))
- })
- .collect::<Vec<_>>();
- scored.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
- scored
- .into_iter()
- .map(|(_, name)| name)
- .take(max_results)
- .collect()
- }
- fn normalize_tool_search_query(query: &str) -> String {
- query
- .trim()
- .split(|ch: char| ch.is_whitespace() || ch == ',')
- .filter(|term| !term.is_empty())
- .map(canonical_tool_token)
- .collect::<Vec<_>>()
- .join(" ")
- }
- fn canonical_tool_token(value: &str) -> String {
- let mut canonical = value
- .chars()
- .filter(char::is_ascii_alphanumeric)
- .flat_map(char::to_lowercase)
- .collect::<String>();
- if let Some(stripped) = canonical.strip_suffix("tool") {
- canonical = stripped.to_string();
- }
- canonical
- }
- fn agent_store_dir() -> Result<std::path::PathBuf, String> {
- if let Ok(path) = std::env::var("CLAWD_AGENT_STORE") {
- return Ok(std::path::PathBuf::from(path));
- }
- let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
- if let Some(workspace_root) = cwd.ancestors().nth(2) {
- return Ok(workspace_root.join(".clawd-agents"));
- }
- Ok(cwd.join(".clawd-agents"))
- }
- fn make_agent_id() -> String {
- let nanos = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .unwrap_or_default()
- .as_nanos();
- format!("agent-{nanos}")
- }
- fn slugify_agent_name(description: &str) -> String {
- let mut out = description
- .chars()
- .map(|ch| {
- if ch.is_ascii_alphanumeric() {
- ch.to_ascii_lowercase()
- } else {
- '-'
- }
- })
- .collect::<String>();
- while out.contains("--") {
- out = out.replace("--", "-");
- }
- out.trim_matches('-').chars().take(32).collect()
- }
- fn normalize_subagent_type(subagent_type: Option<&str>) -> String {
- let trimmed = subagent_type.map(str::trim).unwrap_or_default();
- if trimmed.is_empty() {
- return String::from("general-purpose");
- }
- match canonical_tool_token(trimmed).as_str() {
- "general" | "generalpurpose" | "generalpurposeagent" => String::from("general-purpose"),
- "explore" | "explorer" | "exploreagent" => String::from("Explore"),
- "plan" | "planagent" => String::from("Plan"),
- "verification" | "verificationagent" | "verify" | "verifier" => {
- String::from("Verification")
- }
- "clawguide" | "clawguideagent" | "guide" => String::from("claw-guide"),
- "statusline" | "statuslinesetup" => String::from("statusline-setup"),
- _ => trimmed.to_string(),
- }
- }
- fn iso8601_now() -> String {
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .unwrap_or_default()
- .as_secs()
- .to_string()
- }
- #[allow(clippy::too_many_lines)]
- fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput, String> {
- let path = std::path::PathBuf::from(&input.notebook_path);
- if path.extension().and_then(|ext| ext.to_str()) != Some("ipynb") {
- return Err(String::from(
- "File must be a Jupyter notebook (.ipynb file).",
- ));
- }
- let original_file = std::fs::read_to_string(&path).map_err(|error| error.to_string())?;
- let mut notebook: serde_json::Value =
- serde_json::from_str(&original_file).map_err(|error| error.to_string())?;
- let language = notebook
- .get("metadata")
- .and_then(|metadata| metadata.get("kernelspec"))
- .and_then(|kernelspec| kernelspec.get("language"))
- .and_then(serde_json::Value::as_str)
- .unwrap_or("python")
- .to_string();
- let cells = notebook
- .get_mut("cells")
- .and_then(serde_json::Value::as_array_mut)
- .ok_or_else(|| String::from("Notebook cells array not found"))?;
- let edit_mode = input.edit_mode.unwrap_or(NotebookEditMode::Replace);
- let target_index = match input.cell_id.as_deref() {
- Some(cell_id) => Some(resolve_cell_index(cells, Some(cell_id), edit_mode)?),
- None if matches!(
- edit_mode,
- NotebookEditMode::Replace | NotebookEditMode::Delete
- ) =>
- {
- Some(resolve_cell_index(cells, None, edit_mode)?)
- }
- None => None,
- };
- let resolved_cell_type = match edit_mode {
- NotebookEditMode::Delete => None,
- NotebookEditMode::Insert => Some(input.cell_type.unwrap_or(NotebookCellType::Code)),
- NotebookEditMode::Replace => Some(input.cell_type.unwrap_or_else(|| {
- target_index
- .and_then(|index| cells.get(index))
- .and_then(cell_kind)
- .unwrap_or(NotebookCellType::Code)
- })),
- };
- let new_source = require_notebook_source(input.new_source, edit_mode)?;
- let cell_id = match edit_mode {
- NotebookEditMode::Insert => {
- let resolved_cell_type = resolved_cell_type
- .ok_or_else(|| String::from("insert mode requires a cell type"))?;
- let new_id = make_cell_id(cells.len());
- let new_cell = build_notebook_cell(&new_id, resolved_cell_type, &new_source);
- let insert_at = target_index.map_or(cells.len(), |index| index + 1);
- cells.insert(insert_at, new_cell);
- cells
- .get(insert_at)
- .and_then(|cell| cell.get("id"))
- .and_then(serde_json::Value::as_str)
- .map(ToString::to_string)
- }
- NotebookEditMode::Delete => {
- let idx = target_index
- .ok_or_else(|| String::from("delete mode requires a target cell index"))?;
- let removed = cells.remove(idx);
- removed
- .get("id")
- .and_then(serde_json::Value::as_str)
- .map(ToString::to_string)
- }
- NotebookEditMode::Replace => {
- let resolved_cell_type = resolved_cell_type
- .ok_or_else(|| String::from("replace mode requires a cell type"))?;
- let idx = target_index
- .ok_or_else(|| String::from("replace mode requires a target cell index"))?;
- let cell = cells
- .get_mut(idx)
- .ok_or_else(|| String::from("Cell index out of range"))?;
- cell["source"] = serde_json::Value::Array(source_lines(&new_source));
- cell["cell_type"] = serde_json::Value::String(match resolved_cell_type {
- NotebookCellType::Code => String::from("code"),
- NotebookCellType::Markdown => String::from("markdown"),
- });
- match resolved_cell_type {
- NotebookCellType::Code => {
- if !cell.get("outputs").is_some_and(serde_json::Value::is_array) {
- cell["outputs"] = json!([]);
- }
- if cell.get("execution_count").is_none() {
- cell["execution_count"] = serde_json::Value::Null;
- }
- }
- NotebookCellType::Markdown => {
- if let Some(object) = cell.as_object_mut() {
- object.remove("outputs");
- object.remove("execution_count");
- }
- }
- }
- cell.get("id")
- .and_then(serde_json::Value::as_str)
- .map(ToString::to_string)
- }
- };
- let updated_file =
- serde_json::to_string_pretty(¬ebook).map_err(|error| error.to_string())?;
- std::fs::write(&path, &updated_file).map_err(|error| error.to_string())?;
- Ok(NotebookEditOutput {
- new_source,
- cell_id,
- cell_type: resolved_cell_type,
- language,
- edit_mode: format_notebook_edit_mode(edit_mode),
- error: None,
- notebook_path: path.display().to_string(),
- original_file,
- updated_file,
- })
- }
- fn require_notebook_source(
- source: Option<String>,
- edit_mode: NotebookEditMode,
- ) -> Result<String, String> {
- match edit_mode {
- NotebookEditMode::Delete => Ok(source.unwrap_or_default()),
- NotebookEditMode::Insert | NotebookEditMode::Replace => source
- .ok_or_else(|| String::from("new_source is required for insert and replace edits")),
- }
- }
- fn build_notebook_cell(cell_id: &str, cell_type: NotebookCellType, source: &str) -> Value {
- let mut cell = json!({
- "cell_type": match cell_type {
- NotebookCellType::Code => "code",
- NotebookCellType::Markdown => "markdown",
- },
- "id": cell_id,
- "metadata": {},
- "source": source_lines(source),
- });
- if let Some(object) = cell.as_object_mut() {
- match cell_type {
- NotebookCellType::Code => {
- object.insert(String::from("outputs"), json!([]));
- object.insert(String::from("execution_count"), Value::Null);
- }
- NotebookCellType::Markdown => {}
- }
- }
- cell
- }
- fn cell_kind(cell: &serde_json::Value) -> Option<NotebookCellType> {
- cell.get("cell_type")
- .and_then(serde_json::Value::as_str)
- .map(|kind| {
- if kind == "markdown" {
- NotebookCellType::Markdown
- } else {
- NotebookCellType::Code
- }
- })
- }
- const MAX_SLEEP_DURATION_MS: u64 = 300_000;
- #[allow(clippy::needless_pass_by_value)]
- fn execute_sleep(input: SleepInput) -> Result<SleepOutput, String> {
- if input.duration_ms > MAX_SLEEP_DURATION_MS {
- return Err(format!(
- "duration_ms {} exceeds maximum allowed sleep of {MAX_SLEEP_DURATION_MS}ms",
- input.duration_ms,
- ));
- }
- std::thread::sleep(Duration::from_millis(input.duration_ms));
- Ok(SleepOutput {
- duration_ms: input.duration_ms,
- message: format!("Slept for {}ms", input.duration_ms),
- })
- }
- fn execute_brief(input: BriefInput) -> Result<BriefOutput, String> {
- if input.message.trim().is_empty() {
- return Err(String::from("message must not be empty"));
- }
- let attachments = input
- .attachments
- .as_ref()
- .map(|paths| {
- paths
- .iter()
- .map(|path| resolve_attachment(path))
- .collect::<Result<Vec<_>, String>>()
- })
- .transpose()?;
- let message = match input.status {
- BriefStatus::Normal | BriefStatus::Proactive => input.message,
- };
- Ok(BriefOutput {
- message,
- attachments,
- sent_at: iso8601_timestamp(),
- })
- }
- fn resolve_attachment(path: &str) -> Result<ResolvedAttachment, String> {
- let resolved = std::fs::canonicalize(path).map_err(|error| error.to_string())?;
- let metadata = std::fs::metadata(&resolved).map_err(|error| error.to_string())?;
- Ok(ResolvedAttachment {
- path: resolved.display().to_string(),
- size: metadata.len(),
- is_image: is_image_path(&resolved),
- })
- }
- fn is_image_path(path: &Path) -> bool {
- matches!(
- path.extension()
- .and_then(|ext| ext.to_str())
- .map(str::to_ascii_lowercase)
- .as_deref(),
- Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg")
- )
- }
- fn execute_config(input: ConfigInput) -> Result<ConfigOutput, String> {
- let setting = input.setting.trim();
- if setting.is_empty() {
- return Err(String::from("setting must not be empty"));
- }
- let Some(spec) = supported_config_setting(setting) else {
- return Ok(ConfigOutput {
- success: false,
- operation: None,
- setting: None,
- value: None,
- previous_value: None,
- new_value: None,
- error: Some(format!("Unknown setting: \"{setting}\"")),
- });
- };
- let path = config_file_for_scope(spec.scope)?;
- let mut document = read_json_object(&path)?;
- if let Some(value) = input.value {
- let normalized = normalize_config_value(spec, value)?;
- let previous_value = get_nested_value(&document, spec.path).cloned();
- set_nested_value(&mut document, spec.path, normalized.clone());
- write_json_object(&path, &document)?;
- Ok(ConfigOutput {
- success: true,
- operation: Some(String::from("set")),
- setting: Some(setting.to_string()),
- value: Some(normalized.clone()),
- previous_value,
- new_value: Some(normalized),
- error: None,
- })
- } else {
- Ok(ConfigOutput {
- success: true,
- operation: Some(String::from("get")),
- setting: Some(setting.to_string()),
- value: get_nested_value(&document, spec.path).cloned(),
- previous_value: None,
- new_value: None,
- error: None,
- })
- }
- }
- const PERMISSION_DEFAULT_MODE_PATH: &[&str] = &["permissions", "defaultMode"];
- fn execute_enter_plan_mode(_input: EnterPlanModeInput) -> Result<PlanModeOutput, String> {
- let settings_path = config_file_for_scope(ConfigScope::Settings)?;
- let state_path = plan_mode_state_file()?;
- let mut document = read_json_object(&settings_path)?;
- let current_local_mode = get_nested_value(&document, PERMISSION_DEFAULT_MODE_PATH).cloned();
- let current_is_plan =
- matches!(current_local_mode.as_ref(), Some(Value::String(value)) if value == "plan");
- if let Some(state) = read_plan_mode_state(&state_path)? {
- if current_is_plan {
- return Ok(PlanModeOutput {
- success: true,
- operation: String::from("enter"),
- changed: false,
- active: true,
- managed: true,
- message: String::from("Plan mode override is already active for this worktree."),
- settings_path: settings_path.display().to_string(),
- state_path: state_path.display().to_string(),
- previous_local_mode: state.previous_local_mode,
- current_local_mode,
- });
- }
- clear_plan_mode_state(&state_path)?;
- }
- if current_is_plan {
- return Ok(PlanModeOutput {
- success: true,
- operation: String::from("enter"),
- changed: false,
- active: true,
- managed: false,
- message: String::from(
- "Worktree-local plan mode is already enabled outside EnterPlanMode; leaving it unchanged.",
- ),
- settings_path: settings_path.display().to_string(),
- state_path: state_path.display().to_string(),
- previous_local_mode: None,
- current_local_mode,
- });
- }
- let state = PlanModeState {
- had_local_override: current_local_mode.is_some(),
- previous_local_mode: current_local_mode.clone(),
- };
- write_plan_mode_state(&state_path, &state)?;
- set_nested_value(
- &mut document,
- PERMISSION_DEFAULT_MODE_PATH,
- Value::String(String::from("plan")),
- );
- write_json_object(&settings_path, &document)?;
- Ok(PlanModeOutput {
- success: true,
- operation: String::from("enter"),
- changed: true,
- active: true,
- managed: true,
- message: String::from("Enabled worktree-local plan mode override."),
- settings_path: settings_path.display().to_string(),
- state_path: state_path.display().to_string(),
- previous_local_mode: state.previous_local_mode,
- current_local_mode: get_nested_value(&document, PERMISSION_DEFAULT_MODE_PATH).cloned(),
- })
- }
- fn execute_exit_plan_mode(_input: ExitPlanModeInput) -> Result<PlanModeOutput, String> {
- let settings_path = config_file_for_scope(ConfigScope::Settings)?;
- let state_path = plan_mode_state_file()?;
- let mut document = read_json_object(&settings_path)?;
- let current_local_mode = get_nested_value(&document, PERMISSION_DEFAULT_MODE_PATH).cloned();
- let current_is_plan =
- matches!(current_local_mode.as_ref(), Some(Value::String(value)) if value == "plan");
- let Some(state) = read_plan_mode_state(&state_path)? else {
- return Ok(PlanModeOutput {
- success: true,
- operation: String::from("exit"),
- changed: false,
- active: current_is_plan,
- managed: false,
- message: String::from("No EnterPlanMode override is active for this worktree."),
- settings_path: settings_path.display().to_string(),
- state_path: state_path.display().to_string(),
- previous_local_mode: None,
- current_local_mode,
- });
- };
- if !current_is_plan {
- clear_plan_mode_state(&state_path)?;
- return Ok(PlanModeOutput {
- success: true,
- operation: String::from("exit"),
- changed: false,
- active: false,
- managed: false,
- message: String::from(
- "Cleared stale EnterPlanMode state because plan mode was already changed outside the tool.",
- ),
- settings_path: settings_path.display().to_string(),
- state_path: state_path.display().to_string(),
- previous_local_mode: state.previous_local_mode,
- current_local_mode,
- });
- }
- if state.had_local_override {
- if let Some(previous_local_mode) = state.previous_local_mode.clone() {
- set_nested_value(
- &mut document,
- PERMISSION_DEFAULT_MODE_PATH,
- previous_local_mode,
- );
- } else {
- remove_nested_value(&mut document, PERMISSION_DEFAULT_MODE_PATH);
- }
- } else {
- remove_nested_value(&mut document, PERMISSION_DEFAULT_MODE_PATH);
- }
- write_json_object(&settings_path, &document)?;
- clear_plan_mode_state(&state_path)?;
- Ok(PlanModeOutput {
- success: true,
- operation: String::from("exit"),
- changed: true,
- active: false,
- managed: false,
- message: String::from("Restored the prior worktree-local plan mode setting."),
- settings_path: settings_path.display().to_string(),
- state_path: state_path.display().to_string(),
- previous_local_mode: state.previous_local_mode,
- current_local_mode: get_nested_value(&document, PERMISSION_DEFAULT_MODE_PATH).cloned(),
- })
- }
- fn execute_structured_output(
- input: StructuredOutputInput,
- ) -> Result<StructuredOutputResult, String> {
- if input.0.is_empty() {
- return Err(String::from("structured output payload must not be empty"));
- }
- Ok(StructuredOutputResult {
- data: String::from("Structured output provided successfully"),
- structured_output: input.0,
- })
- }
- fn execute_repl(input: ReplInput) -> Result<ReplOutput, String> {
- if input.code.trim().is_empty() {
- return Err(String::from("code must not be empty"));
- }
- let runtime = resolve_repl_runtime(&input.language)?;
- let started = Instant::now();
- let mut process = Command::new(runtime.program);
- process
- .args(runtime.args)
- .arg(&input.code)
- .stdin(std::process::Stdio::null())
- .stdout(std::process::Stdio::piped())
- .stderr(std::process::Stdio::piped());
- let output = if let Some(timeout_ms) = input.timeout_ms {
- let mut child = process.spawn().map_err(|error| error.to_string())?;
- loop {
- if child
- .try_wait()
- .map_err(|error| error.to_string())?
- .is_some()
- {
- break child
- .wait_with_output()
- .map_err(|error| error.to_string())?;
- }
- if started.elapsed() >= Duration::from_millis(timeout_ms) {
- child.kill().map_err(|error| error.to_string())?;
- child
- .wait_with_output()
- .map_err(|error| error.to_string())?;
- return Err(format!(
- "REPL execution exceeded timeout of {timeout_ms} ms"
- ));
- }
- std::thread::sleep(Duration::from_millis(10));
- }
- } else {
- process
- .spawn()
- .map_err(|error| error.to_string())?
- .wait_with_output()
- .map_err(|error| error.to_string())?
- };
- Ok(ReplOutput {
- language: input.language,
- stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
- stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
- exit_code: output.status.code().unwrap_or(1),
- duration_ms: started.elapsed().as_millis(),
- })
- }
- struct ReplRuntime {
- program: &'static str,
- args: &'static [&'static str],
- }
- fn resolve_repl_runtime(language: &str) -> Result<ReplRuntime, String> {
- match language.trim().to_ascii_lowercase().as_str() {
- "python" | "py" => Ok(ReplRuntime {
- program: detect_first_command(&["python3", "python"])
- .ok_or_else(|| String::from("python runtime not found"))?,
- args: &["-c"],
- }),
- "javascript" | "js" | "node" => Ok(ReplRuntime {
- program: detect_first_command(&["node"])
- .ok_or_else(|| String::from("node runtime not found"))?,
- args: &["-e"],
- }),
- "sh" | "shell" | "bash" => Ok(ReplRuntime {
- program: detect_first_command(&["bash", "sh"])
- .ok_or_else(|| String::from("shell runtime not found"))?,
- args: &["-lc"],
- }),
- other => Err(format!("unsupported REPL language: {other}")),
- }
- }
- fn detect_first_command(commands: &[&'static str]) -> Option<&'static str> {
- commands
- .iter()
- .copied()
- .find(|command| command_exists(command))
- }
- #[derive(Clone, Copy)]
- enum ConfigScope {
- Global,
- Settings,
- }
- #[derive(Clone, Copy)]
- struct ConfigSettingSpec {
- scope: ConfigScope,
- kind: ConfigKind,
- path: &'static [&'static str],
- options: Option<&'static [&'static str]>,
- }
- #[derive(Clone, Copy)]
- enum ConfigKind {
- Boolean,
- String,
- }
- fn supported_config_setting(setting: &str) -> Option<ConfigSettingSpec> {
- Some(match setting {
- "theme" => ConfigSettingSpec {
- scope: ConfigScope::Global,
- kind: ConfigKind::String,
- path: &["theme"],
- options: None,
- },
- "editorMode" => ConfigSettingSpec {
- scope: ConfigScope::Global,
- kind: ConfigKind::String,
- path: &["editorMode"],
- options: Some(&["default", "vim", "emacs"]),
- },
- "verbose" => ConfigSettingSpec {
- scope: ConfigScope::Global,
- kind: ConfigKind::Boolean,
- path: &["verbose"],
- options: None,
- },
- "preferredNotifChannel" => ConfigSettingSpec {
- scope: ConfigScope::Global,
- kind: ConfigKind::String,
- path: &["preferredNotifChannel"],
- options: None,
- },
- "autoCompactEnabled" => ConfigSettingSpec {
- scope: ConfigScope::Global,
- kind: ConfigKind::Boolean,
- path: &["autoCompactEnabled"],
- options: None,
- },
- "autoMemoryEnabled" => ConfigSettingSpec {
- scope: ConfigScope::Settings,
- kind: ConfigKind::Boolean,
- path: &["autoMemoryEnabled"],
- options: None,
- },
- "autoDreamEnabled" => ConfigSettingSpec {
- scope: ConfigScope::Settings,
- kind: ConfigKind::Boolean,
- path: &["autoDreamEnabled"],
- options: None,
- },
- "fileCheckpointingEnabled" => ConfigSettingSpec {
- scope: ConfigScope::Global,
- kind: ConfigKind::Boolean,
- path: &["fileCheckpointingEnabled"],
- options: None,
- },
- "showTurnDuration" => ConfigSettingSpec {
- scope: ConfigScope::Global,
- kind: ConfigKind::Boolean,
- path: &["showTurnDuration"],
- options: None,
- },
- "terminalProgressBarEnabled" => ConfigSettingSpec {
- scope: ConfigScope::Global,
- kind: ConfigKind::Boolean,
- path: &["terminalProgressBarEnabled"],
- options: None,
- },
- "todoFeatureEnabled" => ConfigSettingSpec {
- scope: ConfigScope::Global,
- kind: ConfigKind::Boolean,
- path: &["todoFeatureEnabled"],
- options: None,
- },
- "model" => ConfigSettingSpec {
- scope: ConfigScope::Settings,
- kind: ConfigKind::String,
- path: &["model"],
- options: None,
- },
- "alwaysThinkingEnabled" => ConfigSettingSpec {
- scope: ConfigScope::Settings,
- kind: ConfigKind::Boolean,
- path: &["alwaysThinkingEnabled"],
- options: None,
- },
- "permissions.defaultMode" => ConfigSettingSpec {
- scope: ConfigScope::Settings,
- kind: ConfigKind::String,
- path: &["permissions", "defaultMode"],
- options: Some(&["default", "plan", "acceptEdits", "dontAsk", "auto"]),
- },
- "language" => ConfigSettingSpec {
- scope: ConfigScope::Settings,
- kind: ConfigKind::String,
- path: &["language"],
- options: None,
- },
- "teammateMode" => ConfigSettingSpec {
- scope: ConfigScope::Global,
- kind: ConfigKind::String,
- path: &["teammateMode"],
- options: Some(&["tmux", "in-process", "auto"]),
- },
- _ => return None,
- })
- }
- fn normalize_config_value(spec: ConfigSettingSpec, value: ConfigValue) -> Result<Value, String> {
- let normalized = match (spec.kind, value) {
- (ConfigKind::Boolean, ConfigValue::Bool(value)) => Value::Bool(value),
- (ConfigKind::Boolean, ConfigValue::String(value)) => {
- match value.trim().to_ascii_lowercase().as_str() {
- "true" => Value::Bool(true),
- "false" => Value::Bool(false),
- _ => return Err(String::from("setting requires true or false")),
- }
- }
- (ConfigKind::Boolean, ConfigValue::Number(_)) => {
- return Err(String::from("setting requires true or false"))
- }
- (ConfigKind::String, ConfigValue::String(value)) => Value::String(value),
- (ConfigKind::String, ConfigValue::Bool(value)) => Value::String(value.to_string()),
- (ConfigKind::String, ConfigValue::Number(value)) => json!(value),
- };
- if let Some(options) = spec.options {
- let Some(as_str) = normalized.as_str() else {
- return Err(String::from("setting requires a string value"));
- };
- if !options.iter().any(|option| option == &as_str) {
- return Err(format!(
- "Invalid value \"{as_str}\". Options: {}",
- options.join(", ")
- ));
- }
- }
- Ok(normalized)
- }
- fn config_file_for_scope(scope: ConfigScope) -> Result<PathBuf, String> {
- let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
- Ok(match scope {
- ConfigScope::Global => config_home_dir()?.join("settings.json"),
- ConfigScope::Settings => cwd.join(".claw").join("settings.local.json"),
- })
- }
- fn config_home_dir() -> Result<PathBuf, String> {
- if let Ok(path) = std::env::var("CLAW_CONFIG_HOME") {
- return Ok(PathBuf::from(path));
- }
- let home = std::env::var("HOME").map_err(|_| String::from("HOME is not set"))?;
- Ok(PathBuf::from(home).join(".claw"))
- }
- fn read_json_object(path: &Path) -> Result<serde_json::Map<String, Value>, String> {
- match std::fs::read_to_string(path) {
- Ok(contents) => {
- if contents.trim().is_empty() {
- return Ok(serde_json::Map::new());
- }
- serde_json::from_str::<Value>(&contents)
- .map_err(|error| error.to_string())?
- .as_object()
- .cloned()
- .ok_or_else(|| String::from("config file must contain a JSON object"))
- }
- Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(serde_json::Map::new()),
- Err(error) => Err(error.to_string()),
- }
- }
- fn write_json_object(path: &Path, value: &serde_json::Map<String, Value>) -> Result<(), String> {
- if let Some(parent) = path.parent() {
- std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
- }
- std::fs::write(
- path,
- serde_json::to_string_pretty(value).map_err(|error| error.to_string())?,
- )
- .map_err(|error| error.to_string())
- }
- fn get_nested_value<'a>(
- value: &'a serde_json::Map<String, Value>,
- path: &[&str],
- ) -> Option<&'a Value> {
- let (first, rest) = path.split_first()?;
- let mut current = value.get(*first)?;
- for key in rest {
- current = current.as_object()?.get(*key)?;
- }
- Some(current)
- }
- fn set_nested_value(root: &mut serde_json::Map<String, Value>, path: &[&str], new_value: Value) {
- let (first, rest) = path.split_first().expect("config path must not be empty");
- if rest.is_empty() {
- root.insert((*first).to_string(), new_value);
- return;
- }
- let entry = root
- .entry((*first).to_string())
- .or_insert_with(|| Value::Object(serde_json::Map::new()));
- if !entry.is_object() {
- *entry = Value::Object(serde_json::Map::new());
- }
- let map = entry.as_object_mut().expect("object inserted");
- set_nested_value(map, rest, new_value);
- }
- fn remove_nested_value(root: &mut serde_json::Map<String, Value>, path: &[&str]) -> bool {
- let Some((first, rest)) = path.split_first() else {
- return false;
- };
- if rest.is_empty() {
- return root.remove(*first).is_some();
- }
- let mut should_remove_parent = false;
- let removed = root.get_mut(*first).is_some_and(|entry| {
- entry.as_object_mut().is_some_and(|map| {
- let removed = remove_nested_value(map, rest);
- should_remove_parent = removed && map.is_empty();
- removed
- })
- });
- if should_remove_parent {
- root.remove(*first);
- }
- removed
- }
- fn plan_mode_state_file() -> Result<PathBuf, String> {
- Ok(config_file_for_scope(ConfigScope::Settings)?
- .parent()
- .ok_or_else(|| String::from("settings.local.json has no parent directory"))?
- .join("tool-state")
- .join("plan-mode.json"))
- }
- fn read_plan_mode_state(path: &Path) -> Result<Option<PlanModeState>, String> {
- match std::fs::read_to_string(path) {
- Ok(contents) => {
- if contents.trim().is_empty() {
- return Ok(None);
- }
- serde_json::from_str(&contents)
- .map(Some)
- .map_err(|error| error.to_string())
- }
- Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
- Err(error) => Err(error.to_string()),
- }
- }
- fn write_plan_mode_state(path: &Path, state: &PlanModeState) -> Result<(), String> {
- if let Some(parent) = path.parent() {
- std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
- }
- std::fs::write(
- path,
- serde_json::to_string_pretty(state).map_err(|error| error.to_string())?,
- )
- .map_err(|error| error.to_string())
- }
- fn clear_plan_mode_state(path: &Path) -> Result<(), String> {
- match std::fs::remove_file(path) {
- Ok(()) => Ok(()),
- Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
- Err(error) => Err(error.to_string()),
- }
- }
- fn iso8601_timestamp() -> String {
- if let Ok(output) = Command::new("date")
- .args(["-u", "+%Y-%m-%dT%H:%M:%SZ"])
- .output()
- {
- if output.status.success() {
- return String::from_utf8_lossy(&output.stdout).trim().to_string();
- }
- }
- iso8601_now()
- }
- #[allow(clippy::needless_pass_by_value)]
- fn execute_powershell(input: PowerShellInput) -> std::io::Result<runtime::BashCommandOutput> {
- let _ = &input.description;
- let shell = detect_powershell_shell()?;
- execute_shell_command(
- shell,
- &input.command,
- input.timeout,
- input.run_in_background,
- )
- }
- fn detect_powershell_shell() -> std::io::Result<&'static str> {
- if command_exists("pwsh") {
- Ok("pwsh")
- } else if command_exists("powershell") {
- Ok("powershell")
- } else {
- Err(std::io::Error::new(
- std::io::ErrorKind::NotFound,
- "PowerShell executable not found (expected `pwsh` or `powershell` in PATH)",
- ))
- }
- }
- fn command_exists(command: &str) -> bool {
- std::process::Command::new("sh")
- .arg("-lc")
- .arg(format!("command -v {command} >/dev/null 2>&1"))
- .status()
- .map(|status| status.success())
- .unwrap_or(false)
- }
- #[allow(clippy::too_many_lines)]
- fn execute_shell_command(
- shell: &str,
- command: &str,
- timeout: Option<u64>,
- run_in_background: Option<bool>,
- ) -> std::io::Result<runtime::BashCommandOutput> {
- if run_in_background.unwrap_or(false) {
- let child = std::process::Command::new(shell)
- .arg("-NoProfile")
- .arg("-NonInteractive")
- .arg("-Command")
- .arg(command)
- .stdin(std::process::Stdio::null())
- .stdout(std::process::Stdio::null())
- .stderr(std::process::Stdio::null())
- .spawn()?;
- return Ok(runtime::BashCommandOutput {
- stdout: String::new(),
- stderr: String::new(),
- raw_output_path: None,
- interrupted: false,
- is_image: None,
- background_task_id: Some(child.id().to_string()),
- backgrounded_by_user: Some(true),
- assistant_auto_backgrounded: Some(false),
- dangerously_disable_sandbox: None,
- return_code_interpretation: None,
- no_output_expected: Some(true),
- structured_content: None,
- persisted_output_path: None,
- persisted_output_size: None,
- sandbox_status: None,
- });
- }
- let mut process = std::process::Command::new(shell);
- process
- .arg("-NoProfile")
- .arg("-NonInteractive")
- .arg("-Command")
- .arg(command);
- process
- .stdout(std::process::Stdio::piped())
- .stderr(std::process::Stdio::piped());
- if let Some(timeout_ms) = timeout {
- let mut child = process.spawn()?;
- let started = Instant::now();
- loop {
- if let Some(status) = child.try_wait()? {
- let output = child.wait_with_output()?;
- return Ok(runtime::BashCommandOutput {
- stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
- stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
- raw_output_path: None,
- interrupted: false,
- is_image: None,
- background_task_id: None,
- backgrounded_by_user: None,
- assistant_auto_backgrounded: None,
- dangerously_disable_sandbox: None,
- return_code_interpretation: status
- .code()
- .filter(|code| *code != 0)
- .map(|code| format!("exit_code:{code}")),
- no_output_expected: Some(output.stdout.is_empty() && output.stderr.is_empty()),
- structured_content: None,
- persisted_output_path: None,
- persisted_output_size: None,
- sandbox_status: None,
- });
- }
- if started.elapsed() >= Duration::from_millis(timeout_ms) {
- let _ = child.kill();
- let output = child.wait_with_output()?;
- let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
- let stderr = if stderr.trim().is_empty() {
- format!("Command exceeded timeout of {timeout_ms} ms")
- } else {
- format!(
- "{}
- Command exceeded timeout of {timeout_ms} ms",
- stderr.trim_end()
- )
- };
- return Ok(runtime::BashCommandOutput {
- stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
- stderr,
- raw_output_path: None,
- interrupted: true,
- is_image: None,
- background_task_id: None,
- backgrounded_by_user: None,
- assistant_auto_backgrounded: None,
- dangerously_disable_sandbox: None,
- return_code_interpretation: Some(String::from("timeout")),
- no_output_expected: Some(false),
- structured_content: None,
- persisted_output_path: None,
- persisted_output_size: None,
- sandbox_status: None,
- });
- }
- std::thread::sleep(Duration::from_millis(10));
- }
- }
- let output = process.output()?;
- Ok(runtime::BashCommandOutput {
- stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
- stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
- raw_output_path: None,
- interrupted: false,
- is_image: None,
- background_task_id: None,
- backgrounded_by_user: None,
- assistant_auto_backgrounded: None,
- dangerously_disable_sandbox: None,
- return_code_interpretation: output
- .status
- .code()
- .filter(|code| *code != 0)
- .map(|code| format!("exit_code:{code}")),
- no_output_expected: Some(output.stdout.is_empty() && output.stderr.is_empty()),
- structured_content: None,
- persisted_output_path: None,
- persisted_output_size: None,
- sandbox_status: None,
- })
- }
- fn resolve_cell_index(
- cells: &[serde_json::Value],
- cell_id: Option<&str>,
- edit_mode: NotebookEditMode,
- ) -> Result<usize, String> {
- if cells.is_empty()
- && matches!(
- edit_mode,
- NotebookEditMode::Replace | NotebookEditMode::Delete
- )
- {
- return Err(String::from("Notebook has no cells to edit"));
- }
- if let Some(cell_id) = cell_id {
- cells
- .iter()
- .position(|cell| cell.get("id").and_then(serde_json::Value::as_str) == Some(cell_id))
- .ok_or_else(|| format!("Cell id not found: {cell_id}"))
- } else {
- Ok(cells.len().saturating_sub(1))
- }
- }
- fn source_lines(source: &str) -> Vec<serde_json::Value> {
- if source.is_empty() {
- return vec![serde_json::Value::String(String::new())];
- }
- source
- .split_inclusive('\n')
- .map(|line| serde_json::Value::String(line.to_string()))
- .collect()
- }
- fn format_notebook_edit_mode(mode: NotebookEditMode) -> String {
- match mode {
- NotebookEditMode::Replace => String::from("replace"),
- NotebookEditMode::Insert => String::from("insert"),
- NotebookEditMode::Delete => String::from("delete"),
- }
- }
- fn make_cell_id(index: usize) -> String {
- format!("cell-{}", index + 1)
- }
- fn parse_skill_description(contents: &str) -> Option<String> {
- for line in contents.lines() {
- if let Some(value) = line.strip_prefix("description:") {
- let trimmed = value.trim();
- if !trimmed.is_empty() {
- return Some(trimmed.to_string());
- }
- }
- }
- None
- }
- #[cfg(test)]
- mod tests {
- use std::collections::BTreeMap;
- use std::collections::BTreeSet;
- use std::fs;
- use std::io::{Read, Write};
- use std::net::{SocketAddr, TcpListener};
- use std::path::PathBuf;
- use std::sync::{Arc, Mutex, OnceLock};
- use std::thread;
- use std::time::Duration;
- use super::{
- agent_permission_policy, allowed_tools_for_subagent, execute_agent_with_spawn,
- execute_tool, final_assistant_text, mvp_tool_specs, permission_mode_from_plugin,
- persist_agent_terminal_state, push_output_block, AgentInput, AgentJob,
- SubagentToolExecutor,
- };
- use api::OutputContentBlock;
- use runtime::{ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session};
- use serde_json::json;
- fn env_lock() -> &'static Mutex<()> {
- static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
- LOCK.get_or_init(|| Mutex::new(()))
- }
- fn temp_path(name: &str) -> PathBuf {
- let unique = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .expect("time")
- .as_nanos();
- std::env::temp_dir().join(format!("clawd-tools-{unique}-{name}"))
- }
- #[test]
- fn exposes_mvp_tools() {
- let names = mvp_tool_specs()
- .into_iter()
- .map(|spec| spec.name)
- .collect::<Vec<_>>();
- assert!(names.contains(&"bash"));
- assert!(names.contains(&"read_file"));
- assert!(names.contains(&"WebFetch"));
- assert!(names.contains(&"WebSearch"));
- assert!(names.contains(&"TodoWrite"));
- assert!(names.contains(&"Skill"));
- assert!(names.contains(&"Agent"));
- assert!(names.contains(&"ToolSearch"));
- assert!(names.contains(&"NotebookEdit"));
- assert!(names.contains(&"Sleep"));
- assert!(names.contains(&"SendUserMessage"));
- assert!(names.contains(&"Config"));
- assert!(names.contains(&"EnterPlanMode"));
- assert!(names.contains(&"ExitPlanMode"));
- assert!(names.contains(&"StructuredOutput"));
- assert!(names.contains(&"REPL"));
- assert!(names.contains(&"PowerShell"));
- }
- #[test]
- fn rejects_unknown_tool_names() {
- let error = execute_tool("nope", &json!({})).expect_err("tool should be rejected");
- assert!(error.contains("unsupported tool"));
- }
- #[test]
- fn permission_mode_from_plugin_rejects_invalid_inputs() {
- let unknown_permission = permission_mode_from_plugin("admin")
- .expect_err("unknown plugin permission should fail");
- assert!(unknown_permission.contains("unsupported plugin permission: admin"));
- let empty_permission =
- permission_mode_from_plugin("").expect_err("empty plugin permission should fail");
- assert!(empty_permission.contains("unsupported plugin permission: "));
- }
- #[test]
- fn web_fetch_returns_prompt_aware_summary() {
- let server = TestServer::spawn(Arc::new(|request_line: &str| {
- assert!(request_line.starts_with("GET /page "));
- HttpResponse::html(
- 200,
- "OK",
- "<html><head><title>Ignored</title></head><body><h1>Test Page</h1><p>Hello <b>world</b> from local server.</p></body></html>",
- )
- }));
- let result = execute_tool(
- "WebFetch",
- &json!({
- "url": format!("http://{}/page", server.addr()),
- "prompt": "Summarize this page"
- }),
- )
- .expect("WebFetch should succeed");
- let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
- assert_eq!(output["code"], 200);
- let summary = output["result"].as_str().expect("result string");
- assert!(summary.contains("Fetched"));
- assert!(summary.contains("Test Page"));
- assert!(summary.contains("Hello world from local server"));
- let titled = execute_tool(
- "WebFetch",
- &json!({
- "url": format!("http://{}/page", server.addr()),
- "prompt": "What is the page title?"
- }),
- )
- .expect("WebFetch title query should succeed");
- let titled_output: serde_json::Value = serde_json::from_str(&titled).expect("valid json");
- let titled_summary = titled_output["result"].as_str().expect("result string");
- assert!(titled_summary.contains("Title: Ignored"));
- }
- #[test]
- fn web_fetch_supports_plain_text_and_rejects_invalid_url() {
- let server = TestServer::spawn(Arc::new(|request_line: &str| {
- assert!(request_line.starts_with("GET /plain "));
- HttpResponse::text(200, "OK", "plain text response")
- }));
- let result = execute_tool(
- "WebFetch",
- &json!({
- "url": format!("http://{}/plain", server.addr()),
- "prompt": "Show me the content"
- }),
- )
- .expect("WebFetch should succeed for text content");
- let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
- assert_eq!(output["url"], format!("http://{}/plain", server.addr()));
- assert!(output["result"]
- .as_str()
- .expect("result")
- .contains("plain text response"));
- let error = execute_tool(
- "WebFetch",
- &json!({
- "url": "not a url",
- "prompt": "Summarize"
- }),
- )
- .expect_err("invalid URL should fail");
- assert!(error.contains("relative URL without a base") || error.contains("invalid"));
- }
- #[test]
- fn web_search_extracts_and_filters_results() {
- let server = TestServer::spawn(Arc::new(|request_line: &str| {
- assert!(request_line.contains("GET /search?q=rust+web+search "));
- HttpResponse::html(
- 200,
- "OK",
- r#"
- <html><body>
- <a class="result__a" href="https://docs.rs/reqwest">Reqwest docs</a>
- <a class="result__a" href="https://example.com/blocked">Blocked result</a>
- </body></html>
- "#,
- )
- }));
- std::env::set_var(
- "CLAWD_WEB_SEARCH_BASE_URL",
- format!("http://{}/search", server.addr()),
- );
- let result = execute_tool(
- "WebSearch",
- &json!({
- "query": "rust web search",
- "allowed_domains": ["https://DOCS.rs/"],
- "blocked_domains": ["HTTPS://EXAMPLE.COM"]
- }),
- )
- .expect("WebSearch should succeed");
- std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
- let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
- assert_eq!(output["query"], "rust web search");
- let results = output["results"].as_array().expect("results array");
- let search_result = results
- .iter()
- .find(|item| item.get("content").is_some())
- .expect("search result block present");
- let content = search_result["content"].as_array().expect("content array");
- assert_eq!(content.len(), 1);
- assert_eq!(content[0]["title"], "Reqwest docs");
- assert_eq!(content[0]["url"], "https://docs.rs/reqwest");
- }
- #[test]
- fn web_search_handles_generic_links_and_invalid_base_url() {
- let _guard = env_lock()
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- let server = TestServer::spawn(Arc::new(|request_line: &str| {
- assert!(request_line.contains("GET /fallback?q=generic+links "));
- HttpResponse::html(
- 200,
- "OK",
- r#"
- <html><body>
- <a href="https://example.com/one">Example One</a>
- <a href="https://example.com/one">Duplicate Example One</a>
- <a href="https://docs.rs/tokio">Tokio Docs</a>
- </body></html>
- "#,
- )
- }));
- std::env::set_var(
- "CLAWD_WEB_SEARCH_BASE_URL",
- format!("http://{}/fallback", server.addr()),
- );
- let result = execute_tool(
- "WebSearch",
- &json!({
- "query": "generic links"
- }),
- )
- .expect("WebSearch fallback parsing should succeed");
- std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
- let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
- let results = output["results"].as_array().expect("results array");
- let search_result = results
- .iter()
- .find(|item| item.get("content").is_some())
- .expect("search result block present");
- let content = search_result["content"].as_array().expect("content array");
- assert_eq!(content.len(), 2);
- assert_eq!(content[0]["url"], "https://example.com/one");
- assert_eq!(content[1]["url"], "https://docs.rs/tokio");
- std::env::set_var("CLAWD_WEB_SEARCH_BASE_URL", "://bad-base-url");
- let error = execute_tool("WebSearch", &json!({ "query": "generic links" }))
- .expect_err("invalid base URL should fail");
- std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
- assert!(error.contains("relative URL without a base") || error.contains("empty host"));
- }
- #[test]
- fn pending_tools_preserve_multiple_streaming_tool_calls_by_index() {
- let mut events = Vec::new();
- let mut pending_tools = BTreeMap::new();
- push_output_block(
- OutputContentBlock::ToolUse {
- id: "tool-1".to_string(),
- name: "read_file".to_string(),
- input: json!({}),
- },
- 1,
- &mut events,
- &mut pending_tools,
- true,
- );
- push_output_block(
- OutputContentBlock::ToolUse {
- id: "tool-2".to_string(),
- name: "grep_search".to_string(),
- input: json!({}),
- },
- 2,
- &mut events,
- &mut pending_tools,
- true,
- );
- pending_tools
- .get_mut(&1)
- .expect("first tool pending")
- .2
- .push_str("{\"path\":\"src/main.rs\"}");
- pending_tools
- .get_mut(&2)
- .expect("second tool pending")
- .2
- .push_str("{\"pattern\":\"TODO\"}");
- assert_eq!(
- pending_tools.remove(&1),
- Some((
- "tool-1".to_string(),
- "read_file".to_string(),
- "{\"path\":\"src/main.rs\"}".to_string(),
- ))
- );
- assert_eq!(
- pending_tools.remove(&2),
- Some((
- "tool-2".to_string(),
- "grep_search".to_string(),
- "{\"pattern\":\"TODO\"}".to_string(),
- ))
- );
- }
- #[test]
- fn todo_write_persists_and_returns_previous_state() {
- let _guard = env_lock()
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- let path = temp_path("todos.json");
- std::env::set_var("CLAWD_TODO_STORE", &path);
- let first = execute_tool(
- "TodoWrite",
- &json!({
- "todos": [
- {"content": "Add tool", "activeForm": "Adding tool", "status": "in_progress"},
- {"content": "Run tests", "activeForm": "Running tests", "status": "pending"}
- ]
- }),
- )
- .expect("TodoWrite should succeed");
- let first_output: serde_json::Value = serde_json::from_str(&first).expect("valid json");
- assert_eq!(first_output["oldTodos"].as_array().expect("array").len(), 0);
- let second = execute_tool(
- "TodoWrite",
- &json!({
- "todos": [
- {"content": "Add tool", "activeForm": "Adding tool", "status": "completed"},
- {"content": "Run tests", "activeForm": "Running tests", "status": "completed"},
- {"content": "Verify", "activeForm": "Verifying", "status": "completed"}
- ]
- }),
- )
- .expect("TodoWrite should succeed");
- std::env::remove_var("CLAWD_TODO_STORE");
- let _ = std::fs::remove_file(path);
- let second_output: serde_json::Value = serde_json::from_str(&second).expect("valid json");
- assert_eq!(
- second_output["oldTodos"].as_array().expect("array").len(),
- 2
- );
- assert_eq!(
- second_output["newTodos"].as_array().expect("array").len(),
- 3
- );
- assert!(second_output["verificationNudgeNeeded"].is_null());
- }
- #[test]
- fn todo_write_rejects_invalid_payloads_and_sets_verification_nudge() {
- let _guard = env_lock()
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- let path = temp_path("todos-errors.json");
- std::env::set_var("CLAWD_TODO_STORE", &path);
- let empty = execute_tool("TodoWrite", &json!({ "todos": [] }))
- .expect_err("empty todos should fail");
- assert!(empty.contains("todos must not be empty"));
- // Multiple in_progress items are now allowed for parallel workflows
- let _multi_active = execute_tool(
- "TodoWrite",
- &json!({
- "todos": [
- {"content": "One", "activeForm": "Doing one", "status": "in_progress"},
- {"content": "Two", "activeForm": "Doing two", "status": "in_progress"}
- ]
- }),
- )
- .expect("multiple in-progress todos should succeed");
- let blank_content = execute_tool(
- "TodoWrite",
- &json!({
- "todos": [
- {"content": " ", "activeForm": "Doing it", "status": "pending"}
- ]
- }),
- )
- .expect_err("blank content should fail");
- assert!(blank_content.contains("todo content must not be empty"));
- let nudge = execute_tool(
- "TodoWrite",
- &json!({
- "todos": [
- {"content": "Write tests", "activeForm": "Writing tests", "status": "completed"},
- {"content": "Fix errors", "activeForm": "Fixing errors", "status": "completed"},
- {"content": "Ship branch", "activeForm": "Shipping branch", "status": "completed"}
- ]
- }),
- )
- .expect("completed todos should succeed");
- std::env::remove_var("CLAWD_TODO_STORE");
- let _ = fs::remove_file(path);
- let output: serde_json::Value = serde_json::from_str(&nudge).expect("valid json");
- assert_eq!(output["verificationNudgeNeeded"], true);
- }
- #[test]
- fn skill_loads_local_skill_prompt() {
- let _guard = env_lock().lock().expect("env lock should acquire");
- let home = temp_path("skills-home");
- let skill_dir = home.join(".agents").join("skills").join("help");
- fs::create_dir_all(&skill_dir).expect("skill dir should exist");
- fs::write(
- skill_dir.join("SKILL.md"),
- "# help\n\nGuide on using oh-my-codex plugin\n",
- )
- .expect("skill file should exist");
- let original_home = std::env::var("HOME").ok();
- std::env::set_var("HOME", &home);
- let result = execute_tool(
- "Skill",
- &json!({
- "skill": "help",
- "args": "overview"
- }),
- )
- .expect("Skill should succeed");
- let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
- assert_eq!(output["skill"], "help");
- assert!(output["path"]
- .as_str()
- .expect("path")
- .ends_with("/help/SKILL.md"));
- assert!(output["prompt"]
- .as_str()
- .expect("prompt")
- .contains("Guide on using oh-my-codex plugin"));
- let dollar_result = execute_tool(
- "Skill",
- &json!({
- "skill": "$help"
- }),
- )
- .expect("Skill should accept $skill invocation form");
- let dollar_output: serde_json::Value =
- serde_json::from_str(&dollar_result).expect("valid json");
- assert_eq!(dollar_output["skill"], "$help");
- assert!(dollar_output["path"]
- .as_str()
- .expect("path")
- .ends_with("/help/SKILL.md"));
- if let Some(home) = original_home {
- std::env::set_var("HOME", home);
- } else {
- std::env::remove_var("HOME");
- }
- fs::remove_dir_all(home).expect("temp home should clean up");
- }
- #[test]
- fn tool_search_supports_keyword_and_select_queries() {
- let keyword = execute_tool(
- "ToolSearch",
- &json!({"query": "web current", "max_results": 3}),
- )
- .expect("ToolSearch should succeed");
- let keyword_output: serde_json::Value = serde_json::from_str(&keyword).expect("valid json");
- let matches = keyword_output["matches"].as_array().expect("matches");
- assert!(matches.iter().any(|value| value == "WebSearch"));
- let selected = execute_tool("ToolSearch", &json!({"query": "select:Agent,Skill"}))
- .expect("ToolSearch should succeed");
- let selected_output: serde_json::Value =
- serde_json::from_str(&selected).expect("valid json");
- assert_eq!(selected_output["matches"][0], "Agent");
- assert_eq!(selected_output["matches"][1], "Skill");
- let aliased = execute_tool("ToolSearch", &json!({"query": "AgentTool"}))
- .expect("ToolSearch should support tool aliases");
- let aliased_output: serde_json::Value = serde_json::from_str(&aliased).expect("valid json");
- assert_eq!(aliased_output["matches"][0], "Agent");
- assert_eq!(aliased_output["normalized_query"], "agent");
- let selected_with_alias =
- execute_tool("ToolSearch", &json!({"query": "select:AgentTool,Skill"}))
- .expect("ToolSearch alias select should succeed");
- let selected_with_alias_output: serde_json::Value =
- serde_json::from_str(&selected_with_alias).expect("valid json");
- assert_eq!(selected_with_alias_output["matches"][0], "Agent");
- assert_eq!(selected_with_alias_output["matches"][1], "Skill");
- }
- #[test]
- fn agent_persists_handoff_metadata() {
- let _guard = env_lock()
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- let dir = temp_path("agent-store");
- std::env::set_var("CLAWD_AGENT_STORE", &dir);
- let captured = Arc::new(Mutex::new(None::<AgentJob>));
- let captured_for_spawn = Arc::clone(&captured);
- let manifest = execute_agent_with_spawn(
- AgentInput {
- description: "Audit the branch".to_string(),
- prompt: "Check tests and outstanding work.".to_string(),
- subagent_type: Some("Explore".to_string()),
- name: Some("ship-audit".to_string()),
- model: None,
- },
- move |job| {
- *captured_for_spawn
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(job);
- Ok(())
- },
- )
- .expect("Agent should succeed");
- std::env::remove_var("CLAWD_AGENT_STORE");
- assert_eq!(manifest.name, "ship-audit");
- assert_eq!(manifest.subagent_type.as_deref(), Some("Explore"));
- assert_eq!(manifest.status, "running");
- assert!(!manifest.created_at.is_empty());
- assert!(manifest.started_at.is_some());
- assert!(manifest.completed_at.is_none());
- let contents = std::fs::read_to_string(&manifest.output_file).expect("agent file exists");
- let manifest_contents =
- std::fs::read_to_string(&manifest.manifest_file).expect("manifest file exists");
- assert!(contents.contains("Audit the branch"));
- assert!(contents.contains("Check tests and outstanding work."));
- assert!(manifest_contents.contains("\"subagentType\": \"Explore\""));
- assert!(manifest_contents.contains("\"status\": \"running\""));
- let captured_job = captured
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .clone()
- .expect("spawn job should be captured");
- assert_eq!(captured_job.prompt, "Check tests and outstanding work.");
- assert!(captured_job.allowed_tools.contains("read_file"));
- assert!(!captured_job.allowed_tools.contains("Agent"));
- let normalized = execute_tool(
- "Agent",
- &json!({
- "description": "Verify the branch",
- "prompt": "Check tests.",
- "subagent_type": "explorer"
- }),
- )
- .expect("Agent should normalize built-in aliases");
- let normalized_output: serde_json::Value =
- serde_json::from_str(&normalized).expect("valid json");
- assert_eq!(normalized_output["subagentType"], "Explore");
- let named = execute_tool(
- "Agent",
- &json!({
- "description": "Review the branch",
- "prompt": "Inspect diff.",
- "name": "Ship Audit!!!"
- }),
- )
- .expect("Agent should normalize explicit names");
- let named_output: serde_json::Value = serde_json::from_str(&named).expect("valid json");
- assert_eq!(named_output["name"], "ship-audit");
- let _ = std::fs::remove_dir_all(dir);
- }
- #[test]
- fn agent_fake_runner_can_persist_completion_and_failure() {
- let _guard = env_lock()
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- let dir = temp_path("agent-runner");
- std::env::set_var("CLAWD_AGENT_STORE", &dir);
- let completed = execute_agent_with_spawn(
- AgentInput {
- description: "Complete the task".to_string(),
- prompt: "Do the work".to_string(),
- subagent_type: Some("Explore".to_string()),
- name: Some("complete-task".to_string()),
- model: Some("claude-sonnet-4-6".to_string()),
- },
- |job| {
- persist_agent_terminal_state(
- &job.manifest,
- "completed",
- Some("Finished successfully"),
- None,
- )
- },
- )
- .expect("completed agent should succeed");
- let completed_manifest = std::fs::read_to_string(&completed.manifest_file)
- .expect("completed manifest should exist");
- let completed_output =
- std::fs::read_to_string(&completed.output_file).expect("completed output should exist");
- assert!(completed_manifest.contains("\"status\": \"completed\""));
- assert!(completed_output.contains("Finished successfully"));
- let failed = execute_agent_with_spawn(
- AgentInput {
- description: "Fail the task".to_string(),
- prompt: "Do the failing work".to_string(),
- subagent_type: Some("Verification".to_string()),
- name: Some("fail-task".to_string()),
- model: None,
- },
- |job| {
- persist_agent_terminal_state(
- &job.manifest,
- "failed",
- None,
- Some(String::from("simulated failure")),
- )
- },
- )
- .expect("failed agent should still spawn");
- let failed_manifest =
- std::fs::read_to_string(&failed.manifest_file).expect("failed manifest should exist");
- let failed_output =
- std::fs::read_to_string(&failed.output_file).expect("failed output should exist");
- assert!(failed_manifest.contains("\"status\": \"failed\""));
- assert!(failed_manifest.contains("simulated failure"));
- assert!(failed_output.contains("simulated failure"));
- let spawn_error = execute_agent_with_spawn(
- AgentInput {
- description: "Spawn error task".to_string(),
- prompt: "Never starts".to_string(),
- subagent_type: None,
- name: Some("spawn-error".to_string()),
- model: None,
- },
- |_| Err(String::from("thread creation failed")),
- )
- .expect_err("spawn errors should surface");
- assert!(spawn_error.contains("failed to spawn sub-agent"));
- let spawn_error_manifest = std::fs::read_dir(&dir)
- .expect("agent dir should exist")
- .filter_map(Result::ok)
- .map(|entry| entry.path())
- .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json"))
- .find_map(|path| {
- let contents = std::fs::read_to_string(&path).ok()?;
- contents
- .contains("\"name\": \"spawn-error\"")
- .then_some(contents)
- })
- .expect("failed manifest should still be written");
- assert!(spawn_error_manifest.contains("\"status\": \"failed\""));
- assert!(spawn_error_manifest.contains("thread creation failed"));
- std::env::remove_var("CLAWD_AGENT_STORE");
- let _ = std::fs::remove_dir_all(dir);
- }
- #[test]
- fn agent_tool_subset_mapping_is_expected() {
- let general = allowed_tools_for_subagent("general-purpose");
- assert!(general.contains("bash"));
- assert!(general.contains("write_file"));
- assert!(!general.contains("Agent"));
- let explore = allowed_tools_for_subagent("Explore");
- assert!(explore.contains("read_file"));
- assert!(explore.contains("grep_search"));
- assert!(!explore.contains("bash"));
- let plan = allowed_tools_for_subagent("Plan");
- assert!(plan.contains("TodoWrite"));
- assert!(plan.contains("StructuredOutput"));
- assert!(!plan.contains("Agent"));
- let verification = allowed_tools_for_subagent("Verification");
- assert!(verification.contains("bash"));
- assert!(verification.contains("PowerShell"));
- assert!(!verification.contains("write_file"));
- }
- #[derive(Debug)]
- struct MockSubagentApiClient {
- calls: usize,
- input_path: String,
- }
- impl runtime::ApiClient for MockSubagentApiClient {
- fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
- self.calls += 1;
- match self.calls {
- 1 => {
- assert_eq!(request.messages.len(), 1);
- Ok(vec![
- AssistantEvent::ToolUse {
- id: "tool-1".to_string(),
- name: "read_file".to_string(),
- input: json!({ "path": self.input_path }).to_string(),
- },
- AssistantEvent::MessageStop,
- ])
- }
- 2 => {
- assert!(request.messages.len() >= 3);
- Ok(vec![
- AssistantEvent::TextDelta("Scope: completed mock review".to_string()),
- AssistantEvent::MessageStop,
- ])
- }
- _ => panic!("unexpected mock stream call"),
- }
- }
- }
- #[test]
- fn subagent_runtime_executes_tool_loop_with_isolated_session() {
- let _guard = env_lock()
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- let path = temp_path("subagent-input.txt");
- std::fs::write(&path, "hello from child").expect("write input file");
- let mut runtime = ConversationRuntime::new(
- Session::new(),
- MockSubagentApiClient {
- calls: 0,
- input_path: path.display().to_string(),
- },
- SubagentToolExecutor::new(BTreeSet::from([String::from("read_file")])),
- agent_permission_policy(),
- vec![String::from("system prompt")],
- );
- let summary = runtime
- .run_turn("Inspect the delegated file", None)
- .expect("subagent loop should succeed");
- assert_eq!(
- final_assistant_text(&summary),
- "Scope: completed mock review"
- );
- assert!(runtime
- .session()
- .messages
- .iter()
- .flat_map(|message| message.blocks.iter())
- .any(|block| matches!(
- block,
- runtime::ContentBlock::ToolResult { output, .. }
- if output.contains("hello from child")
- )));
- let _ = std::fs::remove_file(path);
- }
- #[test]
- fn agent_rejects_blank_required_fields() {
- let missing_description = execute_tool(
- "Agent",
- &json!({
- "description": " ",
- "prompt": "Inspect"
- }),
- )
- .expect_err("blank description should fail");
- assert!(missing_description.contains("description must not be empty"));
- let missing_prompt = execute_tool(
- "Agent",
- &json!({
- "description": "Inspect branch",
- "prompt": " "
- }),
- )
- .expect_err("blank prompt should fail");
- assert!(missing_prompt.contains("prompt must not be empty"));
- }
- #[test]
- fn notebook_edit_replaces_inserts_and_deletes_cells() {
- let path = temp_path("notebook.ipynb");
- std::fs::write(
- &path,
- r#"{
- "cells": [
- {"cell_type": "code", "id": "cell-a", "metadata": {}, "source": ["print(1)\n"], "outputs": [], "execution_count": null}
- ],
- "metadata": {"kernelspec": {"language": "python"}},
- "nbformat": 4,
- "nbformat_minor": 5
- }"#,
- )
- .expect("write notebook");
- let replaced = execute_tool(
- "NotebookEdit",
- &json!({
- "notebook_path": path.display().to_string(),
- "cell_id": "cell-a",
- "new_source": "print(2)\n",
- "edit_mode": "replace"
- }),
- )
- .expect("NotebookEdit replace should succeed");
- let replaced_output: serde_json::Value = serde_json::from_str(&replaced).expect("json");
- assert_eq!(replaced_output["cell_id"], "cell-a");
- assert_eq!(replaced_output["cell_type"], "code");
- let inserted = execute_tool(
- "NotebookEdit",
- &json!({
- "notebook_path": path.display().to_string(),
- "cell_id": "cell-a",
- "new_source": "# heading\n",
- "cell_type": "markdown",
- "edit_mode": "insert"
- }),
- )
- .expect("NotebookEdit insert should succeed");
- let inserted_output: serde_json::Value = serde_json::from_str(&inserted).expect("json");
- assert_eq!(inserted_output["cell_type"], "markdown");
- let appended = execute_tool(
- "NotebookEdit",
- &json!({
- "notebook_path": path.display().to_string(),
- "new_source": "print(3)\n",
- "edit_mode": "insert"
- }),
- )
- .expect("NotebookEdit append should succeed");
- let appended_output: serde_json::Value = serde_json::from_str(&appended).expect("json");
- assert_eq!(appended_output["cell_type"], "code");
- let deleted = execute_tool(
- "NotebookEdit",
- &json!({
- "notebook_path": path.display().to_string(),
- "cell_id": "cell-a",
- "edit_mode": "delete"
- }),
- )
- .expect("NotebookEdit delete should succeed without new_source");
- let deleted_output: serde_json::Value = serde_json::from_str(&deleted).expect("json");
- assert!(deleted_output["cell_type"].is_null());
- assert_eq!(deleted_output["new_source"], "");
- let final_notebook: serde_json::Value =
- serde_json::from_str(&std::fs::read_to_string(&path).expect("read notebook"))
- .expect("valid notebook json");
- let cells = final_notebook["cells"].as_array().expect("cells array");
- assert_eq!(cells.len(), 2);
- assert_eq!(cells[0]["cell_type"], "markdown");
- assert!(cells[0].get("outputs").is_none());
- assert_eq!(cells[1]["cell_type"], "code");
- assert_eq!(cells[1]["source"][0], "print(3)\n");
- let _ = std::fs::remove_file(path);
- }
- #[test]
- fn notebook_edit_rejects_invalid_inputs() {
- let text_path = temp_path("notebook.txt");
- fs::write(&text_path, "not a notebook").expect("write text file");
- let wrong_extension = execute_tool(
- "NotebookEdit",
- &json!({
- "notebook_path": text_path.display().to_string(),
- "new_source": "print(1)\n"
- }),
- )
- .expect_err("non-ipynb file should fail");
- assert!(wrong_extension.contains("Jupyter notebook"));
- let _ = fs::remove_file(&text_path);
- let empty_notebook = temp_path("empty.ipynb");
- fs::write(
- &empty_notebook,
- r#"{"cells":[],"metadata":{"kernelspec":{"language":"python"}},"nbformat":4,"nbformat_minor":5}"#,
- )
- .expect("write empty notebook");
- let missing_source = execute_tool(
- "NotebookEdit",
- &json!({
- "notebook_path": empty_notebook.display().to_string(),
- "edit_mode": "insert"
- }),
- )
- .expect_err("insert without source should fail");
- assert!(missing_source.contains("new_source is required"));
- let missing_cell = execute_tool(
- "NotebookEdit",
- &json!({
- "notebook_path": empty_notebook.display().to_string(),
- "edit_mode": "delete"
- }),
- )
- .expect_err("delete on empty notebook should fail");
- assert!(missing_cell.contains("Notebook has no cells to edit"));
- let _ = fs::remove_file(empty_notebook);
- }
- #[test]
- fn bash_tool_reports_success_exit_failure_timeout_and_background() {
- let success = execute_tool("bash", &json!({ "command": "printf 'hello'" }))
- .expect("bash should succeed");
- let success_output: serde_json::Value = serde_json::from_str(&success).expect("json");
- assert_eq!(success_output["stdout"], "hello");
- assert_eq!(success_output["interrupted"], false);
- let failure = execute_tool("bash", &json!({ "command": "printf 'oops' >&2; exit 7" }))
- .expect("bash failure should still return structured output");
- let failure_output: serde_json::Value = serde_json::from_str(&failure).expect("json");
- assert_eq!(failure_output["returnCodeInterpretation"], "exit_code:7");
- assert!(failure_output["stderr"]
- .as_str()
- .expect("stderr")
- .contains("oops"));
- let timeout = execute_tool("bash", &json!({ "command": "sleep 1", "timeout": 10 }))
- .expect("bash timeout should return output");
- let timeout_output: serde_json::Value = serde_json::from_str(&timeout).expect("json");
- assert_eq!(timeout_output["interrupted"], true);
- assert_eq!(timeout_output["returnCodeInterpretation"], "timeout");
- assert!(timeout_output["stderr"]
- .as_str()
- .expect("stderr")
- .contains("Command exceeded timeout"));
- let background = execute_tool(
- "bash",
- &json!({ "command": "sleep 1", "run_in_background": true }),
- )
- .expect("bash background should succeed");
- let background_output: serde_json::Value = serde_json::from_str(&background).expect("json");
- assert!(background_output["backgroundTaskId"].as_str().is_some());
- assert_eq!(background_output["noOutputExpected"], true);
- }
- #[test]
- fn file_tools_cover_read_write_and_edit_behaviors() {
- let _guard = env_lock()
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- let root = temp_path("fs-suite");
- fs::create_dir_all(&root).expect("create root");
- let original_dir = std::env::current_dir().expect("cwd");
- std::env::set_current_dir(&root).expect("set cwd");
- let write_create = execute_tool(
- "write_file",
- &json!({ "path": "nested/demo.txt", "content": "alpha\nbeta\nalpha\n" }),
- )
- .expect("write create should succeed");
- let write_create_output: serde_json::Value =
- serde_json::from_str(&write_create).expect("json");
- assert_eq!(write_create_output["type"], "create");
- assert!(root.join("nested/demo.txt").exists());
- let write_update = execute_tool(
- "write_file",
- &json!({ "path": "nested/demo.txt", "content": "alpha\nbeta\ngamma\n" }),
- )
- .expect("write update should succeed");
- let write_update_output: serde_json::Value =
- serde_json::from_str(&write_update).expect("json");
- assert_eq!(write_update_output["type"], "update");
- assert_eq!(write_update_output["originalFile"], "alpha\nbeta\nalpha\n");
- let read_full = execute_tool("read_file", &json!({ "path": "nested/demo.txt" }))
- .expect("read full should succeed");
- let read_full_output: serde_json::Value = serde_json::from_str(&read_full).expect("json");
- assert_eq!(read_full_output["file"]["content"], "alpha\nbeta\ngamma");
- assert_eq!(read_full_output["file"]["startLine"], 1);
- let read_slice = execute_tool(
- "read_file",
- &json!({ "path": "nested/demo.txt", "offset": 1, "limit": 1 }),
- )
- .expect("read slice should succeed");
- let read_slice_output: serde_json::Value = serde_json::from_str(&read_slice).expect("json");
- assert_eq!(read_slice_output["file"]["content"], "beta");
- assert_eq!(read_slice_output["file"]["startLine"], 2);
- let read_past_end = execute_tool(
- "read_file",
- &json!({ "path": "nested/demo.txt", "offset": 50 }),
- )
- .expect("read past EOF should succeed");
- let read_past_end_output: serde_json::Value =
- serde_json::from_str(&read_past_end).expect("json");
- assert_eq!(read_past_end_output["file"]["content"], "");
- assert_eq!(read_past_end_output["file"]["startLine"], 4);
- let read_error = execute_tool("read_file", &json!({ "path": "missing.txt" }))
- .expect_err("missing file should fail");
- assert!(!read_error.is_empty());
- let edit_once = execute_tool(
- "edit_file",
- &json!({ "path": "nested/demo.txt", "old_string": "alpha", "new_string": "omega" }),
- )
- .expect("single edit should succeed");
- let edit_once_output: serde_json::Value = serde_json::from_str(&edit_once).expect("json");
- assert_eq!(edit_once_output["replaceAll"], false);
- assert_eq!(
- fs::read_to_string(root.join("nested/demo.txt")).expect("read file"),
- "omega\nbeta\ngamma\n"
- );
- execute_tool(
- "write_file",
- &json!({ "path": "nested/demo.txt", "content": "alpha\nbeta\nalpha\n" }),
- )
- .expect("reset file");
- let edit_all = execute_tool(
- "edit_file",
- &json!({
- "path": "nested/demo.txt",
- "old_string": "alpha",
- "new_string": "omega",
- "replace_all": true
- }),
- )
- .expect("replace all should succeed");
- let edit_all_output: serde_json::Value = serde_json::from_str(&edit_all).expect("json");
- assert_eq!(edit_all_output["replaceAll"], true);
- assert_eq!(
- fs::read_to_string(root.join("nested/demo.txt")).expect("read file"),
- "omega\nbeta\nomega\n"
- );
- let edit_same = execute_tool(
- "edit_file",
- &json!({ "path": "nested/demo.txt", "old_string": "omega", "new_string": "omega" }),
- )
- .expect_err("identical old/new should fail");
- assert!(edit_same.contains("must differ"));
- let edit_missing = execute_tool(
- "edit_file",
- &json!({ "path": "nested/demo.txt", "old_string": "missing", "new_string": "omega" }),
- )
- .expect_err("missing substring should fail");
- assert!(edit_missing.contains("old_string not found"));
- std::env::set_current_dir(&original_dir).expect("restore cwd");
- let _ = fs::remove_dir_all(root);
- }
- #[test]
- fn glob_and_grep_tools_cover_success_and_errors() {
- let _guard = env_lock()
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- let root = temp_path("search-suite");
- fs::create_dir_all(root.join("nested")).expect("create root");
- let original_dir = std::env::current_dir().expect("cwd");
- std::env::set_current_dir(&root).expect("set cwd");
- fs::write(
- root.join("nested/lib.rs"),
- "fn main() {}\nlet alpha = 1;\nlet alpha = 2;\n",
- )
- .expect("write rust file");
- fs::write(root.join("nested/notes.txt"), "alpha\nbeta\n").expect("write txt file");
- let globbed = execute_tool("glob_search", &json!({ "pattern": "nested/*.rs" }))
- .expect("glob should succeed");
- let globbed_output: serde_json::Value = serde_json::from_str(&globbed).expect("json");
- assert_eq!(globbed_output["numFiles"], 1);
- assert!(globbed_output["filenames"][0]
- .as_str()
- .expect("filename")
- .ends_with("nested/lib.rs"));
- let glob_error = execute_tool("glob_search", &json!({ "pattern": "[" }))
- .expect_err("invalid glob should fail");
- assert!(!glob_error.is_empty());
- let grep_content = execute_tool(
- "grep_search",
- &json!({
- "pattern": "alpha",
- "path": "nested",
- "glob": "*.rs",
- "output_mode": "content",
- "-n": true,
- "head_limit": 1,
- "offset": 1
- }),
- )
- .expect("grep content should succeed");
- let grep_content_output: serde_json::Value =
- serde_json::from_str(&grep_content).expect("json");
- assert_eq!(grep_content_output["numFiles"], 0);
- assert!(grep_content_output["appliedLimit"].is_null());
- assert_eq!(grep_content_output["appliedOffset"], 1);
- assert!(grep_content_output["content"]
- .as_str()
- .expect("content")
- .contains("let alpha = 2;"));
- let grep_count = execute_tool(
- "grep_search",
- &json!({ "pattern": "alpha", "path": "nested", "output_mode": "count" }),
- )
- .expect("grep count should succeed");
- let grep_count_output: serde_json::Value = serde_json::from_str(&grep_count).expect("json");
- assert_eq!(grep_count_output["numMatches"], 3);
- let grep_error = execute_tool(
- "grep_search",
- &json!({ "pattern": "(alpha", "path": "nested" }),
- )
- .expect_err("invalid regex should fail");
- assert!(!grep_error.is_empty());
- std::env::set_current_dir(&original_dir).expect("restore cwd");
- let _ = fs::remove_dir_all(root);
- }
- #[test]
- fn sleep_waits_and_reports_duration() {
- let started = std::time::Instant::now();
- let result =
- execute_tool("Sleep", &json!({"duration_ms": 20})).expect("Sleep should succeed");
- let elapsed = started.elapsed();
- let output: serde_json::Value = serde_json::from_str(&result).expect("json");
- assert_eq!(output["duration_ms"], 20);
- assert!(output["message"]
- .as_str()
- .expect("message")
- .contains("Slept for 20ms"));
- assert!(elapsed >= Duration::from_millis(15));
- }
- #[test]
- fn given_excessive_duration_when_sleep_then_rejects_with_error() {
- let result = execute_tool("Sleep", &json!({"duration_ms": 999_999_999_u64}));
- let error = result.expect_err("excessive sleep should fail");
- assert!(error.contains("exceeds maximum allowed sleep"));
- }
- #[test]
- fn given_zero_duration_when_sleep_then_succeeds() {
- let result =
- execute_tool("Sleep", &json!({"duration_ms": 0})).expect("0ms sleep should succeed");
- let output: serde_json::Value = serde_json::from_str(&result).expect("json");
- assert_eq!(output["duration_ms"], 0);
- }
- #[test]
- fn brief_returns_sent_message_and_attachment_metadata() {
- let attachment = std::env::temp_dir().join(format!(
- "clawd-brief-{}.png",
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .expect("time")
- .as_nanos()
- ));
- std::fs::write(&attachment, b"png-data").expect("write attachment");
- let result = execute_tool(
- "SendUserMessage",
- &json!({
- "message": "hello user",
- "attachments": [attachment.display().to_string()],
- "status": "normal"
- }),
- )
- .expect("SendUserMessage should succeed");
- let output: serde_json::Value = serde_json::from_str(&result).expect("json");
- assert_eq!(output["message"], "hello user");
- assert!(output["sentAt"].as_str().is_some());
- assert_eq!(output["attachments"][0]["isImage"], true);
- let _ = std::fs::remove_file(attachment);
- }
- #[test]
- fn config_reads_and_writes_supported_values() {
- let _guard = env_lock()
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- let root = std::env::temp_dir().join(format!(
- "clawd-config-{}",
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .expect("time")
- .as_nanos()
- ));
- let home = root.join("home");
- let cwd = root.join("cwd");
- std::fs::create_dir_all(home.join(".claw")).expect("home dir");
- std::fs::create_dir_all(cwd.join(".claw")).expect("cwd dir");
- std::fs::write(
- home.join(".claw").join("settings.json"),
- r#"{"verbose":false}"#,
- )
- .expect("write global settings");
- let original_home = std::env::var("HOME").ok();
- let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
- let original_dir = std::env::current_dir().expect("cwd");
- std::env::set_var("HOME", &home);
- std::env::remove_var("CLAW_CONFIG_HOME");
- std::env::set_current_dir(&cwd).expect("set cwd");
- let get = execute_tool("Config", &json!({"setting": "verbose"})).expect("get config");
- let get_output: serde_json::Value = serde_json::from_str(&get).expect("json");
- assert_eq!(get_output["value"], false);
- let set = execute_tool(
- "Config",
- &json!({"setting": "permissions.defaultMode", "value": "plan"}),
- )
- .expect("set config");
- let set_output: serde_json::Value = serde_json::from_str(&set).expect("json");
- assert_eq!(set_output["operation"], "set");
- assert_eq!(set_output["newValue"], "plan");
- let invalid = execute_tool(
- "Config",
- &json!({"setting": "permissions.defaultMode", "value": "bogus"}),
- )
- .expect_err("invalid config value should error");
- assert!(invalid.contains("Invalid value"));
- let unknown =
- execute_tool("Config", &json!({"setting": "nope"})).expect("unknown setting result");
- let unknown_output: serde_json::Value = serde_json::from_str(&unknown).expect("json");
- assert_eq!(unknown_output["success"], false);
- std::env::set_current_dir(&original_dir).expect("restore cwd");
- match original_home {
- Some(value) => std::env::set_var("HOME", value),
- None => std::env::remove_var("HOME"),
- }
- match original_config_home {
- Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
- None => std::env::remove_var("CLAW_CONFIG_HOME"),
- }
- let _ = std::fs::remove_dir_all(root);
- }
- #[test]
- fn enter_and_exit_plan_mode_round_trip_existing_local_override() {
- let _guard = env_lock()
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- let root = std::env::temp_dir().join(format!(
- "clawd-plan-mode-{}",
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .expect("time")
- .as_nanos()
- ));
- let home = root.join("home");
- let cwd = root.join("cwd");
- std::fs::create_dir_all(home.join(".claw")).expect("home dir");
- std::fs::create_dir_all(cwd.join(".claw")).expect("cwd dir");
- std::fs::write(
- cwd.join(".claw").join("settings.local.json"),
- r#"{"permissions":{"defaultMode":"acceptEdits"}}"#,
- )
- .expect("write local settings");
- let original_home = std::env::var("HOME").ok();
- let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
- let original_dir = std::env::current_dir().expect("cwd");
- std::env::set_var("HOME", &home);
- std::env::remove_var("CLAW_CONFIG_HOME");
- std::env::set_current_dir(&cwd).expect("set cwd");
- let enter = execute_tool("EnterPlanMode", &json!({})).expect("enter plan mode");
- let enter_output: serde_json::Value = serde_json::from_str(&enter).expect("json");
- assert_eq!(enter_output["changed"], true);
- assert_eq!(enter_output["managed"], true);
- assert_eq!(enter_output["previousLocalMode"], "acceptEdits");
- assert_eq!(enter_output["currentLocalMode"], "plan");
- let local_settings = std::fs::read_to_string(cwd.join(".claw").join("settings.local.json"))
- .expect("local settings after enter");
- assert!(local_settings.contains(r#""defaultMode": "plan""#));
- let state =
- std::fs::read_to_string(cwd.join(".claw").join("tool-state").join("plan-mode.json"))
- .expect("plan mode state");
- assert!(state.contains(r#""hadLocalOverride": true"#));
- assert!(state.contains(r#""previousLocalMode": "acceptEdits""#));
- let exit = execute_tool("ExitPlanMode", &json!({})).expect("exit plan mode");
- let exit_output: serde_json::Value = serde_json::from_str(&exit).expect("json");
- assert_eq!(exit_output["changed"], true);
- assert_eq!(exit_output["managed"], false);
- assert_eq!(exit_output["previousLocalMode"], "acceptEdits");
- assert_eq!(exit_output["currentLocalMode"], "acceptEdits");
- let local_settings = std::fs::read_to_string(cwd.join(".claw").join("settings.local.json"))
- .expect("local settings after exit");
- assert!(local_settings.contains(r#""defaultMode": "acceptEdits""#));
- assert!(!cwd
- .join(".claw")
- .join("tool-state")
- .join("plan-mode.json")
- .exists());
- std::env::set_current_dir(&original_dir).expect("restore cwd");
- match original_home {
- Some(value) => std::env::set_var("HOME", value),
- None => std::env::remove_var("HOME"),
- }
- match original_config_home {
- Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
- None => std::env::remove_var("CLAW_CONFIG_HOME"),
- }
- let _ = std::fs::remove_dir_all(root);
- }
- #[test]
- fn exit_plan_mode_clears_override_when_enter_created_it_from_empty_local_state() {
- let _guard = env_lock()
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- let root = std::env::temp_dir().join(format!(
- "clawd-plan-mode-empty-{}",
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .expect("time")
- .as_nanos()
- ));
- let home = root.join("home");
- let cwd = root.join("cwd");
- std::fs::create_dir_all(home.join(".claw")).expect("home dir");
- std::fs::create_dir_all(cwd.join(".claw")).expect("cwd dir");
- let original_home = std::env::var("HOME").ok();
- let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
- let original_dir = std::env::current_dir().expect("cwd");
- std::env::set_var("HOME", &home);
- std::env::remove_var("CLAW_CONFIG_HOME");
- std::env::set_current_dir(&cwd).expect("set cwd");
- let enter = execute_tool("EnterPlanMode", &json!({})).expect("enter plan mode");
- let enter_output: serde_json::Value = serde_json::from_str(&enter).expect("json");
- assert_eq!(enter_output["previousLocalMode"], serde_json::Value::Null);
- assert_eq!(enter_output["currentLocalMode"], "plan");
- let exit = execute_tool("ExitPlanMode", &json!({})).expect("exit plan mode");
- let exit_output: serde_json::Value = serde_json::from_str(&exit).expect("json");
- assert_eq!(exit_output["changed"], true);
- assert_eq!(exit_output["currentLocalMode"], serde_json::Value::Null);
- let local_settings = std::fs::read_to_string(cwd.join(".claw").join("settings.local.json"))
- .expect("local settings after exit");
- let local_settings_json: serde_json::Value =
- serde_json::from_str(&local_settings).expect("valid settings json");
- assert_eq!(
- local_settings_json.get("permissions"),
- None,
- "permissions override should be removed on exit"
- );
- assert!(!cwd
- .join(".claw")
- .join("tool-state")
- .join("plan-mode.json")
- .exists());
- std::env::set_current_dir(&original_dir).expect("restore cwd");
- match original_home {
- Some(value) => std::env::set_var("HOME", value),
- None => std::env::remove_var("HOME"),
- }
- match original_config_home {
- Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
- None => std::env::remove_var("CLAW_CONFIG_HOME"),
- }
- let _ = std::fs::remove_dir_all(root);
- }
- #[test]
- fn structured_output_echoes_input_payload() {
- let result = execute_tool("StructuredOutput", &json!({"ok": true, "items": [1, 2, 3]}))
- .expect("StructuredOutput should succeed");
- let output: serde_json::Value = serde_json::from_str(&result).expect("json");
- assert_eq!(output["data"], "Structured output provided successfully");
- assert_eq!(output["structured_output"]["ok"], true);
- assert_eq!(output["structured_output"]["items"][1], 2);
- }
- #[test]
- fn given_empty_payload_when_structured_output_then_rejects_with_error() {
- let result = execute_tool("StructuredOutput", &json!({}));
- let error = result.expect_err("empty payload should fail");
- assert!(error.contains("must not be empty"));
- }
- #[test]
- fn repl_executes_python_code() {
- let result = execute_tool(
- "REPL",
- &json!({"language": "python", "code": "print(1 + 1)", "timeout_ms": 500}),
- )
- .expect("REPL should succeed");
- let output: serde_json::Value = serde_json::from_str(&result).expect("json");
- assert_eq!(output["language"], "python");
- assert_eq!(output["exitCode"], 0);
- assert!(output["stdout"].as_str().expect("stdout").contains('2'));
- }
- #[test]
- fn given_empty_code_when_repl_then_rejects_with_error() {
- let result = execute_tool("REPL", &json!({"language": "python", "code": " "}));
- let error = result.expect_err("empty REPL code should fail");
- assert!(error.contains("code must not be empty"));
- }
- #[test]
- fn given_unsupported_language_when_repl_then_rejects_with_error() {
- let result = execute_tool("REPL", &json!({"language": "ruby", "code": "puts 1"}));
- let error = result.expect_err("unsupported REPL language should fail");
- assert!(error.contains("unsupported REPL language: ruby"));
- }
- #[test]
- fn given_timeout_ms_when_repl_blocks_then_returns_timeout_error() {
- let result = execute_tool(
- "REPL",
- &json!({
- "language": "python",
- "code": "import time\ntime.sleep(1)",
- "timeout_ms": 10
- }),
- );
- let error = result.expect_err("timed out REPL execution should fail");
- assert!(error.contains("REPL execution exceeded timeout of 10 ms"));
- }
- #[test]
- fn powershell_runs_via_stub_shell() {
- let _guard = env_lock()
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- let dir = std::env::temp_dir().join(format!(
- "clawd-pwsh-bin-{}",
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .expect("time")
- .as_nanos()
- ));
- std::fs::create_dir_all(&dir).expect("create dir");
- let script = dir.join("pwsh");
- std::fs::write(
- &script,
- r#"#!/bin/sh
- while [ "$1" != "-Command" ] && [ $# -gt 0 ]; do shift; done
- shift
- printf 'pwsh:%s' "$1"
- "#,
- )
- .expect("write script");
- std::process::Command::new("/bin/chmod")
- .arg("+x")
- .arg(&script)
- .status()
- .expect("chmod");
- let original_path = std::env::var("PATH").unwrap_or_default();
- std::env::set_var("PATH", format!("{}:{}", dir.display(), original_path));
- let result = execute_tool(
- "PowerShell",
- &json!({"command": "Write-Output hello", "timeout": 1000}),
- )
- .expect("PowerShell should succeed");
- let background = execute_tool(
- "PowerShell",
- &json!({"command": "Write-Output hello", "run_in_background": true}),
- )
- .expect("PowerShell background should succeed");
- std::env::set_var("PATH", original_path);
- let _ = std::fs::remove_dir_all(dir);
- let output: serde_json::Value = serde_json::from_str(&result).expect("json");
- assert_eq!(output["stdout"], "pwsh:Write-Output hello");
- assert!(output["stderr"].as_str().expect("stderr").is_empty());
- let background_output: serde_json::Value = serde_json::from_str(&background).expect("json");
- assert!(background_output["backgroundTaskId"].as_str().is_some());
- assert_eq!(background_output["backgroundedByUser"], true);
- assert_eq!(background_output["assistantAutoBackgrounded"], false);
- }
- #[test]
- fn powershell_errors_when_shell_is_missing() {
- let _guard = env_lock()
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- let original_path = std::env::var("PATH").unwrap_or_default();
- let empty_dir = std::env::temp_dir().join(format!(
- "clawd-empty-bin-{}",
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .expect("time")
- .as_nanos()
- ));
- std::fs::create_dir_all(&empty_dir).expect("create empty dir");
- std::env::set_var("PATH", empty_dir.display().to_string());
- let err = execute_tool("PowerShell", &json!({"command": "Write-Output hello"}))
- .expect_err("PowerShell should fail when shell is missing");
- std::env::set_var("PATH", original_path);
- let _ = std::fs::remove_dir_all(empty_dir);
- assert!(err.contains("PowerShell executable not found"));
- }
- struct TestServer {
- addr: SocketAddr,
- shutdown: Option<std::sync::mpsc::Sender<()>>,
- handle: Option<thread::JoinHandle<()>>,
- }
- impl TestServer {
- fn spawn(handler: Arc<dyn Fn(&str) -> HttpResponse + Send + Sync + 'static>) -> Self {
- let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
- listener
- .set_nonblocking(true)
- .expect("set nonblocking listener");
- let addr = listener.local_addr().expect("local addr");
- let (tx, rx) = std::sync::mpsc::channel::<()>();
- let handle = thread::spawn(move || loop {
- if rx.try_recv().is_ok() {
- break;
- }
- match listener.accept() {
- Ok((mut stream, _)) => {
- let mut buffer = [0_u8; 4096];
- let size = stream.read(&mut buffer).expect("read request");
- let request = String::from_utf8_lossy(&buffer[..size]).into_owned();
- let request_line = request.lines().next().unwrap_or_default().to_string();
- let response = handler(&request_line);
- stream
- .write_all(response.to_bytes().as_slice())
- .expect("write response");
- }
- Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
- thread::sleep(Duration::from_millis(10));
- }
- Err(error) => panic!("server accept failed: {error}"),
- }
- });
- Self {
- addr,
- shutdown: Some(tx),
- handle: Some(handle),
- }
- }
- fn addr(&self) -> SocketAddr {
- self.addr
- }
- }
- impl Drop for TestServer {
- fn drop(&mut self) {
- if let Some(tx) = self.shutdown.take() {
- let _ = tx.send(());
- }
- if let Some(handle) = self.handle.take() {
- handle.join().expect("join test server");
- }
- }
- }
- struct HttpResponse {
- status: u16,
- reason: &'static str,
- content_type: &'static str,
- body: String,
- }
- impl HttpResponse {
- fn html(status: u16, reason: &'static str, body: &str) -> Self {
- Self {
- status,
- reason,
- content_type: "text/html; charset=utf-8",
- body: body.to_string(),
- }
- }
- fn text(status: u16, reason: &'static str, body: &str) -> Self {
- Self {
- status,
- reason,
- content_type: "text/plain; charset=utf-8",
- body: body.to_string(),
- }
- }
- fn to_bytes(&self) -> Vec<u8> {
- format!(
- "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
- self.status,
- self.reason,
- self.content_type,
- self.body.len(),
- self.body
- )
- .into_bytes()
- }
- }
- }
|