@cjhyy/code-shell-core 0.7.0-beta.1 → 0.7.1
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/arena/arena.d.ts +2 -0
- package/dist/arena/arena.js +30 -1
- package/dist/arena/phases/adjudication.d.ts +2 -1
- package/dist/arena/phases/adjudication.js +2 -1
- package/dist/arena/phases/build-consensus.d.ts +2 -1
- package/dist/arena/phases/build-consensus.js +3 -1
- package/dist/arena/phases/cross-review.d.ts +3 -1
- package/dist/arena/phases/cross-review.js +8 -2
- package/dist/arena/phases/debate-rounds.d.ts +2 -1
- package/dist/arena/phases/debate-rounds.js +4 -2
- package/dist/arena/phases/participant-research.d.ts +2 -1
- package/dist/arena/phases/participant-research.js +3 -1
- package/dist/arena/phases/planning-detail-expansion.d.ts +2 -1
- package/dist/arena/phases/planning-detail-expansion.js +2 -1
- package/dist/arena/planner.d.ts +2 -1
- package/dist/arena/planner.js +2 -1
- package/dist/arena/types.d.ts +5 -1
- package/dist/cc-orchestrator/agent-adapter.d.ts +2 -0
- package/dist/cc-orchestrator/agent-adapter.js +4 -0
- package/dist/cc-orchestrator/codex-session-history.d.ts +14 -1
- package/dist/cc-orchestrator/codex-session-history.js +64 -4
- package/dist/cc-orchestrator/external-agent-changes.js +22 -5
- package/dist/cc-orchestrator/external-agent-driver.d.ts +1 -1
- package/dist/cc-orchestrator/external-agent-driver.js +202 -38
- package/dist/cc-orchestrator/session-history.d.ts +35 -0
- package/dist/cc-orchestrator/session-history.js +96 -13
- package/dist/cli/agent-server-tcp.js +13 -2
- package/dist/context/manager.d.ts +3 -3
- package/dist/context/manager.js +6 -6
- package/dist/context/token-counter.js +5 -3
- package/dist/context/tool-result-storage.d.ts +6 -0
- package/dist/context/tool-result-storage.js +25 -4
- package/dist/credentials/access.d.ts +11 -1
- package/dist/credentials/access.js +77 -1
- package/dist/credentials/index.d.ts +3 -1
- package/dist/credentials/index.js +2 -0
- package/dist/credentials/oauth.d.ts +25 -0
- package/dist/credentials/oauth.js +179 -0
- package/dist/credentials/store.d.ts +2 -1
- package/dist/credentials/store.js +19 -8
- package/dist/credentials/types.d.ts +84 -1
- package/dist/credentials/types.js +16 -1
- package/dist/engine/engine.d.ts +67 -34
- package/dist/engine/engine.js +448 -247
- package/dist/engine/goal.d.ts +19 -0
- package/dist/engine/goal.js +16 -6
- package/dist/engine/input-attachments.js +156 -13
- package/dist/engine/run-image-input.d.ts +22 -0
- package/dist/engine/run-image-input.js +195 -0
- package/dist/engine/session-title.d.ts +2 -1
- package/dist/engine/session-title.js +4 -1
- package/dist/engine/steer-queue.d.ts +3 -1
- package/dist/engine/steer-queue.js +10 -2
- package/dist/engine/turn-loop.d.ts +48 -1
- package/dist/engine/turn-loop.js +307 -37
- package/dist/engine/types.d.ts +6 -2
- package/dist/git/worktree/crud.d.ts +3 -0
- package/dist/git/worktree/crud.js +32 -3
- package/dist/git/worktree/git-exec.d.ts +2 -2
- package/dist/git/worktree/git-exec.js +47 -11
- package/dist/git/worktree/query.d.ts +8 -7
- package/dist/git/worktree/query.js +27 -20
- package/dist/hooks/events.d.ts +3 -0
- package/dist/hooks/events.js +0 -3
- package/dist/hooks/goal-stop-hook.d.ts +44 -2
- package/dist/hooks/goal-stop-hook.js +775 -52
- package/dist/hooks/registry.js +3 -0
- package/dist/hooks/shell-runner.d.ts +12 -1
- package/dist/hooks/shell-runner.js +160 -9
- package/dist/index.d.ts +7 -6
- package/dist/index.js +6 -5
- package/dist/llm/client-base.js +12 -10
- package/dist/llm/types.d.ts +12 -5
- package/dist/plugins/pluginCommandHook.d.ts +4 -4
- package/dist/plugins/pluginCommandHook.js +111 -13
- package/dist/preset/index.js +14 -4
- package/dist/protocol/chat-session-manager.d.ts +13 -2
- package/dist/protocol/chat-session-manager.js +90 -18
- package/dist/protocol/chat-session.d.ts +12 -0
- package/dist/protocol/chat-session.js +30 -5
- package/dist/protocol/client.d.ts +5 -2
- package/dist/protocol/client.js +22 -1
- package/dist/protocol/server.d.ts +25 -11
- package/dist/protocol/server.js +291 -73
- package/dist/protocol/types.d.ts +36 -2
- package/dist/protocol/types.js +2 -0
- package/dist/services/dream-consolidation.d.ts +3 -0
- package/dist/services/dream-consolidation.js +4 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.js +1 -1
- package/dist/services/oauth.d.ts +34 -10
- package/dist/services/oauth.js +233 -98
- package/dist/session/session-manager.d.ts +35 -6
- package/dist/session/session-manager.js +396 -27
- package/dist/session/transcript.d.ts +30 -1
- package/dist/session/transcript.js +119 -4
- package/dist/tool-system/builtin/agent-notifications.d.ts +11 -4
- package/dist/tool-system/builtin/agent-notifications.js +19 -7
- package/dist/tool-system/builtin/agent.js +5 -1
- package/dist/tool-system/builtin/arena.js +1 -0
- package/dist/tool-system/builtin/background-jobs.d.ts +28 -5
- package/dist/tool-system/builtin/background-jobs.js +109 -7
- package/dist/tool-system/builtin/background-work.d.ts +6 -1
- package/dist/tool-system/builtin/background-work.js +5 -1
- package/dist/tool-system/builtin/bash.d.ts +3 -5
- package/dist/tool-system/builtin/bash.js +10 -5
- package/dist/tool-system/builtin/browser-tools.d.ts +2 -2
- package/dist/tool-system/builtin/cron-list.definition.d.ts +3 -0
- package/dist/tool-system/builtin/cron-list.definition.js +6 -0
- package/dist/tool-system/builtin/cron.d.ts +1 -2
- package/dist/tool-system/builtin/cron.js +9 -7
- package/dist/tool-system/builtin/drive-claude-code.d.ts +7 -0
- package/dist/tool-system/builtin/drive-claude-code.js +307 -20
- package/dist/tool-system/builtin/edit.d.ts +2 -1
- package/dist/tool-system/builtin/edit.js +12 -4
- package/dist/tool-system/builtin/generate-video.d.ts +4 -0
- package/dist/tool-system/builtin/generate-video.js +138 -21
- package/dist/tool-system/builtin/glob.d.ts +2 -1
- package/dist/tool-system/builtin/glob.js +28 -3
- package/dist/tool-system/builtin/grep.d.ts +1 -0
- package/dist/tool-system/builtin/grep.js +82 -17
- package/dist/tool-system/builtin/index.d.ts +25 -11
- package/dist/tool-system/builtin/index.js +60 -5
- package/dist/tool-system/builtin/sleep.d.ts +1 -2
- package/dist/tool-system/builtin/sleep.definition.d.ts +8 -0
- package/dist/tool-system/builtin/sleep.definition.js +28 -0
- package/dist/tool-system/builtin/sleep.js +1 -22
- package/dist/tool-system/builtin/video-providers.d.ts +12 -15
- package/dist/tool-system/builtin/video-providers.js +1 -0
- package/dist/tool-system/builtin/view-image.d.ts +2 -2
- package/dist/tool-system/builtin/web-fetch.js +44 -3
- package/dist/tool-system/builtin/worktree.js +25 -7
- package/dist/tool-system/builtin/write.d.ts +2 -1
- package/dist/tool-system/builtin/write.js +14 -4
- package/dist/tool-system/context.d.ts +35 -5
- package/dist/tool-system/executor.js +24 -8
- package/dist/tool-system/mcp-manager.d.ts +20 -2
- package/dist/tool-system/mcp-manager.js +111 -12
- package/dist/tool-system/path-policy.d.ts +19 -0
- package/dist/tool-system/path-policy.js +62 -1
- package/dist/tool-system/permission.d.ts +43 -3
- package/dist/tool-system/permission.js +383 -30
- package/dist/tool-system/registry.d.ts +3 -2
- package/dist/tool-system/registry.js +52 -34
- package/dist/types.d.ts +38 -7
- package/package.json +1 -1
package/dist/engine/engine.js
CHANGED
|
@@ -11,7 +11,7 @@ import { applyDynamicToolDef } from "./dynamic-tool-defs.js";
|
|
|
11
11
|
import { getMergedCatalog } from "../model-catalog/index.js";
|
|
12
12
|
import { modelEntriesFromConnections } from "./model-connections-pool.js";
|
|
13
13
|
import { resolveAuxKey } from "./aux-key.js";
|
|
14
|
-
import { addCumulativeUsage, cumulativeCacheHitRate, foldRunUsage, normalizeCumulativeUsageCounters, } from "./session-usage.js";
|
|
14
|
+
import { addTokenUsage, addCumulativeUsage, cumulativeCacheHitRate, foldRunUsage, normalizeCumulativeUsageCounters, } from "./session-usage.js";
|
|
15
15
|
import { enqueueSteerItem, consumeSteerItems, removeSteerItem, } from "./steer-queue.js";
|
|
16
16
|
import { resolveSandboxConfig } from "./sandbox-config.js";
|
|
17
17
|
import { sandboxCacheKey } from "./sandbox-cache-key.js";
|
|
@@ -23,7 +23,7 @@ import { PermissionClassifier, HeadlessApprovalBackend, AutoApprovalBackend, Int
|
|
|
23
23
|
import { HookRegistry } from "../hooks/registry.js";
|
|
24
24
|
import { wrapHookMessages } from "../hooks/inject.js";
|
|
25
25
|
import { createGoalStopHook } from "../hooks/goal-stop-hook.js";
|
|
26
|
-
import { normalizeGoal, resolveGoalSetAt, resolveMaxTurns, resolveMaxStopBlocks, } from "./goal.js";
|
|
26
|
+
import { normalizeGoal, resolveGoalSetAt, resolveMaxTurns, resolveMaxStopBlocks, isSameGoalInstance, } from "./goal.js";
|
|
27
27
|
import { loadPluginHooks } from "../plugins/loadPluginHooks.js";
|
|
28
28
|
import { pluginAgentDirs } from "../plugins/installer/loadPluginAgents.js";
|
|
29
29
|
import { patchOrphanedToolUses } from "./patch-orphaned-tools.js";
|
|
@@ -32,7 +32,7 @@ import { ContextManager } from "../context/manager.js";
|
|
|
32
32
|
import { estimateTokens, clampContextRatios as clampContextRatiosImpl, } from "../context/compaction.js";
|
|
33
33
|
import { PLAN_MODE_ALLOWED_TOOLS } from "../tool-system/plan-mode-allowlist.js";
|
|
34
34
|
import { PromptComposer } from "../prompt/composer.js";
|
|
35
|
-
import { SessionManager } from "../session/session-manager.js";
|
|
35
|
+
import { SessionManager, } from "../session/session-manager.js";
|
|
36
36
|
import { ModelFacade } from "./model-facade.js";
|
|
37
37
|
import { logger, runWithSid, getCurrentSid } from "../logging/logger.js";
|
|
38
38
|
import { recordSessionStart, recordSessionEnd } from "../logging/session-recorder.js";
|
|
@@ -53,16 +53,17 @@ import { AgentDefinitionRegistry } from "../agent/agent-definition-registry.js";
|
|
|
53
53
|
import { defaultCacheDir } from "../llm/model-cache.js";
|
|
54
54
|
import { detectProviderFromApiKey, buildModelPool } from "../onboarding.js";
|
|
55
55
|
import { detectPastedNoise } from "../utils/task-sanitizer.js";
|
|
56
|
-
import {
|
|
57
|
-
import { buildInputAttachmentContext } from "./input-attachments.js";
|
|
58
|
-
import { enforceImagePolicy, byteLengthFromBase64, dropOversizedImages, collectAttachedImagePaths, } from "./image-policy.js";
|
|
59
|
-
import { tryCompressImages } from "./image-compression.js";
|
|
56
|
+
import { formatFriendlyError } from "./friendly-error.js";
|
|
60
57
|
import { buildSessionTitle } from "./session-title.js";
|
|
61
|
-
import { capabilitiesFor } from "../llm/capabilities/index.js";
|
|
62
58
|
import { MemoryOrchestrator } from "../services/memory-orchestrator.js";
|
|
63
59
|
import { runDreamConsolidation } from "../services/dream-consolidation.js";
|
|
64
|
-
import {
|
|
60
|
+
import { buildRunUserMessageContent, prepareRunImageInput } from "./run-image-input.js";
|
|
61
|
+
import { join } from "node:path";
|
|
65
62
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
63
|
+
const CACHE_READ_DROP_MIN_PREVIOUS_TOKENS = 100;
|
|
64
|
+
const CACHE_READ_DROP_MAX_CURRENT_TOKENS = 64;
|
|
65
|
+
const CACHE_READ_DROP_RATIO = 0.1;
|
|
66
|
+
const CACHE_READ_DIAGNOSTIC_MAX_SESSIONS = 256;
|
|
66
67
|
/**
|
|
67
68
|
* Build ScanOptions.compatFileNames from the user's instruction compat toggles.
|
|
68
69
|
* Primary file name stays hard-wired to CODESHELL.md (not exposed). Turning a
|
|
@@ -229,6 +230,8 @@ export class Engine {
|
|
|
229
230
|
/** Shared resources supplied at construction (adapter pattern — null when self-constructed). */
|
|
230
231
|
runtime;
|
|
231
232
|
sandboxCache = new Map();
|
|
233
|
+
interactiveBackends = new WeakMap();
|
|
234
|
+
activeApprovalRouter;
|
|
232
235
|
/** Active permission mode for this Engine instance. */
|
|
233
236
|
permissionMode;
|
|
234
237
|
/** True when permissionMode === "plan". */
|
|
@@ -245,7 +248,7 @@ export class Engine {
|
|
|
245
248
|
*/
|
|
246
249
|
auxClientCache;
|
|
247
250
|
// Live state from the current/most-recent run, retained for /compact and
|
|
248
|
-
//
|
|
251
|
+
// run-boundary PermissionClassifier replacement/reconfiguration.
|
|
249
252
|
lastContextManager;
|
|
250
253
|
lastMessages;
|
|
251
254
|
lastSessionId;
|
|
@@ -266,6 +269,7 @@ export class Engine {
|
|
|
266
269
|
* LLM response arrives.
|
|
267
270
|
*/
|
|
268
271
|
ctxOverheadBySid = new Map();
|
|
272
|
+
lastCacheReadBySid = new Map();
|
|
269
273
|
/**
|
|
270
274
|
* Step-gap steering queue (per sessionId, in-memory). Host pushes user
|
|
271
275
|
* messages here via enqueueSteer while a run is in flight; the turn loop
|
|
@@ -299,6 +303,19 @@ export class Engine {
|
|
|
299
303
|
* Null when idle; set at run start, cleared in run's finally.
|
|
300
304
|
*/
|
|
301
305
|
activeRunSession = null;
|
|
306
|
+
/**
|
|
307
|
+
* Same-instance run guard. Engine owns single-valued live controls and one
|
|
308
|
+
* HookRegistry, so a second run must not enter until the first has completed
|
|
309
|
+
* all state persistence and end hooks. This prevents handle contamination and
|
|
310
|
+
* whole-state saveState overlap only within this Engine instance. It does not
|
|
311
|
+
* coordinate different Engine instances sharing a sessionId, Workers, or
|
|
312
|
+
* processes; session-level locking/CAS for those cases is a separate finding.
|
|
313
|
+
*/
|
|
314
|
+
runInProgress = false;
|
|
315
|
+
/** Permission update requested while runInProgress. Applied in run() finally. */
|
|
316
|
+
pendingPermissionMode = null;
|
|
317
|
+
/** Plan update paired with pendingPermissionMode for one atomic boundary apply. */
|
|
318
|
+
pendingPlanMode = null;
|
|
302
319
|
/** Public accessor so UI/clients can read the resolved per-model window. */
|
|
303
320
|
get maxContextTokens() {
|
|
304
321
|
return this.resolveMaxContextTokens();
|
|
@@ -334,10 +351,11 @@ export class Engine {
|
|
|
334
351
|
* emits should go through this wrapper to keep the context envelope
|
|
335
352
|
* uniform with TurnLoop.emitHook.
|
|
336
353
|
*/
|
|
337
|
-
async emitHook(event, data = {}) {
|
|
354
|
+
async emitHook(event, data = {}, signal) {
|
|
338
355
|
return this.hooks.emit(event, {
|
|
339
356
|
...data,
|
|
340
357
|
isSubAgent: this.config.isSubAgent === true,
|
|
358
|
+
signal,
|
|
341
359
|
});
|
|
342
360
|
}
|
|
343
361
|
/**
|
|
@@ -654,7 +672,7 @@ export class Engine {
|
|
|
654
672
|
* the queued draft) and is the handle `unsteer` uses to revoke a still-pending
|
|
655
673
|
* entry. A blank id is tolerated but means the entry can't be revoked.
|
|
656
674
|
*/
|
|
657
|
-
enqueueSteer(sessionId, text, id = "", clientMessageId) {
|
|
675
|
+
enqueueSteer(sessionId, text, id = "", clientMessageId, attachments) {
|
|
658
676
|
const q = this.steerQueueBySid.get(sessionId) ?? [];
|
|
659
677
|
const entryId = id || `steer-${q.length}`;
|
|
660
678
|
if (!sessionId)
|
|
@@ -666,12 +684,13 @@ export class Engine {
|
|
|
666
684
|
sessionId,
|
|
667
685
|
id: entryId,
|
|
668
686
|
clientMessageId,
|
|
687
|
+
attachmentCount: attachments?.length ?? 0,
|
|
669
688
|
activeRunSessionId: activeRunSessionId ?? null,
|
|
670
689
|
queueLength: q.length,
|
|
671
690
|
});
|
|
672
691
|
return { accepted: false, id: entryId };
|
|
673
692
|
}
|
|
674
|
-
const next = enqueueSteerItem(q, entryId, text, clientMessageId);
|
|
693
|
+
const next = enqueueSteerItem(q, entryId, text, clientMessageId, attachments);
|
|
675
694
|
if (next === q)
|
|
676
695
|
return { accepted: false, id: entryId }; // blank text dropped
|
|
677
696
|
this.steerQueueBySid.set(sessionId, next);
|
|
@@ -679,6 +698,7 @@ export class Engine {
|
|
|
679
698
|
sessionId,
|
|
680
699
|
id: entryId,
|
|
681
700
|
clientMessageId,
|
|
701
|
+
attachmentCount: attachments?.length ?? 0,
|
|
682
702
|
activeRunSessionId,
|
|
683
703
|
queueLength: next.length,
|
|
684
704
|
});
|
|
@@ -715,6 +735,13 @@ export class Engine {
|
|
|
715
735
|
});
|
|
716
736
|
return drained;
|
|
717
737
|
}
|
|
738
|
+
/** Put failed steer preparation back ahead of messages queued while it was being prepared. */
|
|
739
|
+
restoreSteer(sessionId, items) {
|
|
740
|
+
if (items.length === 0)
|
|
741
|
+
return;
|
|
742
|
+
const queued = this.steerQueueBySid.get(sessionId) ?? [];
|
|
743
|
+
this.steerQueueBySid.set(sessionId, [...items, ...queued]);
|
|
744
|
+
}
|
|
718
745
|
/** Wire the cookie→browser injection callback (InjectCredential tool). Same
|
|
719
746
|
* post-construction injection model as setBrowserBridge. */
|
|
720
747
|
setInjectCredential(fn) {
|
|
@@ -737,10 +764,43 @@ export class Engine {
|
|
|
737
764
|
sessionExistsOnDisk(sessionId) {
|
|
738
765
|
return this.sessionManager.exists(sessionId);
|
|
739
766
|
}
|
|
767
|
+
forkSession(sourceSessionId, options) {
|
|
768
|
+
return this.sessionManager.fork(sourceSessionId, options);
|
|
769
|
+
}
|
|
740
770
|
/**
|
|
741
|
-
* Run a task from start to finish.
|
|
771
|
+
* Run a task from start to finish. Rejects immediately when this Engine
|
|
772
|
+
* instance already has a run in progress; hosts that want queueing own that
|
|
773
|
+
* policy (for example ChatSession's FIFO queue).
|
|
742
774
|
*/
|
|
743
775
|
async run(task, options) {
|
|
776
|
+
if (this.runInProgress) {
|
|
777
|
+
throw new Error("Engine.run() cannot start while another run is in progress");
|
|
778
|
+
}
|
|
779
|
+
this.runInProgress = true;
|
|
780
|
+
try {
|
|
781
|
+
return await this.runExclusive(task, options);
|
|
782
|
+
}
|
|
783
|
+
finally {
|
|
784
|
+
try {
|
|
785
|
+
this.applyPendingPermissionState();
|
|
786
|
+
}
|
|
787
|
+
finally {
|
|
788
|
+
this.runInProgress = false;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
async runExclusive(task, options) {
|
|
793
|
+
// Freeze permission context once, before the first await. Per-turn protocol
|
|
794
|
+
// overrides live only for this run; persistent setPermissionMode/setPlanMode
|
|
795
|
+
// calls made while busy are staged separately and cannot mutate this pair.
|
|
796
|
+
let runPermissionMode = options?.permissionMode ?? this.config.permissionMode ?? "acceptEdits";
|
|
797
|
+
if (options?.planMode === true) {
|
|
798
|
+
runPermissionMode = "plan";
|
|
799
|
+
}
|
|
800
|
+
else if (options?.planMode === false && runPermissionMode === "plan") {
|
|
801
|
+
runPermissionMode = "acceptEdits";
|
|
802
|
+
}
|
|
803
|
+
const runPlanMode = runPermissionMode === "plan";
|
|
744
804
|
const workspaceResume = options?.sessionId && this.sessionManager.exists(options.sessionId)
|
|
745
805
|
? await this.sessionManager.resolveSessionWorkspaceForResume(options.sessionId)
|
|
746
806
|
: undefined;
|
|
@@ -807,139 +867,16 @@ export class Engine {
|
|
|
807
867
|
};
|
|
808
868
|
if (options)
|
|
809
869
|
options.onStream = wrappedOnStream;
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
// 2. Models that don't accept vision must be refused immediately,
|
|
817
|
-
// with the image bytes intact for the user to retry on another
|
|
818
|
-
// model. Silent text-only fallback was the failure mode this
|
|
819
|
-
// gate is here to prevent.
|
|
820
|
-
let parsedTask;
|
|
821
|
-
try {
|
|
822
|
-
parsedTask = parseTaskWithImages(task);
|
|
823
|
-
}
|
|
824
|
-
catch (err) {
|
|
825
|
-
const msg = err.message;
|
|
826
|
-
logger.warn("engine.run.image_parse_failed", { error: msg });
|
|
827
|
-
return {
|
|
828
|
-
text: `ERROR: image attachment is malformed (${msg}). Drop the image and try again, or re-attach it.`,
|
|
829
|
-
reason: "image_error",
|
|
830
|
-
sessionId: options?.sessionId ?? "image-parse-failed",
|
|
831
|
-
turnCount: 0,
|
|
832
|
-
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
833
|
-
};
|
|
834
|
-
}
|
|
835
|
-
const cap = capabilitiesFor((this.config.llm.providerKind ?? this.config.llm.provider), this.config.llm.model);
|
|
836
|
-
const attachmentContext = await buildInputAttachmentContext(options?.attachments, cwd, {
|
|
837
|
-
includeImageBytes: cap.supportsVision,
|
|
838
|
-
expectedSessionId: options?.sessionId,
|
|
870
|
+
const imageInput = await prepareRunImageInput({
|
|
871
|
+
task,
|
|
872
|
+
cwd,
|
|
873
|
+
llm: this.config.llm,
|
|
874
|
+
sessionId: options?.sessionId,
|
|
875
|
+
attachments: options?.attachments,
|
|
839
876
|
});
|
|
840
|
-
if (
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
return {
|
|
844
|
-
text: `ERROR: input attachment could not be read (${detail}). Re-attach it or choose a path inside the workspace.`,
|
|
845
|
-
reason: "image_error",
|
|
846
|
-
sessionId: options?.sessionId ?? "input-attachment-failed",
|
|
847
|
-
turnCount: 0,
|
|
848
|
-
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
849
|
-
};
|
|
850
|
-
}
|
|
851
|
-
if (attachmentContext.text || attachmentContext.hasStructuredImageAttachments) {
|
|
852
|
-
parsedTask = {
|
|
853
|
-
text: [parsedTask.text, attachmentContext.text].filter(Boolean).join("\n\n"),
|
|
854
|
-
images: [...parsedTask.images, ...attachmentContext.images],
|
|
855
|
-
hasImages: parsedTask.hasImages ||
|
|
856
|
-
attachmentContext.images.length > 0 ||
|
|
857
|
-
attachmentContext.hasStructuredImageAttachments,
|
|
858
|
-
};
|
|
859
|
-
}
|
|
860
|
-
if (parsedTask.hasImages) {
|
|
861
|
-
if (!cap.supportsVision) {
|
|
862
|
-
logger.warn("engine.run.vision_not_supported", {
|
|
863
|
-
provider: this.config.llm.provider,
|
|
864
|
-
model: this.config.llm.model,
|
|
865
|
-
imageCount: parsedTask.images.length,
|
|
866
|
-
});
|
|
867
|
-
return {
|
|
868
|
-
text: `ERROR: model "${this.config.llm.model}" does not accept image input. ` +
|
|
869
|
-
`Switch to a vision-capable model (e.g. gpt-4o, claude-sonnet, gemini-1.5-pro) and resend.`,
|
|
870
|
-
reason: "image_error",
|
|
871
|
-
sessionId: options?.sessionId ?? "vision-not-supported",
|
|
872
|
-
turnCount: 0,
|
|
873
|
-
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
874
|
-
};
|
|
875
|
-
}
|
|
876
|
-
// Size gate. Hosts (desktop renderer, TUI) are expected to
|
|
877
|
-
// pre-compress to IMAGE_TARGETS — if they didn't, we fail the turn
|
|
878
|
-
// fast with a clear message instead of letting the OpenAI client
|
|
879
|
-
// grind through three 16-second "Connection error" retries on a
|
|
880
|
-
// 4 MB body. See `image-policy.ts` for the rationale and limits.
|
|
881
|
-
let verdict = enforceImagePolicy(parsedTask.images);
|
|
882
|
-
if (!verdict.ok && verdict.code === "image_too_large") {
|
|
883
|
-
// One image blew the per-image cap. Try the engine-side
|
|
884
|
-
// compressor (jimp-backed when installed; no-op otherwise) so
|
|
885
|
-
// TUI / MCP paths that lack a host-side resize don't fail
|
|
886
|
-
// outright on a screenshot they could have rescaled. The
|
|
887
|
-
// re-check below is what decides whether we proceed.
|
|
888
|
-
const compressed = await tryCompressImages(parsedTask.images);
|
|
889
|
-
if (compressed.anyCompressed) {
|
|
890
|
-
parsedTask.images = compressed.images;
|
|
891
|
-
logger.info("engine.run.image_compressed", {
|
|
892
|
-
before: verdict.offender?.bytes,
|
|
893
|
-
after: compressed.images.reduce((s, i) => s + byteLengthFromBase64(i.base64), 0),
|
|
894
|
-
});
|
|
895
|
-
verdict = enforceImagePolicy(parsedTask.images);
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
// After compression, anything still over the per-image cap is
|
|
899
|
-
// dropped with a textual placeholder instead of failing the
|
|
900
|
-
// turn (TODO-week.md #9e). The "5MB brick session" failure
|
|
901
|
-
// mode from Claude Code (research doc §A) was the case where a
|
|
902
|
-
// poisoned image entered history and every subsequent request
|
|
903
|
-
// re-sent it; placeholders keep history clean while letting
|
|
904
|
-
// the rest of the turn run.
|
|
905
|
-
if (!verdict.ok && verdict.code === "image_too_large") {
|
|
906
|
-
const drop = dropOversizedImages(parsedTask.images);
|
|
907
|
-
if (drop.droppedCount > 0) {
|
|
908
|
-
parsedTask.images = drop.kept;
|
|
909
|
-
parsedTask.hasImages = drop.kept.length > 0;
|
|
910
|
-
parsedTask.text = drop.placeholder + "\n\n" + parsedTask.text;
|
|
911
|
-
logger.warn("engine.run.image_dropped", {
|
|
912
|
-
droppedCount: drop.droppedCount,
|
|
913
|
-
keptCount: drop.kept.length,
|
|
914
|
-
});
|
|
915
|
-
verdict = enforceImagePolicy(parsedTask.images);
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
if (!verdict.ok) {
|
|
919
|
-
// Cumulative / count caps can't be rescued by per-image
|
|
920
|
-
// dropping (well — too_many_images could trim by FIFO, but
|
|
921
|
-
// that's a bigger UX call than we want to make silently).
|
|
922
|
-
// Refuse the turn with the policy message.
|
|
923
|
-
logger.warn("engine.run.image_policy_failed", {
|
|
924
|
-
code: verdict.code,
|
|
925
|
-
imageCount: verdict.totals.imageCount,
|
|
926
|
-
totalBytes: verdict.totals.totalBytes,
|
|
927
|
-
offender: verdict.offender,
|
|
928
|
-
});
|
|
929
|
-
return {
|
|
930
|
-
text: `ERROR: ${verdict.message}`,
|
|
931
|
-
reason: "image_error",
|
|
932
|
-
sessionId: options?.sessionId ?? `image-policy-${verdict.code}`,
|
|
933
|
-
turnCount: 0,
|
|
934
|
-
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
935
|
-
};
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
// For downstream noise-detection + transcript persistence we want the
|
|
939
|
-
// *text* portion only — base64 bytes count as "noise" by the heuristic
|
|
940
|
-
// and would also bloat the transcript by megabytes per image. Image
|
|
941
|
-
// bytes ride in parsedTask.images and re-enter the message tree below.
|
|
942
|
-
const taskText = parsedTask.text;
|
|
877
|
+
if (!imageInput.ok)
|
|
878
|
+
return imageInput.result;
|
|
879
|
+
const { parsedTask, taskText } = imageInput;
|
|
943
880
|
const noise = detectPastedNoise(taskText);
|
|
944
881
|
if (noise.isNoise) {
|
|
945
882
|
const hint = `Your input looks like pasted terminal output (${noise.reason}). ` +
|
|
@@ -962,7 +899,7 @@ export class Engine {
|
|
|
962
899
|
describe: () => ({
|
|
963
900
|
cwd,
|
|
964
901
|
preset: this.preset.name,
|
|
965
|
-
permissionMode:
|
|
902
|
+
permissionMode: runPermissionMode,
|
|
966
903
|
}),
|
|
967
904
|
spawn: async (req) => {
|
|
968
905
|
// Anchor this sub-agent in the PARENT transcript at spawn time — before
|
|
@@ -997,7 +934,7 @@ export class Engine {
|
|
|
997
934
|
// rather surface failures than burn a 9 s exponential backoff loop.
|
|
998
935
|
clientDefaults: { ...(this.config.clientDefaults ?? {}), retryMaxAttempts: 2 },
|
|
999
936
|
cwd,
|
|
1000
|
-
permissionMode:
|
|
937
|
+
permissionMode: runPermissionMode,
|
|
1001
938
|
preset: this.preset.name,
|
|
1002
939
|
enabledBuiltinTools: childEnabled,
|
|
1003
940
|
disabledBuiltinTools: childDisabled,
|
|
@@ -1065,7 +1002,7 @@ export class Engine {
|
|
|
1065
1002
|
onStream: childStream,
|
|
1066
1003
|
sessionId: childSessionId,
|
|
1067
1004
|
});
|
|
1068
|
-
return { text: result.text, sessionId: result.sessionId };
|
|
1005
|
+
return { text: result.text, sessionId: result.sessionId, usage: result.usage };
|
|
1069
1006
|
},
|
|
1070
1007
|
sessionExists: (sessionId) => this.sessionManager.exists(sessionId),
|
|
1071
1008
|
};
|
|
@@ -1100,6 +1037,9 @@ export class Engine {
|
|
|
1100
1037
|
// after the assignment.
|
|
1101
1038
|
const toolCtx = {
|
|
1102
1039
|
...this.buildToolContext(),
|
|
1040
|
+
approvalRouter: options?.approvalRouter ?? this.config.approvalRouter,
|
|
1041
|
+
permissionMode: runPermissionMode,
|
|
1042
|
+
planMode: runPlanMode,
|
|
1103
1043
|
subAgentSpawner,
|
|
1104
1044
|
agentDefinitions: this.getAgentDefinitions(cwd),
|
|
1105
1045
|
// Stamp the resolved network policy onto the backend the tools see so
|
|
@@ -1128,40 +1068,7 @@ export class Engine {
|
|
|
1128
1068
|
preset: this.preset.name,
|
|
1129
1069
|
imageCount: parsedTask.images.length,
|
|
1130
1070
|
});
|
|
1131
|
-
|
|
1132
|
-
// shape. With images, content becomes a ContentBlock[] holding one
|
|
1133
|
-
// text block (when prose is present) followed by one image block per
|
|
1134
|
-
// attachment — the provider-specific clients translate this to OpenAI
|
|
1135
|
-
// `image_url` or Anthropic `{type:image, source:base64}` downstream.
|
|
1136
|
-
// When an attached image came from a workspace FILE (the desktop composer's
|
|
1137
|
-
// path-attach flow sets ParsedImage.name = the absolute path), surface that
|
|
1138
|
-
// path to the model as text. The image bytes still ride along for vision,
|
|
1139
|
-
// but tools that operate on files — GenerateImage(referenceImages),
|
|
1140
|
-
// Read, etc. — need the on-disk path, not just the pixels. Without this the
|
|
1141
|
-
// path the composer already knew was silently dropped, and the model would
|
|
1142
|
-
// answer "图片没落到项目文件夹,找不到路径" (the seedance 图生图 dead-end).
|
|
1143
|
-
// Only names that resolve to an existing file qualify; a pasted screenshot
|
|
1144
|
-
// whose name is just "screenshot.png" is not a path and is left out.
|
|
1145
|
-
const attachedPaths = collectAttachedImagePaths(parsedTask.images, (name) => (isAbsolute(name) ? name : join(cwd, name)), existsSync);
|
|
1146
|
-
const pathHint = attachedPaths.length > 0
|
|
1147
|
-
? `\n\n<attached-image-paths>\n${attachedPaths.join("\n")}\n</attached-image-paths>\n` +
|
|
1148
|
-
`(上面附带的图片在工作区的真实路径,如需把它们作为工具输入(例如 GenerateImage 的 referenceImages、图生图参考图),直接使用这些路径。)`
|
|
1149
|
-
: "";
|
|
1150
|
-
const userMessageContent = parsedTask.hasImages
|
|
1151
|
-
? [
|
|
1152
|
-
...(parsedTask.text || pathHint
|
|
1153
|
-
? [{ type: "text", text: `${parsedTask.text}${pathHint}` }]
|
|
1154
|
-
: []),
|
|
1155
|
-
...parsedTask.images.map((img) => ({
|
|
1156
|
-
type: "image",
|
|
1157
|
-
source: {
|
|
1158
|
-
type: "base64",
|
|
1159
|
-
media_type: img.mime,
|
|
1160
|
-
data: img.base64,
|
|
1161
|
-
},
|
|
1162
|
-
})),
|
|
1163
|
-
]
|
|
1164
|
-
: taskText;
|
|
1071
|
+
const userMessageContent = buildRunUserMessageContent(parsedTask, cwd, taskText);
|
|
1165
1072
|
// Create or resume session.
|
|
1166
1073
|
//
|
|
1167
1074
|
// Three valid shapes:
|
|
@@ -1284,12 +1191,18 @@ export class Engine {
|
|
|
1284
1191
|
// notifications) attribute to the right session. toolCtx is created
|
|
1285
1192
|
// before the session bundle is resolved (see ~line 635), so this is
|
|
1286
1193
|
// the first point we can set it. After this assignment treat the
|
|
1287
|
-
// field
|
|
1194
|
+
// field follows the latest successfully injected user intent for the rest
|
|
1195
|
+
// of the run, so tools launched after a steer attribute their side effects
|
|
1196
|
+
// to that steer rather than this original submit.
|
|
1288
1197
|
toolCtx.sessionId = session.state.sessionId;
|
|
1198
|
+
toolCtx.originClientMessageId = options?.clientMessageId;
|
|
1199
|
+
toolCtx.recordExternalFileChanges = (record) => {
|
|
1200
|
+
session.transcript.append("external_file_changes", { ...record });
|
|
1201
|
+
};
|
|
1289
1202
|
toolCtx.setSessionWorkspace = (workspace) => {
|
|
1290
1203
|
session.state.workspace = workspace;
|
|
1291
1204
|
};
|
|
1292
|
-
|
|
1205
|
+
const sessionRun = runWithSid(session.state.sessionId, async () => {
|
|
1293
1206
|
recordSessionStart(session.state.sessionId, {
|
|
1294
1207
|
// Strip <codeshell-image> base64 payloads before they reach
|
|
1295
1208
|
// <repo>/log/. Reader still sees the marker + byte count, just
|
|
@@ -1298,7 +1211,7 @@ export class Engine {
|
|
|
1298
1211
|
cwd,
|
|
1299
1212
|
model: this.config.llm.model,
|
|
1300
1213
|
provider: this.config.llm.provider,
|
|
1301
|
-
permissionMode:
|
|
1214
|
+
permissionMode: runPermissionMode,
|
|
1302
1215
|
resumed: resumedFromDisk,
|
|
1303
1216
|
});
|
|
1304
1217
|
// Session-level hook: fired once per Engine.run() entry, regardless of
|
|
@@ -1311,7 +1224,7 @@ export class Engine {
|
|
|
1311
1224
|
cwd,
|
|
1312
1225
|
resumed: resumedFromDisk,
|
|
1313
1226
|
source: resumedFromDisk ? "resume" : "startup",
|
|
1314
|
-
});
|
|
1227
|
+
}, options?.signal);
|
|
1315
1228
|
// Per-turn hook: fired every time a new user prompt enters the loop.
|
|
1316
1229
|
// Equivalent to CC's UserPromptSubmit. Handlers can inject lightweight
|
|
1317
1230
|
// reminders that should accompany each user turn (e.g. "skills
|
|
@@ -1325,7 +1238,7 @@ export class Engine {
|
|
|
1325
1238
|
// exfiltration risk a curious user-installed shell hook shouldn't carry.
|
|
1326
1239
|
prompt: taskText,
|
|
1327
1240
|
resumed: resumedFromDisk,
|
|
1328
|
-
});
|
|
1241
|
+
}, options?.signal);
|
|
1329
1242
|
// updatedPrompt: handler rewrote the user's prompt text. Replace the
|
|
1330
1243
|
// last user message we just pushed (cold-start: line ~511; resume:
|
|
1331
1244
|
// line ~500). Original prompt is in the transcript already — we log
|
|
@@ -1412,8 +1325,14 @@ export class Engine {
|
|
|
1412
1325
|
}
|
|
1413
1326
|
// Kick off LLM client creation early (network handshake)
|
|
1414
1327
|
const llmClientPromise = createLLMClient(this.config.llm, this.config.clientDefaults);
|
|
1415
|
-
|
|
1416
|
-
|
|
1328
|
+
// MCP connection below may keep us from awaiting this promise for a while.
|
|
1329
|
+
// Observe rejection immediately so a fast client-init failure cannot become
|
|
1330
|
+
// an unhandledRejection during that gap; Promise.all still receives the
|
|
1331
|
+
// original promise and routes the same error through the lifecycle catch.
|
|
1332
|
+
void llmClientPromise.catch(() => { });
|
|
1333
|
+
const mode = runPermissionMode;
|
|
1334
|
+
this.activeApprovalRouter = toolCtx.approvalRouter;
|
|
1335
|
+
const { rules: defaultRules, backend: approvalBackend } = this.buildPermissionConfig(mode, cwd, toolCtx.approvalRouter);
|
|
1417
1336
|
const permission = new PermissionClassifier(defaultRules, mode, approvalBackend);
|
|
1418
1337
|
this.activePermission = permission;
|
|
1419
1338
|
// If the backend is the interactive one, wire it for project-scope
|
|
@@ -1575,7 +1494,7 @@ export class Engine {
|
|
|
1575
1494
|
// PLAN_MODE_ALLOWED_TOOLS so what the model SEES and what the executor
|
|
1576
1495
|
// RUNS can't drift apart. (Bash is in the set; the executor additionally
|
|
1577
1496
|
// gates Bash to read-only commands at call time.)
|
|
1578
|
-
const toolDefs =
|
|
1497
|
+
const toolDefs = runPlanMode
|
|
1579
1498
|
? allToolDefs.filter((t) => PLAN_MODE_ALLOWED_TOOLS.has(t.name))
|
|
1580
1499
|
: allToolDefs;
|
|
1581
1500
|
const [llmClient, fullSystemPrompt, dynamicContextMsg] = await Promise.all([
|
|
@@ -1634,15 +1553,81 @@ export class Engine {
|
|
|
1634
1553
|
// These are tiny throwaway outputs ("Wrote design doc") fired every turn;
|
|
1635
1554
|
// that high-frequency, low-stakes chore is exactly what aux is for.
|
|
1636
1555
|
const auxSummaryClient = await this.resolveAuxClient(llmClient);
|
|
1556
|
+
// Auto-compaction runs inside TurnLoop.manageAsync(), after the loop has
|
|
1557
|
+
// initialized its run-scoped Goal tracker. The closure is wired before
|
|
1558
|
+
// construction but cannot execute until turnLoop.run() starts.
|
|
1559
|
+
let turnLoop;
|
|
1560
|
+
let autoCompactionGoalTermination;
|
|
1561
|
+
let externalRunUsage = {
|
|
1562
|
+
promptTokens: 0,
|
|
1563
|
+
completionTokens: 0,
|
|
1564
|
+
totalTokens: 0,
|
|
1565
|
+
cacheReadTokens: 0,
|
|
1566
|
+
cacheCreationTokens: 0,
|
|
1567
|
+
};
|
|
1568
|
+
let runAccountingFinalized = false;
|
|
1637
1569
|
Object.assign(session.state, normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage));
|
|
1638
1570
|
const recordCumulativeUsage = (usage) => {
|
|
1639
1571
|
const next = addCumulativeUsage(session.state, usage);
|
|
1640
1572
|
Object.assign(session.state, next);
|
|
1641
1573
|
return next;
|
|
1642
1574
|
};
|
|
1643
|
-
|
|
1575
|
+
const recordExternalBilledUsage = (usage) => {
|
|
1576
|
+
externalRunUsage = addTokenUsage(externalRunUsage, usage);
|
|
1577
|
+
const cumulative = recordCumulativeUsage(usage);
|
|
1578
|
+
autoCompactionGoalTermination = turnLoop.recordGoalJudgeUsage(usage);
|
|
1579
|
+
if (runAccountingFinalized) {
|
|
1580
|
+
try {
|
|
1581
|
+
const latest = this.sessionManager.resume(sid).state;
|
|
1582
|
+
const lateCumulative = addCumulativeUsage(latest, usage);
|
|
1583
|
+
this.sessionManager.updateSessionState(sid, {
|
|
1584
|
+
tokenUsage: addTokenUsage(latest.tokenUsage, usage),
|
|
1585
|
+
...lateCumulative,
|
|
1586
|
+
...(this.config.costStore
|
|
1587
|
+
? {
|
|
1588
|
+
costState: this.config.costStore.serialize(),
|
|
1589
|
+
}
|
|
1590
|
+
: {}),
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
catch (err) {
|
|
1594
|
+
logger.warn("engine.late_usage_persist_failed", {
|
|
1595
|
+
sessionId: sid,
|
|
1596
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
return cumulative;
|
|
1601
|
+
};
|
|
1602
|
+
contextManager.setSummarizeFn(this.buildSummarizeFn(llmClient, recordExternalBilledUsage));
|
|
1644
1603
|
// Create components (requires resolved llmClient).
|
|
1645
1604
|
const modelFacade = new ModelFacade(llmClient, session.transcript);
|
|
1605
|
+
const getRunUsage = () => {
|
|
1606
|
+
const visible = modelFacade.getUsage();
|
|
1607
|
+
return {
|
|
1608
|
+
...visible,
|
|
1609
|
+
totalPromptTokens: visible.totalPromptTokens + externalRunUsage.promptTokens,
|
|
1610
|
+
totalCompletionTokens: visible.totalCompletionTokens + externalRunUsage.completionTokens,
|
|
1611
|
+
totalTokens: visible.totalTokens + externalRunUsage.totalTokens,
|
|
1612
|
+
totalCacheReadTokens: visible.totalCacheReadTokens + (externalRunUsage.cacheReadTokens ?? 0),
|
|
1613
|
+
totalCacheCreationTokens: visible.totalCacheCreationTokens + (externalRunUsage.cacheCreationTokens ?? 0),
|
|
1614
|
+
};
|
|
1615
|
+
};
|
|
1616
|
+
const callPrimaryModel = modelFacade.call.bind(modelFacade);
|
|
1617
|
+
modelFacade.call = async (...args) => {
|
|
1618
|
+
// A primary-model summary may itself exhaust the Goal budget. Do not
|
|
1619
|
+
// issue the main turn request after that billed sub-call; return control
|
|
1620
|
+
// to TurnLoop, whose existing post-response guard emits and persists the
|
|
1621
|
+
// canonical goal_budget_exhausted termination.
|
|
1622
|
+
if (autoCompactionGoalTermination) {
|
|
1623
|
+
return {
|
|
1624
|
+
text: "",
|
|
1625
|
+
toolCalls: [],
|
|
1626
|
+
stopReason: "stop",
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
return callPrimaryModel(...args);
|
|
1630
|
+
};
|
|
1646
1631
|
// Session-cumulative usage baseline: the LLM client is recreated per run
|
|
1647
1632
|
// (its getUsage() counts only THIS run), so to accumulate across runs we
|
|
1648
1633
|
// capture the persisted total at run start and fold this run's usage onto
|
|
@@ -1650,23 +1635,25 @@ export class Engine {
|
|
|
1650
1635
|
const usageBaseline = { ...session.state.tokenUsage };
|
|
1651
1636
|
// Wire getOutputTokens for token budget tracking
|
|
1652
1637
|
modelFacade.getOutputTokens = () => {
|
|
1653
|
-
const usage =
|
|
1638
|
+
const usage = getRunUsage();
|
|
1654
1639
|
return usage.totalCompletionTokens;
|
|
1655
1640
|
};
|
|
1656
|
-
// Wire summarize for tool use summaries (uses lightweight call).
|
|
1657
|
-
//
|
|
1658
|
-
//
|
|
1659
|
-
// turns/requestCount stay aligned.
|
|
1641
|
+
// Wire summarize for tool use summaries (uses lightweight call). Keep the
|
|
1642
|
+
// request out of the foreground tracker while billing and reporting it to
|
|
1643
|
+
// the owning session/Goal budget.
|
|
1660
1644
|
modelFacade.summarize = async (sysPrompt, userMsg) => {
|
|
1661
1645
|
const resp = await auxSummaryClient.createMessage({
|
|
1662
1646
|
systemPrompt: sysPrompt,
|
|
1663
1647
|
messages: [{ role: "user", content: userMsg }],
|
|
1664
1648
|
tools: [],
|
|
1665
1649
|
maxTokens: 256,
|
|
1666
|
-
|
|
1650
|
+
billingEnabled: true,
|
|
1651
|
+
requestVisible: false,
|
|
1667
1652
|
// Auxiliary call — see contextManager.setSummarizeFn above.
|
|
1668
1653
|
reasoning: { mode: "off" },
|
|
1669
1654
|
});
|
|
1655
|
+
if (resp.usage)
|
|
1656
|
+
recordExternalBilledUsage(resp.usage);
|
|
1670
1657
|
logger.debug("summarize.call", {
|
|
1671
1658
|
sysPromptLen: sysPrompt.length,
|
|
1672
1659
|
userMsgLen: userMsg.length,
|
|
@@ -1720,21 +1707,24 @@ export class Engine {
|
|
|
1720
1707
|
sessionId: session.state.sessionId,
|
|
1721
1708
|
task,
|
|
1722
1709
|
model: this.config.llm.model,
|
|
1723
|
-
});
|
|
1710
|
+
}, options?.signal);
|
|
1724
1711
|
// Goal mode: register a GoalStopHook for the lifetime of THIS run so the
|
|
1725
1712
|
// turn loop keeps going until the session model judges the goal met.
|
|
1726
1713
|
// Registered per-run (and cleared in `finally`) so a later goal-less
|
|
1727
|
-
// send doesn't inherit a stale goal. The judge runs on
|
|
1728
|
-
//
|
|
1729
|
-
//
|
|
1730
|
-
// aux-tier task, and a goal run can invoke the judge up to maxStopBlocks
|
|
1731
|
-
// times.
|
|
1714
|
+
// send doesn't inherit a stale goal. The judge runs on the primary
|
|
1715
|
+
// session client; auxSummaryClient remains dedicated to low-consequence
|
|
1716
|
+
// summaries/titles and retains defaults.auxText routing/fallback behavior.
|
|
1732
1717
|
// Normalize the raw goal (string | GoalConfig) once at the run boundary;
|
|
1733
1718
|
// everything inward uses the GoalConfig. normalizeGoal() returns undefined
|
|
1734
1719
|
// when there's effectively no goal (empty objective).
|
|
1735
1720
|
//
|
|
1736
1721
|
// PERSISTENT GOAL (CC /goal style): a goal set on one send survives across
|
|
1737
|
-
// later sends and manual interrupts until met or cleared.
|
|
1722
|
+
// later sends and manual interrupts until met or cleared. Goal completion
|
|
1723
|
+
// is a high-consequence decision, so V1 routes it to the primary session
|
|
1724
|
+
// client, which is the model expected to interpret the supplied execution
|
|
1725
|
+
// evidence. defaults.auxText remains in force for summaries, titles and
|
|
1726
|
+
// other auxiliary work through auxSummaryClient.
|
|
1727
|
+
// Resolution:
|
|
1738
1728
|
// 1. options.goal — this send explicitly sets/replaces the goal.
|
|
1739
1729
|
// 2. session.state.activeGoal — a goal set on an earlier send.
|
|
1740
1730
|
// 3. config.goal — engine-level default (rare; e.g. headless).
|
|
@@ -1743,7 +1733,15 @@ export class Engine {
|
|
|
1743
1733
|
// bare send with no options.goal inherits the stored active goal so the
|
|
1744
1734
|
// model keeps working toward it — that's what makes it persistent.
|
|
1745
1735
|
const explicitGoal = normalizeGoal(options?.goal);
|
|
1746
|
-
|
|
1736
|
+
let storedGoal = this.config.isSubAgent !== true ? session.state.activeGoal : undefined;
|
|
1737
|
+
// Defense in depth: a stale whole-state writer may have restored the
|
|
1738
|
+
// activeGoal field after this exact goal instance was force-terminated.
|
|
1739
|
+
// Refuse to arm it and converge the live bundle before hook registration.
|
|
1740
|
+
if (storedGoal && isSameGoalInstance(storedGoal, session.state.goalTerminal)) {
|
|
1741
|
+
session.state.activeGoal = undefined;
|
|
1742
|
+
storedGoal = undefined;
|
|
1743
|
+
this.sessionManager.saveState(session.state);
|
|
1744
|
+
}
|
|
1747
1745
|
if (explicitGoal && this.config.isSubAgent !== true) {
|
|
1748
1746
|
const replaced = !!storedGoal && storedGoal.objective !== explicitGoal.objective;
|
|
1749
1747
|
// Stamp WHEN this goal was set so the judge can anchor relative deadlines
|
|
@@ -1752,7 +1750,14 @@ export class Engine {
|
|
|
1752
1750
|
// or changed objective gets a fresh stamp; re-sending the SAME objective
|
|
1753
1751
|
// keeps the original anchor (the goal continues, the user didn't restate a
|
|
1754
1752
|
// new deadline). User input never carries setAtMs, so we set it here.
|
|
1755
|
-
|
|
1753
|
+
const resolvedSetAt = resolveGoalSetAt(explicitGoal.objective, storedGoal, Date.now());
|
|
1754
|
+
// A user explicitly re-starting the same objective creates a new goal
|
|
1755
|
+
// instance. Avoid a same-millisecond collision with its old tombstone.
|
|
1756
|
+
explicitGoal.setAtMs =
|
|
1757
|
+
session.state.goalTerminal?.objective === explicitGoal.objective &&
|
|
1758
|
+
session.state.goalTerminal.setAtMs === resolvedSetAt
|
|
1759
|
+
? resolvedSetAt + 1
|
|
1760
|
+
: resolvedSetAt;
|
|
1756
1761
|
session.state.activeGoal = explicitGoal;
|
|
1757
1762
|
this.sessionManager.saveState(session.state);
|
|
1758
1763
|
options?.onStream?.({
|
|
@@ -1762,18 +1767,35 @@ export class Engine {
|
|
|
1762
1767
|
});
|
|
1763
1768
|
}
|
|
1764
1769
|
const normalizedGoal = explicitGoal ?? storedGoal ?? normalizeGoal(this.config.goal);
|
|
1770
|
+
// Snapshot the persisted goal identity owned by THIS run. Terminal
|
|
1771
|
+
// cleanup compares against this immutable copy so an old run cannot
|
|
1772
|
+
// delete a replacement goal installed while it was finishing.
|
|
1773
|
+
const persistedRunGoal = normalizedGoal && isSameGoalInstance(session.state.activeGoal, normalizedGoal)
|
|
1774
|
+
? { ...normalizedGoal }
|
|
1775
|
+
: undefined;
|
|
1765
1776
|
let goalHookHandler = null;
|
|
1777
|
+
let goalJudgeContext;
|
|
1766
1778
|
if (normalizedGoal && this.config.isSubAgent !== true) {
|
|
1767
1779
|
goalHookHandler = createGoalStopHook({
|
|
1768
1780
|
goal: normalizedGoal,
|
|
1769
|
-
llm:
|
|
1781
|
+
llm: llmClient,
|
|
1770
1782
|
log: logger,
|
|
1783
|
+
getJudgeContext: () => goalJudgeContext,
|
|
1784
|
+
onJudgeUsage: (usage) => {
|
|
1785
|
+
// The provider records this request into llmClient.getUsage() and the
|
|
1786
|
+
// process-wide CostTracker. This separate callback feeds the session
|
|
1787
|
+
// cumulative cache counters and the live Goal hard-budget tracker.
|
|
1788
|
+
if (usage)
|
|
1789
|
+
recordCumulativeUsage(usage);
|
|
1790
|
+
return turnLoop.recordGoalJudgeUsage(usage);
|
|
1791
|
+
},
|
|
1771
1792
|
// Clear the persisted active goal the moment the judge says it's met,
|
|
1772
1793
|
// so a later bare send doesn't re-inherit a satisfied goal. The hook
|
|
1773
1794
|
// calls this from inside its met branch (single source of truth for
|
|
1774
1795
|
// "goal achieved"); engine owns the persistence side-effect.
|
|
1775
1796
|
onMet: () => {
|
|
1776
|
-
if (
|
|
1797
|
+
if (persistedRunGoal &&
|
|
1798
|
+
isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
|
|
1777
1799
|
session.state.activeGoal = undefined;
|
|
1778
1800
|
this.sessionManager.saveState(session.state);
|
|
1779
1801
|
}
|
|
@@ -1783,7 +1805,7 @@ export class Engine {
|
|
|
1783
1805
|
// in-RAM session are untouched) actually stops the judge. Reads disk
|
|
1784
1806
|
// via readActiveGoal — authoritative and independent of which session
|
|
1785
1807
|
// instance the run closure holds.
|
|
1786
|
-
isGoalActive: (sid) => this.sessionManager.readActiveGoal(sid)
|
|
1808
|
+
isGoalActive: (sid) => isSameGoalInstance(this.sessionManager.readActiveGoal(sid), normalizedGoal),
|
|
1787
1809
|
});
|
|
1788
1810
|
this.hooks.register("on_stop", goalHookHandler, 0, "goal-stop");
|
|
1789
1811
|
// Expose for clearGoal() mid-run. Already guarded by isSubAgent above.
|
|
@@ -1799,7 +1821,7 @@ export class Engine {
|
|
|
1799
1821
|
options?.onStream?.({ type: "context_compact", ...info });
|
|
1800
1822
|
});
|
|
1801
1823
|
// Run turn loop
|
|
1802
|
-
|
|
1824
|
+
turnLoop = new TurnLoop({
|
|
1803
1825
|
model: modelFacade,
|
|
1804
1826
|
toolExecutor,
|
|
1805
1827
|
contextManager,
|
|
@@ -1815,8 +1837,31 @@ export class Engine {
|
|
|
1815
1837
|
return info;
|
|
1816
1838
|
},
|
|
1817
1839
|
consumeSteer: (source) => this.consumeSteer(sid, source),
|
|
1840
|
+
restoreSteer: (items) => this.restoreSteer(sid, items),
|
|
1841
|
+
buildSteerUserMessageContent: async (item) => {
|
|
1842
|
+
const steerImageInput = await prepareRunImageInput({
|
|
1843
|
+
task: item.text,
|
|
1844
|
+
cwd,
|
|
1845
|
+
llm: this.config.llm,
|
|
1846
|
+
sessionId: sid,
|
|
1847
|
+
attachments: item.attachments,
|
|
1848
|
+
});
|
|
1849
|
+
if (!steerImageInput.ok) {
|
|
1850
|
+
throw new Error(steerImageInput.result.text);
|
|
1851
|
+
}
|
|
1852
|
+
return buildRunUserMessageContent(steerImageInput.parsedTask, cwd, steerImageInput.taskText);
|
|
1853
|
+
},
|
|
1818
1854
|
claimClientMessageId: (clientMessageId, source) => claimClientMessageId(session, clientMessageId, source),
|
|
1855
|
+
releaseClientMessageId: (clientMessageId) => {
|
|
1856
|
+
claimedClientMessageIds.delete(clientMessageId);
|
|
1857
|
+
},
|
|
1858
|
+
setOriginClientMessageId: (clientMessageId) => {
|
|
1859
|
+
toolCtx.originClientMessageId = clientMessageId;
|
|
1860
|
+
},
|
|
1819
1861
|
recordCumulativeUsage,
|
|
1862
|
+
recordCacheReadDiagnostics: (usage) => {
|
|
1863
|
+
this.recordCacheReadDiagnostics(sid, usage);
|
|
1864
|
+
},
|
|
1820
1865
|
recordContextUsageAnchor: (anchor) => {
|
|
1821
1866
|
session.state.contextUsageAnchor = {
|
|
1822
1867
|
...anchor,
|
|
@@ -1829,7 +1874,8 @@ export class Engine {
|
|
|
1829
1874
|
// turns don't re-arm) AND persists it, and drops the in-flight stop
|
|
1830
1875
|
// hook so nothing re-blocks the stop we're about to return.
|
|
1831
1876
|
clearPersistedGoal: () => {
|
|
1832
|
-
if (
|
|
1877
|
+
if (persistedRunGoal &&
|
|
1878
|
+
isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
|
|
1833
1879
|
session.state.activeGoal = undefined;
|
|
1834
1880
|
this.sessionManager.saveState(session.state);
|
|
1835
1881
|
}
|
|
@@ -1839,6 +1885,9 @@ export class Engine {
|
|
|
1839
1885
|
this.activeGoalHook = null;
|
|
1840
1886
|
}
|
|
1841
1887
|
},
|
|
1888
|
+
updateGoalJudgeContext: (context) => {
|
|
1889
|
+
goalJudgeContext = context;
|
|
1890
|
+
},
|
|
1842
1891
|
ctxOverheadStore: {
|
|
1843
1892
|
get: (s) => this.ctxOverheadBySid.get(s) ?? 0,
|
|
1844
1893
|
set: (s, n) => {
|
|
@@ -1863,6 +1912,7 @@ export class Engine {
|
|
|
1863
1912
|
onStream: options?.onStream,
|
|
1864
1913
|
signal: options?.signal,
|
|
1865
1914
|
freshImageMessages: freshImageMessage ? [freshImageMessage] : undefined,
|
|
1915
|
+
volatileContextMessages: dynamicContextMsg ? [dynamicContextMsg] : undefined,
|
|
1866
1916
|
// Goal mode: the active goal is surfaced to the on_stop handler via
|
|
1867
1917
|
// ctx.data.goal; the GoalStopHook (registered above) judges it.
|
|
1868
1918
|
goal: normalizedGoal,
|
|
@@ -1874,7 +1924,7 @@ export class Engine {
|
|
|
1874
1924
|
session.state.turnCount = turnCount;
|
|
1875
1925
|
// baseline + this run's running total (idempotent per boundary,
|
|
1876
1926
|
// accumulates across runs; carries cacheRead/cacheCreation too).
|
|
1877
|
-
session.state.tokenUsage = foldRunUsage(usageBaseline,
|
|
1927
|
+
session.state.tokenUsage = foldRunUsage(usageBaseline, getRunUsage());
|
|
1878
1928
|
// Surface the whole-session monotonic cache counts to the UI.
|
|
1879
1929
|
// Separate from turn-loop's authoritative per-response emit (which
|
|
1880
1930
|
// drives the live context reading and single-turn metric).
|
|
@@ -1901,6 +1951,7 @@ export class Engine {
|
|
|
1901
1951
|
this.sessionManager.saveState(session.state);
|
|
1902
1952
|
},
|
|
1903
1953
|
});
|
|
1954
|
+
toolCtx.recordBilledUsage = recordExternalBilledUsage;
|
|
1904
1955
|
// Expose this run's loop for mid-run extension (TODO 3.1). Top-level only —
|
|
1905
1956
|
// a sub-agent's loop is its own concern and isn't user-extendable.
|
|
1906
1957
|
if (this.config.isSubAgent !== true)
|
|
@@ -1910,9 +1961,38 @@ export class Engine {
|
|
|
1910
1961
|
// only — sub-agents don't carry user-clearable persistent goals.
|
|
1911
1962
|
if (this.config.isSubAgent !== true)
|
|
1912
1963
|
this.activeRunSession = session;
|
|
1964
|
+
const applyGoalTermination = (termination) => {
|
|
1965
|
+
if (!termination || !persistedRunGoal)
|
|
1966
|
+
return;
|
|
1967
|
+
// Judge prompt overflow ends only this run. The objective is unfinished
|
|
1968
|
+
// and may be resumed after the user reduces fixed judge context, so it
|
|
1969
|
+
// must not get a terminal tombstone or be cleared from activeGoal.
|
|
1970
|
+
if (termination === "judge_prompt_too_large")
|
|
1971
|
+
return;
|
|
1972
|
+
// Record the terminal identity even when a newer goal has already
|
|
1973
|
+
// replaced it. Only clear activeGoal when it is still the run's goal.
|
|
1974
|
+
session.state.goalTerminal = {
|
|
1975
|
+
objective: persistedRunGoal.objective,
|
|
1976
|
+
setAtMs: persistedRunGoal.setAtMs,
|
|
1977
|
+
reason: termination,
|
|
1978
|
+
terminatedAtMs: Date.now(),
|
|
1979
|
+
};
|
|
1980
|
+
if (isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
|
|
1981
|
+
session.state.activeGoal = undefined;
|
|
1982
|
+
}
|
|
1983
|
+
this.sessionManager.saveState(session.state);
|
|
1984
|
+
if (goalHookHandler) {
|
|
1985
|
+
this.hooks.unregister("on_stop", goalHookHandler);
|
|
1986
|
+
if (this.activeGoalHook === goalHookHandler)
|
|
1987
|
+
this.activeGoalHook = null;
|
|
1988
|
+
}
|
|
1989
|
+
};
|
|
1913
1990
|
let result;
|
|
1991
|
+
let firstGoalTermination;
|
|
1914
1992
|
try {
|
|
1915
1993
|
result = await turnLoop.run(messages);
|
|
1994
|
+
firstGoalTermination = result.goalTermination;
|
|
1995
|
+
applyGoalTermination(result.goalTermination);
|
|
1916
1996
|
// ── Headless: drain background sub-agents before resolving ───────
|
|
1917
1997
|
// Unified background-work model (2026-06-17): the engine NO LONGER parks
|
|
1918
1998
|
// every run waiting on background work. Background work (sub-agents,
|
|
@@ -1966,14 +2046,19 @@ export class Engine {
|
|
|
1966
2046
|
role: "user",
|
|
1967
2047
|
content: `<system-reminder>\n${buildNotificationMessage(pending)}\n</system-reminder>`,
|
|
1968
2048
|
};
|
|
1969
|
-
if (aborted) {
|
|
2049
|
+
if (aborted || firstGoalTermination) {
|
|
1970
2050
|
// Mark injected: a synthetic notification, not the user's own input —
|
|
1971
2051
|
// the disk reader drops it on replay so no phantom user bubble.
|
|
2052
|
+
// A goal termination is also a hard boundary: retain the notification
|
|
2053
|
+
// for recovery, but never re-enter TurnLoop (which would reset its
|
|
2054
|
+
// run-scoped goal budget tracker and could overwrite the first reason).
|
|
1972
2055
|
session.transcript.appendMessage(injected.role, injected.content, { injected: true });
|
|
1973
2056
|
result = { ...result, messages: [...result.messages, injected] };
|
|
1974
2057
|
break;
|
|
1975
2058
|
}
|
|
1976
2059
|
result = await turnLoop.run([...result.messages, injected]);
|
|
2060
|
+
firstGoalTermination ??= result.goalTermination;
|
|
2061
|
+
applyGoalTermination(result.goalTermination);
|
|
1977
2062
|
}
|
|
1978
2063
|
}
|
|
1979
2064
|
}
|
|
@@ -1993,17 +2078,18 @@ export class Engine {
|
|
|
1993
2078
|
this.hooks.unregister("on_tool_start", fileHistoryHandler);
|
|
1994
2079
|
}
|
|
1995
2080
|
this.lastMessages = result.messages;
|
|
1996
|
-
|
|
2081
|
+
const cachedMessages = this.stripInjectedContextMessages(result.messages, userContextMsg, dynamicContextMsg);
|
|
2082
|
+
this.compactedMessagesBySession.set(session.state.sessionId, cachedMessages);
|
|
1997
2083
|
logger.info("engine.done", {
|
|
1998
2084
|
sessionId: session.state.sessionId,
|
|
1999
2085
|
reason: result.reason,
|
|
2000
2086
|
turns: turnLoop.currentTurn,
|
|
2001
|
-
tokens:
|
|
2087
|
+
tokens: getRunUsage().totalTokens,
|
|
2002
2088
|
});
|
|
2003
2089
|
recordSessionEnd(session.state.sessionId, {
|
|
2004
2090
|
reason: result.reason,
|
|
2005
2091
|
turns: turnLoop.currentTurn,
|
|
2006
|
-
cost:
|
|
2092
|
+
cost: getRunUsage(),
|
|
2007
2093
|
});
|
|
2008
2094
|
// Session-level hook: fired symmetrically with on_session_start once
|
|
2009
2095
|
// the turn loop has resolved (completion, error, or abort). Handlers
|
|
@@ -2013,11 +2099,11 @@ export class Engine {
|
|
|
2013
2099
|
sessionId: session.state.sessionId,
|
|
2014
2100
|
reason: result.reason,
|
|
2015
2101
|
turnCount: turnLoop.currentTurn,
|
|
2016
|
-
});
|
|
2102
|
+
}, options?.signal);
|
|
2017
2103
|
// Fire-and-forget memory pipeline: extract durable memories from the
|
|
2018
2104
|
// transcript, save a session summary, and conditionally trigger
|
|
2019
2105
|
// auto-dream consolidation. Doesn't block the Engine result.
|
|
2020
|
-
void this.runMemoryPipeline(session.transcript, session.state.sessionId, cwd, llmClient);
|
|
2106
|
+
void this.runMemoryPipeline(session.transcript, session.state.sessionId, cwd, llmClient, recordExternalBilledUsage);
|
|
2021
2107
|
// Fire-and-forget session title generation — only after the FIRST turn.
|
|
2022
2108
|
// Reuses the already-resolved auxSummaryClient (aux model, cheap). Best-
|
|
2023
2109
|
// effort: failures never touch the run result. The renderer writes the
|
|
@@ -2028,20 +2114,21 @@ export class Engine {
|
|
|
2028
2114
|
const userMsgCount = userMsgEvents.length;
|
|
2029
2115
|
const onStream = options?.onStream;
|
|
2030
2116
|
if (userMsgCount === 1 && onStream && result.text) {
|
|
2117
|
+
const sessionId = session.state.sessionId;
|
|
2031
2118
|
const rawContent = userMsgEvents[0]?.data?.content;
|
|
2032
2119
|
const firstUserText = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent ?? "");
|
|
2033
|
-
void buildSessionTitle(auxSummaryClient, firstUserText, result.text)
|
|
2120
|
+
void buildSessionTitle(auxSummaryClient, firstUserText, result.text, recordExternalBilledUsage)
|
|
2034
2121
|
.then((title) => {
|
|
2035
2122
|
if (title) {
|
|
2036
2123
|
// Persist the title so it survives a localStorage wipe / disk
|
|
2037
2124
|
// rebuild — it used to live only in the renderer's localStorage
|
|
2038
|
-
// index.
|
|
2039
|
-
//
|
|
2040
|
-
session.
|
|
2041
|
-
this.sessionManager.
|
|
2125
|
+
// index. Read the latest persisted state at callback time and
|
|
2126
|
+
// merge only title; the completed run's session.state snapshot
|
|
2127
|
+
// may already be stale after later serial session updates.
|
|
2128
|
+
this.sessionManager.updateSessionState(sessionId, { title });
|
|
2042
2129
|
onStream({
|
|
2043
2130
|
type: "session_title",
|
|
2044
|
-
sessionId
|
|
2131
|
+
sessionId,
|
|
2045
2132
|
title,
|
|
2046
2133
|
});
|
|
2047
2134
|
}
|
|
@@ -2054,32 +2141,82 @@ export class Engine {
|
|
|
2054
2141
|
// failures (model_error, prompt_too_long, ...) — previously every
|
|
2055
2142
|
// non-completed outcome collapsed to "errored", which threw away the
|
|
2056
2143
|
// distinction and misled anyone reading state.json.
|
|
2144
|
+
if (session.transcript.flushFailed()) {
|
|
2145
|
+
const failure = session.transcript.getFlushFailure();
|
|
2146
|
+
logger.error("engine.transcript_persistence_failed", {
|
|
2147
|
+
sessionId: session.state.sessionId,
|
|
2148
|
+
terminalReason: result.reason,
|
|
2149
|
+
degraded: true,
|
|
2150
|
+
...failure,
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
2057
2153
|
session.state.turnCount = turnLoop.currentTurn;
|
|
2058
2154
|
session.state.status = result.reason;
|
|
2059
2155
|
// Session-cumulative (baseline + this run) for persistence...
|
|
2060
|
-
const usage =
|
|
2156
|
+
const usage = getRunUsage();
|
|
2061
2157
|
session.state.tokenUsage = foldRunUsage(usageBaseline, usage);
|
|
2062
2158
|
if (this.config.costStore) {
|
|
2063
2159
|
session.state.costState = this.config.costStore.serialize();
|
|
2064
2160
|
}
|
|
2161
|
+
// runInProgress excludes another whole-state writer only on this Engine
|
|
2162
|
+
// instance. A different Engine using the same sessionId can still race
|
|
2163
|
+
// this saveState (including an old run's abort cleanup vs a replacement
|
|
2164
|
+
// Engine); cross-instance/process session serialization is a separate finding.
|
|
2065
2165
|
this.sessionManager.saveState(session.state);
|
|
2166
|
+
runAccountingFinalized = true;
|
|
2066
2167
|
// Hook: agent end
|
|
2067
2168
|
await this.emitHook("on_agent_end", {
|
|
2068
2169
|
sessionId: session.state.sessionId,
|
|
2069
2170
|
reason: result.reason,
|
|
2070
2171
|
turnCount: turnLoop.currentTurn,
|
|
2071
|
-
});
|
|
2172
|
+
}, options?.signal);
|
|
2072
2173
|
// Emit completion
|
|
2073
2174
|
options?.onStream?.({ type: "turn_complete", reason: result.reason });
|
|
2074
2175
|
return {
|
|
2075
2176
|
text: result.text,
|
|
2076
2177
|
reason: result.reason,
|
|
2178
|
+
goalTermination: firstGoalTermination,
|
|
2077
2179
|
sessionId: session.state.sessionId,
|
|
2078
2180
|
turnCount: turnLoop.currentTurn,
|
|
2079
2181
|
usage: {
|
|
2080
2182
|
promptTokens: usage.totalPromptTokens,
|
|
2081
2183
|
completionTokens: usage.totalCompletionTokens,
|
|
2082
2184
|
totalTokens: usage.totalTokens,
|
|
2185
|
+
cacheReadTokens: usage.totalCacheReadTokens,
|
|
2186
|
+
cacheCreationTokens: usage.totalCacheCreationTokens,
|
|
2187
|
+
},
|
|
2188
|
+
};
|
|
2189
|
+
});
|
|
2190
|
+
return Promise.resolve(sessionRun).catch((err) => {
|
|
2191
|
+
// The session is already persisted as active before runWithSid starts.
|
|
2192
|
+
// Initialization failures (client creation, MCP connection, prompt/hooks)
|
|
2193
|
+
// therefore need the same terminal lifecycle treatment as turn-loop errors.
|
|
2194
|
+
const error = formatFriendlyError(err);
|
|
2195
|
+
session.state.status = "model_error";
|
|
2196
|
+
this.sessionManager.saveState(session.state);
|
|
2197
|
+
session.transcript.appendError(error, { phase: "initialization" });
|
|
2198
|
+
logger.error("engine.run_lifecycle_failed", {
|
|
2199
|
+
sessionId: session.state.sessionId,
|
|
2200
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2201
|
+
});
|
|
2202
|
+
recordSessionEnd(session.state.sessionId, {
|
|
2203
|
+
reason: "model_error",
|
|
2204
|
+
turns: session.state.turnCount,
|
|
2205
|
+
});
|
|
2206
|
+
options?.onStream?.({ type: "error", error });
|
|
2207
|
+
options?.onStream?.({ type: "turn_complete", reason: "model_error" });
|
|
2208
|
+
const usage = session.state.tokenUsage;
|
|
2209
|
+
return {
|
|
2210
|
+
text: `ERROR: ${error}`,
|
|
2211
|
+
reason: "model_error",
|
|
2212
|
+
sessionId: session.state.sessionId,
|
|
2213
|
+
turnCount: session.state.turnCount,
|
|
2214
|
+
usage: {
|
|
2215
|
+
promptTokens: usage.promptTokens ?? 0,
|
|
2216
|
+
completionTokens: usage.completionTokens ?? 0,
|
|
2217
|
+
totalTokens: usage.totalTokens ?? 0,
|
|
2218
|
+
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
2219
|
+
cacheCreationTokens: usage.cacheCreationTokens ?? 0,
|
|
2083
2220
|
},
|
|
2084
2221
|
};
|
|
2085
2222
|
});
|
|
@@ -2102,16 +2239,19 @@ export class Engine {
|
|
|
2102
2239
|
* run path and forceCompact share one definition of the summarization call.
|
|
2103
2240
|
*/
|
|
2104
2241
|
buildSummarizeFn(auxSummaryClient, recordCumulativeUsage) {
|
|
2105
|
-
return async (prompt) => {
|
|
2242
|
+
return async (prompt, signal) => {
|
|
2106
2243
|
const summaryResponse = await auxSummaryClient.createMessage({
|
|
2107
2244
|
systemPrompt: "You are a conversation summarizer. Be concise and factual.",
|
|
2108
2245
|
messages: [{ role: "user", content: prompt }],
|
|
2109
2246
|
tools: [],
|
|
2110
2247
|
maxTokens: 1024,
|
|
2248
|
+
billingEnabled: true,
|
|
2249
|
+
requestVisible: false,
|
|
2111
2250
|
// Auxiliary call — no need to burn reasoning tokens. On DeepSeek V4
|
|
2112
2251
|
// this flips thinking off (~3x faster, fewer tokens); on every other
|
|
2113
2252
|
// OpenAI-compatible provider the field is ignored.
|
|
2114
2253
|
reasoning: { mode: "off" },
|
|
2254
|
+
signal,
|
|
2115
2255
|
});
|
|
2116
2256
|
if (summaryResponse.usage) {
|
|
2117
2257
|
recordCumulativeUsage?.(summaryResponse.usage);
|
|
@@ -2171,7 +2311,7 @@ export class Engine {
|
|
|
2171
2311
|
return fallback;
|
|
2172
2312
|
}
|
|
2173
2313
|
}
|
|
2174
|
-
async runMemoryPipeline(transcript, sessionId, cwd, primaryClient) {
|
|
2314
|
+
async runMemoryPipeline(transcript, sessionId, cwd, primaryClient, recordBilledUsage) {
|
|
2175
2315
|
try {
|
|
2176
2316
|
// Background calls run on the auxiliary model when configured, so memory
|
|
2177
2317
|
// book-keeping doesn't burn the expensive primary model every turn.
|
|
@@ -2211,12 +2351,22 @@ export class Engine {
|
|
|
2211
2351
|
messages: [{ role: "user", content: userMsg }],
|
|
2212
2352
|
tools: [],
|
|
2213
2353
|
maxTokens: 1024,
|
|
2214
|
-
|
|
2354
|
+
billingEnabled: true,
|
|
2355
|
+
requestVisible: false,
|
|
2215
2356
|
reasoning: { mode: "off" },
|
|
2216
2357
|
});
|
|
2358
|
+
if (resp.usage)
|
|
2359
|
+
recordBilledUsage?.(resp.usage);
|
|
2217
2360
|
return resp.text;
|
|
2218
2361
|
},
|
|
2219
|
-
runDream: async ({ systemPrompt, userPrompt, projectDir }) => this.runDreamLoop({
|
|
2362
|
+
runDream: async ({ systemPrompt, userPrompt, projectDir }) => this.runDreamLoop({
|
|
2363
|
+
systemPrompt,
|
|
2364
|
+
userPrompt,
|
|
2365
|
+
projectDir,
|
|
2366
|
+
llmClient,
|
|
2367
|
+
sessionId,
|
|
2368
|
+
recordBilledUsage,
|
|
2369
|
+
}),
|
|
2220
2370
|
projectDir: cwd,
|
|
2221
2371
|
// settings.memories.maxCount caps memories accepted per extraction;
|
|
2222
2372
|
// autoExtract=false turns the extractor off (summaries/dream stay).
|
|
@@ -2262,6 +2412,7 @@ export class Engine {
|
|
|
2262
2412
|
toolContext: this.buildToolContext(),
|
|
2263
2413
|
projectDir: opts.projectDir,
|
|
2264
2414
|
sessionId: opts.sessionId,
|
|
2415
|
+
onUsage: opts.recordBilledUsage,
|
|
2265
2416
|
});
|
|
2266
2417
|
return ran;
|
|
2267
2418
|
}
|
|
@@ -2648,11 +2799,39 @@ export class Engine {
|
|
|
2648
2799
|
strategy: after >= before ? "no compaction needed" : (compactStrategy ?? "compacted"),
|
|
2649
2800
|
};
|
|
2650
2801
|
}
|
|
2651
|
-
|
|
2802
|
+
stripInjectedContextMessages(messages, userContextMsg, dynamicContextMsg) {
|
|
2803
|
+
const withoutDynamicContext = dynamicContextMsg
|
|
2804
|
+
? messages.filter((msg) => msg !== dynamicContextMsg)
|
|
2805
|
+
: [...messages];
|
|
2652
2806
|
if (!userContextMsg || messages[0] !== userContextMsg) {
|
|
2653
|
-
return
|
|
2807
|
+
return withoutDynamicContext;
|
|
2808
|
+
}
|
|
2809
|
+
return withoutDynamicContext.slice(1);
|
|
2810
|
+
}
|
|
2811
|
+
recordCacheReadDiagnostics(sessionId, usage) {
|
|
2812
|
+
const current = usage.cacheReadTokens;
|
|
2813
|
+
if (current === undefined || !Number.isFinite(current))
|
|
2814
|
+
return;
|
|
2815
|
+
const previous = this.lastCacheReadBySid.get(sessionId);
|
|
2816
|
+
this.lastCacheReadBySid.delete(sessionId);
|
|
2817
|
+
this.lastCacheReadBySid.set(sessionId, current);
|
|
2818
|
+
if (this.lastCacheReadBySid.size > CACHE_READ_DIAGNOSTIC_MAX_SESSIONS) {
|
|
2819
|
+
const oldestSessionId = this.lastCacheReadBySid.keys().next().value;
|
|
2820
|
+
if (oldestSessionId !== undefined)
|
|
2821
|
+
this.lastCacheReadBySid.delete(oldestSessionId);
|
|
2822
|
+
}
|
|
2823
|
+
if (previous === undefined || previous < CACHE_READ_DROP_MIN_PREVIOUS_TOKENS)
|
|
2824
|
+
return;
|
|
2825
|
+
const dropRatio = previous > 0 ? current / previous : 1;
|
|
2826
|
+
if (current <= CACHE_READ_DROP_MAX_CURRENT_TOKENS && dropRatio <= CACHE_READ_DROP_RATIO) {
|
|
2827
|
+
logger.warn("engine.cache_read_drop", {
|
|
2828
|
+
sessionId,
|
|
2829
|
+
previousCacheReadTokens: previous,
|
|
2830
|
+
currentCacheReadTokens: current,
|
|
2831
|
+
dropRatio,
|
|
2832
|
+
hint: "Prompt cache read tokens dropped sharply. Check for changed cacheable prefix, stale dynamic context in history, tool/schema changes, or provider cache eviction.",
|
|
2833
|
+
});
|
|
2654
2834
|
}
|
|
2655
|
-
return messages.slice(1);
|
|
2656
2835
|
}
|
|
2657
2836
|
getSettingsManager() {
|
|
2658
2837
|
if (!this.settingsManager) {
|
|
@@ -2681,7 +2860,7 @@ export class Engine {
|
|
|
2681
2860
|
}
|
|
2682
2861
|
return target;
|
|
2683
2862
|
}
|
|
2684
|
-
buildPermissionConfig(mode, cwd) {
|
|
2863
|
+
buildPermissionConfig(mode, cwd, approvalRouter) {
|
|
2685
2864
|
const rules = [...this.preset.defaultPermissionRules];
|
|
2686
2865
|
// Memory tools: dream scope is the LLM's own workspace, so save/delete
|
|
2687
2866
|
// there go through without prompting. user-scope save/delete have no
|
|
@@ -2734,7 +2913,16 @@ export class Engine {
|
|
|
2734
2913
|
// every `ask` permission silently fell through to deny-all and
|
|
2735
2914
|
// the user saw "Permission denied by user" with NO modal — exactly
|
|
2736
2915
|
// the bug that motivated this fix.
|
|
2737
|
-
|
|
2916
|
+
let interactive;
|
|
2917
|
+
if (approvalRouter) {
|
|
2918
|
+
interactive =
|
|
2919
|
+
this.interactiveBackends.get(approvalRouter) ??
|
|
2920
|
+
new InteractiveApprovalBackend(approvalRouter);
|
|
2921
|
+
this.interactiveBackends.set(approvalRouter, interactive);
|
|
2922
|
+
}
|
|
2923
|
+
else {
|
|
2924
|
+
interactive = getInteractiveApprovalBackend();
|
|
2925
|
+
}
|
|
2738
2926
|
if (interactive.hasPromptFn()) {
|
|
2739
2927
|
backend = interactive;
|
|
2740
2928
|
}
|
|
@@ -2749,21 +2937,38 @@ export class Engine {
|
|
|
2749
2937
|
return { rules, backend };
|
|
2750
2938
|
}
|
|
2751
2939
|
/**
|
|
2752
|
-
* Switch permission mode at runtime.
|
|
2753
|
-
*
|
|
2754
|
-
* and
|
|
2940
|
+
* Switch permission mode at runtime. Idle updates apply immediately; busy
|
|
2941
|
+
* updates are committed atomically when the current run settles, so its
|
|
2942
|
+
* classifier and ToolContext retain the immutable start-of-run snapshot.
|
|
2755
2943
|
* Session-only — does not persist to settings.
|
|
2756
2944
|
*/
|
|
2757
2945
|
setPermissionMode(mode) {
|
|
2946
|
+
if (this.runInProgress) {
|
|
2947
|
+
this.pendingPermissionMode = mode;
|
|
2948
|
+
this.pendingPlanMode = mode === "plan";
|
|
2949
|
+
return;
|
|
2950
|
+
}
|
|
2951
|
+
this.applyPermissionState(mode, mode === "plan");
|
|
2952
|
+
}
|
|
2953
|
+
applyPermissionState(mode, planMode) {
|
|
2758
2954
|
this.config = { ...this.config, permissionMode: mode };
|
|
2759
2955
|
this.permissionMode = mode;
|
|
2760
|
-
this.planMode =
|
|
2956
|
+
this.planMode = planMode;
|
|
2761
2957
|
if (this.activePermission) {
|
|
2762
2958
|
const cwd = this.config.cwd ?? process.cwd();
|
|
2763
|
-
const { rules, backend } = this.buildPermissionConfig(mode, cwd);
|
|
2959
|
+
const { rules, backend } = this.buildPermissionConfig(mode, cwd, this.activeApprovalRouter);
|
|
2764
2960
|
this.activePermission.reconfigure(mode, backend, rules);
|
|
2765
2961
|
}
|
|
2766
2962
|
}
|
|
2963
|
+
applyPendingPermissionState() {
|
|
2964
|
+
if (this.pendingPermissionMode === null)
|
|
2965
|
+
return;
|
|
2966
|
+
const mode = this.pendingPermissionMode;
|
|
2967
|
+
const planMode = this.pendingPlanMode ?? mode === "plan";
|
|
2968
|
+
this.pendingPermissionMode = null;
|
|
2969
|
+
this.pendingPlanMode = null;
|
|
2970
|
+
this.applyPermissionState(mode, planMode);
|
|
2971
|
+
}
|
|
2767
2972
|
getPermissionMode() {
|
|
2768
2973
|
return this.config.permissionMode ?? "acceptEdits";
|
|
2769
2974
|
}
|
|
@@ -2785,8 +2990,7 @@ export class Engine {
|
|
|
2785
2990
|
* rule set buildPermissionConfig does, without constructing a backend.
|
|
2786
2991
|
*/
|
|
2787
2992
|
getPermissionRules() {
|
|
2788
|
-
return this.buildPermissionConfig(this.getPermissionMode(), this.config.cwd ?? process.cwd())
|
|
2789
|
-
.rules;
|
|
2993
|
+
return this.buildPermissionConfig(this.getPermissionMode(), this.config.cwd ?? process.cwd(), this.activeApprovalRouter).rules;
|
|
2790
2994
|
}
|
|
2791
2995
|
/**
|
|
2792
2996
|
* Toggle plan mode directly. Called by the Plan tool (Task 7) via ToolContext.engine.
|
|
@@ -2796,13 +3000,10 @@ export class Engine {
|
|
|
2796
3000
|
if (value) {
|
|
2797
3001
|
this.setPermissionMode("plan");
|
|
2798
3002
|
}
|
|
2799
|
-
else if (this.permissionMode === "plan") {
|
|
3003
|
+
else if ((this.pendingPermissionMode ?? this.permissionMode) === "plan") {
|
|
2800
3004
|
// Leaving plan mode: drop back to the default.
|
|
2801
3005
|
this.setPermissionMode("acceptEdits");
|
|
2802
3006
|
}
|
|
2803
|
-
else {
|
|
2804
|
-
this.planMode = value;
|
|
2805
|
-
}
|
|
2806
3007
|
}
|
|
2807
3008
|
/**
|
|
2808
3009
|
* Block until a background agent's state changes (finishes / its result is
|