charmbracelet/crush/ PR #3456/ merged into main

Rate-limited search

The web-search tool used to report DuckDuckGo's bot-check page as “No results found”, and the model would rephrase and retry for as long as you let it. This branch teaches the tool to say “rate limited” instead. The full test suite cannot see any of it — everything below was measured with probes.

compared:4dd4442a→3c61d9a3 (both PR commits: the fix and the tests added after it)
2541
scenarios run on each of three builds
0
of them changed result
3
of 4 probes diverged
40
of 135 changed lines are production code
1
pre-existing failure discarded
L0Consequenceswho is affected and what they will see
the model driving the search tool

Being throttled no longer looks like an empty internet

Before, DuckDuckGo's bot-check page parsed into zero results and the tool answered, successfully, “No results found. Try rephrasing your search.” A model that trusts that answer rephrases, narrows, broadens, and keeps going — the PR author reports losing over an hour inside a single agentic fetch this way.

Now the tool returns a failure: “Failed to search: DuckDuckGo is rate-limiting this machine. Do not retry or rephrase; wait a few minutes or fetch known URLs directly.” The instruction not to retry is part of the payload the model reads.

statedproven by probe

A page that merely quotes the bot-check wording is now reported as a rate limit

Detection is a plain substring scan over the whole response body, so it also matches result snippets. A genuine results page containing the phrase Unfortunately, bots use DuckDuckGo too — the kind of page you get searching for DuckDuckGo's bot detection itself — returned that result before and returns the rate-limit error now.

The PR adds a false-positive guard test, but its sample page contains none of the three markers, so this path is untested.

unstatedno test covers itproven by probe
whoever trusts the suite as a safety net

Not one of 2541 scenarios noticed the change

Running the pre-change tests against the post-change code moved exactly zero results: internal/agent/tools/search.go had no test file at all before this branch. Every assertion about the new behavior comes from the tests the PR itself adds, and from the probes below.

That is not an argument against the change. It is the reason a diff-reading review would have had nothing to check it against.

coverage0 of 2541 moved
L1Rulesstruck through = no longer true
C1

What a bot-check response produces

wasThe page is parsed like any other. It contains no result rows, so the tool succeeds with “No results found. Try rephrasing your search.” nowThe body is scanned for anomaly-modal, /anomaly.js, and Unfortunately, bots use DuckDuckGo too. Any hit short-circuits to errSearchRateLimited, which the tool wrapper turns into an error response.
Evidence. Probe P2, the captured bot-check page served with HTTP 200. On base: 0 result(s), tool response = "No results found. Try rephrasing your search." On head: tool response = "Failed to search: DuckDuckGo is rate-limiting this machine…"
C2

How much of the body the markers are matched against

wasNothing about the body's text mattered. Only the parsed result rows did. nowstrings.Contains over the entire response, before parsing and regardless of how many results the page holds. Result titles and snippets are inside that range.
Evidence. Probe P3, a results page whose snippet reads The interstitial reads: Unfortunately, bots use DuckDuckGo too. On base: 1 result(s) … "Why DuckDuckGo blocks bots". On head: the rate-limit error. The PR's own TestSearchParsesNormalResults uses a page with no marker text, so it does not reach this.
C3

Which HTTP statuses count as a result page

was200 and 202 both proceed to parsing; everything else is “search failed with status code: N”. now202 is unconditionally the rate-limit error, before the body is read at all. Only 200 proceeds. Other statuses are unchanged.
Evidence. Probe P1, bot-check page with HTTP 202: base parses it into the empty-result message, head errors. Note the 202 branch now fires whatever the body contains — a 202 carrying real results would be rejected. No probe covers that, because there is no evidence DuckDuckGo emits one.
C4

What the suite can tell you about this file

