SPB Git

spb/zyquo-agent Public MIT

The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.

Swift 94.7% Shell 4.1% Python 0.7% Makefile 0.5%
5.7 KB · 143 lines swift
Raw Blame History
1//2//  ShellTool.swift3//  Zyquo Agent4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  The agent's `bash` tool: runs a shell command inside the task workspace.9//  Every call is (1) cleared by the PolicyEngine — never bypassed, in any10//  mode — (2) executed by ExecutionService with live line streaming and a11//  timeout, and (3) appended to the AuditLog with its exit code and a12//  truncated output excerpt.13//1415import Foundation1617struct ShellTool: Tool {18    private let executor: ExecutionService1920    init(executor: ExecutionService) {21        self.executor = executor22    }2324    let name = "bash"2526    let description = """27        Run a shell command with /bin/bash inside the task workspace (the \28        working directory). Use it for anything a terminal can do: creating \29        files and folders, running scripts, installing project dependencies, \30        inspecting the system. stdout and stderr stream live to the user and \31        are returned with the exit code. Commands may be blocked or held for \32        user approval by the safety policy — if a command is denied, explain \33        the situation to the user or find a safer approach instead of \34        retrying the same command. Prefer the dedicated file tools \35        (read_file, write_file, edit_file, list_dir, search_files) for file \36        content work; prefer relative paths, which resolve inside the \37        workspace. Long or interactive commands will hit the timeout — pass \38        timeout_seconds for legitimately slow commands.39        """4041    var parametersSchema: JSONValue {42        .object([43            "type": .string("object"),44            "properties": .object([45                "command": .object([46                    "type": .string("string"),47                    "description": .string("The exact bash command to execute."),48                ]),49                "timeout_seconds": .object([50                    "type": .string("integer"),51                    "description": .string("Optional timeout in seconds for this command (default 120)."),52                ]),53                "explanation": .object([54                    "type": .string("string"),55                    "description": .string("One short sentence explaining what this command does and why — shown to the user on approval cards."),56                ]),57            ]),58            "required": .array([.string("command")]),59        ])60    }6162    func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult {63        guard case .object(let object) = arguments,64              case .string(let command)? = object["command"],65              !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {66            return .failure("bash: missing required parameter `command`.")67        }68        var timeout: TimeInterval?69        if case .number(let seconds)? = object["timeout_seconds"], seconds > 0 {70            timeout = seconds71        }72        var explanation: String?73        if case .string(let text)? = object["explanation"] {74            explanation = text75        }7677        // ---- Safety gate (never skipped) --------------------------------78        let cleared: ClearedAction79        do {80            cleared = try await context.policy.clear(ActionRequest(81                kind: .shellCommand,82                payload: command,83                cwd: context.workspaceURL,84                explanation: explanation85            ))86        } catch let denial as PolicyDenied {87            await context.audit.append(AuditEntry(88                actionKind: name,89                payload: command,90                cwd: context.workspaceURL.path,91                ruling: PolicyDecisionRecord.Ruling.denied.rawValue,92                exitCode: nil,93                outputExcerpt: denial.reason94            ))95            return .failure("Command not run — denied by the safety policy: \(denial.reason)")96        }9798        // ---- Execute ------------------------------------------------------99        let result: ExecutionResult100        do {101            result = try await executor.runBash(102                command: cleared.payload,103                cwd: context.workspaceURL,104                timeout: timeout,105                onOutput: context.onOutput106            )107        } catch let launchError as ExecutionLaunchError {108            await context.audit.append(AuditEntry(109                actionKind: name,110                payload: cleared.payload,111                cwd: context.workspaceURL.path,112                ruling: cleared.decision.ruling.rawValue,113                exitCode: nil,114                outputExcerpt: launchError.reason115            ))116            return .failure("bash failed to launch: \(launchError.reason)")117        }118119        // ---- Audit ---------------------------------------------------------120        await context.audit.append(AuditEntry(121            actionKind: name,122            payload: cleared.payload,123            cwd: context.workspaceURL.path,124            ruling: cleared.decision.ruling.rawValue,125            exitCode: result.exitCode,126            outputExcerpt: result.combinedOutput127        ))128129        // ---- Result for the model -------------------------------------------130        var content = result.combinedOutput131        if result.isTimeout {132            content += "\n[timed out after \(Int(timeout ?? 120))s — process was killed]"133        }134        if result.isCancelled {135            content += "\n[cancelled by the user]"136        }137        content += "\n[exit code: \(result.exitCode.map(String.init) ?? "none")]"138139        let failed = result.isTimeout || result.isCancelled || (result.exitCode ?? 1) != 0140        return ToolExecutionResult(content: content, isError: failed)141    }142}143