lib.rs 73 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228
  1. use std::collections::BTreeSet;
  2. use std::time::{Duration, Instant};
  3. use reqwest::blocking::Client;
  4. use runtime::{
  5. edit_file, execute_bash, glob_search, grep_search, read_file, write_file, BashCommandInput,
  6. GrepSearchInput,
  7. };
  8. use serde::{Deserialize, Serialize};
  9. use serde_json::{json, Value};
  10. #[derive(Debug, Clone, PartialEq, Eq)]
  11. pub struct ToolManifestEntry {
  12. pub name: String,
  13. pub source: ToolSource,
  14. }
  15. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  16. pub enum ToolSource {
  17. Base,
  18. Conditional,
  19. }
  20. #[derive(Debug, Clone, Default, PartialEq, Eq)]
  21. pub struct ToolRegistry {
  22. entries: Vec<ToolManifestEntry>,
  23. }
  24. impl ToolRegistry {
  25. #[must_use]
  26. pub fn new(entries: Vec<ToolManifestEntry>) -> Self {
  27. Self { entries }
  28. }
  29. #[must_use]
  30. pub fn entries(&self) -> &[ToolManifestEntry] {
  31. &self.entries
  32. }
  33. }
  34. #[derive(Debug, Clone, PartialEq, Eq)]
  35. pub struct ToolSpec {
  36. pub name: &'static str,
  37. pub description: &'static str,
  38. pub input_schema: Value,
  39. }
  40. #[must_use]
  41. pub fn mvp_tool_specs() -> Vec<ToolSpec> {
  42. vec![
  43. ToolSpec {
  44. name: "bash",
  45. description: "Execute a shell command in the current workspace.",
  46. input_schema: json!({
  47. "type": "object",
  48. "properties": {
  49. "command": { "type": "string" },
  50. "timeout": { "type": "integer", "minimum": 1 },
  51. "description": { "type": "string" },
  52. "run_in_background": { "type": "boolean" },
  53. "dangerouslyDisableSandbox": { "type": "boolean" }
  54. },
  55. "required": ["command"],
  56. "additionalProperties": false
  57. }),
  58. },
  59. ToolSpec {
  60. name: "read_file",
  61. description: "Read a text file from the workspace.",
  62. input_schema: json!({
  63. "type": "object",
  64. "properties": {
  65. "path": { "type": "string" },
  66. "offset": { "type": "integer", "minimum": 0 },
  67. "limit": { "type": "integer", "minimum": 1 }
  68. },
  69. "required": ["path"],
  70. "additionalProperties": false
  71. }),
  72. },
  73. ToolSpec {
  74. name: "write_file",
  75. description: "Write a text file in the workspace.",
  76. input_schema: json!({
  77. "type": "object",
  78. "properties": {
  79. "path": { "type": "string" },
  80. "content": { "type": "string" }
  81. },
  82. "required": ["path", "content"],
  83. "additionalProperties": false
  84. }),
  85. },
  86. ToolSpec {
  87. name: "edit_file",
  88. description: "Replace text in a workspace file.",
  89. input_schema: json!({
  90. "type": "object",
  91. "properties": {
  92. "path": { "type": "string" },
  93. "old_string": { "type": "string" },
  94. "new_string": { "type": "string" },
  95. "replace_all": { "type": "boolean" }
  96. },
  97. "required": ["path", "old_string", "new_string"],
  98. "additionalProperties": false
  99. }),
  100. },
  101. ToolSpec {
  102. name: "glob_search",
  103. description: "Find files by glob pattern.",
  104. input_schema: json!({
  105. "type": "object",
  106. "properties": {
  107. "pattern": { "type": "string" },
  108. "path": { "type": "string" }
  109. },
  110. "required": ["pattern"],
  111. "additionalProperties": false
  112. }),
  113. },
  114. ToolSpec {
  115. name: "grep_search",
  116. description: "Search file contents with a regex pattern.",
  117. input_schema: json!({
  118. "type": "object",
  119. "properties": {
  120. "pattern": { "type": "string" },
  121. "path": { "type": "string" },
  122. "glob": { "type": "string" },
  123. "output_mode": { "type": "string" },
  124. "-B": { "type": "integer", "minimum": 0 },
  125. "-A": { "type": "integer", "minimum": 0 },
  126. "-C": { "type": "integer", "minimum": 0 },
  127. "context": { "type": "integer", "minimum": 0 },
  128. "-n": { "type": "boolean" },
  129. "-i": { "type": "boolean" },
  130. "type": { "type": "string" },
  131. "head_limit": { "type": "integer", "minimum": 1 },
  132. "offset": { "type": "integer", "minimum": 0 },
  133. "multiline": { "type": "boolean" }
  134. },
  135. "required": ["pattern"],
  136. "additionalProperties": false
  137. }),
  138. },
  139. ToolSpec {
  140. name: "WebFetch",
  141. description:
  142. "Fetch a URL, convert it into readable text, and answer a prompt about it.",
  143. input_schema: json!({
  144. "type": "object",
  145. "properties": {
  146. "url": { "type": "string", "format": "uri" },
  147. "prompt": { "type": "string" }
  148. },
  149. "required": ["url", "prompt"],
  150. "additionalProperties": false
  151. }),
  152. },
  153. ToolSpec {
  154. name: "WebSearch",
  155. description: "Search the web for current information and return cited results.",
  156. input_schema: json!({
  157. "type": "object",
  158. "properties": {
  159. "query": { "type": "string", "minLength": 2 },
  160. "allowed_domains": {
  161. "type": "array",
  162. "items": { "type": "string" }
  163. },
  164. "blocked_domains": {
  165. "type": "array",
  166. "items": { "type": "string" }
  167. }
  168. },
  169. "required": ["query"],
  170. "additionalProperties": false
  171. }),
  172. },
  173. ToolSpec {
  174. name: "TodoWrite",
  175. description: "Update the structured task list for the current session.",
  176. input_schema: json!({
  177. "type": "object",
  178. "properties": {
  179. "todos": {
  180. "type": "array",
  181. "items": {
  182. "type": "object",
  183. "properties": {
  184. "content": { "type": "string" },
  185. "activeForm": { "type": "string" },
  186. "status": {
  187. "type": "string",
  188. "enum": ["pending", "in_progress", "completed"]
  189. }
  190. },
  191. "required": ["content", "activeForm", "status"],
  192. "additionalProperties": false
  193. }
  194. }
  195. },
  196. "required": ["todos"],
  197. "additionalProperties": false
  198. }),
  199. },
  200. ToolSpec {
  201. name: "Skill",
  202. description: "Load a local skill definition and its instructions.",
  203. input_schema: json!({
  204. "type": "object",
  205. "properties": {
  206. "skill": { "type": "string" },
  207. "args": { "type": "string" }
  208. },
  209. "required": ["skill"],
  210. "additionalProperties": false
  211. }),
  212. },
  213. ToolSpec {
  214. name: "Agent",
  215. description: "Launch a specialized agent task and persist its handoff metadata.",
  216. input_schema: json!({
  217. "type": "object",
  218. "properties": {
  219. "description": { "type": "string" },
  220. "prompt": { "type": "string" },
  221. "subagent_type": { "type": "string" },
  222. "name": { "type": "string" },
  223. "model": { "type": "string" }
  224. },
  225. "required": ["description", "prompt"],
  226. "additionalProperties": false
  227. }),
  228. },
  229. ToolSpec {
  230. name: "ToolSearch",
  231. description: "Search for deferred or specialized tools by exact name or keywords.",
  232. input_schema: json!({
  233. "type": "object",
  234. "properties": {
  235. "query": { "type": "string" },
  236. "max_results": { "type": "integer", "minimum": 1 }
  237. },
  238. "required": ["query"],
  239. "additionalProperties": false
  240. }),
  241. },
  242. ToolSpec {
  243. name: "NotebookEdit",
  244. description: "Replace, insert, or delete a cell in a Jupyter notebook.",
  245. input_schema: json!({
  246. "type": "object",
  247. "properties": {
  248. "notebook_path": { "type": "string" },
  249. "cell_id": { "type": "string" },
  250. "new_source": { "type": "string" },
  251. "cell_type": { "type": "string", "enum": ["code", "markdown"] },
  252. "edit_mode": { "type": "string", "enum": ["replace", "insert", "delete"] }
  253. },
  254. "required": ["notebook_path"],
  255. "additionalProperties": false
  256. }),
  257. },
  258. ToolSpec {
  259. name: "Sleep",
  260. description: "Wait for a specified duration without holding a shell process.",
  261. input_schema: json!({
  262. "type": "object",
  263. "properties": {
  264. "duration_ms": { "type": "integer", "minimum": 0 }
  265. },
  266. "required": ["duration_ms"],
  267. "additionalProperties": false
  268. }),
  269. },
  270. ToolSpec {
  271. name: "PowerShell",
  272. description: "Execute a PowerShell command with optional timeout.",
  273. input_schema: json!({
  274. "type": "object",
  275. "properties": {
  276. "command": { "type": "string" },
  277. "timeout": { "type": "integer", "minimum": 1 },
  278. "description": { "type": "string" },
  279. "run_in_background": { "type": "boolean" }
  280. },
  281. "required": ["command"],
  282. "additionalProperties": false
  283. }),
  284. },
  285. ]
  286. }
  287. pub fn execute_tool(name: &str, input: &Value) -> Result<String, String> {
  288. match name {
  289. "bash" => from_value::<BashCommandInput>(input).and_then(run_bash),
  290. "read_file" => from_value::<ReadFileInput>(input).and_then(run_read_file),
  291. "write_file" => from_value::<WriteFileInput>(input).and_then(run_write_file),
  292. "edit_file" => from_value::<EditFileInput>(input).and_then(run_edit_file),
  293. "glob_search" => from_value::<GlobSearchInputValue>(input).and_then(run_glob_search),
  294. "grep_search" => from_value::<GrepSearchInput>(input).and_then(run_grep_search),
  295. "WebFetch" => from_value::<WebFetchInput>(input).and_then(run_web_fetch),
  296. "WebSearch" => from_value::<WebSearchInput>(input).and_then(run_web_search),
  297. "TodoWrite" => from_value::<TodoWriteInput>(input).and_then(run_todo_write),
  298. "Skill" => from_value::<SkillInput>(input).and_then(run_skill),
  299. "Agent" => from_value::<AgentInput>(input).and_then(run_agent),
  300. "ToolSearch" => from_value::<ToolSearchInput>(input).and_then(run_tool_search),
  301. "NotebookEdit" => from_value::<NotebookEditInput>(input).and_then(run_notebook_edit),
  302. "Sleep" => from_value::<SleepInput>(input).and_then(run_sleep),
  303. "PowerShell" => from_value::<PowerShellInput>(input).and_then(run_powershell),
  304. _ => Err(format!("unsupported tool: {name}")),
  305. }
  306. }
  307. fn from_value<T: for<'de> Deserialize<'de>>(input: &Value) -> Result<T, String> {
  308. serde_json::from_value(input.clone()).map_err(|error| error.to_string())
  309. }
  310. fn run_bash(input: BashCommandInput) -> Result<String, String> {
  311. serde_json::to_string_pretty(&execute_bash(input).map_err(|error| error.to_string())?)
  312. .map_err(|error| error.to_string())
  313. }
  314. fn run_read_file(input: ReadFileInput) -> Result<String, String> {
  315. to_pretty_json(read_file(&input.path, input.offset, input.limit).map_err(io_to_string)?)
  316. }
  317. fn run_write_file(input: WriteFileInput) -> Result<String, String> {
  318. to_pretty_json(write_file(&input.path, &input.content).map_err(io_to_string)?)
  319. }
  320. fn run_edit_file(input: EditFileInput) -> Result<String, String> {
  321. to_pretty_json(
  322. edit_file(
  323. &input.path,
  324. &input.old_string,
  325. &input.new_string,
  326. input.replace_all.unwrap_or(false),
  327. )
  328. .map_err(io_to_string)?,
  329. )
  330. }
  331. fn run_glob_search(input: GlobSearchInputValue) -> Result<String, String> {
  332. to_pretty_json(glob_search(&input.pattern, input.path.as_deref()).map_err(io_to_string)?)
  333. }
  334. fn run_grep_search(input: GrepSearchInput) -> Result<String, String> {
  335. to_pretty_json(grep_search(&input).map_err(io_to_string)?)
  336. }
  337. fn run_web_fetch(input: WebFetchInput) -> Result<String, String> {
  338. to_pretty_json(execute_web_fetch(&input)?)
  339. }
  340. fn run_web_search(input: WebSearchInput) -> Result<String, String> {
  341. to_pretty_json(execute_web_search(&input)?)
  342. }
  343. fn run_todo_write(input: TodoWriteInput) -> Result<String, String> {
  344. to_pretty_json(execute_todo_write(input)?)
  345. }
  346. fn run_skill(input: SkillInput) -> Result<String, String> {
  347. to_pretty_json(execute_skill(input)?)
  348. }
  349. fn run_agent(input: AgentInput) -> Result<String, String> {
  350. to_pretty_json(execute_agent(input)?)
  351. }
  352. fn run_tool_search(input: ToolSearchInput) -> Result<String, String> {
  353. to_pretty_json(execute_tool_search(input))
  354. }
  355. fn run_notebook_edit(input: NotebookEditInput) -> Result<String, String> {
  356. to_pretty_json(execute_notebook_edit(input)?)
  357. }
  358. fn run_sleep(input: SleepInput) -> Result<String, String> {
  359. to_pretty_json(execute_sleep(input))
  360. }
  361. fn run_powershell(input: PowerShellInput) -> Result<String, String> {
  362. to_pretty_json(execute_powershell(input).map_err(|error| error.to_string())?)
  363. }
  364. fn to_pretty_json<T: serde::Serialize>(value: T) -> Result<String, String> {
  365. serde_json::to_string_pretty(&value).map_err(|error| error.to_string())
  366. }
  367. fn io_to_string(error: std::io::Error) -> String {
  368. error.to_string()
  369. }
  370. #[derive(Debug, Deserialize)]
  371. struct ReadFileInput {
  372. path: String,
  373. offset: Option<usize>,
  374. limit: Option<usize>,
  375. }
  376. #[derive(Debug, Deserialize)]
  377. struct WriteFileInput {
  378. path: String,
  379. content: String,
  380. }
  381. #[derive(Debug, Deserialize)]
  382. struct EditFileInput {
  383. path: String,
  384. old_string: String,
  385. new_string: String,
  386. replace_all: Option<bool>,
  387. }
  388. #[derive(Debug, Deserialize)]
  389. struct GlobSearchInputValue {
  390. pattern: String,
  391. path: Option<String>,
  392. }
  393. #[derive(Debug, Deserialize)]
  394. struct WebFetchInput {
  395. url: String,
  396. prompt: String,
  397. }
  398. #[derive(Debug, Deserialize)]
  399. struct WebSearchInput {
  400. query: String,
  401. allowed_domains: Option<Vec<String>>,
  402. blocked_domains: Option<Vec<String>>,
  403. }
  404. #[derive(Debug, Deserialize)]
  405. struct TodoWriteInput {
  406. todos: Vec<TodoItem>,
  407. }
  408. #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
  409. struct TodoItem {
  410. content: String,
  411. #[serde(rename = "activeForm")]
  412. active_form: String,
  413. status: TodoStatus,
  414. }
  415. #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
  416. #[serde(rename_all = "snake_case")]
  417. enum TodoStatus {
  418. Pending,
  419. InProgress,
  420. Completed,
  421. }
  422. #[derive(Debug, Deserialize)]
  423. struct SkillInput {
  424. skill: String,
  425. args: Option<String>,
  426. }
  427. #[derive(Debug, Deserialize)]
  428. struct AgentInput {
  429. description: String,
  430. prompt: String,
  431. subagent_type: Option<String>,
  432. name: Option<String>,
  433. model: Option<String>,
  434. }
  435. #[derive(Debug, Deserialize)]
  436. struct ToolSearchInput {
  437. query: String,
  438. max_results: Option<usize>,
  439. }
  440. #[derive(Debug, Deserialize)]
  441. struct NotebookEditInput {
  442. notebook_path: String,
  443. cell_id: Option<String>,
  444. new_source: Option<String>,
  445. cell_type: Option<NotebookCellType>,
  446. edit_mode: Option<NotebookEditMode>,
  447. }
  448. #[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
  449. #[serde(rename_all = "lowercase")]
  450. enum NotebookCellType {
  451. Code,
  452. Markdown,
  453. }
  454. #[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
  455. #[serde(rename_all = "lowercase")]
  456. enum NotebookEditMode {
  457. Replace,
  458. Insert,
  459. Delete,
  460. }
  461. #[derive(Debug, Deserialize)]
  462. struct SleepInput {
  463. duration_ms: u64,
  464. }
  465. #[derive(Debug, Deserialize)]
  466. struct PowerShellInput {
  467. command: String,
  468. timeout: Option<u64>,
  469. description: Option<String>,
  470. run_in_background: Option<bool>,
  471. }
  472. #[derive(Debug, Serialize)]
  473. struct WebFetchOutput {
  474. bytes: usize,
  475. code: u16,
  476. #[serde(rename = "codeText")]
  477. code_text: String,
  478. result: String,
  479. #[serde(rename = "durationMs")]
  480. duration_ms: u128,
  481. url: String,
  482. }
  483. #[derive(Debug, Serialize)]
  484. struct WebSearchOutput {
  485. query: String,
  486. results: Vec<WebSearchResultItem>,
  487. #[serde(rename = "durationSeconds")]
  488. duration_seconds: f64,
  489. }
  490. #[derive(Debug, Serialize)]
  491. struct TodoWriteOutput {
  492. #[serde(rename = "oldTodos")]
  493. old_todos: Vec<TodoItem>,
  494. #[serde(rename = "newTodos")]
  495. new_todos: Vec<TodoItem>,
  496. #[serde(rename = "verificationNudgeNeeded")]
  497. verification_nudge_needed: Option<bool>,
  498. }
  499. #[derive(Debug, Serialize)]
  500. struct SkillOutput {
  501. skill: String,
  502. path: String,
  503. args: Option<String>,
  504. description: Option<String>,
  505. prompt: String,
  506. }
  507. #[derive(Debug, Serialize, Deserialize)]
  508. struct AgentOutput {
  509. #[serde(rename = "agentId")]
  510. agent_id: String,
  511. name: String,
  512. description: String,
  513. #[serde(rename = "subagentType")]
  514. subagent_type: Option<String>,
  515. model: Option<String>,
  516. status: String,
  517. #[serde(rename = "outputFile")]
  518. output_file: String,
  519. #[serde(rename = "manifestFile")]
  520. manifest_file: String,
  521. #[serde(rename = "createdAt")]
  522. created_at: String,
  523. }
  524. #[derive(Debug, Serialize)]
  525. struct ToolSearchOutput {
  526. matches: Vec<String>,
  527. query: String,
  528. normalized_query: String,
  529. #[serde(rename = "total_deferred_tools")]
  530. total_deferred_tools: usize,
  531. #[serde(rename = "pending_mcp_servers")]
  532. pending_mcp_servers: Option<Vec<String>>,
  533. }
  534. #[derive(Debug, Serialize)]
  535. struct NotebookEditOutput {
  536. new_source: String,
  537. cell_id: Option<String>,
  538. cell_type: Option<NotebookCellType>,
  539. language: String,
  540. edit_mode: String,
  541. error: Option<String>,
  542. notebook_path: String,
  543. original_file: String,
  544. updated_file: String,
  545. }
  546. #[derive(Debug, Serialize)]
  547. struct SleepOutput {
  548. duration_ms: u64,
  549. message: String,
  550. }
  551. #[derive(Debug, Serialize)]
  552. #[serde(untagged)]
  553. enum WebSearchResultItem {
  554. SearchResult {
  555. tool_use_id: String,
  556. content: Vec<SearchHit>,
  557. },
  558. Commentary(String),
  559. }
  560. #[derive(Debug, Serialize)]
  561. struct SearchHit {
  562. title: String,
  563. url: String,
  564. }
  565. fn execute_web_fetch(input: &WebFetchInput) -> Result<WebFetchOutput, String> {
  566. let started = Instant::now();
  567. let client = build_http_client()?;
  568. let request_url = normalize_fetch_url(&input.url)?;
  569. let response = client
  570. .get(request_url.clone())
  571. .send()
  572. .map_err(|error| error.to_string())?;
  573. let status = response.status();
  574. let final_url = response.url().to_string();
  575. let code = status.as_u16();
  576. let code_text = status.canonical_reason().unwrap_or("Unknown").to_string();
  577. let content_type = response
  578. .headers()
  579. .get(reqwest::header::CONTENT_TYPE)
  580. .and_then(|value| value.to_str().ok())
  581. .unwrap_or_default()
  582. .to_string();
  583. let body = response.text().map_err(|error| error.to_string())?;
  584. let bytes = body.len();
  585. let normalized = normalize_fetched_content(&body, &content_type);
  586. let result = summarize_web_fetch(&final_url, &input.prompt, &normalized);
  587. Ok(WebFetchOutput {
  588. bytes,
  589. code,
  590. code_text,
  591. result,
  592. duration_ms: started.elapsed().as_millis(),
  593. url: final_url,
  594. })
  595. }
  596. fn execute_web_search(input: &WebSearchInput) -> Result<WebSearchOutput, String> {
  597. let started = Instant::now();
  598. let client = build_http_client()?;
  599. let search_url = build_search_url(&input.query)?;
  600. let response = client
  601. .get(search_url)
  602. .send()
  603. .map_err(|error| error.to_string())?;
  604. let final_url = response.url().clone();
  605. let html = response.text().map_err(|error| error.to_string())?;
  606. let mut hits = extract_search_hits(&html);
  607. if hits.is_empty() && final_url.host_str().is_some() {
  608. hits = extract_search_hits_from_generic_links(&html);
  609. }
  610. if let Some(allowed) = input.allowed_domains.as_ref() {
  611. hits.retain(|hit| host_matches_list(&hit.url, allowed));
  612. }
  613. if let Some(blocked) = input.blocked_domains.as_ref() {
  614. hits.retain(|hit| !host_matches_list(&hit.url, blocked));
  615. }
  616. dedupe_hits(&mut hits);
  617. hits.truncate(8);
  618. let summary = if hits.is_empty() {
  619. format!("No web search results matched the query {:?}.", input.query)
  620. } else {
  621. let rendered_hits = hits
  622. .iter()
  623. .map(|hit| format!("- [{}]({})", hit.title, hit.url))
  624. .collect::<Vec<_>>()
  625. .join("\n");
  626. format!(
  627. "Search results for {:?}. Include a Sources section in the final answer.\n{}",
  628. input.query, rendered_hits
  629. )
  630. };
  631. Ok(WebSearchOutput {
  632. query: input.query.clone(),
  633. results: vec![
  634. WebSearchResultItem::Commentary(summary),
  635. WebSearchResultItem::SearchResult {
  636. tool_use_id: String::from("web_search_1"),
  637. content: hits,
  638. },
  639. ],
  640. duration_seconds: started.elapsed().as_secs_f64(),
  641. })
  642. }
  643. fn build_http_client() -> Result<Client, String> {
  644. Client::builder()
  645. .timeout(Duration::from_secs(20))
  646. .redirect(reqwest::redirect::Policy::limited(10))
  647. .user_agent("clawd-rust-tools/0.1")
  648. .build()
  649. .map_err(|error| error.to_string())
  650. }
  651. fn normalize_fetch_url(url: &str) -> Result<String, String> {
  652. let parsed = reqwest::Url::parse(url).map_err(|error| error.to_string())?;
  653. if parsed.scheme() == "http" {
  654. let host = parsed.host_str().unwrap_or_default();
  655. if host != "localhost" && host != "127.0.0.1" && host != "::1" {
  656. let mut upgraded = parsed;
  657. upgraded
  658. .set_scheme("https")
  659. .map_err(|_| String::from("failed to upgrade URL to https"))?;
  660. return Ok(upgraded.to_string());
  661. }
  662. }
  663. Ok(parsed.to_string())
  664. }
  665. fn build_search_url(query: &str) -> Result<reqwest::Url, String> {
  666. if let Ok(base) = std::env::var("CLAWD_WEB_SEARCH_BASE_URL") {
  667. let mut url = reqwest::Url::parse(&base).map_err(|error| error.to_string())?;
  668. url.query_pairs_mut().append_pair("q", query);
  669. return Ok(url);
  670. }
  671. let mut url = reqwest::Url::parse("https://html.duckduckgo.com/html/")
  672. .map_err(|error| error.to_string())?;
  673. url.query_pairs_mut().append_pair("q", query);
  674. Ok(url)
  675. }
  676. fn normalize_fetched_content(body: &str, content_type: &str) -> String {
  677. if content_type.contains("html") {
  678. html_to_text(body)
  679. } else {
  680. body.trim().to_string()
  681. }
  682. }
  683. fn summarize_web_fetch(url: &str, prompt: &str, content: &str) -> String {
  684. let lower_prompt = prompt.to_lowercase();
  685. let compact = collapse_whitespace(content);
  686. let detail = if lower_prompt.contains("title") {
  687. extract_title(content)
  688. .map(|title| format!("Title: {title}"))
  689. .unwrap_or_else(|| preview_text(&compact, 600))
  690. } else if lower_prompt.contains("summary") || lower_prompt.contains("summarize") {
  691. preview_text(&compact, 900)
  692. } else {
  693. let preview = preview_text(&compact, 900);
  694. format!("Prompt: {prompt}\nContent preview:\n{preview}")
  695. };
  696. format!("Fetched {url}\n{detail}")
  697. }
  698. fn extract_title(content: &str) -> Option<String> {
  699. for line in content.lines() {
  700. let trimmed = line.trim();
  701. if !trimmed.is_empty() {
  702. return Some(trimmed.to_string());
  703. }
  704. }
  705. None
  706. }
  707. fn html_to_text(html: &str) -> String {
  708. let mut text = String::with_capacity(html.len());
  709. let mut in_tag = false;
  710. let mut previous_was_space = false;
  711. for ch in html.chars() {
  712. match ch {
  713. '<' => in_tag = true,
  714. '>' => in_tag = false,
  715. _ if in_tag => {}
  716. '&' => {
  717. text.push('&');
  718. previous_was_space = false;
  719. }
  720. ch if ch.is_whitespace() => {
  721. if !previous_was_space {
  722. text.push(' ');
  723. previous_was_space = true;
  724. }
  725. }
  726. _ => {
  727. text.push(ch);
  728. previous_was_space = false;
  729. }
  730. }
  731. }
  732. collapse_whitespace(&decode_html_entities(&text))
  733. }
  734. fn decode_html_entities(input: &str) -> String {
  735. input
  736. .replace("&amp;", "&")
  737. .replace("&lt;", "<")
  738. .replace("&gt;", ">")
  739. .replace("&quot;", "\"")
  740. .replace("&#39;", "'")
  741. .replace("&nbsp;", " ")
  742. }
  743. fn collapse_whitespace(input: &str) -> String {
  744. input.split_whitespace().collect::<Vec<_>>().join(" ")
  745. }
  746. fn preview_text(input: &str, max_chars: usize) -> String {
  747. if input.chars().count() <= max_chars {
  748. return input.to_string();
  749. }
  750. let shortened = input.chars().take(max_chars).collect::<String>();
  751. format!("{}…", shortened.trim_end())
  752. }
  753. fn extract_search_hits(html: &str) -> Vec<SearchHit> {
  754. let mut hits = Vec::new();
  755. let mut remaining = html;
  756. while let Some(anchor_start) = remaining.find("result__a") {
  757. let after_class = &remaining[anchor_start..];
  758. let Some(href_idx) = after_class.find("href=") else {
  759. remaining = &after_class[1..];
  760. continue;
  761. };
  762. let href_slice = &after_class[href_idx + 5..];
  763. let Some((url, rest)) = extract_quoted_value(href_slice) else {
  764. remaining = &after_class[1..];
  765. continue;
  766. };
  767. let Some(close_tag_idx) = rest.find('>') else {
  768. remaining = &after_class[1..];
  769. continue;
  770. };
  771. let after_tag = &rest[close_tag_idx + 1..];
  772. let Some(end_anchor_idx) = after_tag.find("</a>") else {
  773. remaining = &after_tag[1..];
  774. continue;
  775. };
  776. let title = html_to_text(&after_tag[..end_anchor_idx]);
  777. if let Some(decoded_url) = decode_duckduckgo_redirect(&url) {
  778. hits.push(SearchHit {
  779. title: title.trim().to_string(),
  780. url: decoded_url,
  781. });
  782. }
  783. remaining = &after_tag[end_anchor_idx + 4..];
  784. }
  785. hits
  786. }
  787. fn extract_search_hits_from_generic_links(html: &str) -> Vec<SearchHit> {
  788. let mut hits = Vec::new();
  789. let mut remaining = html;
  790. while let Some(anchor_start) = remaining.find("<a") {
  791. let after_anchor = &remaining[anchor_start..];
  792. let Some(href_idx) = after_anchor.find("href=") else {
  793. remaining = &after_anchor[2..];
  794. continue;
  795. };
  796. let href_slice = &after_anchor[href_idx + 5..];
  797. let Some((url, rest)) = extract_quoted_value(href_slice) else {
  798. remaining = &after_anchor[2..];
  799. continue;
  800. };
  801. let Some(close_tag_idx) = rest.find('>') else {
  802. remaining = &after_anchor[2..];
  803. continue;
  804. };
  805. let after_tag = &rest[close_tag_idx + 1..];
  806. let Some(end_anchor_idx) = after_tag.find("</a>") else {
  807. remaining = &after_anchor[2..];
  808. continue;
  809. };
  810. let title = html_to_text(&after_tag[..end_anchor_idx]);
  811. if title.trim().is_empty() {
  812. remaining = &after_tag[end_anchor_idx + 4..];
  813. continue;
  814. }
  815. let decoded_url = decode_duckduckgo_redirect(&url).unwrap_or(url);
  816. if decoded_url.starts_with("http://") || decoded_url.starts_with("https://") {
  817. hits.push(SearchHit {
  818. title: title.trim().to_string(),
  819. url: decoded_url,
  820. });
  821. }
  822. remaining = &after_tag[end_anchor_idx + 4..];
  823. }
  824. hits
  825. }
  826. fn extract_quoted_value(input: &str) -> Option<(String, &str)> {
  827. let quote = input.chars().next()?;
  828. if quote != '"' && quote != '\'' {
  829. return None;
  830. }
  831. let rest = &input[quote.len_utf8()..];
  832. let end = rest.find(quote)?;
  833. Some((rest[..end].to_string(), &rest[end + quote.len_utf8()..]))
  834. }
  835. fn decode_duckduckgo_redirect(url: &str) -> Option<String> {
  836. if url.starts_with("http://") || url.starts_with("https://") {
  837. return Some(html_entity_decode_url(url));
  838. }
  839. let joined = if url.starts_with("//") {
  840. format!("https:{url}")
  841. } else if url.starts_with('/') {
  842. format!("https://duckduckgo.com{url}")
  843. } else {
  844. return None;
  845. };
  846. let parsed = reqwest::Url::parse(&joined).ok()?;
  847. if parsed.path() == "/l/" || parsed.path() == "/l" {
  848. for (key, value) in parsed.query_pairs() {
  849. if key == "uddg" {
  850. return Some(html_entity_decode_url(value.as_ref()));
  851. }
  852. }
  853. }
  854. Some(joined)
  855. }
  856. fn html_entity_decode_url(url: &str) -> String {
  857. decode_html_entities(url)
  858. }
  859. fn host_matches_list(url: &str, domains: &[String]) -> bool {
  860. let Ok(parsed) = reqwest::Url::parse(url) else {
  861. return false;
  862. };
  863. let Some(host) = parsed.host_str() else {
  864. return false;
  865. };
  866. domains.iter().any(|domain| {
  867. let normalized = domain.trim().trim_start_matches('.');
  868. host == normalized || host.ends_with(&format!(".{normalized}"))
  869. })
  870. }
  871. fn dedupe_hits(hits: &mut Vec<SearchHit>) {
  872. let mut seen = BTreeSet::new();
  873. hits.retain(|hit| seen.insert(hit.url.clone()));
  874. }
  875. fn execute_todo_write(input: TodoWriteInput) -> Result<TodoWriteOutput, String> {
  876. validate_todos(&input.todos)?;
  877. let store_path = todo_store_path()?;
  878. let old_todos = if store_path.exists() {
  879. serde_json::from_str::<Vec<TodoItem>>(
  880. &std::fs::read_to_string(&store_path).map_err(|error| error.to_string())?,
  881. )
  882. .map_err(|error| error.to_string())?
  883. } else {
  884. Vec::new()
  885. };
  886. let all_done = input
  887. .todos
  888. .iter()
  889. .all(|todo| matches!(todo.status, TodoStatus::Completed));
  890. let persisted = if all_done {
  891. Vec::new()
  892. } else {
  893. input.todos.clone()
  894. };
  895. if let Some(parent) = store_path.parent() {
  896. std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
  897. }
  898. std::fs::write(
  899. &store_path,
  900. serde_json::to_string_pretty(&persisted).map_err(|error| error.to_string())?,
  901. )
  902. .map_err(|error| error.to_string())?;
  903. let verification_nudge_needed = (all_done
  904. && input.todos.len() >= 3
  905. && !input
  906. .todos
  907. .iter()
  908. .any(|todo| todo.content.to_lowercase().contains("verif")))
  909. .then_some(true);
  910. Ok(TodoWriteOutput {
  911. old_todos,
  912. new_todos: input.todos,
  913. verification_nudge_needed,
  914. })
  915. }
  916. fn execute_skill(input: SkillInput) -> Result<SkillOutput, String> {
  917. let skill_path = resolve_skill_path(&input.skill)?;
  918. let prompt = std::fs::read_to_string(&skill_path).map_err(|error| error.to_string())?;
  919. let description = parse_skill_description(&prompt);
  920. Ok(SkillOutput {
  921. skill: input.skill,
  922. path: skill_path.display().to_string(),
  923. args: input.args,
  924. description,
  925. prompt,
  926. })
  927. }
  928. fn validate_todos(todos: &[TodoItem]) -> Result<(), String> {
  929. if todos.is_empty() {
  930. return Err(String::from("todos must not be empty"));
  931. }
  932. let in_progress = todos
  933. .iter()
  934. .filter(|todo| matches!(todo.status, TodoStatus::InProgress))
  935. .count();
  936. if in_progress > 1 {
  937. return Err(String::from(
  938. "exactly zero or one todo items may be in_progress",
  939. ));
  940. }
  941. if todos.iter().any(|todo| todo.content.trim().is_empty()) {
  942. return Err(String::from("todo content must not be empty"));
  943. }
  944. if todos.iter().any(|todo| todo.active_form.trim().is_empty()) {
  945. return Err(String::from("todo activeForm must not be empty"));
  946. }
  947. Ok(())
  948. }
  949. fn todo_store_path() -> Result<std::path::PathBuf, String> {
  950. if let Ok(path) = std::env::var("CLAWD_TODO_STORE") {
  951. return Ok(std::path::PathBuf::from(path));
  952. }
  953. let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
  954. Ok(cwd.join(".clawd-todos.json"))
  955. }
  956. fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
  957. let requested = skill.trim().trim_start_matches('/');
  958. if requested.is_empty() {
  959. return Err(String::from("skill must not be empty"));
  960. }
  961. let mut candidates = Vec::new();
  962. if let Ok(codex_home) = std::env::var("CODEX_HOME") {
  963. candidates.push(std::path::PathBuf::from(codex_home).join("skills"));
  964. }
  965. candidates.push(std::path::PathBuf::from("/home/bellman/.codex/skills"));
  966. for root in candidates {
  967. let direct = root.join(requested).join("SKILL.md");
  968. if direct.exists() {
  969. return Ok(direct);
  970. }
  971. if let Ok(entries) = std::fs::read_dir(&root) {
  972. for entry in entries.flatten() {
  973. let path = entry.path().join("SKILL.md");
  974. if !path.exists() {
  975. continue;
  976. }
  977. if entry
  978. .file_name()
  979. .to_string_lossy()
  980. .eq_ignore_ascii_case(requested)
  981. {
  982. return Ok(path);
  983. }
  984. }
  985. }
  986. }
  987. Err(format!("unknown skill: {requested}"))
  988. }
  989. fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
  990. if input.description.trim().is_empty() {
  991. return Err(String::from("description must not be empty"));
  992. }
  993. if input.prompt.trim().is_empty() {
  994. return Err(String::from("prompt must not be empty"));
  995. }
  996. let agent_id = make_agent_id();
  997. let output_dir = agent_store_dir()?;
  998. std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?;
  999. let output_file = output_dir.join(format!("{agent_id}.md"));
  1000. let manifest_file = output_dir.join(format!("{agent_id}.json"));
  1001. let normalized_subagent_type = normalize_subagent_type(input.subagent_type.as_deref());
  1002. let agent_name = input
  1003. .name
  1004. .clone()
  1005. .unwrap_or_else(|| slugify_agent_name(&input.description));
  1006. let created_at = iso8601_now();
  1007. let output_contents = format!(
  1008. "# Agent Task
  1009. - id: {}
  1010. - name: {}
  1011. - description: {}
  1012. - subagent_type: {}
  1013. - created_at: {}
  1014. ## Prompt
  1015. {}
  1016. ",
  1017. agent_id, agent_name, input.description, normalized_subagent_type, created_at, input.prompt
  1018. );
  1019. std::fs::write(&output_file, output_contents).map_err(|error| error.to_string())?;
  1020. let manifest = AgentOutput {
  1021. agent_id,
  1022. name: agent_name,
  1023. description: input.description,
  1024. subagent_type: Some(normalized_subagent_type),
  1025. model: input.model,
  1026. status: String::from("queued"),
  1027. output_file: output_file.display().to_string(),
  1028. manifest_file: manifest_file.display().to_string(),
  1029. created_at,
  1030. };
  1031. std::fs::write(
  1032. &manifest_file,
  1033. serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?,
  1034. )
  1035. .map_err(|error| error.to_string())?;
  1036. Ok(manifest)
  1037. }
  1038. fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput {
  1039. let deferred = deferred_tool_specs();
  1040. let max_results = input.max_results.unwrap_or(5).max(1);
  1041. let query = input.query.trim().to_string();
  1042. let normalized_query = normalize_tool_search_query(&query);
  1043. let matches = search_tool_specs(&query, max_results, &deferred);
  1044. ToolSearchOutput {
  1045. matches,
  1046. query,
  1047. normalized_query,
  1048. total_deferred_tools: deferred.len(),
  1049. pending_mcp_servers: None,
  1050. }
  1051. }
  1052. fn deferred_tool_specs() -> Vec<ToolSpec> {
  1053. mvp_tool_specs()
  1054. .into_iter()
  1055. .filter(|spec| {
  1056. !matches!(
  1057. spec.name,
  1058. "bash" | "read_file" | "write_file" | "edit_file" | "glob_search" | "grep_search"
  1059. )
  1060. })
  1061. .collect()
  1062. }
  1063. fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec<String> {
  1064. let lowered = query.to_lowercase();
  1065. if let Some(selection) = lowered.strip_prefix("select:") {
  1066. return selection
  1067. .split(',')
  1068. .map(str::trim)
  1069. .filter(|part| !part.is_empty())
  1070. .filter_map(|wanted| {
  1071. let wanted = canonical_tool_token(wanted);
  1072. specs
  1073. .iter()
  1074. .find(|spec| canonical_tool_token(spec.name) == wanted)
  1075. .map(|spec| spec.name.to_string())
  1076. })
  1077. .take(max_results)
  1078. .collect();
  1079. }
  1080. let mut required = Vec::new();
  1081. let mut optional = Vec::new();
  1082. for term in lowered.split_whitespace() {
  1083. if let Some(rest) = term.strip_prefix('+') {
  1084. if !rest.is_empty() {
  1085. required.push(rest);
  1086. }
  1087. } else {
  1088. optional.push(term);
  1089. }
  1090. }
  1091. let terms = if required.is_empty() {
  1092. optional.clone()
  1093. } else {
  1094. required.iter().chain(optional.iter()).copied().collect()
  1095. };
  1096. let mut scored = specs
  1097. .iter()
  1098. .filter_map(|spec| {
  1099. let name = spec.name.to_lowercase();
  1100. let canonical_name = canonical_tool_token(spec.name);
  1101. let normalized_description = normalize_tool_search_query(spec.description);
  1102. let haystack = format!(
  1103. "{name} {} {canonical_name}",
  1104. spec.description.to_lowercase()
  1105. );
  1106. let normalized_haystack = format!("{canonical_name} {normalized_description}");
  1107. if required.iter().any(|term| !haystack.contains(term)) {
  1108. return None;
  1109. }
  1110. let mut score = 0_i32;
  1111. for term in &terms {
  1112. let canonical_term = canonical_tool_token(term);
  1113. if haystack.contains(term) {
  1114. score += 2;
  1115. }
  1116. if name == *term {
  1117. score += 8;
  1118. }
  1119. if name.contains(term) {
  1120. score += 4;
  1121. }
  1122. if canonical_name == canonical_term {
  1123. score += 12;
  1124. }
  1125. if normalized_haystack.contains(&canonical_term) {
  1126. score += 3;
  1127. }
  1128. }
  1129. if score == 0 && !lowered.is_empty() {
  1130. return None;
  1131. }
  1132. Some((score, spec.name.to_string()))
  1133. })
  1134. .collect::<Vec<_>>();
  1135. scored.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
  1136. scored
  1137. .into_iter()
  1138. .map(|(_, name)| name)
  1139. .take(max_results)
  1140. .collect()
  1141. }
  1142. fn normalize_tool_search_query(query: &str) -> String {
  1143. query
  1144. .trim()
  1145. .split(|ch: char| ch.is_whitespace() || ch == ',')
  1146. .filter(|term| !term.is_empty())
  1147. .map(canonical_tool_token)
  1148. .collect::<Vec<_>>()
  1149. .join(" ")
  1150. }
  1151. fn canonical_tool_token(value: &str) -> String {
  1152. let mut canonical = value
  1153. .chars()
  1154. .filter(|ch| ch.is_ascii_alphanumeric())
  1155. .flat_map(char::to_lowercase)
  1156. .collect::<String>();
  1157. if let Some(stripped) = canonical.strip_suffix("tool") {
  1158. canonical = stripped.to_string();
  1159. }
  1160. canonical
  1161. }
  1162. fn agent_store_dir() -> Result<std::path::PathBuf, String> {
  1163. if let Ok(path) = std::env::var("CLAWD_AGENT_STORE") {
  1164. return Ok(std::path::PathBuf::from(path));
  1165. }
  1166. let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
  1167. Ok(cwd.join(".clawd-agents"))
  1168. }
  1169. fn make_agent_id() -> String {
  1170. let nanos = std::time::SystemTime::now()
  1171. .duration_since(std::time::UNIX_EPOCH)
  1172. .unwrap_or_default()
  1173. .as_nanos();
  1174. format!("agent-{nanos}")
  1175. }
  1176. fn slugify_agent_name(description: &str) -> String {
  1177. let mut out = description
  1178. .chars()
  1179. .map(|ch| {
  1180. if ch.is_ascii_alphanumeric() {
  1181. ch.to_ascii_lowercase()
  1182. } else {
  1183. '-'
  1184. }
  1185. })
  1186. .collect::<String>();
  1187. while out.contains("--") {
  1188. out = out.replace("--", "-");
  1189. }
  1190. out.trim_matches('-').chars().take(32).collect()
  1191. }
  1192. fn normalize_subagent_type(subagent_type: Option<&str>) -> String {
  1193. let trimmed = subagent_type.map(str::trim).unwrap_or_default();
  1194. if trimmed.is_empty() {
  1195. return String::from("general-purpose");
  1196. }
  1197. match canonical_tool_token(trimmed).as_str() {
  1198. "general" | "generalpurpose" | "generalpurposeagent" => String::from("general-purpose"),
  1199. "explore" | "explorer" | "exploreagent" => String::from("Explore"),
  1200. "plan" | "planagent" => String::from("Plan"),
  1201. "verification" | "verificationagent" | "verify" | "verifier" => {
  1202. String::from("Verification")
  1203. }
  1204. "claudecodeguide" | "claudecodeguideagent" | "guide" => String::from("claude-code-guide"),
  1205. "statusline" | "statuslinesetup" => String::from("statusline-setup"),
  1206. _ => trimmed.to_string(),
  1207. }
  1208. }
  1209. fn iso8601_now() -> String {
  1210. std::time::SystemTime::now()
  1211. .duration_since(std::time::UNIX_EPOCH)
  1212. .unwrap_or_default()
  1213. .as_secs()
  1214. .to_string()
  1215. }
  1216. fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput, String> {
  1217. let path = std::path::PathBuf::from(&input.notebook_path);
  1218. if path.extension().and_then(|ext| ext.to_str()) != Some("ipynb") {
  1219. return Err(String::from(
  1220. "File must be a Jupyter notebook (.ipynb file).",
  1221. ));
  1222. }
  1223. let original_file = std::fs::read_to_string(&path).map_err(|error| error.to_string())?;
  1224. let mut notebook: serde_json::Value =
  1225. serde_json::from_str(&original_file).map_err(|error| error.to_string())?;
  1226. let language = notebook
  1227. .get("metadata")
  1228. .and_then(|metadata| metadata.get("kernelspec"))
  1229. .and_then(|kernelspec| kernelspec.get("language"))
  1230. .and_then(serde_json::Value::as_str)
  1231. .unwrap_or("python")
  1232. .to_string();
  1233. let cells = notebook
  1234. .get_mut("cells")
  1235. .and_then(serde_json::Value::as_array_mut)
  1236. .ok_or_else(|| String::from("Notebook cells array not found"))?;
  1237. let edit_mode = input.edit_mode.unwrap_or(NotebookEditMode::Replace);
  1238. let target_index = match input.cell_id.as_deref() {
  1239. Some(cell_id) => Some(resolve_cell_index(cells, Some(cell_id), edit_mode)?),
  1240. None if matches!(
  1241. edit_mode,
  1242. NotebookEditMode::Replace | NotebookEditMode::Delete
  1243. ) =>
  1244. {
  1245. Some(resolve_cell_index(cells, None, edit_mode)?)
  1246. }
  1247. None => None,
  1248. };
  1249. let resolved_cell_type = match edit_mode {
  1250. NotebookEditMode::Delete => None,
  1251. NotebookEditMode::Insert => Some(input.cell_type.unwrap_or(NotebookCellType::Code)),
  1252. NotebookEditMode::Replace => Some(input.cell_type.unwrap_or_else(|| {
  1253. target_index
  1254. .and_then(|index| cells.get(index))
  1255. .and_then(cell_kind)
  1256. .unwrap_or(NotebookCellType::Code)
  1257. })),
  1258. };
  1259. let new_source = require_notebook_source(input.new_source, edit_mode)?;
  1260. let cell_id = match edit_mode {
  1261. NotebookEditMode::Insert => {
  1262. let resolved_cell_type = resolved_cell_type.expect("insert cell type");
  1263. let new_id = make_cell_id(cells.len());
  1264. let new_cell = build_notebook_cell(&new_id, resolved_cell_type, &new_source);
  1265. let insert_at = target_index.map_or(cells.len(), |index| index + 1);
  1266. cells.insert(insert_at, new_cell);
  1267. cells
  1268. .get(insert_at)
  1269. .and_then(|cell| cell.get("id"))
  1270. .and_then(serde_json::Value::as_str)
  1271. .map(ToString::to_string)
  1272. }
  1273. NotebookEditMode::Delete => {
  1274. let removed = cells.remove(target_index.expect("delete target index"));
  1275. removed
  1276. .get("id")
  1277. .and_then(serde_json::Value::as_str)
  1278. .map(ToString::to_string)
  1279. }
  1280. NotebookEditMode::Replace => {
  1281. let resolved_cell_type = resolved_cell_type.expect("replace cell type");
  1282. let cell = cells
  1283. .get_mut(target_index.expect("replace target index"))
  1284. .ok_or_else(|| String::from("Cell index out of range"))?;
  1285. cell["source"] = serde_json::Value::Array(source_lines(&new_source));
  1286. cell["cell_type"] = serde_json::Value::String(match resolved_cell_type {
  1287. NotebookCellType::Code => String::from("code"),
  1288. NotebookCellType::Markdown => String::from("markdown"),
  1289. });
  1290. match resolved_cell_type {
  1291. NotebookCellType::Code => {
  1292. if !cell.get("outputs").is_some_and(serde_json::Value::is_array) {
  1293. cell["outputs"] = json!([]);
  1294. }
  1295. if !cell.get("execution_count").is_some() {
  1296. cell["execution_count"] = serde_json::Value::Null;
  1297. }
  1298. }
  1299. NotebookCellType::Markdown => {
  1300. if let Some(object) = cell.as_object_mut() {
  1301. object.remove("outputs");
  1302. object.remove("execution_count");
  1303. }
  1304. }
  1305. }
  1306. cell.get("id")
  1307. .and_then(serde_json::Value::as_str)
  1308. .map(ToString::to_string)
  1309. }
  1310. };
  1311. let updated_file =
  1312. serde_json::to_string_pretty(&notebook).map_err(|error| error.to_string())?;
  1313. std::fs::write(&path, &updated_file).map_err(|error| error.to_string())?;
  1314. Ok(NotebookEditOutput {
  1315. new_source,
  1316. cell_id,
  1317. cell_type: resolved_cell_type,
  1318. language,
  1319. edit_mode: format_notebook_edit_mode(edit_mode),
  1320. error: None,
  1321. notebook_path: path.display().to_string(),
  1322. original_file,
  1323. updated_file,
  1324. })
  1325. }
  1326. fn require_notebook_source(
  1327. source: Option<String>,
  1328. edit_mode: NotebookEditMode,
  1329. ) -> Result<String, String> {
  1330. match edit_mode {
  1331. NotebookEditMode::Delete => Ok(source.unwrap_or_default()),
  1332. NotebookEditMode::Insert | NotebookEditMode::Replace => source
  1333. .ok_or_else(|| String::from("new_source is required for insert and replace edits")),
  1334. }
  1335. }
  1336. fn build_notebook_cell(cell_id: &str, cell_type: NotebookCellType, source: &str) -> Value {
  1337. let mut cell = json!({
  1338. "cell_type": match cell_type {
  1339. NotebookCellType::Code => "code",
  1340. NotebookCellType::Markdown => "markdown",
  1341. },
  1342. "id": cell_id,
  1343. "metadata": {},
  1344. "source": source_lines(source),
  1345. });
  1346. if let Some(object) = cell.as_object_mut() {
  1347. match cell_type {
  1348. NotebookCellType::Code => {
  1349. object.insert(String::from("outputs"), json!([]));
  1350. object.insert(String::from("execution_count"), Value::Null);
  1351. }
  1352. NotebookCellType::Markdown => {}
  1353. }
  1354. }
  1355. cell
  1356. }
  1357. fn cell_kind(cell: &serde_json::Value) -> Option<NotebookCellType> {
  1358. cell.get("cell_type")
  1359. .and_then(serde_json::Value::as_str)
  1360. .map(|kind| {
  1361. if kind == "markdown" {
  1362. NotebookCellType::Markdown
  1363. } else {
  1364. NotebookCellType::Code
  1365. }
  1366. })
  1367. }
  1368. fn execute_sleep(input: SleepInput) -> SleepOutput {
  1369. std::thread::sleep(Duration::from_millis(input.duration_ms));
  1370. SleepOutput {
  1371. duration_ms: input.duration_ms,
  1372. message: format!("Slept for {}ms", input.duration_ms),
  1373. }
  1374. }
  1375. fn execute_powershell(input: PowerShellInput) -> std::io::Result<runtime::BashCommandOutput> {
  1376. let _ = &input.description;
  1377. let shell = detect_powershell_shell();
  1378. execute_shell_command(
  1379. shell,
  1380. &input.command,
  1381. input.timeout,
  1382. input.run_in_background,
  1383. )
  1384. }
  1385. fn detect_powershell_shell() -> &'static str {
  1386. if command_exists("pwsh") {
  1387. "pwsh"
  1388. } else {
  1389. "powershell"
  1390. }
  1391. }
  1392. fn command_exists(command: &str) -> bool {
  1393. std::process::Command::new("sh")
  1394. .arg("-lc")
  1395. .arg(format!("command -v {command} >/dev/null 2>&1"))
  1396. .status()
  1397. .map(|status| status.success())
  1398. .unwrap_or(false)
  1399. }
  1400. fn execute_shell_command(
  1401. shell: &str,
  1402. command: &str,
  1403. timeout: Option<u64>,
  1404. run_in_background: Option<bool>,
  1405. ) -> std::io::Result<runtime::BashCommandOutput> {
  1406. if run_in_background.unwrap_or(false) {
  1407. let child = std::process::Command::new(shell)
  1408. .arg("-NoProfile")
  1409. .arg("-NonInteractive")
  1410. .arg("-Command")
  1411. .arg(command)
  1412. .stdin(std::process::Stdio::null())
  1413. .stdout(std::process::Stdio::null())
  1414. .stderr(std::process::Stdio::null())
  1415. .spawn()?;
  1416. return Ok(runtime::BashCommandOutput {
  1417. stdout: String::new(),
  1418. stderr: String::new(),
  1419. raw_output_path: None,
  1420. interrupted: false,
  1421. is_image: None,
  1422. background_task_id: Some(child.id().to_string()),
  1423. backgrounded_by_user: Some(false),
  1424. assistant_auto_backgrounded: Some(false),
  1425. dangerously_disable_sandbox: None,
  1426. return_code_interpretation: None,
  1427. no_output_expected: Some(true),
  1428. structured_content: None,
  1429. persisted_output_path: None,
  1430. persisted_output_size: None,
  1431. });
  1432. }
  1433. let mut process = std::process::Command::new(shell);
  1434. process
  1435. .arg("-NoProfile")
  1436. .arg("-NonInteractive")
  1437. .arg("-Command")
  1438. .arg(command);
  1439. process
  1440. .stdout(std::process::Stdio::piped())
  1441. .stderr(std::process::Stdio::piped());
  1442. if let Some(timeout_ms) = timeout {
  1443. let mut child = process.spawn()?;
  1444. let started = Instant::now();
  1445. loop {
  1446. if let Some(status) = child.try_wait()? {
  1447. let output = child.wait_with_output()?;
  1448. return Ok(runtime::BashCommandOutput {
  1449. stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
  1450. stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
  1451. raw_output_path: None,
  1452. interrupted: false,
  1453. is_image: None,
  1454. background_task_id: None,
  1455. backgrounded_by_user: None,
  1456. assistant_auto_backgrounded: None,
  1457. dangerously_disable_sandbox: None,
  1458. return_code_interpretation: status
  1459. .code()
  1460. .filter(|code| *code != 0)
  1461. .map(|code| format!("exit_code:{code}")),
  1462. no_output_expected: Some(output.stdout.is_empty() && output.stderr.is_empty()),
  1463. structured_content: None,
  1464. persisted_output_path: None,
  1465. persisted_output_size: None,
  1466. });
  1467. }
  1468. if started.elapsed() >= Duration::from_millis(timeout_ms) {
  1469. let _ = child.kill();
  1470. let output = child.wait_with_output()?;
  1471. let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
  1472. let stderr = if stderr.trim().is_empty() {
  1473. format!("Command exceeded timeout of {timeout_ms} ms")
  1474. } else {
  1475. format!(
  1476. "{}
  1477. Command exceeded timeout of {timeout_ms} ms",
  1478. stderr.trim_end()
  1479. )
  1480. };
  1481. return Ok(runtime::BashCommandOutput {
  1482. stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
  1483. stderr,
  1484. raw_output_path: None,
  1485. interrupted: true,
  1486. is_image: None,
  1487. background_task_id: None,
  1488. backgrounded_by_user: None,
  1489. assistant_auto_backgrounded: None,
  1490. dangerously_disable_sandbox: None,
  1491. return_code_interpretation: Some(String::from("timeout")),
  1492. no_output_expected: Some(false),
  1493. structured_content: None,
  1494. persisted_output_path: None,
  1495. persisted_output_size: None,
  1496. });
  1497. }
  1498. std::thread::sleep(Duration::from_millis(10));
  1499. }
  1500. }
  1501. let output = process.output()?;
  1502. Ok(runtime::BashCommandOutput {
  1503. stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
  1504. stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
  1505. raw_output_path: None,
  1506. interrupted: false,
  1507. is_image: None,
  1508. background_task_id: None,
  1509. backgrounded_by_user: None,
  1510. assistant_auto_backgrounded: None,
  1511. dangerously_disable_sandbox: None,
  1512. return_code_interpretation: output
  1513. .status
  1514. .code()
  1515. .filter(|code| *code != 0)
  1516. .map(|code| format!("exit_code:{code}")),
  1517. no_output_expected: Some(output.stdout.is_empty() && output.stderr.is_empty()),
  1518. structured_content: None,
  1519. persisted_output_path: None,
  1520. persisted_output_size: None,
  1521. })
  1522. }
  1523. fn resolve_cell_index(
  1524. cells: &[serde_json::Value],
  1525. cell_id: Option<&str>,
  1526. edit_mode: NotebookEditMode,
  1527. ) -> Result<usize, String> {
  1528. if cells.is_empty()
  1529. && matches!(
  1530. edit_mode,
  1531. NotebookEditMode::Replace | NotebookEditMode::Delete
  1532. )
  1533. {
  1534. return Err(String::from("Notebook has no cells to edit"));
  1535. }
  1536. if let Some(cell_id) = cell_id {
  1537. cells
  1538. .iter()
  1539. .position(|cell| cell.get("id").and_then(serde_json::Value::as_str) == Some(cell_id))
  1540. .ok_or_else(|| format!("Cell id not found: {cell_id}"))
  1541. } else {
  1542. Ok(cells.len().saturating_sub(1))
  1543. }
  1544. }
  1545. fn source_lines(source: &str) -> Vec<serde_json::Value> {
  1546. if source.is_empty() {
  1547. return vec![serde_json::Value::String(String::new())];
  1548. }
  1549. source
  1550. .split_inclusive('\n')
  1551. .map(|line| serde_json::Value::String(line.to_string()))
  1552. .collect()
  1553. }
  1554. fn format_notebook_edit_mode(mode: NotebookEditMode) -> String {
  1555. match mode {
  1556. NotebookEditMode::Replace => String::from("replace"),
  1557. NotebookEditMode::Insert => String::from("insert"),
  1558. NotebookEditMode::Delete => String::from("delete"),
  1559. }
  1560. }
  1561. fn make_cell_id(index: usize) -> String {
  1562. format!("cell-{}", index + 1)
  1563. }
  1564. fn parse_skill_description(contents: &str) -> Option<String> {
  1565. for line in contents.lines() {
  1566. if let Some(value) = line.strip_prefix("description:") {
  1567. let trimmed = value.trim();
  1568. if !trimmed.is_empty() {
  1569. return Some(trimmed.to_string());
  1570. }
  1571. }
  1572. }
  1573. None
  1574. }
  1575. #[cfg(test)]
  1576. mod tests {
  1577. use std::io::{Read, Write};
  1578. use std::net::{SocketAddr, TcpListener};
  1579. use std::sync::Arc;
  1580. use std::thread;
  1581. use std::time::Duration;
  1582. use super::{execute_tool, mvp_tool_specs};
  1583. use serde_json::json;
  1584. #[test]
  1585. fn exposes_mvp_tools() {
  1586. let names = mvp_tool_specs()
  1587. .into_iter()
  1588. .map(|spec| spec.name)
  1589. .collect::<Vec<_>>();
  1590. assert!(names.contains(&"bash"));
  1591. assert!(names.contains(&"read_file"));
  1592. assert!(names.contains(&"WebFetch"));
  1593. assert!(names.contains(&"WebSearch"));
  1594. assert!(names.contains(&"TodoWrite"));
  1595. assert!(names.contains(&"Skill"));
  1596. assert!(names.contains(&"Agent"));
  1597. assert!(names.contains(&"ToolSearch"));
  1598. assert!(names.contains(&"NotebookEdit"));
  1599. assert!(names.contains(&"Sleep"));
  1600. assert!(names.contains(&"PowerShell"));
  1601. }
  1602. #[test]
  1603. fn rejects_unknown_tool_names() {
  1604. let error = execute_tool("nope", &json!({})).expect_err("tool should be rejected");
  1605. assert!(error.contains("unsupported tool"));
  1606. }
  1607. #[test]
  1608. fn web_fetch_returns_prompt_aware_summary() {
  1609. let server = TestServer::spawn(Arc::new(|request_line: &str| {
  1610. assert!(request_line.starts_with("GET /page "));
  1611. HttpResponse::html(
  1612. 200,
  1613. "OK",
  1614. "<html><head><title>Ignored</title></head><body><h1>Test Page</h1><p>Hello <b>world</b> from local server.</p></body></html>",
  1615. )
  1616. }));
  1617. let result = execute_tool(
  1618. "WebFetch",
  1619. &json!({
  1620. "url": format!("http://{}/page", server.addr()),
  1621. "prompt": "Summarize this page"
  1622. }),
  1623. )
  1624. .expect("WebFetch should succeed");
  1625. let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
  1626. assert_eq!(output["code"], 200);
  1627. let summary = output["result"].as_str().expect("result string");
  1628. assert!(summary.contains("Fetched"));
  1629. assert!(summary.contains("Test Page"));
  1630. assert!(summary.contains("Hello world from local server"));
  1631. }
  1632. #[test]
  1633. fn web_search_extracts_and_filters_results() {
  1634. let server = TestServer::spawn(Arc::new(|request_line: &str| {
  1635. assert!(request_line.contains("GET /search?q=rust+web+search "));
  1636. HttpResponse::html(
  1637. 200,
  1638. "OK",
  1639. r#"
  1640. <html><body>
  1641. <a class="result__a" href="https://docs.rs/reqwest">Reqwest docs</a>
  1642. <a class="result__a" href="https://example.com/blocked">Blocked result</a>
  1643. </body></html>
  1644. "#,
  1645. )
  1646. }));
  1647. std::env::set_var(
  1648. "CLAWD_WEB_SEARCH_BASE_URL",
  1649. format!("http://{}/search", server.addr()),
  1650. );
  1651. let result = execute_tool(
  1652. "WebSearch",
  1653. &json!({
  1654. "query": "rust web search",
  1655. "allowed_domains": ["docs.rs"],
  1656. "blocked_domains": ["example.com"]
  1657. }),
  1658. )
  1659. .expect("WebSearch should succeed");
  1660. std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
  1661. let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
  1662. assert_eq!(output["query"], "rust web search");
  1663. let results = output["results"].as_array().expect("results array");
  1664. let search_result = results
  1665. .iter()
  1666. .find(|item| item.get("content").is_some())
  1667. .expect("search result block present");
  1668. let content = search_result["content"].as_array().expect("content array");
  1669. assert_eq!(content.len(), 1);
  1670. assert_eq!(content[0]["title"], "Reqwest docs");
  1671. assert_eq!(content[0]["url"], "https://docs.rs/reqwest");
  1672. }
  1673. #[test]
  1674. fn todo_write_persists_and_returns_previous_state() {
  1675. let path = std::env::temp_dir().join(format!(
  1676. "clawd-tools-todos-{}.json",
  1677. std::time::SystemTime::now()
  1678. .duration_since(std::time::UNIX_EPOCH)
  1679. .expect("time")
  1680. .as_nanos()
  1681. ));
  1682. std::env::set_var("CLAWD_TODO_STORE", &path);
  1683. let first = execute_tool(
  1684. "TodoWrite",
  1685. &json!({
  1686. "todos": [
  1687. {"content": "Add tool", "activeForm": "Adding tool", "status": "in_progress"},
  1688. {"content": "Run tests", "activeForm": "Running tests", "status": "pending"}
  1689. ]
  1690. }),
  1691. )
  1692. .expect("TodoWrite should succeed");
  1693. let first_output: serde_json::Value = serde_json::from_str(&first).expect("valid json");
  1694. assert_eq!(first_output["oldTodos"].as_array().expect("array").len(), 0);
  1695. let second = execute_tool(
  1696. "TodoWrite",
  1697. &json!({
  1698. "todos": [
  1699. {"content": "Add tool", "activeForm": "Adding tool", "status": "completed"},
  1700. {"content": "Run tests", "activeForm": "Running tests", "status": "completed"},
  1701. {"content": "Verify", "activeForm": "Verifying", "status": "completed"}
  1702. ]
  1703. }),
  1704. )
  1705. .expect("TodoWrite should succeed");
  1706. std::env::remove_var("CLAWD_TODO_STORE");
  1707. let _ = std::fs::remove_file(path);
  1708. let second_output: serde_json::Value = serde_json::from_str(&second).expect("valid json");
  1709. assert_eq!(
  1710. second_output["oldTodos"].as_array().expect("array").len(),
  1711. 2
  1712. );
  1713. assert_eq!(
  1714. second_output["newTodos"].as_array().expect("array").len(),
  1715. 3
  1716. );
  1717. assert!(second_output["verificationNudgeNeeded"].is_null());
  1718. }
  1719. #[test]
  1720. fn skill_loads_local_skill_prompt() {
  1721. let result = execute_tool(
  1722. "Skill",
  1723. &json!({
  1724. "skill": "help",
  1725. "args": "overview"
  1726. }),
  1727. )
  1728. .expect("Skill should succeed");
  1729. let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
  1730. assert_eq!(output["skill"], "help");
  1731. assert!(output["path"]
  1732. .as_str()
  1733. .expect("path")
  1734. .ends_with("/help/SKILL.md"));
  1735. assert!(output["prompt"]
  1736. .as_str()
  1737. .expect("prompt")
  1738. .contains("Guide on using oh-my-codex plugin"));
  1739. }
  1740. #[test]
  1741. fn tool_search_supports_keyword_and_select_queries() {
  1742. let keyword = execute_tool(
  1743. "ToolSearch",
  1744. &json!({"query": "web current", "max_results": 3}),
  1745. )
  1746. .expect("ToolSearch should succeed");
  1747. let keyword_output: serde_json::Value = serde_json::from_str(&keyword).expect("valid json");
  1748. let matches = keyword_output["matches"].as_array().expect("matches");
  1749. assert!(matches.iter().any(|value| value == "WebSearch"));
  1750. let selected = execute_tool("ToolSearch", &json!({"query": "select:Agent,Skill"}))
  1751. .expect("ToolSearch should succeed");
  1752. let selected_output: serde_json::Value =
  1753. serde_json::from_str(&selected).expect("valid json");
  1754. assert_eq!(selected_output["matches"][0], "Agent");
  1755. assert_eq!(selected_output["matches"][1], "Skill");
  1756. let aliased = execute_tool("ToolSearch", &json!({"query": "AgentTool"}))
  1757. .expect("ToolSearch should support tool aliases");
  1758. let aliased_output: serde_json::Value = serde_json::from_str(&aliased).expect("valid json");
  1759. assert_eq!(aliased_output["matches"][0], "Agent");
  1760. assert_eq!(aliased_output["normalized_query"], "agent");
  1761. let selected_with_alias =
  1762. execute_tool("ToolSearch", &json!({"query": "select:AgentTool,Skill"}))
  1763. .expect("ToolSearch alias select should succeed");
  1764. let selected_with_alias_output: serde_json::Value =
  1765. serde_json::from_str(&selected_with_alias).expect("valid json");
  1766. assert_eq!(selected_with_alias_output["matches"][0], "Agent");
  1767. assert_eq!(selected_with_alias_output["matches"][1], "Skill");
  1768. }
  1769. #[test]
  1770. fn agent_persists_handoff_metadata() {
  1771. let dir = std::env::temp_dir().join(format!(
  1772. "clawd-agent-store-{}",
  1773. std::time::SystemTime::now()
  1774. .duration_since(std::time::UNIX_EPOCH)
  1775. .expect("time")
  1776. .as_nanos()
  1777. ));
  1778. std::env::set_var("CLAWD_AGENT_STORE", &dir);
  1779. let result = execute_tool(
  1780. "Agent",
  1781. &json!({
  1782. "description": "Audit the branch",
  1783. "prompt": "Check tests and outstanding work.",
  1784. "subagent_type": "Explore",
  1785. "name": "ship-audit"
  1786. }),
  1787. )
  1788. .expect("Agent should succeed");
  1789. std::env::remove_var("CLAWD_AGENT_STORE");
  1790. let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
  1791. assert_eq!(output["name"], "ship-audit");
  1792. assert_eq!(output["subagentType"], "Explore");
  1793. assert_eq!(output["status"], "queued");
  1794. assert!(output["createdAt"].as_str().is_some());
  1795. let manifest_file = output["manifestFile"].as_str().expect("manifest file");
  1796. let output_file = output["outputFile"].as_str().expect("output file");
  1797. let contents = std::fs::read_to_string(output_file).expect("agent file exists");
  1798. let manifest_contents =
  1799. std::fs::read_to_string(manifest_file).expect("manifest file exists");
  1800. assert!(contents.contains("Audit the branch"));
  1801. assert!(contents.contains("Check tests and outstanding work."));
  1802. assert!(manifest_contents.contains("\"subagentType\": \"Explore\""));
  1803. let normalized = execute_tool(
  1804. "Agent",
  1805. &json!({
  1806. "description": "Verify the branch",
  1807. "prompt": "Check tests.",
  1808. "subagent_type": "explorer"
  1809. }),
  1810. )
  1811. .expect("Agent should normalize built-in aliases");
  1812. let normalized_output: serde_json::Value =
  1813. serde_json::from_str(&normalized).expect("valid json");
  1814. assert_eq!(normalized_output["subagentType"], "Explore");
  1815. let _ = std::fs::remove_dir_all(dir);
  1816. }
  1817. #[test]
  1818. fn notebook_edit_replaces_inserts_and_deletes_cells() {
  1819. let path = std::env::temp_dir().join(format!(
  1820. "clawd-notebook-{}.ipynb",
  1821. std::time::SystemTime::now()
  1822. .duration_since(std::time::UNIX_EPOCH)
  1823. .expect("time")
  1824. .as_nanos()
  1825. ));
  1826. std::fs::write(
  1827. &path,
  1828. r#"{
  1829. "cells": [
  1830. {"cell_type": "code", "id": "cell-a", "metadata": {}, "source": ["print(1)\n"], "outputs": [], "execution_count": null}
  1831. ],
  1832. "metadata": {"kernelspec": {"language": "python"}},
  1833. "nbformat": 4,
  1834. "nbformat_minor": 5
  1835. }"#,
  1836. )
  1837. .expect("write notebook");
  1838. let replaced = execute_tool(
  1839. "NotebookEdit",
  1840. &json!({
  1841. "notebook_path": path.display().to_string(),
  1842. "cell_id": "cell-a",
  1843. "new_source": "print(2)\n",
  1844. "edit_mode": "replace"
  1845. }),
  1846. )
  1847. .expect("NotebookEdit replace should succeed");
  1848. let replaced_output: serde_json::Value = serde_json::from_str(&replaced).expect("json");
  1849. assert_eq!(replaced_output["cell_id"], "cell-a");
  1850. assert_eq!(replaced_output["cell_type"], "code");
  1851. let inserted = execute_tool(
  1852. "NotebookEdit",
  1853. &json!({
  1854. "notebook_path": path.display().to_string(),
  1855. "cell_id": "cell-a",
  1856. "new_source": "# heading\n",
  1857. "cell_type": "markdown",
  1858. "edit_mode": "insert"
  1859. }),
  1860. )
  1861. .expect("NotebookEdit insert should succeed");
  1862. let inserted_output: serde_json::Value = serde_json::from_str(&inserted).expect("json");
  1863. assert_eq!(inserted_output["cell_type"], "markdown");
  1864. let appended = execute_tool(
  1865. "NotebookEdit",
  1866. &json!({
  1867. "notebook_path": path.display().to_string(),
  1868. "new_source": "print(3)\n",
  1869. "edit_mode": "insert"
  1870. }),
  1871. )
  1872. .expect("NotebookEdit append should succeed");
  1873. let appended_output: serde_json::Value = serde_json::from_str(&appended).expect("json");
  1874. assert_eq!(appended_output["cell_type"], "code");
  1875. let deleted = execute_tool(
  1876. "NotebookEdit",
  1877. &json!({
  1878. "notebook_path": path.display().to_string(),
  1879. "cell_id": "cell-a",
  1880. "edit_mode": "delete"
  1881. }),
  1882. )
  1883. .expect("NotebookEdit delete should succeed without new_source");
  1884. let deleted_output: serde_json::Value = serde_json::from_str(&deleted).expect("json");
  1885. assert!(deleted_output["cell_type"].is_null());
  1886. assert_eq!(deleted_output["new_source"], "");
  1887. let final_notebook: serde_json::Value =
  1888. serde_json::from_str(&std::fs::read_to_string(&path).expect("read notebook"))
  1889. .expect("valid notebook json");
  1890. let cells = final_notebook["cells"].as_array().expect("cells array");
  1891. assert_eq!(cells.len(), 2);
  1892. assert_eq!(cells[0]["cell_type"], "markdown");
  1893. assert!(cells[0].get("outputs").is_none());
  1894. assert_eq!(cells[1]["cell_type"], "code");
  1895. assert_eq!(cells[1]["source"][0], "print(3)\n");
  1896. let _ = std::fs::remove_file(path);
  1897. }
  1898. #[test]
  1899. fn sleep_waits_and_reports_duration() {
  1900. let started = std::time::Instant::now();
  1901. let result =
  1902. execute_tool("Sleep", &json!({"duration_ms": 20})).expect("Sleep should succeed");
  1903. let elapsed = started.elapsed();
  1904. let output: serde_json::Value = serde_json::from_str(&result).expect("json");
  1905. assert_eq!(output["duration_ms"], 20);
  1906. assert!(output["message"]
  1907. .as_str()
  1908. .expect("message")
  1909. .contains("Slept for 20ms"));
  1910. assert!(elapsed >= Duration::from_millis(15));
  1911. }
  1912. #[test]
  1913. fn powershell_runs_via_stub_shell() {
  1914. let dir = std::env::temp_dir().join(format!(
  1915. "clawd-pwsh-bin-{}",
  1916. std::time::SystemTime::now()
  1917. .duration_since(std::time::UNIX_EPOCH)
  1918. .expect("time")
  1919. .as_nanos()
  1920. ));
  1921. std::fs::create_dir_all(&dir).expect("create dir");
  1922. let script = dir.join("pwsh");
  1923. std::fs::write(
  1924. &script,
  1925. r#"#!/bin/sh
  1926. while [ "$1" != "-Command" ] && [ $# -gt 0 ]; do shift; done
  1927. shift
  1928. printf 'pwsh:%s' "$1"
  1929. "#,
  1930. )
  1931. .expect("write script");
  1932. std::process::Command::new("chmod")
  1933. .arg("+x")
  1934. .arg(&script)
  1935. .status()
  1936. .expect("chmod");
  1937. let original_path = std::env::var("PATH").unwrap_or_default();
  1938. std::env::set_var("PATH", format!("{}:{}", dir.display(), original_path));
  1939. let result = execute_tool(
  1940. "PowerShell",
  1941. &json!({"command": "Write-Output hello", "timeout": 1000}),
  1942. )
  1943. .expect("PowerShell should succeed");
  1944. std::env::set_var("PATH", original_path);
  1945. let _ = std::fs::remove_dir_all(dir);
  1946. let output: serde_json::Value = serde_json::from_str(&result).expect("json");
  1947. assert_eq!(output["stdout"], "pwsh:Write-Output hello");
  1948. assert!(output["stderr"].as_str().expect("stderr").is_empty());
  1949. }
  1950. struct TestServer {
  1951. addr: SocketAddr,
  1952. shutdown: Option<std::sync::mpsc::Sender<()>>,
  1953. handle: Option<thread::JoinHandle<()>>,
  1954. }
  1955. impl TestServer {
  1956. fn spawn(handler: Arc<dyn Fn(&str) -> HttpResponse + Send + Sync + 'static>) -> Self {
  1957. let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
  1958. listener
  1959. .set_nonblocking(true)
  1960. .expect("set nonblocking listener");
  1961. let addr = listener.local_addr().expect("local addr");
  1962. let (tx, rx) = std::sync::mpsc::channel::<()>();
  1963. let handle = thread::spawn(move || loop {
  1964. if rx.try_recv().is_ok() {
  1965. break;
  1966. }
  1967. match listener.accept() {
  1968. Ok((mut stream, _)) => {
  1969. let mut buffer = [0_u8; 4096];
  1970. let size = stream.read(&mut buffer).expect("read request");
  1971. let request = String::from_utf8_lossy(&buffer[..size]).into_owned();
  1972. let request_line = request.lines().next().unwrap_or_default().to_string();
  1973. let response = handler(&request_line);
  1974. stream
  1975. .write_all(response.to_bytes().as_slice())
  1976. .expect("write response");
  1977. }
  1978. Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
  1979. thread::sleep(Duration::from_millis(10));
  1980. }
  1981. Err(error) => panic!("server accept failed: {error}"),
  1982. }
  1983. });
  1984. Self {
  1985. addr,
  1986. shutdown: Some(tx),
  1987. handle: Some(handle),
  1988. }
  1989. }
  1990. fn addr(&self) -> SocketAddr {
  1991. self.addr
  1992. }
  1993. }
  1994. impl Drop for TestServer {
  1995. fn drop(&mut self) {
  1996. if let Some(tx) = self.shutdown.take() {
  1997. let _ = tx.send(());
  1998. }
  1999. if let Some(handle) = self.handle.take() {
  2000. handle.join().expect("join test server");
  2001. }
  2002. }
  2003. }
  2004. struct HttpResponse {
  2005. status: u16,
  2006. reason: &'static str,
  2007. content_type: &'static str,
  2008. body: String,
  2009. }
  2010. impl HttpResponse {
  2011. fn html(status: u16, reason: &'static str, body: &str) -> Self {
  2012. Self {
  2013. status,
  2014. reason,
  2015. content_type: "text/html; charset=utf-8",
  2016. body: body.to_string(),
  2017. }
  2018. }
  2019. fn to_bytes(&self) -> Vec<u8> {
  2020. format!(
  2021. "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
  2022. self.status,
  2023. self.reason,
  2024. self.content_type,
  2025. self.body.len(),
  2026. self.body
  2027. )
  2028. .into_bytes()
  2029. }
  2030. }
  2031. }