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%

phase7.1: live provider verification 77/80 green — VerifyHarness, --load-vault, Gemini thought_signature round-trip, GPT-5.4+ reasoning_effort quirk

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 11 days ago (Jul 30, 2026) parent bd18a49

Showing 5 changed files with +644 and −9

modified Sources/ZyquoAgent/App/AgentCLI.swift +27 −4
@@ -18,7 +18,12 @@
18 18 // Hidden CI smoke test: same engine, scripted MockProviderClient,
19 19 // scratch workspace, no network/keys.
20 20 //
21 // --verify Provider tool-calling harness (arrives in Phase 7).
21 +// --verify [--provider <id>] [--model <id | provider/id>]
22 +// Phase 7.1: live provider tool-calling verification over every
23 +// agent-capable model (see Verify/VerifyHarness.swift). Writes
24 +// docs/VERIFICATION.md.
25 +//
26 +// --load-vault Imports environment API keys into the encrypted vault.
22 27 // --verify-policy PolicyEngine safety self-check (Phase 3.C).
23 28 //
24 29 // `--yes` auto-approves mode-driven approvals for scripted runs, but NEVER
@@ -40,8 +45,7 @@ enum AgentCLI {
40 45 return allPassed ? 0 : 1
41 46 }
42 47 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
48 + return await VerifyHarness.run(arguments: arguments)
45 49 }
46 50 if arguments.contains("--run") || arguments.contains("--run-mock") {
47 51 switch parseRunOptions(arguments) {
@@ -56,8 +60,27 @@ enum AgentCLI {
56 60 return 64
57 61 }
58 62
63 + /// `--load-vault`: seeds the encrypted vault from environment variables.
64 + /// Prints stored/skipped per provider; NEVER prints any part of a key.
59 65 static func loadVault() {
60 FileHandle.standardError.write(Data("Zyquo Agent CLI: --load-vault arrives with the Settings key-import flow.\n".utf8))
66 + let store = SecureKeyStore()
67 + let environment = ProcessInfo.processInfo.environment
68 + print("Zyquo Agent — importing API keys from the environment into the vault")
69 + for provider in ProviderID.builtIn {
70 + let names = environmentKeyNames(for: provider)
71 + guard let name = names.first(where: { !(environment[$0] ?? "").isEmpty }),
72 + let key = environment[name] else {
73 + print(" skipped \(provider.rawValue) — no \(names.joined(separator: "/")) set")
74 + continue
75 + }
76 + do {
77 + try store.setKey(key, for: provider)
78 + print(" stored \(provider.rawValue) (from \(name))")
79 + } catch {
80 + print(" FAILED \(provider.rawValue)\(error.localizedDescription)")
81 + }
82 + }
83 + print("Vault: \(store.vaultURL.path)")
61 84 }
62 85
63 86 private static let usage = """
modified Sources/ZyquoAgent/Models/ToolTypes.swift +8 −1
@@ -37,11 +37,18 @@ struct ToolCall: Codable, Identifiable, Hashable {
37 37 /// Raw accumulated JSON string of the arguments; parsed and validated
38 38 /// against the ToolSpec schema by the agent loop before execution.
39 39 var argumentsJSON: String
40 + /// Gemini 3+ thought signature (`extra_content.google.thought_signature`
41 + /// on the compat endpoint). Opaque; captured from responses and echoed
42 + /// back verbatim when the call is threaded into history — Gemini rejects
43 + /// tool results whose originating call lost its signature (verified live,
44 + /// Phase 7.1). Nil for every other provider.
45 + var thoughtSignature: String?
40 46
41 init(id: String, name: String, argumentsJSON: String) {
47 + init(id: String, name: String, argumentsJSON: String, thoughtSignature: String? = nil) {
42 48 self.id = id
43 49 self.name = name
44 50 self.argumentsJSON = argumentsJSON
51 + self.thoughtSignature = thoughtSignature
45 52 }
46 53
47 54 /// Arguments parsed to a dictionary; nil when the model produced
modified Sources/ZyquoAgent/Providers/OpenAICompatibleClient.swift +53 −4
@@ -112,16 +112,35 @@ struct OpenAICompatibleClient: ProviderClient {
112 112 }
113 113 }
114 114
115 /// An assistant tool call echoed back into history.
115 + /// An assistant tool call echoed back into history. `extraContent` carries
116 + /// Gemini 3+ thought signatures (`extra_content.google.thought_signature`)
117 + /// back verbatim — Gemini rejects threaded tool results without them.
116 118 private struct WireToolCallOut: Encodable {
117 119 var id: String
118 120 var type = "function"
119 121 var function: Function
122 + var extraContent: ExtraContent?
123 +
124 + enum CodingKeys: String, CodingKey {
125 + case id, type, function
126 + case extraContent = "extra_content"
127 + }
120 128
121 129 struct Function: Encodable {
122 130 var name: String
123 131 var arguments: String
124 132 }
133 +
134 + struct ExtraContent: Codable {
135 + var google: Google
136 +
137 + struct Google: Codable {
138 + var thoughtSignature: String?
139 + enum CodingKeys: String, CodingKey {
140 + case thoughtSignature = "thought_signature"
141 + }
142 + }
143 + }
125 144 }
126 145
127 146 /// Message content: plain string, or an array of text/image parts for vision.
@@ -257,6 +276,14 @@ struct OpenAICompatibleClient: ProviderClient {
257 276 var index: Int?
258 277 var id: String?
259 278 var function: FunctionFragment?
279 + /// Gemini 3+ attaches `extra_content.google.thought_signature` to the
280 + /// call (both streaming fragments and non-streaming entries).
281 + var extraContent: WireToolCallOut.ExtraContent?
282 +
283 + enum CodingKeys: String, CodingKey {
284 + case index, id, function
285 + case extraContent = "extra_content"
286 + }
260 287
261 288 struct FunctionFragment: Decodable {
262 289 var name: String?
@@ -378,6 +405,15 @@ struct OpenAICompatibleClient: ProviderClient {
378 405 // Native tool calling (only for models that support it — providers
379 406 // reject `tools` on non-tool models).
380 407 if !request.tools.isEmpty, request.model.capabilities.tools {
408 + // OpenAI GPT-5.4+ rejects function tools combined with any
409 + // reasoning_effort other than "none" on /chat/completions
410 + // ("Function tools with reasoning_effort are not supported …
411 + // set reasoning_effort to 'none'" — verified live, Phase 7.1).
412 + // GPT-5.2 and earlier accept both together.
413 + if providerID == .openai, wire.reasoningEffort != nil,
414 + ["gpt-5.4", "gpt-5.5", "gpt-5.6"].contains(where: request.model.id.hasPrefix) {
415 + wire.reasoningEffort = "none"
416 + }
381 417 wire.tools = request.tools.map { spec in
382 418 WireTool(function: WireTool.Function(
383 419 name: spec.name,
@@ -420,7 +456,14 @@ struct OpenAICompatibleClient: ProviderClient {
420 456 calls.isEmpty ? nil : calls.map { call in
421 457 WireToolCallOut(
422 458 id: call.id,
423 function: WireToolCallOut.Function(name: call.name, arguments: call.argumentsJSON)
459 + function: WireToolCallOut.Function(name: call.name, arguments: call.argumentsJSON),
460 + // Echo Gemini 3+ thought signatures back verbatim; other
461 + // providers never set one and never receive the field.
462 + extraContent: call.thoughtSignature.map {
463 + WireToolCallOut.ExtraContent(
464 + google: WireToolCallOut.ExtraContent.Google(thoughtSignature: $0)
465 + )
466 + }
424 467 )
425 468 }
426 469 }
@@ -446,6 +489,7 @@ struct OpenAICompatibleClient: ProviderClient {
446 489 var id: String?
447 490 var name: String?
448 491 var arguments = ""
492 + var thoughtSignature: String?
449 493 var announced = false
450 494 }
451 495
@@ -463,6 +507,9 @@ struct OpenAICompatibleClient: ProviderClient {
463 507 if let name = fragment.function?.name, !name.isEmpty {
464 508 partial.name = (partial.name ?? "") + name
465 509 }
510 + if let signature = fragment.extraContent?.google.thoughtSignature, !signature.isEmpty {
511 + partial.thoughtSignature = signature
512 + }
466 513 if !partial.announced, let name = partial.name {
467 514 partial.announced = true
468 515 if partial.id == nil {
@@ -486,7 +533,8 @@ struct OpenAICompatibleClient: ProviderClient {
486 533 ToolCall(
487 534 id: partial.id ?? "call_\(index)_\(UUID().uuidString.prefix(8))",
488 535 name: partial.name ?? "",
489 argumentsJSON: partial.arguments.isEmpty ? "{}" : partial.arguments
536 + argumentsJSON: partial.arguments.isEmpty ? "{}" : partial.arguments,
537 + thoughtSignature: partial.thoughtSignature
490 538 )
491 539 }
492 540 }
@@ -583,7 +631,8 @@ struct OpenAICompatibleClient: ProviderClient {
583 631 ToolCall(
584 632 id: call.id ?? "call_\(call.index ?? offset)_\(UUID().uuidString.prefix(8))",
585 633 name: call.function?.name ?? "",
586 argumentsJSON: call.function?.arguments ?? "{}"
634 + argumentsJSON: call.function?.arguments ?? "{}",
635 + thoughtSignature: call.extraContent?.google.thoughtSignature
587 636 )
588 637 }
589 638 }
added Sources/ZyquoAgent/Verify/VerifyHarness.swift +431 −0
@@ -0,0 +1,431 @@
1 +//
2 +// VerifyHarness.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Phase 7.1 — provider tool-calling verification, run against LIVE APIs with
9 +// the exact production provider clients. For every agent-capable model whose
10 +// provider has an API key available (environment or vault), the harness:
11 +//
12 +// 1. Streams a chat request offering ONE tool (`list_files`) and asserts the
13 +// model emits a tool call with the right name, syntactically valid JSON
14 +// arguments, and a normalized StopReason of `.toolUse`.
15 +// 2. Threads a canned tool_result back in the provider's wire format and
16 +// asserts the model produces a final text answer that mentions at least
17 +// one of the listed file names, with StopReason `.endTurn`.
18 +// 3. Asserts streaming actually streamed (more than one text/argument delta
19 +// across the exchange, not a single blob).
20 +//
21 +// Results are printed live and written as a markdown table to
22 +// docs/VERIFICATION.md. Providers run sequentially, models within a provider
23 +// sequentially, with a polite delay between calls. Keys are never logged.
24 +//
25 +// Usage: ZyquoAgent --verify [--provider <id>] [--model <id | provider/id>]
26 +//
27 +
28 +import Foundation
29 +
30 +enum VerifyHarness {
31 +
32 + // MARK: - Constants
33 +
34 + /// The single tool offered to every model. {path?: string} — an empty
35 + /// arguments object is a perfectly valid call.
36 + static let listFilesTool = ToolSpec(
37 + name: "list_files",
38 + description: "List the files in the current workspace directory.",
39 + parametersJSONSchema: """
40 + {"type":"object","properties":{"path":{"type":"string","description":"Optional subdirectory to list, relative to the workspace root."}}}
41 + """
42 + )
43 +
44 + static let systemPrompt = """
45 + You are Zyquo Agent, an autonomous agent operating inside a workspace directory on the user's Mac. \
46 + You must use the provided tools to inspect the workspace — never guess or fabricate file listings. \
47 + Call a tool when you need information. Once you have the tool result, answer the user in plain text \
48 + (naming the files you found) and stop; do not call the same tool again.
49 + """
50 +
51 + static let userPrompt = "List the files in the workspace using the tool."
52 +
53 + /// Canned tool_result threaded back on step 2.
54 + static let cannedListing = "MEMORY.md\nnotes.txt\nresult.csv"
55 + static let expectedNames = ["MEMORY.md", "notes.txt", "result.csv"]
56 +
57 + /// Whole-model budget (both steps + retries within a step).
58 + static let perModelTimeout: TimeInterval = 90
59 +
60 + // MARK: - Result model
61 +
62 + struct ModelReport {
63 + let model: AIModel
64 + var toolCall = false
65 + var threading = false
66 + var streaming = false
67 + var latency: TimeInterval?
68 + var notes: [String] = []
69 +
70 + var green: Bool { toolCall && threading && streaming }
71 + }
72 +
73 + private struct VerifyTimeout: Error {}
74 +
75 + /// Everything observed while one streamed response was consumed.
76 + private struct StreamOutcome {
77 + var text = ""
78 + var textDeltas = 0
79 + var reasoningDeltas = 0
80 + var toolCallStarts = 0
81 + var argDeltas = 0
82 + var toolCalls: [ToolCall] = []
83 + var rawReason: String?
84 + var stop: StopReason = .other(nil)
85 + }
86 +
87 + // MARK: - Entry
88 +
89 + static func run(arguments: [String]) async -> Int32 {
90 + let onlyProvider = value(after: "--provider", in: arguments).flatMap { ProviderID(rawValue: $0) }
91 + let onlyModel = value(after: "--model", in: arguments)
92 +
93 + var models = ModelCatalogData.all.filter(\.agentCapable)
94 + if let onlyProvider {
95 + models = models.filter { $0.provider == onlyProvider }
96 + }
97 + if let onlyModel {
98 + models = models.filter { $0.id == onlyModel || "\($0.provider.rawValue)/\($0.id)" == onlyModel }
99 + if models.isEmpty {
100 + FileHandle.standardError.write(Data("No agent-capable model matches “\(onlyModel)”.\n".utf8))
101 + return 64
102 + }
103 + }
104 +
105 + // Group by provider, in the canonical provider order.
106 + let providers = ProviderID.builtIn.filter { provider in
107 + models.contains { $0.provider == provider }
108 + }
109 + var keyless: [ProviderID] = []
110 + var reports: [ModelReport] = []
111 +
112 + let total = providers.reduce(0) { count, provider in
113 + AgentCLI.resolveAPIKey(for: provider) == nil
114 + ? count
115 + : count + models.filter { $0.provider == provider }.count
116 + }
117 + print("Zyquo Agent — provider tool-calling verification")
118 + print(" \(total) agent-capable model(s) across \(providers.count) provider(s)\n")
119 +
120 + for provider in providers {
121 + let providerModels = models.filter { $0.provider == provider }
122 + guard let key = AgentCLI.resolveAPIKey(for: provider) else {
123 + keyless.append(provider)
124 + print("⚠️ \(provider.displayName): no API key in environment or vault — \(providerModels.count) model(s) skipped")
125 + continue
126 + }
127 + let client = ProviderRegistry.client(for: provider)
128 + print("── \(provider.displayName) (\(providerModels.count) model(s)) " + String(repeating: "─", count: 30))
129 + for model in providerModels {
130 + var report = await checkWithRetry(model: model, client: client, key: key)
131 + if !report.green {
132 + report.notes.insert("failed twice (attempt 1 + retry)", at: 0)
133 + }
134 + reports.append(report)
135 + printLine(report)
136 + try? await Task.sleep(nanoseconds: interCallDelay(for: provider))
137 + }
138 + }
139 +
140 + writeReport(reports: reports, keyless: keyless)
141 +
142 + let green = reports.filter(\.green).count
143 + print("\n\(reports.count) model(s) tested · \(green) green · \(reports.count - green) failed")
144 + print("Full table: docs/VERIFICATION.md")
145 + return green == reports.count ? 0 : 1
146 + }
147 +
148 + // MARK: - Per-model check
149 +
150 + /// One attempt; on any failure, a single automatic retry (transient
151 + /// hiccups, rate-limit blips). Notes reflect the final attempt.
152 + private static func checkWithRetry(model: AIModel, client: any ProviderClient, key: String) async -> ModelReport {
153 + var start = Date()
154 + var first = await check(model: model, client: client, key: key)
155 + first.latency = Date().timeIntervalSince(start)
156 + if first.green { return first }
157 + try? await Task.sleep(nanoseconds: 2_000_000_000)
158 + start = Date()
159 + var second = await check(model: model, client: client, key: key)
160 + second.latency = Date().timeIntervalSince(start)
161 + if second.green {
162 + second.notes.append("passed on retry")
163 + }
164 + return second
165 + }
166 +
167 + private static func check(model: AIModel, client: any ProviderClient, key: String) async -> ModelReport {
168 + var report = ModelReport(model: model)
169 + let start = Date()
170 +
171 + var parameters = ChatParameters(maxTokens: 300)
172 + if model.parameterSupport.reasoningEffort { parameters.reasoningEffort = "low" }
173 + if model.parameterSupport.thinkingToggle { parameters.thinkingEnabled = false }
174 +
175 + // ---- Step 1: the model must call list_files -------------------------
176 + let step1 = ChatRequest(
177 + model: model,
178 + systemPrompt: systemPrompt,
179 + messages: [Message(role: .user, text: userPrompt)],
180 + parameters: parameters,
181 + tools: [listFilesTool],
182 + toolChoice: .auto
183 + )
184 + let outcome1: StreamOutcome
185 + do {
186 + outcome1 = try await collectPatiently(client, step1, apiKey: key, deadline: perModelTimeout)
187 + } catch {
188 + report.notes.append("step 1: \(describe(error))")
189 + return report
190 + }
191 +
192 + let calls = outcome1.toolCalls
193 + if calls.isEmpty {
194 + report.notes.append("step 1: no tool call emitted (stop=\(outcome1.rawReason ?? "nil"))"
195 + + (outcome1.text.isEmpty ? "" : "; text: “\(truncate(outcome1.text, 80))”"))
196 + if outcome1.stop == .maxTokens {
197 + report.notes.append("token-starved at 300 max_tokens")
198 + }
199 + return report
200 + }
201 + var callProblems: [String] = []
202 + if !calls.contains(where: { $0.name == listFilesTool.name }) {
203 + callProblems.append("wrong tool name “\(calls.map(\.name).joined(separator: ","))”")
204 + }
205 + for call in calls where call.argumentsDictionary == nil {
206 + callProblems.append("arguments not valid JSON object: \(truncate(call.argumentsJSON, 80))")
207 + }
208 + if outcome1.stop != .toolUse {
209 + callProblems.append("stop reason not toolUse (raw=\(outcome1.rawReason ?? "nil"))")
210 + }
211 + report.toolCall = callProblems.isEmpty
212 + report.notes.append(contentsOf: callProblems.map { "step 1: \($0)" })
213 + if calls.count > 1 {
214 + report.notes.append("emitted \(calls.count) parallel calls")
215 + }
216 + guard report.toolCall else { return report }
217 +
218 + // ---- Step 2: thread the canned tool_result back ----------------------
219 + let assistantTurn = Message.assistantToolCalls(calls, text: outcome1.text)
220 + let results = calls.map { ToolResult(toolCallID: $0.id, content: cannedListing) }
221 + let step2 = ChatRequest(
222 + model: model,
223 + systemPrompt: systemPrompt,
224 + messages: [
225 + Message(role: .user, text: userPrompt),
226 + assistantTurn,
227 + Message.toolResultsMessage(results),
228 + ],
229 + parameters: parameters,
230 + tools: [listFilesTool],
231 + toolChoice: .auto
232 + )
233 + let remaining = max(15, perModelTimeout - Date().timeIntervalSince(start))
234 + let outcome2: StreamOutcome
235 + do {
236 + outcome2 = try await collectPatiently(client, step2, apiKey: key, deadline: remaining)
237 + } catch {
238 + report.notes.append("step 2: \(describe(error))")
239 + return report
240 + }
241 +
242 + var threadProblems: [String] = []
243 + let answer = outcome2.text.trimmingCharacters(in: .whitespacesAndNewlines)
244 + if !outcome2.toolCalls.isEmpty {
245 + threadProblems.append("called the tool again after receiving the result")
246 + }
247 + if answer.isEmpty {
248 + threadProblems.append("empty final answer (stop=\(outcome2.rawReason ?? "nil"))")
249 + } else if !expectedNames.contains(where: { answer.localizedCaseInsensitiveContains($0) }) {
250 + threadProblems.append("final answer names none of the files: “\(truncate(answer, 80))”")
251 + }
252 + if outcome2.stop != .endTurn {
253 + threadProblems.append("stop reason not endTurn (raw=\(outcome2.rawReason ?? "nil"))")
254 + }
255 + report.threading = threadProblems.isEmpty
256 + report.notes.append(contentsOf: threadProblems.map { "step 2: \($0)" })
257 +
258 + // ---- Streaming: deltas must have arrived incrementally ---------------
259 + let deltas = outcome1.textDeltas + outcome1.argDeltas + outcome2.textDeltas
260 + report.streaming = deltas > 1
261 + if !report.streaming {
262 + report.notes.append("streaming: only \(deltas) delta event(s) — arrived as a single blob")
263 + }
264 + return report
265 + }
266 +
267 + // MARK: - Stream consumption
268 +
269 + /// `collect` with rate-limit patience: strict per-model RPM tiers and
270 + /// intermittent capacity waves (Moonshot's kimi-k3 429s with
271 + /// `engine_overloaded_error` on ~half of requests) clear after a short
272 + /// wait — retry up to twice with growing pauses. Other errors propagate.
273 + private static func collectPatiently(
274 + _ client: any ProviderClient,
275 + _ request: ChatRequest,
276 + apiKey: String,
277 + deadline: TimeInterval
278 + ) async throws -> StreamOutcome {
279 + var wait: TimeInterval = 20
280 + for _ in 0..<2 {
281 + do {
282 + return try await collect(client, request, apiKey: apiKey, deadline: deadline)
283 + } catch ProviderError.rateLimited(_, let retryAfter) {
284 + try await Task.sleep(nanoseconds: UInt64(max(retryAfter ?? wait, wait) * 1_000_000_000))
285 + wait *= 2
286 + }
287 + }
288 + return try await collect(client, request, apiKey: apiKey, deadline: deadline)
289 + }
290 +
291 + /// Consumes one streamed response fully, racing a deadline. Any thrown
292 + /// provider/network error propagates; a deadline hit throws VerifyTimeout.
293 + private static func collect(
294 + _ client: any ProviderClient,
295 + _ request: ChatRequest,
296 + apiKey: String,
297 + deadline: TimeInterval
298 + ) async throws -> StreamOutcome {
299 + try await withThrowingTaskGroup(of: StreamOutcome?.self) { group in
300 + group.addTask {
301 + var out = StreamOutcome()
302 + for try await event in client.streamChat(request, apiKey: apiKey) {
303 + switch event {
304 + case .textDelta(let delta):
305 + out.text += delta
306 + out.textDeltas += 1
307 + case .reasoningDelta:
308 + out.reasoningDeltas += 1
309 + case .toolCallStarted:
310 + out.toolCallStarts += 1
311 + case .toolCallArgumentsDelta:
312 + out.argDeltas += 1
313 + case .toolCalls(let calls):
314 + out.toolCalls = calls
315 + case .finished(let reason, let stop):
316 + out.rawReason = reason
317 + out.stop = stop
318 + case .citations, .usage:
319 + break
320 + }
321 + }
322 + return out
323 + }
324 + group.addTask {
325 + try await Task.sleep(nanoseconds: UInt64(deadline * 1_000_000_000))
326 + return nil
327 + }
328 + guard let first = try await group.next(), let outcome = first else {
329 + group.cancelAll()
330 + throw VerifyTimeout()
331 + }
332 + group.cancelAll()
333 + return outcome
334 + }
335 + }
336 +
337 + // MARK: - Reporting
338 +
339 + private static func printLine(_ report: ModelReport) {
340 + let marks = [
341 + report.toolCall ? "tool✅" : "tool❌",
342 + report.threading ? "thread✅" : "thread❌",
343 + report.streaming ? "stream✅" : "stream❌",
344 + ].joined(separator: " ")
345 + let latency = report.latency.map { String(format: "%.1fs", $0) } ?? "—"
346 + var line = " \(report.green ? "✅" : "❌") \(report.model.id) \(marks) \(latency)"
347 + if !report.notes.isEmpty {
348 + line += "\n · " + report.notes.joined(separator: "\n · ")
349 + }
350 + print(line)
351 + }
352 +
353 + private static func writeReport(reports: [ModelReport], keyless: [ProviderID]) {
354 + var lines: [String] = []
355 + lines.append("<!--")
356 + lines.append(" VERIFICATION.md")
357 + lines.append(" Zyquo Agent")
358 + lines.append(" Author: Simon-Pierre Boucher")
359 + lines.append(" Mail: contact@spboucher.ai")
360 + lines.append("-->")
361 + lines.append("")
362 + lines.append("# Provider Tool-Calling Verification — \(ISO8601DateFormatter().string(from: Date()))")
363 + lines.append("")
364 + lines.append("Phase 7.1 live sweep (`ZyquoAgent --verify`): every agent-capable model receives")
365 + lines.append("the `list_files` tool schema, must emit a valid tool call (streamed), consume the")
366 + lines.append("threaded `tool_result`, and produce a final answer naming the listed files.")
367 + lines.append("")
368 + let green = reports.filter(\.green).count
369 + lines.append("**\(reports.count) models tested · \(green) green · \(reports.count - green) failed**")
370 + if !keyless.isEmpty {
371 + lines.append("")
372 + lines.append("Providers skipped (no key available): \(keyless.map(\.displayName).joined(separator: ", "))")
373 + }
374 + lines.append("")
375 + lines.append("| Provider | Model | Tool call | Threading | Streaming | Latency | Notes |")
376 + lines.append("|---|---|---|---|---|---|---|")
377 + for report in reports {
378 + let latency = report.latency.map { String(format: "%.1fs", $0) } ?? "—"
379 + let notes = report.notes.joined(separator: "; ")
380 + .replacingOccurrences(of: "|", with: "\\|")
381 + .replacingOccurrences(of: "\n", with: " ")
382 + lines.append("| \(report.model.provider.displayName) | `\(report.model.id)` "
383 + + "| \(report.toolCall ? "✅" : "❌") | \(report.threading ? "✅" : "❌") "
384 + + "| \(report.streaming ? "✅" : "❌") | \(latency) | \(notes) |")
385 + }
386 + lines.append("")
387 + let url = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
388 + .appendingPathComponent("docs/VERIFICATION.md")
389 + try? lines.joined(separator: "\n").data(using: .utf8)?.write(to: url)
390 + }
391 +
392 + // MARK: - Helpers
393 +
394 + /// Gentle pacing between models of one provider (Cerebras free-tier rate
395 + /// limits are the strictest; everyone else gets a polite second).
396 + private static func interCallDelay(for provider: ProviderID) -> UInt64 {
397 + switch provider {
398 + case .cerebras: return 10_000_000_000
399 + case .mistral: return 2_000_000_000
400 + default: return 1_200_000_000
401 + }
402 + }
403 +
404 + private static func describe(_ error: Error) -> String {
405 + if error is VerifyTimeout { return "timed out after \(Int(perModelTimeout))s" }
406 + if let provider = error as? ProviderError {
407 + var text = provider.errorDescription ?? "\(provider)"
408 + if case .badRequest(_, let message) = provider {
409 + let lowered = (message ?? "").lowercased()
410 + if lowered.contains("model") &&
411 + (lowered.contains("not") || lowered.contains("exist") || lowered.contains("found")
412 + || lowered.contains("invalid") || lowered.contains("decommission")
413 + || lowered.contains("deprecat") || lowered.contains("access")) {
414 + text = "model id rejected by API — " + text
415 + }
416 + }
417 + return truncate(text, 220)
418 + }
419 + return truncate(error.localizedDescription, 220)
420 + }
421 +
422 + private static func truncate(_ text: String, _ limit: Int) -> String {
423 + let flat = text.replacingOccurrences(of: "\n", with: " ")
424 + return flat.count > limit ? String(flat.prefix(limit)) + "…" : flat
425 + }
426 +
427 + private static func value(after flag: String, in arguments: [String]) -> String? {
428 + guard let index = arguments.firstIndex(of: flag), index + 1 < arguments.count else { return nil }
429 + return arguments[index + 1]
430 + }
431 +}
added docs/VERIFICATION.md +125 −0
@@ -0,0 +1,125 @@
1 +<!--
2 + VERIFICATION.md
3 + Zyquo Agent
4 +
5 + Author: Simon-Pierre Boucher
6 + Mail: contact@spboucher.ai
7 +-->
8 +
9 +# Provider Tool-Calling Verification — 2026-07-30
10 +
11 +Phase 7.1 live sweep (`ZyquoAgent --verify`): every agent-capable model receives
12 +the `list_files` tool schema, must emit a valid tool call (streamed), consume the
13 +threaded `tool_result`, and produce a final answer naming the listed files.
14 +
15 +**80 models tested · 77 green · 3 failed** (all 3 failures are provider-side —
16 +see the classification at the bottom)
17 +
18 +This table merges the full pass-1 sweep with the per-provider re-runs performed
19 +after the two client fixes below (each fixed model was re-verified green live):
20 +
21 +- **OpenAI GPT-5.4/5.5/5.6:** `/chat/completions` rejects function tools combined
22 + with any `reasoning_effort` other than `"none"` — the client now downgrades
23 + `reasoning_effort` to `"none"` whenever tools are attached for these models
24 + (`OpenAICompatibleClient.buildBody`).
25 +- **Gemini 3.x (compat endpoint):** tool calls carry an
26 + `extra_content.google.thought_signature` that MUST be echoed back verbatim when
27 + the call is threaded into history, or Gemini rejects the request
28 + (`INVALID_ARGUMENT: Function call is missing a thought_signature`). `ToolCall`
29 + now carries the opaque signature; the client captures it from streaming
30 + fragments / non-streaming entries and re-attaches it on assistant `tool_calls`.
31 +
32 +| Provider | Model | Tool call | Threading | Streaming | Latency | Notes |
33 +|---|---|---|---|---|---|---|
34 +| OpenAI | `gpt-5.6-sol` | ✅ | ✅ | ✅ | 3.0s | pass 1 ❌ (tools+reasoning_effort rejected); green after client fix |
35 +| OpenAI | `gpt-5.6-terra` | ✅ | ✅ | ✅ | 1.4s | pass 1 ❌ (tools+reasoning_effort rejected); green after client fix |
36 +| OpenAI | `gpt-5.6-luna` | ✅ | ✅ | ✅ | 1.6s | pass 1 ❌ (tools+reasoning_effort rejected); green after client fix |
37 +| OpenAI | `gpt-5.5` | ✅ | ✅ | ✅ | 4.1s | pass 1 ❌ (tools+reasoning_effort rejected); green after client fix |
38 +| OpenAI | `gpt-5.4` | ✅ | ✅ | ✅ | 1.8s | pass 1 ❌ (tools+reasoning_effort rejected); green after client fix |
39 +| OpenAI | `gpt-5.4-mini` | ✅ | ✅ | ✅ | 0.9s | pass 1 ❌ (tools+reasoning_effort rejected); green after client fix |
40 +| OpenAI | `gpt-5.2` | ✅ | ✅ | ✅ | 3.3s | |
41 +| OpenAI | `gpt-5.1` | ✅ | ✅ | ✅ | 1.6s | |
42 +| OpenAI | `gpt-5` | ✅ | ✅ | ✅ | 2.4s | |
43 +| OpenAI | `gpt-5-mini` | ✅ | ✅ | ✅ | 2.1s | |
44 +| OpenAI | `o3` | ✅ | ✅ | ✅ | 1.3s | |
45 +| OpenAI | `o4-mini` | ✅ | ✅ | ✅ | 1.2s | |
46 +| Anthropic | `claude-opus-5` | ✅ | ✅ | ✅ | 3.9s | |
47 +| Anthropic | `claude-sonnet-5` | ✅ | ✅ | ✅ | 4.0s | |
48 +| Anthropic | `claude-fable-5` | ✅ | ✅ | ✅ | 7.5s | |
49 +| Anthropic | `claude-opus-4-8` | ✅ | ✅ | ✅ | 3.2s | |
50 +| Anthropic | `claude-opus-4-7` | ✅ | ✅ | ✅ | 3.2s | |
51 +| Anthropic | `claude-opus-4-6` | ✅ | ✅ | ✅ | 6.1s | |
52 +| Anthropic | `claude-sonnet-4-6` | ✅ | ✅ | ✅ | 4.1s | |
53 +| Anthropic | `claude-haiku-4-5-20251001` | ✅ | ✅ | ✅ | 1.6s | |
54 +| xAI | `grok-4.5` | ✅ | ✅ | ✅ | 2.5s | |
55 +| xAI | `grok-4.3` | ✅ | ✅ | ✅ | 4.5s | |
56 +| xAI | `grok-4.20` | ✅ | ✅ | ✅ | 4.9s | |
57 +| xAI | `grok-4.20-non-reasoning` | ✅ | ✅ | ✅ | 1.2s | |
58 +| xAI | `grok-code-fast-1` | ✅ | ✅ | ✅ | 5.3s | |
59 +| Mistral | `mistral-medium-latest` | ✅ | ✅ | ✅ | 1.4s | |
60 +| Mistral | `mistral-large-latest` | ✅ | ✅ | ✅ | 3.7s | |
61 +| Mistral | `mistral-small-latest` | ✅ | ✅ | ✅ | 0.9s | |
62 +| Google Gemini | `gemini-3.6-flash` | ✅ | ✅ | ✅ | 2.1s | pass 1 ❌ (thought_signature missing on threading); green after client fix |
63 +| Google Gemini | `gemini-3.5-flash` | ✅ | ✅ | ✅ | 2.0s | pass 1 ❌ (thought_signature); green after client fix |
64 +| Google Gemini | `gemini-3.5-flash-lite` | ✅ | ✅ | ✅ | 1.0s | pass 1 ❌ (thought_signature); green after client fix |
65 +| Google Gemini | `gemini-3.1-pro-preview` | ✅ | ✅ | ✅ | 3.9s | pass 1 ❌ (thought_signature); green after client fix |
66 +| Google Gemini | `gemini-2.5-pro` | ✅ | ✅ | ✅ | 3.3s | |
67 +| Google Gemini | `gemini-2.5-flash` | ✅ | ✅ | ✅ | 1.3s | |
68 +| Google Gemini | `gemini-pro-latest` | ✅ | ✅ | ✅ | 4.2s | alias of Gemini 3.x; pass 1 ❌ (thought_signature); green after client fix |
69 +| Google Gemini | `gemini-flash-latest` | ✅ | ✅ | ✅ | 1.5s | alias of Gemini 3.x; pass 1 ❌ (thought_signature); green after client fix |
70 +| Alibaba Qwen | `qwen3.7-max` | ✅ | ✅ | ✅ | 3.0s | |
71 +| Alibaba Qwen | `qwen3.7-plus` | ✅ | ✅ | ✅ | 2.6s | |
72 +| Alibaba Qwen | `qwen3.7-flash` | ✅ | ✅ | ✅ | 1.6s | |
73 +| Alibaba Qwen | `qwen3.6-plus` | ✅ | ✅ | ✅ | 2.2s | |
74 +| Alibaba Qwen | `qwen3.5-plus` | ✅ | ✅ | ✅ | 2.0s | |
75 +| Alibaba Qwen | `qwen3-coder-plus` | ✅ | ✅ | ✅ | 2.1s | |
76 +| Alibaba Qwen | `qwen3-coder-flash` | ✅ | ✅ | ✅ | 2.0s | |
77 +| Alibaba Qwen | `qwen3-coder-next` | ✅ | ✅ | ✅ | 2.3s | |
78 +| Alibaba Qwen | `qwen3-coder-480b-a35b-instruct` | ✅ | ✅ | ✅ | 2.6s | |
79 +| Alibaba Qwen | `qwen3.5-397b-a17b` | ✅ | ✅ | ✅ | 1.9s | |
80 +| Alibaba Qwen | `deepseek-v4-pro` | ✅ | ✅ | ✅ | 4.6s | |
81 +| Alibaba Qwen | `glm-5.2` | ✅ | ✅ | ✅ | 2.1s | |
82 +| DeepSeek | `deepseek-v4-flash` | ✅ | ✅ | ✅ | 2.5s | |
83 +| DeepSeek | `deepseek-v4-pro` | ✅ | ✅ | ✅ | 3.2s | |
84 +| Kimi | `kimi-k3` | ❌ | ❌ | ❌ | 100.5s | INFRA: HTTP 429 `engine_overloaded_error` on most requests during the sweep window (reproduced with raw curl in every request shape; intermittent 200s prove the key/tier is fine). One harness attempt DID emit a correct `list_files` call, so the wire format is right — Moonshot capacity issue; re-verify when the engine recovers |
85 +| Kimi | `kimi-k2.7-code` | ✅ | ✅ | ✅ | 2.3s | |
86 +| Kimi | `kimi-k2.7-code-highspeed` | ✅ | ✅ | ✅ | 1.8s | |
87 +| Kimi | `kimi-k2.6` | ✅ | ✅ | ✅ | 5.1s | |
88 +| Kimi | `kimi-k2.5` | ✅ | ✅ | ✅ | 4.8s | |
89 +| Together AI | `moonshotai/Kimi-K3` | ✅ | ✅ | ✅ | 19.0s | slow first token (reasoning) |
90 +| Together AI | `moonshotai/Kimi-K2.7-Code` | ✅ | ✅ | ✅ | 1.6s | |
91 +| Together AI | `deepseek-ai/DeepSeek-V4-Pro` | ✅ | ✅ | ✅ | 1.6s | |
92 +| Together AI | `zai-org/GLM-5.2` | ✅ | ✅ | ✅ | 1.2s | |
93 +| Together AI | `Qwen/Qwen3.7-Max` | ✅ | ✅ | ✅ | 4.6s | |
94 +| Together AI | `openai/gpt-oss-120b` | ✅ | ✅ | ✅ | 2.2s | |
95 +| Together AI | `nvidia/nemotron-3-ultra-550b-a55b` | ✅ | ✅ | ✅ | 1.2s | |
96 +| Together AI | `MiniMaxAI/MiniMax-M3` | ✅ | ✅ | ✅ | 6.1s | |
97 +| DeepInfra | `anthropic/claude-fable-5` | ✅ | ✅ | ✅ | 6.3s | |
98 +| DeepInfra | `anthropic/claude-opus-5` | ✅ | ✅ | ✅ | 5.6s | |
99 +| DeepInfra | `anthropic/claude-sonnet-5` | ✅ | ✅ | ✅ | 7.1s | |
100 +| DeepInfra | `anthropic/claude-opus-4-8` | ✅ | ✅ | ✅ | 4.1s | |
101 +| DeepInfra | `anthropic/claude-haiku-4-5` | ✅ | ✅ | ✅ | 2.3s | |
102 +| DeepInfra | `google/gemini-3.1-pro` | ✅ | ❌ | ❌ | 4.0s | SERVER-SIDE: step 1 works, but DeepInfra never returns Gemini's `thought_signature` and strips `extra_content` from requests (verified with raw probes, incl. Google's documented bypass placeholder) → Google rejects every threaded tool result with INVALID_ARGUMENT. Multi-turn tool use is impossible through DeepInfra — REMOVAL CANDIDATE (the same model is green via the native Gemini provider) |
103 +| DeepInfra | `google/gemini-3.5-flash` | ✅ | ❌ | ❌ | 2.3s | SERVER-SIDE: same as `google/gemini-3.1-pro` — thought_signature dropped by DeepInfra; REMOVAL CANDIDATE |
104 +| DeepInfra | `deepseek-ai/DeepSeek-V4-Pro` | ✅ | ✅ | ✅ | 4.1s | |
105 +| DeepInfra | `deepseek-ai/DeepSeek-V4-Flash` | ✅ | ✅ | ✅ | 3.3s | |
106 +| DeepInfra | `moonshotai/Kimi-K2.7-Code` | ✅ | ✅ | ✅ | 2.2s | |
107 +| DeepInfra | `zai-org/GLM-5.2` | ✅ | ✅ | ✅ | 3.1s | |
108 +| DeepInfra | `Qwen/Qwen3.7-Max` | ✅ | ✅ | ✅ | 4.7s | |
109 +| DeepInfra | `Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo` | ✅ | ✅ | ✅ | 0.8s | |
110 +| DeepInfra | `openai/gpt-oss-120b` | ✅ | ✅ | ✅ | 2.6s | |
111 +| DeepInfra | `MiniMaxAI/MiniMax-M3` | ✅ | ✅ | ✅ | 3.0s | |
112 +| Cerebras | `gpt-oss-120b` | ✅ | ✅ | ✅ | 1.4s | |
113 +| Cerebras | `gemma-4-31b` | ✅ | ✅ | ✅ | 0.6s | |
114 +
115 +## Failure classification
116 +
117 +| Model | Class | Evidence / disposition |
118 +|---|---|---|
119 +| `deepinfra` `google/gemini-3.1-pro` | Broken server-side | DeepInfra never emits `thought_signature` and drops `extra_content` on requests; Gemini 3 requires it for threaded tool calls. No client-side workaround exists (Google's documented placeholder is also stripped). **Recommend removing from the agent-capable set** — the identical models are green via the native Gemini provider. |
120 +| `deepinfra` `google/gemini-3.5-flash` | Broken server-side | Same as above. **Recommend removing from the agent-capable set.** |
121 +| `kimi` `kimi-k3` | Infra (provider capacity) | HTTP 429 `engine_overloaded_error` on most requests during the sweep window, reproduced with raw curl in every request shape; intermittent 200s prove the key/tier is fine and one harness attempt did emit a correct `list_files` call. Not a wire-format or capability failure — keep in the set, re-verify when Moonshot capacity recovers. |
122 +
123 +No model id in the agent-capable set was rejected as nonexistent/deprecated, and
124 +no model failed tool calling because of model capability — every ❌ above is
125 +provider-side.
126