Skip to content

Commit 82c77b6

Browse files
committed
Agent mode phase 3: verification loop / completion criteria
The agent loop just stopped whenever the model ran out of tool calls, or hit the step limit — nothing ever checked that the work it did actually builds or passes tests. "Done" was whatever the model said it was. - detectProjectScripts() (already backing the Test/Lint/Format quick-action buttons) gained a fourth field, build, detected the same way as the others from package.json's scripts.build. - New opt-in settings (off by default — this runs commands automatically, which shouldn't surprise anyone who didn't turn it on): verificationEnabled, verificationCommands (explicit override, one per line in the new Settings section; falls back to the workspace's detected build+test scripts when unset), verificationMaxRetries (default 3, deliberately separate from agentMaxSteps so a persistently failing check can't loop forever even while the step budget still has headroom). - Hooked into the exact point where runCompletion's post-stream handler already distinguishes "model called more tools" from "model believes the turn is over" (previously only used to trigger TTS auto-read). On the "turn is over" branch, when verification is enabled, runVerification() runs the configured command(s) for real through the same run_command tool path everything else uses — deterministically, not left to the model to decide whether to check its own work — and only lets the turn actually end once they pass. A failure gets fed back as a tool-result message so the model can see and fix it, then loops through the existing continueAfterTools plumbing for another attempt. - New isVerification flag on ChatMessage (mirrors the existing pinned field: a UI-only affordance, never sent to a provider) lets the verification result render as a distinct pass/fail card instead of a generic tool-output box, plus a toolbar attempt counter next to the existing agent-step one. 2 existing detectProjectScripts test cases updated for the new build field, plus a new one covering all four scripts detected together.
1 parent 1df0b68 commit 82c77b6

8 files changed

Lines changed: 203 additions & 6 deletions

File tree

app/src/agent-tools.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,18 +208,24 @@ describe("agent-tools", () => {
208208
path.join(workspace, "package.json"),
209209
JSON.stringify({ scripts: { test: "vitest", build: "tsc" } })
210210
);
211-
expect(detectProjectScripts(workspace)).toEqual({ test: "npm test", lint: undefined, format: undefined });
211+
expect(detectProjectScripts(workspace)).toEqual({
212+
test: "npm test",
213+
lint: undefined,
214+
format: undefined,
215+
build: "npm run build",
216+
});
212217
});
213218

214-
it("reports test, lint, and format scripts together when all are present", () => {
219+
it("reports test, lint, format, and build scripts together when all are present", () => {
215220
fs.writeFileSync(
216221
path.join(workspace, "package.json"),
217-
JSON.stringify({ scripts: { test: "vitest", lint: "eslint .", format: "prettier --write ." } })
222+
JSON.stringify({ scripts: { test: "vitest", lint: "eslint .", format: "prettier --write .", build: "tsc" } })
218223
);
219224
expect(detectProjectScripts(workspace)).toEqual({
220225
test: "npm test",
221226
lint: "npm run lint",
222227
format: "npm run format",
228+
build: "npm run build",
223229
});
224230
});
225231
});

app/src/agent-tools.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1315,11 +1315,14 @@ export interface ProjectScripts {
13151315
test?: string;
13161316
lint?: string;
13171317
format?: string;
1318+
build?: string;
13181319
}
13191320

1320-
// Backs the Test/Lint/Format quick-action buttons — only npm-style
1321-
// package.json scripts are recognized, which covers the JS/TS projects this
1322-
// app's Agent mode is primarily used against.
1321+
// Backs the Test/Lint/Format quick-action buttons, and (build + test) the
1322+
// default command list for the verification loop when the user hasn't set
1323+
// an explicit override — only npm-style package.json scripts are
1324+
// recognized, which covers the JS/TS projects this app's Agent mode is
1325+
// primarily used against.
13231326
export function detectProjectScripts(workspaceRoot: string): ProjectScripts {
13241327
const pkgPath = resolveSafePath(workspaceRoot, "package.json");
13251328
let scripts: Record<string, string> = {};
@@ -1333,6 +1336,7 @@ export function detectProjectScripts(workspaceRoot: string): ProjectScripts {
13331336
test: scripts.test ? "npm test" : undefined,
13341337
lint: scripts.lint ? "npm run lint" : undefined,
13351338
format: scripts.format ? "npm run format" : undefined,
1339+
build: scripts.build ? "npm run build" : undefined,
13361340
};
13371341
}
13381342

