@cjhyy/code-shell-core 0.7.0-beta.1 → 0.7.0
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/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/credentials/access.d.ts +1 -0
- package/dist/credentials/access.js +2 -0
- package/dist/credentials/index.d.ts +2 -1
- package/dist/credentials/index.js +1 -0
- package/dist/credentials/oauth.d.ts +20 -0
- package/dist/credentials/oauth.js +114 -0
- package/dist/credentials/store.d.ts +1 -0
- package/dist/credentials/store.js +3 -1
- package/dist/credentials/types.d.ts +47 -1
- package/dist/engine/engine.d.ts +6 -2
- package/dist/engine/engine.js +159 -192
- package/dist/engine/goal.d.ts +17 -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/steer-queue.d.ts +3 -1
- package/dist/engine/steer-queue.js +10 -2
- package/dist/engine/turn-loop.d.ts +30 -1
- package/dist/engine/turn-loop.js +112 -17
- package/dist/hooks/goal-stop-hook.d.ts +33 -1
- package/dist/hooks/goal-stop-hook.js +202 -34
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/preset/index.js +14 -4
- package/dist/protocol/server.js +1 -1
- package/dist/protocol/types.d.ts +2 -0
- package/dist/session/session-manager.js +34 -1
- 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/background-jobs.d.ts +25 -5
- package/dist/tool-system/builtin/background-jobs.js +105 -7
- 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/index.js +15 -3
- 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/context.d.ts +18 -0
- package/dist/tool-system/mcp-manager.d.ts +14 -2
- package/dist/tool-system/mcp-manager.js +56 -7
- package/dist/types.d.ts +23 -7
- package/package.json +1 -1
package/dist/engine/engine.js
CHANGED
|
@@ -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";
|
|
@@ -53,16 +53,16 @@ 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 { parseTaskWithImages } from "./parse-task.js";
|
|
57
|
-
import { buildInputAttachmentContext } from "./input-attachments.js";
|
|
58
|
-
import { enforceImagePolicy, byteLengthFromBase64, dropOversizedImages, collectAttachedImagePaths, } from "./image-policy.js";
|
|
59
|
-
import { tryCompressImages } from "./image-compression.js";
|
|
60
56
|
import { buildSessionTitle } from "./session-title.js";
|
|
61
|
-
import { capabilitiesFor } from "../llm/capabilities/index.js";
|
|
62
57
|
import { MemoryOrchestrator } from "../services/memory-orchestrator.js";
|
|
63
58
|
import { runDreamConsolidation } from "../services/dream-consolidation.js";
|
|
64
|
-
import {
|
|
59
|
+
import { buildRunUserMessageContent, prepareRunImageInput } from "./run-image-input.js";
|
|
60
|
+
import { join } from "node:path";
|
|
65
61
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
62
|
+
const CACHE_READ_DROP_MIN_PREVIOUS_TOKENS = 100;
|
|
63
|
+
const CACHE_READ_DROP_MAX_CURRENT_TOKENS = 64;
|
|
64
|
+
const CACHE_READ_DROP_RATIO = 0.1;
|
|
65
|
+
const CACHE_READ_DIAGNOSTIC_MAX_SESSIONS = 256;
|
|
66
66
|
/**
|
|
67
67
|
* Build ScanOptions.compatFileNames from the user's instruction compat toggles.
|
|
68
68
|
* Primary file name stays hard-wired to CODESHELL.md (not exposed). Turning a
|
|
@@ -266,6 +266,7 @@ export class Engine {
|
|
|
266
266
|
* LLM response arrives.
|
|
267
267
|
*/
|
|
268
268
|
ctxOverheadBySid = new Map();
|
|
269
|
+
lastCacheReadBySid = new Map();
|
|
269
270
|
/**
|
|
270
271
|
* Step-gap steering queue (per sessionId, in-memory). Host pushes user
|
|
271
272
|
* messages here via enqueueSteer while a run is in flight; the turn loop
|
|
@@ -654,7 +655,7 @@ export class Engine {
|
|
|
654
655
|
* the queued draft) and is the handle `unsteer` uses to revoke a still-pending
|
|
655
656
|
* entry. A blank id is tolerated but means the entry can't be revoked.
|
|
656
657
|
*/
|
|
657
|
-
enqueueSteer(sessionId, text, id = "", clientMessageId) {
|
|
658
|
+
enqueueSteer(sessionId, text, id = "", clientMessageId, attachments) {
|
|
658
659
|
const q = this.steerQueueBySid.get(sessionId) ?? [];
|
|
659
660
|
const entryId = id || `steer-${q.length}`;
|
|
660
661
|
if (!sessionId)
|
|
@@ -666,12 +667,13 @@ export class Engine {
|
|
|
666
667
|
sessionId,
|
|
667
668
|
id: entryId,
|
|
668
669
|
clientMessageId,
|
|
670
|
+
attachmentCount: attachments?.length ?? 0,
|
|
669
671
|
activeRunSessionId: activeRunSessionId ?? null,
|
|
670
672
|
queueLength: q.length,
|
|
671
673
|
});
|
|
672
674
|
return { accepted: false, id: entryId };
|
|
673
675
|
}
|
|
674
|
-
const next = enqueueSteerItem(q, entryId, text, clientMessageId);
|
|
676
|
+
const next = enqueueSteerItem(q, entryId, text, clientMessageId, attachments);
|
|
675
677
|
if (next === q)
|
|
676
678
|
return { accepted: false, id: entryId }; // blank text dropped
|
|
677
679
|
this.steerQueueBySid.set(sessionId, next);
|
|
@@ -679,6 +681,7 @@ export class Engine {
|
|
|
679
681
|
sessionId,
|
|
680
682
|
id: entryId,
|
|
681
683
|
clientMessageId,
|
|
684
|
+
attachmentCount: attachments?.length ?? 0,
|
|
682
685
|
activeRunSessionId,
|
|
683
686
|
queueLength: next.length,
|
|
684
687
|
});
|
|
@@ -715,6 +718,13 @@ export class Engine {
|
|
|
715
718
|
});
|
|
716
719
|
return drained;
|
|
717
720
|
}
|
|
721
|
+
/** Put failed steer preparation back ahead of messages queued while it was being prepared. */
|
|
722
|
+
restoreSteer(sessionId, items) {
|
|
723
|
+
if (items.length === 0)
|
|
724
|
+
return;
|
|
725
|
+
const queued = this.steerQueueBySid.get(sessionId) ?? [];
|
|
726
|
+
this.steerQueueBySid.set(sessionId, [...items, ...queued]);
|
|
727
|
+
}
|
|
718
728
|
/** Wire the cookie→browser injection callback (InjectCredential tool). Same
|
|
719
729
|
* post-construction injection model as setBrowserBridge. */
|
|
720
730
|
setInjectCredential(fn) {
|
|
@@ -807,139 +817,16 @@ export class Engine {
|
|
|
807
817
|
};
|
|
808
818
|
if (options)
|
|
809
819
|
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,
|
|
820
|
+
const imageInput = await prepareRunImageInput({
|
|
821
|
+
task,
|
|
822
|
+
cwd,
|
|
823
|
+
llm: this.config.llm,
|
|
824
|
+
sessionId: options?.sessionId,
|
|
825
|
+
attachments: options?.attachments,
|
|
839
826
|
});
|
|
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;
|
|
827
|
+
if (!imageInput.ok)
|
|
828
|
+
return imageInput.result;
|
|
829
|
+
const { parsedTask, taskText } = imageInput;
|
|
943
830
|
const noise = detectPastedNoise(taskText);
|
|
944
831
|
if (noise.isNoise) {
|
|
945
832
|
const hint = `Your input looks like pasted terminal output (${noise.reason}). ` +
|
|
@@ -1128,40 +1015,7 @@ export class Engine {
|
|
|
1128
1015
|
preset: this.preset.name,
|
|
1129
1016
|
imageCount: parsedTask.images.length,
|
|
1130
1017
|
});
|
|
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;
|
|
1018
|
+
const userMessageContent = buildRunUserMessageContent(parsedTask, cwd, taskText);
|
|
1165
1019
|
// Create or resume session.
|
|
1166
1020
|
//
|
|
1167
1021
|
// Three valid shapes:
|
|
@@ -1284,8 +1138,14 @@ export class Engine {
|
|
|
1284
1138
|
// notifications) attribute to the right session. toolCtx is created
|
|
1285
1139
|
// before the session bundle is resolved (see ~line 635), so this is
|
|
1286
1140
|
// the first point we can set it. After this assignment treat the
|
|
1287
|
-
// field
|
|
1141
|
+
// field follows the latest successfully injected user intent for the rest
|
|
1142
|
+
// of the run, so tools launched after a steer attribute their side effects
|
|
1143
|
+
// to that steer rather than this original submit.
|
|
1288
1144
|
toolCtx.sessionId = session.state.sessionId;
|
|
1145
|
+
toolCtx.originClientMessageId = options?.clientMessageId;
|
|
1146
|
+
toolCtx.recordExternalFileChanges = (record) => {
|
|
1147
|
+
session.transcript.append("external_file_changes", { ...record });
|
|
1148
|
+
};
|
|
1289
1149
|
toolCtx.setSessionWorkspace = (workspace) => {
|
|
1290
1150
|
session.state.workspace = workspace;
|
|
1291
1151
|
};
|
|
@@ -1724,17 +1584,20 @@ export class Engine {
|
|
|
1724
1584
|
// Goal mode: register a GoalStopHook for the lifetime of THIS run so the
|
|
1725
1585
|
// turn loop keeps going until the session model judges the goal met.
|
|
1726
1586
|
// 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.
|
|
1587
|
+
// send doesn't inherit a stale goal. The judge runs on the primary
|
|
1588
|
+
// session client; auxSummaryClient remains dedicated to low-consequence
|
|
1589
|
+
// summaries/titles and retains defaults.auxText routing/fallback behavior.
|
|
1732
1590
|
// Normalize the raw goal (string | GoalConfig) once at the run boundary;
|
|
1733
1591
|
// everything inward uses the GoalConfig. normalizeGoal() returns undefined
|
|
1734
1592
|
// when there's effectively no goal (empty objective).
|
|
1735
1593
|
//
|
|
1736
1594
|
// PERSISTENT GOAL (CC /goal style): a goal set on one send survives across
|
|
1737
|
-
// later sends and manual interrupts until met or cleared.
|
|
1595
|
+
// later sends and manual interrupts until met or cleared. Goal completion
|
|
1596
|
+
// is a high-consequence decision, so V1 routes it to the primary session
|
|
1597
|
+
// client, which is the model expected to interpret the supplied execution
|
|
1598
|
+
// evidence. defaults.auxText remains in force for summaries, titles and
|
|
1599
|
+
// other auxiliary work through auxSummaryClient.
|
|
1600
|
+
// Resolution:
|
|
1738
1601
|
// 1. options.goal — this send explicitly sets/replaces the goal.
|
|
1739
1602
|
// 2. session.state.activeGoal — a goal set on an earlier send.
|
|
1740
1603
|
// 3. config.goal — engine-level default (rare; e.g. headless).
|
|
@@ -1743,7 +1606,15 @@ export class Engine {
|
|
|
1743
1606
|
// bare send with no options.goal inherits the stored active goal so the
|
|
1744
1607
|
// model keeps working toward it — that's what makes it persistent.
|
|
1745
1608
|
const explicitGoal = normalizeGoal(options?.goal);
|
|
1746
|
-
|
|
1609
|
+
let storedGoal = this.config.isSubAgent !== true ? session.state.activeGoal : undefined;
|
|
1610
|
+
// Defense in depth: a stale whole-state writer may have restored the
|
|
1611
|
+
// activeGoal field after this exact goal instance was force-terminated.
|
|
1612
|
+
// Refuse to arm it and converge the live bundle before hook registration.
|
|
1613
|
+
if (storedGoal && isSameGoalInstance(storedGoal, session.state.goalTerminal)) {
|
|
1614
|
+
session.state.activeGoal = undefined;
|
|
1615
|
+
storedGoal = undefined;
|
|
1616
|
+
this.sessionManager.saveState(session.state);
|
|
1617
|
+
}
|
|
1747
1618
|
if (explicitGoal && this.config.isSubAgent !== true) {
|
|
1748
1619
|
const replaced = !!storedGoal && storedGoal.objective !== explicitGoal.objective;
|
|
1749
1620
|
// Stamp WHEN this goal was set so the judge can anchor relative deadlines
|
|
@@ -1752,7 +1623,14 @@ export class Engine {
|
|
|
1752
1623
|
// or changed objective gets a fresh stamp; re-sending the SAME objective
|
|
1753
1624
|
// keeps the original anchor (the goal continues, the user didn't restate a
|
|
1754
1625
|
// new deadline). User input never carries setAtMs, so we set it here.
|
|
1755
|
-
|
|
1626
|
+
const resolvedSetAt = resolveGoalSetAt(explicitGoal.objective, storedGoal, Date.now());
|
|
1627
|
+
// A user explicitly re-starting the same objective creates a new goal
|
|
1628
|
+
// instance. Avoid a same-millisecond collision with its old tombstone.
|
|
1629
|
+
explicitGoal.setAtMs =
|
|
1630
|
+
session.state.goalTerminal?.objective === explicitGoal.objective &&
|
|
1631
|
+
session.state.goalTerminal.setAtMs === resolvedSetAt
|
|
1632
|
+
? resolvedSetAt + 1
|
|
1633
|
+
: resolvedSetAt;
|
|
1756
1634
|
session.state.activeGoal = explicitGoal;
|
|
1757
1635
|
this.sessionManager.saveState(session.state);
|
|
1758
1636
|
options?.onStream?.({
|
|
@@ -1762,18 +1640,27 @@ export class Engine {
|
|
|
1762
1640
|
});
|
|
1763
1641
|
}
|
|
1764
1642
|
const normalizedGoal = explicitGoal ?? storedGoal ?? normalizeGoal(this.config.goal);
|
|
1643
|
+
// Snapshot the persisted goal identity owned by THIS run. Terminal
|
|
1644
|
+
// cleanup compares against this immutable copy so an old run cannot
|
|
1645
|
+
// delete a replacement goal installed while it was finishing.
|
|
1646
|
+
const persistedRunGoal = normalizedGoal && isSameGoalInstance(session.state.activeGoal, normalizedGoal)
|
|
1647
|
+
? { ...normalizedGoal }
|
|
1648
|
+
: undefined;
|
|
1765
1649
|
let goalHookHandler = null;
|
|
1650
|
+
let goalJudgeContext;
|
|
1766
1651
|
if (normalizedGoal && this.config.isSubAgent !== true) {
|
|
1767
1652
|
goalHookHandler = createGoalStopHook({
|
|
1768
1653
|
goal: normalizedGoal,
|
|
1769
|
-
llm:
|
|
1654
|
+
llm: llmClient,
|
|
1770
1655
|
log: logger,
|
|
1656
|
+
getJudgeContext: () => goalJudgeContext,
|
|
1771
1657
|
// Clear the persisted active goal the moment the judge says it's met,
|
|
1772
1658
|
// so a later bare send doesn't re-inherit a satisfied goal. The hook
|
|
1773
1659
|
// calls this from inside its met branch (single source of truth for
|
|
1774
1660
|
// "goal achieved"); engine owns the persistence side-effect.
|
|
1775
1661
|
onMet: () => {
|
|
1776
|
-
if (
|
|
1662
|
+
if (persistedRunGoal &&
|
|
1663
|
+
isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
|
|
1777
1664
|
session.state.activeGoal = undefined;
|
|
1778
1665
|
this.sessionManager.saveState(session.state);
|
|
1779
1666
|
}
|
|
@@ -1783,7 +1670,7 @@ export class Engine {
|
|
|
1783
1670
|
// in-RAM session are untouched) actually stops the judge. Reads disk
|
|
1784
1671
|
// via readActiveGoal — authoritative and independent of which session
|
|
1785
1672
|
// instance the run closure holds.
|
|
1786
|
-
isGoalActive: (sid) => this.sessionManager.readActiveGoal(sid)
|
|
1673
|
+
isGoalActive: (sid) => isSameGoalInstance(this.sessionManager.readActiveGoal(sid), normalizedGoal),
|
|
1787
1674
|
});
|
|
1788
1675
|
this.hooks.register("on_stop", goalHookHandler, 0, "goal-stop");
|
|
1789
1676
|
// Expose for clearGoal() mid-run. Already guarded by isSubAgent above.
|
|
@@ -1815,8 +1702,31 @@ export class Engine {
|
|
|
1815
1702
|
return info;
|
|
1816
1703
|
},
|
|
1817
1704
|
consumeSteer: (source) => this.consumeSteer(sid, source),
|
|
1705
|
+
restoreSteer: (items) => this.restoreSteer(sid, items),
|
|
1706
|
+
buildSteerUserMessageContent: async (item) => {
|
|
1707
|
+
const steerImageInput = await prepareRunImageInput({
|
|
1708
|
+
task: item.text,
|
|
1709
|
+
cwd,
|
|
1710
|
+
llm: this.config.llm,
|
|
1711
|
+
sessionId: sid,
|
|
1712
|
+
attachments: item.attachments,
|
|
1713
|
+
});
|
|
1714
|
+
if (!steerImageInput.ok) {
|
|
1715
|
+
throw new Error(steerImageInput.result.text);
|
|
1716
|
+
}
|
|
1717
|
+
return buildRunUserMessageContent(steerImageInput.parsedTask, cwd, steerImageInput.taskText);
|
|
1718
|
+
},
|
|
1818
1719
|
claimClientMessageId: (clientMessageId, source) => claimClientMessageId(session, clientMessageId, source),
|
|
1720
|
+
releaseClientMessageId: (clientMessageId) => {
|
|
1721
|
+
claimedClientMessageIds.delete(clientMessageId);
|
|
1722
|
+
},
|
|
1723
|
+
setOriginClientMessageId: (clientMessageId) => {
|
|
1724
|
+
toolCtx.originClientMessageId = clientMessageId;
|
|
1725
|
+
},
|
|
1819
1726
|
recordCumulativeUsage,
|
|
1727
|
+
recordCacheReadDiagnostics: (usage) => {
|
|
1728
|
+
this.recordCacheReadDiagnostics(sid, usage);
|
|
1729
|
+
},
|
|
1820
1730
|
recordContextUsageAnchor: (anchor) => {
|
|
1821
1731
|
session.state.contextUsageAnchor = {
|
|
1822
1732
|
...anchor,
|
|
@@ -1829,7 +1739,8 @@ export class Engine {
|
|
|
1829
1739
|
// turns don't re-arm) AND persists it, and drops the in-flight stop
|
|
1830
1740
|
// hook so nothing re-blocks the stop we're about to return.
|
|
1831
1741
|
clearPersistedGoal: () => {
|
|
1832
|
-
if (
|
|
1742
|
+
if (persistedRunGoal &&
|
|
1743
|
+
isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
|
|
1833
1744
|
session.state.activeGoal = undefined;
|
|
1834
1745
|
this.sessionManager.saveState(session.state);
|
|
1835
1746
|
}
|
|
@@ -1839,6 +1750,9 @@ export class Engine {
|
|
|
1839
1750
|
this.activeGoalHook = null;
|
|
1840
1751
|
}
|
|
1841
1752
|
},
|
|
1753
|
+
updateGoalJudgeContext: (context) => {
|
|
1754
|
+
goalJudgeContext = context;
|
|
1755
|
+
},
|
|
1842
1756
|
ctxOverheadStore: {
|
|
1843
1757
|
get: (s) => this.ctxOverheadBySid.get(s) ?? 0,
|
|
1844
1758
|
set: (s, n) => {
|
|
@@ -1863,6 +1777,7 @@ export class Engine {
|
|
|
1863
1777
|
onStream: options?.onStream,
|
|
1864
1778
|
signal: options?.signal,
|
|
1865
1779
|
freshImageMessages: freshImageMessage ? [freshImageMessage] : undefined,
|
|
1780
|
+
volatileContextMessages: dynamicContextMsg ? [dynamicContextMsg] : undefined,
|
|
1866
1781
|
// Goal mode: the active goal is surfaced to the on_stop handler via
|
|
1867
1782
|
// ctx.data.goal; the GoalStopHook (registered above) judges it.
|
|
1868
1783
|
goal: normalizedGoal,
|
|
@@ -1910,9 +1825,31 @@ export class Engine {
|
|
|
1910
1825
|
// only — sub-agents don't carry user-clearable persistent goals.
|
|
1911
1826
|
if (this.config.isSubAgent !== true)
|
|
1912
1827
|
this.activeRunSession = session;
|
|
1828
|
+
const applyGoalTermination = (termination) => {
|
|
1829
|
+
if (!termination || !persistedRunGoal)
|
|
1830
|
+
return;
|
|
1831
|
+
// Record the terminal identity even when a newer goal has already
|
|
1832
|
+
// replaced it. Only clear activeGoal when it is still the run's goal.
|
|
1833
|
+
session.state.goalTerminal = {
|
|
1834
|
+
objective: persistedRunGoal.objective,
|
|
1835
|
+
setAtMs: persistedRunGoal.setAtMs,
|
|
1836
|
+
reason: termination,
|
|
1837
|
+
terminatedAtMs: Date.now(),
|
|
1838
|
+
};
|
|
1839
|
+
if (isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
|
|
1840
|
+
session.state.activeGoal = undefined;
|
|
1841
|
+
}
|
|
1842
|
+
this.sessionManager.saveState(session.state);
|
|
1843
|
+
if (goalHookHandler) {
|
|
1844
|
+
this.hooks.unregister("on_stop", goalHookHandler);
|
|
1845
|
+
if (this.activeGoalHook === goalHookHandler)
|
|
1846
|
+
this.activeGoalHook = null;
|
|
1847
|
+
}
|
|
1848
|
+
};
|
|
1913
1849
|
let result;
|
|
1914
1850
|
try {
|
|
1915
1851
|
result = await turnLoop.run(messages);
|
|
1852
|
+
applyGoalTermination(result.goalTermination);
|
|
1916
1853
|
// ── Headless: drain background sub-agents before resolving ───────
|
|
1917
1854
|
// Unified background-work model (2026-06-17): the engine NO LONGER parks
|
|
1918
1855
|
// every run waiting on background work. Background work (sub-agents,
|
|
@@ -1974,6 +1911,7 @@ export class Engine {
|
|
|
1974
1911
|
break;
|
|
1975
1912
|
}
|
|
1976
1913
|
result = await turnLoop.run([...result.messages, injected]);
|
|
1914
|
+
applyGoalTermination(result.goalTermination);
|
|
1977
1915
|
}
|
|
1978
1916
|
}
|
|
1979
1917
|
}
|
|
@@ -1993,7 +1931,8 @@ export class Engine {
|
|
|
1993
1931
|
this.hooks.unregister("on_tool_start", fileHistoryHandler);
|
|
1994
1932
|
}
|
|
1995
1933
|
this.lastMessages = result.messages;
|
|
1996
|
-
|
|
1934
|
+
const cachedMessages = this.stripInjectedContextMessages(result.messages, userContextMsg, dynamicContextMsg);
|
|
1935
|
+
this.compactedMessagesBySession.set(session.state.sessionId, cachedMessages);
|
|
1997
1936
|
logger.info("engine.done", {
|
|
1998
1937
|
sessionId: session.state.sessionId,
|
|
1999
1938
|
reason: result.reason,
|
|
@@ -2648,11 +2587,39 @@ export class Engine {
|
|
|
2648
2587
|
strategy: after >= before ? "no compaction needed" : (compactStrategy ?? "compacted"),
|
|
2649
2588
|
};
|
|
2650
2589
|
}
|
|
2651
|
-
|
|
2590
|
+
stripInjectedContextMessages(messages, userContextMsg, dynamicContextMsg) {
|
|
2591
|
+
const withoutDynamicContext = dynamicContextMsg
|
|
2592
|
+
? messages.filter((msg) => msg !== dynamicContextMsg)
|
|
2593
|
+
: [...messages];
|
|
2652
2594
|
if (!userContextMsg || messages[0] !== userContextMsg) {
|
|
2653
|
-
return
|
|
2595
|
+
return withoutDynamicContext;
|
|
2596
|
+
}
|
|
2597
|
+
return withoutDynamicContext.slice(1);
|
|
2598
|
+
}
|
|
2599
|
+
recordCacheReadDiagnostics(sessionId, usage) {
|
|
2600
|
+
const current = usage.cacheReadTokens;
|
|
2601
|
+
if (current === undefined || !Number.isFinite(current))
|
|
2602
|
+
return;
|
|
2603
|
+
const previous = this.lastCacheReadBySid.get(sessionId);
|
|
2604
|
+
this.lastCacheReadBySid.delete(sessionId);
|
|
2605
|
+
this.lastCacheReadBySid.set(sessionId, current);
|
|
2606
|
+
if (this.lastCacheReadBySid.size > CACHE_READ_DIAGNOSTIC_MAX_SESSIONS) {
|
|
2607
|
+
const oldestSessionId = this.lastCacheReadBySid.keys().next().value;
|
|
2608
|
+
if (oldestSessionId !== undefined)
|
|
2609
|
+
this.lastCacheReadBySid.delete(oldestSessionId);
|
|
2610
|
+
}
|
|
2611
|
+
if (previous === undefined || previous < CACHE_READ_DROP_MIN_PREVIOUS_TOKENS)
|
|
2612
|
+
return;
|
|
2613
|
+
const dropRatio = previous > 0 ? current / previous : 1;
|
|
2614
|
+
if (current <= CACHE_READ_DROP_MAX_CURRENT_TOKENS && dropRatio <= CACHE_READ_DROP_RATIO) {
|
|
2615
|
+
logger.warn("engine.cache_read_drop", {
|
|
2616
|
+
sessionId,
|
|
2617
|
+
previousCacheReadTokens: previous,
|
|
2618
|
+
currentCacheReadTokens: current,
|
|
2619
|
+
dropRatio,
|
|
2620
|
+
hint: "Prompt cache read tokens dropped sharply. Check for changed cacheable prefix, stale dynamic context in history, tool/schema changes, or provider cache eviction.",
|
|
2621
|
+
});
|
|
2654
2622
|
}
|
|
2655
|
-
return messages.slice(1);
|
|
2656
2623
|
}
|
|
2657
2624
|
getSettingsManager() {
|
|
2658
2625
|
if (!this.settingsManager) {
|
package/dist/engine/goal.d.ts
CHANGED
|
@@ -47,6 +47,21 @@ export interface GoalConfig {
|
|
|
47
47
|
*/
|
|
48
48
|
setAtMs?: number;
|
|
49
49
|
}
|
|
50
|
+
/** A forced terminal outcome for one concrete persisted goal instance. */
|
|
51
|
+
export type GoalTerminationReason = "stop_blocks_exhausted" | "token_budget_exhausted" | "time_budget_exhausted" | "max_turns_exhausted";
|
|
52
|
+
/**
|
|
53
|
+
* Persisted terminal marker used to prevent a stale whole-state writer from
|
|
54
|
+
* making an exhausted goal armable again. `objective + setAtMs` is the goal
|
|
55
|
+
* instance identity; `terminatedAtMs` only orders competing tombstones.
|
|
56
|
+
*/
|
|
57
|
+
export interface GoalTerminal {
|
|
58
|
+
objective: string;
|
|
59
|
+
setAtMs?: number;
|
|
60
|
+
reason: GoalTerminationReason;
|
|
61
|
+
terminatedAtMs?: number;
|
|
62
|
+
}
|
|
63
|
+
/** True only when both values identify the same concrete goal instance. */
|
|
64
|
+
export declare function isSameGoalInstance(left: Pick<GoalConfig, "objective" | "setAtMs"> | undefined, right: Pick<GoalConfig, "objective" | "setAtMs"> | undefined): boolean;
|
|
50
65
|
/**
|
|
51
66
|
* Default consecutive-stop-block cap for goal runs. The real safety net for an
|
|
52
67
|
* unattended goal is the token/time budget + maxTurns; this cap only stops a
|
|
@@ -185,3 +200,5 @@ export declare function recordGoalUsage(tracker: GoalBudgetTracker, turnTokens:
|
|
|
185
200
|
* pass the current clock (and tests pass a fixed value).
|
|
186
201
|
*/
|
|
187
202
|
export declare function goalBudgetExceeded(tracker: GoalBudgetTracker, nowMs: number): boolean;
|
|
203
|
+
/** Identify which goal budget forced termination, preserving token-first precedence. */
|
|
204
|
+
export declare function goalBudgetTerminationReason(tracker: GoalBudgetTracker, nowMs: number): Extract<GoalTerminationReason, "token_budget_exhausted" | "time_budget_exhausted"> | undefined;
|
package/dist/engine/goal.js
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* it accumulates total tokens across turns and stamps a wall-clock start,
|
|
11
11
|
* so an unattended goal run can't burn tokens or wall time without bound.
|
|
12
12
|
*/
|
|
13
|
+
/** True only when both values identify the same concrete goal instance. */
|
|
14
|
+
export function isSameGoalInstance(left, right) {
|
|
15
|
+
return !!left && !!right && left.objective === right.objective && left.setAtMs === right.setAtMs;
|
|
16
|
+
}
|
|
13
17
|
/**
|
|
14
18
|
* Default consecutive-stop-block cap for goal runs. The real safety net for an
|
|
15
19
|
* unattended goal is the token/time budget + maxTurns; this cap only stops a
|
|
@@ -94,7 +98,7 @@ export function normalizeGoal(raw) {
|
|
|
94
98
|
*/
|
|
95
99
|
export function resolveGoalSetAt(explicitObjective, stored, nowMs) {
|
|
96
100
|
const sameGoalContinues = !!stored && stored.objective === explicitObjective;
|
|
97
|
-
return sameGoalContinues ? stored.setAtMs ?? nowMs : nowMs;
|
|
101
|
+
return sameGoalContinues ? (stored.setAtMs ?? nowMs) : nowMs;
|
|
98
102
|
}
|
|
99
103
|
/** Interactive (no-goal) turn ceiling — a single prompt rarely needs more. */
|
|
100
104
|
export const INTERACTIVE_DEFAULT_MAX_TURNS = 100;
|
|
@@ -187,10 +191,16 @@ export function recordGoalUsage(tracker, turnTokens) {
|
|
|
187
191
|
* pass the current clock (and tests pass a fixed value).
|
|
188
192
|
*/
|
|
189
193
|
export function goalBudgetExceeded(tracker, nowMs) {
|
|
194
|
+
return goalBudgetTerminationReason(tracker, nowMs) !== undefined;
|
|
195
|
+
}
|
|
196
|
+
/** Identify which goal budget forced termination, preserving token-first precedence. */
|
|
197
|
+
export function goalBudgetTerminationReason(tracker, nowMs) {
|
|
190
198
|
const { tokenBudget, timeBudgetMs } = tracker.goal;
|
|
191
|
-
if (typeof tokenBudget === "number" && tracker.tokensUsed > tokenBudget)
|
|
192
|
-
return
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
199
|
+
if (typeof tokenBudget === "number" && tracker.tokensUsed > tokenBudget) {
|
|
200
|
+
return "token_budget_exhausted";
|
|
201
|
+
}
|
|
202
|
+
if (typeof timeBudgetMs === "number" && nowMs - tracker.startedAtMs > timeBudgetMs) {
|
|
203
|
+
return "time_budget_exhausted";
|
|
204
|
+
}
|
|
205
|
+
return undefined;
|
|
196
206
|
}
|