Skip to content

Commit 338a895

Browse files
j15zclaude
andcommitted
improvement(copilot): retry docs fetches and grep docs directories in parallel
Two robustness upgrades to the docs corpus. Page fetches from docs.sim.ai now retry transient failures (5xx, 429, network, timeout) with jittered backoff over three 3s attempts instead of a single 10s attempt, so a momentary stall recovers in seconds instead of failing the tool call. And grep now accepts a docs directory path: it fans out to every manifest page under the directory with bounded concurrency and runs one multi-file grep, replacing the single-page restriction that forced agents into per-page call sweeps. Pages the site no longer serves are skipped; an unreachable page fails the whole grep so a partial result is never mistaken for "not documented". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7b517f0 commit 338a895

4 files changed

Lines changed: 151 additions & 46 deletions

File tree

apps/sim/lib/copilot/docs/docs-corpus.test.ts

Lines changed: 80 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,21 @@
22
* @vitest-environment node
33
*/
44
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
vi.mock('@sim/utils/helpers', () => ({
7+
sleep: vi.fn(() => Promise.resolve()),
8+
}))
9+
510
import {
611
couldMatchDocsScope,
712
DocsCorpusError,
813
globDocs,
9-
grepDocsPage,
14+
grepDocs,
1015
isDocsPath,
1116
readDocsPage,
1217
} from '@/lib/copilot/docs/docs-corpus'
1318
import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest'
19+
import type { GrepMatch } from '@/lib/copilot/vfs/operations'
1420

1521
const SAMPLE_PAGE = DOCS_MANIFEST.find((path) => path === 'workflows/blocks/agent.mdx')
1622

@@ -98,33 +104,52 @@ describe('readDocsPage', () => {
98104
expect(fetchMock).not.toHaveBeenCalled()
99105
})
100106

101-
it('surfaces a docs-site outage as a retryable error', async () => {
107+
it('surfaces a docs-site outage as a retryable error after exhausting retries', async () => {
102108
fetchMock.mockResolvedValue({ ok: false, status: 502, text: async () => '' })
103-
await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/temporarily unavailable/)
109+
await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/)
110+
expect(fetchMock).toHaveBeenCalledTimes(3)
104111
})
105112

106113
it('treats a network failure as retryable', async () => {
107114
fetchMock.mockRejectedValue(new Error('socket hang up'))
108-
await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/temporarily unavailable/)
115+
await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/)
116+
expect(fetchMock).toHaveBeenCalledTimes(3)
117+
})
118+
119+
it('recovers when a transient failure clears on retry', async () => {
120+
fetchMock
121+
.mockRejectedValueOnce(new Error('socket hang up'))
122+
.mockResolvedValue({ ok: true, status: 200, text: async () => '# Agent\n\nbody' })
123+
124+
const page = await readDocsPage(`docs/${SAMPLE_PAGE}`)
125+
126+
expect(fetchMock).toHaveBeenCalledTimes(2)
127+
expect(page).toEqual({ content: '# Agent\n\nbody', totalLines: 3 })
109128
})
110129

111-
it('reports a page the site no longer serves as permanent, not retryable', async () => {
130+
it('reports a page the site no longer serves as permanent, without retrying', async () => {
112131
fetchMock.mockResolvedValue({ ok: false, status: 404, text: async () => '' })
113132
const error = await readDocsPage(`docs/${SAMPLE_PAGE}`).catch((e) => e)
114133
expect(error).toBeInstanceOf(DocsCorpusError)
115134
expect(error.message).toMatch(/does not serve it/)
116135
expect(error.message).toMatch(/retrying will not help/)
117-
expect(error.message).not.toMatch(/temporarily unavailable/)
136+
expect(error.message).not.toMatch(/could not be reached/)
137+
expect(fetchMock).toHaveBeenCalledOnce()
118138
})
119139

120140
it('still treats 429 as retryable rather than permanent', async () => {
121141
fetchMock.mockResolvedValue({ ok: false, status: 429, text: async () => '' })
122-
await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/temporarily unavailable/)
142+
await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/)
143+
expect(fetchMock).toHaveBeenCalledTimes(3)
123144
})
124145
})
125146

