This commit is contained in:
omp-port
2026-08-11 12:34:26 -04:00
parent 5b7796043e
commit 4742f11d35
10 changed files with 212 additions and 22 deletions

View File

@@ -246,7 +246,7 @@ async function runSessionToCompletion(
session: AgentSession,
opts: AgentTaskOptions,
): Promise<AgentRunResult> {
const acc: SessionEventAccumulator = { text: "" };
const acc: SessionEventAccumulator = { text: "", sawMessage: false };
try {
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
applySessionEvent(acc, event, opts.onEvent);
@@ -259,16 +259,15 @@ async function runSessionToCompletion(
unsubscribe();
// Surface session errors that didn't throw but left no useful output.
// A session ending with stopReason "error" and no text means the model
// call failed silently — treat that as a failed run, not ok:true.
if (acc.errorMessage) {
return { ok: false, text: acc.text, error: acc.errorMessage };
}
if (!acc.text.trim() && acc.stopReason === "error") {
return {
ok: false,
text: acc.text,
error: "sub-agent session ended in error with no output.",
};
// call failed silently — treat that as a failed run, not ok:true. A
// session that settled with NO text and NO observed events means the
// model never produced anything at all (dead provider stream, failed
// start) — reporting that as a successful analysis would let an empty
// scan masquerade as a clean one, and the check's verify hook would
// fail only later with a confusing "artifact missing" error.
const sessionError = emptySessionError(acc);
if (sessionError) {
return { ok: false, text: acc.text, error: sessionError };
}
return { ok: true, text: acc.text };
} catch (err) {
@@ -290,12 +289,34 @@ async function runSessionToCompletion(
export interface SessionEventAccumulator {
/** Joined assistant text seen so far (text_delta stream). */
text: string;
/** Whether any assistant-message or tool event was observed at all. */
sawMessage: boolean;
/** stopReason of the final assistant message, when reported. */
stopReason?: string;
/** errorMessage of the final assistant message, when reported. */
errorMessage?: string;
}
/**
* Classify a settled session's capture: `undefined` when the result is a
* legitimate (possibly empty-text) outcome, else the error that should fail
* the run. Kept pure so the decision is unit-testable without a session.
*/
export function emptySessionError(
acc: SessionEventAccumulator,
): string | undefined {
if (acc.errorMessage) return acc.errorMessage;
if (!acc.text.trim()) {
if (acc.stopReason === "error") {
return "sub-agent session ended in error with no output.";
}
if (!acc.sawMessage) {
return "sub-agent session settled with no output — no assistant message or tool activity was observed. Check model/provider connectivity, then resume.";
}
}
return undefined;
}
/**
* Interpret one session event into the running accumulator and forward the
* stream-driving events to the chat.
@@ -316,6 +337,16 @@ export function applySessionEvent(
): void {
if (!event) return;
try {
// Any message or tool event means the session actually ran — a settled
// capture with none of these is a dead session, not an empty scan.
if (
event.type === "message_update" ||
event.type === "message_end" ||
event.type === "tool_execution_start" ||
event.type === "tool_execution_end"
) {
acc.sawMessage = true;
}
if (
event.type === "message_update" &&
event.assistantMessageEvent?.type === "text_delta"

View File

@@ -56,6 +56,13 @@ export const SCOPE_EXCLUDE_DIRS: ReadonlySet<string> = new Set([
".nuxt",
".turbo",
".svelte-kit",
// Framework/deploy build output: Nitro, Vercel, Netlify artifact dirs.
// Generated bundles dominate a naive marker/stub scan (freno-dev alone
// flagged 119 of 124 candidates inside `.output/`/`.vercel/` minified
// bundles) and must never feed the pre-scan or the agent's file walk.
".output",
".vercel",
".netlify",
"__pycache__",
".venv",
"venv",

View File

@@ -124,6 +124,21 @@ const MAX_CANDIDATES = 500;
const FALLBACK_CAP = 16;
/** Candidate list embedded in the live prompt is truncated at this many. */
const PROMPT_CAP = 40;
/**
* Max characters of a candidate's code line embedded in the task prompt.
* Generated/bundled single lines can be hundreds of KB (e.g. minified assets
* sneaking past scope); embedding them wholesale balloons the task to
* megabytes and chokes the sub-agent. `path:line` plus a truncated prefix is
* enough to classify — the agent can read the file for full context.
*/
const CODE_DISPLAY_CAP = 160;
/** Truncate a candidate's code line for prompt embedding. */
function displayCode(code: string): string {
return code.length > CODE_DISPLAY_CAP
? `${code.slice(0, CODE_DISPLAY_CAP)}`
: code;
}
/** Extract the declared function name from a header line, when present. */
function headerName(line: string): string | undefined {
@@ -369,7 +384,7 @@ function renderFindings(
parts.push(`## ${title}`);
list.slice(0, FALLBACK_CAP).forEach((c, i) => {
parts.push(
`### ${i + 1}. ${relative(cwd, c.path)}:${c.line}${c.code} | snippet: ${c.snippet} | context: ${c.context || relative(cwd, c.path)}`,
`### ${i + 1}. ${relative(cwd, c.path)}:${c.line}${displayCode(c.code)} | snippet: ${c.snippet} | context: ${c.context || relative(cwd, c.path)}`,
);
});
if (list.length > FALLBACK_CAP) {
@@ -434,7 +449,7 @@ export async function buildTodosScanTask(
const candidateList = candidates
.slice(0, PROMPT_CAP)
.map((c) => ` - ${relative(cwd, c.path)}:${c.line} [${c.kind}] ${c.code}`);
.map((c) => ` - ${relative(cwd, c.path)}:${c.line} [${c.kind}] ${displayCode(c.code)}`);
if (candidates.length > PROMPT_CAP) {
candidateList.push(
` - … and ${candidates.length - PROMPT_CAP} more (truncated for brevity)`,