| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228 |
- use std::collections::BTreeSet;
- use std::time::{Duration, Instant};
- use reqwest::blocking::Client;
- use runtime::{
- edit_file, execute_bash, glob_search, grep_search, read_file, write_file, BashCommandInput,
- GrepSearchInput,
- };
- use serde::{Deserialize, Serialize};
- use serde_json::{json, Value};
- #[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,
- }
- #[must_use]
- 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" }
- },
- "required": ["command"],
- "additionalProperties": false
- }),
- },
- 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
- }),
- },
- 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
- }),
- },
- 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
- }),
- },
- 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
- }),
- },
- 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
- }),
- },
- 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
- }),
- },
- 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
- }),
- },
- 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
- }),
- },
- 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
- }),
- },
- 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
- }),
- },
- 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
- }),
- },
- 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
- }),
- },
- 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
- }),
- },
- 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
- }),
- },
- ]
- }
- 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),
- "PowerShell" => from_value::<PowerShellInput>(input).and_then(run_powershell),
- _ => Err(format!("unsupported tool: {name}")),
- }
- }
- 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())
- }
- 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)?)
- }
- fn run_write_file(input: WriteFileInput) -> Result<String, String> {
- to_pretty_json(write_file(&input.path, &input.content).map_err(io_to_string)?)
- }
- 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)?,
- )
- }
- 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)?)
- }
- fn run_grep_search(input: GrepSearchInput) -> Result<String, String> {
- to_pretty_json(grep_search(&input).map_err(io_to_string)?)
- }
- fn run_web_fetch(input: WebFetchInput) -> Result<String, String> {
- to_pretty_json(execute_web_fetch(&input)?)
- }
- 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_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())
- }
- 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 PowerShellInput {
- command: String,
- timeout: Option<u64>,
- description: Option<String>,
- run_in_background: Option<bool>,
- }
- #[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, 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,
- }
- #[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)]
- #[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);
- 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) -> String {
- let lower_prompt = prompt.to_lowercase();
- let compact = collapse_whitespace(content);
- let detail = if lower_prompt.contains("title") {
- extract_title(content)
- .map(|title| format!("Title: {title}"))
- .unwrap_or_else(|| preview_text(&compact, 600))
- } 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) -> Option<String> {
- 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;
- };
- domains.iter().any(|domain| {
- let normalized = domain.trim().trim_start_matches('.');
- host == normalized || host.ends_with(&format!(".{normalized}"))
- })
- }
- 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"));
- }
- let in_progress = todos
- .iter()
- .filter(|todo| matches!(todo.status, TodoStatus::InProgress))
- .count();
- if in_progress > 1 {
- return Err(String::from(
- "exactly zero or one todo items may be in_progress",
- ));
- }
- 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('/');
- 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"));
- }
- 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}"))
- }
- fn execute_agent(input: AgentInput) -> Result<AgentOutput, 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 agent_name = input
- .name
- .clone()
- .unwrap_or_else(|| slugify_agent_name(&input.description));
- let created_at = iso8601_now();
- 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: input.model,
- status: String::from("queued"),
- output_file: output_file.display().to_string(),
- manifest_file: manifest_file.display().to_string(),
- created_at,
- };
- std::fs::write(
- &manifest_file,
- serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?,
- )
- .map_err(|error| error.to_string())?;
- Ok(manifest)
- }
- 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(|ch| ch.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())?;
- 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")
- }
- "claudecodeguide" | "claudecodeguideagent" | "guide" => String::from("claude-code-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()
- }
- 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.expect("insert 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 removed = cells.remove(target_index.expect("delete target index"));
- removed
- .get("id")
- .and_then(serde_json::Value::as_str)
- .map(ToString::to_string)
- }
- NotebookEditMode::Replace => {
- let resolved_cell_type = resolved_cell_type.expect("replace cell type");
- let cell = cells
- .get_mut(target_index.expect("replace target index"))
- .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_some() {
- 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
- }
- })
- }
- fn execute_sleep(input: SleepInput) -> SleepOutput {
- std::thread::sleep(Duration::from_millis(input.duration_ms));
- SleepOutput {
- duration_ms: input.duration_ms,
- message: format!("Slept for {}ms", input.duration_ms),
- }
- }
- 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() -> &'static str {
- if command_exists("pwsh") {
- "pwsh"
- } else {
- "powershell"
- }
- }
- 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)
- }
- 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(false),
- 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,
- });
- }
- 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,
- });
- }
- 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,
- });
- }
- 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,
- })
- }
- 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::io::{Read, Write};
- use std::net::{SocketAddr, TcpListener};
- use std::sync::Arc;
- use std::thread;
- use std::time::Duration;
- use super::{execute_tool, mvp_tool_specs};
- use serde_json::json;
- #[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(&"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 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"));
- }
- #[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": ["docs.rs"],
- "blocked_domains": ["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 todo_write_persists_and_returns_previous_state() {
- let path = std::env::temp_dir().join(format!(
- "clawd-tools-todos-{}.json",
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .expect("time")
- .as_nanos()
- ));
- 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 skill_loads_local_skill_prompt() {
- 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"));
- }
- #[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 dir = std::env::temp_dir().join(format!(
- "clawd-agent-store-{}",
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .expect("time")
- .as_nanos()
- ));
- std::env::set_var("CLAWD_AGENT_STORE", &dir);
- let result = execute_tool(
- "Agent",
- &json!({
- "description": "Audit the branch",
- "prompt": "Check tests and outstanding work.",
- "subagent_type": "Explore",
- "name": "ship-audit"
- }),
- )
- .expect("Agent should succeed");
- std::env::remove_var("CLAWD_AGENT_STORE");
- let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
- assert_eq!(output["name"], "ship-audit");
- assert_eq!(output["subagentType"], "Explore");
- assert_eq!(output["status"], "queued");
- assert!(output["createdAt"].as_str().is_some());
- let manifest_file = output["manifestFile"].as_str().expect("manifest file");
- let output_file = output["outputFile"].as_str().expect("output file");
- let contents = std::fs::read_to_string(output_file).expect("agent file exists");
- let manifest_contents =
- std::fs::read_to_string(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\""));
- 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 _ = std::fs::remove_dir_all(dir);
- }
- #[test]
- fn notebook_edit_replaces_inserts_and_deletes_cells() {
- let path = std::env::temp_dir().join(format!(
- "clawd-notebook-{}.ipynb",
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .expect("time")
- .as_nanos()
- ));
- 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 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 powershell_runs_via_stub_shell() {
- 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("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");
- 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());
- }
- 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 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()
- }
- }
- }
|