Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion src/agent/budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export class BudgetTracker {
private costUsd = 0;
private iterations = 0;
private iterationWarned = false;
/** Bumped on every consume() (= a completed model call). Slow ≠ runaway: the wall-time
* ceiling only fires when the task is ALSO stalled, judged against this timestamp. */
private lastProgressAt = Date.now();

constructor(
private maxTokens: number,
Expand All @@ -34,12 +37,20 @@ export class BudgetTracker {
consume(usage: { tokens?: number; costUsd?: number }): void {
this.tokens += usage.tokens ?? 0;
this.costUsd += usage.costUsd ?? 0;
this.lastProgressAt = Date.now();
}

incrementIteration(): void {
this.iterations++;
}

/** Record non-model progress (a completed TOOL call). A 5-minute shell command is work,
* not a stall — without this, the wall ceiling read long tool runs as 'stalled' and
* killed the task right after the tool returned (live: 808s/600s, stalled 300s). */
noteProgress(): void {
this.lastProgressAt = Date.now();
}

checkpoint(): void {
const wallMs = Date.now() - this.startTime;
// A value of 0 (or negative) on any limit means "no limit" — useful for local
Expand All @@ -52,7 +63,17 @@ export class BudgetTracker {
throw new BudgetExceededError(`Cost budget exceeded: $${this.costUsd.toFixed(4)}/$${this.maxCostUsd}`, 'cost');
}
if (this.maxWallSeconds > 0 && wallMs > this.maxWallSeconds * 1000) {
throw new BudgetExceededError(`Time budget exceeded: ${(wallMs / 1000).toFixed(0)}s/${this.maxWallSeconds}s`, 'time');
// Slow ≠ runaway. checkpoint() runs right AFTER a completed model call, so in the
// old form this ceiling could ONLY ever kill a task that was actively working
// (live: "Time budget exceeded: 608s/600s" mid-edit on a local model) — a hung
// task never reaches a checkpoint at all. The ceiling now fires only when the
// task is ALSO stalled (no completed call in the last 2 minutes); true runaways
// stay bounded by the iteration and token caps.
const idleMs = Date.now() - this.lastProgressAt;
if (idleMs > 120_000) {
throw new BudgetExceededError(
`Time budget exceeded: ${(wallMs / 1000).toFixed(0)}s/${this.maxWallSeconds}s (stalled ${(idleMs / 1000).toFixed(0)}s)`, 'time');
}
}
if (this.maxIterations > 0 && this.iterations > this.maxIterations) {
throw new BudgetExceededError(`Iteration budget exceeded: ${this.iterations}/${this.maxIterations}`, 'iterations');
Expand Down
94 changes: 85 additions & 9 deletions src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { transformError, explainStreamError, detectStuckLoop, detectErrorLoop, e
import { looksLikeBuildTask, isPlanningToolCall, PREFLIGHT_MESSAGE } from './preflight-gate.js';
import { dedupHistory } from './dedup.js';
import { ageToolResults } from './result-aging.js';
import { applySpillGuard } from './tool-spill.js';
import { efficiencyDefaults, resolveSetting } from './efficiency-profile.js';
import { gatherInfraSignals, deriveAutoDisabledTools, ratchetAutoDisabled } from './tool-profile.js';
import { decideThinking, applyThinkingDecision, countTrailingToolErrors, modelSupportsSoftSwitch } from './thinking-control.js';
Expand Down Expand Up @@ -123,6 +124,10 @@ export class AgentLoop {
* ADDS to this — never drops — so the tools block stays a byte-stable cache prefix
* across turns (a sliding per-turn set would flip and invalidate the prompt cache). */
private sessionToolNames = new Set<string>();
/** Where the active model is actually served from ('ollama'/'lmstudio'/'anthropic'/…) and
* its LIVE context window — set each iteration, forwarded on budget_update for the UI. */
private lastModelSource = '';
private lastEffectiveCtxWindow = 0;
private totalToolCalls = 0; // running count of executed tool calls (skill-capture eligibility)
private currentTaskKey = ''; // stable key for THIS run's task (failure-driven learning)
// Ground-truth verification ledger: every checker QodeX actually ran this run + its result.
Expand Down Expand Up @@ -303,6 +308,13 @@ export class AgentLoop {
sessionId: opts.sessionId,
});

// The child session id is fabricated by the dispatcher (`<parent>/sub-<ts>`,
// `<parent>/fanout-<n>`, …) and has no row in `sessions` yet — but
// messages.session_id carries a FK to sessions.id, so the sub-agent's FIRST
// recordTurn would fail with "FOREIGN KEY constraint failed", killing every
// delegation instantly. Create the parent row up front (idempotent).
getSessionStore().ensureSession(opts.sessionId, this.cwd, modelUsed);

// Role-specific tool restriction (allow-list). Built-in policy:
// - vision role: only vision_analyze + read-only browser/file/web tools
// - subagent role: everything except `task` (no recursion) — handled by mode=subagent
Expand Down Expand Up @@ -1158,6 +1170,15 @@ export class AgentLoop {
} catch { /* best-effort — never block the task on baseline capture */ }
}

// Token-budget accounting is NOVEL-tokens-only. An agentic turn makes one API call per
// tool round, and every call re-sends the whole conversation — so counting each call's
// full input against the budget grows QUADRATICALLY with tool rounds (live: a 21k-context
// task on a local model "spent" 216k/200k within two minutes and died). The high-water
// mark counts every context token ONCE: consume = output + max(0, seenNow - seenBefore).
// Dollar cost stays full-usage per call below — bills are bills; the token budget is a
// runaway guard, not an invoice.
let promptHighWater = 0;

while (true) {
try {
budget.incrementIteration();
Expand Down Expand Up @@ -1401,15 +1422,24 @@ export class AgentLoop {
} catch { this.liveCtxWindows = null; }
}
let effectiveCtxWindow = route.modelInfo.contextWindow;
// Where the model is actually served from, for the status bar. Provider name is the
// base truth (ollama/anthropic/openai/custom name); a hit in LM Studio's native model
// registry upgrades it to 'lmstudio' — the OpenAI-compat facade otherwise hides that.
let modelSource = route.provider.name;
if (this.liveCtxWindows) {
const id = route.model;
const live = this.liveCtxWindows[id]
?? this.liveCtxWindows[Object.keys(this.liveCtxWindows).find(k => k.includes(id) || id.includes(k)) ?? ''];
if (typeof live === 'number' && live > 0 && live !== effectiveCtxWindow) {
logger.info('Context window synced from LM Studio', { model: id, config: effectiveCtxWindow, live });
effectiveCtxWindow = live;
if (typeof live === 'number' && live > 0) {
modelSource = 'lmstudio';
if (live !== effectiveCtxWindow) {
logger.info('Context window synced from LM Studio', { model: id, config: effectiveCtxWindow, live });
effectiveCtxWindow = live;
}
}
}
this.lastModelSource = modelSource;
this.lastEffectiveCtxWindow = effectiveCtxWindow;
const contextBudget = Math.floor(effectiveCtxWindow * 0.75);
const estTokens = this.estimateTokens(agedRaw);
// ─── Hooks: PreCompact ───────────────────────────────────────────────────
Expand Down Expand Up @@ -1580,21 +1610,30 @@ export class AgentLoop {
return;
}

// Track budget
// Track budget — novel tokens only (see promptHighWater above): the re-sent
// conversation prefix is counted the FIRST time it enters the context, not on
// every subsequent tool round. Works for every provider, including local ones
// that report no cache fields at all.
const cost = computeCost(lastUsage, route.modelInfo);
budget.consume({ tokens: lastUsage.input + lastUsage.output, costUsd: cost });
// Cache hit-rate: cached reads ÷ total input the model saw (fresh + cached). Lets the
// status line PROVE the hierarchical cache is working (and how much it's saving).
const cacheRead = (lastUsage as any).cacheRead ?? 0;
const totalInputSeen = lastUsage.input + cacheRead;
const freshInput = Math.max(0, totalInputSeen - promptHighWater);
promptHighWater = Math.max(promptHighWater, totalInputSeen);
budget.consume({ tokens: freshInput + lastUsage.output, costUsd: cost });
// Cache hit-rate: cached reads ÷ total input the model saw (fresh + cached). Lets the
// status line PROVE the hierarchical cache is working (and how much it's saving).
const cacheHitRate = totalInputSeen > 0 ? cacheRead / totalInputSeen : 0;
yield {
type: 'budget_update',
data: {
...budget.getUsage(),
lastInputTokens: lastUsage.input, lastOutputTokens: lastUsage.output, lastCostUsd: cost,
lastCacheRead: cacheRead, lastCacheCreation: (lastUsage as any).cacheCreation ?? 0, cacheHitRate,
contextWindow: route.modelInfo.contextWindow,
// The LIVE-synced window (LM Studio native API when available), not the static
// table value — plus where the model is actually served from and whether it bills.
contextWindow: this.lastEffectiveCtxWindow || route.modelInfo.contextWindow,
providerName: this.lastModelSource || route.provider.name,
providerIsLocal: (route.provider as any).isLocal === true || this.lastModelSource === 'lmstudio',
},
};

Expand Down Expand Up @@ -2038,6 +2077,7 @@ export class AgentLoop {
const results = await Promise.all(
readOnlyCalls.map(tc => this.executeToolCall(tc, txn, sessionId, options)),
);
budget.noteProgress();
for (let i = 0; i < readOnlyCalls.length; i++) {
const tc = readOnlyCalls[i]!;
const r = results[i]!;
Expand Down Expand Up @@ -2071,6 +2111,7 @@ export class AgentLoop {
// Single → execute as before
const tc = batch[0]!;
const r = await this.executeToolCall(tc, txn, sessionId, options);
budget.noteProgress();
yield { type: 'tool_result', data: { id: tc.id, name: tc.function.name, result: r.content, isError: r.isError, metadata: r.metadata } };
if (r.isError) this.recordToolFailure(tc.function.name, r.content);
noteResult(tc.function.name, r);
Expand All @@ -2089,6 +2130,7 @@ export class AgentLoop {
const results = await Promise.all(
batch.map(tc => this.executeToolCall(tc, txn, sessionId, options)),
);
budget.noteProgress();
for (let i = 0; i < batch.length; i++) {
const tc = batch[i]!;
const r = results[i]!;
Expand Down Expand Up @@ -2395,11 +2437,45 @@ export class AgentLoop {
}
}

const result = await Promise.race([
let result = await Promise.race([
this.registry.execute(tc.function.name, args, ctx),
timeoutPromise,
]);

// ─── Universal spill guard (THE choke point for oversized results) ───
// Every tool result passes through here before it can become message
// content, so one check covers http_request, web_fetch, shell, grep,
// browser_*, MCP tools — everything. Oversized content is written in
// full to ~/.qodex/tool-spill/<sessionId>/ and replaced by
// head + "[N chars spilled — full output: <path>]" + tail, so the model
// keeps status lines and tail errors AND knows how to read the rest
// (read_file with offset/limit). isError and metadata pass through
// untouched. Runs BEFORE the read-only cache store so a cache hit can
// never resurrect the full-size copy. Best-effort: a failed spill must
// never eat the result.
const spillMax = this.config.tools?.maxResultChars ?? 16_000;
if (spillMax > 0 && typeof result.content === 'string' && result.content.length > spillMax) {
try {
const spill = await applySpillGuard(tc.function.name, sessionId, result.content, {
maxResultChars: spillMax,
});
if (spill.spilled) {
logger.info('Tool result spilled to disk', {
tool: tc.function.name,
chars: result.content.length,
spillPath: spill.spillPath,
});
uiEvents.push({
type: 'progress',
message: `💾 Large ${tc.function.name} output (${result.content.length.toLocaleString()} chars) spilled to disk — context keeps head+tail+path`,
} as any);
result = { ...result, content: spill.content };
}
} catch (e: any) {
logger.warn('Spill guard failed (result kept in full, non-fatal)', { err: e?.message });
}
}

// Store successful read-only results in cache
if (this.toolCache && tool && tool.isReadOnly && !result.isError && typeof result.content === 'string') {
this.toolCache.set(tc.function.name, args, result.content);
Expand Down
4 changes: 2 additions & 2 deletions src/agent/result-aging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ export interface AgingResult {
}

export function ageToolResults(messages: Message[], opts: AgingOptions = {}): AgingResult {
const minAgeTurns = opts.minAgeTurns ?? 3;
const maxChars = opts.maxChars ?? 8_000;
const minAgeTurns = opts.minAgeTurns ?? 2;
const maxChars = opts.maxChars ?? 5_000;
const keepHead = opts.keepHead ?? 1_500;
const keepTail = opts.keepTail ?? 2_500;

Expand Down
Loading