Skip to content

Commit bde6be7

Browse files
author
build_agent
committed
fix_logs_chat
1 parent 765af51 commit bde6be7

27 files changed

Lines changed: 6773 additions & 3241 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ Implemented and known-working:
176176
highlights, selection → "Add to Chat" floating action
177177
- Chat panel: Ask/Agent modes, slash commands, model dropdown, file mention
178178
chips with individual remove, drag-files-into-chat, code-block Apply,
179-
streaming agent thinking, regenerate / copy / stop · shared SVG chevrons
179+
regenerate / copy / stop · shared SVG chevrons
180180
(`ChevronExpand` / `PlayTriangle`) for disclosures and small affordances
181181
- Composer: `MentionInput` auto-grow; avoid spurious empty-state scrollbar
182182
(overflow hidden until max height — see `MentionInput.tsx` + `styles.css`)

app/backend/src/agent/executor.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { listFiles, readFile, writeFile, searchCode, buildCodebaseMapSummary, globFiles } from "../tools/file.js";
22
import { runSmartCommand } from "../tools/smartCommand.js";
3-
import { applyPatches, type PatchResult } from "../tools/patch.js";
3+
import { applyPatches, patchApplyErrorCode, validateWritePatchPayload, type PatchResult } from "../tools/patch.js";
44
import { autoValidate, summarizeValidation, type ValidationReport } from "../validation/validator.js";
55
import { startAgentCommand } from "./commandLog.js";
66
import { getWorkspace } from "../utils/workspace.js";
@@ -253,12 +253,26 @@ export async function executeTool(
253253
case "write_patch": {
254254
const raw = String(input.patches ?? input.patch ?? "");
255255
const path = typeof input.path === "string" ? input.path : undefined;
256-
if (!raw) return { ok: false, summary: "write_patch: missing 'patches'" };
256+
if (!raw) return { ok: false, summary: "[WP_INPUT] write_patch: missing 'patches'." };
257+
const pathTrim = path?.trim();
258+
const formatErr = validateWritePatchPayload(raw, pathTrim);
259+
if (formatErr) return { ok: false, summary: `[${formatErr.code}] ${formatErr.message}` };
257260
const results: PatchResult[] = await applyPatches(raw, path);
258-
if (results.length === 0) return { ok: false, summary: "write_patch: no valid SEARCH/REPLACE blocks parsed" };
261+
if (results.length === 0) {
262+
return {
263+
ok: false,
264+
summary:
265+
"[WP_PARSE_NONE] No SEARCH/REPLACE blocks could be parsed. Use FILE: path then SEARCH/REPLACE/END, or pass path + body starting with SEARCH.",
266+
};
267+
}
259268

260269
const ok = results.every((r) => r.applied);
261-
const lines = results.map((r) => r.applied ? `OK ${r.path}` : `FAIL ${r.path}: ${r.error}`);
270+
const lines = results.map((r) => {
271+
if (r.applied) return `OK ${r.path}`;
272+
const code = patchApplyErrorCode(r.error);
273+
const oneLine = (r.error ?? "unknown").replace(/\s+/g, " ").trim();
274+
return `FAIL [${code}] ${r.path}${oneLine}`;
275+
});
262276
let validation: ValidationReport = { ran: [], ok: true };
263277
if (ok) validation = await autoValidate();
264278
const valSummary = ok ? `\n${summarizeValidation(validation)}` : "";

app/backend/src/agent/runner.ts

Lines changed: 188 additions & 115 deletions
Large diffs are not rendered by default.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Strip ReAct / rubric junk that models paste into FINAL or Ask-mode replies.
3+
* The user bubble must not show planning THOUGHT or "(lang) describing that I…" meta.
4+
*/
5+
export function sanitizeAssistantFinalText(raw: string): string {
6+
let s = raw.replace(/\r\n/g, "\n").trim();
7+
if (!s) return s;
8+
9+
// Drop leaked THOUGHT blocks (same line or following lines)
10+
const lineThought = s.search(/\n\s*THOUGHT\s*:/i);
11+
if (lineThought >= 0) s = s.slice(0, lineThought).trim();
12+
13+
const inlineThought = s.search(/\sTHOUGHT\s*:/i);
14+
if (inlineThought >= 0 && !/^\s*THOUGHT\s*:/im.test(s)) {
15+
s = s.slice(0, inlineThought).trim();
16+
}
17+
18+
if (/^\s*THOUGHT\s*:/im.test(s)) {
19+
s = s.replace(/^\s*THOUGHT\s*:\s*/i, "").trim();
20+
const again = s.search(/\n\s*THOUGHT\s*:/i);
21+
if (again >= 0) s = s.slice(0, again).trim();
22+
}
23+
24+
// System-shaped meta many models leak before the real answer
25+
s = s
26+
.replace(/^\s*\([^)]{0,120}\)\s*(?:describing|explaining|summarizing)\s+that\s+[^\n]+(?:\n|$)/i, "")
27+
.trim();
28+
29+
return s.trim();
30+
}
31+
32+
/** If sanitizer wiped useful content, keep original so the UI isn’t blank. */
33+
export function sanitizeFinalOrKeep(raw: string, minLen = 12): string {
34+
const cleaned = sanitizeAssistantFinalText(raw);
35+
if (cleaned.length >= minLen) return cleaned;
36+
if (cleaned.length > 0 && raw.trim().length < minLen) return cleaned;
37+
return raw.trim();
38+
}

app/backend/src/agent/sessionManager.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -164,18 +164,10 @@ async function runAgentInBackground(state: SessionState): Promise<void> {
164164
session.status = "completed";
165165
session.result = result;
166166
session.completedAt = Date.now();
167-
168-
// Emit a synthetic "session_completed" event so listeners know it's done
169-
const doneEvent: AgentEvent = { type: "final", result: result.result };
170-
for (const listener of state.listeners) {
171-
const l = listener;
172-
setImmediate(() => {
173-
try {
174-
l(doneEvent);
175-
} catch { /* ignore */ }
176-
});
177-
}
178-
167+
168+
// `runAgent` already emitted `final` through onEvent (and it's in session.events).
169+
// Do not broadcast a second `final` — downstream UIs would append duplicate events.
170+
179171
logger.info(`Session completed: ${session.id}`);
180172

181173
} catch (err) {

app/backend/src/llm/client.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,13 +338,37 @@ async function* _sseStream(res: Response, ollama: boolean): AsyncGenerator<strin
338338
}
339339
try {
340340
const obj = JSON.parse(payload) as {
341-
choices?: { delta?: { content?: string }; message?: { content?: string } }[];
341+
choices?: Array<{
342+
delta?: {
343+
content?: string | null;
344+
/** Some OpenAI / proxy variants stream reasoning separately from answer tokens. */
345+
reasoning?: string | null;
346+
reasoning_content?: string | null;
347+
};
348+
message?: { content?: string };
349+
}>;
342350
message?: { content?: string };
343351
done?: boolean;
344352
};
345353
const delta = ollama
346354
? (obj.message?.content ?? "")
347-
: (obj.choices?.[0]?.delta?.content ?? obj.choices?.[0]?.message?.content ?? "");
355+
: (() => {
356+
const choice0 = obj.choices?.[0];
357+
const d = choice0?.delta as Record<string, unknown> | undefined;
358+
const msg0 = choice0?.message;
359+
const content =
360+
(typeof d?.content === "string" ? d.content : "") ||
361+
(typeof msg0?.content === "string" ? msg0.content : "");
362+
// Merge reasoning-shaped fields so the ReAct trace / THOUGHT preview can stream like other providers.
363+
let reasoning = "";
364+
if (d) {
365+
for (const k of ["reasoning", "reasoning_content", "thinking"] as const) {
366+
const v = d[k];
367+
if (typeof v === "string") reasoning += v;
368+
}
369+
}
370+
return reasoning + content;
371+
})();
348372
if (delta) yield delta;
349373
} catch { /* skip malformed chunk */ }
350374
}

