Skip to content

Commit c3fa184

Browse files
feat: ensures pi agent retries once after a buggy run (#62)
1 parent f92ecc6 commit c3fa184

2 files changed

Lines changed: 121 additions & 69 deletions

File tree

.changeset/plain-signs-beam.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"layne": minor
3+
---
4+
5+
Ensure that Pi Agent retries once if the run returns buggy results

src/adapters/pi-agent.ts

Lines changed: 116 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ const SYSTEM_PROMPT =
3939

4040
const ReportFindingParams = Type.Object({
4141
file: Type.String({ description: 'File path relative to the repository root' }),
42-
startLine: Type.Optional(Type.Integer({ description: 'Start line of the finding (ignored — only evidence is used for positioning)' })),
43-
endLine: Type.Optional(Type.Integer({ description: 'End line of the finding (ignored — only evidence is used for positioning)' })),
42+
startLine: Type.Optional(Type.Integer({ description: 'Start line of the finding as shown in the read() output. Must match the line where the evidence string begins in the file. Do not use 1 unless the evidence is genuinely on line 1.' })),
43+
endLine: Type.Optional(Type.Integer({ description: 'End line of the finding as shown in the read() output. Must match the line where the evidence string ends in the file.' })),
4444
severity: Type.Union([Type.Literal('high'), Type.Literal('medium'), Type.Literal('low')]),
4545
message: Type.String({ description: 'Description of the malicious pattern' }),
4646
ruleId: Type.String({ description: 'Short kebab-case rule identifier, e.g. "reverse-shell"' }),
@@ -145,63 +145,23 @@ export async function runPiAgent({
145145

146146
console.log(`[pi-agent] scanning ${changedFiles.length} file(s) with provider ${provider}, model ${toolConfig.model} (thinking: ${toolConfig.thinkingLevel ?? 'medium'})`);
147147

148-
const findings: PiAgentRawFinding[] = [];
149-
150-
// Build the report_finding tool — execute() accumulates into the closure array.
151-
const reportFindingTool: ToolDefinition = {
152-
name: 'report_finding',
153-
label: 'Report Finding',
154-
description: 'Report a confirmed security finding. Call once per finding.',
155-
parameters: ReportFindingParams,
156-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
157-
execute: async (_toolCallId: any, params: any, _signal: any, _onUpdate: any, _ctx: any) => {
158-
const finding = normalizeFinding(params as RawFindingInput);
159-
findings.push(finding);
160-
debug('pi-agent', `finding recorded: ${finding.severity.toUpperCase()} ${finding.file}:${finding.startLine} [${finding.ruleId}]`);
161-
return {
162-
content: [{ type: 'text' as const, text: 'Finding recorded.' }],
163-
details: {},
164-
};
165-
},
166-
};
167-
168-
const systemPrompt = toolConfig.prompt ?? SYSTEM_PROMPT;
169-
170-
const resourceLoader = new DefaultResourceLoader({
171-
systemPromptOverride: () => systemPrompt,
172-
appendSystemPromptOverride: () => [],
173-
noExtensions: true,
174-
noSkills: true,
175-
noPromptTemplates: true,
176-
});
177-
await resourceLoader.reload();
178-
179-
const { session } = await createAgentSession({
180-
cwd: workspacePath,
181-
tools: createConfinedTools(workspacePath, {
182-
headSha,
183-
followImports: toolConfig.followImports ?? true,
184-
}),
185-
customTools: [reportFindingTool],
186-
sessionManager: SessionManager.inMemory(),
187-
model,
188-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
189-
thinkingLevel: (toolConfig.thinkingLevel ?? 'medium') as any,
190-
resourceLoader,
191-
});
192-
193148
const fileList = changedFiles.map(f => {
194149
const ranges = changedLineRanges.get(f) ?? [];
195150
const rangeStr = ranges.length > 0
196151
? ` (changed lines: ${formatLineRanges(ranges)})`
197152
: '';
198153
return ` - ${f}${rangeStr}`;
199154
}).join('\n');
155+
200156
const isCustomPrompt = toolConfig.prompt !== null && toolConfig.prompt !== undefined;
201157
const initialPrompt = isCustomPrompt
202158
? `The following files were changed in this PR and require a security review:\n${fileList}\n\n` +
203-
'Investigate these files thoroughly using the read, grep, find, and ls tools. ' +
204-
'Follow imports and function calls as deeply as needed. ' +
159+
'Start by reading each changed file in full using the read tool on the actual file path (not the .layne/diff-only/ path). ' +
160+
'Then broaden your investigation beyond the changed files themselves: read the files they import from, ' +
161+
'use grep to find where changed functions are called from elsewhere in the codebase, ' +
162+
'and read any shared utilities, models, or middleware that the changed code interacts with. ' +
163+
'Understanding the full context around each change — not just the changed lines — reveals vulnerabilities that span multiple files. ' +
164+
'Follow imports and function calls as deeply as needed to trace data flows from source to sink. ' +
205165
'Use the changed line ranges above to anchor your findings accurately. ' +
206166
'For each confirmed finding, call report_finding.'
207167
: `The following files were changed in this PR and require a security review:\n${fileList}\n\n` +
@@ -211,33 +171,120 @@ export async function runPiAgent({
211171
'Scan each whole file but use the changed line ranges above to prioritize where to anchor your findings. ' +
212172
'For each confirmed finding, call report_finding. Report only high-confidence confirmed malicious patterns.';
213173

174+
const systemPrompt = toolConfig.prompt ?? SYSTEM_PROMPT;
214175
const timeoutMs = (toolConfig.timeoutMinutes ?? 3) * 60 * 1000;
215-
let timedOut = false;
216-
217-
const timer = setTimeout(() => {
218-
timedOut = true;
219-
session.abort().catch(() => {});
220-
}, timeoutMs);
221-
222-
try {
223-
await session.prompt(initialPrompt);
224-
} catch (err) {
225-
if (!timedOut) {
226-
console.error('[pi-agent] error during scan:', (err as Error).message ?? err);
176+
177+
// ---------------------------------------------------------------------------
178+
// Session runner — extracted so it can be retried on silent failure
179+
// ---------------------------------------------------------------------------
180+
const runSession = async (attempt: number): Promise<{ findings: PiAgentRawFinding[]; hadActivity: boolean; timedOut: boolean }> => {
181+
const sessionFindings: PiAgentRawFinding[] = [];
182+
let hadActivity = false;
183+
let timedOut = false;
184+
185+
const reportFindingTool: ToolDefinition = {
186+
name: 'report_finding',
187+
label: 'Report Finding',
188+
description: 'Report a confirmed security finding. Call once per finding.',
189+
parameters: ReportFindingParams,
190+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
191+
execute: async (_toolCallId: any, params: any, _signal: any, _onUpdate: any, _ctx: any) => {
192+
hadActivity = true;
193+
const finding = normalizeFinding(params as RawFindingInput);
194+
sessionFindings.push(finding);
195+
debug('pi-agent', `finding recorded: ${finding.severity.toUpperCase()} ${finding.file}:${finding.startLine} [${finding.ruleId}]`);
196+
return {
197+
content: [{ type: 'text' as const, text: 'Finding recorded.' }],
198+
details: {},
199+
};
200+
},
201+
};
202+
203+
const rawTools = createConfinedTools(workspacePath, {
204+
headSha,
205+
followImports: toolConfig.followImports ?? true,
206+
});
207+
208+
// Wrap each tool's execute to detect model activity (any tool call = session engaged)
209+
const tools = rawTools.map(tool => ({
210+
...tool,
211+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
212+
execute: async (...args: any[]) => {
213+
hadActivity = true;
214+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
215+
return (tool.execute as any)(...args);
216+
},
217+
}));
218+
219+
const resourceLoader = new DefaultResourceLoader({
220+
systemPromptOverride: () => systemPrompt,
221+
appendSystemPromptOverride: () => [],
222+
noExtensions: true,
223+
noSkills: true,
224+
noPromptTemplates: true,
225+
});
226+
await resourceLoader.reload();
227+
228+
const { session } = await createAgentSession({
229+
cwd: workspacePath,
230+
tools,
231+
customTools: [reportFindingTool],
232+
sessionManager: SessionManager.inMemory(),
233+
model,
234+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
235+
thinkingLevel: (toolConfig.thinkingLevel ?? 'medium') as any,
236+
resourceLoader,
237+
});
238+
239+
const timer = setTimeout(() => {
240+
timedOut = true;
241+
session.abort().catch(() => {});
242+
}, timeoutMs);
243+
244+
try {
245+
await session.prompt(initialPrompt);
246+
} catch (err) {
247+
if (!timedOut) {
248+
console.error(`[pi-agent] error during scan (attempt ${attempt}):`, (err as Error).message ?? err);
249+
}
250+
} finally {
251+
clearTimeout(timer);
252+
session.dispose();
227253
}
228-
} finally {
229-
clearTimeout(timer);
230-
session.dispose();
254+
255+
return { findings: sessionFindings, hadActivity, timedOut };
256+
};
257+
258+
// ---------------------------------------------------------------------------
259+
// First attempt
260+
// ---------------------------------------------------------------------------
261+
const first = await runSession(1);
262+
263+
if (first.timedOut) {
264+
console.warn(`[pi-agent] scan timed out after ${toolConfig.timeoutMinutes ?? 3}m — returning ${first.findings.length} partial finding(s)`);
231265
}
232266

233-
if (timedOut) {
234-
console.warn(`[pi-agent] scan timed out after ${toolConfig.timeoutMinutes ?? 3}m — returning ${findings.length} partial finding(s)`);
267+
// Retry once on two failure modes:
268+
// 1. Silent failure — model didn't call any tools at all
269+
// 2. Bad-evidence run — model produced findings but all at line 1, meaning it reported
270+
// from memory rather than reading the actual files (location validator will drop them all)
271+
const silentFailure = first.findings.length === 0 && !first.hadActivity && !first.timedOut;
272+
const badEvidenceRun = first.findings.length > 0 && first.findings.every(f => f.startLine === 1 && f.endLine === 1);
273+
274+
let result = first;
275+
if (!first.timedOut && (silentFailure || badEvidenceRun)) {
276+
console.warn(`[pi-agent] ${silentFailure ? 'session produced no output' : 'all findings at line 1 (bad evidence)'} — retrying once`);
277+
const retry = await runSession(2);
278+
if (retry.timedOut) {
279+
console.warn(`[pi-agent] retry timed out after ${toolConfig.timeoutMinutes ?? 3}m — returning ${retry.findings.length} partial finding(s)`);
280+
}
281+
result = retry;
235282
}
236283

237-
console.log(`[pi-agent] ${findings.length} finding(s)${timedOut ? ' (partial — timed out)' : ''}:`);
238-
for (const f of findings) {
284+
console.log(`[pi-agent] ${result.findings.length} finding(s):`);
285+
for (const f of result.findings) {
239286
console.log(`[pi-agent] ${f.severity.toUpperCase()} ${f.file}:${f.startLine}-${f.endLine} [${f.ruleId}] ${f.message}`);
240287
}
241288

242-
return findings;
289+
return result.findings;
243290
}

0 commit comments

Comments
 (0)