wasNothing — internal/agent/tools/search.go had no test file. nowThree tests: the 202 interstitial, the 200 interstitial, and one false-positive guard. Plus a captured real bot-check page in testdata/.
Evidence. base and mix both ran 2541 scenarios with the same single failure; head ran 2544. The three added names are TestSearchRateLimitedOn202, TestSearchRateLimitedOnAnomalyPage, TestSearchParsesNormalResults.
L2Examplesdimmed rows did not move
What DuckDuckGo returnson baseon headmoved
Bot-check page, HTTP 202probe P1 No results found. Try rephrasing your search. Failed to search: DuckDuckGo is rate-limiting this machine… changed
Bot-check page, HTTP 200probe P2 No results found. Try rephrasing your search. Failed to search: DuckDuckGo is rate-limiting this machine… changed
Real results, one snippet quotes the marker textprobe P3 Found 1 search results: Why DuckDuckGo blocks bots Failed to search: DuckDuckGo is rate-limiting this machine… changed, unstated
Ordinary results pageprobe P4, the control Found 1 search results: Example Post Found 1 search results: Example Post no change
The other 2541 scenarios in ./internal/...base and mix runs as before as before no change
L3Code135 lines · 40 of them production
internal/agent/tools/search.gomarkers + errorC1 · C2
+var errSearchRateLimited = errors.New(+	"DuckDuckGo is rate-limiting this machine. " ++		"Do not retry or rephrase; wait a few minutes or fetch known URLs directly")++var ddgAnomalyMarkers = []string{+	"anomaly-modal",+	"/anomaly.js",+	"Unfortunately, bots use DuckDuckGo too",+}
internal/agent/tools/search.gostatus branchC3
-	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {+	if resp.StatusCode == http.StatusAccepted {+		return nil, errSearchRateLimited+	}+	if resp.StatusCode != http.StatusOK { 		return nil, fmt.Errorf("search failed with status code: %d", resp.StatusCode) 	}
internal/agent/tools/search.gobody scanC2 — the unstated one
-	return parseLiteSearchResults(string(body), maxResults)+	content := string(body)+	for _, marker := range ddgAnomalyMarkers {+		if strings.Contains(content, marker) {+			return nil, errSearchRateLimited+		}+	}++	return parseLiteSearchResults(content, maxResults)
internal/agent/tools/search.gotest seamno behavior of its own
+var ddgLiteEndpoint = "https://lite.duckduckgo.com/lite/?q="-	searchURL := "https://lite.duckduckgo.com/lite/?q=" + url.QueryEscape(query)+	searchURL := ddgLiteEndpoint + url.QueryEscape(query)  Same literal, now reassignable so a test can point at httptest. Ported to base as-is to make the probes measurable on both sides.
tests and fixture95 of 135 lines
+ internal/agent/tools/search_test.go                 76 lines, new file+ internal/agent/tools/testdata/ddg_anomaly_202.html  19 lines, captured 2026-07-29

how this was checked

  • base 4dd4442a (the commit before the PR's first commit) → head 3c61d9a3. The PR contributed two commits: the fix and, separately, the tests — comparing the merge commit against its parent would have shown only the tests.
  • go test ./internal/... -count=1 -json on three builds: base, head, and a mix of head's production code with base's tests and fixtures. 52 packages, 2541 scenarios; head runs 2544.
  • One test fails identically on all three and is discarded as pre-existing: internal/cmd/clientserverrace::TestClientServerSpawnRace.
  • Four probes, byte-identical on both sides, against a local httptest server. Measuring base required porting the PR's one-line test seam (ddgLiteEndpoint) back to base; it carries no behavior. The fourth probe is a control that passes on both sides — without it the other three would prove nothing.

what this did not cover

  • Real DuckDuckGo responses. Every measurement is against a stub serving the captured page; whether those three markers appear in live result pages, and how often, is unknown.
  • A 202 response carrying genuine results. The new branch would reject it, but there is no evidence such a response exists, so no probe was written.
  • The rest of the search tool: rate-limit backoff timing, header randomization, and URL cleaning were not exercised beyond what the existing suite already runs.