app/backend/src/llm/prompt-compact.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ TOOLS:
9494
- search_code: {"type":"search_code","input":{"query":"function name"}}
9595
- glob: {"type":"glob","input":{"pattern":"**/*.ts"}}
9696
- run_command: {"type":"run_command","input":{"cmd":"npm test"}}
97-
- write_patch: {"type":"write_patch","input":{"patches":"FILE:path\\nSEARCH\\n<old>\\nREPLACE\\n<new>\\nEND"}}
97+
- write_patch: {"type":"write_patch","input":{"patches":"FILE:path\\nSEARCH\\n<old>\\nREPLACE\\n<new>\\nEND"}} — after FILE: rel/path the next line must be SEARCH then REPLACE (never raw file body under FILE:); new file = SEARCH\\n\\nREPLACE\\n<content>\\nEND. Or {"path":"x.ts","patches":"SEARCH\\n..."} only.
9898
- create_file: {"type":"create_file","input":{"path":"src/new.ts","content":"// file content"}}
9999
100100
Parallel: emit multiple ACTION lines for independent ops (e.g. reading several files at once).
@@ -120,8 +120,8 @@ FINAL: I'm doing well! How can I help you with your code today?
120120
RULES:
121121
1. ONE action per turn (THOUGHT + ACTION, or THOUGHT + FINAL)
122122
2. Questions about files → read_file first
123-
3. Creating/editing files → use write_patch (never paste code in FINAL)
124-
4. Simple questions → FINAL directly
123+
3. Creating/editing files → use write_patch (never paste code in FINAL); each FILE: block must use SEARCH/REPLACE lines as in TOOLS. On patch failure read OBSERVATION codes like [WP_SEARCH_MISS].
124+
4. Simple questions → FINAL directly (user-facing text only — no meta-rubric, no THOUGHT pasted into FINAL)
125125
5. Match user's language in FINAL`;
126126

