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%
6.4 KB · 141 lines swift
Raw Blame History
1//2//  MockProviderClient.swift3//  Zyquo Agent4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  A scripted ProviderClient behind the hidden `--run-mock` CLI flag: drives9//  the AgentLoop end-to-end WITHOUT network or keys (CI smoke test). Emits10//  three canned turns exactly like a real provider stream would —11//  (1) update_plan draft + a real bash tool call, (2) update_plan marking the12//  work done, (3) a final answer with no tool calls — proving plan13//  interception, the policy gate, real process execution, live output14//  streaming, transcript persistence, and endTurn termination.15//1617import Foundation1819struct MockProviderClient: ProviderClient {20    var providerID: ProviderID { .custom }2122    /// The catalog-shaped model the mock run uses (tools-capable ⇒ agent-capable).23    static let model = AIModel(24        id: "mock-agent-model",25        provider: .custom,26        displayName: "Mock Agent Model",27        contextWindow: 128_000,28        maxOutputTokens: 8_192,29        capabilities: ModelCapabilities(tools: true),30        pricing: nil,31        parameterSupport: ParameterSupport()32    )3334    /// The command the scripted bash call executes. `ZYQUO_MOCK_BASH`35    /// overrides it so CI can also exercise the policy gate's ask/deny paths36    /// (e.g. a destructive payload auto-denied under --yes).37    static var bashCommand: String {38        ProcessInfo.processInfo.environment["ZYQUO_MOCK_BASH"] ?? "echo hello && mkdir demo && ls"39    }4041    func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream<ChatEvent, Error> {42        // Which scripted turn: count the tool-result messages already threaded.43        let toolResultTurns = request.messages.filter { ($0.toolResults?.isEmpty == false) }.count44        return AsyncThrowingStream { continuation in45            switch toolResultTurns {46            case 0:47                Self.emitFirstTurn(into: continuation)48            case 1:49                Self.emitSecondTurn(into: continuation)50            default:51                Self.emitFinalTurn(into: continuation)52            }53            continuation.finish()54        }55    }5657    /// Turn 1: think, draft the plan (update_plan), and issue the bash call.58    private static func emitFirstTurn(into continuation: AsyncThrowingStream<ChatEvent, Error>.Continuation) {59        for fragment in ["I'll draft a plan, ", "then run the command ", "and verify the result."] {60            continuation.yield(.textDelta(fragment))61        }6263        let planArguments = #"{"items":[{"title":"Run the demo command","status":"active"},{"title":"Verify the demo folder exists","status":"pending"}]}"#64        continuation.yield(.toolCallStarted(index: 0, id: "mock_call_plan_1", name: Planner.toolName))65        for fragment in Self.split(planArguments) {66            continuation.yield(.toolCallArgumentsDelta(index: 0, delta: fragment))67        }6869        let bashArguments = JSONValue.object([70            "command": .string(Self.bashCommand),71            "explanation": .string("Print a greeting, create the demo folder, and list the workspace."),72        ]).jsonString73        continuation.yield(.toolCallStarted(index: 1, id: "mock_call_bash_1", name: "bash"))74        for fragment in Self.split(bashArguments) {75            continuation.yield(.toolCallArgumentsDelta(index: 1, delta: fragment))76        }7778        continuation.yield(.toolCalls([79            ToolCall(id: "mock_call_plan_1", name: Planner.toolName, argumentsJSON: planArguments),80            ToolCall(id: "mock_call_bash_1", name: "bash", argumentsJSON: bashArguments),81        ]))82        continuation.yield(.usage(TokenUsage(inputTokens: 850, outputTokens: 120)))83        continuation.yield(.finished(reason: "tool_use", stop: .toolUse))84    }8586    /// Turn 2: observe the result and update the plan to done.87    private static func emitSecondTurn(into continuation: AsyncThrowingStream<ChatEvent, Error>.Continuation) {88        continuation.yield(.textDelta("The command succeeded and `demo` appears in the listing — marking the plan done."))89        let planArguments = #"{"items":[{"title":"Run the demo command","status":"done"},{"title":"Verify the demo folder exists","status":"done","note":"demo/ present in ls output"}]}"#90        continuation.yield(.toolCallStarted(index: 0, id: "mock_call_plan_2", name: Planner.toolName))91        for fragment in Self.split(planArguments) {92            continuation.yield(.toolCallArgumentsDelta(index: 0, delta: fragment))93        }94        continuation.yield(.toolCalls([95            ToolCall(id: "mock_call_plan_2", name: Planner.toolName, argumentsJSON: planArguments),96        ]))97        continuation.yield(.usage(TokenUsage(inputTokens: 1_100, outputTokens: 90)))98        continuation.yield(.finished(reason: "tool_use", stop: .toolUse))99    }100101    /// Turn 3: the final answer — no tool calls ⇒ the loop's done-signal.102    private static func emitFinalTurn(into continuation: AsyncThrowingStream<ChatEvent, Error>.Continuation) {103        for fragment in [104            "Done. I ran `", Self.bashCommand, "` in the workspace: ",105            "it printed `hello`, created the `demo/` folder, ",106            "and the listing confirms `demo` exists alongside MEMORY.md.",107        ] {108            continuation.yield(.textDelta(fragment))109        }110        continuation.yield(.usage(TokenUsage(inputTokens: 1_300, outputTokens: 60)))111        continuation.yield(.finished(reason: "end_turn", stop: .endTurn))112    }113114    /// Splits a JSON string into small fragments the way providers stream115    /// partial tool-call arguments (chunks don't respect JSON boundaries).116    private static func split(_ text: String, chunk: Int = 18) -> [String] {117        var fragments: [String] = []118        var remaining = Substring(text)119        while !remaining.isEmpty {120            fragments.append(String(remaining.prefix(chunk)))121            remaining = remaining.dropFirst(chunk)122        }123        return fragments124    }125126    /// Non-streaming completion (used by MemoryManager summarization —127    /// exercised only if a mock run ever compacts).128    func complete(_ request: ChatRequest, apiKey: String) async throws -> Message {129        Message(130            role: .assistant,131            text: "Sub-tasks completed:\n- (mock summary)\nKey facts, paths & decisions:\n- (mock)\nOpen issues:\n- none",132            modelID: Self.model.id,133            provider: .custom134        )135    }136137    func listModelIDs(apiKey: String) async throws -> [String] {138        [Self.model.id]139    }140}141