app/src/providers/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ export interface ChatMessage {
4141
toolName?: string;
4242
// User-set bookmark, purely a UI affordance — never sent to a provider.
4343
pinned?: boolean;
44+
// Set on the synthetic message the verification loop appends — a UI
45+
// affordance like `pinned`, never sent to a provider.
46+
isVerification?: boolean;
4447
}
4548

4649
export interface ChatChunk {

app/src/settings-store.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,18 @@ export interface AppSettings {
110110
// a runaway process rather than act as a real resource quota system.
111111
sandboxMaxMemoryMB?: number;
112112
sandboxMaxCpuPercent?: number;
113+
// Verification loop (Agent mode): once a turn ends with the model
114+
// calling no more tools, optionally run these command(s) for real and
115+
// feed the result back before treating the turn as actually finished.
116+
// Off by default — running commands automatically after every turn
117+
// would surprise anyone who didn't explicitly turn it on.
118+
// verificationCommands unset falls back to the workspace's detected
119+
// build/test scripts (see detectProjectScripts) at the point of use.
120+
verificationEnabled?: boolean;
121+
verificationCommands?: string[];
122+
// Distinct from agentMaxSteps — bounds verify-fail-retry cycles
123+
// specifically, so a persistently failing check can't loop forever.
124+
verificationMaxRetries?: number;
113125
}
114126

115127
const DEFAULTS: AppSettings = {
@@ -131,6 +143,8 @@ const DEFAULTS: AppSettings = {
131143
llamaCppMaxCachedModels: 2,
132144
networkToolsEnabled: true,
133145
sandboxMaxMemoryMB: 2048,
146+
verificationEnabled: false,
147+
verificationMaxRetries: 3,
134148
};
135149

136150
function filePath(): string {

frontend/src/lib/translations.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ export interface Dictionary {
118118
undoLastEdit: string;
119119
nothingToUndo: string;
120120
terminalPanelToggle: string;
121+
verificationCard: string;
122+
verificationAttemptCounter: (attempt: number, max: number) => string;
121123
restoredFile: string;
122124
deletedNewFile: string;
123125
runTests: string;
@@ -364,6 +366,13 @@ export interface Dictionary {
364366
sandboxStatusBubblewrap: string;
365367
sandboxStatusSandboxExec: string;
366368
sandboxStatusNone: string;
369+
verificationSectionTitle: string;
370+
verificationSectionHint: string;
371+
verificationEnabledLabel: string;
372+
verificationCommandsLabel: string;
373+
verificationCommandsHint: string;
374+
verificationMaxRetriesLabel: string;
375+
verificationMaxRetriesHint: string;
367376
connectedAccountsTitle: string;
368377
connectedAccountsHint: string;
369378
accountGithubHint: string;
@@ -497,6 +506,8 @@ export const en: Dictionary = {
497506
undoLastEdit: "Undo last edit",
498507
nothingToUndo: "Nothing to undo.",
499508
terminalPanelToggle: "Terminal",
509+
verificationCard: "Verification",
510+
verificationAttemptCounter: (attempt, max) => `attempt ${attempt}/${max}`,
500511
restoredFile: "Restored previous content of",
501512
deletedNewFile: "Removed newly-created file",
502513
runTests: "Run tests",
@@ -751,6 +762,13 @@ export const en: Dictionary = {
751762
sandboxStatusBubblewrap: "Linux, via bubblewrap — commands run confined to the workspace, with network access denied unless a command explicitly requests it.",
752763
sandboxStatusSandboxExec: "macOS, via sandbox-exec — commands run confined to the workspace, with network access denied unless a command explicitly requests it.",
753764
sandboxStatusNone: "Not available on this platform — only the command blocklist and the resource limits below apply. On Linux, installing bubblewrap (bwrap) enables real filesystem and network containment.",
765+
verificationSectionTitle: "Verification",
766+
verificationSectionHint: "After the agent stops calling tools, optionally run real commands and feed the result back before treating the turn as done — off by default, since this runs commands automatically.",
767+
verificationEnabledLabel: "Verify before finishing a turn",
768+
verificationCommandsLabel: "Commands to run",
769+
verificationCommandsHint: "One per line. Leave blank to use the workspace's detected build/test scripts automatically.",
770+
verificationMaxRetriesLabel: "Max retries",
771+
verificationMaxRetriesHint: "How many times the agent can try to fix a failing check before it stops and asks you.",
754772
connectedAccountsTitle: "Connected accounts",
755773
connectedAccountsHint: "Link developer services for repository analysis and access to private or gated models. Tokens stay encrypted locally when your OS credential store is available.",
756774
accountGithubHint: "Connect repositories for AI analysis and developer workflows.",
@@ -885,6 +903,8 @@ export const tr: Dictionary = {
885903
undoLastEdit: "Son düzenlemeyi geri al",
886904
nothingToUndo: "Geri alınacak bir şey yok.",
887905
terminalPanelToggle: "Terminal",
906+
verificationCard: "Doğrulama",
907+
verificationAttemptCounter: (attempt, max) => `${attempt}/${max}. deneme`,
888908
restoredFile: "Önceki içerik geri yüklendi:",
889909
deletedNewFile: "Yeni oluşturulan dosya kaldırıldı:",
890910
runTests: "Testleri çalıştır",
@@ -1139,6 +1159,13 @@ export const tr: Dictionary = {
11391159
sandboxStatusBubblewrap: "Linux, bubblewrap aracılığıyla — komutlar çalışma alanıyla sınırlı çalışır, bir komut açıkça talep etmedikçe ağ erişimi reddedilir.",
11401160
sandboxStatusSandboxExec: "macOS, sandbox-exec aracılığıyla — komutlar çalışma alanıyla sınırlı çalışır, bir komut açıkça talep etmedikçe ağ erişimi reddedilir.",
11411161
sandboxStatusNone: "Bu platformda mevcut değil — yalnızca komut engelleme listesi ve aşağıdaki kaynak sınırları uygulanır. Linux'ta bubblewrap (bwrap) kurmak gerçek dosya sistemi ve ağ sınırlaması sağlar.",
1162+
verificationSectionTitle: "Doğrulama",
1163+
verificationSectionHint: "Ajan araç çağırmayı bıraktıktan sonra, isteğe bağlı olarak gerçek komutlar çalıştırıp sonucu geri bildirerek turu bitmiş saymadan önce kontrol edin — komutları otomatik çalıştırdığı için varsayılan olarak kapalıdır.",
1164+
verificationEnabledLabel: "Turu bitirmeden önce doğrula",
1165+
verificationCommandsLabel: "Çalıştırılacak komutlar",
1166+
verificationCommandsHint: "Her satıra bir tane. Çalışma alanının algılanan build/test betiklerini otomatik kullanmak için boş bırakın.",
1167+
verificationMaxRetriesLabel: "Maksimum yeniden deneme",
1168+
verificationMaxRetriesHint: "Ajanın başarısız bir kontrolü durup sizden yardım istemeden önce kaç kez düzeltmeyi deneyebileceği.",
11421169
connectedAccountsTitle: "Bağlı hesaplar",
11431170
connectedAccountsHint: "Depo analizi ve özel veya kısıtlı modellere erişim için geliştirici hizmetlerini bağlayın. İşletim sistemi kimlik bilgisi deposu kullanılabildiğinde jetonlar yerel olarak şifreli kalır.",
11441171
accountGithubHint: "Yapay zeka analizi ve geliştirici iş akışları için depoları bağlayın.",

frontend/src/pages/Chat.tsx

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,24 @@ const MessageBubble = memo(function MessageBubble({
250250
const { t } = useI18n();
251251

252252
if (m.role === "tool") {
253+
if (m.isVerification) {
254+
const passed = m.content.startsWith("Verification passed");
255+
return (
256+
<div className="flex flex-col items-start">
257+
<div
258+
className={cn(
259+
"max-w-[85%] rounded-lg border px-3 py-2 text-sm",
260+
passed ? "border-primary/30 bg-primary/5" : "border-destructive/40 bg-destructive/5"
261+
)}
262+
>
263+
<div className={cn("mb-1 flex items-center gap-1.5 font-medium", passed ? "text-primary" : "text-destructive")}>
264+
{passed ? <Check className="size-3.5" /> : <AlertTriangle className="size-3.5" />} {t.verificationCard}
265+
</div>
266+
<pre className="max-h-48 overflow-auto whitespace-pre-wrap font-mono text-xs text-muted-foreground">{m.content}</pre>
267+
</div>
268+
</div>
269+
);
270+
}
253271
// Tool failures get a visually distinct card — an agent run that hit
254272
// an error mid-way should be scannable at a glance, not require
255273
// reading every result body to find where things went wrong.
@@ -454,6 +472,10 @@ export default function Chat() {
454472
const [pendingToolCalls, setPendingToolCalls] = useState<ToolCall[]>([]);
455473
const [pendingVariablePreset, setPendingVariablePreset] = useState<PromptPreset | null>(null);
456474
const [agentStepCount, setAgentStepCount] = useState(0);
475+
// Separate from agentStepCount — bounds verify-fail-retry cycles
476+
// specifically, so a persistently failing check can't loop forever even
477+
// while agentMaxSteps still has headroom left.
478+
const [verificationAttempt, setVerificationAttempt] = useState(0);
457479
const [autoApprovedTools, setAutoApprovedTools] = useState<Set<string>>(new Set());
458480
const [planSteps, setPlanSteps] = useState<PlanStep[]>([]);
459481
const [contextSummary, setContextSummary] = useState<string | null>(null);
@@ -550,6 +572,7 @@ export default function Chat() {
550572
setAgentWorkspace(session.agentWorkspace ?? null);
551573
setPendingToolCalls([]);
552574
setAgentStepCount(0);
575+
setVerificationAttempt(0);
553576
setPlanSteps(session.planSteps ?? []);
554577
setContextSummary(session.contextSummary ?? null);
555578
setContextSummaryThroughIndex(session.contextSummaryThroughIndex ?? 0);
@@ -1073,6 +1096,18 @@ export default function Chat() {
10731096
for (const call of otherCalls) {
10741097
if (call.name !== "request_checkpoint" && autoApprovedTools.has(call.name)) respondToToolCall(call, true);
10751098
}
1099+
} else if (
1100+
agentMode &&
1101+
agentWorkspace &&
1102+
settings?.verificationEnabled &&
1103+
!result.error &&
1104+
last?.role === "assistant" &&
1105+
verificationAttempt < (settings.verificationMaxRetries ?? 3)
1106+
) {
1107+
// Fire-and-forget: this updater must stay synchronous, the
1108+
// actual command runs (and any setState that follows) happen
1109+
// later once the awaited calls inside resolve.
1110+
void runVerification(finalMessages);
10761111
} else if (settings?.ttsAutoRead && !result.error && last?.role === "assistant" && last.content) {
10771112
const lastIndex = finalMessages.length - 1;
10781113
setSpeakingIndex(lastIndex);
@@ -1090,6 +1125,59 @@ export default function Chat() {
10901125
});
10911126
}
10921127

1128+
// Runs once a turn ends with the model calling no more tools — i.e. it
1129+
// believes it's done. Deterministic and app-driven rather than asking
1130+
// the model whether to check its own work: runs the configured
1131+
// command(s) for real via the same run_command path everything else
1132+
// uses (inheriting whatever sandboxing/resource limits are configured),
1133+
// and only lets the turn actually end once they pass.
1134+
async function runVerification(finalMessages: ChatMessage[]) {
1135+
if (!agentWorkspace) return;
1136+
const commands = settings?.verificationCommands?.length
1137+
? settings.verificationCommands
1138+
: [projectScripts.build, projectScripts.test].filter((c): c is string => Boolean(c));
1139+
if (commands.length === 0) return;
1140+
1141+
const results: { command: string; passed: boolean; output: string }[] = [];
1142+
for (const command of commands) {
1143+
const res = await window.api.agent.executeTool(agentWorkspace, "run_command", { command });
1144+
const output = typeof res.result === "string" ? res.result : (res.error ?? "");
1145+
results.push({ command, passed: !res.error && output.includes("Exit code: 0"), output });
1146+
}
1147+
const allPassed = results.every((r) => r.passed);
1148+
const checklist = results.map((r) => `${r.passed ? "✅" : "❌"} ${r.command}`).join("\n");
1149+
const failureDetail = results
1150+
.filter((r) => !r.passed)
1151+
.map((r) => `--- ${r.command} ---\n${r.output}`)
1152+
.join("\n\n");
1153+
const verificationMessage: ChatMessage = {
1154+
role: "tool",
1155+
content: allPassed
1156+
? `Verification passed:\n${checklist}`
1157+
: `Verification failed:\n${checklist}\n\n${failureDetail}`,
1158+
isVerification: true,
1159+
};
1160+
const nextMessages = [...finalMessages, verificationMessage];
1161+
setMessages(nextMessages);
1162+
if (sessionId) window.api.sessions.update(sessionId, { messages: nextMessages });
1163+
1164+
if (allPassed) return; // the turn really is done
1165+
1166+
const maxRetries = settings?.verificationMaxRetries ?? 3;
1167+
if (verificationAttempt + 1 >= maxRetries) {
1168+
setMessages((m) => [
1169+
...m,
1170+
{
1171+
role: "assistant",
1172+
content: `⚠️ Verification kept failing after ${maxRetries} attempt${maxRetries === 1 ? "" : "s"}. Send another message to try again.`,
1173+
},
1174+
]);
1175+
return;
1176+
}
1177+
setVerificationAttempt((a) => a + 1);
1178+
continueAfterTools(nextMessages);
1179+
}
1180+
10931181
// Runs after every tool call from one assistant turn has been approved or
10941182
// denied — feeds the tool results back to the model so it can continue
10951183
// (e.g. read a file, then act on what it found) without the user having
@@ -1261,6 +1349,7 @@ export default function Chat() {
12611349
setRagFolders([]);
12621350
setImageAttachments([]);
12631351
setAgentStepCount(0);
1352+
setVerificationAttempt(0);
12641353
setPlanSteps([]);
12651354
window.api.sessions.update(sessionId, { planSteps: [] });
12661355
await runCompletion(history, baseMessages, { isFirstMessage, titleSource });
@@ -1590,6 +1679,11 @@ export default function Chat() {
15901679
{t.agentStep} {agentStepCount}/{agentMaxSteps}
15911680
</span>
15921681
)}
1682+
{verificationAttempt > 0 && (
1683+
<span className="text-xs text-muted-foreground">
1684+
{t.verificationCard} {t.verificationAttemptCounter(verificationAttempt, settings?.verificationMaxRetries ?? 3)}
1685+
</span>
1686+
)}
15931687
{agentMode && agentWorkspace && (
15941688
<Button
15951689
size="sm"

0 commit comments

Comments
 (0)