127127
/** Even more compact for simple tasks */
@@ -144,7 +144,8 @@ export const ASK_SYSTEM_PROMPT_COMPACT = `Helpful coding assistant. Answer in Ma
144144
- Use fenced code blocks with language tags
145145
- Be concise, skip filler
146146
- If proposing changes, show the patched code
147-
- Never output THOUGHT/ACTION/FINAL format`;
147+
- Never output THOUGHT/ACTION/FINAL format
148+
- Reply in plain Markdown only — no internal meta-rubric lines (e.g. language tags "describing that I…")`;
148149

149150
/** Ultra-short ASK system prompt (minimal token use). */
150151
export const ASK_SYSTEM_PROMPT_MINIMAL = `Coding assistant. Reply in Markdown only (no tools). Short answers; code in fenced blocks.`;

app/backend/src/llm/prompt.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,11 @@ Available tools (set "type" to one of these):
9292
- "search_code" input: { "query": "text" }
9393
- "glob" input: { "pattern": "**/*.ts" } — find files matching a glob pattern (** = any depth, * = within segment).
9494
- "run_command" input: { "cmd": "shell command" } — Executes shell commands. Long-running processes (servers, watchers, builds) auto-detect and return immediately when ready. See "Execution Intelligence" below.
95-
- "write_patch" input: { "patches": "FILE: path\\nSEARCH\\n<old>\\nREPLACE\\n<new>\\nEND\\n..." }
95+
- "write_patch" input: { "patches": "FILE: path\\nSEARCH\\n<old>\\nREPLACE\\n<new>\\nEND\\n..." } — optional { "path": "rel/path", "patches": "SEARCH\\n..." } for single-file edits only (body must start with SEARCH).
9696
- "create_file" input: { "path": "rel/path", "content": "full file content" } — create or overwrite a file directly (simpler than write_patch for new files).
9797
98+
write_patch shape is strict: after every \`FILE: <relative-path>\` line, the next line must be exactly \`SEARCH\`, then the old text, then a line exactly \`REPLACE\`, then the new text, then optional \`END\`. Do not paste a full file right under \`FILE:\` without those markers. New file: empty SEARCH (\`SEARCH\\n\\nREPLACE\\n<full content>\\nEND\`). On failure, OBSERVATION may include bracket codes (\`[WP_FMT_AFTER_FILE]\`, \`[WP_SEARCH_MISS]\`, etc.)—read them and adjust the patch or re-read the file.
99+
98100
**Parallel execution**: You may emit MULTIPLE ACTION blocks in a single response. All are dispatched concurrently. Only do this for genuinely independent operations (e.g. reading several unrelated files, creating multiple files that don't depend on each other). Format:
99101
THOUGHT: I need to read A and B to understand the issue.
100102
ACTION: {"type":"read_file","input":{"path":"src/a.ts"}}
@@ -160,6 +162,10 @@ Iteration awareness — pace yourself:
160162
161163
- FINAL must be a SHORT summary describing what files were created/modified and why,
162164
pointing the user at the diff. Do NOT repeat the file contents in FINAL.
165+
- The text you put in FINAL is shown to the user **verbatim**. Do NOT paste internal
166+
rubrics like \`(Vietnamese) describing that I can read code…\`, and do NOT repeat
167+
or paste your THOUGHT inside FINAL — keep planning in THOUGHT only; FINAL is the
168+
actual answer they read.
163169
164170
Reasoning & tool discipline (keep THOUGHT to 1–6 sentences, but make them *useful*):
165171
- Anchor each THOUGHT in evidence: what you learned from RECENT STEPS / OBSERVATION (or previews),
@@ -244,7 +250,8 @@ Guidelines:
244250
- For non-trivial questions: brief diagnosis → concrete steps or options → note trade-offs or risks when relevant.
245251
- Separate facts you can infer from the prompt from guesses; say what you would open or run to verify.
246252
- If you need a file you weren't given, say which file you'd want to see.
247-
- NEVER output THOUGHT/ACTION/FINAL/JSON tool calls. Plain Markdown only.`;
253+
- NEVER output THOUGHT/ACTION/FINAL/JSON tool calls. Plain Markdown only.
254+
- Write as if speaking to the user: no internal rubrics (e.g. \`(Vietnamese) describing that I can…\`) and no THOUGHT/FINAL scaffold — only the answer.`;
248255

249256
export function buildAskMessage(task: string, relevant: ScoredFile[], history: { role: string; content: string }[]): string {
250257
const filesBlock = relevant.length === 0

app/backend/src/tools/patch.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,87 @@ export interface PatchResult {
1313
error?: string;
1414
}
1515

16+
/** Short stable codes for OBSERVATION summaries so the model and UI can scan failures quickly. */
17+
export type PatchApplyErrorCode = "WP_SEARCH_MISS" | "WP_SEARCH_AMBIGUOUS" | "WP_FILE_MISSING" | "WP_APPLY";
18+
19+
export function patchApplyErrorCode(error: string | undefined): PatchApplyErrorCode {
20+
if (!error) return "WP_APPLY";
21+
if (error === "SEARCH text not found") return "WP_SEARCH_MISS";
22+
if (/^SEARCH text matches \d+ times\b/.test(error)) return "WP_SEARCH_AMBIGUOUS";
23+
if (error.startsWith("File not found:")) return "WP_FILE_MISSING";
24+
return "WP_APPLY";
25+
}
26+
27+
export type WritePatchFormatError = {
28+
code: WritePatchFormatCode;
29+
message: string;
30+
};
31+
32+
export type WritePatchFormatCode =
33+
| "WP_EMPTY"
34+
| "WP_FMT_FILE_TRUNC"
35+
| "WP_FMT_AFTER_FILE"
36+
| "WP_FMT_DEFAULT_SEARCH"
37+
| "WP_FMT_NEED_FILE_OR_PATH";
38+
39+
/**
40+
* Structural validation before parsing/applying. Catches the common model mistake:
41+
* `FILE: path` then raw file body with no SEARCH/REPLACE markers.
42+
*
43+
* @returns `null` if shape is acceptable for `parsePatch`; otherwise code + message (summary uses `[code] message`).
44+
*/
45+
export function validateWritePatchPayload(raw: string, defaultPathTrimmed?: string): WritePatchFormatError | null {
46+
const text = raw.replace(/\r\n/g, "\n");
47+
const t = text.trim();
48+
if (!t) return { code: "WP_EMPTY", message: "write_patch patches string is empty." };
49+
50+
if (/^FILE:/m.test(t)) {
51+
const parts = t.split(/(?=^FILE:)/m);
52+
for (const part of parts) {
53+
const seg = part.trim();
54+
if (!seg.startsWith("FILE:")) continue;
55+
const nl = seg.indexOf("\n");
56+
if (nl === -1) {
57+
return {
58+
code: "WP_FMT_FILE_TRUNC",
59+
message:
60+
'Each FILE: line must be followed by a newline, then a line exactly "SEARCH", then old text, a line "REPLACE", new text, optional "END".',
61+
};
62+
}
63+
const pathHint = seg.slice(0, nl).replace(/^FILE:[ \t]*/i, "").trim() || "(path)";
64+
const afterPath = seg.slice(nl + 1);
65+
if (!afterPath.startsWith("SEARCH\n")) {
66+
return {
67+
code: "WP_FMT_AFTER_FILE",
68+
message:
69+
`After FILE: ${pathHint} the next line must be exactly "SEARCH", then old text, then "REPLACE", then new text, then optional "END". ` +
70+
`Do not paste raw file content under FILE:. For a new file use SEARCH\\n\\nREPLACE\\n<full file>\\nEND.`,
71+
};
72+
}
73+
}
74+
return null;
75+
}
76+
77+
if (defaultPathTrimmed) {
78+
if (!t.startsWith("SEARCH\n")) {
79+
return {
80+
code: "WP_FMT_DEFAULT_SEARCH",
81+
message:
82+
'When using input.path, the patches body must start with "SEARCH\\n", then old text, "REPLACE\\n", then new text. ' +
83+
"Or use multi-file format: FILE: rel/path then SEARCH/REPLACE blocks.",
84+
};
85+
}
86+
return null;
87+
}
88+
89+
return {
90+
code: "WP_FMT_NEED_FILE_OR_PATH",
91+
message:
92+
"Include at least one FILE: <relative-path> block with SEARCH/REPLACE/END, " +
93+
'or pass path plus a body that starts with "SEARCH\\n".',
94+
};
95+
}
96+
1697
/**
1798
* Parse a patch payload. Two accepted shapes:
1899
*

app/backend/verify_a.txt

Whitespace-only changes.

0 commit comments

Comments
 (0)