SPB Git

spb/zyquo-agent Public MIT

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

Swift 94.7% Shell 4.1% Python 0.7% Makefile 0.5%

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>
simon-pierre boucher committed 11 days ago (Jul 30, 2026) parent 7806cbe

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