@pentoshi/clai 1.2.7 → 1.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/runner.js +867 -854
- package/dist/agent/runner.js.map +1 -1
- package/dist/commands/update.js +1 -1
- package/dist/repl.js +34 -30
- package/dist/repl.js.map +1 -1
- package/package.json +2 -1
package/dist/agent/runner.js
CHANGED
|
@@ -1104,930 +1104,943 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1104
1104
|
emit({ type: "turn-end", finalAnswer: answer, steps });
|
|
1105
1105
|
return answer;
|
|
1106
1106
|
};
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
userMessage.images = options.images;
|
|
1155
|
-
}
|
|
1156
|
-
const messages = [
|
|
1157
|
-
{ role: "system", content: fullSystemPrompt },
|
|
1158
|
-
...(options.history ?? []),
|
|
1159
|
-
userMessage,
|
|
1160
|
-
];
|
|
1161
|
-
const recoveryUserMessage = (content) => {
|
|
1162
|
-
const message = { role: "user", content };
|
|
1107
|
+
try {
|
|
1108
|
+
emit({ type: "turn-start", prompt });
|
|
1109
|
+
const config = getConfig();
|
|
1110
|
+
const maxSteps = options.maxSteps ?? 70;
|
|
1111
|
+
const confirmPort = options.confirm ?? inquirerConfirmPort;
|
|
1112
|
+
const projectContext = await loadProjectContext();
|
|
1113
|
+
const toolNames = availableToolNames();
|
|
1114
|
+
// Build / scaffold / continuation turns must NEVER be diverted into a
|
|
1115
|
+
// web.search for "current info". The /implement directive ("Execute it
|
|
1116
|
+
// now…") and prompts like "create a react app" contain words such as
|
|
1117
|
+
// "now"/"latest" that trip the volatile-info regex; without this guard the
|
|
1118
|
+
// agent burns its turn searching the date instead of writing files.
|
|
1119
|
+
const buildLikeTurn = looksLikeBuildTask(prompt, options.history);
|
|
1120
|
+
const freshWebSearchRequired = !buildLikeTurn &&
|
|
1121
|
+
toolNames.includes("web.search") &&
|
|
1122
|
+
requiresFreshWebSearch(prompt);
|
|
1123
|
+
const systemSections = [renderAgentSystemPrompt(toolNames.join(", "))];
|
|
1124
|
+
if (projectContext) {
|
|
1125
|
+
systemSections.push(`Project context from .clai/context.md:\n${projectContext}`);
|
|
1126
|
+
}
|
|
1127
|
+
if (freshWebSearchRequired) {
|
|
1128
|
+
systemSections.push(freshnessGuardMessage());
|
|
1129
|
+
}
|
|
1130
|
+
let provider = options.provider ?? config.defaultProvider;
|
|
1131
|
+
await ensureProviderConfigured(provider);
|
|
1132
|
+
let model = options.model ?? config.defaultModel;
|
|
1133
|
+
let lastAnswer = "";
|
|
1134
|
+
const session = options.session ?? createSessionPolicy();
|
|
1135
|
+
// ── Active plan context ────────────────────────────────────────────
|
|
1136
|
+
// If this session already has a plan, inject it so the model keeps it in
|
|
1137
|
+
// context. When the user has approved it (via /implement) we instruct the
|
|
1138
|
+
// agent to execute task by task; otherwise the agent should refine/wait.
|
|
1139
|
+
const activePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1140
|
+
if (activePlan) {
|
|
1141
|
+
systemSections.push(planContextMessage(activePlan, session.planApproved.value));
|
|
1142
|
+
}
|
|
1143
|
+
// For build/scaffold turns with no active plan yet, inject an explicit
|
|
1144
|
+
// workflow so the agent does NOT rush to write files in one shot. It must
|
|
1145
|
+
// explore the directory, read the relevant existing files to understand
|
|
1146
|
+
// what's already there, create a comprehensive multi-task plan, then
|
|
1147
|
+
// implement task by task until the goal is met. This mirrors how a careful
|
|
1148
|
+
// coding agent (Claude Code) operates.
|
|
1149
|
+
if (buildLikeTurn && !activePlan) {
|
|
1150
|
+
systemSections.push(buildWorkflowDirective());
|
|
1151
|
+
}
|
|
1152
|
+
const fullSystemPrompt = systemSections.join("\n\n");
|
|
1153
|
+
const userMessage = { role: "user", content: prompt };
|
|
1163
1154
|
if (options.images && options.images.length > 0) {
|
|
1164
|
-
|
|
1165
|
-
// latest user turn. Keep the image attached on recovery nudges so a
|
|
1166
|
-
// thinking-only retry does not degrade into OCR/tool guessing.
|
|
1167
|
-
message.images = options.images;
|
|
1155
|
+
userMessage.images = options.images;
|
|
1168
1156
|
}
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
//
|
|
1238
|
-
//
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
stepBudget =
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
// `step` is the productive-step index (used for display + audit). It only
|
|
1254
|
-
// advances when the previous iteration actually executed a tool.
|
|
1255
|
-
step = productiveSteps;
|
|
1256
|
-
if (productiveSteps >= stepBudget)
|
|
1257
|
-
break;
|
|
1258
|
-
options.signal?.throwIfAborted();
|
|
1259
|
-
// `call` and `assistantText` are shared by both paths below: a fresh
|
|
1260
|
-
// model round-trip, or draining a previously-queued tool call.
|
|
1261
|
-
let call;
|
|
1262
|
-
let assistantText;
|
|
1263
|
-
let recoveredFromBareJson = false;
|
|
1264
|
-
if (pendingCalls.length > 0) {
|
|
1265
|
-
// Drain the next queued call from the previous model message — no new
|
|
1266
|
-
// round-trip. The assistant message and any prose were already shown
|
|
1267
|
-
// when the batch was parsed.
|
|
1268
|
-
call = pendingCalls.shift();
|
|
1269
|
-
assistantText = { visible: "", thinkContent: "", hasThinking: false };
|
|
1270
|
-
const batchStatus = ` ↳ continuing batch (${pendingCalls.length} more queued)\n`;
|
|
1271
|
-
writeStatus(batchStatus, chalk.dim(batchStatus));
|
|
1157
|
+
const messages = [
|
|
1158
|
+
{ role: "system", content: fullSystemPrompt },
|
|
1159
|
+
...(options.history ?? []),
|
|
1160
|
+
userMessage,
|
|
1161
|
+
];
|
|
1162
|
+
const recoveryUserMessage = (content) => {
|
|
1163
|
+
const message = { role: "user", content };
|
|
1164
|
+
if (options.images && options.images.length > 0) {
|
|
1165
|
+
// Some OpenAI-compatible gateways/models attend most strongly to the
|
|
1166
|
+
// latest user turn. Keep the image attached on recovery nudges so a
|
|
1167
|
+
// thinking-only retry does not degrade into OCR/tool guessing.
|
|
1168
|
+
message.images = options.images;
|
|
1169
|
+
}
|
|
1170
|
+
return message;
|
|
1171
|
+
};
|
|
1172
|
+
// Track recent tool calls to detect models stuck in a loop calling the
|
|
1173
|
+
// same tool with the same arguments over and over (e.g. pentest.recon
|
|
1174
|
+
// called 3× on the same target without summarizing).
|
|
1175
|
+
const loopGuard = new LoopGuard();
|
|
1176
|
+
// Track consecutive thinking-only responses so we can nudge the model
|
|
1177
|
+
// to actually act instead of silently returning an empty answer.
|
|
1178
|
+
let emptyVisibleRetries = 0;
|
|
1179
|
+
// Track tool calls truncated by the token limit so we can ask the model
|
|
1180
|
+
// to retry in smaller pieces instead of leaking broken JSON as an answer.
|
|
1181
|
+
let truncatedToolRetries = 0;
|
|
1182
|
+
// Track bare-args JSON tool calls (missing the {name,args} wrapper / fence)
|
|
1183
|
+
// so we can nudge the model to re-emit a proper fenced call a few times
|
|
1184
|
+
// before giving up, instead of leaking the JSON as a final answer.
|
|
1185
|
+
let bareToolJsonRetries = 0;
|
|
1186
|
+
// Track a ```tool fence that is present but whose JSON could not be parsed
|
|
1187
|
+
// (e.g. malformed extra/missing braces that are NOT simple truncation). We
|
|
1188
|
+
// retry instead of leaking the raw block as the final answer.
|
|
1189
|
+
let malformedFenceRetries = 0;
|
|
1190
|
+
// For volatile live-info prompts, make one corrective pass if a model
|
|
1191
|
+
// ignores the freshness guard and tries to answer from stale memory.
|
|
1192
|
+
let sawFreshWebSearch = false;
|
|
1193
|
+
let freshnessRetryUsed = false;
|
|
1194
|
+
// Guard against a model that declares an approved plan "complete" while
|
|
1195
|
+
// tasks are still pending and it never ran the work. We nudge it back to
|
|
1196
|
+
// executing the next task a bounded number of times before giving up.
|
|
1197
|
+
let prematureCompletionRetries = 0;
|
|
1198
|
+
// Guard against a model that NARRATES intent ("let me explore the
|
|
1199
|
+
// directory…") but emits no tool call, so nothing runs and the turn ends
|
|
1200
|
+
// prematurely. On build/scaffold/plan turns where nothing has executed yet,
|
|
1201
|
+
// we nudge it to emit a real tool call instead of accepting the narration
|
|
1202
|
+
// as a final answer. Bounded so a model that truly can't emit the format
|
|
1203
|
+
// still terminates.
|
|
1204
|
+
let actionIntentRetries = 0;
|
|
1205
|
+
// ── Multi-tool execution queue ─────────────────────────────────────
|
|
1206
|
+
// Models naturally emit several tool calls in one message — e.g. the
|
|
1207
|
+
// plan-execution rhythm "task.update in_progress → do the work →
|
|
1208
|
+
// task.update done", or a batch of fs.write calls. Rather than running
|
|
1209
|
+
// only the first and discarding the rest (which made models believe work
|
|
1210
|
+
// ran when it didn't, and broke plan execution), we parse ALL calls in a
|
|
1211
|
+
// message, run the first this iteration, and queue the rest here to run on
|
|
1212
|
+
// subsequent iterations WITHOUT another model round-trip. The queue is
|
|
1213
|
+
// cleared whenever a call fails, is blocked, or needs the model to react,
|
|
1214
|
+
// so the model always sees errors and stays in control.
|
|
1215
|
+
let pendingCalls = [];
|
|
1216
|
+
// ── Step budget ───────────────────────────────────────────────────
|
|
1217
|
+
// The budget governs how many *productive* steps (a tool execution or a
|
|
1218
|
+
// final answer) the agent may take. Recovery iterations — nudging a model
|
|
1219
|
+
// that only produced thinking, asking it to re-emit a malformed tool call,
|
|
1220
|
+
// a freshness retry, or a loop-guard summary — do NOT consume this budget;
|
|
1221
|
+
// they get a separate hard ceiling so a wedged model can't spin forever.
|
|
1222
|
+
//
|
|
1223
|
+
// Complexity is a coarse signal from prompt length, but short follow-up
|
|
1224
|
+
// prompts ("do it", "build fully on your own", "app is not complete") in
|
|
1225
|
+
// the middle of a multi-file build must NOT be capped like a one-shot
|
|
1226
|
+
// lookup — that was the reason a React scaffold stopped half-built after
|
|
1227
|
+
// 10 steps. We bump the budget when the prompt (or recent history) looks
|
|
1228
|
+
// like a build/scaffold or a continuation of one.
|
|
1229
|
+
const analysis = analyzeTask(prompt);
|
|
1230
|
+
const hasHistory = (options.history?.length ?? 0) > 0;
|
|
1231
|
+
const buildLike = buildLikeTurn;
|
|
1232
|
+
let stepBudget = analysis.complexity === "simple"
|
|
1233
|
+
? 20
|
|
1234
|
+
: analysis.complexity === "standard"
|
|
1235
|
+
? 40
|
|
1236
|
+
: maxSteps;
|
|
1237
|
+
if (buildLike) {
|
|
1238
|
+
// Scaffolding / multi-file work needs room: many file writes plus a
|
|
1239
|
+
// verify/build step. Continuation prompts ("do it") inherit this too.
|
|
1240
|
+
stepBudget = Math.max(stepBudget, maxSteps);
|
|
1272
1241
|
}
|
|
1273
|
-
else {
|
|
1274
|
-
//
|
|
1275
|
-
//
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1242
|
+
else if (hasHistory) {
|
|
1243
|
+
// A follow-up to an ongoing task should never be capped tighter than a
|
|
1244
|
+
// standard one-shot, even if it's only a couple of words.
|
|
1245
|
+
stepBudget = Math.max(stepBudget, 40);
|
|
1246
|
+
}
|
|
1247
|
+
// Hard ceiling on total loop iterations (productive + recovery) so a model
|
|
1248
|
+
// stuck emitting only thinking or malformed calls can't loop indefinitely.
|
|
1249
|
+
const maxIterations = stepBudget * 3;
|
|
1250
|
+
let productiveSteps = 0;
|
|
1251
|
+
let step = -1;
|
|
1252
|
+
let nextToolEventId = 0;
|
|
1253
|
+
for (let iteration = 0; iteration < maxIterations; iteration += 1) {
|
|
1254
|
+
// `step` is the productive-step index (used for display + audit). It only
|
|
1255
|
+
// advances when the previous iteration actually executed a tool.
|
|
1256
|
+
step = productiveSteps;
|
|
1257
|
+
if (productiveSteps >= stepBudget)
|
|
1258
|
+
break;
|
|
1259
|
+
options.signal?.throwIfAborted();
|
|
1260
|
+
// `call` and `assistantText` are shared by both paths below: a fresh
|
|
1261
|
+
// model round-trip, or draining a previously-queued tool call.
|
|
1262
|
+
let call;
|
|
1263
|
+
let assistantText;
|
|
1264
|
+
let recoveredFromBareJson = false;
|
|
1265
|
+
if (pendingCalls.length > 0) {
|
|
1266
|
+
// Drain the next queued call from the previous model message — no new
|
|
1267
|
+
// round-trip. The assistant message and any prose were already shown
|
|
1268
|
+
// when the batch was parsed.
|
|
1269
|
+
call = pendingCalls.shift();
|
|
1270
|
+
assistantText = { visible: "", thinkContent: "", hasThinking: false };
|
|
1271
|
+
const batchStatus = ` ↳ continuing batch (${pendingCalls.length} more queued)\n`;
|
|
1272
|
+
writeStatus(batchStatus, chalk.dim(batchStatus));
|
|
1284
1273
|
}
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
emit({ type: "
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
// them headroom so the visible answer / tool call isn't
|
|
1306
|
-
// truncated to silence. The non-thinking budget must be large
|
|
1307
|
-
// enough for a single-file fs.write / multi-file fs.writeMany
|
|
1308
|
-
// payload — a truncated tool-call JSON fails to parse and leaks a
|
|
1309
|
-
// broken (and syntactically invalid) file. 8k was too small for a
|
|
1310
|
-
// full component, so allow more room for the visible tool call.
|
|
1311
|
-
maxTokens: config.thinking?.enabled ? 16_384 : 12_288,
|
|
1312
|
-
signal: options.signal,
|
|
1313
|
-
thinking: config.thinking,
|
|
1314
|
-
}, (token) => {
|
|
1315
|
-
deltaParser?.push(token);
|
|
1316
|
-
// Heuristic: <think>… markers and reasoning_content tokens flow
|
|
1317
|
-
// through onToken. Surface activity in the spinner so the screen
|
|
1318
|
-
// is never empty for minutes.
|
|
1319
|
-
if (!sawReasoning && /<think/i.test(token)) {
|
|
1320
|
-
sawReasoning = true;
|
|
1321
|
-
inThinking = true;
|
|
1322
|
-
spinner.setLabel("thinking");
|
|
1323
|
-
if (!writesDirectly)
|
|
1274
|
+
else {
|
|
1275
|
+
// Buffer LLM output so tool JSON and hidden thinking are not printed raw.
|
|
1276
|
+
// Status messages (rate-limit retries, fallback hints) still surface live.
|
|
1277
|
+
// A spinner gives the user feedback during long thinking phases on
|
|
1278
|
+
// models like glm-5.1 / deepseek-v4-flash that stream reasoning first.
|
|
1279
|
+
const streamLabel = step === 0 ? "waiting for model" : `step ${step + 1}`;
|
|
1280
|
+
const spinner = writesDirectly
|
|
1281
|
+
? startThinkingSpinner(streamLabel, options.signal)
|
|
1282
|
+
: noopSpinner;
|
|
1283
|
+
if (!writesDirectly) {
|
|
1284
|
+
emit({ type: "status", text: streamLabel });
|
|
1285
|
+
}
|
|
1286
|
+
let sawReasoning = false;
|
|
1287
|
+
let inThinking = false;
|
|
1288
|
+
let emittedThinkingStatus = false;
|
|
1289
|
+
const deltaParser = writesDirectly
|
|
1290
|
+
? undefined
|
|
1291
|
+
: createThinkingStreamParser((text) => emit({ type: "assistant-delta", text }), (text) => {
|
|
1292
|
+
if (!emittedThinkingStatus) {
|
|
1293
|
+
emittedThinkingStatus = true;
|
|
1324
1294
|
emit({ type: "status", text: "thinking" });
|
|
1325
|
-
}
|
|
1326
|
-
if (/<\/think>/i.test(token)) {
|
|
1327
|
-
inThinking = false;
|
|
1328
|
-
}
|
|
1329
|
-
// Only push reasoning tokens to the spinner preview. Visible
|
|
1330
|
-
// answer / tool-call tokens should NOT go through the dim
|
|
1331
|
-
// spinner preview — doing so makes the final answer appear
|
|
1332
|
-
// "diluted" in light font when the spinner's last render
|
|
1333
|
-
// briefly shows the answer text before being erased.
|
|
1334
|
-
if (inThinking) {
|
|
1335
|
-
const cleaned = token.replace(/<\/?think[^>]*>/gi, "");
|
|
1336
|
-
if (cleaned) {
|
|
1337
|
-
spinner.pushPreview(cleaned);
|
|
1338
|
-
const approx = cleaned.split(/\s+/).filter(Boolean).length;
|
|
1339
|
-
if (approx > 0)
|
|
1340
|
-
spinner.bumpReasoning(approx);
|
|
1341
1295
|
}
|
|
1342
|
-
|
|
1343
|
-
|
|
1296
|
+
emit({ type: "thinking-delta", text });
|
|
1297
|
+
});
|
|
1298
|
+
let completion;
|
|
1299
|
+
try {
|
|
1300
|
+
completion = await streamWithProvider({
|
|
1301
|
+
provider,
|
|
1302
|
+
model,
|
|
1303
|
+
messages,
|
|
1304
|
+
temperature: 0.2,
|
|
1305
|
+
// Reasoning models can spend a lot on hidden thinking; give
|
|
1306
|
+
// them headroom so the visible answer / tool call isn't
|
|
1307
|
+
// truncated to silence. The non-thinking budget must be large
|
|
1308
|
+
// enough for a single-file fs.write / multi-file fs.writeMany
|
|
1309
|
+
// payload — a truncated tool-call JSON fails to parse and leaks a
|
|
1310
|
+
// broken (and syntactically invalid) file. 8k was too small for a
|
|
1311
|
+
// full component, so allow more room for the visible tool call.
|
|
1312
|
+
maxTokens: config.thinking?.enabled ? 16_384 : 12_288,
|
|
1313
|
+
signal: options.signal,
|
|
1314
|
+
thinking: config.thinking,
|
|
1315
|
+
}, (token) => {
|
|
1316
|
+
deltaParser?.push(token);
|
|
1317
|
+
// Heuristic: <think>… markers and reasoning_content tokens flow
|
|
1318
|
+
// through onToken. Surface activity in the spinner so the screen
|
|
1319
|
+
// is never empty for minutes.
|
|
1320
|
+
if (!sawReasoning && /<think/i.test(token)) {
|
|
1321
|
+
sawReasoning = true;
|
|
1322
|
+
inThinking = true;
|
|
1323
|
+
spinner.setLabel("thinking");
|
|
1324
|
+
if (!writesDirectly)
|
|
1325
|
+
emit({ type: "status", text: "thinking" });
|
|
1326
|
+
}
|
|
1327
|
+
if (/<\/think>/i.test(token)) {
|
|
1328
|
+
inThinking = false;
|
|
1329
|
+
}
|
|
1330
|
+
// Only push reasoning tokens to the spinner preview. Visible
|
|
1331
|
+
// answer / tool-call tokens should NOT go through the dim
|
|
1332
|
+
// spinner preview — doing so makes the final answer appear
|
|
1333
|
+
// "diluted" in light font when the spinner's last render
|
|
1334
|
+
// briefly shows the answer text before being erased.
|
|
1335
|
+
if (inThinking) {
|
|
1336
|
+
const cleaned = token.replace(/<\/?think[^>]*>/gi, "");
|
|
1337
|
+
if (cleaned) {
|
|
1338
|
+
spinner.pushPreview(cleaned);
|
|
1339
|
+
const approx = cleaned.split(/\s+/).filter(Boolean).length;
|
|
1340
|
+
if (approx > 0)
|
|
1341
|
+
spinner.bumpReasoning(approx);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}, (status) => {
|
|
1345
|
+
spinner.stop();
|
|
1346
|
+
writeStatus(status, chalk.dim(status));
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
finally {
|
|
1350
|
+
// Always clear the spinner — abort, network error, or success.
|
|
1344
1351
|
spinner.stop();
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
assistantText = assistantTextResult;
|
|
1357
|
-
// Try visible text first, then thinking content — some models (e.g. glm-5.1)
|
|
1358
|
-
// wrap tool calls inside considering tags, so stripThinking removes them
|
|
1359
|
-
// into thinkContent and visible becomes empty. Recovering from thinkContent
|
|
1360
|
-
// prevents an endless nudge loop where the model keeps hiding the call.
|
|
1361
|
-
call = parseToolCall(assistantText.visible, {
|
|
1362
|
-
strict: getConfig().parserStrict,
|
|
1363
|
-
});
|
|
1364
|
-
if (!call && assistantText.hasThinking) {
|
|
1365
|
-
call = parseToolCall(assistantText.thinkContent, {
|
|
1352
|
+
}
|
|
1353
|
+
provider = completion.provider;
|
|
1354
|
+
model = completion.model;
|
|
1355
|
+
deltaParser?.finish();
|
|
1356
|
+
const assistantTextResult = rememberThinkingFromText(completion.text);
|
|
1357
|
+
assistantText = assistantTextResult;
|
|
1358
|
+
// Try visible text first, then thinking content — some models (e.g. glm-5.1)
|
|
1359
|
+
// wrap tool calls inside considering tags, so stripThinking removes them
|
|
1360
|
+
// into thinkContent and visible becomes empty. Recovering from thinkContent
|
|
1361
|
+
// prevents an endless nudge loop where the model keeps hiding the call.
|
|
1362
|
+
call = parseToolCall(assistantText.visible, {
|
|
1366
1363
|
strict: getConfig().parserStrict,
|
|
1367
1364
|
});
|
|
1368
|
-
if (call) {
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
}
|
|
1372
|
-
// ── Thinking-only recovery ────────────────────────────────────────
|
|
1373
|
-
// Some models (eg gpt-oss-20b on NVIDIA NIM) occasionally spend their
|
|
1374
|
-
// entire budget on hidden <think> reasoning and emit no visible text
|
|
1375
|
-
// or tool call. Without this guard the agent silently returns an empty
|
|
1376
|
-
// answer and the user has to re-submit the same prompt.
|
|
1377
|
-
if (!assistantText.visible.trim() && !call && assistantText.hasThinking) {
|
|
1378
|
-
emptyVisibleRetries += 1;
|
|
1379
|
-
if (emptyVisibleRetries <= 2) {
|
|
1380
|
-
writeThinkingBlock(assistantText.thinkContent);
|
|
1381
|
-
writeNotice("warn", "model produced only thinking — nudging it to take action", chalk.yellow(" ⚠ model produced only thinking — nudging it to take action\n"));
|
|
1382
|
-
messages.push({
|
|
1383
|
-
role: "assistant",
|
|
1384
|
-
content: collapseRepeatedText(completion.text),
|
|
1365
|
+
if (!call && assistantText.hasThinking) {
|
|
1366
|
+
call = parseToolCall(assistantText.thinkContent, {
|
|
1367
|
+
strict: getConfig().parserStrict,
|
|
1385
1368
|
});
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
"You MUST call plan.create using the ```tool format to create a comprehensive plan BEFORE writing any files or running any commands. " +
|
|
1390
|
-
"Do NOT use fs.write, fs.writeMany, fs.edit, shell.exec, shell.start, or pkg.install yet. " +
|
|
1391
|
-
"Your ONLY allowed action right now is plan.create (or read/list for exploration)."
|
|
1392
|
-
: "You only produced internal reasoning with no visible answer or tool call. " +
|
|
1393
|
-
"You MUST either call a tool using the ```tool format or provide your final answer. " +
|
|
1394
|
-
"Do NOT wrap your tool call inside considering or reasoning tags — put it in the VISIBLE response, not hidden. " +
|
|
1395
|
-
"If images are attached, inspect them directly for visual details (text, colors, layout, spacing, style) instead of using OCR unless explicitly needed. " +
|
|
1396
|
-
"Do NOT just think — take action NOW.";
|
|
1397
|
-
messages.push(recoveryUserMessage(buildNudge));
|
|
1398
|
-
continue;
|
|
1369
|
+
if (call) {
|
|
1370
|
+
writeNotice("info", "recovered tool call from thinking content", chalk.dim(" ℹ recovered tool call from thinking content\n"));
|
|
1371
|
+
}
|
|
1399
1372
|
}
|
|
1400
|
-
//
|
|
1401
|
-
//
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
//
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1373
|
+
// ── Thinking-only recovery ────────────────────────────────────────
|
|
1374
|
+
// Some models (eg gpt-oss-20b on NVIDIA NIM) occasionally spend their
|
|
1375
|
+
// entire budget on hidden <think> reasoning and emit no visible text
|
|
1376
|
+
// or tool call. Without this guard the agent silently returns an empty
|
|
1377
|
+
// answer and the user has to re-submit the same prompt.
|
|
1378
|
+
if (!assistantText.visible.trim() && !call && assistantText.hasThinking) {
|
|
1379
|
+
emptyVisibleRetries += 1;
|
|
1380
|
+
if (emptyVisibleRetries <= 2) {
|
|
1381
|
+
writeThinkingBlock(assistantText.thinkContent);
|
|
1382
|
+
writeNotice("warn", "model produced only thinking — nudging it to take action", chalk.yellow(" ⚠ model produced only thinking — nudging it to take action\n"));
|
|
1383
|
+
messages.push({
|
|
1384
|
+
role: "assistant",
|
|
1385
|
+
content: collapseRepeatedText(completion.text),
|
|
1386
|
+
});
|
|
1387
|
+
const buildNudge = buildLikeTurn && !activePlan
|
|
1388
|
+
? "You only produced internal reasoning with no visible answer or tool call. " +
|
|
1389
|
+
"This is a BUILD/SCAFFOLD task with NO plan yet. " +
|
|
1390
|
+
"You MUST call plan.create using the ```tool format to create a comprehensive plan BEFORE writing any files or running any commands. " +
|
|
1391
|
+
"Do NOT use fs.write, fs.writeMany, fs.edit, shell.exec, shell.start, or pkg.install yet. " +
|
|
1392
|
+
"Your ONLY allowed action right now is plan.create (or read/list for exploration)."
|
|
1393
|
+
: "You only produced internal reasoning with no visible answer or tool call. " +
|
|
1394
|
+
"You MUST either call a tool using the ```tool format or provide your final answer. " +
|
|
1395
|
+
"Do NOT wrap your tool call inside considering or reasoning tags — put it in the VISIBLE response, not hidden. " +
|
|
1396
|
+
"If images are attached, inspect them directly for visual details (text, colors, layout, spacing, style) instead of using OCR unless explicitly needed. " +
|
|
1397
|
+
"Do NOT just think — take action NOW.";
|
|
1398
|
+
messages.push(recoveryUserMessage(buildNudge));
|
|
1399
|
+
continue;
|
|
1400
|
+
}
|
|
1401
|
+
// Exhausted retries — fall through to the normal empty-answer path
|
|
1402
|
+
// which will print a warning and return.
|
|
1421
1403
|
}
|
|
1422
|
-
else
|
|
1423
|
-
|
|
1404
|
+
else {
|
|
1405
|
+
// Reset the counter on any successful visible output or recovered call.
|
|
1406
|
+
emptyVisibleRetries = 0;
|
|
1424
1407
|
}
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1408
|
+
// `call` was already extracted above (from visible text or thinking content).
|
|
1409
|
+
// Recovery: the model meant to call a tool but emitted a bare JSON object
|
|
1410
|
+
// with no ```tool fence — either a complete {name,args} the strict
|
|
1411
|
+
// matchers missed (recover it directly), or just an args object like
|
|
1412
|
+
// {"path":"file.pdf"} with the wrapper dropped (nudge a retry below so
|
|
1413
|
+
// the requested action runs instead of the JSON leaking as the answer).
|
|
1414
|
+
let bareArgsOnly = false;
|
|
1415
|
+
recoveredFromBareJson = false;
|
|
1416
|
+
if (!call) {
|
|
1417
|
+
const bare = recognizeBareToolJson(assistantText.visible);
|
|
1418
|
+
if (bare?.call) {
|
|
1419
|
+
call = bare.call;
|
|
1420
|
+
recoveredFromBareJson = true;
|
|
1421
|
+
writeNotice("info", "recovered an unfenced tool call from bare JSON", chalk.dim(" ℹ recovered an unfenced tool call from bare JSON\n"));
|
|
1422
|
+
}
|
|
1423
|
+
else if (bare?.argsOnly) {
|
|
1424
|
+
bareArgsOnly = true;
|
|
1425
|
+
}
|
|
1433
1426
|
}
|
|
1434
|
-
|
|
1435
|
-
|
|
1427
|
+
// Also check thinking content for bare JSON calls.
|
|
1428
|
+
if (!call && assistantText.hasThinking) {
|
|
1429
|
+
const bareThink = recognizeBareToolJson(assistantText.thinkContent);
|
|
1430
|
+
if (bareThink?.call) {
|
|
1431
|
+
call = bareThink.call;
|
|
1432
|
+
recoveredFromBareJson = true;
|
|
1433
|
+
writeNotice("info", "recovered an unfenced tool call from thinking content", chalk.dim(" ℹ recovered an unfenced tool call from thinking content\n"));
|
|
1434
|
+
}
|
|
1435
|
+
else if (bareThink?.argsOnly) {
|
|
1436
|
+
bareArgsOnly = true;
|
|
1437
|
+
}
|
|
1436
1438
|
}
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1439
|
+
if (!call) {
|
|
1440
|
+
if (bareArgsOnly) {
|
|
1441
|
+
bareToolJsonRetries += 1;
|
|
1442
|
+
if (bareToolJsonRetries <= 3) {
|
|
1443
|
+
writeNotice("warn", "tool call missing its name/fence — asking the model to re-emit a proper ```tool block", chalk.yellow(" ⚠ tool call missing its name/fence — asking the model to re-emit a proper ```tool block\n"));
|
|
1444
|
+
messages.push({ role: "assistant", content: assistantText.visible });
|
|
1445
|
+
messages.push(recoveryUserMessage(buildLikeTurn && !activePlan
|
|
1446
|
+
? "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
|
|
1447
|
+
"This is a BUILD/SCAFFOLD task with NO plan yet. " +
|
|
1448
|
+
"You MUST call plan.create using a proper ```tool block. For example:\n" +
|
|
1449
|
+
'```tool\n{"name":"plan.create","args":{"goal":"scaffold todo app","detail":"...","tasks":["...","..."],"kind":"coding"}}\n```\n' +
|
|
1450
|
+
"Do NOT use fs.write, fs.writeMany, shell.exec, or pkg.install yet."
|
|
1451
|
+
: "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
|
|
1452
|
+
"Reply with ONLY a fenced ```tool block of the form " +
|
|
1453
|
+
'`{"name": "<tool>", "args": { ... }}`. For example, to read a PDF:\n' +
|
|
1454
|
+
'```tool\n{"name":"pdf.read","args":{"path":"/abs/file.pdf"}}\n```\n' +
|
|
1455
|
+
"Choose the correct tool name for the task and include those args."));
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1458
|
+
// Exhausted retries — fall through to the normal answer path.
|
|
1459
|
+
}
|
|
1460
|
+
// Detect the case where the model emitted sentinel-style tool-call
|
|
1461
|
+
// markers but the body was malformed or truncated. Printing those
|
|
1462
|
+
// raw tokens looks like a crash to the user — instead, ask the
|
|
1463
|
+
// model to retry the tool call in a clean JSON format.
|
|
1464
|
+
if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
|
|
1465
|
+
writeNotice("warn", "tool call was malformed or cut off — asking the model to retry in JSON form", chalk.yellow(" ⚠ tool call was malformed or cut off — asking the model to retry in JSON form\n"));
|
|
1443
1466
|
messages.push({ role: "assistant", content: assistantText.visible });
|
|
1444
|
-
messages.push(recoveryUserMessage(
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
'```tool\n{"name":"plan.create","args":{"goal":"scaffold todo app","detail":"...","tasks":["...","..."],"kind":"coding"}}\n```\n' +
|
|
1449
|
-
"Do NOT use fs.write, fs.writeMany, shell.exec, or pkg.install yet."
|
|
1450
|
-
: "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
|
|
1451
|
-
"Reply with ONLY a fenced ```tool block of the form " +
|
|
1452
|
-
'`{"name": "<tool>", "args": { ... }}`. For example, to read a PDF:\n' +
|
|
1453
|
-
'```tool\n{"name":"pdf.read","args":{"path":"/abs/file.pdf"}}\n```\n' +
|
|
1454
|
-
"Choose the correct tool name for the task and include those args."));
|
|
1467
|
+
messages.push(recoveryUserMessage("Your previous tool call was malformed or truncated. " +
|
|
1468
|
+
"Reply with ONLY a fenced ```tool block containing valid JSON " +
|
|
1469
|
+
'of the form `{"name": "<tool>", "args": { ... }}`. ' +
|
|
1470
|
+
"Do not use <|tool_call_begin|> markers."));
|
|
1455
1471
|
continue;
|
|
1456
1472
|
}
|
|
1457
|
-
//
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1473
|
+
// Detect a tool call that opened but was cut off by the token limit
|
|
1474
|
+
// (most common with a large multi-file fs.writeMany). Retrying with a
|
|
1475
|
+
// nudge to split the work is far better than rendering broken JSON as
|
|
1476
|
+
// a final answer and leaving the project half-created.
|
|
1477
|
+
if (looksLikeTruncatedToolCall(assistantText.visible)) {
|
|
1478
|
+
truncatedToolRetries += 1;
|
|
1479
|
+
if (truncatedToolRetries <= 3) {
|
|
1480
|
+
writeNotice("warn", "tool call was cut off (output too long) — asking the model to retry in smaller pieces", chalk.yellow(" ⚠ tool call was cut off (output too long) — asking the model to retry in smaller pieces\n"));
|
|
1481
|
+
messages.push({ role: "assistant", content: assistantText.visible });
|
|
1482
|
+
messages.push({
|
|
1483
|
+
role: "user",
|
|
1484
|
+
content: "Your previous tool call was cut off before it finished — the JSON was incomplete, so NOTHING ran. " +
|
|
1485
|
+
"Retry now with a COMPLETE, valid ```tool block. " +
|
|
1486
|
+
"If it was a large fs.writeMany, split it into SMALLER batches (3-5 files per call, and keep each file's content concise) " +
|
|
1487
|
+
"so the whole JSON fits in one response. Do NOT claim any file was written until a tool call actually succeeds.",
|
|
1488
|
+
});
|
|
1489
|
+
continue;
|
|
1490
|
+
}
|
|
1491
|
+
// Exhausted retries — fall through so we don't loop forever, but the
|
|
1492
|
+
// user at least sees the (broken) output and the stop notice.
|
|
1493
|
+
}
|
|
1494
|
+
// Detect a ```tool fence whose JSON could NOT be parsed for any other
|
|
1495
|
+
// reason (malformed braces, trailing junk, a stray `}` — NOT plain
|
|
1496
|
+
// truncation, which is handled above). Without this, the raw block
|
|
1497
|
+
// leaks to the screen as a code fence and the requested action (often
|
|
1498
|
+
// a whole fs.writeMany scaffold) silently never runs — exactly the
|
|
1499
|
+
// "fs.writeMany printed but nothing created" failure. Require the fence
|
|
1500
|
+
// to actually look like an intended call (mentions name/args) so a
|
|
1501
|
+
// genuine ```tool code example in prose isn't mistaken for one.
|
|
1502
|
+
const hasFencedCallShape = countToolFences(assistantText.visible) > 0 &&
|
|
1503
|
+
/```tool\s*\n[\s\S]*?"(?:name|args)"\s*:/i.test(assistantText.visible);
|
|
1504
|
+
if (hasFencedCallShape) {
|
|
1505
|
+
malformedFenceRetries += 1;
|
|
1506
|
+
if (malformedFenceRetries <= 3) {
|
|
1507
|
+
writeNotice("warn", "tool block present but its JSON didn't parse — asking the model to re-emit valid JSON", chalk.yellow(" ⚠ tool block present but its JSON didn't parse — asking the model to re-emit valid JSON\n"));
|
|
1508
|
+
messages.push({ role: "assistant", content: assistantText.visible });
|
|
1509
|
+
messages.push({
|
|
1510
|
+
role: "user",
|
|
1511
|
+
content: "Your previous message contained a ```tool block, but its JSON was INVALID, so NOTHING ran. " +
|
|
1512
|
+
"Common causes: an extra or missing `}` / `]`, a trailing brace after the closing `}`, or unescaped quotes/newlines inside a string value. " +
|
|
1513
|
+
'Re-emit ONE valid ```tool block of the exact form {"name":"<tool>","args":{...}} with balanced braces. ' +
|
|
1514
|
+
"If it was a large fs.writeMany, split it into SMALLER batches (3-5 files) so the JSON is easy to keep valid. " +
|
|
1515
|
+
"Do NOT claim any file was written until a tool call actually succeeds.",
|
|
1516
|
+
});
|
|
1517
|
+
continue;
|
|
1518
|
+
}
|
|
1519
|
+
// Exhausted retries — fall through to the normal path.
|
|
1520
|
+
}
|
|
1521
|
+
// Normal final-answer path: strip any stray sentinel tokens that
|
|
1522
|
+
// somehow leaked into prose so the answer renders cleanly.
|
|
1523
|
+
const cleaned = stripSentinelTokens(assistantText.visible);
|
|
1524
|
+
// ── Act, don't narrate ────────────────────────────────────────────
|
|
1525
|
+
// Build/scaffold/plan turns must DO something. If the model returns
|
|
1526
|
+
// prose with NO tool call, it is narrating intent ("Let me first
|
|
1527
|
+
// explore the directory…") or writing a PLAN as prose ("Goal: … Tasks:
|
|
1528
|
+
// … please approve") instead of calling a tool — accepting it as a
|
|
1529
|
+
// final answer ends the turn with nothing done and no real plan saved.
|
|
1530
|
+
// Nudge it to emit a real tool call, with a concrete example.
|
|
1531
|
+
const wantsAction = buildLikeTurn || (activePlan && session.planApproved.value);
|
|
1532
|
+
const planNarrated = buildLikeTurn && !activePlan && looksLikePlanNarration(cleaned);
|
|
1533
|
+
if (wantsAction &&
|
|
1534
|
+
cleaned.trim().length > 0 &&
|
|
1535
|
+
actionIntentRetries < 3 &&
|
|
1536
|
+
(productiveSteps === 0 ||
|
|
1537
|
+
planNarrated ||
|
|
1538
|
+
looksLikeActionNarration(cleaned))) {
|
|
1539
|
+
actionIntentRetries += 1;
|
|
1540
|
+
let nudge;
|
|
1541
|
+
if (activePlan && session.planApproved.value) {
|
|
1542
|
+
nudge =
|
|
1543
|
+
"You wrote a message but emitted NO ```tool block, so NOTHING ran. Do NOT narrate what you will do — DO it. Emit the next tool call now (task.update / fs.writeMany / shell.exec) in a single ```tool block.";
|
|
1544
|
+
writeNotice("warn", "described an action but emitted no tool call — nudging it to run one", chalk.yellow(" ⚠ described an action but emitted no tool call — nudging it to run one\n"));
|
|
1545
|
+
}
|
|
1546
|
+
else if (planNarrated || productiveSteps > 0) {
|
|
1547
|
+
// It explored and/or wrote a plan as prose but never called
|
|
1548
|
+
// plan.create — emit the plan as a real tool call so the user gets
|
|
1549
|
+
// a checklist and the /implement gate.
|
|
1550
|
+
nudge =
|
|
1551
|
+
"You wrote the plan as PROSE but did NOT call plan.create, so no plan was saved and the user cannot /implement it. Emit it as a real tool call NOW — exactly one ```tool block:\n" +
|
|
1552
|
+
'```tool\n{"name":"plan.create","args":{"goal":"<short goal>","detail":"<stack/approach and how you\'ll verify>","tasks":["task 1","task 2","task 3"],"kind":"coding"}}\n```\n' +
|
|
1553
|
+
"Do not describe the plan again in prose — just emit the plan.create tool block.";
|
|
1554
|
+
writeNotice("warn", "plan was written as text, not created — nudging it to call plan.create", chalk.yellow(" ⚠ plan was written as text, not created — nudging it to call plan.create\n"));
|
|
1555
|
+
}
|
|
1556
|
+
else {
|
|
1557
|
+
nudge =
|
|
1558
|
+
"You described what you will do but emitted NO ```tool block, so NOTHING actually happened — narration is not action. Emit a real tool call NOW. For this build task, explore first like this:\n" +
|
|
1559
|
+
'```tool\n{"name":"fs.list","args":{"path":"."}}\n```\n' +
|
|
1560
|
+
"Then read key files, and once you understand the directory, call plan.create. Every turn MUST contain a ```tool block until the task is done.";
|
|
1561
|
+
writeNotice("warn", "described an action but emitted no tool call — nudging it to run one", chalk.yellow(" ⚠ described an action but emitted no tool call — nudging it to run one\n"));
|
|
1562
|
+
}
|
|
1480
1563
|
messages.push({ role: "assistant", content: assistantText.visible });
|
|
1481
|
-
messages.push(
|
|
1482
|
-
role: "user",
|
|
1483
|
-
content: "Your previous tool call was cut off before it finished — the JSON was incomplete, so NOTHING ran. " +
|
|
1484
|
-
"Retry now with a COMPLETE, valid ```tool block. " +
|
|
1485
|
-
"If it was a large fs.writeMany, split it into SMALLER batches (3-5 files per call, and keep each file's content concise) " +
|
|
1486
|
-
"so the whole JSON fits in one response. Do NOT claim any file was written until a tool call actually succeeds.",
|
|
1487
|
-
});
|
|
1564
|
+
messages.push(recoveryUserMessage(nudge));
|
|
1488
1565
|
continue;
|
|
1489
1566
|
}
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
// Detect a ```tool fence whose JSON could NOT be parsed for any other
|
|
1494
|
-
// reason (malformed braces, trailing junk, a stray `}` — NOT plain
|
|
1495
|
-
// truncation, which is handled above). Without this, the raw block
|
|
1496
|
-
// leaks to the screen as a code fence and the requested action (often
|
|
1497
|
-
// a whole fs.writeMany scaffold) silently never runs — exactly the
|
|
1498
|
-
// "fs.writeMany printed but nothing created" failure. Require the fence
|
|
1499
|
-
// to actually look like an intended call (mentions name/args) so a
|
|
1500
|
-
// genuine ```tool code example in prose isn't mistaken for one.
|
|
1501
|
-
const hasFencedCallShape = countToolFences(assistantText.visible) > 0 &&
|
|
1502
|
-
/```tool\s*\n[\s\S]*?"(?:name|args)"\s*:/i.test(assistantText.visible);
|
|
1503
|
-
if (hasFencedCallShape) {
|
|
1504
|
-
malformedFenceRetries += 1;
|
|
1505
|
-
if (malformedFenceRetries <= 3) {
|
|
1506
|
-
writeNotice("warn", "tool block present but its JSON didn't parse — asking the model to re-emit valid JSON", chalk.yellow(" ⚠ tool block present but its JSON didn't parse — asking the model to re-emit valid JSON\n"));
|
|
1567
|
+
if (freshWebSearchRequired && !sawFreshWebSearch && !freshnessRetryUsed) {
|
|
1568
|
+
freshnessRetryUsed = true;
|
|
1569
|
+
writeNotice("info", "current-info question detected — searching the web before answering", chalk.dim(" ℹ current-info question detected — searching the web before answering\n"));
|
|
1507
1570
|
messages.push({ role: "assistant", content: assistantText.visible });
|
|
1508
1571
|
messages.push({
|
|
1509
1572
|
role: "user",
|
|
1510
|
-
content:
|
|
1511
|
-
"
|
|
1512
|
-
'Re-emit ONE valid ```tool block of the exact form {"name":"<tool>","args":{...}} with balanced braces. ' +
|
|
1513
|
-
"If it was a large fs.writeMany, split it into SMALLER batches (3-5 files) so the JSON is easy to keep valid. " +
|
|
1514
|
-
"Do NOT claim any file was written until a tool call actually succeeds.",
|
|
1573
|
+
content: freshnessGuardMessage() +
|
|
1574
|
+
" Reply with ONLY a fenced ```tool block for web.search now.",
|
|
1515
1575
|
});
|
|
1516
1576
|
continue;
|
|
1517
1577
|
}
|
|
1518
|
-
//
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1578
|
+
// ── Premature-completion guard (approved plan still has work) ──────
|
|
1579
|
+
// If the user approved a plan and the model now gives a final answer
|
|
1580
|
+
// while tasks are still pending/in_progress — without having run the
|
|
1581
|
+
// work — it is fabricating completion (the exact "all tasks completed,
|
|
1582
|
+
// running at localhost:5173" failure). Force it back to executing the
|
|
1583
|
+
// next real task instead of accepting the false claim.
|
|
1584
|
+
if (session.planApproved.value && prematureCompletionRetries < 3) {
|
|
1585
|
+
const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1586
|
+
const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
|
|
1587
|
+
if (livePlan && unfinished && unfinished.length > 0) {
|
|
1588
|
+
prematureCompletionRetries += 1;
|
|
1589
|
+
const next = unfinished[0];
|
|
1590
|
+
writeNotice("warn", `${unfinished.length} plan task(s) still unfinished — not accepting a "done" claim; resuming execution`, chalk.yellow(` ⚠ ${unfinished.length} plan task(s) still unfinished — not accepting a "done" claim; resuming execution\n`));
|
|
1591
|
+
messages.push({ role: "assistant", content: assistantText.visible });
|
|
1592
|
+
messages.push({
|
|
1593
|
+
role: "user",
|
|
1594
|
+
content: `You have NOT finished the approved plan: ${unfinished.length} task(s) remain ` +
|
|
1595
|
+
`(${unfinished.map((t) => `[${t.id}] ${t.title}`).join("; ")}). ` +
|
|
1596
|
+
`Do NOT claim the work is complete, that files were created, or that a server is running ` +
|
|
1597
|
+
`unless a tool call actually succeeded and you saw the output. ` +
|
|
1598
|
+
`Resume now with the NEXT task ${next.id} ("${next.title}"): call task.update {taskId:"${next.id}", state:"in_progress"}, ` +
|
|
1599
|
+
`then do the real work with a tool call (fs.writeMany / shell.exec / shell.start), VERIFY it, and mark it done. ` +
|
|
1600
|
+
`Continue task by task until EVERY task is actually finished.`,
|
|
1601
|
+
});
|
|
1602
|
+
continue;
|
|
1603
|
+
}
|
|
1544
1604
|
}
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1605
|
+
// If we still print a final answer while an approved plan has unfinished
|
|
1606
|
+
// tasks (retries exhausted), do NOT let a fabricated "it's done" stand
|
|
1607
|
+
// unchallenged — append an explicit, honest status so the user knows the
|
|
1608
|
+
// build did not actually complete.
|
|
1609
|
+
let completionWarning = "";
|
|
1610
|
+
let completionWarningText = "";
|
|
1611
|
+
if (session.planApproved.value) {
|
|
1612
|
+
const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1613
|
+
const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
|
|
1614
|
+
if (livePlan && unfinished && unfinished.length > 0) {
|
|
1615
|
+
completionWarningText =
|
|
1616
|
+
`${unfinished.length} of ${livePlan.tasks.length} plan task(s) are NOT actually complete. ` +
|
|
1617
|
+
"The summary above may overstate progress.";
|
|
1618
|
+
completionWarning =
|
|
1619
|
+
chalk.yellow(`\n ⚠ ${unfinished.length} of ${livePlan.tasks.length} plan task(s) are NOT actually complete:\n`) +
|
|
1620
|
+
unfinished
|
|
1621
|
+
.map((t) => chalk.yellow(` • [${t.id}] ${t.title}`))
|
|
1622
|
+
.join("\n") +
|
|
1623
|
+
chalk.dim("\n The summary above may overstate progress. Re-run with /implement, or ask clai to finish the remaining tasks.\n");
|
|
1624
|
+
}
|
|
1554
1625
|
}
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
"You described what you will do but emitted NO ```tool block, so NOTHING actually happened — narration is not action. Emit a real tool call NOW. For this build task, explore first like this:\n" +
|
|
1558
|
-
'```tool\n{"name":"fs.list","args":{"path":"."}}\n```\n' +
|
|
1559
|
-
"Then read key files, and once you understand the directory, call plan.create. Every turn MUST contain a ```tool block until the task is done.";
|
|
1560
|
-
writeNotice("warn", "described an action but emitted no tool call — nudging it to run one", chalk.yellow(" ⚠ described an action but emitted no tool call — nudging it to run one\n"));
|
|
1626
|
+
if (cleaned) {
|
|
1627
|
+
writeAssistantMessage(cleaned);
|
|
1561
1628
|
}
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
continue;
|
|
1565
|
-
}
|
|
1566
|
-
if (freshWebSearchRequired && !sawFreshWebSearch && !freshnessRetryUsed) {
|
|
1567
|
-
freshnessRetryUsed = true;
|
|
1568
|
-
writeNotice("info", "current-info question detected — searching the web before answering", chalk.dim(" ℹ current-info question detected — searching the web before answering\n"));
|
|
1569
|
-
messages.push({ role: "assistant", content: assistantText.visible });
|
|
1570
|
-
messages.push({
|
|
1571
|
-
role: "user",
|
|
1572
|
-
content: freshnessGuardMessage() +
|
|
1573
|
-
" Reply with ONLY a fenced ```tool block for web.search now.",
|
|
1574
|
-
});
|
|
1575
|
-
continue;
|
|
1576
|
-
}
|
|
1577
|
-
// ── Premature-completion guard (approved plan still has work) ──────
|
|
1578
|
-
// If the user approved a plan and the model now gives a final answer
|
|
1579
|
-
// while tasks are still pending/in_progress — without having run the
|
|
1580
|
-
// work — it is fabricating completion (the exact "all tasks completed,
|
|
1581
|
-
// running at localhost:5173" failure). Force it back to executing the
|
|
1582
|
-
// next real task instead of accepting the false claim.
|
|
1583
|
-
if (session.planApproved.value && prematureCompletionRetries < 3) {
|
|
1584
|
-
const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1585
|
-
const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
|
|
1586
|
-
if (livePlan && unfinished && unfinished.length > 0) {
|
|
1587
|
-
prematureCompletionRetries += 1;
|
|
1588
|
-
const next = unfinished[0];
|
|
1589
|
-
writeNotice("warn", `${unfinished.length} plan task(s) still unfinished — not accepting a "done" claim; resuming execution`, chalk.yellow(` ⚠ ${unfinished.length} plan task(s) still unfinished — not accepting a "done" claim; resuming execution\n`));
|
|
1590
|
-
messages.push({ role: "assistant", content: assistantText.visible });
|
|
1591
|
-
messages.push({
|
|
1592
|
-
role: "user",
|
|
1593
|
-
content: `You have NOT finished the approved plan: ${unfinished.length} task(s) remain ` +
|
|
1594
|
-
`(${unfinished.map((t) => `[${t.id}] ${t.title}`).join("; ")}). ` +
|
|
1595
|
-
`Do NOT claim the work is complete, that files were created, or that a server is running ` +
|
|
1596
|
-
`unless a tool call actually succeeded and you saw the output. ` +
|
|
1597
|
-
`Resume now with the NEXT task ${next.id} ("${next.title}"): call task.update {taskId:"${next.id}", state:"in_progress"}, ` +
|
|
1598
|
-
`then do the real work with a tool call (fs.writeMany / shell.exec / shell.start), VERIFY it, and mark it done. ` +
|
|
1599
|
-
`Continue task by task until EVERY task is actually finished.`,
|
|
1600
|
-
});
|
|
1601
|
-
continue;
|
|
1629
|
+
if (completionWarning) {
|
|
1630
|
+
writeNotice("warn", completionWarningText, completionWarning);
|
|
1602
1631
|
}
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
// tasks (retries exhausted), do NOT let a fabricated "it's done" stand
|
|
1606
|
-
// unchallenged — append an explicit, honest status so the user knows the
|
|
1607
|
-
// build did not actually complete.
|
|
1608
|
-
let completionWarning = "";
|
|
1609
|
-
let completionWarningText = "";
|
|
1610
|
-
if (session.planApproved.value) {
|
|
1611
|
-
const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1612
|
-
const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
|
|
1613
|
-
if (livePlan && unfinished && unfinished.length > 0) {
|
|
1614
|
-
completionWarningText =
|
|
1615
|
-
`${unfinished.length} of ${livePlan.tasks.length} plan task(s) are NOT actually complete. ` +
|
|
1616
|
-
"The summary above may overstate progress.";
|
|
1617
|
-
completionWarning =
|
|
1618
|
-
chalk.yellow(`\n ⚠ ${unfinished.length} of ${livePlan.tasks.length} plan task(s) are NOT actually complete:\n`) +
|
|
1619
|
-
unfinished
|
|
1620
|
-
.map((t) => chalk.yellow(` • [${t.id}] ${t.title}`))
|
|
1621
|
-
.join("\n") +
|
|
1622
|
-
chalk.dim("\n The summary above may overstate progress. Re-run with /implement, or ask clai to finish the remaining tasks.\n");
|
|
1632
|
+
if (assistantText.hasThinking) {
|
|
1633
|
+
writeThinkingBlock(assistantText.thinkContent);
|
|
1623
1634
|
}
|
|
1635
|
+
await auditLog("agent.final", { provider, model, steps: step + 1 });
|
|
1636
|
+
lastAnswer = cleaned;
|
|
1637
|
+
return finishTurn(lastAnswer, step + 1);
|
|
1624
1638
|
}
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1639
|
+
// A valid primary tool call exists for this fresh model turn. Show any
|
|
1640
|
+
// prose / thinking that preceded it, record the assistant message ONCE,
|
|
1641
|
+
// then queue any additional tool calls from the same message so they
|
|
1642
|
+
// run in order on the next iterations (no extra round-trip).
|
|
1643
|
+
const beforeTool = recoveredFromBareJson
|
|
1644
|
+
? ""
|
|
1645
|
+
: textBeforeToolCall(assistantText.visible);
|
|
1646
|
+
if (beforeTool) {
|
|
1647
|
+
writeAssistantMessage(beforeTool);
|
|
1630
1648
|
}
|
|
1631
1649
|
if (assistantText.hasThinking) {
|
|
1632
1650
|
writeThinkingBlock(assistantText.thinkContent);
|
|
1633
1651
|
}
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1652
|
+
messages.push({
|
|
1653
|
+
role: "assistant",
|
|
1654
|
+
content: collapseRepeatedText(assistantText.visible),
|
|
1655
|
+
});
|
|
1656
|
+
if (!recoveredFromBareJson && call) {
|
|
1657
|
+
const allCalls = parseAllToolCalls(assistantText.visible);
|
|
1658
|
+
if (allCalls.length > 1 &&
|
|
1659
|
+
allCalls[0] &&
|
|
1660
|
+
sameToolCall(allCalls[0], call)) {
|
|
1661
|
+
pendingCalls = allCalls.slice(1);
|
|
1662
|
+
writeNotice("info", `${allCalls.length} tool calls in this message — running them in order`, chalk.dim(` ℹ ${allCalls.length} tool calls in this message — running them in order\n`));
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1637
1665
|
}
|
|
1638
|
-
//
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
//
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1666
|
+
// Type guard: every path above either set `call` or returned/continued.
|
|
1667
|
+
if (!call)
|
|
1668
|
+
continue;
|
|
1669
|
+
// Models often emit a bare CLI name as the tool (e.g. {"name":"sed",...})
|
|
1670
|
+
// instead of wrapping it in shell.exec. Rewrite such unknown, un-namespaced
|
|
1671
|
+
// names into a shell.exec call BEFORE classification so the command both
|
|
1672
|
+
// runs and is safety-classified as the shell command it really is —
|
|
1673
|
+
// instead of dead-ending on "Unknown tool: sed".
|
|
1674
|
+
call = normalizeToolCall(call);
|
|
1675
|
+
const toolEventId = `tool-${++nextToolEventId}`;
|
|
1676
|
+
// ── Duplicate-call detection ──────────────────────────────────────────
|
|
1677
|
+
// If the model calls the exact same tool with the exact same args
|
|
1678
|
+
// repeatedly, it's stuck in a loop. Inject a corrective message
|
|
1679
|
+
// telling it to summarize the results it already has.
|
|
1680
|
+
const loopCheck = loopGuard.shouldBlock(call.name, call.args);
|
|
1681
|
+
if (loopCheck.block) {
|
|
1682
|
+
const isWrite = call.name === "fs.write" ||
|
|
1683
|
+
call.name === "fs.writeMany" ||
|
|
1684
|
+
call.name === "fs.edit";
|
|
1685
|
+
const reason = `${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}`;
|
|
1686
|
+
writeNotice("warn", reason, chalk.yellow(` ⚠ ${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}\n`));
|
|
1687
|
+
// A repeat means this batch went off the rails — drop any queued calls
|
|
1688
|
+
// and let the model react. The assistant message was already recorded.
|
|
1689
|
+
pendingCalls = [];
|
|
1690
|
+
messages.push({
|
|
1691
|
+
role: "user",
|
|
1692
|
+
content: isWrite
|
|
1693
|
+
? `You already wrote that exact file with ${call.name}. It is saved. ` +
|
|
1694
|
+
"Do NOT write it again. Move on to the NEXT file or step. If every file is written, " +
|
|
1695
|
+
"verify the project (list the tree, run the build/install command) and give your final answer."
|
|
1696
|
+
: `You already called ${call.name} with the same arguments and received results. ` +
|
|
1697
|
+
"Do NOT call it again. Summarize the findings you already have and give your final answer NOW.",
|
|
1698
|
+
});
|
|
1699
|
+
continue;
|
|
1647
1700
|
}
|
|
1648
|
-
if (
|
|
1649
|
-
|
|
1701
|
+
if (loopCheck.reason) {
|
|
1702
|
+
writeNotice("info", loopCheck.reason, chalk.dim(` ℹ ${loopCheck.reason}\n`));
|
|
1650
1703
|
}
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1704
|
+
// ── Plan / task tools (session-scoped, handled inline) ─────────────
|
|
1705
|
+
// These don't go through the generic registry because they need the
|
|
1706
|
+
// session id and mutate the live plan that the user can view (Ctrl+P).
|
|
1707
|
+
if (call.name === "plan.create" || call.name === "task.update") {
|
|
1708
|
+
const planResult = await handlePlanTool(call, session, {
|
|
1709
|
+
loopGuard,
|
|
1710
|
+
step,
|
|
1711
|
+
});
|
|
1712
|
+
if (planResult.handled) {
|
|
1713
|
+
productiveSteps += 1;
|
|
1714
|
+
loopGuard.recordAttempt(step, call.name, call.args, planResult.ok, 0);
|
|
1715
|
+
if (planResult.plan) {
|
|
1716
|
+
writePlanUpdate(planResult.plan, planResult.display);
|
|
1717
|
+
}
|
|
1718
|
+
// plan.create means "STOP and wait for /implement" — abandon any
|
|
1719
|
+
// other calls the model batched alongside it.
|
|
1720
|
+
if (call.name === "plan.create")
|
|
1721
|
+
pendingCalls = [];
|
|
1722
|
+
messages.push({
|
|
1723
|
+
role: "tool",
|
|
1724
|
+
content: `Tool ${call.name} result (ok=${planResult.ok}):\n${planResult.modelNote}`,
|
|
1725
|
+
});
|
|
1726
|
+
continue;
|
|
1662
1727
|
}
|
|
1663
1728
|
}
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
// names into a shell.exec call BEFORE classification so the command both
|
|
1671
|
-
// runs and is safety-classified as the shell command it really is —
|
|
1672
|
-
// instead of dead-ending on "Unknown tool: sed".
|
|
1673
|
-
call = normalizeToolCall(call);
|
|
1674
|
-
const toolEventId = `tool-${++nextToolEventId}`;
|
|
1675
|
-
// ── Duplicate-call detection ──────────────────────────────────────────
|
|
1676
|
-
// If the model calls the exact same tool with the exact same args
|
|
1677
|
-
// repeatedly, it's stuck in a loop. Inject a corrective message
|
|
1678
|
-
// telling it to summarize the results it already has.
|
|
1679
|
-
const loopCheck = loopGuard.shouldBlock(call.name, call.args);
|
|
1680
|
-
if (loopCheck.block) {
|
|
1681
|
-
const isWrite = call.name === "fs.write" ||
|
|
1682
|
-
call.name === "fs.writeMany" ||
|
|
1683
|
-
call.name === "fs.edit";
|
|
1684
|
-
const reason = `${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}`;
|
|
1685
|
-
writeNotice("warn", reason, chalk.yellow(` ⚠ ${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}\n`));
|
|
1686
|
-
// A repeat means this batch went off the rails — drop any queued calls
|
|
1687
|
-
// and let the model react. The assistant message was already recorded.
|
|
1688
|
-
pendingCalls = [];
|
|
1689
|
-
messages.push({
|
|
1690
|
-
role: "user",
|
|
1691
|
-
content: isWrite
|
|
1692
|
-
? `You already wrote that exact file with ${call.name}. It is saved. ` +
|
|
1693
|
-
"Do NOT write it again. Move on to the NEXT file or step. If every file is written, " +
|
|
1694
|
-
"verify the project (list the tree, run the build/install command) and give your final answer."
|
|
1695
|
-
: `You already called ${call.name} with the same arguments and received results. ` +
|
|
1696
|
-
"Do NOT call it again. Summarize the findings you already have and give your final answer NOW.",
|
|
1729
|
+
const scope = await loadScope();
|
|
1730
|
+
const decision = classifyToolCall(call, { scope });
|
|
1731
|
+
await auditLog("tool.classified", {
|
|
1732
|
+
call,
|
|
1733
|
+
decision,
|
|
1734
|
+
scope: isScopeActive(scope) ? (scope.name ?? "(unnamed)") : "(none)",
|
|
1697
1735
|
});
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
loopGuard.recordAttempt(step, call.name, call.args, planResult.ok, 0);
|
|
1714
|
-
if (planResult.plan) {
|
|
1715
|
-
writePlanUpdate(planResult.plan, planResult.display);
|
|
1716
|
-
}
|
|
1717
|
-
// plan.create means "STOP and wait for /implement" — abandon any
|
|
1718
|
-
// other calls the model batched alongside it.
|
|
1719
|
-
if (call.name === "plan.create")
|
|
1720
|
-
pendingCalls = [];
|
|
1736
|
+
// ── Plan-awaiting-approval gate ────────────────────────────────────
|
|
1737
|
+
// When an active plan exists but the user has NOT approved it with
|
|
1738
|
+
// /implement, the agent must NOT execute the plan. Any free-text the
|
|
1739
|
+
// user typed after the plan was shown is a PLAN REVISION, not a "go"
|
|
1740
|
+
// signal — the agent should re-plan (plan.create) and wait again. We
|
|
1741
|
+
// hard-block execution tools here so a model that ignores the prompt
|
|
1742
|
+
// directive (or recovers a stray tool call) can't start running the
|
|
1743
|
+
// plan. Read-only exploration is still allowed so it can refine the
|
|
1744
|
+
// plan intelligently.
|
|
1745
|
+
if (activePlan &&
|
|
1746
|
+
!session.planApproved.value &&
|
|
1747
|
+
!isPreApprovalAllowedTool(call.name)) {
|
|
1748
|
+
const reason = `plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)`;
|
|
1749
|
+
writeNotice("warn", reason, chalk.yellow(` ⚠ plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)\n`));
|
|
1750
|
+
pendingCalls = [];
|
|
1721
1751
|
messages.push({
|
|
1722
|
-
role: "
|
|
1723
|
-
content: `
|
|
1752
|
+
role: "user",
|
|
1753
|
+
content: `There is an ACTIVE PLAN that has NOT been approved yet, so you must NOT execute it — ` +
|
|
1754
|
+
`you tried to call ${call.name}, which is blocked. The user's latest message is a PLAN REVISION, ` +
|
|
1755
|
+
`not approval. Update the plan to incorporate their feedback by calling plan.create again with the ` +
|
|
1756
|
+
`revised goal/detail/tasks, then STOP and wait. The user approves with /implement or cancels with /discard. ` +
|
|
1757
|
+
`Do NOT run any execution tool (shell.exec, pkg.install, fs.write, net.scan, tool.check, etc.) until they /implement.`,
|
|
1724
1758
|
});
|
|
1725
1759
|
continue;
|
|
1726
1760
|
}
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
call
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
`you tried to call ${call.name}, which is blocked. The user's latest message is a PLAN REVISION, ` +
|
|
1754
|
-
`not approval. Update the plan to incorporate their feedback by calling plan.create again with the ` +
|
|
1755
|
-
`revised goal/detail/tasks, then STOP and wait. The user approves with /implement or cancels with /discard. ` +
|
|
1756
|
-
`Do NOT run any execution tool (shell.exec, pkg.install, fs.write, net.scan, tool.check, etc.) until they /implement.`,
|
|
1757
|
-
});
|
|
1758
|
-
continue;
|
|
1759
|
-
}
|
|
1760
|
-
if (call.name === "web.search") {
|
|
1761
|
-
sawFreshWebSearch = true;
|
|
1762
|
-
}
|
|
1763
|
-
// Show tool call
|
|
1764
|
-
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) + chalk.gray(` ${formatToolArgs(call)}`);
|
|
1765
|
-
writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
|
|
1766
|
-
const scopeTarget = scopeTargetForToolCall(call);
|
|
1767
|
-
if (scopeTarget &&
|
|
1768
|
-
(!isScopeActive(scope) || !targetInScope(scopeTarget, scope))) {
|
|
1769
|
-
writeNotice("info", `scope optional: ${scopeHint(scopeTarget)}`, chalk.dim(` scope optional: ${scopeHint(scopeTarget)}\n`));
|
|
1770
|
-
}
|
|
1771
|
-
if (decision.level === "block") {
|
|
1772
|
-
writeToolBlocked(toolEventId, call.name, decision.reason, chalk.red(` ✗ blocked: ${decision.reason}`) + "\n");
|
|
1773
|
-
lastAnswer = `Blocked: ${call.name} — ${decision.reason}`;
|
|
1774
|
-
return finishTurn(lastAnswer, productiveSteps);
|
|
1775
|
-
}
|
|
1776
|
-
// Pentest authorization — if user confirms this, skip the per-tool confirm
|
|
1777
|
-
let pentestJustConfirmed = false;
|
|
1778
|
-
const needsPentestAuth = isPentestToolCall(call) &&
|
|
1779
|
-
!getConfig().pentestAuthorized &&
|
|
1780
|
-
!session.pentestAuthorized.value;
|
|
1781
|
-
const authorized = await ensurePentestAuthorization(call, Boolean(options.autoConfirm), session, confirmPort);
|
|
1782
|
-
// inquirer's confirm() creates its own readline interface which resets
|
|
1783
|
-
// raw mode AND pauses stdin when it finishes. Re-assert raw mode and
|
|
1784
|
-
// resume stdin so the outer keypress handler (ESC/Ctrl+C abort, Ctrl+O
|
|
1785
|
-
// output pane) keeps working during the next streaming/tool phase.
|
|
1786
|
-
restoreInteractiveStdin();
|
|
1787
|
-
if (!authorized) {
|
|
1788
|
-
lastAnswer = "Pentest authorization not confirmed.";
|
|
1789
|
-
writeToolBlocked(toolEventId, call.name, lastAnswer, chalk.red(` ✗ ${lastAnswer}`) + "\n");
|
|
1790
|
-
return finishTurn(lastAnswer, productiveSteps);
|
|
1791
|
-
}
|
|
1792
|
-
if (needsPentestAuth) {
|
|
1793
|
-
pentestJustConfirmed = true;
|
|
1794
|
-
}
|
|
1795
|
-
// Confirm if needed (safe tools auto-execute, pentest-auth'd tools skip)
|
|
1796
|
-
// fs.delete and shell deletions NEVER auto-confirm even with -y flag.
|
|
1797
|
-
const forceManualConfirm = call.name === "fs.delete";
|
|
1798
|
-
if (decision.level === "confirm" && !pentestJustConfirmed) {
|
|
1799
|
-
const ok = await confirmToolExecution(call, forceManualConfirm ? false : Boolean(options.autoConfirm), session, confirmPort);
|
|
1800
|
-
// Re-assert raw mode and resume stdin after inquirer's confirm()
|
|
1801
|
-
// (see restoreInteractiveStdin / the comment above).
|
|
1761
|
+
if (call.name === "web.search") {
|
|
1762
|
+
sawFreshWebSearch = true;
|
|
1763
|
+
}
|
|
1764
|
+
// Show tool call
|
|
1765
|
+
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) + chalk.gray(` ${formatToolArgs(call)}`);
|
|
1766
|
+
writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
|
|
1767
|
+
const scopeTarget = scopeTargetForToolCall(call);
|
|
1768
|
+
if (scopeTarget &&
|
|
1769
|
+
(!isScopeActive(scope) || !targetInScope(scopeTarget, scope))) {
|
|
1770
|
+
writeNotice("info", `scope optional: ${scopeHint(scopeTarget)}`, chalk.dim(` scope optional: ${scopeHint(scopeTarget)}\n`));
|
|
1771
|
+
}
|
|
1772
|
+
if (decision.level === "block") {
|
|
1773
|
+
writeToolBlocked(toolEventId, call.name, decision.reason, chalk.red(` ✗ blocked: ${decision.reason}`) + "\n");
|
|
1774
|
+
lastAnswer = `Blocked: ${call.name} — ${decision.reason}`;
|
|
1775
|
+
return finishTurn(lastAnswer, productiveSteps);
|
|
1776
|
+
}
|
|
1777
|
+
// Pentest authorization — if user confirms this, skip the per-tool confirm
|
|
1778
|
+
let pentestJustConfirmed = false;
|
|
1779
|
+
const needsPentestAuth = isPentestToolCall(call) &&
|
|
1780
|
+
!getConfig().pentestAuthorized &&
|
|
1781
|
+
!session.pentestAuthorized.value;
|
|
1782
|
+
const authorized = await ensurePentestAuthorization(call, Boolean(options.autoConfirm), session, confirmPort);
|
|
1783
|
+
// inquirer's confirm() creates its own readline interface which resets
|
|
1784
|
+
// raw mode AND pauses stdin when it finishes. Re-assert raw mode and
|
|
1785
|
+
// resume stdin so the outer keypress handler (ESC/Ctrl+C abort, Ctrl+O
|
|
1786
|
+
// output pane) keeps working during the next streaming/tool phase.
|
|
1802
1787
|
restoreInteractiveStdin();
|
|
1803
|
-
if (!
|
|
1804
|
-
lastAnswer = "
|
|
1805
|
-
|
|
1788
|
+
if (!authorized) {
|
|
1789
|
+
lastAnswer = "Pentest authorization not confirmed.";
|
|
1790
|
+
writeToolBlocked(toolEventId, call.name, lastAnswer, chalk.red(` ✗ ${lastAnswer}`) + "\n");
|
|
1806
1791
|
return finishTurn(lastAnswer, productiveSteps);
|
|
1807
1792
|
}
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
let liveBytes = 0;
|
|
1824
|
-
const liveCap = 16_000; // Stop streaming after this many bytes to avoid flooding the terminal.
|
|
1825
|
-
let liveTruncatedNotified = false;
|
|
1826
|
-
let lastProgressAt = 0;
|
|
1827
|
-
// When the underlying command may pause for a password prompt
|
|
1828
|
-
// (sudo / ssh / etc.) we stream the live preview *without* the dim
|
|
1829
|
-
// attribute so the prompt is fully readable. Otherwise we keep the
|
|
1830
|
-
// dim styling that makes ordinary tool chatter visually distinct
|
|
1831
|
-
// from the model's prose.
|
|
1832
|
-
const shouldDimLive = !interactiveCommand;
|
|
1833
|
-
const printLive = (chunk) => {
|
|
1834
|
-
// Suppress live preview for fs.read / fs.list — those are read-only
|
|
1835
|
-
// and the final summary is already concise. Stream shell-style tools
|
|
1836
|
-
// (shell.exec, net.scan, pentest.recon, pkg.install).
|
|
1837
|
-
if (call.name === "fs.read" ||
|
|
1838
|
-
call.name === "fs.list" ||
|
|
1839
|
-
call.name === "fs.search")
|
|
1840
|
-
return;
|
|
1841
|
-
if (liveBytes >= liveCap) {
|
|
1842
|
-
if (!liveTruncatedNotified) {
|
|
1843
|
-
liveTruncatedNotified = true;
|
|
1844
|
-
writeNotice("info", "live preview truncated, full output saved", chalk.dim("\n … live preview truncated, full output saved\n"));
|
|
1845
|
-
writeNotice("info", "tool still running — ESC or Ctrl+C to abort", chalk.dim(" (tool still running — ESC or Ctrl+C to abort)\n"));
|
|
1846
|
-
lastProgressAt = Date.now();
|
|
1793
|
+
if (needsPentestAuth) {
|
|
1794
|
+
pentestJustConfirmed = true;
|
|
1795
|
+
}
|
|
1796
|
+
// Confirm if needed (safe tools auto-execute, pentest-auth'd tools skip)
|
|
1797
|
+
// fs.delete and shell deletions NEVER auto-confirm even with -y flag.
|
|
1798
|
+
const forceManualConfirm = call.name === "fs.delete";
|
|
1799
|
+
if (decision.level === "confirm" && !pentestJustConfirmed) {
|
|
1800
|
+
const ok = await confirmToolExecution(call, forceManualConfirm ? false : Boolean(options.autoConfirm), session, confirmPort);
|
|
1801
|
+
// Re-assert raw mode and resume stdin after inquirer's confirm()
|
|
1802
|
+
// (see restoreInteractiveStdin / the comment above).
|
|
1803
|
+
restoreInteractiveStdin();
|
|
1804
|
+
if (!ok) {
|
|
1805
|
+
lastAnswer = "Cancelled.";
|
|
1806
|
+
writeNotice("warn", "cancelled", chalk.yellow(` ✗ cancelled`) + "\n");
|
|
1807
|
+
return finishTurn(lastAnswer, productiveSteps);
|
|
1847
1808
|
}
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1809
|
+
}
|
|
1810
|
+
// Execute tool
|
|
1811
|
+
options.signal?.throwIfAborted();
|
|
1812
|
+
options.onToolStart?.(call);
|
|
1813
|
+
// Heads-up when the command is about to run something that may pause
|
|
1814
|
+
// for a password prompt (sudo, ssh, gpg, ...). The shell tool already
|
|
1815
|
+
// routes such commands through inherited stdin so the user can type
|
|
1816
|
+
// directly into the controlling TTY; we just warn them to expect it.
|
|
1817
|
+
const interactiveCommand = call.name === "shell.exec" &&
|
|
1818
|
+
typeof call.args.command === "string" &&
|
|
1819
|
+
looksInteractiveStdin(call.args.command);
|
|
1820
|
+
if (interactiveCommand && process.stdin.isTTY) {
|
|
1821
|
+
writeNotice("warn", "this command may prompt for a password — type it when asked", chalk.yellow(" ⚠ this command may prompt for a password — type it when asked\n"));
|
|
1822
|
+
}
|
|
1823
|
+
let result;
|
|
1824
|
+
let liveBytes = 0;
|
|
1825
|
+
const liveCap = 16_000; // Stop streaming after this many bytes to avoid flooding the terminal.
|
|
1826
|
+
let liveTruncatedNotified = false;
|
|
1827
|
+
let lastProgressAt = 0;
|
|
1828
|
+
// When the underlying command may pause for a password prompt
|
|
1829
|
+
// (sudo / ssh / etc.) we stream the live preview *without* the dim
|
|
1830
|
+
// attribute so the prompt is fully readable. Otherwise we keep the
|
|
1831
|
+
// dim styling that makes ordinary tool chatter visually distinct
|
|
1832
|
+
// from the model's prose.
|
|
1833
|
+
const shouldDimLive = !interactiveCommand;
|
|
1834
|
+
const printLive = (chunk) => {
|
|
1835
|
+
// Suppress live preview for fs.read / fs.list — those are read-only
|
|
1836
|
+
// and the final summary is already concise. Stream shell-style tools
|
|
1837
|
+
// (shell.exec, net.scan, pentest.recon, pkg.install).
|
|
1838
|
+
if (call.name === "fs.read" ||
|
|
1839
|
+
call.name === "fs.list" ||
|
|
1840
|
+
call.name === "fs.search")
|
|
1841
|
+
return;
|
|
1842
|
+
if (liveBytes >= liveCap) {
|
|
1843
|
+
if (!liveTruncatedNotified) {
|
|
1844
|
+
liveTruncatedNotified = true;
|
|
1845
|
+
writeNotice("info", "live preview truncated, full output saved", chalk.dim("\n … live preview truncated, full output saved\n"));
|
|
1846
|
+
writeNotice("info", "tool still running — ESC or Ctrl+C to abort", chalk.dim(" (tool still running — ESC or Ctrl+C to abort)\n"));
|
|
1847
|
+
lastProgressAt = Date.now();
|
|
1848
|
+
}
|
|
1849
|
+
// After truncation, show a dot every 5 seconds so the user knows
|
|
1850
|
+
// the tool is still running and the terminal isn't frozen.
|
|
1851
|
+
const now = Date.now();
|
|
1852
|
+
if (now - lastProgressAt > 5_000) {
|
|
1853
|
+
lastProgressAt = now;
|
|
1854
|
+
writeToolOutput(toolEventId, ".", chalk.dim("."));
|
|
1855
|
+
}
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
const remaining = liveCap - liveBytes;
|
|
1859
|
+
const slice = chunk.length > remaining ? chunk.slice(0, remaining) : chunk;
|
|
1860
|
+
liveBytes += slice.length;
|
|
1861
|
+
// Indent each line so live output lines up under the tool call.
|
|
1862
|
+
const indented = slice.replace(/\r/g, "").replace(/\n(?!$)/g, "\n ");
|
|
1863
|
+
const body = indented.startsWith("\n") ? indented : ` ${indented}`;
|
|
1864
|
+
// Skip the dim wrapper for interactive commands so a sudo password
|
|
1865
|
+
// prompt is rendered at full brightness; everything else stays dim
|
|
1866
|
+
// so tool chatter is visually distinct from the model's prose.
|
|
1867
|
+
writeToolOutput(toolEventId, slice, shouldDimLive ? chalk.dim(body) : body);
|
|
1868
|
+
};
|
|
1869
|
+
try {
|
|
1870
|
+
result = await runToolCall(call, {
|
|
1871
|
+
signal: options.signal,
|
|
1872
|
+
onOutput: (chunk) => {
|
|
1873
|
+
if (options.signal?.aborted)
|
|
1874
|
+
return;
|
|
1875
|
+
printLive(chunk);
|
|
1876
|
+
},
|
|
1877
|
+
});
|
|
1878
|
+
// Newline separator if live output or progress dots didn't already end with one.
|
|
1879
|
+
if (liveBytes > 0 || liveTruncatedNotified) {
|
|
1880
|
+
writeToolOutput(toolEventId, "\n", "\n");
|
|
1854
1881
|
}
|
|
1855
|
-
return;
|
|
1856
1882
|
}
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
result
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1883
|
+
catch (toolError) {
|
|
1884
|
+
if (isAbortError(toolError, options.signal)) {
|
|
1885
|
+
lastAnswer = "Aborted.";
|
|
1886
|
+
writeAbort();
|
|
1887
|
+
return lastAnswer;
|
|
1888
|
+
}
|
|
1889
|
+
const errMsg = toolError instanceof Error ? toolError.message : String(toolError);
|
|
1890
|
+
result = { ok: false, output: `Tool error: ${errMsg}`, exitCode: 1 };
|
|
1891
|
+
}
|
|
1892
|
+
// Stop-on-error: if this call failed, abandon any remaining queued calls
|
|
1893
|
+
// from the same message so the model sees the failure and decides what to
|
|
1894
|
+
// do next instead of blindly running steps that depended on it.
|
|
1895
|
+
if (!result.ok && pendingCalls.length > 0) {
|
|
1896
|
+
const cancelledQueuedStatus = ` ↳ ${pendingCalls.length} queued call(s) cancelled because this step failed\n`;
|
|
1897
|
+
writeStatus(cancelledQueuedStatus, chalk.dim(cancelledQueuedStatus));
|
|
1898
|
+
pendingCalls = [];
|
|
1899
|
+
}
|
|
1900
|
+
const output = result.output.trim();
|
|
1901
|
+
const displayMax = 6_000;
|
|
1902
|
+
// If the tool already produced an artifact (shell.exec now streams to one
|
|
1903
|
+
// as it runs), respect that path. Otherwise, fall back to the post-hoc
|
|
1904
|
+
// save for tools that return their full output in memory.
|
|
1905
|
+
const savedOutputPath = result.outputPath ??
|
|
1906
|
+
(output.length > displayMax
|
|
1907
|
+
? await saveToolOutput(call, output)
|
|
1908
|
+
: undefined);
|
|
1909
|
+
const resultWithArtifact = {
|
|
1910
|
+
...result,
|
|
1911
|
+
outputPath: savedOutputPath,
|
|
1912
|
+
truncated: result.truncated ?? Boolean(savedOutputPath),
|
|
1913
|
+
};
|
|
1914
|
+
const contextOutput = formatToolContext(call, resultWithArtifact);
|
|
1915
|
+
emitToolResult(toolEventId, resultWithArtifact, contextOutput, savedOutputPath);
|
|
1916
|
+
options.onToolResult?.(call, resultWithArtifact);
|
|
1917
|
+
await auditLog("tool.result", {
|
|
1918
|
+
call,
|
|
1919
|
+
ok: result.ok,
|
|
1920
|
+
exitCode: result.exitCode,
|
|
1921
|
+
output: result.output.slice(0, 4_000),
|
|
1876
1922
|
});
|
|
1877
|
-
//
|
|
1878
|
-
|
|
1879
|
-
|
|
1923
|
+
// Record the attempt in the loop guard for dedup tracking.
|
|
1924
|
+
loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode);
|
|
1925
|
+
// A tool actually executed this iteration — count it against the
|
|
1926
|
+
// productive-step budget. Recovery iterations (thinking-only nudges,
|
|
1927
|
+
// malformed-call retries, freshness/loop-guard prompts) reach `continue`
|
|
1928
|
+
// before this point and therefore never consume the budget.
|
|
1929
|
+
productiveSteps += 1;
|
|
1930
|
+
// ── Auto-retry on "command not found" ──────────────────────────
|
|
1931
|
+
// Detect missing tools and instruct the model to install + retry.
|
|
1932
|
+
const NOT_FOUND_RE = /command not found|ENOENT.*spawn|is not recognized/i;
|
|
1933
|
+
if (!result.ok && NOT_FOUND_RE.test(output)) {
|
|
1934
|
+
const cmdName = call.name === "shell.exec"
|
|
1935
|
+
? String(call.args.command ?? "").split(/\s+/)[0]
|
|
1936
|
+
: call.name === "net.scan"
|
|
1937
|
+
? "nmap"
|
|
1938
|
+
: call.name === "image.ocr"
|
|
1939
|
+
? "tesseract"
|
|
1940
|
+
: undefined;
|
|
1941
|
+
if (cmdName) {
|
|
1942
|
+
writeNotice("warn", `${cmdName} not found — asking model to install and retry`, chalk.yellow(` ⚠ ${cmdName} not found — asking model to install and retry\n`));
|
|
1943
|
+
messages.push({
|
|
1944
|
+
role: "tool",
|
|
1945
|
+
content: `Tool failed: "${cmdName}" is not installed.\n` +
|
|
1946
|
+
`You MUST: 1) use pkg.install to install "${cmdName}", ` +
|
|
1947
|
+
`2) then RETRY the original command. Do NOT stop or give up.`,
|
|
1948
|
+
});
|
|
1949
|
+
continue;
|
|
1950
|
+
}
|
|
1880
1951
|
}
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1952
|
+
// ── Auto-retry on "a terminal is required" sudo error ──────────────
|
|
1953
|
+
// Older Node versions and some non-TTY contexts can still surface the
|
|
1954
|
+
// canonical sudo "a terminal is required" or "no askpass program"
|
|
1955
|
+
// failure. Tell the model to retry through plain `sudo …` (which the
|
|
1956
|
+
// shell tool now inherits stdin for) instead of getting clever with
|
|
1957
|
+
// -S / askpass / piping a password.
|
|
1958
|
+
const SUDO_NEEDS_TTY_RE = /sudo:\s+a terminal is required to read the password|sudo:\s+a password is required|no askpass program|sudo: \d+ incorrect password attempts|sudo:\s+(?:no tty present|sorry, you must have a tty)/i;
|
|
1959
|
+
if (!result.ok && SUDO_NEEDS_TTY_RE.test(output)) {
|
|
1960
|
+
writeNotice("warn", "sudo needs an interactive terminal — asking the model to retry without -S/askpass", chalk.yellow(" ⚠ sudo needs an interactive terminal — asking the model to retry without -S/askpass\n"));
|
|
1961
|
+
messages.push({
|
|
1962
|
+
role: "tool",
|
|
1963
|
+
content: "Tool failed: sudo could not read a password.\n" +
|
|
1964
|
+
"On the next attempt: call shell.exec with `sudo <command>` directly. " +
|
|
1965
|
+
"clai inherits stdin from the user's terminal, so the user can type the password live. " +
|
|
1966
|
+
'DO NOT use `echo "<pwd>" | sudo -S`, DO NOT use SUDO_ASKPASS, DO NOT ask the user for the password in chat. ' +
|
|
1967
|
+
"Just run `sudo <command>` and the password prompt will be visible.",
|
|
1968
|
+
});
|
|
1969
|
+
continue;
|
|
1970
|
+
}
|
|
1971
|
+
// Print tool result
|
|
1972
|
+
const statusIcon = result.ok ? chalk.green(" ✓") : chalk.red(" ✗");
|
|
1973
|
+
writeToolOutput(toolEventId, result.ok ? "ok\n" : "failed\n", statusIcon + "\n");
|
|
1974
|
+
if (output) {
|
|
1975
|
+
const displaySummary = summarizeOutput(output, displayMax);
|
|
1976
|
+
const displayText = displaySummary.truncated
|
|
1977
|
+
? `${displaySummary.text}${savedOutputPath ? chalk.dim(`\n ... full output saved to ${savedOutputPath}`) : chalk.dim("\n ... output truncated")}`
|
|
1978
|
+
: displaySummary.text;
|
|
1979
|
+
// If we already streamed live output for this call, skip re-printing
|
|
1980
|
+
// the same bytes. Just note where the full output lives if it was saved.
|
|
1981
|
+
if (liveBytes > 0) {
|
|
1982
|
+
if (savedOutputPath) {
|
|
1983
|
+
writeNotice("info", `full output saved to ${savedOutputPath}`, chalk.dim(` full output saved to ${savedOutputPath}\n`));
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
else {
|
|
1987
|
+
const renderedOutput = indentAndWrapText(displayText);
|
|
1988
|
+
writeToolOutput(toolEventId, displayText, styleToolChatter(call, renderedOutput) + "\n");
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
if (isAbortError(undefined, options.signal)) {
|
|
1884
1992
|
lastAnswer = "Aborted.";
|
|
1885
1993
|
writeAbort();
|
|
1886
1994
|
return lastAnswer;
|
|
1887
1995
|
}
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
writeStatus(cancelledQueuedStatus, chalk.dim(cancelledQueuedStatus));
|
|
1897
|
-
pendingCalls = [];
|
|
1898
|
-
}
|
|
1899
|
-
const output = result.output.trim();
|
|
1900
|
-
const displayMax = 6_000;
|
|
1901
|
-
// If the tool already produced an artifact (shell.exec now streams to one
|
|
1902
|
-
// as it runs), respect that path. Otherwise, fall back to the post-hoc
|
|
1903
|
-
// save for tools that return their full output in memory.
|
|
1904
|
-
const savedOutputPath = result.outputPath ??
|
|
1905
|
-
(output.length > displayMax
|
|
1906
|
-
? await saveToolOutput(call, output)
|
|
1907
|
-
: undefined);
|
|
1908
|
-
const resultWithArtifact = {
|
|
1909
|
-
...result,
|
|
1910
|
-
outputPath: savedOutputPath,
|
|
1911
|
-
truncated: result.truncated ?? Boolean(savedOutputPath),
|
|
1912
|
-
};
|
|
1913
|
-
const contextOutput = formatToolContext(call, resultWithArtifact);
|
|
1914
|
-
emitToolResult(toolEventId, resultWithArtifact, contextOutput, savedOutputPath);
|
|
1915
|
-
options.onToolResult?.(call, resultWithArtifact);
|
|
1916
|
-
await auditLog("tool.result", {
|
|
1917
|
-
call,
|
|
1918
|
-
ok: result.ok,
|
|
1919
|
-
exitCode: result.exitCode,
|
|
1920
|
-
output: result.output.slice(0, 4_000),
|
|
1921
|
-
});
|
|
1922
|
-
// Record the attempt in the loop guard for dedup tracking.
|
|
1923
|
-
loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode);
|
|
1924
|
-
// A tool actually executed this iteration — count it against the
|
|
1925
|
-
// productive-step budget. Recovery iterations (thinking-only nudges,
|
|
1926
|
-
// malformed-call retries, freshness/loop-guard prompts) reach `continue`
|
|
1927
|
-
// before this point and therefore never consume the budget.
|
|
1928
|
-
productiveSteps += 1;
|
|
1929
|
-
// ── Auto-retry on "command not found" ──────────────────────────
|
|
1930
|
-
// Detect missing tools and instruct the model to install + retry.
|
|
1931
|
-
const NOT_FOUND_RE = /command not found|ENOENT.*spawn|is not recognized/i;
|
|
1932
|
-
if (!result.ok && NOT_FOUND_RE.test(output)) {
|
|
1933
|
-
const cmdName = call.name === "shell.exec"
|
|
1934
|
-
? String(call.args.command ?? "").split(/\s+/)[0]
|
|
1935
|
-
: call.name === "net.scan"
|
|
1936
|
-
? "nmap"
|
|
1937
|
-
: call.name === "image.ocr"
|
|
1938
|
-
? "tesseract"
|
|
1939
|
-
: undefined;
|
|
1940
|
-
if (cmdName) {
|
|
1941
|
-
writeNotice("warn", `${cmdName} not found — asking model to install and retry`, chalk.yellow(` ⚠ ${cmdName} not found — asking model to install and retry\n`));
|
|
1942
|
-
messages.push({
|
|
1943
|
-
role: "tool",
|
|
1944
|
-
content: `Tool failed: "${cmdName}" is not installed.\n` +
|
|
1945
|
-
`You MUST: 1) use pkg.install to install "${cmdName}", ` +
|
|
1946
|
-
`2) then RETRY the original command. Do NOT stop or give up.`,
|
|
1996
|
+
// Register a collapse/expand viewport so the user can pull the full raw
|
|
1997
|
+
// output back with Ctrl+O or `/output last` after the AI summary lands.
|
|
1998
|
+
if (output) {
|
|
1999
|
+
const viewport = registerViewport({
|
|
2000
|
+
toolName: call.name,
|
|
2001
|
+
argsDisplay: formatToolArgs(call),
|
|
2002
|
+
artifactPath: savedOutputPath,
|
|
2003
|
+
summary: contextOutput,
|
|
1947
2004
|
});
|
|
1948
|
-
|
|
2005
|
+
// Only print the Ctrl+O hint when there's a real artifact file
|
|
2006
|
+
// (large output saved to disk). Avoid spamming the hint for
|
|
2007
|
+
// every tiny tool call — the user can always use /output last.
|
|
2008
|
+
if (savedOutputPath) {
|
|
2009
|
+
const viewportHint = `${formatViewportHint(viewport)}\n`;
|
|
2010
|
+
writeStatus(viewportHint, viewportHint);
|
|
2011
|
+
}
|
|
1949
2012
|
}
|
|
1950
|
-
}
|
|
1951
|
-
// ── Auto-retry on "a terminal is required" sudo error ──────────────
|
|
1952
|
-
// Older Node versions and some non-TTY contexts can still surface the
|
|
1953
|
-
// canonical sudo "a terminal is required" or "no askpass program"
|
|
1954
|
-
// failure. Tell the model to retry through plain `sudo …` (which the
|
|
1955
|
-
// shell tool now inherits stdin for) instead of getting clever with
|
|
1956
|
-
// -S / askpass / piping a password.
|
|
1957
|
-
const SUDO_NEEDS_TTY_RE = /sudo:\s+a terminal is required to read the password|sudo:\s+a password is required|no askpass program|sudo: \d+ incorrect password attempts|sudo:\s+(?:no tty present|sorry, you must have a tty)/i;
|
|
1958
|
-
if (!result.ok && SUDO_NEEDS_TTY_RE.test(output)) {
|
|
1959
|
-
writeNotice("warn", "sudo needs an interactive terminal — asking the model to retry without -S/askpass", chalk.yellow(" ⚠ sudo needs an interactive terminal — asking the model to retry without -S/askpass\n"));
|
|
1960
2013
|
messages.push({
|
|
1961
2014
|
role: "tool",
|
|
1962
|
-
content:
|
|
1963
|
-
"On the next attempt: call shell.exec with `sudo <command>` directly. " +
|
|
1964
|
-
"clai inherits stdin from the user's terminal, so the user can type the password live. " +
|
|
1965
|
-
'DO NOT use `echo "<pwd>" | sudo -S`, DO NOT use SUDO_ASKPASS, DO NOT ask the user for the password in chat. ' +
|
|
1966
|
-
"Just run `sudo <command>` and the password prompt will be visible.",
|
|
2015
|
+
content: `Tool ${call.name} result (exit=${result.exitCode ?? 0}, ok=${result.ok}):\n${contextOutput}`,
|
|
1967
2016
|
});
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
// If we already streamed live output for this call, skip re-printing
|
|
1979
|
-
// the same bytes. Just note where the full output lives if it was saved.
|
|
1980
|
-
if (liveBytes > 0) {
|
|
1981
|
-
if (savedOutputPath) {
|
|
1982
|
-
writeNotice("info", `full output saved to ${savedOutputPath}`, chalk.dim(` full output saved to ${savedOutputPath}\n`));
|
|
2017
|
+
// Compact older messages when the running estimate exceeds budget so
|
|
2018
|
+
// free-tier context windows are not blown by long pentest sessions.
|
|
2019
|
+
if (estimateMessagesTokens(messages) > 24_000) {
|
|
2020
|
+
const compacted = compactMessages(messages);
|
|
2021
|
+
if (compacted.length < messages.length) {
|
|
2022
|
+
messages.splice(0, messages.length, ...compacted);
|
|
2023
|
+
await auditLog("agent.compact", {
|
|
2024
|
+
newLength: messages.length,
|
|
2025
|
+
estimatedTokens: estimateMessagesTokens(messages),
|
|
2026
|
+
});
|
|
1983
2027
|
}
|
|
1984
2028
|
}
|
|
1985
|
-
else {
|
|
1986
|
-
const renderedOutput = indentAndWrapText(displayText);
|
|
1987
|
-
writeToolOutput(toolEventId, displayText, styleToolChatter(call, renderedOutput) + "\n");
|
|
1988
|
-
}
|
|
1989
2029
|
}
|
|
1990
|
-
|
|
1991
|
-
|
|
2030
|
+
lastAnswer = `Stopped after ${productiveSteps} steps.`;
|
|
2031
|
+
writeNotice("warn", lastAnswer, " " + chalk.yellow(lastAnswer) + "\n");
|
|
2032
|
+
return finishTurn(lastAnswer, productiveSteps);
|
|
2033
|
+
}
|
|
2034
|
+
catch (error) {
|
|
2035
|
+
if (isAbortError(error, options.signal)) {
|
|
1992
2036
|
writeAbort();
|
|
1993
|
-
return
|
|
2037
|
+
return "Aborted.";
|
|
1994
2038
|
}
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
const viewport = registerViewport({
|
|
1999
|
-
toolName: call.name,
|
|
2000
|
-
argsDisplay: formatToolArgs(call),
|
|
2001
|
-
artifactPath: savedOutputPath,
|
|
2002
|
-
summary: contextOutput,
|
|
2003
|
-
});
|
|
2004
|
-
// Only print the Ctrl+O hint when there's a real artifact file
|
|
2005
|
-
// (large output saved to disk). Avoid spamming the hint for
|
|
2006
|
-
// every tiny tool call — the user can always use /output last.
|
|
2007
|
-
if (savedOutputPath) {
|
|
2008
|
-
const viewportHint = `${formatViewportHint(viewport)}\n`;
|
|
2009
|
-
writeStatus(viewportHint, viewportHint);
|
|
2010
|
-
}
|
|
2011
|
-
}
|
|
2012
|
-
messages.push({
|
|
2013
|
-
role: "tool",
|
|
2014
|
-
content: `Tool ${call.name} result (exit=${result.exitCode ?? 0}, ok=${result.ok}):\n${contextOutput}`,
|
|
2039
|
+
emit({
|
|
2040
|
+
type: "turn-error",
|
|
2041
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2015
2042
|
});
|
|
2016
|
-
|
|
2017
|
-
// free-tier context windows are not blown by long pentest sessions.
|
|
2018
|
-
if (estimateMessagesTokens(messages) > 24_000) {
|
|
2019
|
-
const compacted = compactMessages(messages);
|
|
2020
|
-
if (compacted.length < messages.length) {
|
|
2021
|
-
messages.splice(0, messages.length, ...compacted);
|
|
2022
|
-
await auditLog("agent.compact", {
|
|
2023
|
-
newLength: messages.length,
|
|
2024
|
-
estimatedTokens: estimateMessagesTokens(messages),
|
|
2025
|
-
});
|
|
2026
|
-
}
|
|
2027
|
-
}
|
|
2043
|
+
throw error;
|
|
2028
2044
|
}
|
|
2029
|
-
lastAnswer = `Stopped after ${productiveSteps} steps.`;
|
|
2030
|
-
writeNotice("warn", lastAnswer, " " + chalk.yellow(lastAnswer) + "\n");
|
|
2031
|
-
return finishTurn(lastAnswer, productiveSteps);
|
|
2032
2045
|
}
|
|
2033
2046
|
//# sourceMappingURL=runner.js.map
|