Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,121 @@ describe('search authority and completeness', () => {
expect(result.stderr).toContain('broken')
})

it('skips files with no text extractor without fetching them or failing the search', async () => {
const requested: string[] = []
const result = await runEngine(
'grep',
['needle'],
runtimeWith(
{
'/api/v2/files': {
data: [
{ id: 'notes', name: 'notes.md', type: 'text/markdown' },
{ id: 'report', name: 'report.pdf', type: 'application/pdf' },
{ id: 'font', name: 'brand.woff2', type: 'font/woff2' },
{ id: 'clip', name: 'demo.mp4', type: 'video/mp4' },
],
nextCursor: null,
},
'/api/v2/files/notes/text': { data: { text: 'needle', degraded: false } },
'/api/v2/files/report/text': { data: { text: 'a needle', degraded: false } },
},
requested
),
{ scope: 'files' }
)
expect(requested.filter((path) => path !== '/api/v2/files').sort()).toEqual([
'/api/v2/files/notes/text',
'/api/v2/files/report/text',
])
expect(result.exitCode).toBe(0)
expect(result.stdout).toBe(
'files/notes.md (notes):1: needle\nfiles/report.pdf (report):1: a needle'
)
expect(result.stderr).toBe(
'Skipped 2 files with no searchable text (images, media, archives, and other binary formats).'
)
})

it('keeps real read failures incomplete beside skipped non-text files', async () => {
const result = await runEngine(
'grep',
['needle'],
runtimeWith({
'/api/v2/files': {
data: [
{ id: 'broken', name: 'broken.txt', type: 'text/plain' },
{ id: 'logo', name: 'logo.png', type: 'image/png' },
],
nextCursor: null,
},
}),
{ scope: 'files' }
)
expect(result.exitCode).toBe(1)
expect(result.stderr).toMatch(/^Search incomplete \(1 issue\)/)
expect(result.stderr).toContain('files/broken.txt (broken): could not read text')
expect(result.stderr).toContain('Skipped 1 file with no searchable text')
})

it('names a selected non-text file as searched-and-skipped, not an unknown selector', async () => {
const requested: string[] = []
const result = await runEngine(
'grep',
['needle'],
runtimeWith(
{
'/api/v2/files': {
data: [{ id: 'logo', name: 'logo.png', type: 'image/png' }],
nextCursor: null,
},
},
requested
),
{ scope: 'files', in: 'files/logo' }
)
expect(requested).toEqual(['/api/v2/files'])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('No matches')
expect(result.stderr).toContain('Skipped 1 file with no searchable text')
})

it('spends the file-read limit only on files with text', async () => {
const files = [
...Array.from({ length: 300 }, (_, i) => ({
id: `img${i}`,
name: `img${i}.png`,
type: 'image/png',
})),
{ id: 'notes', name: 'notes.txt', type: 'text/plain' },
]
const result = await runEngine(
'grep',
['needle'],
{
...runtimeWith({}),
client: {
request: async <T>(
path: string,
options?: { query?: Record<string, string> }
): Promise<T> => {
if (path === '/api/v2/files') {
const offset = Number(options?.query?.cursor ?? 0)
return {
data: files.slice(offset, offset + 100),
nextCursor: offset + 100 < files.length ? String(offset + 100) : null,
} as T
}
if (path === '/api/v2/files/notes/text') return { data: { text: 'needle' } } as T
throw new Error(`Unexpected request: ${path}`)
},
},
},
{ scope: 'files', count: true }
)
expect(result).toMatchObject({ exitCode: 0, stdout: '1 (files=1)' })
})

