phase3: AgentLoop actor, Planner (update_plan), LoopGuard, MemoryManager (compaction+offload+MEMORY.md), Transcript, system prompt, CLI --run/--run-mock
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 10 changed files with +2,302 and −8
added
Sources/ZyquoAgent/Agent/AgentEvent.swift
+84 −0
@@ -0,0 +1,84 @@ | ||
| 1 | +// | |
| 2 | +// AgentEvent.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The engine→UI event stream. AgentLoop emits these while a run executes; | |
| 9 | +// the CLI renderer (Phase 3) and the full UI (Phase 6) render EXCLUSIVELY | |
| 10 | +// from this stream plus the persisted Transcript — nothing about a run is | |
| 11 | +// observable any other way. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Foundation | |
| 15 | + | |
| 16 | +/// Coarse run state, for status pills and the sidebar activity indicator. | |
| 17 | +enum AgentRunStatus: Sendable, Equatable { | |
| 18 | + /// Workspace/memory/system prompt being prepared. | |
| 19 | + case preparing | |
| 20 | + /// A model turn is streaming. | |
| 21 | + case running | |
| 22 | + /// Tool calls from the current turn are executing. | |
| 23 | + case executingTools | |
| 24 | + /// Context compaction in progress. | |
| 25 | + case compacting | |
| 26 | + /// A LoopGuard trip paused the run; waiting for resume/stop. | |
| 27 | + case awaitingUser | |
| 28 | + /// The run ended (see the accompanying `runFinished` outcome). | |
| 29 | + case finished | |
| 30 | +} | |
| 31 | + | |
| 32 | +/// How a run ended. Persisted in the Transcript. | |
| 33 | +enum AgentRunOutcome: Codable, Sendable { | |
| 34 | + /// The model produced a final answer (its user-facing result summary). | |
| 35 | + case completed(finalAnswer: String) | |
| 36 | + /// The run failed with a human-readable reason (provider error, refusal, | |
| 37 | + /// repeated truncation, model without tool support…). | |
| 38 | + case failed(reason: String) | |
| 39 | + /// The surrounding task was cancelled (user pressed Stop / ⌘.). | |
| 40 | + case cancelled | |
| 41 | + /// A LoopGuard trip was answered with "stop". | |
| 42 | + case stoppedByUser(reason: String) | |
| 43 | +} | |
| 44 | + | |
| 45 | +/// One event in the live run stream. | |
| 46 | +enum AgentEvent: Sendable { | |
| 47 | + case statusChanged(AgentRunStatus) | |
| 48 | + | |
| 49 | + /// A new loop iteration began (step has its index + id; content follows). | |
| 50 | + case stepStarted(AgentStep) | |
| 51 | + /// Reasoning-model thinking text for the step, as it streams. | |
| 52 | + case thinkingDelta(stepID: UUID, String) | |
| 53 | + /// Assistant-visible text for the step, as it streams. | |
| 54 | + case textDelta(stepID: UUID, String) | |
| 55 | + | |
| 56 | + /// The model opened a tool-call block — show the live chip immediately, | |
| 57 | + /// before the arguments finish streaming. | |
| 58 | + case toolCallStreaming(stepID: UUID, index: Int, id: String, name: String) | |
| 59 | + /// Raw tool-call argument JSON fragments for the call at `index` | |
| 60 | + /// (concatenate to render the command as it streams; may be partial JSON). | |
| 61 | + case toolCallArgumentsDelta(stepID: UUID, index: Int, delta: String) | |
| 62 | + | |
| 63 | + /// A finalized tool call is about to execute (arguments complete, | |
| 64 | + /// heading into the policy gate). | |
| 65 | + case toolCallStarted(stepID: UUID, AgentToolInvocation) | |
| 66 | + /// Live stdout/stderr/note chunks from the executing tool. | |
| 67 | + case toolOutput(stepID: UUID, callID: String, ToolOutputChunk) | |
| 68 | + /// The tool call finished; the invocation carries its ToolResult. | |
| 69 | + case toolCallFinished(stepID: UUID, AgentToolInvocation) | |
| 70 | + | |
| 71 | + /// The step is complete (final AgentStep snapshot, with invocations). | |
| 72 | + case stepCompleted(AgentStep) | |
| 73 | + | |
| 74 | + /// The plan/todo list changed (created or updated via `update_plan`). | |
| 75 | + case planUpdated(TaskPlan) | |
| 76 | + /// A LoopGuard limit tripped; the run is pausing (answer with | |
| 77 | + /// `AgentLoop.resume(raisingBudget:)` or `AgentLoop.stop()`). | |
| 78 | + case guardTripped(LoopGuardTrip) | |
| 79 | + /// The MemoryManager compacted older history. | |
| 80 | + case compactionPerformed(CompactionRecord) | |
| 81 | + | |
| 82 | + /// Terminal event — always the last one emitted. | |
| 83 | + case runFinished(AgentRunOutcome) | |
| 84 | +} | |
added
Sources/ZyquoAgent/Agent/AgentLoop.swift
+576 −0
@@ -0,0 +1,576 @@ | ||
| 1 | +// | |
| 2 | +// AgentLoop.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The plan→act→observe→reflect engine (docs/AGENT-RESEARCH.md §1.3): a | |
| 9 | +// while-loop keyed on the normalized StopReason. Each iteration streams one | |
| 10 | +// model turn (text, thinking, and tool-call arguments live), executes any | |
| 11 | +// tool calls through the ToolRegistry — every side effect pre-cleared by | |
| 12 | +// the PolicyEngine and audited — threads the results back as one message | |
| 13 | +// correlated by call id, offloads oversized outputs, compacts memory near | |
| 14 | +// the context limit, and checks the LoopGuard. A turn without tool calls | |
| 15 | +// is the done-signal: its text is the user-facing result. Trips pause the | |
| 16 | +// run (resume/stop), they never abort it; cancellation kills any in-flight | |
| 17 | +// process via Task cancellation. | |
| 18 | +// | |
| 19 | + | |
| 20 | +import Foundation | |
| 21 | + | |
| 22 | +/// Per-run engine tunables (Settings › Agent in Phase 6). | |
| 23 | +struct AgentConfiguration: Sendable { | |
| 24 | + var loopGuard = LoopGuardConfiguration.default | |
| 25 | + var memory = MemoryConfiguration.default | |
| 26 | + /// Execute a turn's tool calls concurrently when they are ALL read-only | |
| 27 | + /// under the active policy. Mutating calls always run sequentially. | |
| 28 | + var parallelToolCalls = false | |
| 29 | + /// Default per-command timeout for the ExecutionService the host wires up | |
| 30 | + /// (the loop itself never spawns processes). | |
| 31 | + var perCommandTimeout: TimeInterval? | |
| 32 | + | |
| 33 | + static let `default` = AgentConfiguration() | |
| 34 | +} | |
| 35 | + | |
| 36 | +/// The agent engine. Actor: one run at a time, all mutable run state | |
| 37 | +/// (history, plan, guard, transcript) confined here. | |
| 38 | +actor AgentLoop { | |
| 39 | + // MARK: Dependencies | |
| 40 | + | |
| 41 | + private let model: AIModel | |
| 42 | + private let client: any ProviderClient | |
| 43 | + private let apiKey: String | |
| 44 | + private let tools: ToolRegistry | |
| 45 | + private let policy: PolicyEngine | |
| 46 | + private let audit: AuditLog | |
| 47 | + private let workspace: WorkspaceManager | |
| 48 | + private let configuration: AgentConfiguration | |
| 49 | + | |
| 50 | + // MARK: Run state | |
| 51 | + | |
| 52 | + private var planner: Planner | |
| 53 | + private var loopGuard: LoopGuard | |
| 54 | + private let memory: MemoryManager | |
| 55 | + private var transcript: Transcript? | |
| 56 | + private var history: [HistoryEntry] = [] | |
| 57 | + private var runTask: Task<Void, Never>? | |
| 58 | + private var eventContinuation: AsyncThrowingStream<AgentEvent, Error>.Continuation? | |
| 59 | + private var tripContinuation: CheckedContinuation<TripDecision, Never>? | |
| 60 | + private var isRunning = false | |
| 61 | + | |
| 62 | + /// The user's answer to a LoopGuard trip. | |
| 63 | + enum TripDecision: Sendable { | |
| 64 | + case resume(raiseBudget: Bool) | |
| 65 | + case stop | |
| 66 | + } | |
| 67 | + | |
| 68 | + init( | |
| 69 | + model: AIModel, | |
| 70 | + client: any ProviderClient, | |
| 71 | + apiKey: String, | |
| 72 | + tools: ToolRegistry, | |
| 73 | + policy: PolicyEngine, | |
| 74 | + audit: AuditLog, | |
| 75 | + workspace: WorkspaceManager, | |
| 76 | + configuration: AgentConfiguration = .default | |
| 77 | + ) { | |
| 78 | + self.model = model | |
| 79 | + self.client = client | |
| 80 | + self.apiKey = apiKey | |
| 81 | + self.tools = tools | |
| 82 | + self.policy = policy | |
| 83 | + self.audit = audit | |
| 84 | + self.workspace = workspace | |
| 85 | + self.configuration = configuration | |
| 86 | + self.planner = Planner(workspaceRoot: workspace.root) | |
| 87 | + self.loopGuard = LoopGuard(configuration: configuration.loopGuard) | |
| 88 | + self.memory = MemoryManager( | |
| 89 | + workspaceRoot: workspace.root, | |
| 90 | + outputsDirectory: workspace.outputsDirectory, | |
| 91 | + configuration: configuration.memory | |
| 92 | + ) | |
| 93 | + } | |
| 94 | + | |
| 95 | + // MARK: - Public API | |
| 96 | + | |
| 97 | + /// Starts the run and returns its live event stream. The stream ends | |
| 98 | + /// after `.runFinished`; provider failures that end the run are reported | |
| 99 | + /// through `.runFinished(.failed…)`, not thrown. | |
| 100 | + func run(task: String) -> AsyncThrowingStream<AgentEvent, Error> { | |
| 101 | + let (stream, continuation) = AsyncThrowingStream<AgentEvent, Error>.makeStream() | |
| 102 | + guard !isRunning else { | |
| 103 | + continuation.yield(.runFinished(.failed(reason: "A run is already in progress on this AgentLoop."))) | |
| 104 | + continuation.finish() | |
| 105 | + return stream | |
| 106 | + } | |
| 107 | + isRunning = true | |
| 108 | + eventContinuation = continuation | |
| 109 | + runTask = Task { | |
| 110 | + await self.execute(task: task) | |
| 111 | + } | |
| 112 | + return stream | |
| 113 | + } | |
| 114 | + | |
| 115 | + /// Stops the run: cancels the in-flight model stream and any executing | |
| 116 | + /// process (ExecutionService honors Task cancellation with SIGTERM→SIGKILL). | |
| 117 | + func cancel() { | |
| 118 | + runTask?.cancel() | |
| 119 | + // A run paused on a guard trip is not awaiting a cancellable | |
| 120 | + // suspension — unblock it explicitly. | |
| 121 | + tripContinuation?.resume(returning: .stop) | |
| 122 | + tripContinuation = nil | |
| 123 | + } | |
| 124 | + | |
| 125 | + /// Answers a `.guardTripped` pause: continue, optionally raising every | |
| 126 | + /// budget (without raising, a budget trip will re-trip on the next step; | |
| 127 | + /// repetition/stall trips reset their counters either way). | |
| 128 | + func resume(raisingBudget: Bool = true) { | |
| 129 | + guard let continuation = tripContinuation else { return } | |
| 130 | + tripContinuation = nil | |
| 131 | + continuation.resume(returning: .resume(raiseBudget: raisingBudget)) | |
| 132 | + } | |
| 133 | + | |
| 134 | + /// Answers a `.guardTripped` pause: end the run. | |
| 135 | + func stop() { | |
| 136 | + guard let continuation = tripContinuation else { return } | |
| 137 | + tripContinuation = nil | |
| 138 | + continuation.resume(returning: .stop) | |
| 139 | + } | |
| 140 | + | |
| 141 | + // MARK: - The loop | |
| 142 | + | |
| 143 | + private func execute(task: String) async { | |
| 144 | + emit(.statusChanged(.preparing)) | |
| 145 | + | |
| 146 | + guard model.capabilities.tools else { | |
| 147 | + finish(.failed(reason: "\(model.displayName) does not support tool calling and cannot run agent tasks. Pick an agent-capable model.")) | |
| 148 | + return | |
| 149 | + } | |
| 150 | + | |
| 151 | + memory.ensureMemoryFile() | |
| 152 | + transcript = Transcript(task: task, model: model, workspaceRoot: workspace.root) | |
| 153 | + history = [HistoryEntry(stepIndex: 0, message: Message(role: .user, text: task), pinned: true)] | |
| 154 | + if let plan = planner.plan { | |
| 155 | + // Reopened workspace with a persisted plan — surface it. | |
| 156 | + emit(.planUpdated(plan)) | |
| 157 | + } | |
| 158 | + | |
| 159 | + let systemPrompt = AgentSystemPrompt.build( | |
| 160 | + workspacePath: workspace.root.path, | |
| 161 | + toolNames: tools.toolNames, | |
| 162 | + safetyMode: await policy.mode | |
| 163 | + ) | |
| 164 | + let toolSpecs = tools.toolSpecs + [Planner.toolSpec] | |
| 165 | + let fixedTokens = MemoryManager.estimateFixedTokens(systemPrompt: systemPrompt, toolSpecs: toolSpecs) | |
| 166 | + | |
| 167 | + var stepIndex = 0 | |
| 168 | + var truncationNudges = 0 | |
| 169 | + var emptyAnswerNudges = 0 | |
| 170 | + | |
| 171 | + while true { | |
| 172 | + if Task.isCancelled { | |
| 173 | + finish(.cancelled) | |
| 174 | + return | |
| 175 | + } | |
| 176 | + stepIndex += 1 | |
| 177 | + | |
| 178 | + // ---- LoopGuard budgets (pause-and-ask, never abort) ---------- | |
| 179 | + if let trip = loopGuard.checkBeforeStep(index: stepIndex) { | |
| 180 | + if await pauseOnTrip(trip) == false { | |
| 181 | + finish(.stoppedByUser(reason: trip.message)) | |
| 182 | + return | |
| 183 | + } | |
| 184 | + } | |
| 185 | + | |
| 186 | + // ---- Memory compaction (before the request, at step boundary) - | |
| 187 | + if memory.shouldCompact(entries: history, fixedTokens: fixedTokens, contextWindow: model.contextWindow, currentStep: stepIndex - 1) { | |
| 188 | + emit(.statusChanged(.compacting)) | |
| 189 | + if let outcome = await memory.compact( | |
| 190 | + entries: history, | |
| 191 | + planContext: planner.renderedForContext(), | |
| 192 | + steps: transcript?.document.steps ?? [], | |
| 193 | + client: client, | |
| 194 | + apiKey: apiKey, | |
| 195 | + model: model, | |
| 196 | + currentStep: stepIndex - 1, | |
| 197 | + fixedTokens: fixedTokens | |
| 198 | + ) { | |
| 199 | + history = outcome.entries | |
| 200 | + transcript?.recordCompaction(outcome.record) | |
| 201 | + emit(.compactionPerformed(outcome.record)) | |
| 202 | + } | |
| 203 | + } | |
| 204 | + | |
| 205 | + // ---- One model turn, streamed -------------------------------- | |
| 206 | + var step = AgentStep(index: stepIndex, text: "") | |
| 207 | + emit(.stepStarted(step)) | |
| 208 | + emit(.statusChanged(.running)) | |
| 209 | + | |
| 210 | + let turn: ModelTurn | |
| 211 | + do { | |
| 212 | + turn = try await streamModelTurn(stepID: step.id, systemPrompt: systemPrompt, toolSpecs: toolSpecs) | |
| 213 | + } catch is CancellationError { | |
| 214 | + finish(.cancelled) | |
| 215 | + return | |
| 216 | + } catch { | |
| 217 | + step.status = .failed | |
| 218 | + step.finishedAt = Date() | |
| 219 | + transcript?.upsert(step: step) | |
| 220 | + emit(.stepCompleted(step)) | |
| 221 | + finish(.failed(reason: error.localizedDescription)) | |
| 222 | + return | |
| 223 | + } | |
| 224 | + | |
| 225 | + step.text = turn.text | |
| 226 | + step.thinking = turn.reasoning.isEmpty ? nil : turn.reasoning | |
| 227 | + if let usage = turn.usage { | |
| 228 | + step.inputTokens = usage.inputTokens | |
| 229 | + step.outputTokens = usage.outputTokens | |
| 230 | + loopGuard.recordUsage(usage) | |
| 231 | + transcript?.addUsage(usage) | |
| 232 | + memory.lastReportedInputTokens = usage.inputTokens | |
| 233 | + } | |
| 234 | + | |
| 235 | + // ---- Branch on the normalized stop reason -------------------- | |
| 236 | + switch turn.stop { | |
| 237 | + case .toolUse where !turn.toolCalls.isEmpty: | |
| 238 | + history.append(HistoryEntry( | |
| 239 | + stepIndex: stepIndex, | |
| 240 | + message: .assistantToolCalls(turn.toolCalls, text: turn.text, reasoning: step.thinking) | |
| 241 | + )) | |
| 242 | + step.status = .executing | |
| 243 | + emit(.statusChanged(.executingTools)) | |
| 244 | + | |
| 245 | + let filesBefore = await filesSignature() | |
| 246 | + let execution: ToolExecutionOutcome | |
| 247 | + do { | |
| 248 | + execution = try await executeToolCalls(turn.toolCalls, stepIndex: stepIndex, stepID: step.id) | |
| 249 | + } catch { | |
| 250 | + // Only CancellationError escapes the registry. | |
| 251 | + step.status = .cancelled | |
| 252 | + step.finishedAt = Date() | |
| 253 | + transcript?.upsert(step: step) | |
| 254 | + emit(.stepCompleted(step)) | |
| 255 | + finish(.cancelled) | |
| 256 | + return | |
| 257 | + } | |
| 258 | + history.append(HistoryEntry(stepIndex: stepIndex, message: .toolResultsMessage(execution.results))) | |
| 259 | + await workspace.refreshScan() | |
| 260 | + let filesAfter = await filesSignature() | |
| 261 | + | |
| 262 | + step.toolInvocations = execution.invocations | |
| 263 | + step.status = .completed | |
| 264 | + step.finishedAt = Date() | |
| 265 | + transcript?.upsert(step: step) | |
| 266 | + emit(.stepCompleted(step)) | |
| 267 | + | |
| 268 | + if let trip = loopGuard.recordStepOutcome( | |
| 269 | + stepIndex: stepIndex, | |
| 270 | + planChanged: execution.planChanged, | |
| 271 | + filesChanged: filesBefore != filesAfter, | |
| 272 | + sawNewToolCall: execution.sawNewToolCall | |
| 273 | + ) { | |
| 274 | + if await pauseOnTrip(trip) == false { | |
| 275 | + finish(.stoppedByUser(reason: trip.message)) | |
| 276 | + return | |
| 277 | + } | |
| 278 | + } | |
| 279 | + | |
| 280 | + case .endTurn, .toolUse, .other: | |
| 281 | + // .toolUse with an empty call list and .other are anomalies — | |
| 282 | + // treat a non-empty text as the final answer, nudge otherwise. | |
| 283 | + let answer = turn.text.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 284 | + if answer.isEmpty, stepIndex > 1, emptyAnswerNudges < 1 { | |
| 285 | + emptyAnswerNudges += 1 | |
| 286 | + step.status = .completed | |
| 287 | + step.finishedAt = Date() | |
| 288 | + transcript?.upsert(step: step) | |
| 289 | + emit(.stepCompleted(step)) | |
| 290 | + history.append(HistoryEntry( | |
| 291 | + stepIndex: stepIndex, | |
| 292 | + message: Message(role: .user, text: "Your last message was empty. If the task is complete, state the result summary; otherwise continue with the next tool call.") | |
| 293 | + )) | |
| 294 | + continue | |
| 295 | + } | |
| 296 | + step.status = .completed | |
| 297 | + step.finishedAt = Date() | |
| 298 | + transcript?.upsert(step: step) | |
| 299 | + emit(.stepCompleted(step)) | |
| 300 | + finish(.completed(finalAnswer: turn.text)) | |
| 301 | + return | |
| 302 | + | |
| 303 | + case .maxTokens: | |
| 304 | + // A truncated turn may carry INCOMPLETE tool calls — never | |
| 305 | + // execute them (docs/AGENT-RESEARCH.md §6.2). Nudge instead. | |
| 306 | + truncationNudges += 1 | |
| 307 | + step.status = .completed | |
| 308 | + step.finishedAt = Date() | |
| 309 | + transcript?.upsert(step: step) | |
| 310 | + emit(.stepCompleted(step)) | |
| 311 | + if truncationNudges > 2 { | |
| 312 | + finish(.failed(reason: "The model's output was truncated by its token limit three times in a row — the task cannot proceed. Try a model with a larger output limit or split the task.")) | |
| 313 | + return | |
| 314 | + } | |
| 315 | + if !turn.text.isEmpty { | |
| 316 | + history.append(HistoryEntry(stepIndex: stepIndex, message: Message(role: .assistant, text: turn.text))) | |
| 317 | + } | |
| 318 | + history.append(HistoryEntry( | |
| 319 | + stepIndex: stepIndex, | |
| 320 | + message: Message(role: .user, text: "Your previous response was cut off by the output token limit; any tool calls in it were NOT executed. Continue more concisely, re-issuing the needed tool call(s) one at a time.") | |
| 321 | + )) | |
| 322 | + | |
| 323 | + case .refusal: | |
| 324 | + step.status = .failed | |
| 325 | + step.finishedAt = Date() | |
| 326 | + transcript?.upsert(step: step) | |
| 327 | + emit(.stepCompleted(step)) | |
| 328 | + finish(.failed(reason: "The model declined to continue this task\(turn.text.isEmpty ? "." : ": \(turn.text)")")) | |
| 329 | + return | |
| 330 | + } | |
| 331 | + } | |
| 332 | + } | |
| 333 | + | |
| 334 | + // MARK: - Model turn streaming | |
| 335 | + | |
| 336 | + /// One accumulated assistant turn. | |
| 337 | + private struct ModelTurn { | |
| 338 | + var text = "" | |
| 339 | + var reasoning = "" | |
| 340 | + var toolCalls: [ToolCall] = [] | |
| 341 | + var usage: TokenUsage? | |
| 342 | + var stop = StopReason.endTurn | |
| 343 | + } | |
| 344 | + | |
| 345 | + /// Streams one turn, forwarding deltas as AgentEvents. Retries once with | |
| 346 | + /// backoff on transient provider errors (network, 429, 5xx). | |
| 347 | + private func streamModelTurn(stepID: UUID, systemPrompt: String, toolSpecs: [ToolSpec]) async throws -> ModelTurn { | |
| 348 | + var attempt = 0 | |
| 349 | + while true { | |
| 350 | + attempt += 1 | |
| 351 | + do { | |
| 352 | + return try await streamOnce(stepID: stepID, systemPrompt: systemPrompt, toolSpecs: toolSpecs) | |
| 353 | + } catch let error as ProviderError where attempt == 1 && Self.isTransient(error) { | |
| 354 | + let delay: TimeInterval | |
| 355 | + if case .rateLimited(_, let retryAfter) = error, let retryAfter { | |
| 356 | + delay = min(retryAfter, 30) | |
| 357 | + } else { | |
| 358 | + delay = 2 | |
| 359 | + } | |
| 360 | + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) | |
| 361 | + } | |
| 362 | + } | |
| 363 | + } | |
| 364 | + | |
| 365 | + private static func isTransient(_ error: ProviderError) -> Bool { | |
| 366 | + switch error { | |
| 367 | + case .networkError, .rateLimited: | |
| 368 | + return true | |
| 369 | + case .serverError(_, let status, _): | |
| 370 | + return status >= 500 | |
| 371 | + default: | |
| 372 | + return false | |
| 373 | + } | |
| 374 | + } | |
| 375 | + | |
| 376 | + private func streamOnce(stepID: UUID, systemPrompt: String, toolSpecs: [ToolSpec]) async throws -> ModelTurn { | |
| 377 | + let request = ChatRequest( | |
| 378 | + model: model, | |
| 379 | + systemPrompt: systemPrompt, | |
| 380 | + messages: history.map(\.message), | |
| 381 | + parameters: ChatParameters(), | |
| 382 | + stream: true, | |
| 383 | + tools: toolSpecs, | |
| 384 | + toolChoice: .auto | |
| 385 | + ) | |
| 386 | + var turn = ModelTurn() | |
| 387 | + for try await event in client.streamChat(request, apiKey: apiKey) { | |
| 388 | + switch event { | |
| 389 | + case .textDelta(let delta): | |
| 390 | + turn.text += delta | |
| 391 | + emit(.textDelta(stepID: stepID, delta)) | |
| 392 | + case .reasoningDelta(let delta): | |
| 393 | + turn.reasoning += delta | |
| 394 | + emit(.thinkingDelta(stepID: stepID, delta)) | |
| 395 | + case .toolCallStarted(let index, let id, let name): | |
| 396 | + emit(.toolCallStreaming(stepID: stepID, index: index, id: id, name: name)) | |
| 397 | + case .toolCallArgumentsDelta(let index, let delta): | |
| 398 | + emit(.toolCallArgumentsDelta(stepID: stepID, index: index, delta: delta)) | |
| 399 | + case .toolCalls(let calls): | |
| 400 | + turn.toolCalls = calls | |
| 401 | + case .usage(let usage): | |
| 402 | + turn.usage = usage | |
| 403 | + case .citations: | |
| 404 | + break | |
| 405 | + case .finished(_, let stop): | |
| 406 | + turn.stop = stop | |
| 407 | + } | |
| 408 | + } | |
| 409 | + return turn | |
| 410 | + } | |
| 411 | + | |
| 412 | + // MARK: - Tool execution | |
| 413 | + | |
| 414 | + private struct ToolExecutionOutcome { | |
| 415 | + var results: [ToolResult] = [] | |
| 416 | + var invocations: [AgentToolInvocation] = [] | |
| 417 | + var planChanged = false | |
| 418 | + var sawNewToolCall = false | |
| 419 | + } | |
| 420 | + | |
| 421 | + /// Executes a turn's tool calls — `update_plan` intercepted internally | |
| 422 | + /// (no side effect ⇒ no policy gate), everything else through the | |
| 423 | + /// registry (which routes side effects through the PolicyEngine). Results | |
| 424 | + /// come back in call order, correlated by id, in ONE tool-results | |
| 425 | + /// message. Sequential by default; concurrent only when enabled AND every | |
| 426 | + /// call is read-only under the active policy. Throws only on cancellation. | |
| 427 | + private func executeToolCalls( | |
| 428 | + _ calls: [ToolCall], | |
| 429 | + stepIndex: Int, | |
| 430 | + stepID: UUID | |
| 431 | + ) async throws -> ToolExecutionOutcome { | |
| 432 | + var outcome = ToolExecutionOutcome() | |
| 433 | + | |
| 434 | + if configuration.parallelToolCalls, calls.count > 1, await allReadOnly(calls) { | |
| 435 | + // Concurrent read-only execution; results re-ordered by call index. | |
| 436 | + var startedAt: [String: Date] = [:] | |
| 437 | + for call in calls { | |
| 438 | + let invocation = AgentToolInvocation(call: call, startedAt: Date()) | |
| 439 | + startedAt[call.id] = invocation.startedAt | |
| 440 | + emit(.toolCallStarted(stepID: stepID, invocation)) | |
| 441 | + } | |
| 442 | + let registry = tools | |
| 443 | + let collected: [(Int, ToolResult)] = try await withThrowingTaskGroup(of: (Int, ToolResult).self) { group in | |
| 444 | + for (index, call) in calls.enumerated() { | |
| 445 | + let context = makeContext(stepID: stepID, callID: call.id) | |
| 446 | + group.addTask { | |
| 447 | + (index, try await registry.execute(call: call, context: context)) | |
| 448 | + } | |
| 449 | + } | |
| 450 | + var results: [(Int, ToolResult)] = [] | |
| 451 | + for try await item in group { results.append(item) } | |
| 452 | + return results.sorted { $0.0 < $1.0 } | |
| 453 | + } | |
| 454 | + for (index, rawResult) in collected { | |
| 455 | + let call = calls[index] | |
| 456 | + let result = memory.offloadIfNeeded(rawResult, stepIndex: stepIndex, toolName: call.name) | |
| 457 | + outcome.sawNewToolCall = loopGuard.recordInvocation(call: call, isError: result.isError) || outcome.sawNewToolCall | |
| 458 | + var invocation = AgentToolInvocation(call: call, result: result, startedAt: startedAt[call.id]) | |
| 459 | + invocation.finishedAt = Date() | |
| 460 | + outcome.results.append(result) | |
| 461 | + outcome.invocations.append(invocation) | |
| 462 | + emit(.toolCallFinished(stepID: stepID, invocation)) | |
| 463 | + } | |
| 464 | + return outcome | |
| 465 | + } | |
| 466 | + | |
| 467 | + for call in calls { | |
| 468 | + try Task.checkCancellation() | |
| 469 | + var invocation = AgentToolInvocation(call: call, startedAt: Date()) | |
| 470 | + emit(.toolCallStarted(stepID: stepID, invocation)) | |
| 471 | + | |
| 472 | + let result: ToolResult | |
| 473 | + if call.name == Planner.toolName { | |
| 474 | + let applied = planner.apply(call: call) | |
| 475 | + result = applied.result | |
| 476 | + outcome.planChanged = outcome.planChanged || applied.changed | |
| 477 | + if let plan = planner.plan { | |
| 478 | + emit(.planUpdated(plan)) | |
| 479 | + transcript?.recordPlan(plan, stepIndex: stepIndex) | |
| 480 | + } | |
| 481 | + } else { | |
| 482 | + let context = makeContext(stepID: stepID, callID: call.id) | |
| 483 | + let raw = try await tools.execute(call: call, context: context) | |
| 484 | + result = memory.offloadIfNeeded(raw, stepIndex: stepIndex, toolName: call.name) | |
| 485 | + } | |
| 486 | + | |
| 487 | + outcome.sawNewToolCall = loopGuard.recordInvocation(call: call, isError: result.isError) || outcome.sawNewToolCall | |
| 488 | + invocation.result = result | |
| 489 | + invocation.finishedAt = Date() | |
| 490 | + outcome.results.append(result) | |
| 491 | + outcome.invocations.append(invocation) | |
| 492 | + emit(.toolCallFinished(stepID: stepID, invocation)) | |
| 493 | + } | |
| 494 | + return outcome | |
| 495 | + } | |
| 496 | + | |
| 497 | + private func makeContext(stepID: UUID, callID: String) -> ToolExecutionContext { | |
| 498 | + let continuation = eventContinuation | |
| 499 | + return ToolExecutionContext( | |
| 500 | + workspaceURL: workspace.root, | |
| 501 | + policy: policy, | |
| 502 | + audit: audit, | |
| 503 | + onOutput: { chunk in | |
| 504 | + continuation?.yield(.toolOutput(stepID: stepID, callID: callID, chunk)) | |
| 505 | + }, | |
| 506 | + workspaceManager: workspace | |
| 507 | + ) | |
| 508 | + } | |
| 509 | + | |
| 510 | + /// True when every call in the batch is safe to run concurrently: the | |
| 511 | + /// read-only file tools, or a bash command the policy auto-allows under | |
| 512 | + /// the current mode (i.e. its whole payload is read-only). Anything | |
| 513 | + /// mutating, plan-touching, or approval-bound runs sequentially. | |
| 514 | + private func allReadOnly(_ calls: [ToolCall]) async -> Bool { | |
| 515 | + let readOnlyTools: Set<String> = ["read_file", "list_dir", "search_files"] | |
| 516 | + for call in calls { | |
| 517 | + if readOnlyTools.contains(call.name) { continue } | |
| 518 | + if call.name == "bash", | |
| 519 | + let command = call.argumentsDictionary?["command"] as? String { | |
| 520 | + let ruling = await policy.evaluate(ActionRequest( | |
| 521 | + kind: .shellCommand, payload: command, cwd: workspace.root, explanation: nil | |
| 522 | + )) | |
| 523 | + if case .allow = ruling { continue } | |
| 524 | + } | |
| 525 | + return false | |
| 526 | + } | |
| 527 | + return true | |
| 528 | + } | |
| 529 | + | |
| 530 | + // MARK: - Progress & pausing | |
| 531 | + | |
| 532 | + /// Cheap signature of the workspace's tracked files (stall detection). | |
| 533 | + private func filesSignature() async -> Int { | |
| 534 | + var hasher = Hasher() | |
| 535 | + for entry in await workspace.files() { | |
| 536 | + hasher.combine(entry.path) | |
| 537 | + hasher.combine(entry.lastTouched) | |
| 538 | + } | |
| 539 | + return hasher.finalize() | |
| 540 | + } | |
| 541 | + | |
| 542 | + /// Emits the trip, pauses until resume()/stop()/cancel() answers, and | |
| 543 | + /// returns true when the run should continue (with raised budgets). | |
| 544 | + private func pauseOnTrip(_ trip: LoopGuardTrip) async -> Bool { | |
| 545 | + emit(.guardTripped(trip)) | |
| 546 | + emit(.statusChanged(.awaitingUser)) | |
| 547 | + let decision = await withCheckedContinuation { (continuation: CheckedContinuation<TripDecision, Never>) in | |
| 548 | + tripContinuation = continuation | |
| 549 | + } | |
| 550 | + switch decision { | |
| 551 | + case .resume(let raiseBudget): | |
| 552 | + if raiseBudget { | |
| 553 | + loopGuard.raiseBudgets() | |
| 554 | + } | |
| 555 | + emit(.statusChanged(.running)) | |
| 556 | + return true | |
| 557 | + case .stop: | |
| 558 | + return false | |
| 559 | + } | |
| 560 | + } | |
| 561 | + | |
| 562 | + // MARK: - Finishing | |
| 563 | + | |
| 564 | + private func emit(_ event: AgentEvent) { | |
| 565 | + eventContinuation?.yield(event) | |
| 566 | + } | |
| 567 | + | |
| 568 | + private func finish(_ outcome: AgentRunOutcome) { | |
| 569 | + transcript?.finish(outcome: outcome) | |
| 570 | + emit(.statusChanged(.finished)) | |
| 571 | + emit(.runFinished(outcome)) | |
| 572 | + eventContinuation?.finish() | |
| 573 | + eventContinuation = nil | |
| 574 | + isRunning = false | |
| 575 | + } | |
| 576 | +} | |
added
Sources/ZyquoAgent/Agent/AgentSystemPrompt.swift
+85 −0
@@ -0,0 +1,85 @@ | ||
| 1 | +// | |
| 2 | +// AgentSystemPrompt.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The sectioned agent system prompt (docs/AGENT-RESEARCH.md §1.4): role, | |
| 9 | +// environment, tool policy, planning discipline, safety expectations, | |
| 10 | +// memory contract, and the done convention. The prompt is static (and thus | |
| 11 | +// compaction-immune — it travels outside history on every request); only | |
| 12 | +// the environment facts (workspace path, OS, tool list, safety mode) are | |
| 13 | +// interpolated. | |
| 14 | +// | |
| 15 | + | |
| 16 | +import Foundation | |
| 17 | + | |
| 18 | +enum AgentSystemPrompt { | |
| 19 | + /// Builds the system prompt for one run. | |
| 20 | + static func build( | |
| 21 | + workspacePath: String, | |
| 22 | + toolNames: [String], | |
| 23 | + safetyMode: SafetyMode | |
| 24 | + ) -> String { | |
| 25 | + let os = ProcessInfo.processInfo.operatingSystemVersionString | |
| 26 | + let date = ISO8601DateFormatter().string(from: Date()).prefix(10) | |
| 27 | + let tools = toolNames.joined(separator: ", ") | |
| 28 | + | |
| 29 | + return """ | |
| 30 | + You are Zyquo Agent, an autonomous agent that operates the user's Mac by calling tools. \ | |
| 31 | + You work in a plan → act → observe → reflect loop: issue tool calls, examine each result, \ | |
| 32 | + and decide the next action from what you actually observed — never from assumptions. | |
| 33 | + | |
| 34 | + ## Environment | |
| 35 | + - macOS \(os). Today's date: \(date). | |
| 36 | + - Task workspace (your working directory): \(workspacePath) | |
| 37 | + Relative paths resolve inside the workspace; bash commands run with it as cwd. \ | |
| 38 | + Do all work inside the workspace unless the task explicitly requires touching files \ | |
| 39 | + elsewhere — going outside it requires the user's approval. | |
| 40 | + - Safety mode: \(safetyMode.displayName). Actions may be held for the user's approval before they run. | |
| 41 | + | |
| 42 | + ## Tools | |
| 43 | + - Available tools: \(tools), \(Planner.toolName). | |
| 44 | + - Prefer the file tools (read_file, write_file, edit_file, list_dir, search_files) for file \ | |
| 45 | + content work; use bash for everything else a terminal does; use osascript to automate macOS apps. | |
| 46 | + - One logical action per tool call. Check each result — including exit codes — before proceeding; \ | |
| 47 | + never assume an action succeeded. | |
| 48 | + - Always read a file (read_file) before editing it (edit_file). | |
| 49 | + - Very large tool outputs are replaced in this conversation by a stub whose text names a saved \ | |
| 50 | + file under .zyquo/outputs/ — use read_file or search_files on that path when you need the full output. | |
| 51 | + | |
| 52 | + ## Planning | |
| 53 | + - FIRST, before acting on any non-trivial task: call \(Planner.toolName) with a short ordered \ | |
| 54 | + checklist (first item "active", the rest "pending"). | |
| 55 | + - Keep the plan current as you work: exactly one item "active"; mark items "done" the moment \ | |
| 56 | + they are verified, "failed" (with a note) when abandoned. | |
| 57 | + - If a step fails twice, stop repeating it: state briefly what went wrong and why, then revise \ | |
| 58 | + the plan with \(Planner.toolName) and take a different approach. | |
| 59 | + | |
| 60 | + ## Safety | |
| 61 | + - Every command, script, and file write passes a user-controlled policy gate. A denied action is \ | |
| 62 | + a signal to adapt: explain the situation or choose a safer alternative — never retry a denied \ | |
| 63 | + action verbatim and never try to work around the gate. | |
| 64 | + - Never use sudo or request elevated privileges. If a task genuinely needs them, say so in your \ | |
| 65 | + final answer instead of attempting it. | |
| 66 | + - Be conservative with destructive operations (deletion, overwriting, killing processes): prefer \ | |
| 67 | + reversible steps and confirm targets first. | |
| 68 | + | |
| 69 | + ## Memory | |
| 70 | + - MEMORY.md in the workspace root is yours. Read it near the start of the task; append durable \ | |
| 71 | + facts, decisions, and open questions as you learn them (edit_file/write_file). On long tasks, \ | |
| 72 | + older conversation turns are compacted into a summary — only the plan, MEMORY.md, and recent \ | |
| 73 | + messages survive verbatim, so write down anything you cannot afford to lose. | |
| 74 | + | |
| 75 | + ## Completion | |
| 76 | + - The task is complete only after you VERIFY the result with tools (list the directory, run the \ | |
| 77 | + test, read the file back). | |
| 78 | + - When — and only when — the task is verified complete, respond WITHOUT any tool call. That \ | |
| 79 | + final message is shown to the user as the result: summarize what was done, where the artifacts \ | |
| 80 | + are, and any caveats. | |
| 81 | + - If you cannot complete the task, respond without tool calls explaining what you tried, what \ | |
| 82 | + blocks you, and what the user could do. | |
| 83 | + """ | |
| 84 | + } | |
| 85 | +} | |
added
Sources/ZyquoAgent/Agent/LoopGuard.swift
+189 −0
@@ -0,0 +1,189 @@ | ||
| 1 | +// | |
| 2 | +// LoopGuard.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Bounds every run (docs/AGENT-RESEARCH.md §6.1): max steps, cumulative | |
| 9 | +// token budget, wall-clock budget, repetition detection (the same tool | |
| 10 | +// call failing N times), and stall detection (M consecutive steps with no | |
| 11 | +// plan change, no file change, and no new distinct tool call). A trip never | |
| 12 | +// aborts the run — the loop pauses and hands control back to the user | |
| 13 | +// ("pause, ask" is the correct trip behavior, per Anthropic's own | |
| 14 | +// pause_turn design), who can continue with raised budgets or stop. | |
| 15 | +// | |
| 16 | + | |
| 17 | +import Foundation | |
| 18 | + | |
| 19 | +/// Budgets and detection thresholds, user-tunable in Settings › Agent. | |
| 20 | +struct LoopGuardConfiguration: Sendable { | |
| 21 | + /// Hard cap on loop iterations per run. | |
| 22 | + var maxSteps = 50 | |
| 23 | + /// Cumulative token budget (input + output + reasoning) per run. | |
| 24 | + var tokenBudget = 500_000 | |
| 25 | + /// Wall-clock budget per run. | |
| 26 | + var wallClockBudget: TimeInterval = 30 * 60 | |
| 27 | + /// Same tool + normalized args failing this many times ⇒ repetition trip. | |
| 28 | + var repetitionThreshold = 3 | |
| 29 | + /// Consecutive no-progress steps ⇒ stall trip. | |
| 30 | + var stallThreshold = 6 | |
| 31 | + | |
| 32 | + static let `default` = LoopGuardConfiguration() | |
| 33 | +} | |
| 34 | + | |
| 35 | +/// Why the guard tripped. | |
| 36 | +enum LoopGuardTripReason: String, Codable, Sendable { | |
| 37 | + case maxSteps | |
| 38 | + case tokenBudget | |
| 39 | + case wallClockBudget | |
| 40 | + case repetition | |
| 41 | + case stall | |
| 42 | +} | |
| 43 | + | |
| 44 | +/// One trip, surfaced to the UI/CLI so the user can decide. | |
| 45 | +struct LoopGuardTrip: Codable, Sendable { | |
| 46 | + var reason: LoopGuardTripReason | |
| 47 | + /// Human-readable explanation shown on the pause card. | |
| 48 | + var message: String | |
| 49 | + /// Step at which the trip occurred. | |
| 50 | + var stepIndex: Int | |
| 51 | +} | |
| 52 | + | |
| 53 | +/// Run-bounding state machine. Plain struct — mutated only inside the | |
| 54 | +/// AgentLoop actor. | |
| 55 | +struct LoopGuard { | |
| 56 | + private(set) var configuration: LoopGuardConfiguration | |
| 57 | + private let startedAt = Date() | |
| 58 | + private(set) var totalTokens = 0 | |
| 59 | + | |
| 60 | + /// Consecutive failure count per tool-call signature (reset on success). | |
| 61 | + private var failingCallCounts: [String: Int] = [:] | |
| 62 | + /// Every distinct tool-call signature seen this run (novelty = progress). | |
| 63 | + private var seenCallSignatures: Set<String> = [] | |
| 64 | + private var stepsWithoutProgress = 0 | |
| 65 | + | |
| 66 | + init(configuration: LoopGuardConfiguration = .default) { | |
| 67 | + self.configuration = configuration | |
| 68 | + } | |
| 69 | + | |
| 70 | + var elapsed: TimeInterval { Date().timeIntervalSince(startedAt) } | |
| 71 | + | |
| 72 | + // MARK: Budgets (checked before each step) | |
| 73 | + | |
| 74 | + /// Nil when the next step may proceed; a trip otherwise. | |
| 75 | + mutating func checkBeforeStep(index: Int) -> LoopGuardTrip? { | |
| 76 | + if index > configuration.maxSteps { | |
| 77 | + return LoopGuardTrip( | |
| 78 | + reason: .maxSteps, | |
| 79 | + message: "Reached the step limit (\(configuration.maxSteps) steps).", | |
| 80 | + stepIndex: index | |
| 81 | + ) | |
| 82 | + } | |
| 83 | + if totalTokens >= configuration.tokenBudget { | |
| 84 | + return LoopGuardTrip( | |
| 85 | + reason: .tokenBudget, | |
| 86 | + message: "Reached the token budget (\(totalTokens) of \(configuration.tokenBudget) tokens used).", | |
| 87 | + stepIndex: index | |
| 88 | + ) | |
| 89 | + } | |
| 90 | + if elapsed >= configuration.wallClockBudget { | |
| 91 | + return LoopGuardTrip( | |
| 92 | + reason: .wallClockBudget, | |
| 93 | + message: "Reached the time budget (\(Int(elapsed / 60)) of \(Int(configuration.wallClockBudget / 60)) minutes).", | |
| 94 | + stepIndex: index | |
| 95 | + ) | |
| 96 | + } | |
| 97 | + return nil | |
| 98 | + } | |
| 99 | + | |
| 100 | + mutating func recordUsage(_ usage: TokenUsage) { | |
| 101 | + totalTokens += usage.totalTokens + (usage.reasoningTokens ?? 0) | |
| 102 | + } | |
| 103 | + | |
| 104 | + // MARK: Repetition & novelty (recorded per tool invocation) | |
| 105 | + | |
| 106 | + /// Records one executed tool call. Returns true when the call was a NEW | |
| 107 | + /// distinct signature (a progress signal for stall detection). | |
| 108 | + mutating func recordInvocation(call: ToolCall, isError: Bool) -> Bool { | |
| 109 | + let signature = Self.signature(of: call) | |
| 110 | + let isNew = seenCallSignatures.insert(signature).inserted | |
| 111 | + if isError { | |
| 112 | + failingCallCounts[signature, default: 0] += 1 | |
| 113 | + } else { | |
| 114 | + failingCallCounts[signature] = 0 | |
| 115 | + } | |
| 116 | + return isNew | |
| 117 | + } | |
| 118 | + | |
| 119 | + // MARK: Post-step verdict | |
| 120 | + | |
| 121 | + /// Checks repetition and stall after a tool-executing step. `planChanged`, | |
| 122 | + /// `filesChanged`, and `sawNewToolCall` are the progress signals. | |
| 123 | + mutating func recordStepOutcome( | |
| 124 | + stepIndex: Int, | |
| 125 | + planChanged: Bool, | |
| 126 | + filesChanged: Bool, | |
| 127 | + sawNewToolCall: Bool | |
| 128 | + ) -> LoopGuardTrip? { | |
| 129 | + if let (signature, count) = failingCallCounts.first(where: { $0.value >= configuration.repetitionThreshold }) { | |
| 130 | + // Reset so a user-resumed run isn't instantly re-tripped. | |
| 131 | + failingCallCounts[signature] = 0 | |
| 132 | + let display = signature.count > 160 ? String(signature.prefix(160)) + "…" : signature | |
| 133 | + return LoopGuardTrip( | |
| 134 | + reason: .repetition, | |
| 135 | + message: "The same tool call has failed \(count) times: \(display)", | |
| 136 | + stepIndex: stepIndex | |
| 137 | + ) | |
| 138 | + } | |
| 139 | + | |
| 140 | + if planChanged || filesChanged || sawNewToolCall { | |
| 141 | + stepsWithoutProgress = 0 | |
| 142 | + } else { | |
| 143 | + stepsWithoutProgress += 1 | |
| 144 | + } | |
| 145 | + if stepsWithoutProgress >= configuration.stallThreshold { | |
| 146 | + stepsWithoutProgress = 0 | |
| 147 | + return LoopGuardTrip( | |
| 148 | + reason: .stall, | |
| 149 | + message: "No progress detected for \(configuration.stallThreshold) consecutive steps (no plan change, no file change, no new tool call).", | |
| 150 | + stepIndex: stepIndex | |
| 151 | + ) | |
| 152 | + } | |
| 153 | + return nil | |
| 154 | + } | |
| 155 | + | |
| 156 | + // MARK: Resume support | |
| 157 | + | |
| 158 | + /// Raises every budget by half its current value (with sensible floors) | |
| 159 | + /// and clears the repetition/stall state — called when the user answers | |
| 160 | + /// a trip with "continue". | |
| 161 | + mutating func raiseBudgets() { | |
| 162 | + configuration.maxSteps += max(10, configuration.maxSteps / 2) | |
| 163 | + configuration.tokenBudget += max(100_000, configuration.tokenBudget / 2) | |
| 164 | + configuration.wallClockBudget += max(600, configuration.wallClockBudget / 2) | |
| 165 | + failingCallCounts = [:] | |
| 166 | + stepsWithoutProgress = 0 | |
| 167 | + } | |
| 168 | + | |
| 169 | + // MARK: Signature normalization | |
| 170 | + | |
| 171 | + /// "name|canonical-args": arguments parsed and re-serialized with sorted | |
| 172 | + /// keys so cosmetic JSON differences don't defeat repetition detection. | |
| 173 | + static func signature(of call: ToolCall) -> String { | |
| 174 | + "\(call.name)|\(normalizedArguments(call.argumentsJSON))" | |
| 175 | + } | |
| 176 | + | |
| 177 | + static func normalizedArguments(_ argumentsJSON: String) -> String { | |
| 178 | + guard let value = JSONValue.parse(argumentsJSON) else { | |
| 179 | + return argumentsJSON.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 180 | + } | |
| 181 | + let encoder = JSONEncoder() | |
| 182 | + encoder.outputFormatting = [.sortedKeys] | |
| 183 | + guard let data = try? encoder.encode(value), | |
| 184 | + let canonical = String(data: data, encoding: .utf8) else { | |
| 185 | + return argumentsJSON.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 186 | + } | |
| 187 | + return canonical | |
| 188 | + } | |
| 189 | +} | |
added
Sources/ZyquoAgent/Agent/MemoryManager.swift
+364 −0
@@ -0,0 +1,364 @@ | ||
| 1 | +// | |
| 2 | +// MemoryManager.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Context compression for long autonomous runs (docs/AGENT-RESEARCH.md §3): | |
| 9 | +// | |
| 10 | +// • Token accounting — chars/4 heuristic, calibrated by the provider's | |
| 11 | +// reported input tokens when available; the model's contextWindow bounds it. | |
| 12 | +// • Compaction at 85% — older completed steps' messages are replaced by ONE | |
| 13 | +// synthetic summary message produced by a summarization call to the SAME | |
| 14 | +// provider/model (with a mechanical fallback when that call fails). Always | |
| 15 | +// kept verbatim: the system prompt (never in history), the original task | |
| 16 | +// message, the most recent N steps, and — re-injected fresh inside the | |
| 17 | +// summary — the current plan and MEMORY.md. A thrash guard enforces a | |
| 18 | +// minimum step distance between compactions. | |
| 19 | +// • Output offloading — tool results above a size threshold are written to | |
| 20 | +// `.zyquo/outputs/step<N>-<tool>-<id>.txt` and replaced in-context by a | |
| 21 | +// head-of-output stub pointing at the file (readable via read_file / | |
| 22 | +// search_files). | |
| 23 | +// • MEMORY.md — created in the workspace root at task start; the system | |
| 24 | +// prompt gives the model ownership of it via the file tools; its content | |
| 25 | +// is re-read and re-injected at each compaction. | |
| 26 | +// | |
| 27 | + | |
| 28 | +import Foundation | |
| 29 | + | |
| 30 | +/// Compaction/offloading tunables (Settings › Agent in Phase 6). | |
| 31 | +struct MemoryConfiguration: Sendable { | |
| 32 | + /// Fraction of the model's context window that triggers compaction. | |
| 33 | + var compactionThreshold = 0.85 | |
| 34 | + /// Steps whose messages are always kept verbatim (most recent). | |
| 35 | + var keepRecentSteps = 6 | |
| 36 | + /// Thrash guard: minimum steps between two compactions. | |
| 37 | + var minStepsBetweenCompactions = 10 | |
| 38 | + /// Tool-result content above this many bytes is offloaded to a file. | |
| 39 | + var offloadThresholdBytes = 8_192 | |
| 40 | + /// Lines of the original output kept in the in-context stub. | |
| 41 | + var offloadStubLines = 40 | |
| 42 | + /// Max tokens requested from the summarization call. | |
| 43 | + var summaryMaxTokens = 1_200 | |
| 44 | + | |
| 45 | + static let `default` = MemoryConfiguration() | |
| 46 | +} | |
| 47 | + | |
| 48 | +/// One message of the loop's conversation history, tagged with the step that | |
| 49 | +/// produced it so compaction can operate on whole steps (preserving | |
| 50 | +/// tool_use/tool_result pairing, which is per-step by construction). | |
| 51 | +struct HistoryEntry: Sendable { | |
| 52 | + /// 0 = the initial task message; N = messages of step N. | |
| 53 | + var stepIndex: Int | |
| 54 | + var message: Message | |
| 55 | + /// Never compacted (the original task message). | |
| 56 | + var pinned: Bool = false | |
| 57 | + /// A synthetic summary produced by an earlier compaction (eligible for | |
| 58 | + /// re-summarization by the next one). | |
| 59 | + var isSummary: Bool = false | |
| 60 | +} | |
| 61 | + | |
| 62 | +/// One compaction, for the transcript and the UI. | |
| 63 | +struct CompactionRecord: Codable, Sendable { | |
| 64 | + var id: UUID = UUID() | |
| 65 | + var timestamp: Date = Date() | |
| 66 | + /// Estimated context tokens immediately before / after. | |
| 67 | + var beforeTokens: Int | |
| 68 | + var afterTokens: Int | |
| 69 | + /// How many step's message groups were folded into the summary, through | |
| 70 | + /// which step index. | |
| 71 | + var summarizedSteps: Int | |
| 72 | + var throughStep: Int | |
| 73 | + /// The synthetic summary text injected into context. | |
| 74 | + var summary: String | |
| 75 | +} | |
| 76 | + | |
| 77 | +/// Owned by the AgentLoop actor (class: mutable state survives async | |
| 78 | +/// summarization calls without inout-across-await restrictions). | |
| 79 | +final class MemoryManager { | |
| 80 | + let configuration: MemoryConfiguration | |
| 81 | + private let workspaceRoot: URL | |
| 82 | + private let outputsDirectory: URL | |
| 83 | + private(set) var lastCompactionStep = 0 | |
| 84 | + /// Latest input-token figure reported by the provider (calibrates the | |
| 85 | + /// heuristic estimate). | |
| 86 | + var lastReportedInputTokens: Int? | |
| 87 | + | |
| 88 | + init(workspaceRoot: URL, outputsDirectory: URL, configuration: MemoryConfiguration = .default) { | |
| 89 | + self.workspaceRoot = workspaceRoot | |
| 90 | + self.outputsDirectory = outputsDirectory | |
| 91 | + self.configuration = configuration | |
| 92 | + } | |
| 93 | + | |
| 94 | + // MARK: - Token accounting | |
| 95 | + | |
| 96 | + /// chars/4 heuristic — deliberately conservative and provider-neutral. | |
| 97 | + static func estimateTokens(_ text: String) -> Int { | |
| 98 | + text.isEmpty ? 0 : max(1, text.count / 4) | |
| 99 | + } | |
| 100 | + | |
| 101 | + /// Estimated tokens of one message (text + reasoning + tool calls + | |
| 102 | + /// tool results + fixed per-message overhead). | |
| 103 | + static func estimateTokens(of message: Message) -> Int { | |
| 104 | + var total = 8 // role/framing overhead | |
| 105 | + total += estimateTokens(message.text) | |
| 106 | + if let reasoning = message.reasoning { total += estimateTokens(reasoning) } | |
| 107 | + for call in message.toolCalls ?? [] { | |
| 108 | + total += estimateTokens(call.name) + estimateTokens(call.argumentsJSON) + 8 | |
| 109 | + } | |
| 110 | + for result in message.toolResults ?? [] { | |
| 111 | + total += estimateTokens(result.content) + 8 | |
| 112 | + } | |
| 113 | + return total | |
| 114 | + } | |
| 115 | + | |
| 116 | + func estimateTokens(entries: [HistoryEntry]) -> Int { | |
| 117 | + entries.reduce(0) { $0 + Self.estimateTokens(of: $1.message) } | |
| 118 | + } | |
| 119 | + | |
| 120 | + /// Fixed per-request overhead: system prompt + tool schemas. | |
| 121 | + static func estimateFixedTokens(systemPrompt: String, toolSpecs: [ToolSpec]) -> Int { | |
| 122 | + var total = estimateTokens(systemPrompt) | |
| 123 | + for spec in toolSpecs { | |
| 124 | + total += estimateTokens(spec.name) + estimateTokens(spec.description) + estimateTokens(spec.parametersJSONSchema) | |
| 125 | + } | |
| 126 | + return total | |
| 127 | + } | |
| 128 | + | |
| 129 | + /// Best current estimate of the next request's input size. | |
| 130 | + func currentContextTokens(entries: [HistoryEntry], fixedTokens: Int) -> Int { | |
| 131 | + max(fixedTokens + estimateTokens(entries: entries), lastReportedInputTokens ?? 0) | |
| 132 | + } | |
| 133 | + | |
| 134 | + /// True when usage crossed the threshold, the thrash guard allows it, and | |
| 135 | + /// there is at least one whole old step to fold away. | |
| 136 | + func shouldCompact(entries: [HistoryEntry], fixedTokens: Int, contextWindow: Int, currentStep: Int) -> Bool { | |
| 137 | + guard contextWindow > 0 else { return false } | |
| 138 | + let usage = currentContextTokens(entries: entries, fixedTokens: fixedTokens) | |
| 139 | + guard Double(usage) >= configuration.compactionThreshold * Double(contextWindow) else { return false } | |
| 140 | + guard currentStep - lastCompactionStep >= configuration.minStepsBetweenCompactions else { return false } | |
| 141 | + let cutoff = currentStep - configuration.keepRecentSteps | |
| 142 | + return entries.contains { !$0.pinned && $0.stepIndex <= cutoff && $0.stepIndex >= 1 } | |
| 143 | + } | |
| 144 | + | |
| 145 | + // MARK: - Compaction | |
| 146 | + | |
| 147 | + struct CompactionOutcome { | |
| 148 | + var entries: [HistoryEntry] | |
| 149 | + var record: CompactionRecord | |
| 150 | + } | |
| 151 | + | |
| 152 | + /// Folds every non-pinned message of steps ≤ (currentStep − keepRecent) | |
| 153 | + /// — including previous summaries — into one synthetic user message: | |
| 154 | + /// summary + fresh plan + fresh MEMORY.md. Returns nil when there is | |
| 155 | + /// nothing to compact. | |
| 156 | + func compact( | |
| 157 | + entries: [HistoryEntry], | |
| 158 | + planContext: String?, | |
| 159 | + steps: [AgentStep], | |
| 160 | + client: any ProviderClient, | |
| 161 | + apiKey: String, | |
| 162 | + model: AIModel, | |
| 163 | + currentStep: Int, | |
| 164 | + fixedTokens: Int | |
| 165 | + ) async -> CompactionOutcome? { | |
| 166 | + let cutoff = currentStep - configuration.keepRecentSteps | |
| 167 | + let toSummarize = entries.filter { !$0.pinned && $0.stepIndex <= cutoff } | |
| 168 | + guard !toSummarize.isEmpty else { return nil } | |
| 169 | + let kept = entries.filter { $0.pinned || $0.stepIndex > cutoff } | |
| 170 | + | |
| 171 | + let beforeTokens = fixedTokens + estimateTokens(entries: entries) | |
| 172 | + | |
| 173 | + // Summarize via the same provider/model; mechanical fallback on error. | |
| 174 | + let material = Self.renderMaterial(toSummarize) | |
| 175 | + var summary: String | |
| 176 | + do { | |
| 177 | + summary = try await Self.requestSummary( | |
| 178 | + material: material, | |
| 179 | + client: client, | |
| 180 | + apiKey: apiKey, | |
| 181 | + model: model, | |
| 182 | + maxTokens: configuration.summaryMaxTokens | |
| 183 | + ) | |
| 184 | + } catch { | |
| 185 | + summary = Self.mechanicalSummary(steps: steps, throughStep: cutoff) | |
| 186 | + summary += "\n(Note: the summarization call failed — this is a mechanical digest. Full history: .zyquo/transcript.json.)" | |
| 187 | + } | |
| 188 | + | |
| 189 | + var checkpoint = "[CONTEXT CHECKPOINT — steps 1–\(max(cutoff, 1)) were compacted into this summary]\n" | |
| 190 | + checkpoint += summary.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 191 | + checkpoint += "\n\nCURRENT PLAN:\n\(planContext ?? "(no plan recorded yet)")" | |
| 192 | + let memoryText = readMemoryFile()?.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 193 | + checkpoint += "\n\nMEMORY.md:\n\((memoryText?.isEmpty == false ? memoryText! : "(empty)"))" | |
| 194 | + | |
| 195 | + let summaryEntry = HistoryEntry( | |
| 196 | + stepIndex: max(cutoff, 1), | |
| 197 | + message: Message(role: .user, text: checkpoint), | |
| 198 | + isSummary: true | |
| 199 | + ) | |
| 200 | + | |
| 201 | + // Pinned early entries (the task message) stay first, then the | |
| 202 | + // summary, then the recent steps in their original order. | |
| 203 | + let earlyPinned = kept.filter { $0.stepIndex <= cutoff } | |
| 204 | + let recent = kept.filter { $0.stepIndex > cutoff } | |
| 205 | + let newEntries = earlyPinned + [summaryEntry] + recent | |
| 206 | + | |
| 207 | + lastCompactionStep = currentStep | |
| 208 | + let afterTokens = fixedTokens + estimateTokens(entries: newEntries) | |
| 209 | + let record = CompactionRecord( | |
| 210 | + beforeTokens: beforeTokens, | |
| 211 | + afterTokens: afterTokens, | |
| 212 | + summarizedSteps: Set(toSummarize.map(\.stepIndex)).count, | |
| 213 | + throughStep: max(cutoff, 1), | |
| 214 | + summary: summary | |
| 215 | + ) | |
| 216 | + return CompactionOutcome(entries: newEntries, record: record) | |
| 217 | + } | |
| 218 | + | |
| 219 | + /// Renders the messages being folded away into summarization material, | |
| 220 | + /// capped so the summarization call itself cannot blow the window. | |
| 221 | + static func renderMaterial(_ entries: [HistoryEntry]) -> String { | |
| 222 | + var lines: [String] = [] | |
| 223 | + for entry in entries { | |
| 224 | + let message = entry.message | |
| 225 | + var line = "[step \(entry.stepIndex)] \(message.role.rawValue):" | |
| 226 | + if !message.text.isEmpty { line += " \(message.text)" } | |
| 227 | + for call in message.toolCalls ?? [] { | |
| 228 | + line += "\n → \(call.name) \(String(call.argumentsJSON.prefix(400)))" | |
| 229 | + } | |
| 230 | + for result in message.toolResults ?? [] { | |
| 231 | + let status = result.isError ? "ERROR" : "ok" | |
| 232 | + line += "\n ← [\(status)] \(String(result.content.prefix(600)))" | |
| 233 | + } | |
| 234 | + lines.append(line) | |
| 235 | + } | |
| 236 | + var material = lines.joined(separator: "\n") | |
| 237 | + let cap = 60_000 | |
| 238 | + if material.count > cap { | |
| 239 | + // Keep the head (task framing) and the tail (recent detail). | |
| 240 | + let head = String(material.prefix(cap / 3)) | |
| 241 | + let tail = String(material.suffix(cap * 2 / 3)) | |
| 242 | + material = head + "\n… [middle elided] …\n" + tail | |
| 243 | + } | |
| 244 | + return material | |
| 245 | + } | |
| 246 | + | |
| 247 | + /// The summarization call: same provider/model, non-streaming, no tools. | |
| 248 | + private static func requestSummary( | |
| 249 | + material: String, | |
| 250 | + client: any ProviderClient, | |
| 251 | + apiKey: String, | |
| 252 | + model: AIModel, | |
| 253 | + maxTokens: Int | |
| 254 | + ) async throws -> String { | |
| 255 | + let instruction = """ | |
| 256 | + You compress an autonomous agent's working history into a compact \ | |
| 257 | + structured record. From the transcript below, produce EXACTLY \ | |
| 258 | + these three sections, tersely, preserving concrete values \ | |
| 259 | + (paths, filenames, commands, numbers, error messages): | |
| 260 | + | |
| 261 | + Sub-tasks completed: | |
| 262 | + Key facts, paths & decisions: | |
| 263 | + Open issues: | |
| 264 | + | |
| 265 | + TRANSCRIPT: | |
| 266 | + \(material) | |
| 267 | + """ | |
| 268 | + var parameters = ChatParameters(maxTokens: maxTokens) | |
| 269 | + parameters.temperature = nil | |
| 270 | + let request = ChatRequest( | |
| 271 | + model: model, | |
| 272 | + systemPrompt: "You are a precise summarizer inside the Zyquo Agent memory system. Output only the requested record — no preamble.", | |
| 273 | + messages: [Message(role: .user, text: instruction)], | |
| 274 | + parameters: parameters, | |
| 275 | + stream: false | |
| 276 | + ) | |
| 277 | + let reply = try await client.complete(request, apiKey: apiKey) | |
| 278 | + let text = reply.text.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 279 | + guard !text.isEmpty else { | |
| 280 | + throw ProviderError.invalidResponse(client.providerID, detail: "empty summarization reply") | |
| 281 | + } | |
| 282 | + return text | |
| 283 | + } | |
| 284 | + | |
| 285 | + /// Fallback digest built from the transcript's AgentSteps when the | |
| 286 | + /// summarization call fails (offline, rate-limited, mock runs). | |
| 287 | + static func mechanicalSummary(steps: [AgentStep], throughStep: Int) -> String { | |
| 288 | + var lines = ["Sub-tasks completed:"] | |
| 289 | + for step in steps where step.index >= 1 && step.index <= throughStep { | |
| 290 | + var line = "- Step \(step.index):" | |
| 291 | + let thought = step.text.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 292 | + if !thought.isEmpty { line += " \(String(thought.prefix(120)))" } | |
| 293 | + if !step.toolInvocations.isEmpty { | |
| 294 | + let calls = step.toolInvocations.map { invocation -> String in | |
| 295 | + let ok = invocation.result?.isError == true ? "error" : "ok" | |
| 296 | + return "\(invocation.call.name)(\(String(invocation.call.argumentsJSON.prefix(60))))→\(ok)" | |
| 297 | + } | |
| 298 | + line += " [\(calls.joined(separator: ", "))]" | |
| 299 | + } | |
| 300 | + lines.append(line) | |
| 301 | + } | |
| 302 | + lines.append("Key facts, paths & decisions:\n- (not extracted — mechanical digest)") | |
| 303 | + lines.append("Open issues:\n- (unknown)") | |
| 304 | + return lines.joined(separator: "\n") | |
| 305 | + } | |
| 306 | + | |
| 307 | + // MARK: - Output offloading | |
| 308 | + | |
| 309 | + /// Writes oversized tool-result content to `.zyquo/outputs/…` and returns | |
| 310 | + /// a stub result (head of the output + pointer). Small results pass | |
| 311 | + /// through untouched, as do failures to write the file. | |
| 312 | + func offloadIfNeeded(_ result: ToolResult, stepIndex: Int, toolName: String) -> ToolResult { | |
| 313 | + guard result.content.utf8.count > configuration.offloadThresholdBytes else { return result } | |
| 314 | + | |
| 315 | + let safeTool = toolName.map { $0.isLetter || $0.isNumber ? $0 : "-" } | |
| 316 | + let safeID = result.toolCallID.suffix(8).map { $0.isLetter || $0.isNumber ? $0 : "-" } | |
| 317 | + let fileName = "step\(stepIndex)-\(String(safeTool))-\(String(safeID)).txt" | |
| 318 | + let fileURL = outputsDirectory.appendingPathComponent(fileName) | |
| 319 | + | |
| 320 | + do { | |
| 321 | + try FileManager.default.createDirectory(at: outputsDirectory, withIntermediateDirectories: true) | |
| 322 | + try result.content.write(to: fileURL, atomically: true, encoding: .utf8) | |
| 323 | + } catch { | |
| 324 | + // Offloading is an optimization — never fail the tool call over it. | |
| 325 | + return result | |
| 326 | + } | |
| 327 | + | |
| 328 | + let allLines = result.content.components(separatedBy: "\n") | |
| 329 | + var head = allLines.prefix(configuration.offloadStubLines).joined(separator: "\n") | |
| 330 | + if head.count > 4_000 { head = String(head.prefix(4_000)) } | |
| 331 | + let sizeKB = Double(result.content.utf8.count) / 1000 | |
| 332 | + let relativePath = ".zyquo/outputs/\(fileName)" | |
| 333 | + let stub = head + """ | |
| 334 | + \n… [output truncated: \(String(format: "%.1f", sizeKB)) KB, \(allLines.count) lines total — \ | |
| 335 | + full output: \(relativePath) — use read_file or search_files on that path] | |
| 336 | + """ | |
| 337 | + return ToolResult(toolCallID: result.toolCallID, content: stub, isError: result.isError) | |
| 338 | + } | |
| 339 | + | |
| 340 | + // MARK: - MEMORY.md | |
| 341 | + | |
| 342 | + var memoryFileURL: URL { workspaceRoot.appendingPathComponent("MEMORY.md") } | |
| 343 | + | |
| 344 | + /// Creates MEMORY.md with its contract header if absent (task start). | |
| 345 | + func ensureMemoryFile() { | |
| 346 | + guard !FileManager.default.fileExists(atPath: memoryFileURL.path) else { return } | |
| 347 | + let template = """ | |
| 348 | + # MEMORY.md — Zyquo Agent task memory | |
| 349 | + | |
| 350 | + Durable facts, decisions, and open questions for this task. | |
| 351 | + Maintained by the agent (see system prompt): read at task start, | |
| 352 | + append important knowledge as it is learned. Content here survives | |
| 353 | + context compaction. | |
| 354 | + | |
| 355 | + """ | |
| 356 | + try? template.write(to: memoryFileURL, atomically: true, encoding: .utf8) | |
| 357 | + } | |
| 358 | + | |
| 359 | + /// Current MEMORY.md content (capped — memory is notes, not a dump). | |
| 360 | + func readMemoryFile() -> String? { | |
| 361 | + guard let text = try? String(contentsOf: memoryFileURL, encoding: .utf8) else { return nil } | |
| 362 | + return text.count > 16_000 ? String(text.prefix(16_000)) + "\n… [MEMORY.md truncated]" : text | |
| 363 | + } | |
| 364 | +} | |
added
Sources/ZyquoAgent/Agent/Planner.swift
+189 −0
@@ -0,0 +1,189 @@ | ||
| 1 | +// | |
| 2 | +// Planner.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The externalized todo list (docs/AGENT-RESEARCH.md §5.1): the model | |
| 9 | +// creates and maintains the plan by calling the `update_plan` TOOL — the | |
| 10 | +// reliable pattern (Claude Code's TodoWrite) versus parsing a plan out of | |
| 11 | +// prose. `update_plan` is intercepted by AgentLoop and handled here; it is | |
| 12 | +// NOT a side effect, so it never passes the PolicyEngine and never reaches | |
| 13 | +// the ToolRegistry. The plan persists to `.zyquo/plan.json` on every change | |
| 14 | +// and is re-injected into context after each compaction, so it survives by | |
| 15 | +// construction (§3.3 rule 1). | |
| 16 | +// | |
| 17 | + | |
| 18 | +import Foundation | |
| 19 | + | |
| 20 | +/// State of one plan item, mirrored by the Plan panel in Phase 6. | |
| 21 | +enum PlanItemStatus: String, Codable, Sendable { | |
| 22 | + case pending | |
| 23 | + case active | |
| 24 | + case done | |
| 25 | + case failed | |
| 26 | +} | |
| 27 | + | |
| 28 | +/// One checklist entry of the task plan. | |
| 29 | +struct PlanItem: Codable, Identifiable, Hashable, Sendable { | |
| 30 | + var id: UUID = UUID() | |
| 31 | + var title: String | |
| 32 | + var status: PlanItemStatus = .pending | |
| 33 | + /// Short model-provided note (why it failed, what changed…). | |
| 34 | + var note: String? | |
| 35 | +} | |
| 36 | + | |
| 37 | +/// The live task plan the agent maintains. | |
| 38 | +struct TaskPlan: Codable, Hashable, Sendable { | |
| 39 | + var items: [PlanItem] = [] | |
| 40 | + var updatedAt: Date = Date() | |
| 41 | + | |
| 42 | + var doneCount: Int { items.filter { $0.status == .done }.count } | |
| 43 | + var failedCount: Int { items.filter { $0.status == .failed }.count } | |
| 44 | + | |
| 45 | + /// Human-readable checklist (context injection, CLI, exports). | |
| 46 | + func rendered() -> String { | |
| 47 | + guard !items.isEmpty else { return "(empty plan)" } | |
| 48 | + return items.map { item in | |
| 49 | + let box: String | |
| 50 | + switch item.status { | |
| 51 | + case .pending: box = "[ ]" | |
| 52 | + case .active: box = "[>]" | |
| 53 | + case .done: box = "[x]" | |
| 54 | + case .failed: box = "[!]" | |
| 55 | + } | |
| 56 | + let note = item.note.map { " — \($0)" } ?? "" | |
| 57 | + return "\(box) \(item.title)\(note)" | |
| 58 | + }.joined(separator: "\n") | |
| 59 | + } | |
| 60 | +} | |
| 61 | + | |
| 62 | +/// Owns the plan for one run: applies `update_plan` calls, persists to | |
| 63 | +/// `.zyquo/plan.json`, and renders the plan for context injection. Plain | |
| 64 | +/// struct — mutated only inside the AgentLoop actor. | |
| 65 | +struct Planner { | |
| 66 | + /// Wire name of the internal plan tool (intercepted by AgentLoop). | |
| 67 | + static let toolName = "update_plan" | |
| 68 | + | |
| 69 | + /// The `update_plan` spec offered to the model alongside the real tools. | |
| 70 | + static let toolSpec = ToolSpec( | |
| 71 | + name: toolName, | |
| 72 | + description: """ | |
| 73 | + Create or update your task plan (a short ordered checklist the \ | |
| 74 | + user watches live). Call this FIRST on any non-trivial task to \ | |
| 75 | + draft the plan, then again whenever an item's status changes or \ | |
| 76 | + the approach changes — resend the COMPLETE list every time (it \ | |
| 77 | + replaces the previous plan). Keep exactly one item "active" \ | |
| 78 | + while working; mark items "done" immediately when verified and \ | |
| 79 | + "failed" (with a note) when abandoned. This tool only records \ | |
| 80 | + the plan — it has no other effect and needs no approval. | |
| 81 | + """, | |
| 82 | + parametersJSONSchema: #""" | |
| 83 | + {"type":"object","properties":{"items":{"type":"array","description":"The complete current plan, in execution order.","items":{"type":"object","properties":{"title":{"type":"string","description":"Short imperative step description."},"status":{"type":"string","enum":["pending","active","done","failed"],"description":"Current state of this step."},"note":{"type":"string","description":"Optional short note (failure reason, change of approach…)."}},"required":["title","status"]}}},"required":["items"]} | |
| 84 | + """# | |
| 85 | + ) | |
| 86 | + | |
| 87 | + private(set) var plan: TaskPlan? | |
| 88 | + private let fileURL: URL | |
| 89 | + | |
| 90 | + /// `workspaceRoot/.zyquo/plan.json`; reattaching an existing workspace | |
| 91 | + /// reloads its persisted plan. | |
| 92 | + init(workspaceRoot: URL) { | |
| 93 | + self.fileURL = workspaceRoot | |
| 94 | + .appendingPathComponent(".zyquo") | |
| 95 | + .appendingPathComponent("plan.json") | |
| 96 | + if let data = try? Data(contentsOf: fileURL), | |
| 97 | + let existing = try? Self.decoder.decode(TaskPlan.self, from: data) { | |
| 98 | + self.plan = existing | |
| 99 | + } | |
| 100 | + } | |
| 101 | + | |
| 102 | + /// Applies one `update_plan` call: parses the items, preserves stable | |
| 103 | + /// item IDs by position where titles match, persists, and returns the | |
| 104 | + /// ToolResult to thread back plus whether the plan actually changed | |
| 105 | + /// (LoopGuard progress signal). | |
| 106 | + mutating func apply(call: ToolCall) -> (result: ToolResult, changed: Bool) { | |
| 107 | + guard let arguments = call.argumentsDictionary, | |
| 108 | + let rawItems = arguments["items"] as? [[String: Any]] else { | |
| 109 | + return (ToolResult( | |
| 110 | + toolCallID: call.id, | |
| 111 | + content: "update_plan: expected {\"items\":[{\"title\":…,\"status\":…}]} — arguments were not a valid items array. Re-issue with the full plan.", | |
| 112 | + isError: true | |
| 113 | + ), false) | |
| 114 | + } | |
| 115 | + | |
| 116 | + var newItems: [PlanItem] = [] | |
| 117 | + for (index, raw) in rawItems.enumerated() { | |
| 118 | + guard let title = raw["title"] as? String, !title.isEmpty else { | |
| 119 | + return (ToolResult( | |
| 120 | + toolCallID: call.id, | |
| 121 | + content: "update_plan: item \(index + 1) is missing a non-empty `title`.", | |
| 122 | + isError: true | |
| 123 | + ), false) | |
| 124 | + } | |
| 125 | + let status = (raw["status"] as? String).flatMap(PlanItemStatus.init(rawValue:)) ?? .pending | |
| 126 | + let note = raw["note"] as? String | |
| 127 | + // Keep the item ID stable when the same title sits at the same | |
| 128 | + // position (the Plan panel animates state changes, not churn). | |
| 129 | + let previous = plan?.items.indices.contains(index) == true ? plan?.items[index] : nil | |
| 130 | + let id = (previous?.title == title) ? previous!.id : UUID() | |
| 131 | + newItems.append(PlanItem(id: id, title: title, status: status, note: note?.isEmpty == true ? nil : note)) | |
| 132 | + } | |
| 133 | + | |
| 134 | + func signature(_ items: [PlanItem]) -> String { | |
| 135 | + items.map { "\($0.title)|\($0.status.rawValue)|\($0.note ?? "")" }.joined(separator: "\n") | |
| 136 | + } | |
| 137 | + let changed = plan.map { signature($0.items) != signature(newItems) } ?? true | |
| 138 | + | |
| 139 | + plan = TaskPlan(items: newItems, updatedAt: Date()) | |
| 140 | + persist() | |
| 141 | + | |
| 142 | + let counts = statusCounts(of: newItems) | |
| 143 | + return (ToolResult( | |
| 144 | + toolCallID: call.id, | |
| 145 | + content: "Plan recorded (\(newItems.count) item\(newItems.count == 1 ? "" : "s"): \(counts))." | |
| 146 | + ), changed) | |
| 147 | + } | |
| 148 | + | |
| 149 | + /// The plan rendered for context re-injection after compaction; nil when | |
| 150 | + /// no plan exists yet. | |
| 151 | + func renderedForContext() -> String? { | |
| 152 | + plan?.rendered() | |
| 153 | + } | |
| 154 | + | |
| 155 | + // MARK: - Helpers | |
| 156 | + | |
| 157 | + private func statusCounts(of items: [PlanItem]) -> String { | |
| 158 | + var counts: [PlanItemStatus: Int] = [:] | |
| 159 | + for item in items { counts[item.status, default: 0] += 1 } | |
| 160 | + return [PlanItemStatus.done, .active, .pending, .failed] | |
| 161 | + .compactMap { status in counts[status].map { "\($0) \(status.rawValue)" } } | |
| 162 | + .joined(separator: ", ") | |
| 163 | + } | |
| 164 | + | |
| 165 | + private func persist() { | |
| 166 | + guard let plan else { return } | |
| 167 | + do { | |
| 168 | + try FileManager.default.createDirectory( | |
| 169 | + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true | |
| 170 | + ) | |
| 171 | + try Self.encoder.encode(plan).write(to: fileURL, options: .atomic) | |
| 172 | + } catch { | |
| 173 | + FileHandle.standardError.write(Data("Planner: plan.json save failed: \(error)\n".utf8)) | |
| 174 | + } | |
| 175 | + } | |
| 176 | + | |
| 177 | + private static let encoder: JSONEncoder = { | |
| 178 | + let encoder = JSONEncoder() | |
| 179 | + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] | |
| 180 | + encoder.dateEncodingStrategy = .iso8601 | |
| 181 | + return encoder | |
| 182 | + }() | |
| 183 | + | |
| 184 | + private static let decoder: JSONDecoder = { | |
| 185 | + let decoder = JSONDecoder() | |
| 186 | + decoder.dateDecodingStrategy = .iso8601 | |
| 187 | + return decoder | |
| 188 | + }() | |
| 189 | +} | |
added
Sources/ZyquoAgent/Agent/Transcript.swift
+127 −0
@@ -0,0 +1,127 @@ | ||
| 1 | +// | |
| 2 | +// Transcript.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Structured, persistent run history: the ordered AgentSteps, plan | |
| 9 | +// snapshots, compaction records, cumulative usage, and the final outcome. | |
| 10 | +// Saved incrementally to `.zyquo/transcript.json` after every mutation so a | |
| 11 | +// crashed or interrupted run loses nothing; replayable by the Phase 6 UI | |
| 12 | +// and inspectable by the Phase 7 trajectory review. | |
| 13 | +// | |
| 14 | + | |
| 15 | +import Foundation | |
| 16 | + | |
| 17 | +/// The plan as it stood after a given step (the Plan panel's history). | |
| 18 | +struct PlanSnapshot: Codable, Sendable { | |
| 19 | + var stepIndex: Int | |
| 20 | + var timestamp: Date = Date() | |
| 21 | + var plan: TaskPlan | |
| 22 | +} | |
| 23 | + | |
| 24 | +/// The complete on-disk run record. | |
| 25 | +struct TranscriptDocument: Codable, Sendable { | |
| 26 | + var version = 1 | |
| 27 | + var task: String | |
| 28 | + var modelID: String | |
| 29 | + var provider: ProviderID | |
| 30 | + var workspacePath: String | |
| 31 | + var startedAt: Date = Date() | |
| 32 | + var finishedAt: Date? | |
| 33 | + var steps: [AgentStep] = [] | |
| 34 | + var planSnapshots: [PlanSnapshot] = [] | |
| 35 | + var compactions: [CompactionRecord] = [] | |
| 36 | + var totalUsage = TokenUsage() | |
| 37 | + var outcome: AgentRunOutcome? | |
| 38 | +} | |
| 39 | + | |
| 40 | +/// Incremental writer around a TranscriptDocument. Plain struct — mutated | |
| 41 | +/// only inside the AgentLoop actor; every mutating call persists. | |
| 42 | +struct Transcript { | |
| 43 | + private(set) var document: TranscriptDocument | |
| 44 | + let fileURL: URL | |
| 45 | + | |
| 46 | + /// Creates a fresh transcript for a run, writing the initial document. | |
| 47 | + init(task: String, model: AIModel, workspaceRoot: URL) { | |
| 48 | + self.document = TranscriptDocument( | |
| 49 | + task: task, | |
| 50 | + modelID: model.id, | |
| 51 | + provider: model.provider, | |
| 52 | + workspacePath: workspaceRoot.path | |
| 53 | + ) | |
| 54 | + self.fileURL = workspaceRoot | |
| 55 | + .appendingPathComponent(".zyquo") | |
| 56 | + .appendingPathComponent("transcript.json") | |
| 57 | + save() | |
| 58 | + } | |
| 59 | + | |
| 60 | + /// Loads a persisted transcript (task reopen / trajectory inspection). | |
| 61 | + static func load(from workspaceRoot: URL) -> TranscriptDocument? { | |
| 62 | + let url = workspaceRoot | |
| 63 | + .appendingPathComponent(".zyquo") | |
| 64 | + .appendingPathComponent("transcript.json") | |
| 65 | + guard let data = try? Data(contentsOf: url) else { return nil } | |
| 66 | + return try? Self.decoder.decode(TranscriptDocument.self, from: data) | |
| 67 | + } | |
| 68 | + | |
| 69 | + // MARK: Mutations (each one persists) | |
| 70 | + | |
| 71 | + /// Inserts the step or replaces the existing snapshot with the same id. | |
| 72 | + mutating func upsert(step: AgentStep) { | |
| 73 | + if let index = document.steps.firstIndex(where: { $0.id == step.id }) { | |
| 74 | + document.steps[index] = step | |
| 75 | + } else { | |
| 76 | + document.steps.append(step) | |
| 77 | + } | |
| 78 | + save() | |
| 79 | + } | |
| 80 | + | |
| 81 | + mutating func recordPlan(_ plan: TaskPlan, stepIndex: Int) { | |
| 82 | + document.planSnapshots.append(PlanSnapshot(stepIndex: stepIndex, plan: plan)) | |
| 83 | + save() | |
| 84 | + } | |
| 85 | + | |
| 86 | + mutating func recordCompaction(_ record: CompactionRecord) { | |
| 87 | + document.compactions.append(record) | |
| 88 | + save() | |
| 89 | + } | |
| 90 | + | |
| 91 | + mutating func addUsage(_ usage: TokenUsage) { | |
| 92 | + document.totalUsage = document.totalUsage + usage | |
| 93 | + save() | |
| 94 | + } | |
| 95 | + | |
| 96 | + mutating func finish(outcome: AgentRunOutcome) { | |
| 97 | + document.outcome = outcome | |
| 98 | + document.finishedAt = Date() | |
| 99 | + save() | |
| 100 | + } | |
| 101 | + | |
| 102 | + // MARK: Persistence | |
| 103 | + | |
| 104 | + func save() { | |
| 105 | + do { | |
| 106 | + try FileManager.default.createDirectory( | |
| 107 | + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true | |
| 108 | + ) | |
| 109 | + try Self.encoder.encode(document).write(to: fileURL, options: .atomic) | |
| 110 | + } catch { | |
| 111 | + FileHandle.standardError.write(Data("Transcript save failed: \(error)\n".utf8)) | |
| 112 | + } | |
| 113 | + } | |
| 114 | + | |
| 115 | + private static let encoder: JSONEncoder = { | |
| 116 | + let encoder = JSONEncoder() | |
| 117 | + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] | |
| 118 | + encoder.dateEncodingStrategy = .iso8601 | |
| 119 | + return encoder | |
| 120 | + }() | |
| 121 | + | |
| 122 | + private static let decoder: JSONDecoder = { | |
| 123 | + let decoder = JSONDecoder() | |
| 124 | + decoder.dateDecodingStrategy = .iso8601 | |
| 125 | + return decoder | |
| 126 | + }() | |
| 127 | +} | |
modified
Sources/ZyquoAgent/App/AgentCLI.swift
+544 −6
@@ -5,25 +5,563 @@ | ||
| 5 | 5 | // Author: Simon-Pierre Boucher |
| 6 | 6 | // Mail: contact@spboucher.ai |
| 7 | 7 | // |
| 8 | −// Headless command-line modes: `--run "<task>"` (agent loop POC, Phase 3), | |
| 9 | −// `--verify` (provider tool-calling harness, Phase 7), and | |
| 10 | −// `--verify-policy` (PolicyEngine safety self-check, Phase 3.C — asserts | |
| 11 | −// the circuit-breaker guarantees and prints PASS/FAIL per case). | |
| 8 | +// Headless command-line modes: | |
| 9 | +// | |
| 10 | +// --run "<task>" [--model <id | provider/id>] [--mode manual|guarded|autonomous] | |
| 11 | +// [--workspace <path>] [--max-steps N] [--yes] [--allow-destructive] | |
| 12 | +// The Phase 3 agent POC: runs a real agent task end-to-end with live | |
| 13 | +// rendering, a stdin approval presenter, and a transcript summary. | |
| 14 | +// API keys come from the environment (ANTHROPIC_API_KEY, …) or the | |
| 15 | +// encrypted vault. | |
| 16 | +// | |
| 17 | +// --run-mock ["<task>"] | |
| 18 | +// Hidden CI smoke test: same engine, scripted MockProviderClient, | |
| 19 | +// scratch workspace, no network/keys. | |
| 20 | +// | |
| 21 | +// --verify Provider tool-calling harness (arrives in Phase 7). | |
| 22 | +// --verify-policy PolicyEngine safety self-check (Phase 3.C). | |
| 23 | +// | |
| 24 | +// `--yes` auto-approves mode-driven approvals for scripted runs, but NEVER | |
| 25 | +// silently approves the always-ask class (destructive/elevated actions, | |
| 26 | +// file access outside the workspace): those are auto-DENIED with a message | |
| 27 | +// unless `--allow-destructive` is also passed. Guard trips auto-stop under | |
| 28 | +// `--yes` (a scripted run must not raise its own budgets forever). | |
| 12 | 29 | // |
| 13 | 30 | |
| 14 | 31 | import Foundation |
| 15 | 32 | |
| 16 | 33 | enum AgentCLI { |
| 34 | + | |
| 35 | + // MARK: - Entry | |
| 36 | + | |
| 17 | 37 | static func run(arguments: [String]) async -> Int32 { |
| 18 | 38 | if arguments.contains("--verify-policy") { |
| 19 | 39 | let allPassed = await PolicyEngineSelfCheck.run() |
| 20 | 40 | return allPassed ? 0 : 1 |
| 21 | 41 | } |
| 22 | − FileHandle.standardError.write(Data("Zyquo Agent CLI: engine not built yet (arrives in Phase 3/7). Arguments: \(arguments.dropFirst().joined(separator: " "))\n".utf8)) | |
| 42 | + if arguments.contains("--verify") { | |
| 43 | + FileHandle.standardError.write(Data("Zyquo Agent CLI: --verify (provider tool-calling harness) arrives in Phase 7.\n".utf8)) | |
| 44 | + return 64 | |
| 45 | + } | |
| 46 | + if arguments.contains("--run") || arguments.contains("--run-mock") { | |
| 47 | + switch parseRunOptions(arguments) { | |
| 48 | + case .failure(let error): | |
| 49 | + FileHandle.standardError.write(Data((error.message + "\n" + usage + "\n").utf8)) | |
| 50 | + return 64 | |
| 51 | + case .success(let options): | |
| 52 | + return await runAgent(options: options) | |
| 53 | + } | |
| 54 | + } | |
| 55 | + FileHandle.standardError.write(Data((usage + "\n").utf8)) | |
| 23 | 56 | return 64 |
| 24 | 57 | } |
| 25 | 58 | |
| 26 | 59 | static func loadVault() { |
| 27 | − FileHandle.standardError.write(Data("Zyquo Agent CLI: vault loading arrives with the ported SecureKeyStore.\n".utf8)) | |
| 60 | + FileHandle.standardError.write(Data("Zyquo Agent CLI: --load-vault arrives with the Settings key-import flow.\n".utf8)) | |
| 61 | + } | |
| 62 | + | |
| 63 | + private static let usage = """ | |
| 64 | + Usage: ZyquoAgent --run "<task>" [--model <id | provider/id>] [--mode manual|guarded|autonomous] | |
| 65 | + [--workspace <path>] [--max-steps N] [--yes] [--allow-destructive] | |
| 66 | + """ | |
| 67 | + | |
| 68 | + // MARK: - Options | |
| 69 | + | |
| 70 | + private struct CLIUsageError: Error { | |
| 71 | + let message: String | |
| 72 | + } | |
| 73 | + | |
| 74 | + private struct RunOptions { | |
| 75 | + var task: String | |
| 76 | + var modelSpec: String? | |
| 77 | + var mode: SafetyMode = .guarded | |
| 78 | + var workspacePath: String? | |
| 79 | + var maxSteps: Int? | |
| 80 | + var autoApprove = false | |
| 81 | + var allowDestructive = false | |
| 82 | + var mock = false | |
| 83 | + } | |
| 84 | + | |
| 85 | + private static func parseRunOptions(_ arguments: [String]) -> Result<RunOptions, CLIUsageError> { | |
| 86 | + func fail(_ message: String) -> Result<RunOptions, CLIUsageError> { | |
| 87 | + .failure(CLIUsageError(message: message)) | |
| 88 | + } | |
| 89 | + | |
| 90 | + var options = RunOptions(task: "") | |
| 91 | + var index = 1 | |
| 92 | + var sawRunFlag = false | |
| 93 | + let args = arguments | |
| 94 | + | |
| 95 | + while index < args.count { | |
| 96 | + let arg = args[index] | |
| 97 | + func value(for flag: String) -> String? { | |
| 98 | + guard index + 1 < args.count else { return nil } | |
| 99 | + index += 1 | |
| 100 | + return args[index] | |
| 101 | + } | |
| 102 | + switch arg { | |
| 103 | + case "--run": | |
| 104 | + sawRunFlag = true | |
| 105 | + guard let task = value(for: arg), !task.hasPrefix("--"), !task.isEmpty else { | |
| 106 | + return fail("--run requires a task string.") | |
| 107 | + } | |
| 108 | + options.task = task | |
| 109 | + case "--run-mock": | |
| 110 | + sawRunFlag = true | |
| 111 | + options.mock = true | |
| 112 | + options.autoApprove = true // scripted, non-interactive by design | |
| 113 | + if index + 1 < args.count, !args[index + 1].hasPrefix("--") { | |
| 114 | + index += 1 | |
| 115 | + options.task = args[index] | |
| 116 | + } | |
| 117 | + if options.task.isEmpty { | |
| 118 | + options.task = "Create a demo folder in the workspace with a shell command and verify it exists." | |
| 119 | + } | |
| 120 | + case "--model": | |
| 121 | + guard let spec = value(for: arg) else { return fail("--model requires a model id.") } | |
| 122 | + options.modelSpec = spec | |
| 123 | + case "--mode": | |
| 124 | + guard let raw = value(for: arg), let mode = SafetyMode(rawValue: raw) else { | |
| 125 | + return fail("--mode must be manual, guarded, or autonomous.") | |
| 126 | + } | |
| 127 | + options.mode = mode | |
| 128 | + case "--workspace": | |
| 129 | + guard let path = value(for: arg) else { return fail("--workspace requires a path.") } | |
| 130 | + options.workspacePath = path | |
| 131 | + case "--max-steps": | |
| 132 | + guard let raw = value(for: arg), let steps = Int(raw), steps > 0 else { | |
| 133 | + return fail("--max-steps requires a positive integer.") | |
| 134 | + } | |
| 135 | + options.maxSteps = steps | |
| 136 | + case "--yes": | |
| 137 | + options.autoApprove = true | |
| 138 | + case "--allow-destructive": | |
| 139 | + options.allowDestructive = true | |
| 140 | + default: | |
| 141 | + break // tolerate unrelated flags (e.g. process serial numbers) | |
| 142 | + } | |
| 143 | + index += 1 | |
| 144 | + } | |
| 145 | + | |
| 146 | + guard sawRunFlag, !options.task.isEmpty else { | |
| 147 | + return fail("No task given.") | |
| 148 | + } | |
| 149 | + return .success(options) | |
| 150 | + } | |
| 151 | + | |
| 152 | + // MARK: - Run orchestration | |
| 153 | + | |
| 154 | + private static func runAgent(options: RunOptions) async -> Int32 { | |
| 155 | + let ansi = Ansi() | |
| 156 | + | |
| 157 | + // ---- Model + client + key ----------------------------------------- | |
| 158 | + let model: AIModel | |
| 159 | + let client: any ProviderClient | |
| 160 | + let apiKey: String | |
| 161 | + if options.mock { | |
| 162 | + model = MockProviderClient.model | |
| 163 | + client = MockProviderClient() | |
| 164 | + apiKey = "mock" | |
| 165 | + } else { | |
| 166 | + guard let resolved = resolveModel(spec: options.modelSpec) else { | |
| 167 | + FileHandle.standardError.write(Data("No model matches “\(options.modelSpec ?? "<default>")”. Use --model <id> or <provider>/<id> from the shared catalog.\n".utf8)) | |
| 168 | + return 64 | |
| 169 | + } | |
| 170 | + model = resolved | |
| 171 | + guard model.agentCapable else { | |
| 172 | + FileHandle.standardError.write(Data("\(model.displayName) is not in the agent-capable subset (docs/PROVIDER-REUSE.md §3) — it cannot run agent tasks reliably.\n".utf8)) | |
| 173 | + return 64 | |
| 174 | + } | |
| 175 | + client = ProviderRegistry.client(for: model) | |
| 176 | + guard let key = resolveAPIKey(for: model.provider) else { | |
| 177 | + let names = environmentKeyNames(for: model.provider).joined(separator: " or ") | |
| 178 | + FileHandle.standardError.write(Data("No API key for \(model.provider.displayName). Set \(names), or store one in the vault.\n".utf8)) | |
| 179 | + return 64 | |
| 180 | + } | |
| 181 | + apiKey = key | |
| 182 | + } | |
| 183 | + | |
| 184 | + // ---- Workspace ----------------------------------------------------- | |
| 185 | + let workspace: WorkspaceManager | |
| 186 | + do { | |
| 187 | + if let path = options.workspacePath { | |
| 188 | + let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath).standardizedFileURL | |
| 189 | + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) | |
| 190 | + workspace = try WorkspaceManager(existingAt: url) | |
| 191 | + } else if options.mock { | |
| 192 | + workspace = try WorkspaceManager.scratch(label: "mockrun") | |
| 193 | + } else { | |
| 194 | + workspace = try WorkspaceManager(taskTitle: options.task) | |
| 195 | + } | |
| 196 | + } catch { | |
| 197 | + FileHandle.standardError.write(Data("Could not prepare the workspace: \(error.localizedDescription)\n".utf8)) | |
| 198 | + return 1 | |
| 199 | + } | |
| 200 | + | |
| 201 | + // ---- Engine assembly ------------------------------------------------ | |
| 202 | + var configuration = AgentConfiguration.default | |
| 203 | + if let maxSteps = options.maxSteps { | |
| 204 | + configuration.loopGuard.maxSteps = maxSteps | |
| 205 | + } | |
| 206 | + | |
| 207 | + var executionConfiguration = ExecutionConfiguration.default | |
| 208 | + if let timeout = configuration.perCommandTimeout { | |
| 209 | + executionConfiguration.defaultTimeout = timeout | |
| 210 | + } | |
| 211 | + let executor = ExecutionService(configuration: executionConfiguration) | |
| 212 | + let audit = AuditLog(fileURL: workspace.internalDirectory.appendingPathComponent("audit.jsonl")) | |
| 213 | + let approvals = CLIApprovalPresenter( | |
| 214 | + autoApprove: options.autoApprove, | |
| 215 | + allowDestructive: options.allowDestructive, | |
| 216 | + ansi: ansi | |
| 217 | + ) | |
| 218 | + // Mock runs use temp-rooted persistence so remembered rules never | |
| 219 | + // touch the user's real policy-rules.json. | |
| 220 | + let persistence: PersistenceService = options.mock | |
| 221 | + ? PersistenceService(rootDirectory: FileManager.default.temporaryDirectory | |
| 222 | + .appendingPathComponent("ZyquoAgent-mockrun-\(UUID().uuidString.prefix(8))")) | |
| 223 | + : .shared | |
| 224 | + let policy = PolicyEngine(mode: options.mode, approvals: approvals, persistence: persistence) | |
| 225 | + let tools = ToolRegistry.standard(executor: executor) | |
| 226 | + let loop = AgentLoop( | |
| 227 | + model: model, | |
| 228 | + client: client, | |
| 229 | + apiKey: apiKey, | |
| 230 | + tools: tools, | |
| 231 | + policy: policy, | |
| 232 | + audit: audit, | |
| 233 | + workspace: workspace, | |
| 234 | + configuration: configuration | |
| 235 | + ) | |
| 236 | + | |
| 237 | + // ---- Banner ---------------------------------------------------------- | |
| 238 | + print(ansi.bold("Zyquo Agent") + " — headless run") | |
| 239 | + print(" task: \(options.task)") | |
| 240 | + print(" model: \(model.displayName) (\(model.provider.displayName))") | |
| 241 | + print(" mode: \(options.mode.displayName)\(options.autoApprove ? " [--yes]" : "")") | |
| 242 | + print(" workspace: \(workspace.root.path)") | |
| 243 | + | |
| 244 | + // ---- Consume the event stream --------------------------------------- | |
| 245 | + let renderer = CLIRenderer(ansi: ansi) | |
| 246 | + var finalOutcome: AgentRunOutcome? | |
| 247 | + do { | |
| 248 | + for try await event in await loop.run(task: options.task) { | |
| 249 | + renderer.render(event) | |
| 250 | + switch event { | |
| 251 | + case .guardTripped: | |
| 252 | + if options.autoApprove { | |
| 253 | + print(ansi.yellow(" Guard trip auto-answered with STOP (--yes runs never raise their own budgets).")) | |
| 254 | + await loop.stop() | |
| 255 | + } else { | |
| 256 | + print(ansi.yellow(" [c] continue with raised budget / [s] stop > "), terminator: "") | |
| 257 | + fflush(stdout) | |
| 258 | + let answer = readLine()?.lowercased() ?? "s" | |
| 259 | + if answer.hasPrefix("c") { | |
| 260 | + await loop.resume(raisingBudget: true) | |
| 261 | + } else { | |
| 262 | + await loop.stop() | |
| 263 | + } | |
| 264 | + } | |
| 265 | + case .runFinished(let outcome): | |
| 266 | + finalOutcome = outcome | |
| 267 | + default: | |
| 268 | + break | |
| 269 | + } | |
| 270 | + } | |
| 271 | + } catch { | |
| 272 | + print(ansi.red("\nRun stream failed: \(error.localizedDescription)")) | |
| 273 | + return 1 | |
| 274 | + } | |
| 275 | + | |
| 276 | + renderer.printSummary(workspacePath: workspace.root.path) | |
| 277 | + | |
| 278 | + if case .completed = finalOutcome { | |
| 279 | + return 0 | |
| 280 | + } | |
| 281 | + return 1 | |
| 282 | + } | |
| 283 | + | |
| 284 | + // MARK: - Model & key resolution | |
| 285 | + | |
| 286 | + /// Accepts a bare model id or "provider/id" ("anthropic/claude-sonnet-5"). | |
| 287 | + /// Bare ids that exist under several providers prefer the agent-capable, | |
| 288 | + /// then recommended entry. | |
| 289 | + private static func resolveModel(spec: String?) -> AIModel? { | |
| 290 | + let catalog = ModelCatalogData.all | |
| 291 | + guard let spec, !spec.isEmpty else { | |
| 292 | + return catalog.first { | |
| 293 | + $0.provider == AgentModelSupport.defaultModelProvider && $0.id == AgentModelSupport.defaultModelID | |
| 294 | + } ?? catalog.first(where: \.agentCapable) | |
| 295 | + } | |
| 296 | + if let slash = spec.firstIndex(of: "/"), | |
| 297 | + let provider = ProviderID(rawValue: String(spec[spec.startIndex..<slash])) { | |
| 298 | + let id = String(spec[spec.index(after: slash)...]) | |
| 299 | + if let exact = catalog.first(where: { $0.provider == provider && $0.id == id }) { | |
| 300 | + return exact | |
| 301 | + } | |
| 302 | + } | |
| 303 | + let matches = catalog.filter { $0.id == spec } | |
| 304 | + return matches.first(where: \.agentCapable) | |
| 305 | + ?? matches.first(where: \.isRecommended) | |
| 306 | + ?? matches.first | |
| 307 | + } | |
| 308 | + | |
| 309 | + /// Environment variable names checked (in order) for each provider, | |
| 310 | + /// before falling back to the encrypted vault. | |
| 311 | + private static func environmentKeyNames(for provider: ProviderID) -> [String] { | |
| 312 | + switch provider { | |
| 313 | + case .openai: return ["OPENAI_API_KEY"] | |
| 314 | + case .anthropic: return ["ANTHROPIC_API_KEY"] | |
| 315 | + case .xai: return ["XAI_API_KEY"] | |
| 316 | + case .mistral: return ["MISTRAL_API_KEY"] | |
| 317 | + case .gemini: return ["GEMINI_API_KEY", "GOOGLE_API_KEY"] | |
| 318 | + case .qwen: return ["QWEN_API_KEY", "DASHSCOPE_API_KEY"] | |
| 319 | + case .deepseek: return ["DEEPSEEK_API_KEY"] | |
| 320 | + case .kimi: return ["KIMI_API_KEY", "MOONSHOT_API_KEY"] | |
| 321 | + case .perplexity: return ["PERPLEXITY_API_KEY"] | |
| 322 | + case .together: return ["TOGETHER_API_KEY"] | |
| 323 | + case .deepinfra: return ["DEEPINFRA_API_KEY"] | |
| 324 | + case .cerebras: return ["CEREBRAS_API_KEY"] | |
| 325 | + case .custom: return ["ZYQUO_CUSTOM_API_KEY"] | |
| 326 | + } | |
| 327 | + } | |
| 328 | + | |
| 329 | + private static func resolveAPIKey(for provider: ProviderID) -> String? { | |
| 330 | + let environment = ProcessInfo.processInfo.environment | |
| 331 | + for name in environmentKeyNames(for: provider) { | |
| 332 | + if let value = environment[name], !value.isEmpty { | |
| 333 | + return value | |
| 334 | + } | |
| 335 | + } | |
| 336 | + return ((try? SecureKeyStore().key(for: provider)) ?? nil) | |
| 337 | + } | |
| 338 | +} | |
| 339 | + | |
| 340 | +// MARK: - Approval presenter (stdin) | |
| 341 | + | |
| 342 | +/// Prints the approval card and reads the decision from stdin. With | |
| 343 | +/// `autoApprove` (--yes): mode-driven asks are approved, but the always-ask | |
| 344 | +/// class (destructive/elevated risk, any file access outside the workspace) | |
| 345 | +/// is auto-DENIED unless `allowDestructive` (--allow-destructive) is set — | |
| 346 | +/// a scripted run must never silently authorize a destructive action. | |
| 347 | +struct CLIApprovalPresenter: ApprovalPresenting { | |
| 348 | + let autoApprove: Bool | |
| 349 | + let allowDestructive: Bool | |
| 350 | + let ansi: Ansi | |
| 351 | + | |
| 352 | + func requestApproval(for action: ActionRequest, risk: RiskAssessment) async -> ApprovalResolution { | |
| 353 | + printCard(for: action, risk: risk) | |
| 354 | + | |
| 355 | + if autoApprove { | |
| 356 | + let alwaysAskClass = risk.level == .destructive || risk.level == .elevated | |
| 357 | + || action.kind == .fileWriteOutsideWorkspace | |
| 358 | + || action.kind == .fileReadOutsideWorkspace | |
| 359 | + if alwaysAskClass && !allowDestructive { | |
| 360 | + print(ansi.red(" ✗ auto-DENIED under --yes: \(risk.reason) (pass --allow-destructive to permit)")) | |
| 361 | + return .deny | |
| 362 | + } | |
| 363 | + print(ansi.green(" ✓ auto-approved (--yes)")) | |
| 364 | + return .approve | |
| 365 | + } | |
| 366 | + | |
| 367 | + while true { | |
| 368 | + print(ansi.bold(" [a]pprove / [e]dit / [d]eny > "), terminator: "") | |
| 369 | + fflush(stdout) | |
| 370 | + guard let answer = readLine()?.lowercased() else { return .deny } | |
| 371 | + if answer.hasPrefix("a") { return .approve } | |
| 372 | + if answer.hasPrefix("d") { return .deny } | |
| 373 | + if answer.hasPrefix("e") { | |
| 374 | + print(" edited payload > ", terminator: "") | |
| 375 | + fflush(stdout) | |
| 376 | + guard let edited = readLine(), !edited.trimmingCharacters(in: .whitespaces).isEmpty else { | |
| 377 | + print(ansi.red(" empty edit — denied.")) | |
| 378 | + return .deny | |
| 379 | + } | |
| 380 | + return .approveEdited(edited) | |
| 381 | + } | |
| 382 | + } | |
| 383 | + } | |
| 384 | + | |
| 385 | + private func printCard(for action: ActionRequest, risk: RiskAssessment) { | |
| 386 | + let kind: String | |
| 387 | + switch action.kind { | |
| 388 | + case .shellCommand: kind = "shell command" | |
| 389 | + case .appleScript: kind = "AppleScript" | |
| 390 | + case .fileWrite: kind = "file write (workspace)" | |
| 391 | + case .fileWriteOutsideWorkspace: kind = "file write OUTSIDE the workspace" | |
| 392 | + case .fileReadOutsideWorkspace: kind = "file read OUTSIDE the workspace" | |
| 393 | + } | |
| 394 | + print("") | |
| 395 | + print(ansi.yellow(" ┌─ APPROVAL REQUIRED ─────────────────────────────")) | |
| 396 | + print(ansi.yellow(" │ ") + "kind: \(kind)") | |
| 397 | + print(ansi.yellow(" │ ") + "risk: \(risk.level.rawValue) — \(risk.reason)") | |
| 398 | + print(ansi.yellow(" │ ") + "cwd: \(action.cwd.path)") | |
| 399 | + for line in action.payload.split(separator: "\n", omittingEmptySubsequences: false) { | |
| 400 | + print(ansi.yellow(" │ ") + ansi.bold(" \(line)")) | |
| 401 | + } | |
| 402 | + if let explanation = action.explanation, !explanation.isEmpty { | |
| 403 | + print(ansi.yellow(" │ ") + "why: \(explanation)") | |
| 404 | + } | |
| 405 | + print(ansi.yellow(" └──────────────────────────────────────────────────")) | |
| 28 | 406 | } |
| 29 | 407 | } |
| 408 | + | |
| 409 | +// MARK: - Live renderer | |
| 410 | + | |
| 411 | +/// Renders the AgentEvent stream to the terminal: step headers, streamed | |
| 412 | +/// text, dimmed thinking, tool chips with payloads, live stdout/stderr, | |
| 413 | +/// plan checklists, compactions, and the highlighted final answer. | |
| 414 | +final class CLIRenderer { | |
| 415 | + private let ansi: Ansi | |
| 416 | + private let startedAt = Date() | |
| 417 | + private var stepsCompleted = 0 | |
| 418 | + private var totalInputTokens = 0 | |
| 419 | + private var totalOutputTokens = 0 | |
| 420 | + private var midLine = false | |
| 421 | + | |
| 422 | + init(ansi: Ansi) { | |
| 423 | + self.ansi = ansi | |
| 424 | + } | |
| 425 | + | |
| 426 | + func render(_ event: AgentEvent) { | |
| 427 | + switch event { | |
| 428 | + case .statusChanged(let status): | |
| 429 | + if status == .compacting { | |
| 430 | + breakLine() | |
| 431 | + print(ansi.dim(" ⟳ compacting context…")) | |
| 432 | + } | |
| 433 | + | |
| 434 | + case .stepStarted(let step): | |
| 435 | + breakLine() | |
| 436 | + print("\n" + ansi.bold("── Step \(step.index) ") + ansi.dim(String(repeating: "─", count: 40))) | |
| 437 | + | |
| 438 | + case .thinkingDelta(_, let delta): | |
| 439 | + print(ansi.dim(delta), terminator: "") | |
| 440 | + midLine = true | |
| 441 | + fflush(stdout) | |
| 442 | + | |
| 443 | + case .textDelta(_, let delta): | |
| 444 | + print(delta, terminator: "") | |
| 445 | + midLine = true | |
| 446 | + fflush(stdout) | |
| 447 | + | |
| 448 | + case .toolCallStreaming(_, _, _, let name): | |
| 449 | + breakLine() | |
| 450 | + print(ansi.violet(" ⚙ \(name) "), terminator: "") | |
| 451 | + midLine = true | |
| 452 | + fflush(stdout) | |
| 453 | + | |
| 454 | + case .toolCallArgumentsDelta(_, _, let delta): | |
| 455 | + print(ansi.dim(delta), terminator: "") | |
| 456 | + midLine = true | |
| 457 | + fflush(stdout) | |
| 458 | + | |
| 459 | + case .toolCallStarted(_, let invocation): | |
| 460 | + breakLine() | |
| 461 | + print(ansi.violet(" ▶ \(invocation.call.name)") + ansi.dim(" \(truncate(invocation.call.argumentsJSON, to: 200))")) | |
| 462 | + | |
| 463 | + case .toolOutput(_, _, let chunk): | |
| 464 | + breakLine() | |
| 465 | + switch chunk { | |
| 466 | + case .stdout(let line): print(" │ \(line)") | |
| 467 | + case .stderr(let line): print(ansi.red(" │ \(line)")) | |
| 468 | + case .note(let line): print(ansi.dim(" · \(line)")) | |
| 469 | + } | |
| 470 | + | |
| 471 | + case .toolCallFinished(_, let invocation): | |
| 472 | + breakLine() | |
| 473 | + if let result = invocation.result { | |
| 474 | + if result.isError { | |
| 475 | + print(ansi.red(" ✘ \(invocation.call.name) failed: \(truncate(firstLine(of: result.content), to: 160))")) | |
| 476 | + } else { | |
| 477 | + print(ansi.green(" ✔ \(invocation.call.name)") + ansi.dim(" \(truncate(firstLine(of: result.content), to: 120))")) | |
| 478 | + } | |
| 479 | + } | |
| 480 | + | |
| 481 | + case .stepCompleted(let step): | |
| 482 | + breakLine() | |
| 483 | + stepsCompleted = max(stepsCompleted, step.index) | |
| 484 | + totalInputTokens += step.inputTokens ?? 0 | |
| 485 | + totalOutputTokens += step.outputTokens ?? 0 | |
| 486 | + | |
| 487 | + case .planUpdated(let plan): | |
| 488 | + breakLine() | |
| 489 | + print(ansi.bold(" Plan (\(plan.doneCount)/\(plan.items.count) done):")) | |
| 490 | + for line in plan.rendered().split(separator: "\n") { | |
| 491 | + print(" \(line)") | |
| 492 | + } | |
| 493 | + | |
| 494 | + case .guardTripped(let trip): | |
| 495 | + breakLine() | |
| 496 | + print(ansi.yellow("\n ⏸ LOOP GUARD [\(trip.reason.rawValue)] at step \(trip.stepIndex): \(trip.message)")) | |
| 497 | + | |
| 498 | + case .compactionPerformed(let record): | |
| 499 | + breakLine() | |
| 500 | + print(ansi.dim(" ⟳ compacted \(record.summarizedSteps) step(s) through step \(record.throughStep): ~\(record.beforeTokens) → ~\(record.afterTokens) tokens")) | |
| 501 | + | |
| 502 | + case .runFinished(let outcome): | |
| 503 | + breakLine() | |
| 504 | + switch outcome { | |
| 505 | + case .completed(let finalAnswer): | |
| 506 | + print("\n" + ansi.green(ansi.bold("✔ Task complete"))) | |
| 507 | + let answer = finalAnswer.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 508 | + if !answer.isEmpty { | |
| 509 | + for line in answer.split(separator: "\n", omittingEmptySubsequences: false) { | |
| 510 | + print(" \(line)") | |
| 511 | + } | |
| 512 | + } | |
| 513 | + case .failed(let reason): | |
| 514 | + print("\n" + ansi.red(ansi.bold("✘ Task failed")) + " — \(reason)") | |
| 515 | + case .cancelled: | |
| 516 | + print("\n" + ansi.yellow(ansi.bold("■ Task cancelled"))) | |
| 517 | + case .stoppedByUser(let reason): | |
| 518 | + print("\n" + ansi.yellow(ansi.bold("■ Task stopped")) + " — \(reason)") | |
| 519 | + } | |
| 520 | + } | |
| 521 | + } | |
| 522 | + | |
| 523 | + func printSummary(workspacePath: String) { | |
| 524 | + let seconds = Date().timeIntervalSince(startedAt) | |
| 525 | + print(ansi.dim("\n steps: \(stepsCompleted) tokens: \(totalInputTokens) in / \(totalOutputTokens) out duration: \(String(format: "%.1f", seconds))s")) | |
| 526 | + print(ansi.dim(" workspace: \(workspacePath)")) | |
| 527 | + print(ansi.dim(" transcript: \(workspacePath)/.zyquo/transcript.json")) | |
| 528 | + } | |
| 529 | + | |
| 530 | + private func breakLine() { | |
| 531 | + if midLine { | |
| 532 | + print("") | |
| 533 | + midLine = false | |
| 534 | + } | |
| 535 | + } | |
| 536 | + | |
| 537 | + private func truncate(_ text: String, to limit: Int) -> String { | |
| 538 | + text.count > limit ? String(text.prefix(limit)) + "…" : text | |
| 539 | + } | |
| 540 | + | |
| 541 | + private func firstLine(of text: String) -> String { | |
| 542 | + text.split(separator: "\n", omittingEmptySubsequences: true).first.map(String.init) ?? text | |
| 543 | + } | |
| 544 | +} | |
| 545 | + | |
| 546 | +// MARK: - ANSI colors | |
| 547 | + | |
| 548 | +/// Minimal ANSI styling, disabled when stdout is not a TTY or NO_COLOR is set. | |
| 549 | +struct Ansi: Sendable { | |
| 550 | + let enabled: Bool | |
| 551 | + | |
| 552 | + init() { | |
| 553 | + self.enabled = isatty(STDOUT_FILENO) == 1 | |
| 554 | + && ProcessInfo.processInfo.environment["NO_COLOR"] == nil | |
| 555 | + } | |
| 556 | + | |
| 557 | + private func wrap(_ text: String, _ code: String) -> String { | |
| 558 | + enabled ? "\u{1B}[\(code)m\(text)\u{1B}[0m" : text | |
| 559 | + } | |
| 560 | + | |
| 561 | + func bold(_ text: String) -> String { wrap(text, "1") } | |
| 562 | + func dim(_ text: String) -> String { wrap(text, "2") } | |
| 563 | + func red(_ text: String) -> String { wrap(text, "31") } | |
| 564 | + func green(_ text: String) -> String { wrap(text, "32") } | |
| 565 | + func yellow(_ text: String) -> String { wrap(text, "33") } | |
| 566 | + func violet(_ text: String) -> String { wrap(text, "35") } | |
| 567 | +} | |
modified
Sources/ZyquoAgent/App/Main.swift
+4 −2
@@ -6,7 +6,8 @@ | ||
| 6 | 6 | // Mail: contact@spboucher.ai |
| 7 | 7 | // |
| 8 | 8 | // Entry point. `--run "<task>"` executes a headless agent task (Phase 3 CLI |
| 9 | −// POC), `--verify` runs the provider tool-calling harness (Phase 7), | |
| 9 | +// POC), `--run-mock` drives the same engine with a scripted provider (CI | |
| 10 | +// smoke test), `--verify` runs the provider tool-calling harness (Phase 7), | |
| 10 | 11 | // `--verify-policy` runs the PolicyEngine safety self-check (Phase 3.C), |
| 11 | 12 | // `--load-vault` seeds the encrypted vault from environment keys; otherwise |
| 12 | 13 | // the SwiftUI app launches. |
@@ -22,7 +23,8 @@ import Foundation | ||
| 22 | 23 | enum Main { |
| 23 | 24 | static func main() { |
| 24 | 25 | let arguments = CommandLine.arguments |
| 25 | − if arguments.contains("--run") || arguments.contains("--verify") || arguments.contains("--verify-policy") { | |
| 26 | + if arguments.contains("--run") || arguments.contains("--run-mock") | |
| 27 | + || arguments.contains("--verify") || arguments.contains("--verify-policy") { | |
| 26 | 28 | Task.detached { |
| 27 | 29 | let status = await AgentCLI.run(arguments: arguments) |
| 28 | 30 | exit(status) |
added
Sources/ZyquoAgent/App/MockProviderClient.swift
+140 −0
@@ -0,0 +1,140 @@ | ||
| 1 | +// | |
| 2 | +// MockProviderClient.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// A scripted ProviderClient behind the hidden `--run-mock` CLI flag: drives | |
| 9 | +// the AgentLoop end-to-end WITHOUT network or keys (CI smoke test). Emits | |
| 10 | +// three canned turns exactly like a real provider stream would — | |
| 11 | +// (1) update_plan draft + a real bash tool call, (2) update_plan marking the | |
| 12 | +// work done, (3) a final answer with no tool calls — proving plan | |
| 13 | +// interception, the policy gate, real process execution, live output | |
| 14 | +// streaming, transcript persistence, and endTurn termination. | |
| 15 | +// | |
| 16 | + | |
| 17 | +import Foundation | |
| 18 | + | |
| 19 | +struct MockProviderClient: ProviderClient { | |
| 20 | + var providerID: ProviderID { .custom } | |
| 21 | + | |
| 22 | + /// 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 | + ) | |
| 33 | + | |
| 34 | + /// The command the scripted bash call executes. `ZYQUO_MOCK_BASH` | |
| 35 | + /// overrides it so CI can also exercise the policy gate's ask/deny paths | |
| 36 | + /// (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 | + } | |
| 40 | + | |
| 41 | + 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) }.count | |
| 44 | + return AsyncThrowingStream { continuation in | |
| 45 | + 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 | + } | |
| 56 | + | |
| 57 | + /// 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 | + } | |
| 62 | + | |
| 63 | + 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 | + } | |
| 68 | + | |
| 69 | + let bashArguments = JSONValue.object([ | |
| 70 | + "command": .string(Self.bashCommand), | |
| 71 | + "explanation": .string("Print a greeting, create the demo folder, and list the workspace."), | |
| 72 | + ]).jsonString | |
| 73 | + 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 | + } | |
| 77 | + | |
| 78 | + 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 | + } | |
| 85 | + | |
| 86 | + /// 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 | + } | |
| 100 | + | |
| 101 | + /// 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 | + } | |
| 113 | + | |
| 114 | + /// Splits a JSON string into small fragments the way providers stream | |
| 115 | + /// 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 fragments | |
| 124 | + } | |
| 125 | + | |
| 126 | + /// 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: .custom | |
| 134 | + ) | |
| 135 | + } | |
| 136 | + | |
| 137 | + func listModelIDs(apiKey: String) async throws -> [String] { | |
| 138 | + [Self.model.id] | |
| 139 | + } | |
| 140 | +} | |
| 141 | ||