Skip to content

Commit d9b9e1d

Browse files
authored
fix(copilot): bound agent CLI grep matching (#8338)
* fix(copilot): bound agent CLI grep matching * fix(copilot): bound grep context expansion
1 parent 5b667d7 commit d9b9e1d

2 files changed

Lines changed: 120 additions & 14 deletions

File tree

‎apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { flushMicrotasks } from '@sim/testing/helpers/async'
12
import { sleep } from '@sim/utils/helpers'
23
import { generateId } from '@sim/utils/id'
34
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -71,6 +72,87 @@ function runtimeWithFilePages(count: number, requested: string[]): AgentCliRunti
7172
}
7273

7374
describe('universal grep', () => {
75+
it('bounds matching time for adversarial patterns across file lines', async () => {
76+
const runtime = runtimeWith({
77+
'/api/v2/files': { data: [{ id: 'input' }], nextCursor: null },
78+
'/api/v2/files/input/text': {
79+
data: { text: Array.from({ length: 12 }, () => `${'a'.repeat(26)}!`).join('\n') },
80+
},
81+
})
82+
const started = performance.now()
83+
const result = await runEngine('grep', ['(a+)+$'], runtime, { scope: 'files', count: true })
84+
expect(performance.now() - started).toBeLessThan(2_000)
85+
expect(result).toMatchObject({ exitCode: 0, stdout: '0' })
86+
}, 15_000)
87+
88+
it('matches unsupported regex syntax literally without falling back to backtracking', async () => {
89+
const result = await runEngine(
90+
'grep',
91+
['(?=NEEDLE)'],
92+
runtimeWith({
93+
'/api/v2/files': { data: [{ id: 'input' }], nextCursor: null },
94+
'/api/v2/files/input/text': { data: { text: '(?=needle)\nneedle' } },
95+
}),
96+
{ scope: 'files', count: true, i: true }
97+
)
98+
expect(result).toMatchObject({ exitCode: 0, stdout: '1 (files=1)' })
99+
})
100+
101+
it('bounds overlapping context windows while counting matches beyond the output limit', async () => {
102+
const result = await runEngine(
103+
'grep',
104+
['needle'],
105+
runtimeWith({
106+
'/api/v2/files': { data: [{ id: 'input' }], nextCursor: null },
107+
'/api/v2/files/input/text': {
108+
data: { text: ['header', ...Array.from({ length: 30_000 }, () => 'needle')].join('\n') },
109+
},
110+
}),
111+
{ scope: 'files', C: '30000', limit: '3' }
112+
)
113+
expect(result).toMatchObject({
114+
exitCode: 0,
115+
stdout:
116+
'files/input:1: header\nfiles/input:2: needle\nfiles/input:3: needle\n' +
117+
'[2 of 30000 matching lines shown — narrow with --scope, --in, or a tighter pattern]',
118+
})
119+
}, 15_000)
120+
121+
it('fails an exhausted scan budget instead of returning a complete count', async () => {
122+
vi.spyOn(performance, 'now').mockReturnValueOnce(0).mockReturnValue(6_000)
123+
const result = await runEngine('grep', ['id'], runtimeWith(CATALOG), {
124+
scope: 'blocks',
125+
count: true,
126+
})
127+
expect(result.exitCode).toBe(1)
128+
expect(result.stdout).toBe('')
129+
expect(result.stderr).toMatch(/incomplete.*budget/i)
130+
})
131+
132+
it('yields during scanning so a pending cancellation can stop the search', async () => {
133+
vi.useFakeTimers()
134+
try {
135+
vi.spyOn(performance, 'now').mockReturnValueOnce(0).mockReturnValue(20)
136+
const controller = new AbortController()
137+
setTimeout(() => controller.abort(new Error('Search stopped')), 0)
138+
const pending = runEngine(
139+
'grep',
140+
['id'],
141+
{ ...runtimeWith(CATALOG), signal: controller.signal },
142+
{ scope: 'blocks' }
143+
)
144+
await flushMicrotasks()
145+
await vi.runAllTimersAsync()
146+
expect(await pending).toMatchObject({
147+
exitCode: 1,
148+
stdout: '',
149+
stderr: expect.stringContaining('Search stopped'),
150+
})
151+
} finally {
152+
vi.useRealTimers()
153+
}
154+
})
155+
74156
it('finds field ids inside block definitions and names the path-shaped line', async () => {
75157
const result = await runEngine('grep', ['stream'], runtimeWith(CATALOG), {
76158
scope: 'blocks',

‎apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts‎

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import { sleep } from '@sim/utils/helpers'
12
import { isRecordLike } from '@sim/utils/object'
23
import type { ReadFileTextResponse } from 'sim/embed'
34
import { listCatalogTools } from '@/lib/catalog/application/list-tools'
45
import { readBlockCatalog } from '@/lib/catalog/application/read-block-catalog'
6+
import { compileLinearRegex, isPlainText, literalRegex } from '@/lib/core/security/linear-regex'
57
import { enginePrincipal } from '@/lib/mothership/agent-cli/engine-principal'
68
import {
79
type AgentCliEngine,
@@ -46,6 +48,9 @@ const CATALOG_ALL = 100_000
4648
const MAX_FILES = 300
4749
const FILE_READ_CONCURRENCY = 5
4850
const MAX_BYTES_PER_FILE = 262_144
51+
/** Bound scanning after materialization without charging backend read latency. */
52+
const MAX_SCAN_TIME_MS = 5_000
53+
const SCAN_YIELD_INTERVAL_MS = 10
4954
/** `workflow:<uuid>` — a prefixed form no world or resource ever prints as its path. */
5055
const PREFIX_SELECTOR = /^\w+:/
5156
const PLATFORM_SCOPES: ReadonlySet<Scope> = new Set<Scope>(['blocks', 'tools'])
@@ -416,13 +421,12 @@ async function materializeWithin(
416421
}
417422

418423
function compilePattern(raw: string, ignoreCase: boolean): (line: string) => boolean {
419-
try {
420-
const regex = new RegExp(raw, ignoreCase ? 'i' : '')
421-
return (line) => regex.test(line)
422-
} catch {
423-
const needle = ignoreCase ? raw.toLowerCase() : raw
424-
return (line) => (ignoreCase ? line.toLowerCase() : line).includes(needle)
425-
}
424+
const regex = isPlainText(raw)
425+
? literalRegex(raw, { ignoreCase })
426+
: compileLinearRegex(raw, { ignoreCase })
427+
if (regex) return (line) => regex.test(line)
428+
const needle = ignoreCase ? raw.toLowerCase() : raw
429+
return (line) => (ignoreCase ? line.toLowerCase() : line).includes(needle)
426430
}
427431

428432
function clip(line: string): string {
@@ -570,31 +574,51 @@ export const universalGrepCommand: AgentCliEngine = {
570574
let total = 0
571575
let shownMatches = 0
572576
const perScope = new Map<Scope, number>()
577+
const scanStartedAt = performance.now()
578+
let lastYieldAt = scanStartedAt
579+
const checkScan = () => {
580+
runtime.signal?.throwIfAborted()
581+
const now = performance.now()
582+
if (now - scanStartedAt >= MAX_SCAN_TIME_MS) {
583+
throw new Error(
584+
'Search incomplete: scanning exceeded the time budget. Narrow with --scope or --in.'
585+
)
586+
}
587+
return now
588+
}
573589
for (const resource of candidates) {
574590
if (resource.text === null) continue
575591
const lines = resource.text.split('\n')
576-
const selected = new Set<number>()
592+
const selected = new Map<number, boolean>()
593+
const remainingLines = limit - out.length
594+
let nextContextLine = 0
577595
for (let i = 0; i < lines.length; i++) {
596+
if (checkScan() - lastYieldAt >= SCAN_YIELD_INTERVAL_MS) {
597+
await sleep(0)
598+
lastYieldAt = checkScan()
599+
}
578600
if (!matches(lines[i])) continue
579601
total++
580602
perScope.set(resource.scope, (perScope.get(resource.scope) ?? 0) + 1)
581603
if (countOnly) continue
582604
for (
583-
let j = Math.max(0, i - context.before);
584-
j <= Math.min(lines.length - 1, i + context.after);
605+
let j = Math.max(nextContextLine, i - context.before);
606+
j <= Math.min(lines.length - 1, i + context.after) && selected.size < remainingLines;
585607
j++
586608
) {
587-
selected.add(j)
609+
selected.set(j, false)
610+
nextContextLine = j + 1
588611
}
612+
if (selected.has(i)) selected.set(i, true)
589613
}
590614
if (countOnly || selected.size === 0 || out.length >= limit) continue
591615
const header = `${resource.scope}/${resource.label}${resource.label === resource.id ? '' : ` (${resource.id})`}`
592-
for (const i of [...selected].sort((a, b) => a - b)) {
593-
if (out.length >= limit) break
616+
for (const [i, isMatch] of selected) {
594617
out.push(`${header}:${i + 1}: ${clip(lines[i])}`)
595-
if (matches(lines[i])) shownMatches++
618+
if (isMatch) shownMatches++
596619
}
597620
}
621+
checkScan()
598622

599623
if (countOnly) {
600624
const breakdown = [...perScope.entries()].map(([s, n]) => `${s}=${n}`).join(' ')

0 commit comments

Comments
 (0)