newmark-agent 0.6.3 → 0.6.7
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/config.example.json +13 -3
- package/dist/cli-commands.d.ts +1 -1
- package/dist/cli-commands.js +53 -2
- package/dist/cli-discovery.js +3 -0
- package/dist/conversation-utility-host.bundle.cjs +3046 -1038
- package/dist/conversation-utility-host.js +94 -7
- package/dist/core/agent.d.ts +149 -12
- package/dist/core/agent.js +843 -176
- package/dist/core/agentKernelRunner.d.ts +7 -1
- package/dist/core/agentKernelRunner.js +171 -22
- package/dist/core/autoRouter.d.ts +49 -44
- package/dist/core/autoRouter.js +117 -302
- package/dist/core/branchIdentity.d.ts +29 -0
- package/dist/core/branchIdentity.js +54 -0
- package/dist/core/config.js +27 -11
- package/dist/core/continuation/contracts.d.ts +1 -1
- package/dist/core/continuation/store.d.ts +119 -1
- package/dist/core/continuation/store.js +337 -19
- package/dist/core/conversationKernel.d.ts +62 -8
- package/dist/core/conversationKernel.js +361 -47
- package/dist/core/conversationStateDocument.d.ts +18 -0
- package/dist/core/conversationStateDocument.js +88 -0
- package/dist/core/conversationVisualBudget.d.ts +74 -0
- package/dist/core/conversationVisualBudget.js +153 -0
- package/dist/core/electronUtilityAgentClient.d.ts +3 -2
- package/dist/core/electronUtilityAgentClient.js +32 -8
- package/dist/core/electronUtilityRuntimePool.d.ts +28 -3
- package/dist/core/electronUtilityRuntimePool.js +93 -24
- package/dist/core/hostRuntimeHooks.d.ts +23 -0
- package/dist/core/hostRuntimeHooks.js +17 -0
- package/dist/core/jevDecision.d.ts +144 -0
- package/dist/core/jevDecision.js +226 -0
- package/dist/core/memoryProbe.d.ts +24 -0
- package/dist/core/memoryProbe.js +104 -0
- package/dist/core/mobilePairing.d.ts +7 -1
- package/dist/core/mobilePairing.js +9 -1
- package/dist/core/performanceDiagnostics.d.ts +1 -1
- package/dist/core/routeDecisionValidator.d.ts +86 -0
- package/dist/core/routeDecisionValidator.js +249 -0
- package/dist/core/routeEligibility.d.ts +52 -0
- package/dist/core/routeEligibility.js +108 -0
- package/dist/core/runtimeMemoryBudget.d.ts +6 -0
- package/dist/core/runtimeMemoryBudget.js +12 -0
- package/dist/core/utilityAgentProtocol.d.ts +8 -10
- package/dist/core/utilityHostToolRouter.js +2 -0
- package/dist/core/visualDownscale.d.ts +6 -0
- package/dist/core/visualDownscale.js +137 -0
- package/dist/core/wslAgentClient.d.ts +1 -2
- package/dist/core/wslAgentClient.js +0 -8
- package/dist/core/wslAgentProtocol.d.ts +1 -10
- package/dist/core/wslAgentRuntimePool.d.ts +1 -3
- package/dist/core/wslAgentRuntimePool.js +17 -22
- package/dist/llm/provider.d.ts +3 -1
- package/dist/llm/provider.js +53 -9
- package/dist/main.js +108 -31
- package/dist/preload.js +3 -1
- package/dist/server.js +30 -18
- package/dist/tools/computerUse.d.ts +4 -0
- package/dist/tools/computerUse.js +202 -4
- package/dist/tools/computerUsePowerShellHost.d.ts +6 -1
- package/dist/tools/computerUsePowerShellHost.js +105 -31
- package/dist/tools/index.js +4 -1
- package/dist/tui/src/adapters/core-runtime-adapter.js +13 -1
- package/dist/tui/src/i18n.js +12 -1
- package/dist/tui/src/render.js +9 -2
- package/dist/tui/src/settings-schema.js +19 -0
- package/dist/tui/src/state.js +69 -1
- package/dist/ui/index.html +994 -277
- package/dist/ui/lucide-sprite.svg +0 -8
- package/dist/wsl-agent-host.bundle.cjs +2851 -1013
- package/dist/wsl-agent-host.js +0 -3
- package/package.json +38 -9
package/dist/core/agent.js
CHANGED
|
@@ -58,6 +58,7 @@ const skills_1 = require("./skills");
|
|
|
58
58
|
const flow_1 = require("./flow");
|
|
59
59
|
const memoryLab_1 = require("./memoryLab");
|
|
60
60
|
const agentKernelRunner_1 = require("./agentKernelRunner");
|
|
61
|
+
const branchIdentity_1 = require("./branchIdentity");
|
|
61
62
|
const toolPolicy_1 = require("./toolPolicy");
|
|
62
63
|
const conversationTarget_1 = require("./conversationTarget");
|
|
63
64
|
const utilityHostToolBridge_1 = require("./utilityHostToolBridge");
|
|
@@ -67,6 +68,8 @@ const pngjs_1 = require("pngjs");
|
|
|
67
68
|
const modelValidation_1 = require("./modelValidation");
|
|
68
69
|
const modelValidationStore_1 = require("./modelValidationStore");
|
|
69
70
|
const autoRouter_1 = require("./autoRouter");
|
|
71
|
+
const jevDecision_1 = require("./jevDecision");
|
|
72
|
+
const routeDecisionValidator_1 = require("./routeDecisionValidator");
|
|
70
73
|
const agent_context_manager_1 = require("../context/services/agent-context-manager");
|
|
71
74
|
const toolchain_1 = require("../toolchain");
|
|
72
75
|
const agent_runtime_1 = require("../agent-runtime");
|
|
@@ -177,7 +180,7 @@ let CORE_SYSTEM_PROMPT = `You are Newmark Agent, a powerful AI coding assistant
|
|
|
177
180
|
- Be thorough and precise. Verify your work.
|
|
178
181
|
- Use tools appropriately - don't just describe, do it.
|
|
179
182
|
- Deletion safety (intrinsic, non-overridable): file deletion is allowed only ONE file at a time under Agent supervision. Use the delete_file tool with an absolute path for every single-file delete. Never use bash or the terminal to delete files in bulk: recursive deletes (rm -r/-rf, Remove-Item -Recurse, rmdir /s, del /s), wildcard deletes (rm *.log, del *), loop deletes (for/foreach/while + rm), pipe-fed deletes (Get-ChildItem | Remove-Item), find/xargs deletes, and multi-target or multi-statement deletes are hard-blocked by the runtime and will be rejected. To remove a directory, delete its files one by one first, then remove the now-empty directory without a recursive flag.
|
|
180
|
-
- For desktop Computer Use requests, follow observe -> decide -> act -> observe. Start visible takeover with computer_use takeover_start before multi-step desktop control and stop it when finished. Prefer app-scoped actions through app_list/app_observe/app_* when controlling one taskbar application, because this preserves human collaboration around other windows. Prefer target_id from the latest high-priority semantic UI objects, otherwise precise coordinates from the latest observation. Use vision plus UI controls together when the selected model has vision input. Avoid destructive UI actions unless the user asked for them, and do not claim YOLO/OCR perception unless an actual detector/OCR result is present.
|
|
183
|
+
- For desktop Computer Use requests, follow observe -> decide -> act -> observe. For passive waits, prefer one observe call with sparse_wait_ms (up to 5000) over repeated immediate full observations; it still returns a fresh full screenshot/UIA whether signaled or timed out, and no signal is not proof of no change. Start visible takeover with computer_use takeover_start before multi-step desktop control and stop it when finished. Prefer app-scoped actions through app_list/app_observe/app_* when controlling one taskbar application, because this preserves human collaboration around other windows. Prefer target_id from the latest high-priority semantic UI objects, otherwise precise coordinates from the latest observation. Use vision plus UI controls together when the selected model has vision input. Avoid destructive UI actions unless the user asked for them, and do not claim YOLO/OCR perception unless an actual detector/OCR result is present.
|
|
181
184
|
- For browser buttons and scanned document pages, the strict recognition sequence is text layer/DOM -> screenshot to a validated vision model -> local OCR. Do not skip directly to OCR. If OCR is used, treat it as approximate evidence and repair only what surrounding context supports.
|
|
182
185
|
- Multiple tool calls emitted in one provider turn run concurrently. Treat their returned records as one barrier: continue reasoning only after every call in that batch has returned either a successful receipt or a failure receipt.
|
|
183
186
|
- terminal_takeover start creates or reuses a persistent session. Its successful start receipt completes that Build step immediately; continue with write/read or other work without waiting for detach, stop, shell exit, or terminal closure.
|
|
@@ -274,6 +277,8 @@ class Agent {
|
|
|
274
277
|
// Bounded caller-owned peer slots retain protocol compatibility learning
|
|
275
278
|
// between mailbox jobs. They never retain the child Agent or go to disk.
|
|
276
279
|
peerProviderCaches = new Map();
|
|
280
|
+
/** Reuses the provider connection for current-model JEV calls without sharing conversation build state. */
|
|
281
|
+
jevProviderCache = {};
|
|
277
282
|
awaitingAgentKernelRuntime = false;
|
|
278
283
|
pendingAgentKernelQueue = [];
|
|
279
284
|
linkedPlanAccess;
|
|
@@ -293,7 +298,7 @@ class Agent {
|
|
|
293
298
|
finalizingWorkRunId = '';
|
|
294
299
|
agentRunService = null;
|
|
295
300
|
agentRunByWorkRunId = new Map();
|
|
296
|
-
|
|
301
|
+
routeController;
|
|
297
302
|
resolvedDeployment = null;
|
|
298
303
|
fixedDeployment = null;
|
|
299
304
|
preferredConversationModelSelection = null;
|
|
@@ -305,11 +310,21 @@ class Agent {
|
|
|
305
310
|
lastRouteRetryDelayMs = 0;
|
|
306
311
|
routeAttemptStartedAt = 0;
|
|
307
312
|
providerBalanceBlockedUntilByDeployment = new Map();
|
|
308
|
-
explicitlyRatedRoutes = new Map();
|
|
309
313
|
conversationStateCache = new Map();
|
|
310
314
|
conversationStateCacheFingerprint = new Map();
|
|
315
|
+
conversationStateWrittenFingerprint = new Map();
|
|
316
|
+
conversationShardFingerprint = new Map();
|
|
317
|
+
hydratedShardEntries = new WeakSet();
|
|
318
|
+
/**
|
|
319
|
+
* Revision of the durable queue snapshot this runtime is working from. The
|
|
320
|
+
* merge only accepts a writer's queue when that writer has seen the persisted
|
|
321
|
+
* revision, so an outdated in-memory snapshot can neither drop queued work
|
|
322
|
+
* nor resurrect an entry that was already consumed or cleared.
|
|
323
|
+
*/
|
|
324
|
+
continuationRevision = 0;
|
|
311
325
|
conversationStateFlushTimers = new Map();
|
|
312
326
|
conversationStateDirty = new Map();
|
|
327
|
+
conversationStateLastWriteAt = new Map();
|
|
313
328
|
systemPromptCache = null;
|
|
314
329
|
toolDefinitionCache = new Map();
|
|
315
330
|
modelValidationPromise = null;
|
|
@@ -380,8 +395,7 @@ class Agent {
|
|
|
380
395
|
/* crash recovery is best-effort at startup */
|
|
381
396
|
}
|
|
382
397
|
}
|
|
383
|
-
this.
|
|
384
|
-
this.loadLearnedRouteFeedback();
|
|
398
|
+
this.routeController = new autoRouter_1.RouteExecutionController({ policyVersion: autoRouter_1.ROUTE_POLICY_VERSION });
|
|
385
399
|
if (this.isSubagentRuntime || this.agentOnly) {
|
|
386
400
|
this.config.set('workspace', 'auto_create_timestamp_workspace', false);
|
|
387
401
|
}
|
|
@@ -856,7 +870,6 @@ class Agent {
|
|
|
856
870
|
}
|
|
857
871
|
return;
|
|
858
872
|
}
|
|
859
|
-
const previousAuto = this.model === 'auto' ? this.resolvedDeployment : null;
|
|
860
873
|
const qualified = parseDeploymentSelectionValue(requested);
|
|
861
874
|
const legacyQualified = requested.includes('/')
|
|
862
875
|
? this.config.allModels().filter(model => `${model.provider_id}/${model.name}` === requested || `${model.provider}/${model.name}` === requested)
|
|
@@ -870,11 +883,8 @@ class Agent {
|
|
|
870
883
|
this.pendingAutoAttempts = [];
|
|
871
884
|
if (current?.provider_id)
|
|
872
885
|
this.config.set('models', 'auto_switch_anchor_provider', current.provider_id);
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
this.recordRouteFeedbackFor(previousAuto, taskClass, -1, 'manual_switch');
|
|
876
|
-
this.recordRouteFeedbackFor(this.deploymentRef(current), taskClass, 1, 'manual_switch');
|
|
877
|
-
}
|
|
886
|
+
// A manual model switch is explicit user control. Auto Router v2 records
|
|
887
|
+
// no feedback for it: switching models must never train future Auto routes.
|
|
878
888
|
this.lastRouteDecision = null;
|
|
879
889
|
this.routeAttemptStartedAt = 0;
|
|
880
890
|
this.routeStreamCommitted = false;
|
|
@@ -1020,6 +1030,22 @@ class Agent {
|
|
|
1020
1030
|
currentWorkRunId() {
|
|
1021
1031
|
return this.activeWorkRunId || this.finalizingWorkRunId || '';
|
|
1022
1032
|
}
|
|
1033
|
+
/**
|
|
1034
|
+
* The branch node that owns the currently active Build. Provider session ids
|
|
1035
|
+
* and branch-scoped caches must use this identity instead of "the branch that
|
|
1036
|
+
* happens to be active", otherwise a concurrently running sibling branch
|
|
1037
|
+
* (branch-communication mode) or a page switched mid-Build would read another
|
|
1038
|
+
* branch's remote session and folded history.
|
|
1039
|
+
*/
|
|
1040
|
+
activeWorkRunBranchId() {
|
|
1041
|
+
const runId = this.currentWorkRunId();
|
|
1042
|
+
const run = runId ? this.workRuns.find(item => String(item.runId || '') === runId) : undefined;
|
|
1043
|
+
return String(run?.branchNodeId || '') || this.currentBranchNodeId();
|
|
1044
|
+
}
|
|
1045
|
+
/** Full conversation identity of one branch node (shared by every branch kind). */
|
|
1046
|
+
branchConversationIdentity(branchNodeId) {
|
|
1047
|
+
return (0, branchIdentity_1.branchConversationIdentity)(this.activeConversationId || 'default', branchNodeId);
|
|
1048
|
+
}
|
|
1023
1049
|
activeModelConfig() {
|
|
1024
1050
|
const deployment = this.activeDeployment();
|
|
1025
1051
|
return deployment ? this.config.findDeployment(deployment) : this.config.findModel(this.model);
|
|
@@ -1035,7 +1061,7 @@ class Agent {
|
|
|
1035
1061
|
}
|
|
1036
1062
|
resetAutoRoute() {
|
|
1037
1063
|
if (this.routeTransactionId)
|
|
1038
|
-
this.
|
|
1064
|
+
this.routeController.endTransaction(this.routeTransactionId);
|
|
1039
1065
|
this.resolvedDeployment = null;
|
|
1040
1066
|
this.lastRouteDecision = null;
|
|
1041
1067
|
this.pendingAutoAttempts = [];
|
|
@@ -1078,7 +1104,7 @@ class Agent {
|
|
|
1078
1104
|
const deployment = this.activeDeployment();
|
|
1079
1105
|
if (!deployment)
|
|
1080
1106
|
return;
|
|
1081
|
-
this.
|
|
1107
|
+
this.routeController.recordEndpointSuccess(deployment, latencyMs, throughput);
|
|
1082
1108
|
if (this.lastRouteDecision) {
|
|
1083
1109
|
const attempt = [...this.lastRouteDecision.attempts].reverse().find(item => deploymentIdentity(item.deployment) === deploymentIdentity(deployment));
|
|
1084
1110
|
if (attempt) {
|
|
@@ -1097,7 +1123,7 @@ class Agent {
|
|
|
1097
1123
|
const deployment = this.activeDeployment();
|
|
1098
1124
|
if (!deployment)
|
|
1099
1125
|
return;
|
|
1100
|
-
this.
|
|
1126
|
+
this.routeController.recordToolOutcome(deployment, valid);
|
|
1101
1127
|
}
|
|
1102
1128
|
updateProviders(value) {
|
|
1103
1129
|
const before = this.config.providers();
|
|
@@ -1118,7 +1144,7 @@ class Agent {
|
|
|
1118
1144
|
if (!provider)
|
|
1119
1145
|
continue;
|
|
1120
1146
|
for (const model of provider.models || []) {
|
|
1121
|
-
this.
|
|
1147
|
+
this.routeController.resetEndpointAfterConfigChange({
|
|
1122
1148
|
providerId: provider.id,
|
|
1123
1149
|
modelId: model.name,
|
|
1124
1150
|
logicalModelGroupId: model.logical_model_group_id || undefined,
|
|
@@ -1129,76 +1155,59 @@ class Agent {
|
|
|
1129
1155
|
if (changed)
|
|
1130
1156
|
this.resetAutoRoute();
|
|
1131
1157
|
}
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
}
|
|
1140
|
-
rateActiveAutoRoute(score, expectedRouteId = '') {
|
|
1141
|
-
const normalizedScore = Number(score);
|
|
1142
|
-
if (normalizedScore !== -1 && normalizedScore !== 1) {
|
|
1143
|
-
return { ok: false, reason: 'invalid_score' };
|
|
1144
|
-
}
|
|
1145
|
-
const decision = this.lastRouteDecision;
|
|
1146
|
-
const deployment = this.activeDeployment();
|
|
1147
|
-
if (this.model !== 'auto'
|
|
1148
|
-
|| decision?.requestedSelection.kind !== 'auto'
|
|
1149
|
-
|| !decision.routeId
|
|
1150
|
-
|| !decision.resolvedDeployment
|
|
1151
|
-
|| !deployment) {
|
|
1152
|
-
return { ok: false, reason: 'no_active_auto_route' };
|
|
1153
|
-
}
|
|
1154
|
-
const requestedRouteId = String(expectedRouteId || '').trim();
|
|
1155
|
-
if (requestedRouteId && requestedRouteId !== decision.routeId) {
|
|
1156
|
-
return { ok: false, routeId: decision.routeId, reason: 'stale_route' };
|
|
1157
|
-
}
|
|
1158
|
-
const previousScore = this.explicitlyRatedRoutes.get(decision.routeId);
|
|
1159
|
-
if (previousScore !== undefined) {
|
|
1160
|
-
return { ok: false, score: previousScore, routeId: decision.routeId, reason: 'already_rated' };
|
|
1161
|
-
}
|
|
1162
|
-
const taskClasses = [...new Set(decision.taskClasses)];
|
|
1163
|
-
for (const taskClass of taskClasses) {
|
|
1164
|
-
this.recordRouteFeedbackFor(deployment, taskClass, normalizedScore, 'explicit_rating');
|
|
1165
|
-
}
|
|
1166
|
-
this.recordRouteQualityObservation(deployment, taskClasses, normalizedScore > 0);
|
|
1167
|
-
this.explicitlyRatedRoutes.set(decision.routeId, normalizedScore);
|
|
1168
|
-
while (this.explicitlyRatedRoutes.size > 1_000) {
|
|
1169
|
-
const oldest = this.explicitlyRatedRoutes.keys().next().value;
|
|
1170
|
-
if (!oldest)
|
|
1171
|
-
break;
|
|
1172
|
-
this.explicitlyRatedRoutes.delete(oldest);
|
|
1173
|
-
}
|
|
1174
|
-
return { ok: true, score: normalizedScore, routeId: decision.routeId };
|
|
1158
|
+
/**
|
|
1159
|
+
* Auto master switch. Every automatic model decision in this runtime is
|
|
1160
|
+
* gated on it: when it is off the Agent neither routes nor edits the current
|
|
1161
|
+
* model decision settings, and no code path may move the active model on its own.
|
|
1162
|
+
*/
|
|
1163
|
+
autoDecisionEnabled() {
|
|
1164
|
+
return this.config.autoSwitchEnabled();
|
|
1175
1165
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1166
|
+
/** Current-model JEV settings exposed to GUI / TUI / CLI editors. */
|
|
1167
|
+
jevDecisionModelState() {
|
|
1168
|
+
const autoEnabled = this.autoDecisionEnabled();
|
|
1169
|
+
const timeout = Number(this.config.get('models', 'jev_timeout_ms'));
|
|
1170
|
+
return {
|
|
1171
|
+
autoEnabled,
|
|
1172
|
+
locked: !autoEnabled,
|
|
1173
|
+
...(autoEnabled ? {} : { lockReason: 'auto_disabled' }),
|
|
1174
|
+
enabled: this.config.get('models', 'jev_enabled') !== false,
|
|
1175
|
+
decisionModel: 'current_eligible_model',
|
|
1176
|
+
thinkingMode: 'off',
|
|
1177
|
+
outputFormat: 'strict_json_schema',
|
|
1178
|
+
timeoutMs: Number.isFinite(timeout) && timeout > 0 ? Math.round(timeout) : jevDecision_1.DEFAULT_JEV_TIMEOUT_MS,
|
|
1179
|
+
};
|
|
1186
1180
|
}
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
};
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
|
|
1181
|
+
/**
|
|
1182
|
+
* Edit current-model Auto decision settings.
|
|
1183
|
+
*
|
|
1184
|
+
* Refuses every write while Auto is off (`auto_disabled`): with automatic
|
|
1185
|
+
* decision-making disabled, not even the decision-model configuration may
|
|
1186
|
+
* change — that is what "Auto off never moves a model" means at the editor
|
|
1187
|
+
* boundary. Invalid values are rejected without touching the stored config.
|
|
1188
|
+
*/
|
|
1189
|
+
updateJevDecisionModel(patch = {}) {
|
|
1190
|
+
if (!this.autoDecisionEnabled()) {
|
|
1191
|
+
return { ok: false, reason: 'auto_disabled', state: this.jevDecisionModelState() };
|
|
1192
|
+
}
|
|
1193
|
+
const current = this.jevDecisionModelState();
|
|
1194
|
+
const next = { ...current };
|
|
1195
|
+
const reject = () => ({ ok: false, reason: 'invalid_value', state: this.jevDecisionModelState() });
|
|
1196
|
+
const editableKeys = new Set(['enabled', 'timeoutMs']);
|
|
1197
|
+
if (Object.keys(patch).some(key => !editableKeys.has(key)))
|
|
1198
|
+
return reject();
|
|
1199
|
+
if (patch.enabled !== undefined)
|
|
1200
|
+
next.enabled = patch.enabled === true;
|
|
1201
|
+
if (patch.timeoutMs !== undefined) {
|
|
1202
|
+
const timeout = Number(patch.timeoutMs);
|
|
1203
|
+
if (!Number.isFinite(timeout) || timeout < 100 || timeout > 30_000)
|
|
1204
|
+
return reject();
|
|
1205
|
+
next.timeoutMs = Math.round(timeout);
|
|
1206
|
+
}
|
|
1207
|
+
this.config.set('models', 'jev_enabled', next.enabled);
|
|
1208
|
+
this.config.set('models', 'jev_timeout_ms', next.timeoutMs);
|
|
1201
1209
|
this.config.save();
|
|
1210
|
+
return { ok: true, state: this.jevDecisionModelState() };
|
|
1202
1211
|
}
|
|
1203
1212
|
workspaceConversationKey() {
|
|
1204
1213
|
const ws = this.workspace.current;
|
|
@@ -1211,6 +1220,158 @@ class Agent {
|
|
|
1211
1220
|
return null;
|
|
1212
1221
|
return path.join(ws.path, 'conversations', 'state.json');
|
|
1213
1222
|
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Heavy conversation payload that lives in a per-conversation shard file
|
|
1225
|
+
* instead of the workspace index document. Everything else (title, pinning,
|
|
1226
|
+
* order, draft, mode, goal, runtime owner, counters) stays in the index so
|
|
1227
|
+
* listing, sorting and cross-process merge never need to parse a transcript.
|
|
1228
|
+
*/
|
|
1229
|
+
static CONVERSATION_SHARD_FIELDS = [
|
|
1230
|
+
'chatMessages', 'history', 'workRuns', 'continuations',
|
|
1231
|
+
'compressionCache', 'branches', 'tree', 'branchMailbox',
|
|
1232
|
+
];
|
|
1233
|
+
workspaceConversationShardDir(ws = this.workspace.current) {
|
|
1234
|
+
const store = this.workspaceConversationStorePath(ws);
|
|
1235
|
+
if (!store)
|
|
1236
|
+
return null;
|
|
1237
|
+
return path.join(path.dirname(store), 'shards');
|
|
1238
|
+
}
|
|
1239
|
+
conversationShardFileName(key) {
|
|
1240
|
+
const safe = String(key).replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 180);
|
|
1241
|
+
const digest = crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 12);
|
|
1242
|
+
return `${safe}.${digest}.json`;
|
|
1243
|
+
}
|
|
1244
|
+
conversationShardPath(key, ws = this.workspace.current) {
|
|
1245
|
+
const dir = this.workspaceConversationShardDir(ws);
|
|
1246
|
+
if (!dir)
|
|
1247
|
+
return null;
|
|
1248
|
+
return path.join(dir, this.conversationShardFileName(key));
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Hydrate one conversation entry from its shard on first access. The heavy
|
|
1252
|
+
* fields are non-enumerable so `{ ...entry }` (and therefore the index
|
|
1253
|
+
* document written back to disk) never pulls a transcript into memory, while
|
|
1254
|
+
* every existing reader that asks for `entry.chatMessages` / `entry.history`
|
|
1255
|
+
* keeps working unchanged.
|
|
1256
|
+
*/
|
|
1257
|
+
attachConversationShard(entry, shardFile, key) {
|
|
1258
|
+
const cache = { loaded: false, value: null };
|
|
1259
|
+
const load = () => {
|
|
1260
|
+
if (cache.loaded)
|
|
1261
|
+
return cache.value;
|
|
1262
|
+
cache.loaded = true;
|
|
1263
|
+
this.hydratedShardEntries.add(entry);
|
|
1264
|
+
try {
|
|
1265
|
+
const raw = fs.readFileSync(shardFile, 'utf-8').replace(/^\uFEFF/, '');
|
|
1266
|
+
const parsed = JSON.parse(raw);
|
|
1267
|
+
cache.value = parsed && typeof parsed === 'object' ? parsed : null;
|
|
1268
|
+
}
|
|
1269
|
+
catch {
|
|
1270
|
+
cache.value = null;
|
|
1271
|
+
}
|
|
1272
|
+
return cache.value;
|
|
1273
|
+
};
|
|
1274
|
+
for (const field of Agent.CONVERSATION_SHARD_FIELDS) {
|
|
1275
|
+
Object.defineProperty(entry, field, {
|
|
1276
|
+
enumerable: false,
|
|
1277
|
+
configurable: true,
|
|
1278
|
+
get: () => {
|
|
1279
|
+
const shard = load();
|
|
1280
|
+
return shard ? shard[field] : undefined;
|
|
1281
|
+
},
|
|
1282
|
+
set: (value) => {
|
|
1283
|
+
this.hydratedShardEntries.add(entry);
|
|
1284
|
+
const shard = { ...(cache.value || {}) };
|
|
1285
|
+
shard[field] = value;
|
|
1286
|
+
cache.value = shard;
|
|
1287
|
+
cache.loaded = true;
|
|
1288
|
+
},
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
void key;
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Heavy payload carried in memory by this entry, or `null` when the entry is
|
|
1295
|
+
* a shard-backed index record whose heavy fields were never touched. That
|
|
1296
|
+
* distinction is what keeps unrelated conversations off the write path.
|
|
1297
|
+
*/
|
|
1298
|
+
conversationShardSnapshot(entry) {
|
|
1299
|
+
// Ownership rule: a *live* entry carries the heavy fields as own enumerable
|
|
1300
|
+
// properties; an index record read back from disk exposes them as
|
|
1301
|
+
// non-enumerable accessors that hydrate from the shard on demand. Only a
|
|
1302
|
+
// record that was actually hydrated (its accessors fired) counts as owning
|
|
1303
|
+
// its payload — keying on a `shard` marker instead would misclassify the
|
|
1304
|
+
// live entry, because it is built by spreading the previous index entry.
|
|
1305
|
+
const hydrated = this.hydratedShardEntries.has(entry);
|
|
1306
|
+
const snapshot = {};
|
|
1307
|
+
let any = false;
|
|
1308
|
+
for (const field of Agent.CONVERSATION_SHARD_FIELDS) {
|
|
1309
|
+
const descriptor = Object.getOwnPropertyDescriptor(entry, field);
|
|
1310
|
+
if (!descriptor)
|
|
1311
|
+
continue;
|
|
1312
|
+
if (!descriptor.enumerable && !hydrated)
|
|
1313
|
+
continue;
|
|
1314
|
+
const value = descriptor.get ? descriptor.get.call(entry) : descriptor.value;
|
|
1315
|
+
if (value === undefined)
|
|
1316
|
+
continue;
|
|
1317
|
+
snapshot[field] = value;
|
|
1318
|
+
any = true;
|
|
1319
|
+
}
|
|
1320
|
+
return any ? snapshot : null;
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* Force one shard-backed entry into memory. Used only on the rare recovery
|
|
1324
|
+
* paths that must compare against a payload written by another runtime.
|
|
1325
|
+
*/
|
|
1326
|
+
hydrateConversationEntry(entry) {
|
|
1327
|
+
if (typeof entry.shard !== 'string' || !entry.shard)
|
|
1328
|
+
return;
|
|
1329
|
+
this.hydratedShardEntries.add(entry);
|
|
1330
|
+
for (const field of Agent.CONVERSATION_SHARD_FIELDS) {
|
|
1331
|
+
const value = entry[field];
|
|
1332
|
+
if (value !== undefined)
|
|
1333
|
+
entry[field] = value;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Durable queue merge.
|
|
1338
|
+
*
|
|
1339
|
+
* Queue entries are the user's pending work, so a runtime that never saw an
|
|
1340
|
+
* entry must not delete it, while an entry this runtime actually consumed
|
|
1341
|
+
* must not be resurrected. Identity-based union minus exactly the consumed
|
|
1342
|
+
* ids gives both guarantees without needing a global revision clock.
|
|
1343
|
+
*/
|
|
1344
|
+
mergeConversationContinuations(incoming, existingCount, existingShardBacked, existingEntry, incomingRevision, existingRevision) {
|
|
1345
|
+
// A writer that has seen the persisted revision owns the queue (including
|
|
1346
|
+
// deliberate clearing/consumption). Anything older keeps what is stored.
|
|
1347
|
+
if (incomingRevision >= existingRevision)
|
|
1348
|
+
return this.normalizeContinuations(incoming);
|
|
1349
|
+
if (existingShardBacked)
|
|
1350
|
+
this.hydrateConversationEntry(existingEntry);
|
|
1351
|
+
const existing = existingEntry.continuations || [];
|
|
1352
|
+
void existingCount;
|
|
1353
|
+
return this.normalizeContinuations(existing.length ? existing : incoming);
|
|
1354
|
+
}
|
|
1355
|
+
/** Index-document form of a conversation entry: metadata plus a shard pointer. */
|
|
1356
|
+
conversationIndexEntryForDisk(entry, shardFileName, shardBytes, counts) {
|
|
1357
|
+
const index = {};
|
|
1358
|
+
for (const [field, value] of Object.entries(entry))
|
|
1359
|
+
index[field] = value;
|
|
1360
|
+
for (const field of Agent.CONVERSATION_SHARD_FIELDS)
|
|
1361
|
+
delete index[field];
|
|
1362
|
+
index.messageCount = counts.messageCount;
|
|
1363
|
+
index.historyCount = counts.historyCount;
|
|
1364
|
+
index.workRunCount = counts.workRunCount;
|
|
1365
|
+
index.hasTree = counts.hasTree;
|
|
1366
|
+
index.hasBranches = counts.hasBranches;
|
|
1367
|
+
index.continuationCount = counts.continuationCount;
|
|
1368
|
+
index.continuationRevision = counts.continuationRevision;
|
|
1369
|
+
if (shardFileName) {
|
|
1370
|
+
index.shard = shardFileName;
|
|
1371
|
+
index.shardBytes = shardBytes;
|
|
1372
|
+
}
|
|
1373
|
+
return index;
|
|
1374
|
+
}
|
|
1214
1375
|
workspaceConversationStateKey(conversationId = this.activeConversationId) {
|
|
1215
1376
|
return this.workspaceConversationStateKeyFor(conversationId, this.workspace.current);
|
|
1216
1377
|
}
|
|
@@ -1520,6 +1681,26 @@ class Agent {
|
|
|
1520
1681
|
tree,
|
|
1521
1682
|
};
|
|
1522
1683
|
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Bound the live Build ledger.
|
|
1686
|
+
*
|
|
1687
|
+
* `normalizeWorkRuns()` already windows the persisted/served ledger to 120
|
|
1688
|
+
* runs, but the live `this.workRuns` array kept every Build block of the
|
|
1689
|
+
* conversation (measured: 307 runs / 4.6 MiB after 300 turns). The renderer
|
|
1690
|
+
* only ever shows the newest window, so older terminal runs can be dropped
|
|
1691
|
+
* from memory while the conversation continues.
|
|
1692
|
+
*/
|
|
1693
|
+
pruneLiveWorkRuns() {
|
|
1694
|
+
const window = 120;
|
|
1695
|
+
if (this.workRuns.length <= window)
|
|
1696
|
+
return;
|
|
1697
|
+
const running = this.workRuns.filter(run => run.status === 'running');
|
|
1698
|
+
const keep = new Set(running.map(run => run.runId));
|
|
1699
|
+
const byRecency = [...this.workRuns].sort((a, b) => String(a.startedAt || '').localeCompare(String(b.startedAt || '')));
|
|
1700
|
+
for (const run of byRecency.slice(-window))
|
|
1701
|
+
keep.add(run.runId);
|
|
1702
|
+
this.workRuns = this.workRuns.filter(run => keep.has(run.runId));
|
|
1703
|
+
}
|
|
1523
1704
|
normalizeWorkRuns(runs) {
|
|
1524
1705
|
if (!Array.isArray(runs))
|
|
1525
1706
|
return [];
|
|
@@ -1798,6 +1979,7 @@ class Agent {
|
|
|
1798
1979
|
return true;
|
|
1799
1980
|
}
|
|
1800
1981
|
beginConversationWorkRun(runId, target = this.currentConversationTarget(), startedAt = this.nowIso(), managed = false, runtimeKey) {
|
|
1982
|
+
this.pruneLiveWorkRuns();
|
|
1801
1983
|
const cleanRunId = String(runId || crypto.randomUUID()).trim().slice(0, 200);
|
|
1802
1984
|
const existing = this.workRuns.find(run => run.runId === cleanRunId);
|
|
1803
1985
|
if (existing) {
|
|
@@ -2482,8 +2664,11 @@ class Agent {
|
|
|
2482
2664
|
}
|
|
2483
2665
|
if (event.type === 'start')
|
|
2484
2666
|
this.saveWorkspaceConversationState(false);
|
|
2667
|
+
// Terminal events are coalesced on purpose: `Agent.process()` performs the
|
|
2668
|
+
// synchronous save right after this sequence, so persisting here as well
|
|
2669
|
+
// only rewrote the whole conversation again for the same turn.
|
|
2485
2670
|
if (event.type === 'done' || event.type === 'error')
|
|
2486
|
-
this.saveWorkspaceConversationState(
|
|
2671
|
+
this.saveWorkspaceConversationState(false);
|
|
2487
2672
|
if (activeRun && this.finalizingWorkRunId !== activeRun.runId
|
|
2488
2673
|
&& (event.type === 'done' || event.type === 'error') && activeRun.status !== 'running') {
|
|
2489
2674
|
const terminalStatus = activeRun.status;
|
|
@@ -2600,8 +2785,10 @@ class Agent {
|
|
|
2600
2785
|
const before = this.continuations.length;
|
|
2601
2786
|
this.continuations = this.continuations.filter(item => item.queueMode !== 'followUp'
|
|
2602
2787
|
|| !matches(item.content, item.hiddenUserInput === true, item.clientMessageId));
|
|
2603
|
-
if (this.continuations.length !== before)
|
|
2788
|
+
if (this.continuations.length !== before) {
|
|
2789
|
+
this.continuationRevision += 1;
|
|
2604
2790
|
this.saveWorkspaceConversationState(true);
|
|
2791
|
+
}
|
|
2605
2792
|
for (const subscriber of this.rootInboxRetiredSubscribers)
|
|
2606
2793
|
subscriber(ids);
|
|
2607
2794
|
return ids;
|
|
@@ -2688,6 +2875,7 @@ class Agent {
|
|
|
2688
2875
|
createdAt: item.createdAt || new Date().toISOString(),
|
|
2689
2876
|
}))]);
|
|
2690
2877
|
this.continuations = combined;
|
|
2878
|
+
this.continuationRevision += 1;
|
|
2691
2879
|
this.saveWorkspaceConversationState(true);
|
|
2692
2880
|
return combined.map(item => ({
|
|
2693
2881
|
...item,
|
|
@@ -2702,6 +2890,7 @@ class Agent {
|
|
|
2702
2890
|
if (index < 0)
|
|
2703
2891
|
return false;
|
|
2704
2892
|
this.continuations.splice(index, 1);
|
|
2893
|
+
this.continuationRevision += 1;
|
|
2705
2894
|
this.saveWorkspaceConversationState(true);
|
|
2706
2895
|
return true;
|
|
2707
2896
|
}
|
|
@@ -2791,7 +2980,15 @@ class Agent {
|
|
|
2791
2980
|
state.version = Math.max(1, Number(state.version || 1));
|
|
2792
2981
|
state.conversations = state.conversations || {};
|
|
2793
2982
|
let migrated = false;
|
|
2794
|
-
|
|
2983
|
+
const shardDir = this.workspaceConversationShardDir(ws);
|
|
2984
|
+
for (const [key, entry] of Object.entries(state.conversations)) {
|
|
2985
|
+
if (entry.shard) {
|
|
2986
|
+
// Index record: attach lazy accessors instead of reading the shard.
|
|
2987
|
+
const shardFile = shardDir ? path.join(shardDir, String(entry.shard)) : '';
|
|
2988
|
+
if (shardFile)
|
|
2989
|
+
this.attachConversationShard(entry, shardFile, key);
|
|
2990
|
+
continue;
|
|
2991
|
+
}
|
|
2795
2992
|
if (!entry.tree && entry.branches?.length) {
|
|
2796
2993
|
this.normalizeConversationTree(entry);
|
|
2797
2994
|
entry.branches = undefined;
|
|
@@ -2811,25 +3008,87 @@ class Agent {
|
|
|
2811
3008
|
}
|
|
2812
3009
|
return {};
|
|
2813
3010
|
}
|
|
3011
|
+
/** Durable replacement-style text write (temp file + fsync + rename). */
|
|
3012
|
+
writeAtomicTextFile(file, content) {
|
|
3013
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
3014
|
+
const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
3015
|
+
try {
|
|
3016
|
+
const handle = fs.openSync(temp, 'w');
|
|
3017
|
+
try {
|
|
3018
|
+
fs.writeFileSync(handle, content, 'utf-8');
|
|
3019
|
+
fs.fsyncSync(handle);
|
|
3020
|
+
}
|
|
3021
|
+
finally {
|
|
3022
|
+
fs.closeSync(handle);
|
|
3023
|
+
}
|
|
3024
|
+
let lastError = null;
|
|
3025
|
+
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
3026
|
+
try {
|
|
3027
|
+
fs.renameSync(temp, file);
|
|
3028
|
+
return;
|
|
3029
|
+
}
|
|
3030
|
+
catch (error) {
|
|
3031
|
+
lastError = error;
|
|
3032
|
+
const code = error && typeof error === 'object' && 'code' in error ? String(error.code || '') : '';
|
|
3033
|
+
if (!['EPERM', 'EACCES', 'EBUSY'].includes(code))
|
|
3034
|
+
break;
|
|
3035
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10 * (attempt + 1));
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
throw lastError;
|
|
3039
|
+
}
|
|
3040
|
+
finally {
|
|
3041
|
+
try {
|
|
3042
|
+
fs.unlinkSync(temp);
|
|
3043
|
+
}
|
|
3044
|
+
catch { }
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
2814
3047
|
writeStoredConversationState(state, ws = this.workspace.current, deletedKeys = []) {
|
|
2815
3048
|
this.writeStoredConversationStateNow(state, ws, deletedKeys);
|
|
2816
3049
|
}
|
|
2817
|
-
|
|
3050
|
+
/**
|
|
3051
|
+
* Coalesced conversation-state write.
|
|
3052
|
+
*
|
|
3053
|
+
* Mid-turn persistence used to rewrite the whole workspace document
|
|
3054
|
+
* synchronously on every kernel event (measured: 5.33 full rewrites per turn
|
|
3055
|
+
* with no tools). The document grows with the conversation, so that cost grew
|
|
3056
|
+
* with the turn count. This path keeps durability at turn boundaries (the
|
|
3057
|
+
* terminal save stays synchronous and cancels any pending timer) while
|
|
3058
|
+
* throttling intermediate saves: the latest snapshot wins, and bursts inside
|
|
3059
|
+
* one throttle window collapse into a single write.
|
|
3060
|
+
*/
|
|
3061
|
+
scheduleStoredConversationState(state, ws = this.workspace.current, minIntervalMs = 80) {
|
|
2818
3062
|
const file = this.workspaceConversationStorePath(ws);
|
|
2819
3063
|
if (!file)
|
|
2820
3064
|
return;
|
|
2821
|
-
this.conversationStateDirty.set(file, { state, ws });
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
3065
|
+
this.conversationStateDirty.set(file, { state, ws, at: Date.now() });
|
|
3066
|
+
// A write is already queued: keep it and let it carry the newest snapshot
|
|
3067
|
+
// instead of pushing the deadline out (debounce) on every event.
|
|
3068
|
+
if (this.conversationStateFlushTimers.has(file))
|
|
3069
|
+
return;
|
|
3070
|
+
const elapsed = Date.now() - (this.conversationStateLastWriteAt.get(file) || 0);
|
|
3071
|
+
const wait = Math.max(0, minIntervalMs - elapsed);
|
|
3072
|
+
const timer = setTimeout(() => {
|
|
2826
3073
|
this.conversationStateFlushTimers.delete(file);
|
|
2827
3074
|
const pending = this.conversationStateDirty.get(file);
|
|
2828
3075
|
if (!pending)
|
|
2829
3076
|
return;
|
|
2830
3077
|
this.conversationStateDirty.delete(file);
|
|
2831
|
-
|
|
2832
|
-
|
|
3078
|
+
try {
|
|
3079
|
+
this.writeStoredConversationStateNow(pending.state, pending.ws);
|
|
3080
|
+
}
|
|
3081
|
+
catch (error) {
|
|
3082
|
+
// A deferred write must never take the process down: keep the snapshot
|
|
3083
|
+
// pending so the next save or the exit flush retries it.
|
|
3084
|
+
this.conversationStateDirty.set(file, pending);
|
|
3085
|
+
if (process.env.NEWMARK_PROVIDER_DIAGNOSTICS === '1')
|
|
3086
|
+
console.error(`[NewmarkState] deferred conversation-state write failed: ${String(error)}`);
|
|
3087
|
+
}
|
|
3088
|
+
}, wait);
|
|
3089
|
+
if (typeof timer.unref === 'function')
|
|
3090
|
+
timer.unref();
|
|
3091
|
+
this.conversationStateFlushTimers.set(file, timer);
|
|
2833
3092
|
}
|
|
2834
3093
|
flushWorkspaceConversationState() {
|
|
2835
3094
|
const file = this.workspaceConversationStorePath();
|
|
@@ -2912,6 +3171,16 @@ class Agent {
|
|
|
2912
3171
|
writeStoredConversationStateNow(state, ws = this.workspace.current, deletedKeys = []) {
|
|
2913
3172
|
const file = this.workspaceConversationStorePath(ws);
|
|
2914
3173
|
if (file) {
|
|
3174
|
+
// A queued coalesced snapshot may hold changes for *other* conversations
|
|
3175
|
+
// (for example provider usage recorded just before switching away). Fold
|
|
3176
|
+
// those in before cancelling it so a synchronous save can never drop them.
|
|
3177
|
+
const pending = this.conversationStateDirty.get(file);
|
|
3178
|
+
if (pending) {
|
|
3179
|
+
state = {
|
|
3180
|
+
...state,
|
|
3181
|
+
conversations: { ...(pending.state.conversations || {}), ...(state.conversations || {}) },
|
|
3182
|
+
};
|
|
3183
|
+
}
|
|
2915
3184
|
const pendingTimer = this.conversationStateFlushTimers.get(file);
|
|
2916
3185
|
if (pendingTimer)
|
|
2917
3186
|
clearTimeout(pendingTimer);
|
|
@@ -2933,29 +3202,139 @@ class Agent {
|
|
|
2933
3202
|
const existingUpdatedAt = Date.parse(existing.updatedAt || '') || 0;
|
|
2934
3203
|
const preferred = incomingUpdatedAt >= existingUpdatedAt ? { ...existing, ...incoming } : { ...incoming, ...existing };
|
|
2935
3204
|
const branchPageReplacement = !!incoming.branchReset || !!incoming.tree || !!incoming.branches?.length;
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
3205
|
+
// Only the side that actually carries a payload may decide the heavy
|
|
3206
|
+
// fields. A shard-backed index entry that this process never touched
|
|
3207
|
+
// must not be hydrated just to merge metadata.
|
|
3208
|
+
const incomingEntry = incoming;
|
|
3209
|
+
const existingEntry = existing;
|
|
3210
|
+
const incomingShard = this.conversationShardSnapshot(incomingEntry);
|
|
3211
|
+
const count = (value) => (Array.isArray(value) ? value.length : 0);
|
|
3212
|
+
const existingMessages = Number(existing.messageCount) || count(existingEntry.chatMessages);
|
|
3213
|
+
const existingHistory = Number(existing.historyCount) || count(existingEntry.history);
|
|
3214
|
+
const incomingMessages = incomingShard ? count(incomingShard.chatMessages) : Number(incoming.messageCount) || 0;
|
|
3215
|
+
const incomingHistory = incomingShard ? count(incomingShard.history) : Number(incoming.historyCount) || 0;
|
|
3216
|
+
const incomingTranscriptEmpty = incomingMessages === 0 && incomingHistory === 0;
|
|
3217
|
+
// Legacy guard: an empty in-memory snapshot must never clobber a stored
|
|
3218
|
+
// transcript (mirrors, cold reloads and legacy callers can carry an
|
|
3219
|
+
// entry without its messages).
|
|
3220
|
+
const keepsExistingPayload = incomingTranscriptEmpty
|
|
3221
|
+
&& (existingMessages > 0 || existingHistory > 0)
|
|
3222
|
+
&& !branchPageReplacement;
|
|
3223
|
+
if (!incomingShard || keepsExistingPayload) {
|
|
3224
|
+
// Metadata-only update: keep the existing heavy payload and pointer.
|
|
3225
|
+
preferred.messageCount = existingMessages || Number(preferred.messageCount) || 0;
|
|
3226
|
+
preferred.historyCount = existingHistory || Number(preferred.historyCount) || 0;
|
|
3227
|
+
preferred.workRunCount = Number(existing.workRunCount) || Number(preferred.workRunCount) || 0;
|
|
3228
|
+
if (typeof existing.shard === 'string' && existing.shard) {
|
|
3229
|
+
preferred.shard = existing.shard;
|
|
3230
|
+
preferred.shardBytes = Number(existing.shardBytes) || Number(preferred.shardBytes) || 0;
|
|
3231
|
+
}
|
|
3232
|
+
// `preferred` is a shallow metadata copy: the lazy shard accessors are
|
|
3233
|
+
// non-enumerable and therefore absent from it. Re-attach them so the
|
|
3234
|
+
// cached state keeps exposing this conversation's transcript instead
|
|
3235
|
+
// of looking like an empty conversation to later readers.
|
|
3236
|
+
if (typeof preferred.shard === 'string' && preferred.shard) {
|
|
3237
|
+
const shardDir = this.workspaceConversationShardDir(ws);
|
|
3238
|
+
if (shardDir) {
|
|
3239
|
+
this.attachConversationShard(preferred, path.join(shardDir, String(preferred.shard)), key);
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
3242
|
+
// Even a metadata-only writer can carry a stale WorkRun ledger. Load
|
|
3243
|
+
// the existing shard, then reconcile by run id and event sequence;
|
|
3244
|
+
// the other heavy fields still come from that same persisted shard.
|
|
3245
|
+
// Explicit branch-page replacement is the only path that replaces
|
|
3246
|
+
// runs without merging. A completed snapshot wins over an older
|
|
3247
|
+
// running copy, while explicit resume emits a newer sequence.
|
|
3248
|
+
const incomingRuns = Array.isArray(incoming.workRuns) ? incoming.workRuns : null;
|
|
3249
|
+
if (branchPageReplacement) {
|
|
3250
|
+
preferred.workRuns = this.normalizeWorkRuns(incomingRuns || []);
|
|
3251
|
+
}
|
|
3252
|
+
else if (incomingRuns) {
|
|
3253
|
+
const existingShardBacked = typeof preferred.shard === 'string' && !!preferred.shard;
|
|
3254
|
+
let previousRuns = [];
|
|
3255
|
+
if (existingShardBacked) {
|
|
3256
|
+
const preferredEntry = preferred;
|
|
3257
|
+
this.hydrateConversationEntry(preferredEntry);
|
|
3258
|
+
previousRuns = preferred.workRuns || [];
|
|
3259
|
+
}
|
|
3260
|
+
else {
|
|
3261
|
+
previousRuns = existing.workRuns || [];
|
|
3262
|
+
}
|
|
3263
|
+
preferred.workRuns = this.normalizeWorkRuns([...previousRuns, ...incomingRuns]);
|
|
3264
|
+
}
|
|
3265
|
+
merged[key] = preferred;
|
|
3266
|
+
continue;
|
|
2941
3267
|
}
|
|
3268
|
+
// This process owns a payload for this conversation. Which side wins is
|
|
3269
|
+
// decided per field exactly like the pre-shard merge did:
|
|
3270
|
+
// - `updatedAt` orders whole entries (a stale in-memory snapshot must
|
|
3271
|
+
// never revert a newer persisted transcript/queue);
|
|
3272
|
+
// - fields the caller does not produce at all (`tree`/`branches` come
|
|
3273
|
+
// from branch transactions, never from saveWorkspaceConversationState)
|
|
3274
|
+
// always survive from the stored payload;
|
|
3275
|
+
// - a branch page replacement still takes the incoming side verbatim.
|
|
3276
|
+
const preferIncoming = incomingUpdatedAt >= existingUpdatedAt;
|
|
3277
|
+
const existingShardBacked = Object.prototype.hasOwnProperty.call(existing, 'shard');
|
|
3278
|
+
const needsExistingPayload = existingShardBacked && (!preferIncoming
|
|
3279
|
+
|| (!!existing.hasTree && incomingShard.tree === undefined)
|
|
3280
|
+
|| (!!existing.hasBranches && incomingShard.branches === undefined));
|
|
3281
|
+
const priorPayload = needsExistingPayload
|
|
3282
|
+
? (this.hydrateConversationEntry(existingEntry), existingEntry)
|
|
3283
|
+
: null;
|
|
3284
|
+
const winningHeavy = (field) => {
|
|
3285
|
+
const existingValue = priorPayload ? priorPayload[field] : undefined;
|
|
3286
|
+
const incomingValue = incomingShard[field];
|
|
3287
|
+
if (preferIncoming)
|
|
3288
|
+
return incomingValue !== undefined ? incomingValue : existingValue;
|
|
3289
|
+
return existingValue !== undefined ? existingValue : incomingValue;
|
|
3290
|
+
};
|
|
2942
3291
|
const incomingPlanRevision = Math.max(0, Number(incoming.linkedPlan?.revision || 0));
|
|
2943
3292
|
const existingPlanRevision = Math.max(0, Number(existing.linkedPlan?.revision || 0));
|
|
2944
3293
|
preferred.linkedPlan = incomingPlanRevision >= existingPlanRevision ? incoming.linkedPlan : existing.linkedPlan;
|
|
2945
3294
|
const incomingSequence = Math.max(0, Number(incoming.subagentState?.nextSequence || 0));
|
|
2946
3295
|
const existingSequence = Math.max(0, Number(existing.subagentState?.nextSequence || 0));
|
|
2947
3296
|
preferred.subagentState = incomingSequence >= existingSequence ? incoming.subagentState : existing.subagentState;
|
|
2948
|
-
preferred.chatMessages = branchPageReplacement
|
|
2949
|
-
|
|
3297
|
+
preferred.chatMessages = branchPageReplacement
|
|
3298
|
+
? [...(incomingShard.chatMessages || [])]
|
|
3299
|
+
: winningHeavy('chatMessages') || [];
|
|
3300
|
+
preferred.history = branchPageReplacement
|
|
3301
|
+
? [...(incomingShard.history || [])]
|
|
3302
|
+
: winningHeavy('history') || [];
|
|
3303
|
+
// WorkRun ledgers can diverge across isolated runtimes while keeping
|
|
3304
|
+
// the same number of runs (for example, a stale `running` snapshot
|
|
3305
|
+
// after another runtime durably completed that run). Always reconcile
|
|
3306
|
+
// the persisted ledger with the incoming one; normalizeWorkRuns merges
|
|
3307
|
+
// duplicate run ids by event sequence and terminal rank. Explicit
|
|
3308
|
+
// resume emits a newer event sequence, so it still reopens the run.
|
|
3309
|
+
const incomingRuns = incomingShard.workRuns || [];
|
|
3310
|
+
const previousRuns = existingShardBacked && !branchPageReplacement
|
|
3311
|
+
? (this.hydrateConversationEntry(existingEntry), existingEntry.workRuns || [])
|
|
3312
|
+
: [];
|
|
2950
3313
|
preferred.workRuns = branchPageReplacement
|
|
2951
|
-
? this.normalizeWorkRuns(
|
|
2952
|
-
: this.normalizeWorkRuns([...
|
|
2953
|
-
preferred.continuations = branchPageReplacement
|
|
2954
|
-
|
|
3314
|
+
? this.normalizeWorkRuns(incomingRuns)
|
|
3315
|
+
: this.normalizeWorkRuns([...previousRuns, ...incomingRuns]);
|
|
3316
|
+
preferred.continuations = branchPageReplacement
|
|
3317
|
+
? this.normalizeContinuations(incomingShard.continuations || [])
|
|
3318
|
+
: this.mergeConversationContinuations(incomingShard.continuations || [], Number(existing.continuationCount) || 0, existingShardBacked, existingEntry, Number(incoming.continuationRevision) || 0, Number(existing.continuationRevision) || 0);
|
|
3319
|
+
if (branchPageReplacement)
|
|
3320
|
+
preferred.branches = [...(incomingShard.branches || [])];
|
|
3321
|
+
else if (winningHeavy('branches') !== undefined)
|
|
3322
|
+
preferred.branches = winningHeavy('branches');
|
|
2955
3323
|
preferred.activeBranchId = branchPageReplacement ? String(incoming.activeBranchId || '') : preferred.activeBranchId;
|
|
2956
3324
|
preferred.activeBranchGroupId = branchPageReplacement ? String(incoming.activeBranchGroupId || '') : preferred.activeBranchGroupId;
|
|
2957
|
-
preferred.tree =
|
|
3325
|
+
preferred.tree = winningHeavy('tree') || preferred.tree;
|
|
2958
3326
|
preferred.branchReset = branchPageReplacement;
|
|
3327
|
+
if (priorPayload) {
|
|
3328
|
+
for (const field of Agent.CONVERSATION_SHARD_FIELDS) {
|
|
3329
|
+
if (field === 'chatMessages' || field === 'history')
|
|
3330
|
+
continue;
|
|
3331
|
+
if (preferred[field] === undefined && priorPayload[field] !== undefined) {
|
|
3332
|
+
preferred[field] = priorPayload[field];
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
delete preferred.shard;
|
|
3337
|
+
delete preferred.shardBytes;
|
|
2959
3338
|
merged[key] = preferred;
|
|
2960
3339
|
}
|
|
2961
3340
|
return {
|
|
@@ -2966,6 +3345,17 @@ class Agent {
|
|
|
2966
3345
|
conversations: merged,
|
|
2967
3346
|
};
|
|
2968
3347
|
});
|
|
3348
|
+
// A deleted conversation must not leave its transcript shard behind.
|
|
3349
|
+
for (const key of deletedKeys) {
|
|
3350
|
+
const shardFile = this.conversationShardPath(String(key), ws);
|
|
3351
|
+
if (!shardFile)
|
|
3352
|
+
continue;
|
|
3353
|
+
try {
|
|
3354
|
+
fs.unlinkSync(shardFile);
|
|
3355
|
+
}
|
|
3356
|
+
catch { }
|
|
3357
|
+
this.conversationShardFingerprint.delete(shardFile);
|
|
3358
|
+
}
|
|
2969
3359
|
stopTimer();
|
|
2970
3360
|
}
|
|
2971
3361
|
mutateStoredConversationState(ws, mutate, select) {
|
|
@@ -2975,9 +3365,15 @@ class Agent {
|
|
|
2975
3365
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
2976
3366
|
const lock = `${file}.lock`;
|
|
2977
3367
|
let lockHandle = null;
|
|
2978
|
-
for (let attempt = 0; attempt <
|
|
3368
|
+
for (let attempt = 0; attempt < 400; attempt += 1) {
|
|
2979
3369
|
try {
|
|
2980
3370
|
lockHandle = fs.openSync(lock, 'wx');
|
|
3371
|
+
// Record the owner so a lock left behind by a killed runtime can be
|
|
3372
|
+
// reclaimed immediately instead of blocking saves for the age window.
|
|
3373
|
+
try {
|
|
3374
|
+
fs.writeFileSync(lockHandle, String(process.pid), 'utf-8');
|
|
3375
|
+
}
|
|
3376
|
+
catch { }
|
|
2981
3377
|
break;
|
|
2982
3378
|
}
|
|
2983
3379
|
catch (error) {
|
|
@@ -2986,7 +3382,22 @@ class Agent {
|
|
|
2986
3382
|
throw error;
|
|
2987
3383
|
if (code === 'EEXIST') {
|
|
2988
3384
|
try {
|
|
2989
|
-
|
|
3385
|
+
const stat = fs.statSync(lock);
|
|
3386
|
+
const rawOwner = fs.readFileSync(lock, 'utf-8').trim();
|
|
3387
|
+
const ownerPid = Number(rawOwner);
|
|
3388
|
+
const knownOwner = rawOwner.length > 0 && Number.isFinite(ownerPid) && ownerPid > 0;
|
|
3389
|
+
const ownerAlive = knownOwner && (ownerPid === process.pid || (() => {
|
|
3390
|
+
try {
|
|
3391
|
+
process.kill(ownerPid, 0);
|
|
3392
|
+
return true;
|
|
3393
|
+
}
|
|
3394
|
+
catch {
|
|
3395
|
+
return false;
|
|
3396
|
+
}
|
|
3397
|
+
})());
|
|
3398
|
+
if (knownOwner && !ownerAlive)
|
|
3399
|
+
fs.unlinkSync(lock);
|
|
3400
|
+
else if (Date.now() - stat.mtimeMs > 30_000)
|
|
2990
3401
|
fs.unlinkSync(lock);
|
|
2991
3402
|
}
|
|
2992
3403
|
catch { }
|
|
@@ -3011,8 +3422,49 @@ class Agent {
|
|
|
3011
3422
|
contentState.version = 3;
|
|
3012
3423
|
contentState.conversations = contentState.conversations || {};
|
|
3013
3424
|
const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
3014
|
-
const
|
|
3015
|
-
|
|
3425
|
+
const shardDir = this.workspaceConversationShardDir(ws);
|
|
3426
|
+
const diskConversations = {};
|
|
3427
|
+
const pendingShards = [];
|
|
3428
|
+
for (const [key, rawEntry] of Object.entries(contentState.conversations || {})) {
|
|
3429
|
+
const entry = rawEntry;
|
|
3430
|
+
const snapshot = this.conversationShardSnapshot(entry);
|
|
3431
|
+
let shardFileName = typeof entry.shard === 'string' ? entry.shard : '';
|
|
3432
|
+
let shardBytes = Number(entry.shardBytes) || 0;
|
|
3433
|
+
let messageCount = Number(entry.messageCount) || 0;
|
|
3434
|
+
let historyCount = Number(entry.historyCount) || 0;
|
|
3435
|
+
let workRunCount = Number(entry.workRunCount) || 0;
|
|
3436
|
+
let hasTree = !!entry.hasTree;
|
|
3437
|
+
let hasBranches = !!entry.hasBranches;
|
|
3438
|
+
let continuationCount = Number(entry.continuationCount) || 0;
|
|
3439
|
+
let continuationRevision = Number(entry.continuationRevision) || 0;
|
|
3440
|
+
if (snapshot && shardDir) {
|
|
3441
|
+
const payload = this.conversationEntryForDisk(snapshot);
|
|
3442
|
+
messageCount = Array.isArray(payload.chatMessages) ? payload.chatMessages.length : 0;
|
|
3443
|
+
historyCount = Array.isArray(payload.history) ? payload.history.length : 0;
|
|
3444
|
+
workRunCount = Array.isArray(payload.workRuns) ? payload.workRuns.length : 0;
|
|
3445
|
+
hasTree = !!payload.tree;
|
|
3446
|
+
hasBranches = Array.isArray(payload.branches) && payload.branches.length > 0;
|
|
3447
|
+
continuationCount = Array.isArray(payload.continuations) ? payload.continuations.length : 0;
|
|
3448
|
+
continuationRevision = Number(entry.continuationRevision) || this.continuationRevision;
|
|
3449
|
+
// Shard content is the heavy payload only: no `updatedAt`, no metadata.
|
|
3450
|
+
// A save that only bumps metadata therefore produces a byte-identical
|
|
3451
|
+
// shard and is skipped by the fingerprint check below.
|
|
3452
|
+
const shardContent = JSON.stringify({ version: 1, key, ...payload });
|
|
3453
|
+
shardFileName = shardFileName || this.conversationShardFileName(key);
|
|
3454
|
+
const shardFile = path.join(shardDir, shardFileName);
|
|
3455
|
+
const shardFingerprint = crypto.createHash('sha1').update(shardContent).digest('hex');
|
|
3456
|
+
if (this.conversationShardFingerprint.get(shardFile) !== shardFingerprint) {
|
|
3457
|
+
pendingShards.push({ file: shardFile, content: shardContent });
|
|
3458
|
+
this.conversationShardFingerprint.set(shardFile, shardFingerprint);
|
|
3459
|
+
}
|
|
3460
|
+
shardBytes = Buffer.byteLength(shardContent, 'utf8');
|
|
3461
|
+
}
|
|
3462
|
+
diskConversations[key] = this.conversationIndexEntryForDisk(entry, shardFileName || null, shardBytes, {
|
|
3463
|
+
messageCount, historyCount, workRunCount, hasTree, hasBranches, continuationCount, continuationRevision,
|
|
3464
|
+
});
|
|
3465
|
+
}
|
|
3466
|
+
for (const shard of pendingShards)
|
|
3467
|
+
this.writeAtomicTextFile(shard.file, shard.content);
|
|
3016
3468
|
const content = JSON.stringify({
|
|
3017
3469
|
version: 3,
|
|
3018
3470
|
activeConversationId: contentState.activeConversationId || this.activeConversationId || 'default',
|
|
@@ -3020,6 +3472,27 @@ class Agent {
|
|
|
3020
3472
|
flowSuspensions: contentState.flowSuspensions || undefined,
|
|
3021
3473
|
conversations: diskConversations,
|
|
3022
3474
|
}, null, 2);
|
|
3475
|
+
// Content-fingerprint short circuit: a save whose serialized document is
|
|
3476
|
+
// byte-identical to what this process last wrote needs no temp file, no
|
|
3477
|
+
// fsync and no rename. Repeated saves inside one turn (Build start, kernel
|
|
3478
|
+
// events, work-run bookkeeping) usually collapse into this case.
|
|
3479
|
+
const contentFingerprint = crypto.createHash('sha1').update(content).digest('hex');
|
|
3480
|
+
const knownDiskFingerprint = this.conversationStateCacheFingerprint.get(file);
|
|
3481
|
+
let diskUntouched = false;
|
|
3482
|
+
if (knownDiskFingerprint) {
|
|
3483
|
+
try {
|
|
3484
|
+
const current = fs.statSync(file, { bigint: true });
|
|
3485
|
+
diskUntouched = knownDiskFingerprint === `${current.mtimeNs}:${current.ctimeNs}:${current.size}`;
|
|
3486
|
+
}
|
|
3487
|
+
catch {
|
|
3488
|
+
diskUntouched = false;
|
|
3489
|
+
}
|
|
3490
|
+
}
|
|
3491
|
+
if (diskUntouched && this.conversationStateWrittenFingerprint.get(file) === contentFingerprint) {
|
|
3492
|
+
this.conversationStateCache.set(file, contentState);
|
|
3493
|
+
this.conversationStateLastWriteAt.set(file, Date.now());
|
|
3494
|
+
return select ? select(contentState) : undefined;
|
|
3495
|
+
}
|
|
3023
3496
|
try {
|
|
3024
3497
|
const handle = fs.openSync(temp, 'w');
|
|
3025
3498
|
try {
|
|
@@ -3034,6 +3507,8 @@ class Agent {
|
|
|
3034
3507
|
try {
|
|
3035
3508
|
fs.renameSync(temp, file);
|
|
3036
3509
|
this.conversationStateCache.set(file, contentState);
|
|
3510
|
+
this.conversationStateWrittenFingerprint.set(file, contentFingerprint);
|
|
3511
|
+
this.conversationStateLastWriteAt.set(file, Date.now());
|
|
3037
3512
|
const writtenStat = fs.statSync(file, { bigint: true });
|
|
3038
3513
|
this.conversationStateCacheFingerprint.set(file, `${writtenStat.mtimeNs}:${writtenStat.ctimeNs}:${writtenStat.size}`);
|
|
3039
3514
|
return select ? select(contentState) : undefined;
|
|
@@ -3137,11 +3612,17 @@ class Agent {
|
|
|
3137
3612
|
if (prefix && !key.startsWith(prefix))
|
|
3138
3613
|
continue;
|
|
3139
3614
|
const id = key.slice(prefix.length + 1) || key;
|
|
3615
|
+
// Index-resident counts: listing conversations must never hydrate a
|
|
3616
|
+
// shard, otherwise every UI refresh would parse every transcript.
|
|
3617
|
+
const entry = value;
|
|
3618
|
+
const shardBacked = typeof entry.shard === 'string' && !!entry.shard;
|
|
3140
3619
|
rows.push({
|
|
3141
3620
|
id,
|
|
3142
3621
|
key,
|
|
3143
|
-
title: value.title ||
|
|
3144
|
-
|
|
3622
|
+
title: value.title || (shardBacked
|
|
3623
|
+
? String(entry.title || id)
|
|
3624
|
+
: this.titleFromMessages(value.chatMessages || [], id)),
|
|
3625
|
+
messageCount: Number(value.messageCount) || value.chatMessages?.length || 0,
|
|
3145
3626
|
historyCount: value.history?.length || 0,
|
|
3146
3627
|
updatedAt: value.updatedAt || '',
|
|
3147
3628
|
pinned: !!value.pinned,
|
|
@@ -3615,6 +4096,10 @@ class Agent {
|
|
|
3615
4096
|
&& ((targetGuideNodeId && group.pageGuideIds?.[parentNodeId] === targetGuideNodeId)
|
|
3616
4097
|
|| (!targetGuideNodeId && targetMessageId && group.pageMessageIds?.[parentNodeId] === targetMessageId))));
|
|
3617
4098
|
const branchId = crypto.randomUUID();
|
|
4099
|
+
// dev-0.6.4: 新分支(用户分页编辑与实验性分支交流共用这一事务)必须拿到
|
|
4100
|
+
// 全新的全对话 id。复用节点 id 会让两个分支共享 provider 会话序列、
|
|
4101
|
+
// 压缩历史与本地缓存,正是「跨分支缓存」必须禁止的情形。
|
|
4102
|
+
(0, branchIdentity_1.assertFreshBranchIdentity)(clean || this.activeConversationId || 'default', branchId, Object.keys(tree.nodes));
|
|
3618
4103
|
const branch = this.treeNodeFromEntry(branchId, parentNodeId, index, text, {
|
|
3619
4104
|
...entry,
|
|
3620
4105
|
chatMessages: sourceNode.chatMessages.slice(0, index),
|
|
@@ -3735,6 +4220,10 @@ class Agent {
|
|
|
3735
4220
|
return false;
|
|
3736
4221
|
existing.pinned = !!pinned;
|
|
3737
4222
|
existing.pinnedAt = existing.pinned ? new Date().toISOString() : '';
|
|
4223
|
+
// The pin is a real change: advance `updatedAt` so a concurrent older
|
|
4224
|
+
// snapshot (which ties on the previous timestamp) cannot win the merge and
|
|
4225
|
+
// silently unpin the conversation.
|
|
4226
|
+
existing.updatedAt = new Date().toISOString();
|
|
3738
4227
|
const siblingOrders = Object.entries(stored.conversations)
|
|
3739
4228
|
.filter(([key, value]) => key !== stateKey && !!value.pinned === existing.pinned && Number.isFinite(value.order))
|
|
3740
4229
|
.map(([, value]) => Number(value.order));
|
|
@@ -3761,6 +4250,7 @@ class Agent {
|
|
|
3761
4250
|
return item;
|
|
3762
4251
|
return byId.get(orderedIds[nextIndex++]);
|
|
3763
4252
|
});
|
|
4253
|
+
this.continuationRevision += 1;
|
|
3764
4254
|
this.saveWorkspaceConversationState(true);
|
|
3765
4255
|
return this.conversationContinuations();
|
|
3766
4256
|
}
|
|
@@ -4112,6 +4602,12 @@ class Agent {
|
|
|
4112
4602
|
this.firstAgentResponseStarted = true;
|
|
4113
4603
|
return true;
|
|
4114
4604
|
}
|
|
4605
|
+
// This is a hard durability gate: it verifies the flag by reading the store
|
|
4606
|
+
// back, so the snapshot produced by the current turn must be on disk first.
|
|
4607
|
+
// Intermediate saves are coalesced, which can leave the entry unwritten for
|
|
4608
|
+
// up to one throttle window; flush it synchronously here instead of relying
|
|
4609
|
+
// on timing.
|
|
4610
|
+
this.saveWorkspaceConversationState(true);
|
|
4115
4611
|
const marked = this.mutateStoredConversationState(workspace, latest => {
|
|
4116
4612
|
const current = latest.conversations?.[stateKey];
|
|
4117
4613
|
const firstUser = (current?.chatMessages || []).find(message => message.role === 'user');
|
|
@@ -4173,7 +4669,14 @@ class Agent {
|
|
|
4173
4669
|
if (orderSlots.some(order => !Number.isFinite(order)))
|
|
4174
4670
|
return latest;
|
|
4175
4671
|
orderSlots.sort((a, b) => a - b);
|
|
4176
|
-
|
|
4672
|
+
const reorderedAt = new Date().toISOString();
|
|
4673
|
+
normalized.forEach((id, index) => {
|
|
4674
|
+
const entry = entryById.get(id);
|
|
4675
|
+
entry.order = orderSlots[index];
|
|
4676
|
+
// Same reason as pinning: keep a real change strictly newer than any
|
|
4677
|
+
// snapshot a coalesced save may still be holding.
|
|
4678
|
+
entry.updatedAt = reorderedAt;
|
|
4679
|
+
});
|
|
4177
4680
|
accepted = true;
|
|
4178
4681
|
return latest;
|
|
4179
4682
|
});
|
|
@@ -4289,6 +4792,7 @@ class Agent {
|
|
|
4289
4792
|
subagentState: this.subagents.serialize(),
|
|
4290
4793
|
workRuns: this.normalizeWorkRuns(this.workRuns),
|
|
4291
4794
|
continuations: this.normalizeContinuations(this.continuations),
|
|
4795
|
+
continuationRevision: this.continuationRevision,
|
|
4292
4796
|
modelSelection: this.currentConversationModelSelection(),
|
|
4293
4797
|
flowSelection: this.currentConversationFlowSelection(),
|
|
4294
4798
|
inputMode: this.inputMode,
|
|
@@ -4317,7 +4821,7 @@ class Agent {
|
|
|
4317
4821
|
chatMessages: [...this.chatMessages],
|
|
4318
4822
|
history: [...this.history],
|
|
4319
4823
|
providerUsage: this.conversationProviderUsage(),
|
|
4320
|
-
compressionCache:
|
|
4824
|
+
compressionCache: this.mergedCompressionCacheForPersist(stored.conversations[stateKey]),
|
|
4321
4825
|
branchMailbox: [...this.branchMailbox],
|
|
4322
4826
|
branchCommunication: this.branchCommunicationEnabled,
|
|
4323
4827
|
plan: this.normalizeConversationPlan(this.conversationPlan),
|
|
@@ -4335,6 +4839,7 @@ class Agent {
|
|
|
4335
4839
|
runtimeOwnerId: runtimeOwner.runtimeOwnerId,
|
|
4336
4840
|
runtimeOwnerPid: runtimeOwner.runtimeOwnerPid,
|
|
4337
4841
|
runtimeLifecycleRole: runtimeOwner.runtimeLifecycleRole,
|
|
4842
|
+
continuationRevision: this.continuationRevision,
|
|
4338
4843
|
updatedAt,
|
|
4339
4844
|
};
|
|
4340
4845
|
if (nextEntry.tree || nextEntry.branches?.length) {
|
|
@@ -4362,6 +4867,7 @@ class Agent {
|
|
|
4362
4867
|
this.linkedPlan = { markdown: '', revision: 0 };
|
|
4363
4868
|
this.workRuns = [];
|
|
4364
4869
|
this.continuations = [];
|
|
4870
|
+
this.continuationRevision = 0;
|
|
4365
4871
|
this.mode = 'build';
|
|
4366
4872
|
this.goal = null;
|
|
4367
4873
|
this.titleRequestMessageId = '';
|
|
@@ -4377,7 +4883,7 @@ class Agent {
|
|
|
4377
4883
|
if (saved) {
|
|
4378
4884
|
this.restoreProviderUsage(saved.providerUsage);
|
|
4379
4885
|
this.history = [...saved.history];
|
|
4380
|
-
this.compressionCache =
|
|
4886
|
+
this.compressionCache = this.compressionCacheForBranch(saved.compressionCache);
|
|
4381
4887
|
this.nextCompressionCacheId = Math.max(1, ...this.compressionCache.map(entry => Number(entry.id.replace(/^ctx-cache-/, '')) || 0)) + 1;
|
|
4382
4888
|
this.branchMailbox = (saved.branchMailbox || []).map(message => ({ ...message }));
|
|
4383
4889
|
this.nextBranchMessageSequence = Math.max(1, ...this.branchMailbox.map(message => Number(message.sequence) || 0)) + 1;
|
|
@@ -4388,6 +4894,7 @@ class Agent {
|
|
|
4388
4894
|
this.bindConversationSubagents(this.activeConversationId, saved.subagentState);
|
|
4389
4895
|
this.workRuns = this.normalizeWorkRuns(saved.workRuns);
|
|
4390
4896
|
this.continuations = this.normalizeContinuations(saved.continuations);
|
|
4897
|
+
this.continuationRevision = Number(saved.continuationRevision || 0);
|
|
4391
4898
|
this.restoreConversationModelSelection(saved.modelSelection);
|
|
4392
4899
|
this.restoreConversationFlowSelection(saved.flowSelection);
|
|
4393
4900
|
this.inputMode = this.defaultInputMode();
|
|
@@ -4406,7 +4913,7 @@ class Agent {
|
|
|
4406
4913
|
this.restoreProviderUsage(persisted?.providerUsage);
|
|
4407
4914
|
const repairedHistory = this.repairDanglingToolCalls(persisted?.history ? [...persisted.history] : []);
|
|
4408
4915
|
this.history = repairedHistory.messages;
|
|
4409
|
-
this.compressionCache = persisted?.compressionCache
|
|
4916
|
+
this.compressionCache = this.compressionCacheForBranch(persisted?.compressionCache);
|
|
4410
4917
|
this.nextCompressionCacheId = Math.max(1, ...this.compressionCache.map(entry => Number(entry.id.replace(/^ctx-cache-/, '')) || 0)) + 1;
|
|
4411
4918
|
this.branchMailbox = (persisted?.branchMailbox || []).map(message => ({ ...message }));
|
|
4412
4919
|
this.nextBranchMessageSequence = Math.max(1, ...this.branchMailbox.map(message => Number(message.sequence) || 0)) + 1;
|
|
@@ -4417,6 +4924,7 @@ class Agent {
|
|
|
4417
4924
|
const recoveredWorkRuns = this.recoverPersistedWorkRuns(persisted?.workRuns, persisted?.updatedAt, this.runtimeLifecycle.unexpectedExit);
|
|
4418
4925
|
this.workRuns = recoveredWorkRuns.runs;
|
|
4419
4926
|
this.continuations = this.normalizeContinuations(persisted?.continuations);
|
|
4927
|
+
this.continuationRevision = Number(persisted?.continuationRevision || 0);
|
|
4420
4928
|
this.restoreConversationModelSelection(persisted?.modelSelection);
|
|
4421
4929
|
this.restoreConversationFlowSelection(persisted?.flowSelection);
|
|
4422
4930
|
this.inputMode = this.defaultInputMode();
|
|
@@ -5774,6 +6282,7 @@ class Agent {
|
|
|
5774
6282
|
this.subagents = source.subagents;
|
|
5775
6283
|
this.workRuns = workRuns;
|
|
5776
6284
|
this.continuations = continuations;
|
|
6285
|
+
this.continuationRevision += 1;
|
|
5777
6286
|
this.mode = source.mode || this.mode;
|
|
5778
6287
|
if (source.goal !== undefined)
|
|
5779
6288
|
this.goal = this.restoreGoal(source.goal);
|
|
@@ -6616,7 +7125,9 @@ class Agent {
|
|
|
6616
7125
|
: (memory?.history ?? persisted?.history ?? []);
|
|
6617
7126
|
const messages = this.normalizeConversationChatMessages(sourceMessages, sourceHistory);
|
|
6618
7127
|
const filename = this.writeSessionArchive(messages, this.modeName(), this.model);
|
|
6619
|
-
|
|
7128
|
+
// Materialize the shard-backed entry before cloning: a plain JSON round-trip
|
|
7129
|
+
// of the index record would archive an empty transcript and no branch tree.
|
|
7130
|
+
const archiveEntry = persisted ? JSON.parse(JSON.stringify(this.conversationEntryForDisk(persisted))) : {
|
|
6620
7131
|
title: this.titleFromMessages(messages, clean),
|
|
6621
7132
|
chatMessages: messages,
|
|
6622
7133
|
history: sourceHistory,
|
|
@@ -6628,6 +7139,9 @@ class Agent {
|
|
|
6628
7139
|
continuations: memory?.continuations,
|
|
6629
7140
|
updatedAt: new Date().toISOString(),
|
|
6630
7141
|
};
|
|
7142
|
+
for (const key of ['shard', 'shardBytes', 'messageCount', 'historyCount', 'workRunCount', 'hasTree', 'hasBranches']) {
|
|
7143
|
+
delete archiveEntry[key];
|
|
7144
|
+
}
|
|
6631
7145
|
const manifest = {
|
|
6632
7146
|
version: 2,
|
|
6633
7147
|
kind: 'newmark-conversation-archive',
|
|
@@ -6694,6 +7208,24 @@ class Agent {
|
|
|
6694
7208
|
const cachedStored = this.readStoredConversationState(ws);
|
|
6695
7209
|
const stored = JSON.parse(JSON.stringify(cachedStored || {}));
|
|
6696
7210
|
const persisted = stored.conversations?.[stateKey];
|
|
7211
|
+
// A shard-backed index record keeps `chatMessages` / `history` / `workRuns`
|
|
7212
|
+
// / `tree` behind non-enumerable accessors, and the JSON round-trip above
|
|
7213
|
+
// drops exactly those. Without this the archive of a conversation this
|
|
7214
|
+
// process does not hold in memory (the GUI archives while a utility runtime
|
|
7215
|
+
// owns the transcript) writes an empty payload -- and then deletes the real
|
|
7216
|
+
// transcript from the store. Read the live record's payload into the
|
|
7217
|
+
// detached copy instead.
|
|
7218
|
+
const cachedEntry = (cachedStored.conversations || {})[stateKey];
|
|
7219
|
+
if (persisted && cachedEntry) {
|
|
7220
|
+
for (const field of Agent.CONVERSATION_SHARD_FIELDS) {
|
|
7221
|
+
if (persisted[field] !== undefined)
|
|
7222
|
+
continue;
|
|
7223
|
+
const value = cachedEntry[field];
|
|
7224
|
+
if (value === undefined)
|
|
7225
|
+
continue;
|
|
7226
|
+
persisted[field] = JSON.parse(JSON.stringify(value));
|
|
7227
|
+
}
|
|
7228
|
+
}
|
|
6697
7229
|
if (persisted)
|
|
6698
7230
|
this.normalizeConversationTree(persisted);
|
|
6699
7231
|
const memory = this.workspaceConversations.get(memoryKey);
|
|
@@ -6714,7 +7246,8 @@ class Agent {
|
|
|
6714
7246
|
model: archiveModel,
|
|
6715
7247
|
goal: archiveGoal,
|
|
6716
7248
|
}, archiveDir);
|
|
6717
|
-
|
|
7249
|
+
// Same materialization rule as the synchronous archive path.
|
|
7250
|
+
const archiveEntry = persisted ? JSON.parse(JSON.stringify(this.conversationEntryForDisk(persisted))) : {
|
|
6718
7251
|
title: this.titleFromMessages(messages, clean),
|
|
6719
7252
|
chatMessages: messages,
|
|
6720
7253
|
history: sourceHistory,
|
|
@@ -6726,6 +7259,9 @@ class Agent {
|
|
|
6726
7259
|
continuations: memory?.continuations,
|
|
6727
7260
|
updatedAt: new Date().toISOString(),
|
|
6728
7261
|
};
|
|
7262
|
+
for (const key of ['shard', 'shardBytes', 'messageCount', 'historyCount', 'workRunCount', 'hasTree', 'hasBranches']) {
|
|
7263
|
+
delete archiveEntry[key];
|
|
7264
|
+
}
|
|
6729
7265
|
const manifest = {
|
|
6730
7266
|
version: 2,
|
|
6731
7267
|
kind: 'newmark-conversation-archive',
|
|
@@ -6771,6 +7307,19 @@ class Agent {
|
|
|
6771
7307
|
nextActiveId = latest.activeConversationId || remaining[0] || 'default';
|
|
6772
7308
|
return latest;
|
|
6773
7309
|
});
|
|
7310
|
+
// The index record is gone, so its transcript shard must go with it: an
|
|
7311
|
+
// orphaned shard is dead weight now and a stale transcript source if the
|
|
7312
|
+
// same conversation id is ever written again. The synchronous archive path
|
|
7313
|
+
// deletes shards through `writeStoredConversationState(...deletedKeys)`; the
|
|
7314
|
+
// async path mutates the locked state directly, so it unlinks here.
|
|
7315
|
+
const archivedShardFile = this.conversationShardPath(stateKey, ws);
|
|
7316
|
+
if (archivedShardFile) {
|
|
7317
|
+
try {
|
|
7318
|
+
fs.unlinkSync(archivedShardFile);
|
|
7319
|
+
}
|
|
7320
|
+
catch { /* already gone */ }
|
|
7321
|
+
this.conversationShardFingerprint.delete(archivedShardFile);
|
|
7322
|
+
}
|
|
6774
7323
|
this.workspaceConversations.delete(memoryKey);
|
|
6775
7324
|
const duplicateMemoryKey = `${ws.isInternal ? 'internal' : 'external'}:${path.resolve(ws.path)}::conversation:${clean}`;
|
|
6776
7325
|
this.workspaceConversations.delete(duplicateMemoryKey);
|
|
@@ -7092,7 +7641,7 @@ class Agent {
|
|
|
7092
7641
|
capabilities.add(capability);
|
|
7093
7642
|
}
|
|
7094
7643
|
}
|
|
7095
|
-
const health = this.
|
|
7644
|
+
const health = this.routeController.endpointMetrics(this.deploymentRef(model));
|
|
7096
7645
|
const latencySeconds = Number(model.evaluation?.latency);
|
|
7097
7646
|
const configuredPrivacy = (model.privacy || ['default']).filter(value => value === 'default' || value === 'no_training' || value === 'zdr');
|
|
7098
7647
|
const supportedParameters = new Set((routeMetadata.supported_parameters || []).map(value => String(value).toLowerCase()));
|
|
@@ -7112,6 +7661,7 @@ class Agent {
|
|
|
7112
7661
|
return {
|
|
7113
7662
|
deployment: this.deploymentRef(model),
|
|
7114
7663
|
enabled: model.enabled !== false,
|
|
7664
|
+
unavailableReason: this.isBalanceBlockedDeployment(this.deploymentRef(model)) ? 'balance_blocked' : undefined,
|
|
7115
7665
|
validation: {
|
|
7116
7666
|
level: validation.level,
|
|
7117
7667
|
status: effectiveValidationStatus,
|
|
@@ -7133,7 +7683,7 @@ class Agent {
|
|
|
7133
7683
|
reliability: health.attempts ? health.reliability : 0.5,
|
|
7134
7684
|
toolValidity: health.toolAttempts ? health.toolValidity : undefined,
|
|
7135
7685
|
throughput: health.throughput ?? (model.speed_rating === 'fast' ? 80 : model.speed_rating === 'medium' ? 40 : model.speed_rating === 'slow' ? 15 : undefined),
|
|
7136
|
-
|
|
7686
|
+
circuit: health.circuit,
|
|
7137
7687
|
preference: Number.isFinite(Number(routeMetadata.route_preference)) ? Number(routeMetadata.route_preference) : undefined,
|
|
7138
7688
|
fallbackOnly: !!model.fallback_only,
|
|
7139
7689
|
};
|
|
@@ -7149,40 +7699,6 @@ class Agent {
|
|
|
7149
7699
|
// Routing remains available when metrics-only audit storage is read-only.
|
|
7150
7700
|
}
|
|
7151
7701
|
}
|
|
7152
|
-
loadLearnedRouteFeedback() {
|
|
7153
|
-
try {
|
|
7154
|
-
const file = path.join(this.rootPath, 'routing', 'feedback.jsonl');
|
|
7155
|
-
if (!fs.existsSync(file))
|
|
7156
|
-
return;
|
|
7157
|
-
const lines = fs.readFileSync(file, 'utf-8').split(/\r?\n/).filter(Boolean).slice(-2000);
|
|
7158
|
-
for (const line of lines) {
|
|
7159
|
-
const value = JSON.parse(line);
|
|
7160
|
-
const deployment = value.deployment;
|
|
7161
|
-
if (!deployment?.providerId || !deployment.modelId)
|
|
7162
|
-
continue;
|
|
7163
|
-
if (!['chat', 'coding', 'reasoning', 'long_context', 'vision', 'image_generation', 'tool_use', 'computer_use'].includes(String(value.taskClass)))
|
|
7164
|
-
continue;
|
|
7165
|
-
if (!['manual_switch', 'explicit_rating', 'objective_success'].includes(String(value.source)))
|
|
7166
|
-
continue;
|
|
7167
|
-
if (!Number.isFinite(Number(value.score)) || !Number.isFinite(Number(value.at)))
|
|
7168
|
-
continue;
|
|
7169
|
-
this.autoRouter.recordFeedback(value);
|
|
7170
|
-
}
|
|
7171
|
-
}
|
|
7172
|
-
catch {
|
|
7173
|
-
// A malformed preference log is ignored instead of influencing routing.
|
|
7174
|
-
}
|
|
7175
|
-
}
|
|
7176
|
-
recordRouteFeedbackFor(deployment, taskClass, score, source) {
|
|
7177
|
-
const event = { deployment: { ...deployment }, taskClass, score, source, at: Date.now() };
|
|
7178
|
-
this.autoRouter.recordFeedback(event);
|
|
7179
|
-
try {
|
|
7180
|
-
const directory = path.join(this.rootPath, 'routing');
|
|
7181
|
-
fs.mkdirSync(directory, { recursive: true });
|
|
7182
|
-
fs.appendFileSync(path.join(directory, 'feedback.jsonl'), `${JSON.stringify(event)}\n`, 'utf-8');
|
|
7183
|
-
}
|
|
7184
|
-
catch { }
|
|
7185
|
-
}
|
|
7186
7702
|
allModelNames() {
|
|
7187
7703
|
const names = this.config.allModels().filter(m => m.enabled !== false).map(m => {
|
|
7188
7704
|
const label = m.display || m.name;
|
|
@@ -7197,13 +7713,8 @@ class Agent {
|
|
|
7197
7713
|
const before = this.resolvedDeployment ? deploymentIdentity(this.resolvedDeployment) : '';
|
|
7198
7714
|
const mode = override?.mode || (0, autoRouter_1.normalizeAutoPreference)(this.config.autoSwitchPreference());
|
|
7199
7715
|
const policy = (0, autoRouter_1.defaultRoutePolicy)(mode);
|
|
7200
|
-
|
|
7201
|
-
|
|
7202
|
-
policy.maxQualityLoss = Math.min(1, configuredQualityLoss);
|
|
7203
|
-
}
|
|
7204
|
-
if (typeof override?.maxQualityLoss === 'number' && Number.isFinite(override.maxQualityLoss) && override.maxQualityLoss >= 0) {
|
|
7205
|
-
policy.maxQualityLoss = Math.min(1, override.maxQualityLoss);
|
|
7206
|
-
}
|
|
7716
|
+
// The tendency is guidance for the current-model JEV decision. Eligibility
|
|
7717
|
+
// and explicit cost ceilings remain hard constraints.
|
|
7207
7718
|
const maxExpectedCost = this.config.get('models', 'auto_max_expected_cost_usd');
|
|
7208
7719
|
if (typeof maxExpectedCost === 'number' && Number.isFinite(maxExpectedCost) && maxExpectedCost > 0)
|
|
7209
7720
|
policy.maxExpectedCostUsd = maxExpectedCost;
|
|
@@ -7256,27 +7767,141 @@ class Agent {
|
|
|
7256
7767
|
};
|
|
7257
7768
|
const ownsTransaction = !this.routeTransactionId;
|
|
7258
7769
|
const transactionId = this.routeTransactionId || `route-${crypto.randomUUID()}`;
|
|
7259
|
-
const
|
|
7770
|
+
const estimatedInputTokens = this.estimateContextTokens();
|
|
7771
|
+
const expectedOutputTokens = this.intelligence === 'ultra' || this.intelligence === 'max' ? 32768
|
|
7772
|
+
: this.intelligence === 'xhigh' ? 16384
|
|
7773
|
+
: this.intelligence === 'high' ? 8192
|
|
7774
|
+
: this.intelligence === 'low' ? 2048 : 4096;
|
|
7775
|
+
const routeRequest = {
|
|
7260
7776
|
transactionId,
|
|
7261
|
-
affinityKey: `${this.workspace.current?.path || this.rootPath}\u0000${this.activeConversationId || 'default'}`,
|
|
7262
7777
|
taskText: task,
|
|
7263
|
-
estimatedInputTokens
|
|
7264
|
-
expectedOutputTokens
|
|
7265
|
-
: this.intelligence === 'xhigh' ? 16384
|
|
7266
|
-
: this.intelligence === 'high' ? 8192
|
|
7267
|
-
: this.intelligence === 'low' ? 2048 : 4096,
|
|
7778
|
+
estimatedInputTokens,
|
|
7779
|
+
expectedOutputTokens,
|
|
7268
7780
|
requiredCapabilities: [...requiredCapabilities],
|
|
7269
7781
|
batch: override?.batch === true,
|
|
7782
|
+
};
|
|
7783
|
+
const decision = await this.decideAutoRoute({
|
|
7784
|
+
selection,
|
|
7785
|
+
policy,
|
|
7786
|
+
task,
|
|
7787
|
+
request: routeRequest,
|
|
7788
|
+
candidates: this.autoRouteCandidates(),
|
|
7270
7789
|
});
|
|
7271
7790
|
if (ownsTransaction)
|
|
7272
|
-
this.
|
|
7791
|
+
this.routeController.endTransaction(transactionId);
|
|
7792
|
+
else if (decision.resolvedDeployment)
|
|
7793
|
+
this.routeController.pinTransaction(transactionId, decision.resolvedDeployment);
|
|
7794
|
+
if (decision.resolvedDeployment)
|
|
7795
|
+
this.routeController.claimEndpointAttempt(decision.resolvedDeployment);
|
|
7273
7796
|
this.lastRouteDecision = decision;
|
|
7274
|
-
|
|
7797
|
+
// Auto Router v2 freeze rule: a decision that produced no deployment must
|
|
7798
|
+
// not move the conversation to another model. Keeping the previous resolved
|
|
7799
|
+
// deployment is what makes "Auto off / decision model off never changes my
|
|
7800
|
+
// model" observable behaviour instead of a silent re-selection.
|
|
7801
|
+
if (decision.resolvedDeployment) {
|
|
7802
|
+
this.resolvedDeployment = { ...decision.resolvedDeployment };
|
|
7803
|
+
}
|
|
7804
|
+
else if (decision.finalStatus !== 'no_candidate') {
|
|
7805
|
+
this.resolvedDeployment = null;
|
|
7806
|
+
}
|
|
7275
7807
|
this.pendingAutoAttempts = [];
|
|
7276
7808
|
this.routeAttemptStartedAt = this.resolvedDeployment ? Date.now() : 0;
|
|
7277
7809
|
this.persistRouteDecision(decision);
|
|
7278
7810
|
return !!this.resolvedDeployment && before !== deploymentIdentity(this.resolvedDeployment);
|
|
7279
7811
|
}
|
|
7812
|
+
/**
|
|
7813
|
+
* Auto Router v2 decision pipeline:
|
|
7814
|
+
*
|
|
7815
|
+
* RouteEligibilityGuard → JevDecisionEngine → RouteDecisionValidator → pin
|
|
7816
|
+
*
|
|
7817
|
+
* The current conversation model is the only Auto decision source. If it is
|
|
7818
|
+
* disabled, times out, crashes or returns something the guard cannot accept,
|
|
7819
|
+
* Auto stops without selecting another deployment.
|
|
7820
|
+
*/
|
|
7821
|
+
async decideAutoRoute(input) {
|
|
7822
|
+
const { selection, policy, task, request, candidates } = input;
|
|
7823
|
+
const taskClasses = (0, autoRouter_1.classifyTaskClasses)(task, [...policy.requiredCapabilities, ...request.requiredCapabilities], request.estimatedInputTokens);
|
|
7824
|
+
const stopTimer = (0, performanceDiagnostics_1.performanceTimer)('jev_route_decision', { conversationId: this.activeConversationId || 'default' });
|
|
7825
|
+
try {
|
|
7826
|
+
const configuredTimeout = Number(this.config.get('models', 'jev_timeout_ms'));
|
|
7827
|
+
const jevUsageRequest = this.beginProviderUsageRequest();
|
|
7828
|
+
const jev = this.resolveCurrentModelJevEngine(jevUsageRequest);
|
|
7829
|
+
return await (0, routeDecisionValidator_1.resolveAutoRouteDecision)({
|
|
7830
|
+
selection,
|
|
7831
|
+
policy,
|
|
7832
|
+
taskText: task,
|
|
7833
|
+
request,
|
|
7834
|
+
candidates,
|
|
7835
|
+
taskClasses,
|
|
7836
|
+
intelligence: this.intelligence,
|
|
7837
|
+
engine: jev.engine || undefined,
|
|
7838
|
+
unavailableReason: jev.unavailableReason,
|
|
7839
|
+
timeoutMs: Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : jevDecision_1.DEFAULT_JEV_TIMEOUT_MS,
|
|
7840
|
+
previousDeployment: this.resolvedDeployment,
|
|
7841
|
+
pinnedDeployment: this.routeTransactionId ? this.routeController.transactionPin(request.transactionId) : undefined,
|
|
7842
|
+
policyVersion: this.routeController.version(),
|
|
7843
|
+
});
|
|
7844
|
+
}
|
|
7845
|
+
finally {
|
|
7846
|
+
stopTimer();
|
|
7847
|
+
}
|
|
7848
|
+
}
|
|
7849
|
+
/** Resolve JEV to the current eligible conversation model, never a second model slot. */
|
|
7850
|
+
resolveCurrentModelJevEngine(usageRequest) {
|
|
7851
|
+
const jevEnabled = this.config.get('models', 'jev_enabled') !== false;
|
|
7852
|
+
const decisionEnabled = this.config.autoSwitchEnabled() && jevEnabled;
|
|
7853
|
+
const engine = !decisionEnabled
|
|
7854
|
+
? null
|
|
7855
|
+
: new jevDecision_1.CurrentModelJevDecisionEngine(request => {
|
|
7856
|
+
const eligible = request.candidates;
|
|
7857
|
+
if (!eligible.length)
|
|
7858
|
+
return null;
|
|
7859
|
+
const eligibleByIdentity = new Map(eligible.map(candidate => [
|
|
7860
|
+
`${candidate.deployment.providerId}\u0000${candidate.deployment.modelId}`,
|
|
7861
|
+
candidate,
|
|
7862
|
+
]));
|
|
7863
|
+
const availableModels = eligible.flatMap(candidate => {
|
|
7864
|
+
const model = this.config.findDeployment(candidate.deployment);
|
|
7865
|
+
return model?.provider_url && model.api_key ? [{ candidate, model }] : [];
|
|
7866
|
+
});
|
|
7867
|
+
const preferredDeployment = request.previous?.deployment || this.activeDeployment();
|
|
7868
|
+
const defaultSelection = this.config.getStr('models', 'default_model');
|
|
7869
|
+
const defaultRef = parseDeploymentSelectionValue(defaultSelection);
|
|
7870
|
+
const defaultModel = defaultRef
|
|
7871
|
+
? this.config.findDeployment(defaultRef)
|
|
7872
|
+
: this.config.findModel(defaultSelection);
|
|
7873
|
+
const defaultDeployment = defaultModel ? this.deploymentRef(defaultModel) : null;
|
|
7874
|
+
const preferred = preferredDeployment
|
|
7875
|
+
? eligibleByIdentity.get(`${preferredDeployment.providerId}\u0000${preferredDeployment.modelId}`)
|
|
7876
|
+
: undefined;
|
|
7877
|
+
const configuredDefault = defaultDeployment
|
|
7878
|
+
? eligibleByIdentity.get(`${defaultDeployment.providerId}\u0000${defaultDeployment.modelId}`)
|
|
7879
|
+
: undefined;
|
|
7880
|
+
const selected = availableModels.find(entry => preferred
|
|
7881
|
+
&& entry.candidate.deployment.providerId === preferred.deployment.providerId
|
|
7882
|
+
&& entry.candidate.deployment.modelId === preferred.deployment.modelId)
|
|
7883
|
+
|| availableModels.find(entry => configuredDefault
|
|
7884
|
+
&& entry.candidate.deployment.providerId === configuredDefault.deployment.providerId
|
|
7885
|
+
&& entry.candidate.deployment.modelId === configuredDefault.deployment.modelId)
|
|
7886
|
+
|| availableModels[0];
|
|
7887
|
+
if (!selected)
|
|
7888
|
+
return null;
|
|
7889
|
+
const modelIdentity = `${selected.model.provider_id}/${selected.model.name}`;
|
|
7890
|
+
return {
|
|
7891
|
+
provider: this.modelProvider(selected.model, this.jevProviderCache),
|
|
7892
|
+
model: selected.model.name,
|
|
7893
|
+
modelVersion: `${jevDecision_1.CURRENT_MODEL_JEV_VERSION}:${modelIdentity}`,
|
|
7894
|
+
thinkingCapable: selected.model.thinking === true,
|
|
7895
|
+
onUsage: usage => this.recordProviderUsage(usage, usageRequest),
|
|
7896
|
+
};
|
|
7897
|
+
});
|
|
7898
|
+
return {
|
|
7899
|
+
engine,
|
|
7900
|
+
unavailableReason: !this.config.autoSwitchEnabled()
|
|
7901
|
+
? 'auto_disabled'
|
|
7902
|
+
: 'disabled',
|
|
7903
|
+
};
|
|
7904
|
+
}
|
|
7280
7905
|
shouldExposeToolInterface() {
|
|
7281
7906
|
return true;
|
|
7282
7907
|
}
|
|
@@ -7312,7 +7937,7 @@ class Agent {
|
|
|
7312
7937
|
return null;
|
|
7313
7938
|
}
|
|
7314
7939
|
if (observedDeployment)
|
|
7315
|
-
this.
|
|
7940
|
+
this.routeController.recordEndpointFailure(observedDeployment, observedFailure);
|
|
7316
7941
|
if (this.model === 'auto') {
|
|
7317
7942
|
const current = this.activeDeployment();
|
|
7318
7943
|
if (!current || !this.lastRouteDecision)
|
|
@@ -7330,7 +7955,7 @@ class Agent {
|
|
|
7330
7955
|
currentAttempt.durationMs = Math.max(0, Date.now() - this.routeAttemptStartedAt);
|
|
7331
7956
|
}
|
|
7332
7957
|
if (!this.pendingAutoAttempts.length) {
|
|
7333
|
-
this.pendingAutoAttempts = this.
|
|
7958
|
+
this.pendingAutoAttempts = this.routeController.planAttempts(this.lastRouteDecision, this.autoRouteCandidates(), {
|
|
7334
7959
|
error: failure,
|
|
7335
7960
|
streamCommitted: this.routeStreamCommitted,
|
|
7336
7961
|
sideEffectCommitted: this.routeSideEffectCommitted,
|
|
@@ -7350,7 +7975,7 @@ class Agent {
|
|
|
7350
7975
|
this.lastRouteDecision.attempts.push({ ...next });
|
|
7351
7976
|
this.lastRouteDecision.resolvedDeployment = { ...next.deployment };
|
|
7352
7977
|
this.lastRouteDecision.finalStatus = 'retrying';
|
|
7353
|
-
this.
|
|
7978
|
+
this.routeController.claimEndpointAttempt(next.deployment);
|
|
7354
7979
|
this.routeAttemptStartedAt = Date.now();
|
|
7355
7980
|
this.persistRouteDecision(this.lastRouteDecision);
|
|
7356
7981
|
return current.modelId;
|
|
@@ -7368,8 +7993,9 @@ class Agent {
|
|
|
7368
7993
|
const usable = all.filter(m => !modelConfigIsUnavailable(m) && !this.failedDeploymentsThisRun.has(deploymentIdentity(this.deploymentRef(m))));
|
|
7369
7994
|
if (!usable.length)
|
|
7370
7995
|
return null;
|
|
7371
|
-
|
|
7372
|
-
|
|
7996
|
+
// Operational recovery is independent of the user-facing Auto tendency;
|
|
7997
|
+
// that tendency is supplied only to the current-model JEV decision.
|
|
7998
|
+
const ranked = [...usable].sort((a, b) => this.modelScore(b, 'balanced', false, false) - this.modelScore(a, 'balanced', false, false));
|
|
7373
7999
|
const next = ranked[0];
|
|
7374
8000
|
if (!next?.name)
|
|
7375
8001
|
return null;
|
|
@@ -8301,8 +8927,14 @@ class Agent {
|
|
|
8301
8927
|
stopTotalTimer();
|
|
8302
8928
|
this.processDepth = Math.max(0, this.processDepth - 1);
|
|
8303
8929
|
if (this.processDepth === 0) {
|
|
8930
|
+
// Kernel events coalesce intermediate writes to avoid rewriting the
|
|
8931
|
+
// workspace document on every token. A completed Agent turn is a
|
|
8932
|
+
// durability boundary across GUI, TUI, and one-shot CLI runtimes; do
|
|
8933
|
+
// the terminal write here so callers can safely read or exit as soon
|
|
8934
|
+
// as process() resolves.
|
|
8935
|
+
this.flushConversationState();
|
|
8304
8936
|
if (this.routeTransactionId)
|
|
8305
|
-
this.
|
|
8937
|
+
this.routeController.endTransaction(this.routeTransactionId);
|
|
8306
8938
|
this.routeTransactionId = '';
|
|
8307
8939
|
this.processingConversationId = null;
|
|
8308
8940
|
this.activeProcessAbortController = null;
|
|
@@ -9795,7 +10427,41 @@ class Agent {
|
|
|
9795
10427
|
compressionArchiveScopeKey() {
|
|
9796
10428
|
if (this.isSubagentRuntime || !this.config.getBool('context', 'compression_archive_enabled'))
|
|
9797
10429
|
return null;
|
|
9798
|
-
|
|
10430
|
+
const base = this.workspaceConversationKey();
|
|
10431
|
+
if (!base)
|
|
10432
|
+
return null;
|
|
10433
|
+
// dev-0.6.4: 折叠历史必须按分支节点隔离——用户分页产生的分支与实验性分支
|
|
10434
|
+
// 交流产生的分支共用这一条身份规则,兄弟分支不得互相读取压缩归档。
|
|
10435
|
+
return (0, branchIdentity_1.branchConversationIdentity)(base, this.currentBranchNodeId());
|
|
10436
|
+
}
|
|
10437
|
+
/**
|
|
10438
|
+
* The hot folded-history cache belongs to one branch node. Legacy entries
|
|
10439
|
+
* (created before branch scoping) belong to the root node, which is where
|
|
10440
|
+
* they were produced.
|
|
10441
|
+
*/
|
|
10442
|
+
compressionCacheForBranch(entries) {
|
|
10443
|
+
const branch = String(this.currentBranchNodeId() || '');
|
|
10444
|
+
const root = String(this.conversationTree()?.rootNodeId || '');
|
|
10445
|
+
return (Array.isArray(entries) ? entries : []).flatMap(entry => {
|
|
10446
|
+
const owner = String(entry.branchNodeId || root || '');
|
|
10447
|
+
if (branch && owner && owner !== branch)
|
|
10448
|
+
return [];
|
|
10449
|
+
if (branch && !owner)
|
|
10450
|
+
return [];
|
|
10451
|
+
return [{ ...entry, branchNodeId: owner || branch || undefined, messages: [...entry.messages] }];
|
|
10452
|
+
});
|
|
10453
|
+
}
|
|
10454
|
+
/**
|
|
10455
|
+
* Persist the union of every branch's folded history: the live in-memory
|
|
10456
|
+
* cache only holds the active branch, and the stored entry holds the rest.
|
|
10457
|
+
*/
|
|
10458
|
+
mergedCompressionCacheForPersist(storedEntry) {
|
|
10459
|
+
const branch = String(this.currentBranchNodeId() || '');
|
|
10460
|
+
const others = (storedEntry?.compressionCache || []).filter(entry => {
|
|
10461
|
+
const owner = String(entry.branchNodeId || '');
|
|
10462
|
+
return !!owner && owner !== branch;
|
|
10463
|
+
});
|
|
10464
|
+
return [...others, ...this.compressionCache.map(entry => ({ ...entry, messages: [...entry.messages] }))];
|
|
9799
10465
|
}
|
|
9800
10466
|
coldCompressionEntries() {
|
|
9801
10467
|
const scopeKey = this.compressionArchiveScopeKey();
|
|
@@ -9872,6 +10538,7 @@ class Agent {
|
|
|
9872
10538
|
foldedChars,
|
|
9873
10539
|
model,
|
|
9874
10540
|
fallback,
|
|
10541
|
+
branchNodeId: this.currentBranchNodeId() || undefined,
|
|
9875
10542
|
});
|
|
9876
10543
|
this.nextCompressionCacheId += 1;
|
|
9877
10544
|
const maxEntries = Math.max(0, Math.floor(this.config.getNum('context', 'compression_cache_max') || 8));
|