126-
describe('grepDocsPage', () => {
147+
describe('grepDocs', () => {
127148
const fetchMock = vi.fn()
149+
const SECTION_DIR = 'docs/workflows/blocks'
150+
const SECTION_PAGES = DOCS_MANIFEST.filter((path) => path.startsWith('workflows/blocks/')).map(
151+
(path) => `docs/${path}`
152+
)
128153

129154
beforeEach(() => {
130155
fetchMock.mockReset()
@@ -135,24 +160,66 @@ describe('grepDocsPage', () => {
135160
vi.unstubAllGlobals()
136161
})
137162

138-
it('greps exactly one page', async () => {
163+
it('greps exactly one page for a page path', async () => {
139164
fetchMock.mockResolvedValue({
140165
ok: true,
141166
status: 200,
142167
text: async () => 'intro line\nsystemPrompt matters\ntail',
143168
})
144169

145-
const matches = await grepDocsPage(`docs/${SAMPLE_PAGE}`, 'systemPrompt')
170+
const matches = await grepDocs(`docs/${SAMPLE_PAGE}`, 'systemPrompt')
146171

147172
expect(fetchMock).toHaveBeenCalledOnce()
148173
expect(matches).toEqual([
149174
{ path: `docs/${SAMPLE_PAGE}`, line: 2, content: 'systemPrompt matters' },
150175
])
151176
})
152177

153-
it('refuses a multi-page scope so one grep is never hundreds of fetches', async () => {
154-
await expect(grepDocsPage('docs/', 'cron')).rejects.toThrow(/single page/)
155-
await expect(grepDocsPage('docs/workflows', 'cron')).rejects.toThrow(/single page/)
178+
it('greps a directory by fetching every page under it', async () => {
179+
fetchMock.mockResolvedValue({
180+
ok: true,
181+
status: 200,
182+
text: async () => 'intro\ncron marker line\ntail',
183+
})
184+
expect(SECTION_PAGES.length).toBeGreaterThan(1)
185+
186+
const matches = (await grepDocs(SECTION_DIR, 'cron marker', {
187+
maxResults: 10_000,
188+
})) as GrepMatch[]
189+
190+
expect(fetchMock).toHaveBeenCalledTimes(SECTION_PAGES.length)
191+
expect(matches.map((match) => match.path)).toEqual(SECTION_PAGES)
192+
})
193+
194+
it('skips pages the site no longer serves instead of failing the directory grep', async () => {
195+
const missingUrl = `https://docs.sim.ai/${SECTION_PAGES[0].slice('docs/'.length)}`
196+
fetchMock.mockImplementation(async (url: string) =>
197+
url === missingUrl
198+
? { ok: false, status: 404, text: async () => '' }
199+
: { ok: true, status: 200, text: async () => 'cron marker line' }
200+
)
201+
202+
const matches = (await grepDocs(SECTION_DIR, 'cron marker', {
203+
maxResults: 10_000,
204+
})) as GrepMatch[]
205+
206+
expect(matches.map((match) => match.path)).toEqual(SECTION_PAGES.slice(1))
207+
})
208+
209+
it('fails the whole directory grep when a page cannot be reached', async () => {
210+
fetchMock.mockImplementation(async (url: string) =>
211+
url.endsWith(`/${SAMPLE_PAGE}`)
212+
? { ok: false, status: 502, text: async () => '' }
213+
: { ok: true, status: 200, text: async () => 'cron marker line' }
214+
)
215+
216+
await expect(grepDocs(SECTION_DIR, 'cron marker')).rejects.toThrow(/Retry shortly/)
217+
})
218+
219+
it('rejects a path that is neither a page nor a directory without fetching', async () => {
220+
await expect(grepDocs('docs/not-a-real-page.mdx', 'cron')).rejects.toThrow(
221+
/not a docs page or directory/
222+
)
156223
expect(fetchMock).not.toHaveBeenCalled()
157224
})
158225
})

apps/sim/lib/copilot/docs/docs-corpus.ts

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { createLogger } from '@sim/logger'
22
import { toError } from '@sim/utils/errors'
3+
import { sleep } from '@sim/utils/helpers'
4+
import { backoffWithJitter } from '@sim/utils/retry'
35
import { foldDocsIndexPath } from '@/lib/copilot/docs/docs-path'
46
import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest'
57
import type { GrepCountEntry, GrepMatch, GrepOptions } from '@/lib/copilot/vfs/operations'
6-
import { glob as globPaths, grepReadResult } from '@/lib/copilot/vfs/operations'
8+
import { glob as globPaths, grep, grepReadResult } from '@/lib/copilot/vfs/operations'
9+
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
710

811
const logger = createLogger('DocsCorpus')
912

@@ -13,7 +16,12 @@ const DOCS_BASE_URL = 'https://docs.sim.ai'
1316
/** VFS prefix the docs corpus is mounted at. */
1417
const DOCS_PREFIX = 'docs/'
1518

16-
const FETCH_TIMEOUT_MS = 10_000
19+
/** Per-attempt budget — the site is CDN-cached and normally answers in well under a second. */
20+
const FETCH_ATTEMPT_TIMEOUT_MS = 3_000
21+
const FETCH_MAX_ATTEMPTS = 3
22+
23+
/** Parallel page fetches for a directory-scoped grep. */
24+
const GREP_FETCH_CONCURRENCY = 8
1725

1826
/**
1927
* Thrown for expected, user-facing docs-corpus conditions (unknown page,
@@ -108,8 +116,9 @@ export interface DocsPage {
108116
* Fetch one docs page's raw markdown from the live site. The manifest path IS
109117
* the URL path (`docs/workflows/blocks/agent.mdx` →
110118
* `https://docs.sim.ai/workflows/blocks/agent.mdx`, which the docs app rewrites
111-
* to its raw-markdown route), so no mapping table is needed. Returns null when
112-
* the page is not in the manifest or the site does not serve it.
119+
* to its raw-markdown route), so no mapping table is needed. Transient failures
120+
* (5xx, 429, network error, timeout) are retried with jittered backoff before
121+
* being reported as unavailable.
113122
*/
114123
type DocsFetchResult =
115124
| { outcome: 'ok'; content: string }
@@ -118,13 +127,10 @@ type DocsFetchResult =
118127
/** Transient: 5xx, 429, network error, or timeout. */
119128
| { outcome: 'unavailable' }
120129

121-
async function fetchDocsPage(path: string): Promise<DocsFetchResult> {
122-
const key = normalize(path)
123-
if (!docsKeyView.has(key)) return { outcome: 'missing' }
124-
const url = `${DOCS_BASE_URL}/${key.slice(DOCS_PREFIX.length)}`
130+
async function fetchDocsPageOnce(url: string): Promise<DocsFetchResult> {
125131
try {
126132
const response = await fetch(url, {
127-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
133+
signal: AbortSignal.timeout(FETCH_ATTEMPT_TIMEOUT_MS),
128134
headers: { Accept: 'text/markdown, text/plain' },
129135
})
130136
if (!response.ok) {
@@ -139,6 +145,17 @@ async function fetchDocsPage(path: string): Promise<DocsFetchResult> {
139145
}
140146
}
141147

148+
async function fetchDocsPage(path: string): Promise<DocsFetchResult> {
149+
const key = normalize(path)
150+
if (!docsKeyView.has(key)) return { outcome: 'missing' }
151+
const url = `${DOCS_BASE_URL}/${key.slice(DOCS_PREFIX.length)}`
152+
for (let attempt = 1; ; attempt++) {
153+
const result = await fetchDocsPageOnce(url)
154+
if (result.outcome !== 'unavailable' || attempt >= FETCH_MAX_ATTEMPTS) return result
155+
await sleep(backoffWithJitter(attempt, null))
156+
}
157+
}
158+
142159
/**
143160
* Read one docs page. Throws {@link DocsCorpusError} for the expected user-facing
144161
* conditions (directory path, unknown page, site unreachable) so the handler can
@@ -163,28 +180,55 @@ export async function readDocsPage(path: string): Promise<DocsPage> {
163180
}
164181
if (result.outcome === 'unavailable') {
165182
throw new DocsCorpusError(
166-
`Could not load ${key} from ${DOCS_BASE_URL} — the docs site is temporarily unavailable. Retry shortly.`
183+
`Could not load ${key} from ${DOCS_BASE_URL} — the docs site could not be reached. Retry shortly.`
167184
)
168185
}
169186
return { content: result.content, totalLines: result.content.split('\n').length }
170187
}
171188

172189
/**
173-
* Grep ONE docs page, mirroring how grep over `files/` works: each page is a
174-
* separate fetch from the docs site, so a multi-page grep would mean hundreds of
175-
* requests. A path that is not a single page throws.
190+
* Grep the docs corpus. A single page greps just that page. A directory path
191+
* (`docs`, `docs/files`) fans out to every manifest page under it: pages are
192+
* fetched in parallel and searched as one multi-file grep, so results follow
193+
* manifest order and `maxResults` applies across pages. Pages the site no
194+
* longer serves are skipped; a page that cannot be reached after retries fails
195+
* the whole grep, because a silent partial result would misread as "not
196+
* documented".
176197
*/
177-
export async function grepDocsPage(
198+
export async function grepDocs(
178199
path: string,
179200
pattern: string,
180201
options?: GrepOptions
181202
): Promise<GrepMatch[] | string[] | GrepCountEntry[]> {
182203
const key = normalize(path)
183-
if (!docsKeyView.has(key)) {
204+
if (docsKeyView.has(key)) {
205+
const page = await readDocsPage(key)
206+
return grepReadResult(key, page, pattern, key, options)
207+
}
208+
if (!isDocsDir(key)) {
209+
throw new DocsCorpusError(
210+
`"${path}" is not a docs page or directory. Use glob("docs/**") to list the docs corpus.`
211+
)
212+
}
213+
const dir = `${key}/`
214+
const pages = [...docsKeyView.keys()].filter((pageKey) => pageKey.startsWith(dir))
215+
let unreachable = 0
216+
const results = await mapWithConcurrency(pages, GREP_FETCH_CONCURRENCY, async (pageKey) => {
217+
// Once any page is unreachable the grep is going to fail — skip the
218+
// remaining fetches instead of hammering a site that is not answering.
219+
if (unreachable > 0) return null
220+
const result = await fetchDocsPage(pageKey)
221+
if (result.outcome === 'unavailable') unreachable++
222+
return result
223+
})
224+
if (unreachable > 0) {
184225
throw new DocsCorpusError(
185-
`Grep over the docs corpus must target a single page (e.g. path: "docs/workflows/blocks/agent.mdx"). "${path}" is not a docs page. Use glob("docs/**") to find the exact path, then grep that one page.`
226+
`Could not load every page under ${dir} from ${DOCS_BASE_URL} — a partial grep could misread as "not documented". Retry shortly.`
186227
)
187228
}
188-
const page = await readDocsPage(key)
189-
return grepReadResult(key, page, pattern, key, options)
229+
const contents = new Map<string, string>()
230+
results.forEach((result, index) => {
231+
if (result?.outcome === 'ok') contents.set(pages[index], result.content)
232+
})
233+
return grep(contents, pattern, undefined, options)
190234
}

apps/sim/lib/copilot/tools/handlers/vfs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
couldMatchDocsScope,
99
DocsCorpusError,
1010
globDocs,
11-
grepDocsPage,
11+
grepDocs,
1212
isDocsPath,
1313
readDocsPage,
1414
} from '@/lib/copilot/docs/docs-corpus'
@@ -203,7 +203,7 @@ export async function executeVfsGrep(
203203
let result: GrepMatch[] | string[] | GrepCountEntry[]
204204
let provenanceFile: WorkspaceFileSecretProvenanceIdentity | undefined
205205
if (rawPath !== undefined && isDocsPath(rawPath)) {
206-
result = await grepDocsPage(rawPath, pattern, grepOptions)
206+
result = await grepDocs(rawPath, pattern, grepOptions)
207207
} else if (isChatUploadGrepPath(rawPath)) {
208208
if (!context.chatId) {
209209
return { success: false, error: 'No chat context available for uploads/' }

apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,13 @@ describe('search_docs dispatch chain', () => {
2727
})
2828
})
2929

30-
/**
31-
* The retired ids are fully unregistered server-side — no catalog entry, no
32-
* handler, no alias. Only the client-side chip suppression survives, forever,
33-
* so historical persisted chats replay without rendering chips for tools that
34-
* no longer exist (the load_agent_skill precedent).
35-
*/
36-
describe('retired docs-tool ids', () => {
37-
for (const retired of ['search_documentation', 'get_platform_actions']) {
38-
it(`${retired} is gone from the catalog and server registry but stays chip-hidden`, () => {
39-
expect(TOOL_CATALOG[retired]).toBeUndefined()
40-
expect(isKnownTool(retired)).toBe(false)
41-
expect(getRegisteredServerToolNames()).not.toContain(retired)
42-
expect(getHiddenToolNames().has(retired)).toBe(true)
30+
describe('removed docs-tool ids', () => {
31+
for (const removed of ['search_documentation', 'get_platform_actions']) {
32+
it(`${removed} is absent from the catalog, registries, and hidden-tool set`, () => {
33+
expect(TOOL_CATALOG[removed]).toBeUndefined()
34+
expect(isKnownTool(removed)).toBe(false)
35+
expect(getRegisteredServerToolNames()).not.toContain(removed)
36+
expect(getHiddenToolNames().has(removed)).toBe(false)
4337
})
4438
}
4539
})

0 commit comments

Comments
 (0)