@pentoshi/clai 1.2.6 → 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/classic-renderer.d.ts +5 -0
- package/dist/agent/classic-renderer.js +124 -0
- package/dist/agent/classic-renderer.js.map +1 -0
- package/dist/agent/events.d.ts +62 -0
- package/dist/agent/events.js +2 -0
- package/dist/agent/events.js.map +1 -0
- package/dist/agent/runner.d.ts +8 -0
- package/dist/agent/runner.js +974 -837
- package/dist/agent/runner.js.map +1 -1
- package/dist/commands/update.js +1 -1
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -1
- package/dist/llm/capabilities.js +1 -0
- package/dist/llm/capabilities.js.map +1 -1
- package/dist/llm/http.js +19 -11
- package/dist/llm/http.js.map +1 -1
- package/dist/llm/openai.js +10 -1
- package/dist/llm/openai.js.map +1 -1
- package/dist/modes/agent.d.ts +4 -1
- package/dist/modes/agent.js.map +1 -1
- package/dist/repl.js +91 -45
- package/dist/repl.js.map +1 -1
- package/dist/tui/App.d.ts +8 -0
- package/dist/tui/App.js +273 -0
- package/dist/tui/App.js.map +1 -0
- package/dist/tui/can-use-tui.d.ts +20 -0
- package/dist/tui/can-use-tui.js +28 -0
- package/dist/tui/can-use-tui.js.map +1 -0
- package/dist/tui/components/Composer.d.ts +6 -0
- package/dist/tui/components/Composer.js +122 -0
- package/dist/tui/components/Composer.js.map +1 -0
- package/dist/tui/components/ConfirmModal.d.ts +6 -0
- package/dist/tui/components/ConfirmModal.js +16 -0
- package/dist/tui/components/ConfirmModal.js.map +1 -0
- package/dist/tui/components/Header.d.ts +7 -0
- package/dist/tui/components/Header.js +6 -0
- package/dist/tui/components/Header.js.map +1 -0
- package/dist/tui/components/JobsPanel.d.ts +11 -0
- package/dist/tui/components/JobsPanel.js +55 -0
- package/dist/tui/components/JobsPanel.js.map +1 -0
- package/dist/tui/components/Pager.d.ts +13 -0
- package/dist/tui/components/Pager.js +46 -0
- package/dist/tui/components/Pager.js.map +1 -0
- package/dist/tui/components/StatusLine.d.ts +7 -0
- package/dist/tui/components/StatusLine.js +25 -0
- package/dist/tui/components/StatusLine.js.map +1 -0
- package/dist/tui/components/items.d.ts +5 -0
- package/dist/tui/components/items.js +57 -0
- package/dist/tui/components/items.js.map +1 -0
- package/dist/tui/confirm.d.ts +16 -0
- package/dist/tui/confirm.js +41 -0
- package/dist/tui/confirm.js.map +1 -0
- package/dist/tui/hooks/useAgentRunner.d.ts +36 -0
- package/dist/tui/hooks/useAgentRunner.js +90 -0
- package/dist/tui/hooks/useAgentRunner.js.map +1 -0
- package/dist/tui/hooks/useJobs.d.ts +7 -0
- package/dist/tui/hooks/useJobs.js +39 -0
- package/dist/tui/hooks/useJobs.js.map +1 -0
- package/dist/tui/hooks/useSpinner.d.ts +5 -0
- package/dist/tui/hooks/useSpinner.js +19 -0
- package/dist/tui/hooks/useSpinner.js.map +1 -0
- package/dist/tui/index.d.ts +8 -0
- package/dist/tui/index.js +22 -0
- package/dist/tui/index.js.map +1 -0
- package/dist/tui/state.d.ts +101 -0
- package/dist/tui/state.js +323 -0
- package/dist/tui/state.js.map +1 -0
- package/dist/types.d.ts +1 -1
- package/package.json +6 -2
package/dist/agent/runner.js
CHANGED
|
@@ -16,7 +16,7 @@ import { auditLog } from "../store/logs.js";
|
|
|
16
16
|
import { loadProjectContext } from "../store/project.js";
|
|
17
17
|
import { loadScope, isScopeActive, targetInScope } from "../store/scope.js";
|
|
18
18
|
import { ensureProviderConfigured } from "../commands/providers.js";
|
|
19
|
-
import { rememberThinkingFromText, renderThinkingSummary, } from "../ui/thinking.js";
|
|
19
|
+
import { createThinkingStreamParser, rememberThinkingFromText, renderThinkingSummary, } from "../ui/thinking.js";
|
|
20
20
|
import { renderMarkdown, indentAndWrapText } from "../ui/markdown.js";
|
|
21
21
|
import { startThinkingSpinner } from "../ui/spinner.js";
|
|
22
22
|
import { safeCwd } from "../os/cwd.js";
|
|
@@ -740,7 +740,7 @@ const PRE_APPROVAL_ALLOWED_TOOLS = new Set([
|
|
|
740
740
|
export function isPreApprovalAllowedTool(name) {
|
|
741
741
|
return PRE_APPROVAL_ALLOWED_TOOLS.has(name);
|
|
742
742
|
}
|
|
743
|
-
function styleToolChatter(call, text) {
|
|
743
|
+
export function styleToolChatter(call, text) {
|
|
744
744
|
return shouldDimToolChatter(call) ? chalk.dim(text) : text;
|
|
745
745
|
}
|
|
746
746
|
function isAbortError(error, signal) {
|
|
@@ -819,7 +819,21 @@ function formatToolContext(call, result) {
|
|
|
819
819
|
: "";
|
|
820
820
|
return `${summary.text}${saved}`.trim();
|
|
821
821
|
}
|
|
822
|
-
|
|
822
|
+
const inquirerConfirmPort = {
|
|
823
|
+
async confirmTool(call) {
|
|
824
|
+
return confirm({
|
|
825
|
+
message: chalk.yellow(` run ${call.name}: ${formatToolArgs(call)}?`),
|
|
826
|
+
default: true,
|
|
827
|
+
});
|
|
828
|
+
},
|
|
829
|
+
async confirmPentest() {
|
|
830
|
+
return confirm({
|
|
831
|
+
message: chalk.red("clai only assists with security testing on systems you own or have written permission to test. Confirm for this session?"),
|
|
832
|
+
default: false,
|
|
833
|
+
});
|
|
834
|
+
},
|
|
835
|
+
};
|
|
836
|
+
async function ensurePentestAuthorization(call, autoConfirm, session, confirmPort) {
|
|
823
837
|
if (!isPentestToolCall(call))
|
|
824
838
|
return true;
|
|
825
839
|
// Persistent auth (via `clai authorize-pentest AGREE`) wins.
|
|
@@ -834,16 +848,13 @@ async function ensurePentestAuthorization(call, autoConfirm, session) {
|
|
|
834
848
|
session.pentestAuthorized.value = true;
|
|
835
849
|
return true;
|
|
836
850
|
}
|
|
837
|
-
const ok = await
|
|
838
|
-
message: chalk.red("clai only assists with security testing on systems you own or have written permission to test. Confirm for this session?"),
|
|
839
|
-
default: false,
|
|
840
|
-
});
|
|
851
|
+
const ok = await confirmPort.confirmPentest();
|
|
841
852
|
if (!ok)
|
|
842
853
|
return false;
|
|
843
854
|
session.pentestAuthorized.value = true;
|
|
844
855
|
return true;
|
|
845
856
|
}
|
|
846
|
-
async function confirmToolExecution(call, autoConfirm, session) {
|
|
857
|
+
async function confirmToolExecution(call, autoConfirm, session, confirmPort) {
|
|
847
858
|
const config = getConfig();
|
|
848
859
|
if (autoConfirm)
|
|
849
860
|
return true;
|
|
@@ -854,10 +865,7 @@ async function confirmToolExecution(call, autoConfirm, session) {
|
|
|
854
865
|
// set so authorizations never leak across processes.
|
|
855
866
|
if (config.allowAlwaysTools.includes(call.name))
|
|
856
867
|
return true;
|
|
857
|
-
return
|
|
858
|
-
message: chalk.yellow(` run ${call.name}: ${formatToolArgs(call)}?`),
|
|
859
|
-
default: true,
|
|
860
|
-
});
|
|
868
|
+
return confirmPort.confirmTool(call);
|
|
861
869
|
}
|
|
862
870
|
/** Build the system-context block describing the session's active plan. */
|
|
863
871
|
function planContextMessage(plan, approved) {
|
|
@@ -950,6 +958,7 @@ async function handlePlanTool(call, session, ctx) {
|
|
|
950
958
|
return {
|
|
951
959
|
handled: true,
|
|
952
960
|
ok: true,
|
|
961
|
+
plan,
|
|
953
962
|
display,
|
|
954
963
|
modelNote: `Plan saved with ${plan.tasks.length} task(s). STOP here and wait — produce NO other tool calls now. ` +
|
|
955
964
|
"Do NOT start executing tasks until the user approves with /implement. " +
|
|
@@ -1008,6 +1017,7 @@ async function handlePlanTool(call, session, ctx) {
|
|
|
1008
1017
|
return {
|
|
1009
1018
|
handled: true,
|
|
1010
1019
|
ok: true,
|
|
1020
|
+
plan,
|
|
1011
1021
|
display: checklist + "\n",
|
|
1012
1022
|
modelNote: allDone
|
|
1013
1023
|
? "Task updated. ALL tasks are now finished. Verify the result and give your final summary."
|
|
@@ -1015,895 +1025,1022 @@ async function handlePlanTool(call, session, ctx) {
|
|
|
1015
1025
|
};
|
|
1016
1026
|
}
|
|
1017
1027
|
export async function runAgentLoop(prompt, options = {}) {
|
|
1018
|
-
const
|
|
1019
|
-
const
|
|
1020
|
-
const
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
const
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
let lastAnswer = "";
|
|
1042
|
-
const session = options.session ?? createSessionPolicy();
|
|
1043
|
-
// ── Active plan context ────────────────────────────────────────────
|
|
1044
|
-
// If this session already has a plan, inject it so the model keeps it in
|
|
1045
|
-
// context. When the user has approved it (via /implement) we instruct the
|
|
1046
|
-
// agent to execute task by task; otherwise the agent should refine/wait.
|
|
1047
|
-
const activePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1048
|
-
if (activePlan) {
|
|
1049
|
-
systemSections.push(planContextMessage(activePlan, session.planApproved.value));
|
|
1050
|
-
}
|
|
1051
|
-
// For build/scaffold turns with no active plan yet, inject an explicit
|
|
1052
|
-
// workflow so the agent does NOT rush to write files in one shot. It must
|
|
1053
|
-
// explore the directory, read the relevant existing files to understand
|
|
1054
|
-
// what's already there, create a comprehensive multi-task plan, then
|
|
1055
|
-
// implement task by task until the goal is met. This mirrors how a careful
|
|
1056
|
-
// coding agent (Claude Code) operates.
|
|
1057
|
-
if (buildLikeTurn && !activePlan) {
|
|
1058
|
-
systemSections.push(buildWorkflowDirective());
|
|
1059
|
-
}
|
|
1060
|
-
const fullSystemPrompt = systemSections.join("\n\n");
|
|
1061
|
-
const userMessage = { role: "user", content: prompt };
|
|
1062
|
-
if (options.images && options.images.length > 0) {
|
|
1063
|
-
userMessage.images = options.images;
|
|
1064
|
-
}
|
|
1065
|
-
const messages = [
|
|
1066
|
-
{ role: "system", content: fullSystemPrompt },
|
|
1067
|
-
...(options.history ?? []),
|
|
1068
|
-
userMessage,
|
|
1069
|
-
];
|
|
1070
|
-
const recoveryUserMessage = (content) => {
|
|
1071
|
-
const message = { role: "user", content };
|
|
1072
|
-
if (options.images && options.images.length > 0) {
|
|
1073
|
-
// Some OpenAI-compatible gateways/models attend most strongly to the
|
|
1074
|
-
// latest user turn. Keep the image attached on recovery nudges so a
|
|
1075
|
-
// thinking-only retry does not degrade into OCR/tool guessing.
|
|
1076
|
-
message.images = options.images;
|
|
1028
|
+
const writesDirectly = !options.onEvent;
|
|
1029
|
+
const emit = (event) => options.onEvent?.(event);
|
|
1030
|
+
const noopSpinner = {
|
|
1031
|
+
setLabel: () => { },
|
|
1032
|
+
bumpReasoning: () => { },
|
|
1033
|
+
pushPreview: () => { },
|
|
1034
|
+
stop: () => { },
|
|
1035
|
+
};
|
|
1036
|
+
const writeStatus = (text, rendered = chalk.dim(text)) => {
|
|
1037
|
+
emit({ type: "status", text });
|
|
1038
|
+
if (writesDirectly)
|
|
1039
|
+
process.stdout.write(rendered);
|
|
1040
|
+
};
|
|
1041
|
+
const writeNotice = (level, text, rendered) => {
|
|
1042
|
+
emit({ type: "notice", level, text });
|
|
1043
|
+
if (writesDirectly)
|
|
1044
|
+
process.stdout.write(rendered);
|
|
1045
|
+
};
|
|
1046
|
+
const writeAssistantMessage = (text) => {
|
|
1047
|
+
emit({ type: "assistant-message", text });
|
|
1048
|
+
const rendered = renderMarkdown(text);
|
|
1049
|
+
if (writesDirectly) {
|
|
1050
|
+
process.stdout.write(text.endsWith("\n") ? rendered : `${rendered}\n`);
|
|
1077
1051
|
}
|
|
1078
|
-
return message;
|
|
1079
1052
|
};
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
// ── Step budget ───────────────────────────────────────────────────
|
|
1125
|
-
// The budget governs how many *productive* steps (a tool execution or a
|
|
1126
|
-
// final answer) the agent may take. Recovery iterations — nudging a model
|
|
1127
|
-
// that only produced thinking, asking it to re-emit a malformed tool call,
|
|
1128
|
-
// a freshness retry, or a loop-guard summary — do NOT consume this budget;
|
|
1129
|
-
// they get a separate hard ceiling so a wedged model can't spin forever.
|
|
1130
|
-
//
|
|
1131
|
-
// Complexity is a coarse signal from prompt length, but short follow-up
|
|
1132
|
-
// prompts ("do it", "build fully on your own", "app is not complete") in
|
|
1133
|
-
// the middle of a multi-file build must NOT be capped like a one-shot
|
|
1134
|
-
// lookup — that was the reason a React scaffold stopped half-built after
|
|
1135
|
-
// 10 steps. We bump the budget when the prompt (or recent history) looks
|
|
1136
|
-
// like a build/scaffold or a continuation of one.
|
|
1137
|
-
const analysis = analyzeTask(prompt);
|
|
1138
|
-
const hasHistory = (options.history?.length ?? 0) > 0;
|
|
1139
|
-
const buildLike = buildLikeTurn;
|
|
1140
|
-
let stepBudget = analysis.complexity === "simple"
|
|
1141
|
-
? 20
|
|
1142
|
-
: analysis.complexity === "standard"
|
|
1143
|
-
? 40
|
|
1144
|
-
: maxSteps;
|
|
1145
|
-
if (buildLike) {
|
|
1146
|
-
// Scaffolding / multi-file work needs room: many file writes plus a
|
|
1147
|
-
// verify/build step. Continuation prompts ("do it") inherit this too.
|
|
1148
|
-
stepBudget = Math.max(stepBudget, maxSteps);
|
|
1149
|
-
}
|
|
1150
|
-
else if (hasHistory) {
|
|
1151
|
-
// A follow-up to an ongoing task should never be capped tighter than a
|
|
1152
|
-
// standard one-shot, even if it's only a couple of words.
|
|
1153
|
-
stepBudget = Math.max(stepBudget, 40);
|
|
1154
|
-
}
|
|
1155
|
-
// Hard ceiling on total loop iterations (productive + recovery) so a model
|
|
1156
|
-
// stuck emitting only thinking or malformed calls can't loop indefinitely.
|
|
1157
|
-
const maxIterations = stepBudget * 3;
|
|
1158
|
-
let productiveSteps = 0;
|
|
1159
|
-
let step = -1;
|
|
1160
|
-
for (let iteration = 0; iteration < maxIterations; iteration += 1) {
|
|
1161
|
-
// `step` is the productive-step index (used for display + audit). It only
|
|
1162
|
-
// advances when the previous iteration actually executed a tool.
|
|
1163
|
-
step = productiveSteps;
|
|
1164
|
-
if (productiveSteps >= stepBudget)
|
|
1165
|
-
break;
|
|
1166
|
-
options.signal?.throwIfAborted();
|
|
1167
|
-
// `call` and `assistantText` are shared by both paths below: a fresh
|
|
1168
|
-
// model round-trip, or draining a previously-queued tool call.
|
|
1169
|
-
let call;
|
|
1170
|
-
let assistantText;
|
|
1171
|
-
let recoveredFromBareJson = false;
|
|
1172
|
-
if (pendingCalls.length > 0) {
|
|
1173
|
-
// Drain the next queued call from the previous model message — no new
|
|
1174
|
-
// round-trip. The assistant message and any prose were already shown
|
|
1175
|
-
// when the batch was parsed.
|
|
1176
|
-
call = pendingCalls.shift();
|
|
1177
|
-
assistantText = { visible: "", thinkContent: "", hasThinking: false };
|
|
1178
|
-
process.stdout.write(chalk.dim(` ↳ continuing batch (${pendingCalls.length} more queued)\n`));
|
|
1053
|
+
const writeThinkingBlock = (content) => {
|
|
1054
|
+
emit({ type: "thinking-block", content });
|
|
1055
|
+
if (writesDirectly)
|
|
1056
|
+
process.stdout.write(`${renderThinkingSummary(content)}\n`);
|
|
1057
|
+
};
|
|
1058
|
+
const writeToolOutput = (id, chunk, rendered) => {
|
|
1059
|
+
emit({ type: "tool-output", id, chunk });
|
|
1060
|
+
if (writesDirectly)
|
|
1061
|
+
process.stdout.write(rendered);
|
|
1062
|
+
};
|
|
1063
|
+
const writeToolCall = (id, call, rendered) => {
|
|
1064
|
+
emit({
|
|
1065
|
+
type: "tool-call",
|
|
1066
|
+
id,
|
|
1067
|
+
name: call.name,
|
|
1068
|
+
argsDisplay: formatToolArgs(call),
|
|
1069
|
+
});
|
|
1070
|
+
if (writesDirectly)
|
|
1071
|
+
process.stdout.write(rendered);
|
|
1072
|
+
};
|
|
1073
|
+
const writePlanUpdate = (plan, rendered) => {
|
|
1074
|
+
emit({ type: "plan-update", plan });
|
|
1075
|
+
if (writesDirectly)
|
|
1076
|
+
process.stdout.write(rendered);
|
|
1077
|
+
};
|
|
1078
|
+
const writeToolBlocked = (id, name, reason, rendered) => {
|
|
1079
|
+
emit({ type: "tool-blocked", id, name, reason });
|
|
1080
|
+
if (writesDirectly)
|
|
1081
|
+
process.stdout.write(rendered);
|
|
1082
|
+
};
|
|
1083
|
+
const writeAbort = () => {
|
|
1084
|
+
emit({ type: "turn-aborted" });
|
|
1085
|
+
if (writesDirectly)
|
|
1086
|
+
process.stdout.write(chalk.yellow(" ⏹ Aborted.\n"));
|
|
1087
|
+
};
|
|
1088
|
+
const emitToolResult = (id, result, summary, artifactPath) => {
|
|
1089
|
+
const event = {
|
|
1090
|
+
type: "tool-result",
|
|
1091
|
+
id,
|
|
1092
|
+
ok: result.ok,
|
|
1093
|
+
summary,
|
|
1094
|
+
};
|
|
1095
|
+
if (typeof result.exitCode === "number") {
|
|
1096
|
+
event.exitCode = result.exitCode;
|
|
1179
1097
|
}
|
|
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
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
if (!call && assistantText.hasThinking) {
|
|
1252
|
-
call = parseToolCall(assistantText.thinkContent, {
|
|
1253
|
-
strict: getConfig().parserStrict,
|
|
1254
|
-
});
|
|
1255
|
-
if (call) {
|
|
1256
|
-
process.stdout.write(chalk.dim(" ℹ recovered tool call from thinking content\n"));
|
|
1257
|
-
}
|
|
1098
|
+
if (artifactPath) {
|
|
1099
|
+
event.artifactPath = artifactPath;
|
|
1100
|
+
}
|
|
1101
|
+
emit(event);
|
|
1102
|
+
};
|
|
1103
|
+
const finishTurn = (answer, steps) => {
|
|
1104
|
+
emit({ type: "turn-end", finalAnswer: answer, steps });
|
|
1105
|
+
return answer;
|
|
1106
|
+
};
|
|
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 };
|
|
1154
|
+
if (options.images && options.images.length > 0) {
|
|
1155
|
+
userMessage.images = options.images;
|
|
1156
|
+
}
|
|
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;
|
|
1258
1169
|
}
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
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);
|
|
1241
|
+
}
|
|
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));
|
|
1289
1273
|
}
|
|
1290
1274
|
else {
|
|
1291
|
-
//
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
recoveredFromBareJson = false;
|
|
1302
|
-
if (!call) {
|
|
1303
|
-
const bare = recognizeBareToolJson(assistantText.visible);
|
|
1304
|
-
if (bare?.call) {
|
|
1305
|
-
call = bare.call;
|
|
1306
|
-
recoveredFromBareJson = true;
|
|
1307
|
-
process.stdout.write(chalk.dim(" ℹ recovered an unfenced tool call from bare JSON\n"));
|
|
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 });
|
|
1308
1285
|
}
|
|
1309
|
-
|
|
1310
|
-
|
|
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;
|
|
1294
|
+
emit({ type: "status", text: "thinking" });
|
|
1295
|
+
}
|
|
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
|
+
});
|
|
1311
1348
|
}
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
const bareThink = recognizeBareToolJson(assistantText.thinkContent);
|
|
1316
|
-
if (bareThink?.call) {
|
|
1317
|
-
call = bareThink.call;
|
|
1318
|
-
recoveredFromBareJson = true;
|
|
1319
|
-
process.stdout.write(chalk.dim(" ℹ recovered an unfenced tool call from thinking content\n"));
|
|
1349
|
+
finally {
|
|
1350
|
+
// Always clear the spinner — abort, network error, or success.
|
|
1351
|
+
spinner.stop();
|
|
1320
1352
|
}
|
|
1321
|
-
|
|
1322
|
-
|
|
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, {
|
|
1363
|
+
strict: getConfig().parserStrict,
|
|
1364
|
+
});
|
|
1365
|
+
if (!call && assistantText.hasThinking) {
|
|
1366
|
+
call = parseToolCall(assistantText.thinkContent, {
|
|
1367
|
+
strict: getConfig().parserStrict,
|
|
1368
|
+
});
|
|
1369
|
+
if (call) {
|
|
1370
|
+
writeNotice("info", "recovered tool call from thinking content", chalk.dim(" ℹ recovered tool call from thinking content\n"));
|
|
1371
|
+
}
|
|
1323
1372
|
}
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
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. " +
|
|
1333
1389
|
"This is a BUILD/SCAFFOLD task with NO plan yet. " +
|
|
1334
|
-
"You MUST call plan.create using
|
|
1335
|
-
|
|
1336
|
-
"
|
|
1337
|
-
: "
|
|
1338
|
-
"
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
"
|
|
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));
|
|
1342
1399
|
continue;
|
|
1343
1400
|
}
|
|
1344
|
-
// Exhausted retries — fall through to the normal answer path
|
|
1401
|
+
// Exhausted retries — fall through to the normal empty-answer path
|
|
1402
|
+
// which will print a warning and return.
|
|
1345
1403
|
}
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
// model to retry the tool call in a clean JSON format.
|
|
1350
|
-
if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
|
|
1351
|
-
process.stdout.write(chalk.yellow(" ⚠ tool call was malformed or cut off — asking the model to retry in JSON form\n"));
|
|
1352
|
-
messages.push({ role: "assistant", content: assistantText.visible });
|
|
1353
|
-
messages.push(recoveryUserMessage("Your previous tool call was malformed or truncated. " +
|
|
1354
|
-
"Reply with ONLY a fenced ```tool block containing valid JSON " +
|
|
1355
|
-
'of the form `{"name": "<tool>", "args": { ... }}`. ' +
|
|
1356
|
-
"Do not use <|tool_call_begin|> markers."));
|
|
1357
|
-
continue;
|
|
1404
|
+
else {
|
|
1405
|
+
// Reset the counter on any successful visible output or recovered call.
|
|
1406
|
+
emptyVisibleRetries = 0;
|
|
1358
1407
|
}
|
|
1359
|
-
//
|
|
1360
|
-
//
|
|
1361
|
-
//
|
|
1362
|
-
//
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
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;
|
|
1376
1425
|
}
|
|
1377
|
-
// Exhausted retries — fall through so we don't loop forever, but the
|
|
1378
|
-
// user at least sees the (broken) output and the stop notice.
|
|
1379
1426
|
}
|
|
1380
|
-
//
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
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
|
+
}
|
|
1438
|
+
}
|
|
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"));
|
|
1394
1466
|
messages.push({ role: "assistant", content: assistantText.visible });
|
|
1395
|
-
messages.push(
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
'Re-emit ONE valid ```tool block of the exact form {"name":"<tool>","args":{...}} with balanced braces. ' +
|
|
1400
|
-
"If it was a large fs.writeMany, split it into SMALLER batches (3-5 files) so the JSON is easy to keep valid. " +
|
|
1401
|
-
"Do NOT claim any file was written until a tool call actually succeeds.",
|
|
1402
|
-
});
|
|
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."));
|
|
1403
1471
|
continue;
|
|
1404
1472
|
}
|
|
1405
|
-
//
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
actionIntentRetries += 1;
|
|
1426
|
-
let nudge;
|
|
1427
|
-
if (activePlan && session.planApproved.value) {
|
|
1428
|
-
nudge =
|
|
1429
|
-
"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.";
|
|
1430
|
-
process.stdout.write(chalk.yellow(" ⚠ described an action but emitted no tool call — nudging it to run one\n"));
|
|
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.
|
|
1431
1493
|
}
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
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.
|
|
1441
1520
|
}
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
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
|
+
}
|
|
1563
|
+
messages.push({ role: "assistant", content: assistantText.visible });
|
|
1564
|
+
messages.push(recoveryUserMessage(nudge));
|
|
1565
|
+
continue;
|
|
1448
1566
|
}
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
}
|
|
1453
|
-
if (freshWebSearchRequired && !sawFreshWebSearch && !freshnessRetryUsed) {
|
|
1454
|
-
freshnessRetryUsed = true;
|
|
1455
|
-
process.stdout.write(chalk.dim(" ℹ current-info question detected — searching the web before answering\n"));
|
|
1456
|
-
messages.push({ role: "assistant", content: assistantText.visible });
|
|
1457
|
-
messages.push({
|
|
1458
|
-
role: "user",
|
|
1459
|
-
content: freshnessGuardMessage() +
|
|
1460
|
-
" Reply with ONLY a fenced ```tool block for web.search now.",
|
|
1461
|
-
});
|
|
1462
|
-
continue;
|
|
1463
|
-
}
|
|
1464
|
-
// ── Premature-completion guard (approved plan still has work) ──────
|
|
1465
|
-
// If the user approved a plan and the model now gives a final answer
|
|
1466
|
-
// while tasks are still pending/in_progress — without having run the
|
|
1467
|
-
// work — it is fabricating completion (the exact "all tasks completed,
|
|
1468
|
-
// running at localhost:5173" failure). Force it back to executing the
|
|
1469
|
-
// next real task instead of accepting the false claim.
|
|
1470
|
-
if (session.planApproved.value && prematureCompletionRetries < 3) {
|
|
1471
|
-
const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1472
|
-
const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
|
|
1473
|
-
if (livePlan && unfinished && unfinished.length > 0) {
|
|
1474
|
-
prematureCompletionRetries += 1;
|
|
1475
|
-
const next = unfinished[0];
|
|
1476
|
-
process.stdout.write(chalk.yellow(` ⚠ ${unfinished.length} plan task(s) still unfinished — not accepting a "done" claim; resuming execution\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"));
|
|
1477
1570
|
messages.push({ role: "assistant", content: assistantText.visible });
|
|
1478
1571
|
messages.push({
|
|
1479
1572
|
role: "user",
|
|
1480
|
-
content:
|
|
1481
|
-
|
|
1482
|
-
`Do NOT claim the work is complete, that files were created, or that a server is running ` +
|
|
1483
|
-
`unless a tool call actually succeeded and you saw the output. ` +
|
|
1484
|
-
`Resume now with the NEXT task ${next.id} ("${next.title}"): call task.update {taskId:"${next.id}", state:"in_progress"}, ` +
|
|
1485
|
-
`then do the real work with a tool call (fs.writeMany / shell.exec / shell.start), VERIFY it, and mark it done. ` +
|
|
1486
|
-
`Continue task by task until EVERY task is actually finished.`,
|
|
1573
|
+
content: freshnessGuardMessage() +
|
|
1574
|
+
" Reply with ONLY a fenced ```tool block for web.search now.",
|
|
1487
1575
|
});
|
|
1488
1576
|
continue;
|
|
1489
1577
|
}
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
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
|
+
}
|
|
1506
1604
|
}
|
|
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
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
if (cleaned) {
|
|
1627
|
+
writeAssistantMessage(cleaned);
|
|
1628
|
+
}
|
|
1629
|
+
if (completionWarning) {
|
|
1630
|
+
writeNotice("warn", completionWarningText, completionWarning);
|
|
1631
|
+
}
|
|
1632
|
+
if (assistantText.hasThinking) {
|
|
1633
|
+
writeThinkingBlock(assistantText.thinkContent);
|
|
1634
|
+
}
|
|
1635
|
+
await auditLog("agent.final", { provider, model, steps: step + 1 });
|
|
1636
|
+
lastAnswer = cleaned;
|
|
1637
|
+
return finishTurn(lastAnswer, step + 1);
|
|
1507
1638
|
}
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
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);
|
|
1515
1648
|
}
|
|
1516
1649
|
if (assistantText.hasThinking) {
|
|
1517
|
-
|
|
1650
|
+
writeThinkingBlock(assistantText.thinkContent);
|
|
1651
|
+
}
|
|
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
|
+
}
|
|
1518
1664
|
}
|
|
1519
|
-
await auditLog("agent.final", { provider, model, steps: step + 1 });
|
|
1520
|
-
lastAnswer = cleaned;
|
|
1521
|
-
return lastAnswer;
|
|
1522
1665
|
}
|
|
1523
|
-
//
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
//
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
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;
|
|
1532
1700
|
}
|
|
1533
|
-
if (
|
|
1534
|
-
|
|
1701
|
+
if (loopCheck.reason) {
|
|
1702
|
+
writeNotice("info", loopCheck.reason, chalk.dim(` ℹ ${loopCheck.reason}\n`));
|
|
1535
1703
|
}
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
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;
|
|
1547
1727
|
}
|
|
1548
1728
|
}
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
// names into a shell.exec call BEFORE classification so the command both
|
|
1556
|
-
// runs and is safety-classified as the shell command it really is —
|
|
1557
|
-
// instead of dead-ending on "Unknown tool: sed".
|
|
1558
|
-
call = normalizeToolCall(call);
|
|
1559
|
-
// ── Duplicate-call detection ──────────────────────────────────────────
|
|
1560
|
-
// If the model calls the exact same tool with the exact same args
|
|
1561
|
-
// repeatedly, it's stuck in a loop. Inject a corrective message
|
|
1562
|
-
// telling it to summarize the results it already has.
|
|
1563
|
-
const loopCheck = loopGuard.shouldBlock(call.name, call.args);
|
|
1564
|
-
if (loopCheck.block) {
|
|
1565
|
-
const isWrite = call.name === "fs.write" ||
|
|
1566
|
-
call.name === "fs.writeMany" ||
|
|
1567
|
-
call.name === "fs.edit";
|
|
1568
|
-
process.stdout.write(chalk.yellow(` ⚠ ${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}\n`));
|
|
1569
|
-
// A repeat means this batch went off the rails — drop any queued calls
|
|
1570
|
-
// and let the model react. The assistant message was already recorded.
|
|
1571
|
-
pendingCalls = [];
|
|
1572
|
-
messages.push({
|
|
1573
|
-
role: "user",
|
|
1574
|
-
content: isWrite
|
|
1575
|
-
? `You already wrote that exact file with ${call.name}. It is saved. ` +
|
|
1576
|
-
"Do NOT write it again. Move on to the NEXT file or step. If every file is written, " +
|
|
1577
|
-
"verify the project (list the tree, run the build/install command) and give your final answer."
|
|
1578
|
-
: `You already called ${call.name} with the same arguments and received results. ` +
|
|
1579
|
-
"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)",
|
|
1580
1735
|
});
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
loopGuard.recordAttempt(step, call.name, call.args, planResult.ok, 0);
|
|
1597
|
-
process.stdout.write(planResult.display);
|
|
1598
|
-
// plan.create means "STOP and wait for /implement" — abandon any
|
|
1599
|
-
// other calls the model batched alongside it.
|
|
1600
|
-
if (call.name === "plan.create")
|
|
1601
|
-
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 = [];
|
|
1602
1751
|
messages.push({
|
|
1603
|
-
role: "
|
|
1604
|
-
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.`,
|
|
1605
1758
|
});
|
|
1606
1759
|
continue;
|
|
1607
1760
|
}
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
call
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
`not approval. Update the plan to incorporate their feedback by calling plan.create again with the ` +
|
|
1635
|
-
`revised goal/detail/tasks, then STOP and wait. The user approves with /implement or cancels with /discard. ` +
|
|
1636
|
-
`Do NOT run any execution tool (shell.exec, pkg.install, fs.write, net.scan, tool.check, etc.) until they /implement.`,
|
|
1637
|
-
});
|
|
1638
|
-
continue;
|
|
1639
|
-
}
|
|
1640
|
-
if (call.name === "web.search") {
|
|
1641
|
-
sawFreshWebSearch = true;
|
|
1642
|
-
}
|
|
1643
|
-
// Show tool call
|
|
1644
|
-
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) + chalk.gray(` ${formatToolArgs(call)}`);
|
|
1645
|
-
process.stdout.write(styleToolChatter(call, toolCallLine) + "\n");
|
|
1646
|
-
const scopeTarget = scopeTargetForToolCall(call);
|
|
1647
|
-
if (scopeTarget &&
|
|
1648
|
-
(!isScopeActive(scope) || !targetInScope(scopeTarget, scope))) {
|
|
1649
|
-
process.stdout.write(chalk.dim(` scope optional: ${scopeHint(scopeTarget)}\n`));
|
|
1650
|
-
}
|
|
1651
|
-
if (decision.level === "block") {
|
|
1652
|
-
process.stdout.write(chalk.red(` ✗ blocked: ${decision.reason}`) + "\n");
|
|
1653
|
-
lastAnswer = `Blocked: ${call.name} — ${decision.reason}`;
|
|
1654
|
-
return lastAnswer;
|
|
1655
|
-
}
|
|
1656
|
-
// Pentest authorization — if user confirms this, skip the per-tool confirm
|
|
1657
|
-
let pentestJustConfirmed = false;
|
|
1658
|
-
const needsPentestAuth = isPentestToolCall(call) &&
|
|
1659
|
-
!getConfig().pentestAuthorized &&
|
|
1660
|
-
!session.pentestAuthorized.value;
|
|
1661
|
-
const authorized = await ensurePentestAuthorization(call, Boolean(options.autoConfirm), session);
|
|
1662
|
-
// inquirer's confirm() creates its own readline interface which resets
|
|
1663
|
-
// raw mode AND pauses stdin when it finishes. Re-assert raw mode and
|
|
1664
|
-
// resume stdin so the outer keypress handler (ESC/Ctrl+C abort, Ctrl+O
|
|
1665
|
-
// output pane) keeps working during the next streaming/tool phase.
|
|
1666
|
-
restoreInteractiveStdin();
|
|
1667
|
-
if (!authorized) {
|
|
1668
|
-
lastAnswer = "Pentest authorization not confirmed.";
|
|
1669
|
-
process.stdout.write(chalk.red(` ✗ ${lastAnswer}`) + "\n");
|
|
1670
|
-
return lastAnswer;
|
|
1671
|
-
}
|
|
1672
|
-
if (needsPentestAuth) {
|
|
1673
|
-
pentestJustConfirmed = true;
|
|
1674
|
-
}
|
|
1675
|
-
// Confirm if needed (safe tools auto-execute, pentest-auth'd tools skip)
|
|
1676
|
-
// fs.delete and shell deletions NEVER auto-confirm even with -y flag.
|
|
1677
|
-
const forceManualConfirm = call.name === "fs.delete";
|
|
1678
|
-
if (decision.level === "confirm" && !pentestJustConfirmed) {
|
|
1679
|
-
const ok = await confirmToolExecution(call, forceManualConfirm ? false : Boolean(options.autoConfirm), session);
|
|
1680
|
-
// Re-assert raw mode and resume stdin after inquirer's confirm()
|
|
1681
|
-
// (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.
|
|
1682
1787
|
restoreInteractiveStdin();
|
|
1683
|
-
if (!
|
|
1684
|
-
lastAnswer = "
|
|
1685
|
-
|
|
1686
|
-
return lastAnswer;
|
|
1788
|
+
if (!authorized) {
|
|
1789
|
+
lastAnswer = "Pentest authorization not confirmed.";
|
|
1790
|
+
writeToolBlocked(toolEventId, call.name, lastAnswer, chalk.red(` ✗ ${lastAnswer}`) + "\n");
|
|
1791
|
+
return finishTurn(lastAnswer, productiveSteps);
|
|
1687
1792
|
}
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
let liveBytes = 0;
|
|
1704
|
-
const liveCap = 16_000; // Stop streaming after this many bytes to avoid flooding the terminal.
|
|
1705
|
-
let liveTruncatedNotified = false;
|
|
1706
|
-
let lastProgressAt = 0;
|
|
1707
|
-
// When the underlying command may pause for a password prompt
|
|
1708
|
-
// (sudo / ssh / etc.) we stream the live preview *without* the dim
|
|
1709
|
-
// attribute so the prompt is fully readable. Otherwise we keep the
|
|
1710
|
-
// dim styling that makes ordinary tool chatter visually distinct
|
|
1711
|
-
// from the model's prose.
|
|
1712
|
-
const shouldDimLive = !interactiveCommand;
|
|
1713
|
-
const printLive = (chunk) => {
|
|
1714
|
-
// Suppress live preview for fs.read / fs.list — those are read-only
|
|
1715
|
-
// and the final summary is already concise. Stream shell-style tools
|
|
1716
|
-
// (shell.exec, net.scan, pentest.recon, pkg.install).
|
|
1717
|
-
if (call.name === "fs.read" ||
|
|
1718
|
-
call.name === "fs.list" ||
|
|
1719
|
-
call.name === "fs.search")
|
|
1720
|
-
return;
|
|
1721
|
-
if (liveBytes >= liveCap) {
|
|
1722
|
-
if (!liveTruncatedNotified) {
|
|
1723
|
-
liveTruncatedNotified = true;
|
|
1724
|
-
process.stdout.write(chalk.dim("\n … live preview truncated, full output saved\n"));
|
|
1725
|
-
process.stdout.write(chalk.dim(" (tool still running — ESC or Ctrl+C to abort)\n"));
|
|
1726
|
-
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);
|
|
1727
1808
|
}
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
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");
|
|
1734
1881
|
}
|
|
1735
|
-
return;
|
|
1736
1882
|
}
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
result
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
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),
|
|
1756
1922
|
});
|
|
1757
|
-
//
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
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
|
+
}
|
|
1766
1951
|
}
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
}
|
|
1777
|
-
const output = result.output.trim();
|
|
1778
|
-
const displayMax = 6_000;
|
|
1779
|
-
// If the tool already produced an artifact (shell.exec now streams to one
|
|
1780
|
-
// as it runs), respect that path. Otherwise, fall back to the post-hoc
|
|
1781
|
-
// save for tools that return their full output in memory.
|
|
1782
|
-
const savedOutputPath = result.outputPath ??
|
|
1783
|
-
(output.length > displayMax
|
|
1784
|
-
? await saveToolOutput(call, output)
|
|
1785
|
-
: undefined);
|
|
1786
|
-
const resultWithArtifact = {
|
|
1787
|
-
...result,
|
|
1788
|
-
outputPath: savedOutputPath,
|
|
1789
|
-
truncated: result.truncated ?? Boolean(savedOutputPath),
|
|
1790
|
-
};
|
|
1791
|
-
options.onToolResult?.(call, resultWithArtifact);
|
|
1792
|
-
await auditLog("tool.result", {
|
|
1793
|
-
call,
|
|
1794
|
-
ok: result.ok,
|
|
1795
|
-
exitCode: result.exitCode,
|
|
1796
|
-
output: result.output.slice(0, 4_000),
|
|
1797
|
-
});
|
|
1798
|
-
// Record the attempt in the loop guard for dedup tracking.
|
|
1799
|
-
loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode);
|
|
1800
|
-
// A tool actually executed this iteration — count it against the
|
|
1801
|
-
// productive-step budget. Recovery iterations (thinking-only nudges,
|
|
1802
|
-
// malformed-call retries, freshness/loop-guard prompts) reach `continue`
|
|
1803
|
-
// before this point and therefore never consume the budget.
|
|
1804
|
-
productiveSteps += 1;
|
|
1805
|
-
// ── Auto-retry on "command not found" ──────────────────────────
|
|
1806
|
-
// Detect missing tools and instruct the model to install + retry.
|
|
1807
|
-
const NOT_FOUND_RE = /command not found|ENOENT.*spawn|is not recognized/i;
|
|
1808
|
-
if (!result.ok && NOT_FOUND_RE.test(output)) {
|
|
1809
|
-
const cmdName = call.name === "shell.exec"
|
|
1810
|
-
? String(call.args.command ?? "").split(/\s+/)[0]
|
|
1811
|
-
: call.name === "net.scan"
|
|
1812
|
-
? "nmap"
|
|
1813
|
-
: call.name === "image.ocr"
|
|
1814
|
-
? "tesseract"
|
|
1815
|
-
: undefined;
|
|
1816
|
-
if (cmdName) {
|
|
1817
|
-
process.stdout.write(chalk.yellow(` ⚠ ${cmdName} not found — asking model to install and retry\n`));
|
|
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"));
|
|
1818
1961
|
messages.push({
|
|
1819
1962
|
role: "tool",
|
|
1820
|
-
content:
|
|
1821
|
-
|
|
1822
|
-
|
|
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.",
|
|
1823
1968
|
});
|
|
1824
1969
|
continue;
|
|
1825
1970
|
}
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
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)) {
|
|
1992
|
+
lastAnswer = "Aborted.";
|
|
1993
|
+
writeAbort();
|
|
1994
|
+
return lastAnswer;
|
|
1995
|
+
}
|
|
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,
|
|
2004
|
+
});
|
|
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
|
+
}
|
|
2012
|
+
}
|
|
1836
2013
|
messages.push({
|
|
1837
2014
|
role: "tool",
|
|
1838
|
-
content:
|
|
1839
|
-
"On the next attempt: call shell.exec with `sudo <command>` directly. " +
|
|
1840
|
-
"clai inherits stdin from the user's terminal, so the user can type the password live. " +
|
|
1841
|
-
'DO NOT use `echo "<pwd>" | sudo -S`, DO NOT use SUDO_ASKPASS, DO NOT ask the user for the password in chat. ' +
|
|
1842
|
-
"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}`,
|
|
1843
2016
|
});
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
// If we already streamed live output for this call, skip re-printing
|
|
1855
|
-
// the same bytes. Just note where the full output lives if it was saved.
|
|
1856
|
-
if (liveBytes > 0) {
|
|
1857
|
-
if (savedOutputPath) {
|
|
1858
|
-
process.stdout.write(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
|
+
});
|
|
1859
2027
|
}
|
|
1860
2028
|
}
|
|
1861
|
-
else {
|
|
1862
|
-
const renderedOutput = indentAndWrapText(displayText);
|
|
1863
|
-
process.stdout.write(styleToolChatter(call, renderedOutput) + "\n");
|
|
1864
|
-
}
|
|
1865
|
-
}
|
|
1866
|
-
if (isAbortError(undefined, options.signal)) {
|
|
1867
|
-
lastAnswer = "Aborted.";
|
|
1868
|
-
process.stdout.write(chalk.yellow(" ⏹ Aborted.\n"));
|
|
1869
|
-
return lastAnswer;
|
|
1870
2029
|
}
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
summary: contextOutput,
|
|
1880
|
-
});
|
|
1881
|
-
// Only print the Ctrl+O hint when there's a real artifact file
|
|
1882
|
-
// (large output saved to disk). Avoid spamming the hint for
|
|
1883
|
-
// every tiny tool call — the user can always use /output last.
|
|
1884
|
-
if (savedOutputPath) {
|
|
1885
|
-
process.stdout.write(`${formatViewportHint(viewport)}\n`);
|
|
1886
|
-
}
|
|
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)) {
|
|
2036
|
+
writeAbort();
|
|
2037
|
+
return "Aborted.";
|
|
1887
2038
|
}
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
2039
|
+
emit({
|
|
2040
|
+
type: "turn-error",
|
|
2041
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1891
2042
|
});
|
|
1892
|
-
|
|
1893
|
-
// free-tier context windows are not blown by long pentest sessions.
|
|
1894
|
-
if (estimateMessagesTokens(messages) > 24_000) {
|
|
1895
|
-
const compacted = compactMessages(messages);
|
|
1896
|
-
if (compacted.length < messages.length) {
|
|
1897
|
-
messages.splice(0, messages.length, ...compacted);
|
|
1898
|
-
await auditLog("agent.compact", {
|
|
1899
|
-
newLength: messages.length,
|
|
1900
|
-
estimatedTokens: estimateMessagesTokens(messages),
|
|
1901
|
-
});
|
|
1902
|
-
}
|
|
1903
|
-
}
|
|
2043
|
+
throw error;
|
|
1904
2044
|
}
|
|
1905
|
-
lastAnswer = `Stopped after ${productiveSteps} steps.`;
|
|
1906
|
-
process.stdout.write(" " + chalk.yellow(lastAnswer) + "\n");
|
|
1907
|
-
return lastAnswer;
|
|
1908
2045
|
}
|
|
1909
2046
|
//# sourceMappingURL=runner.js.map
|