`AgentRuntime` was one actor doing four jobs: holding the run's state, driving the loop, executing tools, and publishing events. Adding an audit, a rewrite, or a new tool source meant editing it. The loop now keeps only the state and the control flow, and each phase is a value it drives — `AgentContextPipeline` at the model boundary, `AgentTurnDriver` for one assistant turn, `AgentToolScheduler` for the batch, `AgentToolExecutor` for a single call, `AgentToolRegistry` for where tools come from. Each is testable without a run around it. Four seams are protocols with real conformers: `AgentToolProviding`, `AgentLoopHook`, `AgentContextTransforming`, `AgentModelStreaming`. Authorization is deliberately not among them — `AgentApprovalHandling` stays a structural stage of the executor, because a permission gate you can remove by leaving an element out of an array is not a gate. A turn's calls run concurrently only when every one of them is marked parallel; a single sequential call takes the whole turn serial rather than being fenced into its own segment. Segmenting would buy back only the cross-host case and cost semantics nobody can hold in their head while reading an incident transcript. Concurrency is declared per tool and may be overridden per call by preflight, which is how `ssh_exec` decides: parallel exactly when `CommandRiskClassifier` proves the command safe, the same locally-proven evidence that already decides its safety. MCP tools are parallel by default, and a server's destructive hint or Ask First makes them sequential — the hints can only tighten concurrency, never grant it, so remote annotations remain risk hints rather than proof of safety. The ceiling is worth stating plainly: `SSHExecSession` and `SFTPClient` are actors, so calls aimed at one host still queue inside their session. The win is several hosts at once, and host work overlapping `fetch`, `web_search`, and MCP. Whatever order they finish in, results reach the transcript in the order the model asked. `toolFinished` still follows completion, because that is when a card should stop spinning, so the two orders now disagree by design — and `AgentToolResultOrdering` repairs it at the model boundary, where both the run's snapshot and the conversation's own copy pass through one implementation. Three defects that serial execution was hiding, in rising order of harm. A denied call was relabelled `Failed` the instant it was refused, because the denial is an error to the model and the card followed the result; `Denied` therefore never reached the screen. A run that gave up halfway — cancelled, out of tool budget, or handed a response truncated mid-arguments — left an assistant turn whose tool calls nothing answered, and every provider rejects that outright on the *next* request, so an interrupted run became a permanently unusable conversation. Every call now gets a card and a result on every path, refusals included. And `pendingApproval` was a single slot: two calls needing approval in one turn overwrote each other, the first continuation was never resumed, and the run stopped with no error and nothing on screen to answer. Approvals and agent questions are queues now, presented one at a time with a `1 of N` count, and run state is derived from what is outstanding rather than asserted by whichever call moved last. Events travel one ordered path. The runtime and the approval broker used to publish independently, each wrapping its durable write in its own unstructured `Task`, so a review's events had no defined order against the tool events they belong between. `AgentEventChannel` fans out to the UI and to a single serial recorder. `messageStarted` now carries its message rather than just an id, so the visible turn is stamped with the run's clock instead of whenever the UI got around to the event — a card proposed later could otherwise carry an earlier timestamp than the turn that asked for it, and sort above it. `AgentContextPipeline` is also where trimming finally has somewhere to live. `ssh_exec` returns up to 256 KB and `sftp_read` up to 128 KB, and each is replayed on every later turn; two log reads were enough to crowd out the conversation they were meant to explain. At most one turn's tool output is now replayed in full, and older results keep their head and their tail, because a truncated command output missing its ending has lost the error message. It shapes only the request: snapshots and stored history keep everything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
520 lines
21 KiB
Swift
520 lines
21 KiB
Swift
import Foundation
|
|
import Testing
|
|
|
|
@testable import Termind
|
|
|
|
@Suite
|
|
struct WebSearchTests {
|
|
|
|
// MARK: - Configuration
|
|
|
|
@Test
|
|
func keylessBackendIsConfiguredWithoutAKey() {
|
|
let bing = WebSearchClient(backend: .bing)
|
|
#expect(bing.isConfigured)
|
|
#expect(!SearchBackend.bing.requiresAPIKey)
|
|
|
|
let brave = WebSearchClient(backend: .brave)
|
|
#expect(!brave.isConfigured)
|
|
#expect(WebSearchClient(backend: .brave, apiKey: "token").isConfigured)
|
|
}
|
|
|
|
@Test
|
|
func blankBaseURLResolvesToTheBackendDefault() {
|
|
#expect(WebSearchClient(backend: .bing).resolvedURL == "https://www.bing.com/search")
|
|
#expect(
|
|
WebSearchClient(backend: .brave, apiKey: "k").resolvedURL
|
|
== "https://api.search.brave.com/res/v1/web/search"
|
|
)
|
|
// A pasted URL very often carries a trailing slash, and every path
|
|
// starts with one.
|
|
#expect(
|
|
WebSearchClient(backend: .tavily, baseURL: "https://proxy.example/", apiKey: "k")
|
|
.resolvedURL == "https://proxy.example/search"
|
|
)
|
|
}
|
|
|
|
// MARK: - Requests
|
|
|
|
@Test
|
|
func bingRequestLooksLikeSafariOnAMac() throws {
|
|
let request = try WebSearchClient(backend: .bing, market: "en-US")
|
|
.buildRequest(query: "swift concurrency", count: 5)
|
|
|
|
let url = try #require(request.url?.absoluteString)
|
|
#expect(url.hasPrefix("https://www.bing.com/search?"))
|
|
#expect(url.contains("q=swift%20concurrency") || url.contains("q=swift+concurrency"))
|
|
#expect(request.httpMethod == "GET")
|
|
|
|
let agent = try #require(request.value(forHTTPHeaderField: "User-Agent"))
|
|
#expect(agent.contains("Macintosh"))
|
|
#expect(agent.contains("Safari"))
|
|
#expect(!agent.contains("Windows"))
|
|
#expect(request.value(forHTTPHeaderField: "Accept-Language") != nil)
|
|
}
|
|
|
|
/// Verified against the live endpoint: with no `mkt`, Bing answers an
|
|
/// anonymous request with a well-formed page of results for an unrelated
|
|
/// query. The parse succeeds and the model is handed confident nonsense,
|
|
/// so this parameter is load-bearing rather than a locale nicety.
|
|
@Test
|
|
func bingAlwaysCarriesAMarket() throws {
|
|
let url = try #require(
|
|
WebSearchClient(backend: .bing, market: "ja-JP")
|
|
.buildRequest(query: "swift", count: 5).url?.absoluteString
|
|
)
|
|
#expect(url.contains("mkt=ja-JP"))
|
|
|
|
// Derived per component, so a Mac with a language but no region still
|
|
// searches in its own language rather than falling all the way to en-US.
|
|
let market = WebSearchClient.preferredMarket
|
|
#expect(market.contains("-"))
|
|
#expect(market.split(separator: "-").count == 2)
|
|
}
|
|
|
|
@Test
|
|
func keyedBackendsCarryTheirOwnAuthenticationHeader() throws {
|
|
let brave = try WebSearchClient(backend: .brave, apiKey: "brave-token")
|
|
.buildRequest(query: "q", count: 3)
|
|
#expect(brave.value(forHTTPHeaderField: "X-Subscription-Token") == "brave-token")
|
|
|
|
let tavily = try WebSearchClient(backend: .tavily, apiKey: "tavily-token")
|
|
.buildRequest(query: "q", count: 3)
|
|
#expect(tavily.value(forHTTPHeaderField: "Authorization") == "Bearer tavily-token")
|
|
#expect(tavily.httpMethod == "POST")
|
|
|
|
let exa = try WebSearchClient(backend: .exa, apiKey: "exa-token")
|
|
.buildRequest(query: "q", count: 3)
|
|
#expect(exa.value(forHTTPHeaderField: "x-api-key") == "exa-token")
|
|
}
|
|
|
|
@Test
|
|
func exaAsksForCappedSnippetsRatherThanWholePages() throws {
|
|
let request = try WebSearchClient(backend: .exa, apiKey: "k")
|
|
.buildRequest(query: "q", count: 3)
|
|
let payload = try #require(request.httpBody)
|
|
let body = try #require(
|
|
JSONSerialization.jsonObject(with: payload) as? [String: Any]
|
|
)
|
|
let contents = try #require(body["contents"] as? [String: Any])
|
|
let text = try #require(contents["text"] as? [String: Any])
|
|
// Reading a page in full stays `fetch`'s job, because that is the path
|
|
// with address classification on the target.
|
|
#expect(text["maxCharacters"] as? Int == 600)
|
|
}
|
|
|
|
// MARK: - Bing result page
|
|
|
|
/// Trimmed from a real results page: the `b_algo` list item, the `h2 > a`
|
|
/// title link, and the `b_algoSlug` snippet Kelivo's selectors read.
|
|
private static let bingPage = """
|
|
<html><body><ol id="b_results">
|
|
<li class="b_algo b_algoBorder" data-bm="5">
|
|
<h2><a href="https://swift.org/documentation/" h="ID=SERP,5">Swift Documentation</a></h2>
|
|
<div class="b_caption">
|
|
<p class="b_algoSlug">The <strong>Swift</strong> language guide & reference.</p>
|
|
</div>
|
|
</li>
|
|
<li class="b_algo">
|
|
<h2><a href="https://www.bing.com/ck/a?!&&p=1&u=a1aHR0cHM6Ly9leGFtcGxlLmNvbS9wYWdl">Example Page</a></h2>
|
|
<div class="b_caption"><p>A second result.</p></div>
|
|
</li>
|
|
<li class="b_algo">
|
|
<h2><a href="https://third.example/">Third</a></h2>
|
|
</li>
|
|
</ol></body></html>
|
|
"""
|
|
|
|
@Test
|
|
func bingPageYieldsTitleURLAndSnippet() {
|
|
let results = WebSearchClient.bingResults(Self.bingPage, limit: 10)
|
|
|
|
#expect(results.count == 3)
|
|
#expect(results[0].title == "Swift Documentation")
|
|
#expect(results[0].url == "https://swift.org/documentation/")
|
|
// Tags stripped and entities decoded: the model reads this as prose,
|
|
// and markup here costs tokens and invites confusion with instructions.
|
|
#expect(results[0].snippet == "The Swift language guide & reference.")
|
|
// A result with no snippet is still a result.
|
|
#expect(results[2].title == "Third")
|
|
#expect(results[2].snippet.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func bingRedirectWrapperIsUnwrappedToTheRealURL() {
|
|
let results = WebSearchClient.bingResults(Self.bingPage, limit: 10)
|
|
// Left wrapped, the model would be handed a bing.com/ck/a URL that says
|
|
// nothing about the source it is about to trust.
|
|
#expect(results[1].url == "https://example.com/page")
|
|
#expect(results[1].title == "Example Page")
|
|
|
|
// Anything that is not the `u=a1<base64url>` form is left alone.
|
|
let plain = "https://example.com/ck/a?u=not-base64"
|
|
#expect(WebSearchClient.unwrapBingRedirect(plain) == plain)
|
|
}
|
|
|
|
@Test
|
|
func bingResultsRespectTheRequestedCount() {
|
|
#expect(WebSearchClient.bingResults(Self.bingPage, limit: 2).count == 2)
|
|
}
|
|
|
|
/// Bing writes its date separator as `· `, so a fixed list of
|
|
/// named entities leaves raw numeric references in the text the model reads.
|
|
@Test
|
|
func numericEntitiesAreDecoded() {
|
|
#expect(
|
|
WebSearchClient.plainText("Jul 10, 2026· At Swift, we're open")
|
|
== "Jul 10, 2026·\u{20}At Swift, we're open"
|
|
)
|
|
#expect(WebSearchClient.plainText("café & bar") == "café & bar")
|
|
// A stray `&#` is left alone rather than swallowing the rest of the
|
|
// text up to whatever semicolon comes next.
|
|
#expect(WebSearchClient.plainText("A &# B; C") == "A &# B; C")
|
|
}
|
|
|
|
@Test
|
|
func markupThatMovedYieldsNoResultsRatherThanFailing() {
|
|
// The honest failure mode of scraping, and the reason a keyed backend
|
|
// exists: an empty list reads to the model as "nothing found".
|
|
#expect(WebSearchClient.bingResults("<html><body>no results here</body></html>", limit: 5).isEmpty)
|
|
}
|
|
|
|
// MARK: - DuckDuckGo result page
|
|
|
|
private static let duckDuckGoPage = """
|
|
<html><body>
|
|
<div class="result results_links results_links_deep web-result ">
|
|
<div class="result__body">
|
|
<h2 class="result__title">
|
|
<a class="result__a" href="https://nvd.nist.gov/vuln/detail/CVE-2024-3094">NVD - CVE-2024-3094</a>
|
|
</h2>
|
|
<a class="result__snippet" href="#">Malicious code was discovered in <b>xz</b> tarballs.</a>
|
|
</div>
|
|
</div>
|
|
<div class="result results_links result--ad">
|
|
<div class="result__body">
|
|
<h2><a class="result__a" href="https://sponsor.example/buy">Sponsored Thing</a></h2>
|
|
<a class="result__snippet" href="#">An advert.</a>
|
|
</div>
|
|
</div>
|
|
<div class="result results_links results_links_deep web-result ">
|
|
<div class="result__body">
|
|
<h2><a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FXZ&rut=abc">XZ Utils backdoor</a></h2>
|
|
</div>
|
|
</div>
|
|
</body></html>
|
|
"""
|
|
|
|
@Test
|
|
func duckDuckGoPageYieldsResultsAndSkipsAds() {
|
|
let results = WebSearchClient.duckDuckGoResults(Self.duckDuckGoPage, limit: 10)
|
|
|
|
#expect(results.count == 2)
|
|
#expect(results[0].url == "https://nvd.nist.gov/vuln/detail/CVE-2024-3094")
|
|
#expect(results[0].title == "NVD - CVE-2024-3094")
|
|
#expect(results[0].snippet == "Malicious code was discovered in xz tarballs.")
|
|
// An advert wears the same result markup, and handing one to the model
|
|
// as a source is worse than returning one result fewer.
|
|
#expect(!results.contains { $0.url.contains("sponsor.example") })
|
|
}
|
|
|
|
@Test
|
|
func duckDuckGoRedirectWrapperIsUnwrapped() {
|
|
let results = WebSearchClient.duckDuckGoResults(Self.duckDuckGoPage, limit: 10)
|
|
#expect(results[1].url == "https://en.wikipedia.org/wiki/XZ")
|
|
|
|
// Both shapes appear on the same endpoint, so a direct link must survive
|
|
// the unwrapper untouched.
|
|
let direct = "https://example.com/page"
|
|
#expect(WebSearchClient.unwrapDuckDuckGoRedirect(direct) == direct)
|
|
}
|
|
|
|
@Test
|
|
func duckDuckGoIsKeylessAndPostsItsQuery() throws {
|
|
#expect(!SearchBackend.duckDuckGo.requiresAPIKey)
|
|
let request = try WebSearchClient(backend: .duckDuckGo, market: "en-US")
|
|
.buildRequest(query: "swift 6", count: 5)
|
|
|
|
#expect(request.httpMethod == "POST")
|
|
#expect(request.url?.absoluteString == "https://html.duckduckgo.com/html/")
|
|
let body = String(decoding: try #require(request.httpBody), as: UTF8.self)
|
|
#expect(body.contains("q=swift"))
|
|
// DuckDuckGo's region is Bing's market with its halves swapped.
|
|
#expect(body.contains("kl=us-en"))
|
|
}
|
|
|
|
// MARK: - JSON backends
|
|
|
|
@Test
|
|
func braveResponseDecodesIntoResults() throws {
|
|
let payload = Data("""
|
|
{"web": {"results": [
|
|
{"title": "Brave <strong>Hit</strong>", "url": "https://a.example",
|
|
"description": "First & best", "page_age": "2026-01-02T03:04:05Z"},
|
|
{"title": "No URL"}
|
|
]}}
|
|
""".utf8)
|
|
|
|
let results = try WebSearchClient.decode(payload, backend: .brave, limit: 10)
|
|
|
|
// The row with no URL is dropped rather than thrown on: one malformed
|
|
// result should not lose the others.
|
|
#expect(results.count == 1)
|
|
#expect(results[0].title == "Brave Hit")
|
|
#expect(results[0].snippet == "First & best")
|
|
#expect(results[0].publishedAt != nil)
|
|
}
|
|
|
|
@Test
|
|
func tavilyAndExaReadTheirOwnSnippetFields() throws {
|
|
let tavily = try WebSearchClient.decode(
|
|
Data("""
|
|
{"results": [{"title": "T", "url": "https://t.example",
|
|
"content": "tavily snippet", "published_date": "2026-02-03"}]}
|
|
""".utf8),
|
|
backend: .tavily, limit: 10
|
|
)
|
|
#expect(tavily[0].snippet == "tavily snippet")
|
|
#expect(tavily[0].publishedAt != nil)
|
|
|
|
let exa = try WebSearchClient.decode(
|
|
Data("""
|
|
{"results": [{"title": "E", "url": "https://e.example", "text": "exa snippet"}]}
|
|
""".utf8),
|
|
backend: .exa, limit: 10
|
|
)
|
|
#expect(exa[0].snippet == "exa snippet")
|
|
}
|
|
|
|
@Test
|
|
func nonJSONFromAKeyedBackendIsAnError() {
|
|
#expect(throws: WebSearchError.self) {
|
|
try WebSearchClient.decode(Data("<html>nope</html>".utf8), backend: .brave, limit: 5)
|
|
}
|
|
}
|
|
|
|
// MARK: - The tool
|
|
|
|
@Test
|
|
func webSearchToolIsReadOnlyForAPublicBackend() async throws {
|
|
let client = WebSearchClient(backend: .bing) { _ in .publicInternet }
|
|
let tool = AgentBuiltInTools.webSearch(client)
|
|
|
|
#expect(tool.descriptor.name == "web_search")
|
|
#expect(tool.descriptor.target == .network)
|
|
#expect(tool.descriptor.safety == .locallyReadOnly)
|
|
|
|
let preflight = try await tool.preflight(invocation(query: "swift"))
|
|
#expect(preflight.safety == .locallyReadOnly)
|
|
#expect(preflight.reasons.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func aBackendOnAPrivateNetworkAsksFirst() async throws {
|
|
// Matching `fetch`: a search backend configured at a LAN address is
|
|
// disclosed rather than silently reached, even though a query changes
|
|
// nothing.
|
|
let client = WebSearchClient(
|
|
backend: .tavily, baseURL: "https://searx.lan", apiKey: "k"
|
|
) { _ in .restricted }
|
|
let preflight = try await AgentBuiltInTools.webSearch(client)
|
|
.preflight(invocation(query: "swift"))
|
|
|
|
#expect(preflight.safety == .requiresAuthorization)
|
|
#expect(preflight.reasons.count == 1)
|
|
#expect(preflight.reasons[0].contains("https://searx.lan/search"))
|
|
}
|
|
|
|
@Test
|
|
func aQueryIsRequired() async {
|
|
let client = WebSearchClient(backend: .bing) { _ in .publicInternet }
|
|
await #expect(throws: AgentToolError.self) {
|
|
try await AgentBuiltInTools.webSearch(client).preflight(
|
|
AgentToolInvocation(
|
|
runID: UUID(),
|
|
call: AgentToolCall(id: "1", name: "web_search", arguments: .object([:]))
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
@Test @MainActor
|
|
func theToolIsAbsentUntilABackendIsConfigured() {
|
|
let hosts = AgentHostResolver(store: HostStore())
|
|
let pool = AgentConnectionPool(
|
|
configuration: { _ in throw AgentToolError.invalidHost },
|
|
routeFingerprint: { _ in "" },
|
|
openExec: { _ in throw AgentToolError.invalidHost },
|
|
openSFTP: { _ in throw AgentToolError.invalidHost },
|
|
sweepInterval: nil
|
|
)
|
|
|
|
let without = AgentBuiltInTools.make(hosts: hosts, connections: pool)
|
|
#expect(!without.contains { $0.descriptor.name == "web_search" })
|
|
|
|
// A model shown a tool it cannot use spends a turn discovering that.
|
|
let with = AgentBuiltInTools.make(
|
|
hosts: hosts, connections: pool, search: WebSearchClient(backend: .bing)
|
|
)
|
|
#expect(with.contains { $0.descriptor.name == "web_search" })
|
|
}
|
|
|
|
// MARK: - The built-in switch
|
|
|
|
/// The escape hatch, and the one thing it must not do is leave a model with
|
|
/// no search at all: turning built-in off has to hand the work to the
|
|
/// backend, not drop it.
|
|
@Test
|
|
func turningOffBuiltInSendsEverythingThroughTheBackend() {
|
|
var model = AIModel(id: "gpt-5.4")
|
|
model.abilities = [.toolCall, .webSearch]
|
|
var provider = ModelProvider(name: "OpenAI")
|
|
provider.usesResponsesAPI = true
|
|
|
|
// With it on, this pairing is the one case that goes native.
|
|
#expect(provider.supportsNativeWebSearch(model: model))
|
|
|
|
// The switch is read in `AppModel.webSearchPlan` ahead of this call, so
|
|
// what it guards is exactly this predicate — and the local client is
|
|
// keyless by default, which is what makes the fallback always there.
|
|
#expect(WebSearchClient(backend: .default).isConfigured)
|
|
}
|
|
|
|
/// Chat Completions has nowhere in the request body to put a server tool,
|
|
/// which is a fact about the protocol rather than a guess about the model —
|
|
/// so it sits on its own property and no setting overrides it.
|
|
@Test
|
|
func protocolSupportIsSeparateFromTheModelGuess() {
|
|
var provider = ModelProvider(name: "Gateway")
|
|
#expect(!provider.acceptsNativeWebSearchTool)
|
|
|
|
provider.usesResponsesAPI = true
|
|
#expect(provider.acceptsNativeWebSearchTool)
|
|
|
|
var anthropic = ModelProvider(name: "Anthropic")
|
|
anthropic.kind = .anthropic
|
|
#expect(anthropic.acceptsNativeWebSearchTool)
|
|
|
|
var google = ModelProvider(name: "Google")
|
|
google.kind = .google
|
|
#expect(google.acceptsNativeWebSearchTool)
|
|
}
|
|
|
|
// MARK: - Provider-native search must stay out of the transcript
|
|
|
|
/// The bug this pins produced, on the *second* request of a conversation:
|
|
///
|
|
/// 400 No tool call found for function call output with call_id
|
|
/// provider-web-search-11328F4A-...
|
|
///
|
|
/// A native search is a card, not a turn. The provider ran it inside its own
|
|
/// assistant turn and never asked Termind for anything, so a transcript that
|
|
/// carries it as a tool result replays a `function_call_output` matching no
|
|
/// `function_call`. The first request looks fine because it is the one that
|
|
/// puts the orphan into the history; the next one carries it and is rejected.
|
|
@Test @MainActor
|
|
func providerNativeSearchMakesACardButNotATranscriptTurn() {
|
|
let conversation = ConversationModel()
|
|
let invocation = self.invocation(query: "swift 6")
|
|
let descriptor = AgentToolDescriptor(
|
|
name: "web_search", summary: "Web search run by the model provider.",
|
|
inputSchema: .object(["type": .string("object")]),
|
|
target: .network, safety: .locallyReadOnly
|
|
)
|
|
|
|
conversation.handle(.toolProposed(invocation, descriptor))
|
|
conversation.handle(.toolFinished(invocation, AgentToolResult(
|
|
callID: invocation.call.id,
|
|
content: "[]",
|
|
metadata: [AgentToolResult.providerNativeKey: .bool(true)]
|
|
)))
|
|
|
|
#expect(conversation.toolCards.count == 1)
|
|
#expect(conversation.transcript.isEmpty)
|
|
// And so nothing reaches the next request's history.
|
|
#expect(conversation.pendingPriorMessages.isEmpty)
|
|
}
|
|
|
|
/// The other half: an ordinary tool result still becomes a turn, or the
|
|
/// model never learns what its own tool returned.
|
|
@Test @MainActor
|
|
func ordinaryToolResultsStillEnterTheTranscript() {
|
|
let conversation = ConversationModel()
|
|
let invocation = AgentToolInvocation(
|
|
runID: UUID(),
|
|
call: AgentToolCall(
|
|
id: "call-1", name: "web_search",
|
|
arguments: .object(["query": .string("swift 6")])
|
|
)
|
|
)
|
|
let descriptor = AgentToolDescriptor(
|
|
name: "web_search", summary: "Search the public web.",
|
|
inputSchema: .object(["type": .string("object")]),
|
|
target: .network, safety: .locallyReadOnly
|
|
)
|
|
|
|
conversation.handle(.toolProposed(invocation, descriptor))
|
|
conversation.handle(.toolFinished(invocation, AgentToolResult(
|
|
callID: "call-1", content: "<untrusted-data>\n[]\n</untrusted-data>"
|
|
)))
|
|
|
|
#expect(conversation.transcript.count == 1)
|
|
#expect(conversation.transcript[0].role == .tool)
|
|
#expect(conversation.transcript[0].toolCallID == "call-1")
|
|
}
|
|
|
|
/// Repairs conversations already damaged by the bug above.
|
|
///
|
|
/// A run persists its snapshot before the request goes out, so the failing
|
|
/// request's own history was written to disk. Without this, an affected
|
|
/// conversation keeps carrying the orphan forward and keeps being rejected,
|
|
/// restart or not.
|
|
@Test
|
|
func orphanedToolResultsAreDroppedFromIncomingHistory() {
|
|
let history: [AgentTranscriptMessage] = [
|
|
AgentTranscriptMessage(role: .user, text: "look it up"),
|
|
AgentTranscriptMessage(
|
|
role: .assistant, text: "",
|
|
toolCalls: [AgentToolCall(
|
|
id: "call-1", name: "ssh_exec", arguments: .object([:])
|
|
)]
|
|
),
|
|
AgentTranscriptMessage(
|
|
role: .tool, text: "ok", toolCallID: "call-1", toolName: "ssh_exec"
|
|
),
|
|
AgentTranscriptMessage(
|
|
role: .tool, text: "[]",
|
|
toolCallID: "provider-web-search-11328F4A", toolName: "web_search"
|
|
),
|
|
AgentTranscriptMessage(role: .assistant, text: "Done."),
|
|
]
|
|
|
|
let repaired = AgentOrphanedToolResultRepair.apply(history)
|
|
|
|
#expect(repaired.count == 4)
|
|
#expect(!repaired.contains { $0.toolCallID?.hasPrefix("provider-web-search") == true })
|
|
// The answered one survives, or the model loses what its tool returned.
|
|
#expect(repaired.contains { $0.toolCallID == "call-1" })
|
|
}
|
|
|
|
@Test
|
|
func toolResultsWithoutACallIDAreKept() {
|
|
// Google addresses a tool result by name, not by id, so an absent id is
|
|
// not evidence of an orphan.
|
|
let history = [
|
|
AgentTranscriptMessage(role: .tool, text: "ok", toolName: "list_hosts")
|
|
]
|
|
#expect(AgentOrphanedToolResultRepair.apply(history).count == 1)
|
|
}
|
|
|
|
private func invocation(query: String) -> AgentToolInvocation {
|
|
AgentToolInvocation(
|
|
runID: UUID(),
|
|
call: AgentToolCall(
|
|
id: "call-1", name: "web_search",
|
|
arguments: .object(["query": .string(query)])
|
|
)
|
|
)
|
|
}
|
|
}
|