it('reports truncated extraction even when a returned prefix has a match', async () => {
const result = await runEngine(
'grep',
Expand Down
75 changes: 56 additions & 19 deletions apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import {
type AgentCliFlags,
type AgentCliRuntime,
agentCliFail,
agentCliOk,
} from '@/lib/mothership/agent-cli/types'
import { buildIntegrationToolSchemas } from '@/lib/mothership/chat/payload'
import { workspaceFileTextFormat } from '@/lib/workspace-files/text-format'
import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
import { OPAQUE_RESOLVED_SECRET_REPLACEMENT } from '@/executor/utils/resolved-secret-matcher'
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
Expand Down Expand Up @@ -61,6 +61,7 @@ const PLATFORM_SCOPES: ReadonlySet<Scope> = new Set<Scope>(['blocks', 'tools'])

interface GrepRuntime extends AgentCliRuntime {
reportIncomplete(message: string): void
reportNonText(): void
}

/**
Expand Down Expand Up @@ -370,22 +371,44 @@ const FETCHERS: Record<
credentials: async (_runtime, item) => render('credentials', item.id, item.label, item.raw),
}

function concurrencyFor(scope: Scope): number {
return scope === 'files' ? FILE_READ_CONCURRENCY : FETCH_CONCURRENCY
/**
* A listed file the agent's text read has no extractor for (fonts, media, archives, images), by
* the same gate that read applies. A listing without a name cannot say, so that file is read.
*/
function lacksText(file: unknown): boolean {
if (!isRecordLike(file)) return false
const name = str(file.name)
return name !== undefined && !workspaceFileTextFormat({ name, type: str(file.type) ?? '' })
}

async function fetchAll(
runtime: GrepRuntime,
scope: Scope,
entries: IndexEntry[]
): Promise<GrepMaterialized[]> {
if (scope === 'files' && entries.length > MAX_GREP_FILES) {
if (scope !== 'files') {
return mapConcurrent(entries, FETCH_CONCURRENCY, (item) => FETCHERS[scope](runtime, item))
}
// Skipped files keep their identity, so `--in` naming one is not an unknown selector.
const skipped: GrepMaterialized[] = []
const readable: IndexEntry[] = []
for (const item of entries) {
if (lacksText(item.raw)) {
runtime.reportNonText()
skipped.push({ scope, id: item.id, label: item.label, text: null })
} else readable.push(item)
}
if (readable.length > MAX_GREP_FILES) {
runtime.reportIncomplete(
`files: searched the first ${MAX_GREP_FILES} of ${entries.length} selected files; narrow with --in files/<name-or-id>.`
`files: searched the first ${MAX_GREP_FILES} of ${readable.length} selected files; narrow with --in files/<name-or-id>.`
)
}
const selected = scope === 'files' ? entries.slice(0, MAX_GREP_FILES) : entries
return mapConcurrent(selected, concurrencyFor(scope), (item) => FETCHERS[scope](runtime, item))
const read = await mapConcurrent(
readable.slice(0, MAX_GREP_FILES),
FILE_READ_CONCURRENCY,
(item) => FETCHERS.files(runtime, item)
)
return [...read, ...skipped]
}

/** A whole world, for a search with no `--in`: every resource the index lists. */
Expand Down Expand Up @@ -566,10 +589,15 @@ export interface GrepSource {
registry?: AgentCliRuntime['resolvedSecretTraceRegistry']
/** The only scopes this corpus holds, and the refusal for a search that names another. */
scopeLimit?: { scopes: readonly Scope[]; refusal: string }
/**
* `reportNonText` counts a file skipped unread because it has no text to extract: a note,
* not an incomplete search. The skipped file is still returned with null text.
*/
materialize(
scopes: Scope[],
nameFilter: string | undefined,
reportIncomplete: (message: string) => void
reportIncomplete: (message: string) => void,
reportNonText: () => void
): Promise<GrepMaterialized[]>
}

Expand Down Expand Up @@ -624,15 +652,24 @@ export async function executeGrep(positionals: string[], flags: AgentCliFlags, s
issueCount++
if (issues.length < 10) issues.push(clip(message))
}
const finish = (stdout: string) =>
issueCount === 0
? agentCliOk(stdout)
: {
exitCode: 1,
stdout,
stderr: `Search incomplete (${issueCount} issue${issueCount === 1 ? '' : 's'}); matches and counts cover only the content read.\n${issues.join('\n')}${issueCount > issues.length ? `\n${issueCount - issues.length} further issues omitted.` : ''}`,
}
const candidates = await source.materialize(searched, nameFilter, reportIncomplete)
let nonTextCount = 0
const reportNonText = () => {
nonTextCount++
}
const finish = (stdout: string) => {
const skipped = nonTextCount
? `Skipped ${nonTextCount} file${nonTextCount === 1 ? '' : 's'} with no searchable text (images, media, archives, and other binary formats).`
: ''
const incomplete =
issueCount &&
`Search incomplete (${issueCount} issue${issueCount === 1 ? '' : 's'}); matches and counts cover only the content read.\n${issues.join('\n')}${issueCount > issues.length ? `\n${issueCount - issues.length} further issues omitted.` : ''}`
return {
exitCode: issueCount ? 1 : 0,
stdout,
stderr: [incomplete, skipped].filter(Boolean).join('\n'),
}
}
const candidates = await source.materialize(searched, nameFilter, reportIncomplete, reportNonText)
source.signal?.throwIfAborted()
/**
* A resource nothing in the searched worlds answers to is a wrong selector, not a
Expand Down Expand Up @@ -721,8 +758,8 @@ export const universalGrepCommand: AgentCliEngine = {
executeGrep(positionals, flags, {
signal: runtime.signal,
registry: runtime.resolvedSecretTraceRegistry,
async materialize(scopes, nameFilter, reportIncomplete) {
const bounded = gatedRuntime({ ...runtime, reportIncomplete })
async materialize(scopes, nameFilter, reportIncomplete, reportNonText) {
const bounded = gatedRuntime({ ...runtime, reportIncomplete, reportNonText })
return nameFilter
? materializeWithin(bounded, scopes, nameFilter)
: (await settleAll(scopes.map((scope) => materializeScope(bounded, scope)))).flat()
Expand Down
17 changes: 17 additions & 0 deletions apps/sim/lib/mothership/agent-cli/project-file-grep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,23 @@ describe('Project grep corpus', () => {
expect(list).toHaveBeenCalledTimes(1)
expect(result.stderr).toContain(`searched the first ${MAX_GREP_FILES} selected files`)
})
it('skips a file with no text extractor without reading it or failing the search', async () => {
list.mockResolvedValue({
files: [
{ id: 'file', name: 'notes.txt', type: 'text/plain', folderPath: '/' },
{ id: 'clip', name: 'notes-demo.mp4', type: 'video/mp4', folderPath: '/' },
],
nextKeys: null,
})
const result = await grepProjectFiles(invocation, context(), 'project')
expect(textRead).toHaveBeenCalledTimes(1)
expect(result).toEqual({
exitCode: 0,
stdout: 'files/notes.txt (file):2: needle',
stderr:
'Skipped 1 file with no searchable text (images, media, archives, and other binary formats).',
})
})
it('marks truncated inventory incomplete instead of claiming a missing selector has no matches', async () => {
list.mockResolvedValue({ files: [], nextKeys: [{ value: 'cursor', direction: 'asc' }] })
const result = await grepProjectFiles(invocation, context(), 'project')
Expand Down
Loading
Loading