@visual-z/xeno 0.2.1 → 0.3.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/CHANGELOG.md +9 -0
- package/README.md +106 -26
- package/docs/install.md +51 -13
- package/docs/providers.md +11 -0
- package/package.json +5 -4
- package/src/apps/approve.ts +3 -3
- package/src/apps/commands.ts +38 -5
- package/src/apps/completions.ts +73 -19
- package/src/apps/image-draft.ts +46 -0
- package/src/apps/project-trust.ts +2 -42
- package/src/apps/provider-connect.ts +346 -0
- package/src/apps/render.ts +22 -47
- package/src/apps/resources.ts +5 -2
- package/src/apps/shell.ts +298 -1526
- package/src/apps/startup-input.ts +45 -9
- package/src/apps/startup.ts +128 -86
- package/src/apps/ui/app.ts +1383 -0
- package/src/apps/ui/extension-host.ts +194 -0
- package/src/apps/ui/image-cache.ts +78 -0
- package/src/apps/ui/palette.ts +88 -0
- package/src/apps/ui/project.ts +127 -0
- package/src/apps/ui/standalone.ts +146 -0
- package/src/apps/ui/status-model.ts +68 -0
- package/src/apps/ui/surface.ts +84 -0
- package/src/apps/ui/theme.ts +86 -0
- package/src/core/acceptance.ts +107 -0
- package/src/core/action-defaults.ts +47 -295
- package/src/core/benchmark.ts +2 -2
- package/src/core/branch-receipt.ts +24 -0
- package/src/core/checkpoints.ts +64 -0
- package/src/core/churn.ts +2 -0
- package/src/core/command-artifacts.ts +94 -0
- package/src/core/command-contract.ts +35 -0
- package/src/core/completion-review.ts +21 -5
- package/src/core/context-detail.ts +37 -0
- package/src/core/context-policy.ts +74 -1
- package/src/core/continuation.ts +17 -2
- package/src/core/derive.ts +29 -3
- package/src/core/draft-images.ts +56 -0
- package/src/core/environment.ts +145 -0
- package/src/core/evaluation-coverage.ts +74 -0
- package/src/core/event-validation.ts +80 -5
- package/src/core/events.ts +97 -3
- package/src/core/evidence-links.ts +7 -0
- package/src/core/evidence.ts +13 -3
- package/src/core/execution.ts +8 -2
- package/src/core/experiment.ts +133 -0
- package/src/core/export.ts +9 -0
- package/src/core/failure-recovery.ts +173 -0
- package/src/core/file-precondition.ts +72 -0
- package/src/core/handoff.ts +65 -4
- package/src/core/history-query.ts +38 -2
- package/src/core/history.ts +38 -1
- package/src/core/integration-health.ts +34 -0
- package/src/core/keymap.ts +8 -20
- package/src/core/model-picker.ts +64 -0
- package/src/core/native-eval.ts +25 -0
- package/src/core/note-guidance.ts +34 -0
- package/src/core/observation-view.ts +49 -8
- package/src/core/permissions.ts +24 -2
- package/src/core/process-receipt.ts +105 -0
- package/src/core/process-state.ts +37 -0
- package/src/core/project.ts +45 -18
- package/src/core/prompt-policy.ts +37 -0
- package/src/core/query-eval-contract.ts +14 -0
- package/src/core/read-observation.ts +82 -0
- package/src/core/recovery-corpus.ts +17 -0
- package/src/core/recovery-evaluation.ts +60 -0
- package/src/core/recovery-sources.ts +74 -0
- package/src/core/runtime-target.ts +94 -0
- package/src/core/search-observation.ts +97 -0
- package/src/core/session-tree.ts +147 -0
- package/src/core/settings.ts +26 -10
- package/src/core/skills.ts +44 -0
- package/src/core/sql-query.ts +211 -0
- package/src/core/theme.ts +36 -58
- package/src/core/tool-contract.ts +51 -2
- package/src/core/tool-discovery.ts +87 -0
- package/src/core/tool-failure.ts +60 -0
- package/src/core/tool-mode-protocol.ts +25 -0
- package/src/core/transcript.ts +19 -4
- package/src/core/trial-lifecycle.ts +14 -0
- package/src/core/tui-mode.ts +29 -0
- package/src/core/update-check.ts +97 -0
- package/src/core/version.ts +55 -0
- package/src/core/work-state.ts +49 -3
- package/src/core/working-set.ts +91 -0
- package/src/runtime/auth.ts +2 -2
- package/src/runtime/branch-jobs.ts +36 -0
- package/src/runtime/clipboard.ts +15 -4
- package/src/runtime/command-artifacts.ts +64 -0
- package/src/runtime/environment-tools.ts +60 -0
- package/src/runtime/evidence-notes.ts +4 -2
- package/src/runtime/exec-sessions.ts +206 -61
- package/src/runtime/execution-control.ts +1 -0
- package/src/runtime/extension-ui.ts +22 -30
- package/src/runtime/extensions.ts +95 -16
- package/src/runtime/harness-comparison.ts +27 -7
- package/src/runtime/harness-eval.ts +460 -0
- package/src/runtime/history-cache.ts +57 -0
- package/src/runtime/history-tools.ts +62 -12
- package/src/runtime/integrations.ts +210 -0
- package/src/runtime/loop.ts +1021 -268
- package/src/runtime/model-discovery.ts +208 -0
- package/src/runtime/native-eval-fixtures.ts +33 -0
- package/src/runtime/native-eval.ts +143 -0
- package/src/runtime/oauth.ts +1 -1
- package/src/runtime/provider-connect.ts +109 -0
- package/src/runtime/provider-presets.ts +87 -0
- package/src/runtime/query-eval-verifier.ts +28 -0
- package/src/runtime/recovery-coverage.ts +71 -0
- package/src/runtime/recovery-evidence.ts +76 -0
- package/src/runtime/recovery-fixtures.ts +116 -0
- package/src/runtime/runtime-target.ts +149 -0
- package/src/runtime/tool-broker.ts +137 -0
- package/src/runtime/tool-discovery.ts +57 -0
- package/src/runtime/tool-mode-eval.ts +146 -0
- package/src/runtime/tool-queue.ts +77 -0
- package/src/runtime/tools.ts +628 -183
- package/src/runtime/trial-control.ts +45 -0
- package/src/runtime/update-check.ts +47 -0
- package/src/runtime/window-trial.ts +2 -1
- package/src/runtime/working-notes.ts +33 -3
- package/src/runtime/workspace-lifecycle.ts +97 -0
- package/src/store/acceptance.ts +15 -0
- package/src/store/checkpoint-history.ts +43 -0
- package/src/store/command-artifacts.ts +96 -0
- package/src/store/command.ts +65 -37
- package/src/store/file-checkpoints.ts +104 -0
- package/src/store/file-index.ts +13 -1
- package/src/store/git-checkpoints.ts +67 -0
- package/src/store/observations.ts +61 -20
- package/src/store/process-receipts.ts +34 -0
- package/src/store/query-source.ts +13 -8
- package/src/store/recovery.ts +2 -3
- package/src/store/replay.ts +16 -4
- package/src/store/runtime-target.ts +64 -0
- package/src/store/search.ts +171 -132
- package/src/store/session-archive.ts +4 -0
- package/src/store/source-cancellation.ts +6 -0
- package/src/store/update-cache.ts +36 -0
- package/src/store/workspace-observation.ts +113 -0
- package/src/tui/ansi-spans.ts +126 -0
- package/src/tui/style.ts +19 -16
- package/src/tui/ui/atomic-draft.ts +121 -0
- package/src/tui/ui/canvas.ts +96 -0
- package/src/tui/ui/colors.ts +70 -0
- package/src/tui/ui/composer.ts +199 -0
- package/src/tui/ui/dialog.ts +379 -0
- package/src/tui/ui/draft.ts +23 -0
- package/src/tui/ui/edge.ts +186 -0
- package/src/tui/ui/image-size.ts +40 -0
- package/src/tui/ui/ink.ts +210 -0
- package/src/tui/ui/items.ts +40 -0
- package/src/tui/ui/keys.ts +40 -0
- package/src/tui/ui/layout.ts +74 -0
- package/src/tui/ui/list-model.ts +86 -0
- package/src/tui/ui/list-view.ts +172 -0
- package/src/tui/ui/mascot.ts +67 -0
- package/src/tui/ui/messages.ts +518 -0
- package/src/tui/ui/motion.ts +28 -0
- package/src/tui/ui/paths.ts +10 -0
- package/src/tui/ui/pictures.ts +78 -0
- package/src/tui/ui/screen.ts +421 -0
- package/src/tui/ui/status.ts +271 -0
- package/src/tui/ui/styled.ts +85 -0
- package/src/tui/ui/testing.ts +5 -0
- package/src/tui/ui/tool-card.ts +150 -0
- package/src/apps/extension-ui.ts +0 -330
- package/src/apps/resource-browser.ts +0 -75
- package/src/apps/session-browser.ts +0 -113
- package/src/apps/session-picker.ts +0 -55
- package/src/tui/ansi.ts +0 -67
- package/src/tui/autocomplete.ts +0 -29
- package/src/tui/chat-viewport.ts +0 -47
- package/src/tui/complete.ts +0 -87
- package/src/tui/editor.ts +0 -940
- package/src/tui/host.ts +0 -264
- package/src/tui/input.ts +0 -75
- package/src/tui/keys.ts +0 -378
- package/src/tui/markdown.ts +0 -17
- package/src/tui/render.ts +0 -153
- package/src/tui/select.ts +0 -258
- package/src/tui/settings.ts +0 -44
- package/src/tui/statusline.ts +0 -162
- package/src/tui/system-theme.ts +0 -700
- package/src/tui/terminal.ts +0 -30
- package/src/tui/text.ts +0 -52
- package/src/tui/toolview.ts +0 -386
- package/src/tui/transcript.ts +0 -123
- package/src/tui/tree.ts +0 -471
- package/src/vendor/pi-tui/LICENSE.upstream +0 -21
- package/src/vendor/pi-tui/UPSTREAM.json +0 -50
- package/src/vendor/pi-tui/VENDOR.md +0 -25
- package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
- package/src/vendor/pi-tui/autocomplete.ts +0 -860
- package/src/vendor/pi-tui/colors.ts +0 -367
- package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
- package/src/vendor/pi-tui/components/box.ts +0 -166
- package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
- package/src/vendor/pi-tui/components/editor.ts +0 -2472
- package/src/vendor/pi-tui/components/h-stack.ts +0 -44
- package/src/vendor/pi-tui/components/image.ts +0 -127
- package/src/vendor/pi-tui/components/input.ts +0 -494
- package/src/vendor/pi-tui/components/loader.ts +0 -101
- package/src/vendor/pi-tui/components/markdown.ts +0 -1021
- package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
- package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
- package/src/vendor/pi-tui/components/select-list.ts +0 -273
- package/src/vendor/pi-tui/components/settings-list.ts +0 -328
- package/src/vendor/pi-tui/components/spacer.ts +0 -28
- package/src/vendor/pi-tui/components/stack.ts +0 -154
- package/src/vendor/pi-tui/components/text.ts +0 -107
- package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
- package/src/vendor/pi-tui/components/v-stack.ts +0 -33
- package/src/vendor/pi-tui/editor-component.ts +0 -74
- package/src/vendor/pi-tui/fuzzy.ts +0 -138
- package/src/vendor/pi-tui/index.ts +0 -186
- package/src/vendor/pi-tui/keybindings.ts +0 -320
- package/src/vendor/pi-tui/keys.ts +0 -1401
- package/src/vendor/pi-tui/kill-ring.ts +0 -46
- package/src/vendor/pi-tui/latex.ts +0 -1506
- package/src/vendor/pi-tui/layout-node.ts +0 -51
- package/src/vendor/pi-tui/layout.ts +0 -449
- package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
- package/src/vendor/pi-tui/native/clipboard.h +0 -208
- package/src/vendor/pi-tui/native/darwin/README.md +0 -22
- package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
- package/src/vendor/pi-tui/native/linux/README.md +0 -23
- package/src/vendor/pi-tui/native/linux/build.sh +0 -65
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
- package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
- package/src/vendor/pi-tui/native/napi.h +0 -91
- package/src/vendor/pi-tui/native/win32/README.md +0 -31
- package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
- package/src/vendor/pi-tui/native-modifiers.ts +0 -13
- package/src/vendor/pi-tui/native-module-path.ts +0 -31
- package/src/vendor/pi-tui/native-platform.ts +0 -65
- package/src/vendor/pi-tui/oklab.ts +0 -233
- package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
- package/src/vendor/pi-tui/terminal-colors.ts +0 -91
- package/src/vendor/pi-tui/terminal-image.ts +0 -731
- package/src/vendor/pi-tui/terminal.ts +0 -554
- package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
- package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
- package/src/vendor/pi-tui/tui.ts +0 -1493
- package/src/vendor/pi-tui/undo-stack.ts +0 -28
- package/src/vendor/pi-tui/utils.ts +0 -1385
- package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
- package/src/vendor/pi-tui/word-navigation.ts +0 -117
package/src/runtime/loop.ts
CHANGED
|
@@ -18,39 +18,62 @@
|
|
|
18
18
|
* request.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import type { LogEvent, AssistantBlock, NodeRef, CutReason } from "../core/events.ts";
|
|
21
|
+
import type { LogEvent, AssistantBlock, NodeRef, CutReason, ToolInvocationEvent } from "../core/events.ts";
|
|
22
22
|
import { userInputData, submissionImages, type UserInput, type ImageRef } from "../core/images.ts";
|
|
23
23
|
import { hydrateImages } from "./images.ts";
|
|
24
24
|
import type { Handoff } from "../core/handoff.ts";
|
|
25
25
|
import { boundHandoff, DEFAULT_HANDOFF_TOKENS } from "../core/history.ts";
|
|
26
26
|
import { boundaryPreparation, continuationGuidance } from "../core/continuation.ts";
|
|
27
|
-
import type { FrozenPrefix, Message } from "../core/project.ts";
|
|
28
|
-
import { projectContext } from "../core/project.ts";
|
|
27
|
+
import type { FrozenPrefix, Message, ProjectInput } from "../core/project.ts";
|
|
28
|
+
import { projectContext, PROJECTION_VERSION } from "../core/project.ts";
|
|
29
29
|
import { deriveHandoff, carryForward } from "../core/derive.ts";
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
30
|
+
import { latestObservedFileVersion } from "../core/file-precondition.ts";
|
|
31
|
+
import { latestProcessReceipt } from "../core/process-receipt.ts";
|
|
32
|
+
import { advanceRecovery, emptyRecoveryState, recoveryReview, type RecoveryState } from "../core/failure-recovery.ts";
|
|
33
|
+
import { shouldStopAndAsk, type BoundaryConfig } from "../core/boundary.ts";
|
|
34
|
+
import {
|
|
35
|
+
contextLimits, decideContext, DEFAULT_CONTEXT_POLICY, estimateBoundaryCost, isContextPolicy,
|
|
36
|
+
type ContextLimits, type ContextPolicy,
|
|
37
|
+
} from "../core/context-policy.ts";
|
|
38
|
+
import { retainedContext, selectRecentContext } from "../core/context-window.ts";
|
|
33
39
|
import { advanceStall, emptyStallState, stallDecision, DEFAULT_STALL_LIMIT, type StallState } from "../core/stall.ts";
|
|
34
40
|
import { DEFAULT_OUTPUT_RECOVERY_LIMIT, isCompleteResponse, incompleteToolText, knownUnexecutedTool, outputRecoveryNotice, validateOutputRecoveryLimit } from "../core/response-recovery.ts";
|
|
35
|
-
import { join } from "node:path";
|
|
41
|
+
import { join, resolve } from "node:path";
|
|
36
42
|
import { randomUUID } from "node:crypto";
|
|
37
43
|
import { pendingToolCalls } from "../core/execution.ts";
|
|
44
|
+
import { declaredToolNames, recordedDeclarations } from "../core/tool-discovery.ts";
|
|
45
|
+
import { DISCOVERY_TOOL, DISCOVERY_TOOL_NAME, currentDeferredTools, discoveryFingerprint, discoveryPermitted, searchDeferredTools } from "./tool-discovery.ts";
|
|
38
46
|
import { executionOutcome, modelOutcome, type ExecutionOutcome } from "../core/outcome.ts";
|
|
47
|
+
import { commandCompletion } from "../core/command-contract.ts";
|
|
39
48
|
import { ExecutionControl } from "./execution-control.ts";
|
|
49
|
+
import { BranchJobs } from "./branch-jobs.ts";
|
|
50
|
+
import { HistoryCache } from "./history-cache.ts";
|
|
51
|
+
import { RuntimeTargets } from "./runtime-target.ts";
|
|
52
|
+
import { ToolBroker, MAX_NESTED_ARGUMENT_BYTES, MAX_INLINE_RESULT_DATA_BYTES, type BrokerReceipt, type BrokerExecution } from "./tool-broker.ts";
|
|
53
|
+
import { ToolQueue } from "./tool-queue.ts";
|
|
54
|
+
import { NATIVE_COMMAND_NAME, nativeCommandContext, nativeCommandResult, type NativeCommandInput, type NativeCommandResult } from "./tools.ts";
|
|
55
|
+
import { isJsonValue, type JsonValue } from "../core/tool-contract.ts";
|
|
56
|
+
import { archiveObservation } from "../store/observations.ts";
|
|
57
|
+
import { disposeExecSessions, runningSessions } from "./exec-sessions.ts";
|
|
58
|
+
import { branchStatus, processStatus, deriveProcesses, type ProcessObservation } from "../core/process-state.ts";
|
|
40
59
|
import { schedulingWindow } from "../core/scheduling.ts";
|
|
41
60
|
import { estimateTokens, anchoredCount, scaledCounter, type TokenCounter, type Anchor } from "../core/tokens.ts";
|
|
42
61
|
import { isContextOverflow } from "../core/overflow.ts";
|
|
43
62
|
import { completionReview } from "../core/completion-review.ts";
|
|
44
63
|
import { churnReview } from "../core/churn.ts";
|
|
64
|
+
import { deriveBranchReceipt } from "../core/branch-receipt.ts";
|
|
65
|
+
import { skillTools, type Skill } from "../core/skills.ts";
|
|
66
|
+
import type { WorkItem } from "../core/work-state.ts";
|
|
67
|
+
import { parseAcceptanceContract, type AcceptanceContract, type AcceptanceState } from "../core/acceptance.ts";
|
|
45
68
|
import { deadlineNotice } from "../core/deadline.ts";
|
|
46
69
|
import { statusOf } from "./retry.ts";
|
|
47
70
|
import { assertNever } from "../core/exhaustive.ts";
|
|
48
71
|
import { isLogEvent } from "../core/event-validation.ts";
|
|
49
72
|
import {
|
|
50
73
|
initNode, appendEvent, readNode, closeNode, digestNode, handoffPath, nodePath, manifestPath,
|
|
51
|
-
listNodes, readCursor, readCursorAt, writeCursor,
|
|
74
|
+
listNodes, readManifest, readCursor, readCursorAt, writeCursor,
|
|
52
75
|
} from "../store/log.ts";
|
|
53
|
-
import { replayContext } from "../store/replay.ts";
|
|
76
|
+
import { replayContext, carriedHandoff } from "../store/replay.ts";
|
|
54
77
|
import { recoverCut } from "../store/recovery.ts";
|
|
55
78
|
import { runHistoryTool } from "./history-tools.ts";
|
|
56
79
|
import { prepareWorkingNote } from "./working-notes.ts";
|
|
@@ -61,10 +84,11 @@ import {
|
|
|
61
84
|
type FileSet, type RestoreResult,
|
|
62
85
|
} from "../store/workspace.ts";
|
|
63
86
|
import { putBlob, getBlob, putFileBlob } from "../store/blobs.ts";
|
|
87
|
+
import { hydrateProcessReceipts } from "../store/process-receipts.ts";
|
|
64
88
|
import { readNodeHistory, planRestoreToNode, applyRestorePlan } from "../store/restore.ts";
|
|
65
89
|
import { createWorktree, removeWorktree } from "../store/worktree.ts";
|
|
66
90
|
import {
|
|
67
|
-
|
|
91
|
+
parseNote, matchGate, branchTools, finishToolResult, prepareNormalizedTool, presentObservation, toolDefinition, modelToolSchema, adviceTools,
|
|
68
92
|
BRANCH_TOOL_NAMES, WRITING_BRANCH_TOOL_NAMES,
|
|
69
93
|
type ToolContext, type Annotation, type ToolResult,
|
|
70
94
|
} from "./tools.ts";
|
|
@@ -79,6 +103,10 @@ import { resolveHistoryPosition } from "../store/session-position.ts";
|
|
|
79
103
|
import { observeCommand } from "../store/command.ts";
|
|
80
104
|
import { headTailView } from "../core/observation-view.ts";
|
|
81
105
|
import { withProgress } from "./progress.ts";
|
|
106
|
+
import { WorkspaceLifecycle } from "./workspace-lifecycle.ts";
|
|
107
|
+
import { changedPaths, sameVersion, type CheckpointEvent, type FileChanges } from "../core/checkpoints.ts";
|
|
108
|
+
import { readEditableFile, preflightChanges, applyFileChanges } from "../store/file-checkpoints.ts";
|
|
109
|
+
import { planCheckpointRestore } from "../store/checkpoint-history.ts";
|
|
82
110
|
|
|
83
111
|
/** Ids and timestamps come from here so the loop stays the only impure part. */
|
|
84
112
|
export interface Clock {
|
|
@@ -156,6 +184,18 @@ export const DEFAULT_BRANCH_LIMITS: BranchLimits = {
|
|
|
156
184
|
};
|
|
157
185
|
|
|
158
186
|
export interface SessionConfig {
|
|
187
|
+
/** P.3: legacy full snapshots are an explicit compatibility mode, never the CLI default. */
|
|
188
|
+
workspaceTracking?: "journal" | "legacy";
|
|
189
|
+
shellCheckpoints?: boolean;
|
|
190
|
+
skills?: readonly Skill[];
|
|
191
|
+
acceptanceContract?: AcceptanceContract | null;
|
|
192
|
+
strictVerification?: boolean;
|
|
193
|
+
/** O.10: opt-in carried view; legacy sessions and evaluations retain D20. */
|
|
194
|
+
workingSet?: boolean;
|
|
195
|
+
/** R9: CLI opts into source-priority views; absent preserves v1 experiment/old-session behavior. */
|
|
196
|
+
workingSetVersion?: 1 | 2;
|
|
197
|
+
/** R10: new CLI guidance; absence retains old carried/prefix wording on resume. */
|
|
198
|
+
guidanceVersion?: 1;
|
|
159
199
|
logDir: string;
|
|
160
200
|
frozen: FrozenPrefix;
|
|
161
201
|
tools: ToolContext;
|
|
@@ -287,14 +327,28 @@ export type RewindResult =
|
|
|
287
327
|
| { ok: false; error: string };
|
|
288
328
|
|
|
289
329
|
/** O.5: child execution returns facts; only the parent coordinator accepts them. */
|
|
290
|
-
type DeferredToolResult = ToolResult & { accept?: () => Promise<LoopEvent[]
|
|
330
|
+
type DeferredToolResult = ToolResult & { accept?: () => Promise<LoopEvent[]>; annotation?: LogEvent };
|
|
291
331
|
type ToolCall = Extract<AssistantBlock, { type: "tool_use" }>;
|
|
332
|
+
type SessionBrokerReceipt = BrokerReceipt<DeferredToolResult> & { events: LoopEvent[] };
|
|
333
|
+
type PreparedBrokerCall = { run(emit?: (event: LoopEvent) => void): Promise<SessionBrokerReceipt> };
|
|
292
334
|
|
|
293
335
|
export class Session {
|
|
336
|
+
private readonly execOwner = randomUUID();
|
|
337
|
+
private readonly jobs = new BranchJobs<DeferredToolResult>();
|
|
338
|
+
private readonly children = new Map<number, Session>();
|
|
339
|
+
private cleanup: Promise<void> | null = null;
|
|
340
|
+
private closing: Promise<void> | null = null;
|
|
341
|
+
private turnTools: ToolContext | null = null;
|
|
294
342
|
private readonly execution = new ExecutionControl();
|
|
343
|
+
private readonly runtimeTargets = new RuntimeTargets();
|
|
344
|
+
private rootTools: ToolQueue | undefined;
|
|
345
|
+
private hostOwner: ReturnType<ExecutionControl["begin"]> | undefined;
|
|
346
|
+
private readonly hostPending = new Set<Promise<NativeCommandResult>>();
|
|
295
347
|
private stall: StallState | null = null;
|
|
348
|
+
private recovery: RecoveryState | null = null;
|
|
296
349
|
private pendingAdoption: number | null = null;
|
|
297
350
|
private readonly allocatedEventIds = new Set<string>();
|
|
351
|
+
private recordTail: Promise<void> = Promise.resolve();
|
|
298
352
|
readonly config: SessionConfig;
|
|
299
353
|
/**
|
|
300
354
|
* Replaced by switchModel. Not readonly: a live session can change which
|
|
@@ -307,13 +361,36 @@ export class Session {
|
|
|
307
361
|
private readonly allocation: { next: number };
|
|
308
362
|
|
|
309
363
|
constructor(config: SessionConfig, provider: Provider, clock: Clock = systemClock(), allocation = { next: 1 }) {
|
|
364
|
+
if (config.acceptanceContract) parseAcceptanceContract(config.acceptanceContract);
|
|
310
365
|
if (config.contextPolicy !== undefined && !isContextPolicy(config.contextPolicy)) throw new Error("unknown context policy");
|
|
311
366
|
stallDecision(emptyStallState(), config.stallLimit ?? DEFAULT_STALL_LIMIT);
|
|
312
367
|
validateOutputRecoveryLimit(config.outputRecoveryLimit ?? DEFAULT_OUTPUT_RECOVERY_LIMIT);
|
|
313
368
|
schedulingWindow([], 0, config.maxParallelTools ?? 3);
|
|
314
369
|
this.allocation = allocation;
|
|
315
|
-
this.config = config;
|
|
370
|
+
this.config = { ...config, ...(config.acceptanceContract ? { acceptanceContract: parseAcceptanceContract(config.acceptanceContract) } : {}), tools: { ...config.tools, execOwner: this.execOwner, historyCache: new HistoryCache() } };
|
|
371
|
+
// A child or restored Session must never inherit another live invocation scope.
|
|
372
|
+
delete this.config.tools.invoke;
|
|
373
|
+
delete this.config.tools.invokeHost;
|
|
374
|
+
delete this.config.tools.runOwnedCommand;
|
|
375
|
+
delete this.config.tools.drainNested;
|
|
376
|
+
delete this.config.tools.origin;
|
|
377
|
+
delete this.config.tools.parentCallId;
|
|
378
|
+
delete this.config.tools.callId;
|
|
379
|
+
// Branch capability sets are owned by branchTools; a parent's optional catalog is not a grant.
|
|
380
|
+
if ((config.branchDepth ?? 0) > 0) delete this.config.tools.deferredTools;
|
|
316
381
|
this.config.tools.outputStore ??= join(config.logDir, "artifacts");
|
|
382
|
+
// R4 / D8: full ancestry, not the bounded model view or a second mutable ledger.
|
|
383
|
+
// Each child Session installs its own callback even when contexts are copied.
|
|
384
|
+
this.config.tools.observedFileVersion = async absolute => {
|
|
385
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
386
|
+
if (!history.ok) throw new Error(history.error);
|
|
387
|
+
return latestObservedFileVersion(history.nodes.flatMap(node => node.events), absolute, path => resolve(this.config.tools.root, path));
|
|
388
|
+
};
|
|
389
|
+
this.config.tools.observedProcess = async session => {
|
|
390
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
391
|
+
if (!history.ok) throw new Error(history.error);
|
|
392
|
+
return latestProcessReceipt(await hydrateProcessReceipts(history.nodes, this.config.tools.outputStore!), session);
|
|
393
|
+
};
|
|
317
394
|
this.provider = provider;
|
|
318
395
|
this.clock = {
|
|
319
396
|
now: () => clock.now(),
|
|
@@ -333,6 +410,26 @@ export class Session {
|
|
|
333
410
|
set nextNode(value: number) { allocation.next = value; },
|
|
334
411
|
worktrees: new Map(),
|
|
335
412
|
};
|
|
413
|
+
this.installWorkspace();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private installWorkspace(): void {
|
|
417
|
+
delete this.config.tools.workspace;
|
|
418
|
+
this.config.tools.ownedStatePaths = [this.config.logDir, ...(this.config.blobDir ? [this.config.blobDir] : [])];
|
|
419
|
+
this.config.tools.legacyWorkspace = this.config.workspaceTracking === "legacy";
|
|
420
|
+
if (this.config.blobDir && this.config.workspaceTracking !== "legacy") this.config.tools.workspace = new WorkspaceLifecycle(
|
|
421
|
+
this.config.tools.root, this.config.blobDir, this.config.shellCheckpoints === true,
|
|
422
|
+
event => this.record({ ...event, id: this.clock.nextId(), at: this.clock.now() }),
|
|
423
|
+
join(this.config.logDir, "checkpoints"),
|
|
424
|
+
this.config.tools.ownedStatePaths,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private async observeEvidence(): Promise<void> {
|
|
429
|
+
const proof = this.state.events.findLast(event => event.t === "tool_result" && event.effect?.kind === "command");
|
|
430
|
+
const prior = proof?.t === "tool_result" && proof.effect?.kind === "command" ? proof.effect.workspace_sha256 :
|
|
431
|
+
this.state.handoff?.state.completed.at(-1)?.evidence.workspace_sha256;
|
|
432
|
+
if (proof || prior || this.config.acceptanceContract) await this.config.tools.workspace?.observe(prior);
|
|
336
433
|
}
|
|
337
434
|
|
|
338
435
|
/**
|
|
@@ -345,7 +442,7 @@ export class Session {
|
|
|
345
442
|
* session quietly different from a continuous one.
|
|
346
443
|
*/
|
|
347
444
|
async start(): Promise<void> {
|
|
348
|
-
this.
|
|
445
|
+
this.resetDerivedToolState();
|
|
349
446
|
const dir = this.config.logDir;
|
|
350
447
|
const model = this.provider.config.spec.id;
|
|
351
448
|
const existing = await listNodes(dir);
|
|
@@ -368,6 +465,11 @@ export class Session {
|
|
|
368
465
|
break;
|
|
369
466
|
|
|
370
467
|
case "resume": {
|
|
468
|
+
this.config.acceptanceContract = null;
|
|
469
|
+
this.config.strictVerification = false;
|
|
470
|
+
this.config.workingSet = false;
|
|
471
|
+
this.config.workingSetVersion = 1;
|
|
472
|
+
delete this.config.guidanceVersion;
|
|
371
473
|
// Logs predating explicit policies meant segmented, regardless of today's default.
|
|
372
474
|
this.config.contextPolicy = "segmented";
|
|
373
475
|
this.config.maxParallelTools = 1;
|
|
@@ -377,7 +479,19 @@ export class Session {
|
|
|
377
479
|
if (plan.cursor.context_ref) {
|
|
378
480
|
const saved = await getBlob(join(dir, "artifacts"), plan.cursor.context_ref);
|
|
379
481
|
if (saved === null) throw new Error("missing frozen session context");
|
|
380
|
-
const context = JSON.parse(saved) as { frozen: FrozenPrefix; boundary?: Partial<BoundaryConfig>; contextPolicy?: unknown; stallLimit?: number; outputRecoveryLimit?: number; guidance?: boolean; gates: Record<string, string>; snapshotIgnore?: readonly string[]; workNamespace?: string; maxParallelTools?: number };
|
|
482
|
+
const context = JSON.parse(saved) as { skills?: Skill[]; acceptanceContract?: AcceptanceContract; strictVerification?: boolean; workingSet?: boolean; workingSetVersion?: 1 | 2; guidanceVersion?: 1; projectionVersion?: number; frozen: FrozenPrefix; boundary?: Partial<BoundaryConfig>; contextPolicy?: unknown; stallLimit?: number; outputRecoveryLimit?: number; guidance?: boolean; gates: Record<string, string>; snapshotIgnore?: readonly string[]; workspaceTracking?: "journal" | "legacy"; shellCheckpoints?: boolean; workNamespace?: string; maxParallelTools?: number };
|
|
483
|
+
if (context.projectionVersion !== undefined && context.projectionVersion !== PROJECTION_VERSION) throw new Error("unsupported saved projection version");
|
|
484
|
+
this.config.workingSet = context.workingSet === true;
|
|
485
|
+
if (context.workingSetVersion !== undefined && ![1, 2].includes(context.workingSetVersion)) throw new Error("unsupported saved working-set version");
|
|
486
|
+
this.config.workingSetVersion = context.workingSetVersion ?? 1;
|
|
487
|
+
if (context.guidanceVersion !== undefined && context.guidanceVersion !== 1) throw new Error("unsupported saved guidance version");
|
|
488
|
+
if (context.guidanceVersion === 1) this.config.guidanceVersion = 1;
|
|
489
|
+
this.config.workspaceTracking = context.workspaceTracking ?? "journal";
|
|
490
|
+
this.config.shellCheckpoints = context.shellCheckpoints === true;
|
|
491
|
+
if (context.acceptanceContract) { parseAcceptanceContract(context.acceptanceContract); this.config.acceptanceContract = context.acceptanceContract; }
|
|
492
|
+
this.config.strictVerification = context.strictVerification === true;
|
|
493
|
+
this.config.skills = context.skills ?? [];
|
|
494
|
+
this.installSkillResources();
|
|
381
495
|
this.config.workNamespace = context.workNamespace ?? "main";
|
|
382
496
|
this.config.maxParallelTools = context.maxParallelTools ?? 1;
|
|
383
497
|
schedulingWindow([], 0, this.config.maxParallelTools);
|
|
@@ -427,7 +541,7 @@ export class Session {
|
|
|
427
541
|
this.state.anchor = null;
|
|
428
542
|
// Compare disk against the durable file projection. Treating current
|
|
429
543
|
// disk as the baseline would erase unlogged effects of a crashed tool.
|
|
430
|
-
if (this.config.blobDir) {
|
|
544
|
+
if (this.config.blobDir && this.config.workspaceTracking === "legacy") {
|
|
431
545
|
const files = await planRestoreToNode(dir, this.state.node);
|
|
432
546
|
if (!files.ok) throw new Error(files.error);
|
|
433
547
|
this.state.lastScan = files.files;
|
|
@@ -447,7 +561,13 @@ export class Session {
|
|
|
447
561
|
return assertNever(plan, "Session.start");
|
|
448
562
|
}
|
|
449
563
|
|
|
450
|
-
|
|
564
|
+
this.installWorkspace();
|
|
565
|
+
if (this.config.workspaceTracking !== "legacy") await this.recoverFileOperations();
|
|
566
|
+
this.restoreDeclaredTools();
|
|
567
|
+
// Consume the original pending receipt before another checkpoint can replace this projection.
|
|
568
|
+
const declarationCut = await this.applyDiscoveredTools();
|
|
569
|
+
await this.refreshRuntimeTargets();
|
|
570
|
+
if (!declarationCut && (cursor?.needs_model_checkpoint || needsReplayCheckpoint(this.state.events, providerReplayKey(this.provider.config.spec), nativeReplay(this.provider.config.spec)))) {
|
|
451
571
|
await this.performCut("model_checkpoint", model);
|
|
452
572
|
}
|
|
453
573
|
await this.saveCursor();
|
|
@@ -461,6 +581,17 @@ export class Session {
|
|
|
461
581
|
*/
|
|
462
582
|
async fork(destLogDir: string, untilEvent?: string, position: "at" | "before" = "at"): Promise<void> {
|
|
463
583
|
await copyAncestryTo(this.config.logDir, destLogDir, this.clock.now(), untilEvent, position);
|
|
584
|
+
if (untilEvent !== undefined && this.config.workspaceTracking !== "legacy") {
|
|
585
|
+
const cursor = await readCursor(destLogDir);
|
|
586
|
+
if (!cursor) throw new Error("missing fork cursor");
|
|
587
|
+
const tip = (await readNode(this.config.logDir, this.state.node)).events.at(-1);
|
|
588
|
+
const filesFrom = { ...await digestedRef(this.config.logDir, this.state.node), ...(tip ? { at_event: tip.id } : {}) };
|
|
589
|
+
const header = (await readNode(destLogDir, cursor.node)).header;
|
|
590
|
+
const event: LogEvent = { t: "rewind", id: this.clock.nextId(), at: this.clock.now(), mode: "context", from: header?.parent ?? filesFrom,
|
|
591
|
+
files_from: filesFrom, prompt: "Forked conversation", restored: [], removed: [], unrecoverable: [], divergence: [] };
|
|
592
|
+
await appendEvent(destLogDir, cursor.node, event);
|
|
593
|
+
await writeCursor(destLogDir, { ...cursor, at_event: event.id, updated_at: event.at });
|
|
594
|
+
}
|
|
464
595
|
}
|
|
465
596
|
|
|
466
597
|
async setThinking(effort: Effort): Promise<void> {
|
|
@@ -468,25 +599,60 @@ export class Session {
|
|
|
468
599
|
await this.saveCursor();
|
|
469
600
|
}
|
|
470
601
|
|
|
471
|
-
/**
|
|
602
|
+
/** Trusted extension commands share the Session owner without inventing a user turn. */
|
|
603
|
+
runExtensionCommand(command: string, args: string[], options?: { cwd?: string; timeout?: number; signal?: AbortSignal }): Promise<NativeCommandResult> {
|
|
604
|
+
if (this.cleanup || this.closing) return Promise.reject(new Error("session is draining or disposed"));
|
|
605
|
+
const input: NativeCommandInput = { argv: [command, ...args], ...(options?.cwd !== undefined ? { cwd: options.cwd } : {}),
|
|
606
|
+
...(options?.timeout !== undefined ? { timeout: options.timeout } : {}) };
|
|
607
|
+
if (!this.execution.running) this.hostOwner = this.execution.begin();
|
|
608
|
+
const signals = [this.execution.signal, options?.signal].filter((signal): signal is AbortSignal => signal !== undefined);
|
|
609
|
+
const signal = AbortSignal.any(signals);
|
|
610
|
+
const result = (async () => {
|
|
611
|
+
const call: ToolCall = { type: "tool_use", call_id: `host-${randomUUID()}`, name: NATIVE_COMMAND_NAME, input };
|
|
612
|
+
const preparation = this.prepareBrokerCall(call, () => 0, false, signal, true);
|
|
613
|
+
let prepared: PreparedBrokerCall;
|
|
614
|
+
for (;;) { const step = await preparation.next(); if (step.done) { prepared = step.value; break; } }
|
|
615
|
+
const receipt = await prepared.run();
|
|
616
|
+
await receipt.commit();
|
|
617
|
+
await this.saveCursor();
|
|
618
|
+
return nativeCommandResult(receipt.value, this.config.tools.outputStore!);
|
|
619
|
+
})();
|
|
620
|
+
this.hostPending.add(result);
|
|
621
|
+
const finished = () => {
|
|
622
|
+
this.hostPending.delete(result);
|
|
623
|
+
if (this.hostPending.size === 0 && this.hostOwner) { this.hostOwner.finish(); this.hostOwner = undefined; }
|
|
624
|
+
};
|
|
625
|
+
void result.then(finished, finished);
|
|
626
|
+
return result;
|
|
627
|
+
}
|
|
628
|
+
|
|
472
629
|
async runUserBash(command: string, excludeFromContext: boolean, signal?: AbortSignal,
|
|
473
630
|
onOutput?: (chunk: Uint8Array) => void): Promise<Extract<LogEvent, { t: "user_bash" }>> {
|
|
631
|
+
if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
|
|
474
632
|
const owned = this.execution.begin(signal);
|
|
633
|
+
let release: (() => void) | undefined;
|
|
475
634
|
try {
|
|
635
|
+
this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
|
|
636
|
+
release = await this.rootTools.acquire("exclusive");
|
|
476
637
|
if (!command.trim()) throw new Error("empty user command");
|
|
477
638
|
for await (const event of this.snapshotTurn([], false, "idle")) {
|
|
478
639
|
if (event.kind === "snapshot_failed") throw new Error(event.reason);
|
|
479
640
|
}
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
641
|
+
const execute = async (): Promise<ToolResult> => {
|
|
642
|
+
const captured = await observeCommand({ command, cwd: this.config.tools.root,
|
|
643
|
+
outputStore: join(this.config.logDir, "artifacts"), maxOutputBytes: this.config.tools.maxOutputBytes,
|
|
644
|
+
timeoutMs: this.config.tools.timeoutMs, signal: owned.signal, ...(onOutput ? { onOutput } : {}) });
|
|
645
|
+
const completion = commandCompletion(captured.exit, captured.stopped, captured.cleanup);
|
|
646
|
+
const preview = headTailView(captured.head, captured.tail, captured.ref.bytes, this.config.tools.maxOutputBytes,
|
|
647
|
+
omitted => `\n[${omitted} bytes omitted; complete output: ${captured.ref.path}]\n`);
|
|
648
|
+
return { ...completion, content: `${preview}\nexit ${captured.exit} · ${completion.outcome.status} · ${completion.outcome.reason}` +
|
|
649
|
+
(captured.cleanup ? `\n${captured.cleanup.message}` : ""),
|
|
650
|
+
content_ref: captured.ref, effect: { kind: "command", cmd: command, exit: captured.exit } };
|
|
651
|
+
};
|
|
652
|
+
const result = this.config.tools.workspace ? await this.config.tools.workspace.command("bash", { command }, execute, owned.signal) : await execute();
|
|
653
|
+
if (result.effect?.kind !== "command" || !result.content_ref || !result.outcome) throw new Error("missing user command observation");
|
|
488
654
|
const event: Extract<LogEvent, { t: "user_bash" }> = { t: "user_bash", id: this.clock.nextId(), at: this.clock.now(),
|
|
489
|
-
command, content, exit:
|
|
655
|
+
command, content: result.content, exit: result.effect.exit, excludeFromContext, outcome: result.outcome, content_ref: result.content_ref };
|
|
490
656
|
await this.record(event);
|
|
491
657
|
for await (const snapshot of this.snapshotTurn([])) {
|
|
492
658
|
if (snapshot.kind === "snapshot_failed") throw new Error(snapshot.reason);
|
|
@@ -497,7 +663,11 @@ export class Session {
|
|
|
497
663
|
await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
|
|
498
664
|
reason: "user command failed before completion; inspect the workspace before retrying", outcome: executionOutcome("execution_error") });
|
|
499
665
|
throw error;
|
|
500
|
-
} finally {
|
|
666
|
+
} finally {
|
|
667
|
+
release?.();
|
|
668
|
+
while (this.hostPending.size > 0) await Promise.allSettled(this.hostPending);
|
|
669
|
+
owned.finish();
|
|
670
|
+
}
|
|
501
671
|
}
|
|
502
672
|
|
|
503
673
|
async userMessages(): Promise<{ id: string; node: number; text: string; images?: readonly ImageRef[] }[]> {
|
|
@@ -515,11 +685,17 @@ export class Session {
|
|
|
515
685
|
const dir = this.config.logDir;
|
|
516
686
|
const users = await this.userMessages();
|
|
517
687
|
const target = await resolveHistoryPosition(dir, eventId, users.some(user => user.id === eventId) ? "before" : "at");
|
|
688
|
+
const previous = (await readNode(dir, this.state.node)).events.at(-1);
|
|
689
|
+
const filesFrom = { ...await digestedRef(dir, this.state.node), ...(previous ? { at_event: previous.id } : {}) };
|
|
518
690
|
for await (const event of this.snapshotTurn([], false, "idle")) {
|
|
519
691
|
if (event.kind === "snapshot_failed") throw new Error(event.reason);
|
|
520
692
|
}
|
|
521
693
|
const node = this.state.nextNode++;
|
|
522
694
|
await initNode(dir, node, target.parent, this.clock.now(), target.basis);
|
|
695
|
+
if (this.config.workspaceTracking !== "legacy") await appendEvent(dir, node, {
|
|
696
|
+
t: "rewind", id: this.clock.nextId(), at: this.clock.now(), mode: "context", from: target.parent ?? filesFrom,
|
|
697
|
+
files_from: filesFrom, prompt: target.editorText ?? "Selected conversation position", restored: [], removed: [], unrecoverable: [], divergence: [],
|
|
698
|
+
});
|
|
523
699
|
const cursor = await readCursor(dir);
|
|
524
700
|
if (!cursor) throw new Error("missing current session cursor");
|
|
525
701
|
const next = { ...cursor, node, next_node: this.state.nextNode, updated_at: this.clock.now(),
|
|
@@ -571,18 +747,47 @@ export class Session {
|
|
|
571
747
|
}
|
|
572
748
|
|
|
573
749
|
/** D6: changing the frozen prefix is an explicit checkpoint, never in-place prompt surgery. */
|
|
574
|
-
async reloadResources(frozen: FrozenPrefix): Promise<boolean> {
|
|
575
|
-
if (JSON.stringify(frozen) === JSON.stringify(this.config.frozen)) return false;
|
|
750
|
+
async reloadResources(frozen: FrozenPrefix, skills = this.config.skills): Promise<boolean> {
|
|
751
|
+
if (JSON.stringify(frozen) === JSON.stringify(this.config.frozen) && JSON.stringify(skills) === JSON.stringify(this.config.skills)) return false;
|
|
576
752
|
if (this.running || this.isHalfRound()) throw new Error("cannot reload resources while a turn is active or a tool call is unpaired");
|
|
577
|
-
const contextRef = await this.persistContext(frozen);
|
|
753
|
+
const contextRef = await this.persistContext(frozen, skills);
|
|
578
754
|
await this.performCut("model_checkpoint", this.provider.config.spec.id, undefined,
|
|
579
755
|
this.provider.config.spec.provider, this.provider.config.effort, contextRef);
|
|
580
756
|
this.config.frozen = frozen;
|
|
757
|
+
if (skills !== undefined) this.config.skills = skills;
|
|
758
|
+
this.installSkillResources();
|
|
581
759
|
this.config.tools.allow = frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" ? [tool.name] : []);
|
|
582
760
|
await this.saveCursor();
|
|
583
761
|
return true;
|
|
584
762
|
}
|
|
585
763
|
|
|
764
|
+
/** Restoring a declaration never installs code: only the current selected local catalog can supply it. */
|
|
765
|
+
private restoreDeclaredTools(): void {
|
|
766
|
+
const declared = new Set(declaredToolNames(this.config.frozen.tools));
|
|
767
|
+
const loaded = currentDeferredTools(this.config.tools).filter(tool => declared.has(tool.name)).map(tool => tool.name);
|
|
768
|
+
if (loaded.length && this.config.tools.allow !== undefined) this.config.tools.allow = [...new Set([...this.config.tools.allow, ...loaded])];
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** D5/D6: consume original discovery receipts only after every invocation has its result. */
|
|
772
|
+
private async applyDiscoveredTools(): Promise<boolean> {
|
|
773
|
+
if (this.isHalfRound() || (this.config.branchDepth ?? 0) > 0 || this.config.tools.extensions?.get(DISCOVERY_TOOL_NAME) !== DISCOVERY_TOOL) return false;
|
|
774
|
+
const declared = new Set(declaredToolNames(this.config.frozen.tools));
|
|
775
|
+
const requested = new Map(recordedDeclarations(this.state.events).map(item => [item.name, item.fingerprint]));
|
|
776
|
+
const additions = currentDeferredTools(this.config.tools).filter(tool => !declared.has(tool.name) &&
|
|
777
|
+
requested.get(tool.name) === discoveryFingerprint(tool) && discoveryPermitted(this.config.tools, tool.name));
|
|
778
|
+
if (additions.length === 0) return false;
|
|
779
|
+
const frozen = { ...this.config.frozen, tools: [...this.config.frozen.tools, ...additions] };
|
|
780
|
+
const contextRef = await this.persistContext(frozen);
|
|
781
|
+
await this.performCut("model_checkpoint", this.provider.config.spec.id, undefined,
|
|
782
|
+
this.provider.config.spec.provider, this.provider.config.effort, contextRef);
|
|
783
|
+
this.config.frozen = frozen;
|
|
784
|
+
this.restoreDeclaredTools();
|
|
785
|
+
// send() restores its base tool scope at the end; keep accepted declarations there too.
|
|
786
|
+
if (this.turnTools?.allow !== undefined) this.turnTools.allow = [...new Set([...this.turnTools.allow, ...additions.map(tool => tool.name)])];
|
|
787
|
+
await this.saveCursor();
|
|
788
|
+
return true;
|
|
789
|
+
}
|
|
790
|
+
|
|
586
791
|
/**
|
|
587
792
|
* The same pairing check switchModel already used. A user message
|
|
588
793
|
* between tool_use and tool_result is the same unpairable request a
|
|
@@ -609,6 +814,7 @@ export class Session {
|
|
|
609
814
|
if (pull === undefined || this.isHalfRound()) return null;
|
|
610
815
|
const text = await pull("after_tools");
|
|
611
816
|
if (text === null || typeof text === "string" && text.length === 0) return null;
|
|
817
|
+
if (this.turnTools) this.config.tools = { ...this.turnTools };
|
|
612
818
|
return text;
|
|
613
819
|
}
|
|
614
820
|
|
|
@@ -619,8 +825,17 @@ export class Session {
|
|
|
619
825
|
* resuming from -- a crash, a kill, a closed laptop -- do not run exit
|
|
620
826
|
* handlers.
|
|
621
827
|
*/
|
|
622
|
-
private async persistContext(frozen = this.config.frozen): Promise<string> {
|
|
828
|
+
private async persistContext(frozen = this.config.frozen, skills = this.config.skills): Promise<string> {
|
|
623
829
|
return putBlob(join(this.config.logDir, "artifacts"), JSON.stringify({
|
|
830
|
+
projectionVersion: PROJECTION_VERSION,
|
|
831
|
+
workspaceTracking: this.config.workspaceTracking ?? "journal",
|
|
832
|
+
shellCheckpoints: this.config.shellCheckpoints === true,
|
|
833
|
+
...(skills?.length ? { skills } : {}),
|
|
834
|
+
...(this.config.acceptanceContract ? { acceptanceContract: this.config.acceptanceContract } : {}),
|
|
835
|
+
...(this.config.strictVerification ? { strictVerification: true } : {}),
|
|
836
|
+
...(this.config.workingSet ? { workingSet: true } : {}),
|
|
837
|
+
...(this.config.workingSet && this.config.workingSetVersion === 2 ? { workingSetVersion: 2 } : {}),
|
|
838
|
+
...(this.config.guidanceVersion === 1 ? { guidanceVersion: 1 } : {}),
|
|
624
839
|
frozen, gates: this.config.tools.gates,
|
|
625
840
|
workNamespace: this.config.workNamespace ?? "main",
|
|
626
841
|
maxParallelTools: this.config.maxParallelTools ?? 3,
|
|
@@ -653,9 +868,194 @@ export class Session {
|
|
|
653
868
|
}
|
|
654
869
|
|
|
655
870
|
private async record(event: LogEvent): Promise<void> {
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
871
|
+
// D8: runtime callers may retain mutable results. The in-memory projection
|
|
872
|
+
// must own the same JSON value that was appended, independent of later edits.
|
|
873
|
+
event = JSON.parse(JSON.stringify(event)) as LogEvent;
|
|
874
|
+
const saved = this.recordTail.then(async () => {
|
|
875
|
+
await appendEvent(this.config.logDir, this.state.node, event);
|
|
876
|
+
this.state.events.push(event);
|
|
877
|
+
if (this.stall !== null) this.stall = advanceStall(this.stall, [{ node: this.state.node, event }]);
|
|
878
|
+
if (this.recovery !== null) this.recovery = advanceRecovery(this.recovery, [event], path => resolve(this.config.tools.root, path));
|
|
879
|
+
});
|
|
880
|
+
this.recordTail = saved.catch(() => {});
|
|
881
|
+
await saved;
|
|
882
|
+
if (event.t === "tool_result" && event.ok && event.effect?.kind === "read") {
|
|
883
|
+
const path = resolve(this.config.tools.root, event.effect.path);
|
|
884
|
+
const skill = this.config.skills?.find(item => item.filePath === path);
|
|
885
|
+
if (skill && !skill.disableModelInvocation) {
|
|
886
|
+
await this.recordSkill(skill, "read", "model");
|
|
887
|
+
this.config.tools.allow = skillTools(this.allowedNames(), skill);
|
|
888
|
+
// Restricted skill scopes cannot grow their capability set by searching a parent directory.
|
|
889
|
+
if (skill.allowedTools !== undefined || skill.disallowedTools !== undefined) this.config.tools.deferredTools = [];
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
private allowedNames(): string[] {
|
|
895
|
+
return this.config.frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" &&
|
|
896
|
+
(this.config.tools.allow === undefined || this.config.tools.allow.includes(tool.name)) ? [tool.name] : []);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
private adviceNames(frozen = this.config.frozen): string[] {
|
|
900
|
+
const declared = frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" ? [tool.name] : []);
|
|
901
|
+
return adviceTools({ ...this.config.tools, advice: declared, ...(frozen === this.config.frozen ? {} : { allow: declared }) });
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
private installSkillResources(): void {
|
|
905
|
+
this.config.tools.skillResources = (this.config.skills ?? []).map(skill => ({ path: skill.filePath,
|
|
906
|
+
text: skill.sourceText ?? skill.body, modelInvocable: !skill.disableModelInvocation && skill.context !== "fork" }));
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
private async recordSkill(skill: Skill, mode: "inline" | "fork" | "read", origin: "user" | "model"): Promise<void> {
|
|
910
|
+
const artifact = await putBlob(join(this.config.logDir, "artifacts"), skill.sourceText ?? skill.body);
|
|
911
|
+
await this.record({ t: "skill_loaded", id: this.clock.nextId(), at: this.clock.now(), name: skill.name, path: skill.filePath,
|
|
912
|
+
sha256: artifact, artifact, mode, origin });
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** O.12: main instructions are durable; permissions apply to this invocation only. */
|
|
916
|
+
async *sendSkill(skill: Skill, prompt: UserInput, signal?: AbortSignal): AsyncGenerator<LoopEvent> {
|
|
917
|
+
if (this.running || this.cleanup || this.closing || this.isHalfRound()) throw new Error("a skill requires an idle paired session");
|
|
918
|
+
skill = this.config.skills?.find(item => item.name === skill.name && item.filePath === skill.filePath) ?? skill;
|
|
919
|
+
if (skill.userInvocable === false) throw new Error("this skill cannot be invoked by users");
|
|
920
|
+
const savedTools = this.config.tools;
|
|
921
|
+
this.config.tools = { ...savedTools, allow: skillTools(this.allowedNames(), skill) };
|
|
922
|
+
if (skill.allowedTools !== undefined || skill.disallowedTools !== undefined) this.config.tools.deferredTools = [];
|
|
923
|
+
try {
|
|
924
|
+
await this.recordSkill(skill, skill.context === "fork" ? "fork" : "inline", "user");
|
|
925
|
+
if (skill.context !== "fork") { yield* this.send(prompt, signal); return; }
|
|
926
|
+
const owned = this.execution.begin(signal);
|
|
927
|
+
try {
|
|
928
|
+
await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), text: `Run isolated skill ${skill.name}; source ${skill.filePath}.` });
|
|
929
|
+
const writes = skill.allowedTools?.some(name => this.allowedNames().includes(name) &&
|
|
930
|
+
WRITING_BRANCH_TOOL_NAMES.includes(name) && !BRANCH_TOOL_NAMES.includes(name)) ?? false;
|
|
931
|
+
const result = yield* this.runBranch({ purpose: `skill ${skill.name}`, prompt: userInputData(prompt).text, writes }, 0, owned.signal, skill);
|
|
932
|
+
await result.accept?.();
|
|
933
|
+
await this.record({ t: "extension", id: this.clock.nextId(), at: this.clock.now(), customType: "skill_result", content: result.content,
|
|
934
|
+
display: true, inContext: true });
|
|
935
|
+
await this.saveCursor();
|
|
936
|
+
yield { kind: "assistant_text", text: result.content };
|
|
937
|
+
yield { kind: "done", outcome: result.outcome ?? executionOutcome("execution_error"), reason: "isolated skill finished" };
|
|
938
|
+
} finally { owned.finish(); }
|
|
939
|
+
} finally { this.config.tools = savedTools; this.restoreDeclaredTools(); }
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** O.8: dispatch and diagnostics share exact source hints, including forks. */
|
|
943
|
+
contextInput(): ProjectInput {
|
|
944
|
+
return { frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events,
|
|
945
|
+
logHint: { manifestPath: manifestPath(this.config.logDir),
|
|
946
|
+
...(this.config.guidanceVersion === 1 ? { scopedGuidance: true } : {}),
|
|
947
|
+
segmentPath: this.state.handoff?.predecessor?.log_ref ?? nodePath(this.config.logDir, this.state.node) } };
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
private async boundCarried(full: Handoff, tools = this.adviceNames()): Promise<Handoff> {
|
|
951
|
+
return carriedHandoff(this.config.logDir, this.state.node, full, { handoff_tokens: DEFAULT_HANDOFF_TOKENS,
|
|
952
|
+
...(this.config.workingSet ? { working_set_version: this.config.workingSetVersion ?? 1 } : {}),
|
|
953
|
+
...(this.config.guidanceVersion === 1 ? { guidance_tools: tools } : {}) });
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
async acceptanceState(): Promise<AcceptanceState | undefined> {
|
|
957
|
+
if (!this.running) await this.observeEvidence();
|
|
958
|
+
// External editor changes must invalidate proof before the user reads it.
|
|
959
|
+
if (!this.running) for await (const result of this.snapshotTurn([], false, "idle")) {
|
|
960
|
+
if (result.kind === "snapshot_failed") throw new Error(`cannot inspect an unobserved workspace: ${result.reason}`);
|
|
961
|
+
}
|
|
962
|
+
if (!this.running) await this.refreshRuntimeTargets();
|
|
963
|
+
return (await this.currentHandoff()).acceptance;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** Only called at paired, owned boundaries; probes never enter a pending model tool exchange. */
|
|
967
|
+
private async refreshRuntimeTargets(): Promise<void> {
|
|
968
|
+
const state = (await this.currentHandoff()).acceptance;
|
|
969
|
+
const observations: string[] = [];
|
|
970
|
+
for (const target of state?.targets ?? []) if (target.observation || target.binding) {
|
|
971
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
972
|
+
if (!history.ok) throw Error(history.error);
|
|
973
|
+
const observed = await this.runtimeTargets.perform(this.config.tools, history.nodes, { action: "inspect", target: target.id });
|
|
974
|
+
const id = this.clock.nextId();
|
|
975
|
+
await this.record({ t: "target_observation", id, at: this.clock.now(), observation: observed.observation });
|
|
976
|
+
observations.push(id);
|
|
977
|
+
}
|
|
978
|
+
if (!observations.length) return;
|
|
979
|
+
const current = (await this.currentHandoff()).acceptance;
|
|
980
|
+
if (current?.targets?.some(target => {
|
|
981
|
+
const before = state?.targets?.find(item => item.id === target.id);
|
|
982
|
+
return before?.status !== target.status || before.binding !== target.binding || before.build_event !== target.build_event;
|
|
983
|
+
})) await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "runtime_acceptance", sources: observations,
|
|
984
|
+
text: `Runtime acceptance observations changed. Status ${current.status}; targets ${JSON.stringify(current.targets)}. Only a ready owned instance can receive human acceptance; old observations do not grant current ownership.` });
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
async workItems(): Promise<readonly WorkItem[]> {
|
|
988
|
+
const replayed = await replayContext(this.config.logDir, this.state.node);
|
|
989
|
+
return deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events, depth: replayed.depth,
|
|
990
|
+
self: await this.nodeRefFor(this.state.node) }).state.work_items ?? [];
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/** What the next handoff would carry if the segment were cut now. Pure derivation; nothing is written. */
|
|
994
|
+
async previewHandoff(): Promise<Handoff> { return this.currentHandoff(); }
|
|
995
|
+
|
|
996
|
+
private async currentHandoff(): Promise<Handoff> {
|
|
997
|
+
const replayed = await replayContext(this.config.logDir, this.state.node);
|
|
998
|
+
return deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events, depth: replayed.depth, self: await this.nodeRefFor(this.state.node) });
|
|
999
|
+
}
|
|
1000
|
+
async branchStatuses() {
|
|
1001
|
+
return (await this.currentHandoff()).branches.map(item => ({ ...item, status: branchStatus(item, this.jobs.state(item.node)) }));
|
|
1002
|
+
}
|
|
1003
|
+
async processStatuses() {
|
|
1004
|
+
const handles = new Map(runningSessions(this.execOwner).map(session => [session.id, session.exit === null ? "running" as const : "ended_unobserved" as const]));
|
|
1005
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
1006
|
+
if (!history.ok) throw new Error(history.error);
|
|
1007
|
+
let processes: ProcessObservation[] = [];
|
|
1008
|
+
for (const part of await hydrateProcessReceipts(history.nodes, this.config.tools.outputStore!)) processes = deriveProcesses(processes, part.events, part.node);
|
|
1009
|
+
return processes.map(item => ({ ...item, status: processStatus(item, handles.get(item.session)) }));
|
|
1010
|
+
}
|
|
1011
|
+
/** User lifecycle commands share the coordinator; no model request is needed. */
|
|
1012
|
+
async resolveBranch(node: number, resolution: "wait" | "cancel" | "status", waitMs = 10_000): Promise<ToolResult> {
|
|
1013
|
+
if (this.running || this.cleanup || this.closing) throw new Error("branch commands require an idle session");
|
|
1014
|
+
const owned = this.execution.begin();
|
|
1015
|
+
try {
|
|
1016
|
+
const result = await this.runResolve({ node, resolution, wait_ms: waitMs });
|
|
1017
|
+
await this.saveCursor(); return finishToolResult(result);
|
|
1018
|
+
} finally { owned.finish(); }
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
private async drainProcesses(): Promise<void> {
|
|
1022
|
+
const results = await disposeExecSessions(this.execOwner, this.config.tools.outputStore!, this.config.tools.maxOutputBytes);
|
|
1023
|
+
await this.config.tools.workspace?.abandonCommands();
|
|
1024
|
+
for (const result of results) {
|
|
1025
|
+
await this.record({ t: "process_exit", id: this.clock.nextId(), at: this.clock.now(), ...result, reason: "disposed" });
|
|
1026
|
+
}
|
|
1027
|
+
if (results.length > 0) await this.saveCursor();
|
|
1028
|
+
}
|
|
1029
|
+
private async acceptReadyBranches(): Promise<void> {
|
|
1030
|
+
for (const node of this.jobs.nodes()) {
|
|
1031
|
+
const done = await this.jobs.wait(node, 0);
|
|
1032
|
+
if (done?.value?.accept) await done.value.accept();
|
|
1033
|
+
this.jobs.forget(node);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
dispose(): Promise<void> {
|
|
1037
|
+
if (this.closing) return this.closing;
|
|
1038
|
+
this.cancel();
|
|
1039
|
+
this.closing = this.whenIdle().then(() => this.rootTools?.close());
|
|
1040
|
+
return this.closing;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/** Only the app's user command calls this; note never grants human acceptance. */
|
|
1044
|
+
async acceptCriterion(criterion: string): Promise<AcceptanceState> {
|
|
1045
|
+
if (this.running || this.isHalfRound()) throw new Error("acceptance requires an idle, paired request");
|
|
1046
|
+
for await (const result of this.snapshotTurn([], true)) {
|
|
1047
|
+
if (result.kind === "snapshot_failed") throw new Error(`cannot accept an unobserved workspace: ${result.reason}`);
|
|
1048
|
+
}
|
|
1049
|
+
const current = await this.acceptanceState();
|
|
1050
|
+
const item = current?.criteria.find(item => item.id === criterion && item.kind === "human");
|
|
1051
|
+
if (!current || !item || item.kind !== "human") throw new Error("unknown human acceptance criterion");
|
|
1052
|
+
const target = item.target ? current.targets?.find(target => target.id === item.target) : undefined;
|
|
1053
|
+
if (item.target && (target?.status !== "ready" || !target.binding || !target.observation)) throw Error("human acceptance requires a current ready target instance");
|
|
1054
|
+
await this.record({ t: "acceptance_decision", id: this.clock.nextId(), at: this.clock.now(), request: current.request,
|
|
1055
|
+
contract_id: current.contract_id, criterion, actor: "user",
|
|
1056
|
+
...(target?.binding && target.observation ? { target_binding: target.binding, target_observation: target.observation } : {}) });
|
|
1057
|
+
await this.saveCursor();
|
|
1058
|
+
return (await this.acceptanceState())!;
|
|
659
1059
|
}
|
|
660
1060
|
|
|
661
1061
|
async appendExtension(entry: Omit<Extract<LogEvent, { t: "extension" }>, "t" | "id" | "at">): Promise<void> {
|
|
@@ -671,8 +1071,21 @@ export class Session {
|
|
|
671
1071
|
return totalUsage(this.state.usage);
|
|
672
1072
|
}
|
|
673
1073
|
|
|
674
|
-
cancel(): void {
|
|
675
|
-
|
|
1074
|
+
cancel(): void {
|
|
1075
|
+
this.execution.cancel(); this.jobs.cancel();
|
|
1076
|
+
for (const child of this.children.values()) child.cancel();
|
|
1077
|
+
if (!this.cleanup) this.cleanup = (async () => {
|
|
1078
|
+
await this.execution.whenIdle(); await this.jobs.drain();
|
|
1079
|
+
// A consumer can close at the preparation marker before execution is
|
|
1080
|
+
// admitted. Those children still have an owner, even without a job.
|
|
1081
|
+
for (const [node, child] of this.children) { await child.dispose(); this.children.delete(node); }
|
|
1082
|
+
await this.acceptReadyBranches(); await this.drainProcesses();
|
|
1083
|
+
})().then(() => { this.cleanup = null; });
|
|
1084
|
+
// A void cancellation caller may await later. Preserve rejection for the
|
|
1085
|
+
// owner while preventing an unhandled rejection from bypassing that path.
|
|
1086
|
+
void this.cleanup.catch(() => {});
|
|
1087
|
+
}
|
|
1088
|
+
async whenIdle(): Promise<void> { await this.execution.whenIdle(); await this.cleanup; }
|
|
676
1089
|
get running(): boolean { return this.execution.running; }
|
|
677
1090
|
|
|
678
1091
|
/**
|
|
@@ -691,7 +1104,12 @@ export class Session {
|
|
|
691
1104
|
signal?: AbortSignal,
|
|
692
1105
|
pull?: (phase: Phase) => Promise<UserInput | null>,
|
|
693
1106
|
): AsyncGenerator<LoopEvent> {
|
|
1107
|
+
if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
|
|
694
1108
|
const owned = this.execution.begin(signal);
|
|
1109
|
+
const savedTools = this.config.tools;
|
|
1110
|
+
this.turnTools = savedTools;
|
|
1111
|
+
this.config.tools = { ...savedTools };
|
|
1112
|
+
this.installSkillResources();
|
|
695
1113
|
const events = this.run(userText, owned.signal, pull);
|
|
696
1114
|
let terminal = false;
|
|
697
1115
|
let exhausted = false;
|
|
@@ -725,7 +1143,13 @@ export class Session {
|
|
|
725
1143
|
if (!exhausted) {
|
|
726
1144
|
await events.return(undefined);
|
|
727
1145
|
}
|
|
728
|
-
} finally {
|
|
1146
|
+
} finally {
|
|
1147
|
+
while (this.hostPending.size > 0) await Promise.allSettled(this.hostPending);
|
|
1148
|
+
this.config.tools = savedTools; this.turnTools = null;
|
|
1149
|
+
if (owned.signal.aborted) this.cancel();
|
|
1150
|
+
owned.finish();
|
|
1151
|
+
if (owned.signal.aborted) await this.whenIdle();
|
|
1152
|
+
}
|
|
729
1153
|
}
|
|
730
1154
|
}
|
|
731
1155
|
|
|
@@ -734,6 +1158,8 @@ export class Session {
|
|
|
734
1158
|
signal?: AbortSignal,
|
|
735
1159
|
pull?: (phase: Phase) => Promise<UserInput | null>,
|
|
736
1160
|
): AsyncGenerator<LoopEvent> {
|
|
1161
|
+
// A crash after a discovery receipt but before its cut is recovered from that same receipt.
|
|
1162
|
+
if (!signal?.aborted) await this.applyDiscoveredTools();
|
|
737
1163
|
if (needsReplayCheckpoint(this.state.events, providerReplayKey(this.provider.config.spec), nativeReplay(this.provider.config.spec))) {
|
|
738
1164
|
if (this.isHalfRound()) throw new Error("Cannot change a wire profile while tool results are pending");
|
|
739
1165
|
await this.performCut("model_checkpoint");
|
|
@@ -747,6 +1173,8 @@ export class Session {
|
|
|
747
1173
|
await this.record({
|
|
748
1174
|
t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(userText, blocked),
|
|
749
1175
|
});
|
|
1176
|
+
if (this.config.acceptanceContract) await this.record({ t: "acceptance_contract", id: this.clock.nextId(), at: this.clock.now(),
|
|
1177
|
+
request: this.state.events.at(-1)!.id, contract: this.config.acceptanceContract });
|
|
750
1178
|
// Idle changes belong to the new prompt. Recording them before `user`
|
|
751
1179
|
// would silently move the preceding exchange's restore point forward.
|
|
752
1180
|
for await (const event of this.snapshotTurn([])) {
|
|
@@ -774,12 +1202,11 @@ export class Session {
|
|
|
774
1202
|
yield { kind: "turn_start", node: this.state.node };
|
|
775
1203
|
|
|
776
1204
|
if (this.config.guidance !== false) {
|
|
777
|
-
const names = this.
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
});
|
|
781
|
-
const guidance = continuationGuidance({ events: this.state.events, handoff: this.state.handoff,
|
|
1205
|
+
const names = this.adviceNames();
|
|
1206
|
+
const guidance = (this.config.harnessReviews === false ? null : await this.recoveryGuidance(names)) ??
|
|
1207
|
+
continuationGuidance({ events: this.state.events, handoff: this.state.handoff,
|
|
782
1208
|
tools: names, remainingRequests: this.config.maxTurns - turn,
|
|
1209
|
+
...(this.config.guidanceVersion === 1 ? { taskAware: true } : {}),
|
|
783
1210
|
...(this.config.continuationAfter === undefined ? {} : { minAssistantTurns: this.config.continuationAfter }) }) ??
|
|
784
1211
|
(this.config.timeBudgetMs === undefined ? null
|
|
785
1212
|
: deadlineNotice(requestStarted, this.clock.now(), this.config.timeBudgetMs, this.state.events)) ??
|
|
@@ -793,15 +1220,7 @@ export class Session {
|
|
|
793
1220
|
yield { kind: "done", reason: "stopped: context cannot fit the model window", outcome: executionOutcome("context_budget") };
|
|
794
1221
|
return;
|
|
795
1222
|
}
|
|
796
|
-
const projected = projectContext(
|
|
797
|
-
frozen: this.config.frozen,
|
|
798
|
-
handoff: this.state.handoff,
|
|
799
|
-
events: this.state.events,
|
|
800
|
-
logHint: {
|
|
801
|
-
manifestPath: manifestPath(this.config.logDir),
|
|
802
|
-
segmentPath: nodePath(this.config.logDir, Math.max(0, this.state.node - 1)),
|
|
803
|
-
},
|
|
804
|
-
});
|
|
1223
|
+
const projected = projectContext(this.contextInput());
|
|
805
1224
|
|
|
806
1225
|
// Pairing violations are a 400 waiting to happen; surface them here,
|
|
807
1226
|
// where the cause is still visible, rather than as a request failure.
|
|
@@ -836,8 +1255,7 @@ export class Session {
|
|
|
836
1255
|
// shrink is a cut, so force one and send once more; a second
|
|
837
1256
|
// overflow means even the retained tail is misjudged, and another
|
|
838
1257
|
// attempt would only be paid for.
|
|
839
|
-
if (overflowRetried ||
|
|
840
|
-
windowBudget(this.provider.config.spec.contextWindow, this.provider.config.maxTokens) === null) {
|
|
1258
|
+
if (overflowRetried || this.policyLimits().kind !== "window") {
|
|
841
1259
|
yield { kind: "done", reason: `stopped: provider rejected the prompt as too long (${error.message})`, outcome: executionOutcome("context_budget") };
|
|
842
1260
|
return;
|
|
843
1261
|
}
|
|
@@ -939,8 +1357,14 @@ export class Session {
|
|
|
939
1357
|
});
|
|
940
1358
|
continue;
|
|
941
1359
|
}
|
|
1360
|
+
if (this.config.acceptanceContract) await this.refreshRuntimeTargets();
|
|
1361
|
+
const acceptance = this.config.acceptanceContract ? await this.acceptanceState() : undefined;
|
|
942
1362
|
const review = this.config.guidance === false || this.config.harnessReviews === false ? null
|
|
943
|
-
: completionReview(this.state.events, this.config.maxTurns - turn - 1
|
|
1363
|
+
: completionReview(this.state.events, this.config.maxTurns - turn - 1, {
|
|
1364
|
+
...(this.config.guidanceVersion === 1 ? { taskAware: true } : {}),
|
|
1365
|
+
...(this.config.strictVerification ? { strictVerification: true } : {}),
|
|
1366
|
+
...(acceptance ? { acceptance } : {}),
|
|
1367
|
+
});
|
|
944
1368
|
if (review) {
|
|
945
1369
|
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...review });
|
|
946
1370
|
if (yield* this.maybeCut(counter, true)) {
|
|
@@ -959,7 +1383,7 @@ export class Session {
|
|
|
959
1383
|
}
|
|
960
1384
|
|
|
961
1385
|
let branchesThisTurn = 0;
|
|
962
|
-
type Settled = { ok: true; value:
|
|
1386
|
+
type Settled = { ok: true; value: SessionBrokerReceipt } | { ok: false; error: unknown };
|
|
963
1387
|
const pending = new Map<string, Promise<Settled>>();
|
|
964
1388
|
const modes = calls.map(call => this.executionMode(call));
|
|
965
1389
|
try { for (let callIndex = 0; callIndex < calls.length; callIndex++) {
|
|
@@ -975,88 +1399,33 @@ export class Session {
|
|
|
975
1399
|
const planned = calls[index]!;
|
|
976
1400
|
yield { kind: "tool_call", tool: { name: planned.name, input: planned.input } };
|
|
977
1401
|
if (signal?.aborted) break;
|
|
978
|
-
const dispatch = yield* this.
|
|
979
|
-
pending.set(planned.call_id, dispatch().then(value => ({ ok: true as const, value }), error => ({ ok: false as const, error })));
|
|
1402
|
+
const dispatch = yield* this.prepareBrokerCall(planned, () => branchesThisTurn++, blocked, signal);
|
|
1403
|
+
pending.set(planned.call_id, dispatch.run().then(value => ({ ok: true as const, value }), error => ({ ok: false as const, error })));
|
|
980
1404
|
}
|
|
981
1405
|
}
|
|
982
1406
|
|
|
983
|
-
let
|
|
984
|
-
let annotation: LogEvent | undefined;
|
|
1407
|
+
let receipt: SessionBrokerReceipt;
|
|
985
1408
|
const dispatched = pending.get(call.call_id);
|
|
986
1409
|
if (dispatched) {
|
|
987
1410
|
const settled = await dispatched;
|
|
988
1411
|
pending.delete(call.call_id);
|
|
989
1412
|
if (!settled.ok) throw settled.error;
|
|
990
|
-
|
|
1413
|
+
receipt = settled.value;
|
|
991
1414
|
} else {
|
|
992
1415
|
if (signal?.aborted) { yield* this.closeInterrupted(completedThisTurn); return; }
|
|
993
|
-
|
|
994
1416
|
yield { kind: "tool_call", tool: { name: call.name, input: call.input } };
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
// covers abort arriving between calls.
|
|
999
|
-
if (signal?.aborted) {
|
|
1000
|
-
yield* this.closeInterrupted(completedThisTurn);
|
|
1001
|
-
return;
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
// Three tools are not workspace actions and are handled here:
|
|
1005
|
-
// `note` writes an annotation the handoff is built from, and
|
|
1006
|
-
// `branch`/`resolve` act on the tree. Each still produces an ordinary
|
|
1007
|
-
// tool_result so the turn stays pairable. They go through the same
|
|
1008
|
-
// gate as read/write/edit/bash, which runTool applies itself.
|
|
1009
|
-
const toolContext = { ...this.config.tools, imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"), imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}) };
|
|
1010
|
-
if (call.name === "note" || call.name === "branch" || call.name === "resolve" || call.name === "history") {
|
|
1011
|
-
const gated = await gateTool(toolContext, call.name, call.input);
|
|
1012
|
-
if (gated) {
|
|
1013
|
-
out = gated;
|
|
1014
|
-
} else if (call.name === "history") {
|
|
1015
|
-
out = await runHistoryTool(this.config.logDir, this.state.node, toolContext, call.input);
|
|
1016
|
-
} else if (call.name === "note") {
|
|
1017
|
-
const parsed = parseNote(call.input);
|
|
1018
|
-
const prepared = await prepareWorkingNote(this.config.logDir, this.state.node, this.config.workNamespace ?? "main",
|
|
1019
|
-
parsed.annotation ? this.toEvent(parsed.annotation) : undefined, parsed.result);
|
|
1020
|
-
out = prepared.out; annotation = prepared.annotation;
|
|
1021
|
-
} else if (call.name === "branch") {
|
|
1022
|
-
out = yield* this.runBranch(call.input, branchesThisTurn++, signal);
|
|
1023
|
-
} else {
|
|
1024
|
-
out = await this.runResolve(call.input);
|
|
1025
|
-
}
|
|
1026
|
-
} else {
|
|
1027
|
-
const decoder = new TextDecoder();
|
|
1028
|
-
out = yield* withProgress<ToolResult, LoopEvent>(emit => runTool({ ...toolContext, callId: call.call_id,
|
|
1029
|
-
onOutput: chunk => emit({ kind: "progress", progress: { kind: "output", name: call.name, text: decoder.decode(chunk, { stream: true }) } }),
|
|
1030
|
-
}, call.name, call.input));
|
|
1031
|
-
}
|
|
1417
|
+
if (signal?.aborted) { yield* this.closeInterrupted(completedThisTurn); return; }
|
|
1418
|
+
const prepared = yield* this.prepareBrokerCall(call, () => branchesThisTurn++, blocked, signal);
|
|
1419
|
+
receipt = yield* withProgress<SessionBrokerReceipt, LoopEvent>(emit => prepared.run(emit));
|
|
1032
1420
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
//
|
|
1421
|
+
await receipt.settle();
|
|
1422
|
+
for (const event of receipt.events.splice(0)) yield event;
|
|
1423
|
+
const out = finishToolResult(receipt.value);
|
|
1424
|
+
// Observation still precedes persistence: crashes here remain unknown,
|
|
1425
|
+
// while nested receipts have already been committed before their caller resumed.
|
|
1037
1426
|
yield { kind: "tool_result", text: out.content, ok: out.ok, outcome: out.outcome! };
|
|
1427
|
+
await receipt.commit();
|
|
1038
1428
|
|
|
1039
|
-
// Recorded before the tool_result so a claim can never precede the
|
|
1040
|
-
// evidence it cites -- deriveHandoff resolves a claim against runs at
|
|
1041
|
-
// or before it, and an annotation is about work already done.
|
|
1042
|
-
if (annotation) await this.record(annotation);
|
|
1043
|
-
|
|
1044
|
-
await this.record({
|
|
1045
|
-
t: "tool_result",
|
|
1046
|
-
id: this.clock.nextId(),
|
|
1047
|
-
at: this.clock.now(),
|
|
1048
|
-
call_id: call.call_id,
|
|
1049
|
-
ok: out.ok,
|
|
1050
|
-
content: out.content,
|
|
1051
|
-
outcome: out.outcome!,
|
|
1052
|
-
...(out.data !== undefined ? { data: out.data } : {}),
|
|
1053
|
-
...(out.observation_sha256 !== undefined ? { observation_sha256: out.observation_sha256 } : {}),
|
|
1054
|
-
...(out.diagnostic !== undefined ? { diagnostic: out.diagnostic } : {}),
|
|
1055
|
-
...(out.content_sha256 ? { content_sha256: out.content_sha256 } : {}),
|
|
1056
|
-
...(out.content_ref ? { content_ref: out.content_ref } : {}),
|
|
1057
|
-
...(out.images?.length ? { images: submissionImages(out.images, blocked) } : {}),
|
|
1058
|
-
...(out.effect ? { effect: out.effect } : {}),
|
|
1059
|
-
});
|
|
1060
1429
|
completedThisTurn.push(call.call_id);
|
|
1061
1430
|
if (signal?.aborted && pending.size === 0) {
|
|
1062
1431
|
yield* this.closeInterrupted(completedThisTurn);
|
|
@@ -1068,6 +1437,8 @@ export class Session {
|
|
|
1068
1437
|
// that means anything, and per-call would be the character-level
|
|
1069
1438
|
// granularity the research warns against.
|
|
1070
1439
|
yield* this.snapshotTurn(calls.map((c) => c.call_id));
|
|
1440
|
+
const declarationNode = this.state.node;
|
|
1441
|
+
if (!signal?.aborted && await this.applyDiscoveredTools()) yield { kind: "cut", node: declarationNode, reason: "model_checkpoint" };
|
|
1071
1442
|
if (this.pendingAdoption !== null) await this.adoptPendingBranch();
|
|
1072
1443
|
|
|
1073
1444
|
// Between complete exchanges is the only safe place to cut.
|
|
@@ -1111,6 +1482,9 @@ export class Session {
|
|
|
1111
1482
|
id: this.clock.nextId(),
|
|
1112
1483
|
at: this.clock.now(),
|
|
1113
1484
|
call_id: block.call_id,
|
|
1485
|
+
...(block.invocation_id ? { invocation_ref: { node: this.state.node, event: block.invocation_id } } : {}),
|
|
1486
|
+
...(block.parent_call_id ? { parent_call_id: block.parent_call_id } : {}),
|
|
1487
|
+
...(block.origin ? { origin: block.origin } : {}),
|
|
1114
1488
|
ok: false,
|
|
1115
1489
|
content: resultText,
|
|
1116
1490
|
outcome: executionOutcome("unknown"),
|
|
@@ -1200,6 +1574,8 @@ export class Session {
|
|
|
1200
1574
|
* delta rather than a copy of the workspace.
|
|
1201
1575
|
*/
|
|
1202
1576
|
private async *snapshotTurn(callIds: readonly string[], baseline = false, phase: "turn" | "idle" = "turn"): AsyncGenerator<LoopEvent> {
|
|
1577
|
+
// P.3: retained only for explicit historical compatibility. New sessions never scan here.
|
|
1578
|
+
if (this.config.workspaceTracking !== "legacy") return;
|
|
1203
1579
|
const blobDir = this.config.blobDir;
|
|
1204
1580
|
if (!blobDir) return;
|
|
1205
1581
|
|
|
@@ -1278,41 +1654,207 @@ export class Session {
|
|
|
1278
1654
|
* the same thing, so nothing here has to reimplement the loop.
|
|
1279
1655
|
*/
|
|
1280
1656
|
private executionMode(call: ToolCall): "parallel" | "exclusive" {
|
|
1657
|
+
if (this.config.tools.extensionHooks?.serial?.()) return "exclusive";
|
|
1658
|
+
if (call.name === "read" && typeof call.input === "object" && call.input !== null &&
|
|
1659
|
+
this.config.skills?.some(skill => skill.filePath === resolve(this.config.tools.root, String((call.input as Record<string, unknown>)["path"])))) return "exclusive";
|
|
1281
1660
|
if (call.name === "branch" && typeof call.input === "object" && call.input !== null &&
|
|
1282
|
-
(call.input as Record<string, unknown>)["writes"] !== true) return "parallel";
|
|
1661
|
+
(call.input as Record<string, unknown>)["writes"] !== true && (call.input as Record<string, unknown>)["background"] !== true) return "parallel";
|
|
1283
1662
|
return toolDefinition(call.name, this.config.tools)?.execution ?? "exclusive";
|
|
1284
1663
|
}
|
|
1285
1664
|
|
|
1286
|
-
private async *
|
|
1287
|
-
|
|
1288
|
-
if (
|
|
1289
|
-
|
|
1290
|
-
|
|
1665
|
+
private async *dispatchBranch(input: unknown, index: number, signal?: AbortSignal): AsyncGenerator<LoopEvent, DeferredToolResult> {
|
|
1666
|
+
if (typeof input !== "object" || input === null || !("background" in input) || input.background !== true) return yield* this.runBranch(input, index, signal);
|
|
1667
|
+
if (this.jobs.active >= (this.config.maxParallelTools ?? 3)) return { ok: false, content: "background branch capacity reached; wait or cancel an owned branch first" };
|
|
1668
|
+
const controller = new AbortController();
|
|
1669
|
+
const abort = () => controller.abort();
|
|
1670
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
1671
|
+
if (signal?.aborted) abort();
|
|
1672
|
+
const detach = () => signal?.removeEventListener("abort", abort);
|
|
1673
|
+
const child = this.runBranch(input, index, controller.signal);
|
|
1674
|
+
let registered = false;
|
|
1675
|
+
try {
|
|
1676
|
+
for (;;) {
|
|
1677
|
+
const step = await child.next();
|
|
1678
|
+
if (step.done) return step.value;
|
|
1679
|
+
yield step.value;
|
|
1680
|
+
if (step.value.kind !== "branch_start") continue;
|
|
1681
|
+
const node = step.value.node!;
|
|
1682
|
+
this.jobs.start(node, controller, async () => {
|
|
1683
|
+
const result = await child.next();
|
|
1684
|
+
if (!result.done) throw new Error("unexpected background child yield");
|
|
1685
|
+
return result.value;
|
|
1686
|
+
}, detach);
|
|
1687
|
+
registered = true;
|
|
1688
|
+
return { ok: true, content: `branch ${node} running; ` + (this.adviceNames().includes("resolve")
|
|
1689
|
+
? `use resolve(node=${node}, resolution='status'|'wait'|'cancel')` : "branch-observation capability unavailable; completion is unknown"), data: { node, background: true, running: true } };
|
|
1690
|
+
}
|
|
1691
|
+
} finally { if (!registered) detach(); }
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
private async *prepareBrokerCall(call: ToolCall, nextBranch: () => number, blockedImages: boolean,
|
|
1695
|
+
signal?: AbortSignal, native = false): AsyncGenerator<LoopEvent, PreparedBrokerCall> {
|
|
1696
|
+
this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
|
|
1697
|
+
const release = await this.rootTools.acquire(this.executionMode(call));
|
|
1698
|
+
let transferred = false;
|
|
1699
|
+
let scope: BrokerExecution<DeferredToolResult> | undefined;
|
|
1700
|
+
try {
|
|
1701
|
+
const events: LoopEvent[] = [];
|
|
1702
|
+
let emit = (event: LoopEvent) => { events.push(event); };
|
|
1703
|
+
let context: ToolContext = { ...this.config.tools, imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"),
|
|
1704
|
+
advice: this.allowedNames(),
|
|
1705
|
+
imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}) };
|
|
1706
|
+
if (native) context = nativeCommandContext(context);
|
|
1707
|
+
const decoder = new TextDecoder();
|
|
1708
|
+
context.onOutput = chunk => emit({ kind: "progress", progress: { kind: "output", name: call.name, text: decoder.decode(chunk, { stream: true }) } });
|
|
1709
|
+
const broker = new ToolBroker<DeferredToolResult>({
|
|
1710
|
+
parallelLimit: this.config.maxParallelTools ?? 3,
|
|
1711
|
+
callId: () => `nested-${randomUUID()}`,
|
|
1712
|
+
mode: request => this.executionMode(request),
|
|
1713
|
+
invocation: (request, ctx, parent) => this.recordInvocation(request, ctx, parent),
|
|
1714
|
+
prepare: (request, ctx) => this.prepareSessionAction(request, ctx, nextBranch, event => emit(event)),
|
|
1715
|
+
settle: async result => {
|
|
1716
|
+
if (result.accept) events.push(...await result.accept());
|
|
1717
|
+
if (!result.content_ref) Object.assign(result, await presentObservation(context, result.content));
|
|
1718
|
+
},
|
|
1719
|
+
result: async (invocation, result) => {
|
|
1720
|
+
if (result.annotation) await this.record(result.annotation);
|
|
1721
|
+
await this.recordToolReceipt(invocation, finishToolResult(result), blockedImages);
|
|
1722
|
+
},
|
|
1723
|
+
});
|
|
1724
|
+
const execute = yield* withProgress<BrokerExecution<DeferredToolResult>, LoopEvent>(async output => {
|
|
1725
|
+
emit = output;
|
|
1726
|
+
scope = await broker.prepare(call, context);
|
|
1727
|
+
return scope;
|
|
1728
|
+
});
|
|
1729
|
+
emit = event => { events.push(event); };
|
|
1730
|
+
transferred = true;
|
|
1731
|
+
return { run: async output => {
|
|
1732
|
+
if (output) emit = output;
|
|
1733
|
+
let commitOwnsSlot = false;
|
|
1734
|
+
try {
|
|
1735
|
+
const receipt = await execute();
|
|
1736
|
+
if (native) {
|
|
1737
|
+
// An idle host result has no generator consumer to own its commit.
|
|
1738
|
+
// Retain the slot so a checkpoint cannot split invocation from receipt.
|
|
1739
|
+
commitOwnsSlot = true;
|
|
1740
|
+
return { ...receipt, events, commit: async () => { try { await receipt.commit(); } finally { release(); } } };
|
|
1741
|
+
}
|
|
1742
|
+
return { ...receipt, events };
|
|
1743
|
+
} finally { emit = event => { events.push(event); }; if (!commitOwnsSlot) release(); }
|
|
1744
|
+
} };
|
|
1745
|
+
} finally { if (!transferred) { try { await scope?.close(); } finally { release(); } } }
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
private async recordInvocation(call: ToolCall, ctx: ToolContext, parent?: ToolInvocationEvent): Promise<ToolInvocationEvent> {
|
|
1749
|
+
const store = ctx.outputStore ?? join(this.config.logDir, "artifacts");
|
|
1750
|
+
const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));
|
|
1751
|
+
const archive = async (bytes: Uint8Array) => {
|
|
1752
|
+
async function* source() { yield bytes; }
|
|
1753
|
+
return (await archiveObservation(store, source(), 0)).ref;
|
|
1754
|
+
};
|
|
1755
|
+
let valid = false;
|
|
1756
|
+
try { valid = isJsonValue(call.input); } catch { /* Cyclic or excessively deep host input is not a JSON request. */ }
|
|
1757
|
+
const bytes = encode(valid ? call.input : { unavailable: "non-JSON arguments", type: typeof call.input });
|
|
1758
|
+
const definition = toolDefinition(call.name, ctx);
|
|
1759
|
+
const declared = this.config.frozen.tools.find(tool => tool !== null && typeof tool === "object" && "name" in tool && tool.name === call.name &&
|
|
1760
|
+
"description" in tool && typeof tool.description === "string" && "input_schema" in tool) as ReturnType<typeof modelToolSchema> | undefined;
|
|
1761
|
+
const schema = definition ? declared ?? modelToolSchema(definition, adviceTools(ctx)) : undefined;
|
|
1762
|
+
const descriptor = definition ? { version: 1, ...schema,
|
|
1763
|
+
...(declared && JSON.stringify(declared.input_schema) !== JSON.stringify(definition.input_schema) ? { admission_schema: definition.input_schema } : {}),
|
|
1764
|
+
access: definition.access, execution: definition.execution, branch: definition.branch, hiddenInputs: definition.hiddenInputs ?? [], result_version: 1 } : null;
|
|
1765
|
+
const original = this.state.events.findLast(event => event.t === "assistant" && event.blocks.some(block => block.type === "tool_use" && block.call_id === call.call_id));
|
|
1766
|
+
const originalInput = original?.t === "assistant" ? original.blocks.find(block => block.type === "tool_use" && block.call_id === call.call_id) : undefined;
|
|
1767
|
+
const changed = originalInput?.type !== "tool_use" || valid && JSON.stringify(originalInput.input) !== JSON.stringify(call.input);
|
|
1768
|
+
const event: ToolInvocationEvent = { t: "tool_invocation", id: this.clock.nextId(), at: this.clock.now(), call_id: call.call_id, name: call.name,
|
|
1769
|
+
...(ctx.origin ? { origin: ctx.origin } : {}),
|
|
1770
|
+
input_ref: await archive(bytes), schema_ref: descriptor ? await archive(encode(descriptor)) : null,
|
|
1771
|
+
...(parent ? { parent_call_id: parent.call_id, parent_ref: { node: this.state.node, event: parent.id } } : {}),
|
|
1772
|
+
...(valid && bytes.length <= MAX_NESTED_ARGUMENT_BYTES && (parent || changed) ? { input: call.input as JsonValue } : {}) };
|
|
1773
|
+
await this.record(event); return event;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
private async recordToolReceipt(invocation: ToolInvocationEvent, out: ToolResult, blockedImages: boolean): Promise<void> {
|
|
1777
|
+
let data = out.data;
|
|
1778
|
+
let dataRef;
|
|
1779
|
+
if (data !== undefined) {
|
|
1780
|
+
const bytes = new TextEncoder().encode(JSON.stringify(data));
|
|
1781
|
+
if (bytes.length > MAX_INLINE_RESULT_DATA_BYTES) {
|
|
1782
|
+
async function* source() { yield bytes; }
|
|
1783
|
+
dataRef = (await archiveObservation(this.config.tools.outputStore!, source(), 0)).ref;
|
|
1784
|
+
data = undefined;
|
|
1785
|
+
}
|
|
1291
1786
|
}
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1787
|
+
await this.record({ t: "tool_result", id: this.clock.nextId(), at: this.clock.now(), call_id: invocation.call_id,
|
|
1788
|
+
invocation_ref: { node: this.state.node, event: invocation.id },
|
|
1789
|
+
...(invocation.origin ? { origin: invocation.origin } : {}),
|
|
1790
|
+
...(invocation.parent_call_id ? { parent_call_id: invocation.parent_call_id } : {}), ok: out.ok, content: out.content,
|
|
1791
|
+
...(out.outcome ? { outcome: out.outcome } : {}), ...(data !== undefined ? { data } : {}), ...(dataRef ? { data_ref: dataRef } : {}),
|
|
1792
|
+
...(out.observation_sha256 !== undefined ? { observation_sha256: out.observation_sha256 } : {}),
|
|
1793
|
+
...(out.observation ? { observation: out.observation } : {}), ...(out.diagnostic ? { diagnostic: out.diagnostic } : {}),
|
|
1794
|
+
...(out.warnings ? { warnings: out.warnings } : {}), ...(out.content_sha256 ? { content_sha256: out.content_sha256 } : {}),
|
|
1795
|
+
...(out.content_ref ? { content_ref: out.content_ref } : {}),
|
|
1796
|
+
...(out.images?.length ? { images: submissionImages(out.images, blockedImages) } : {}), ...(out.effect ? { effect: out.effect } : {}) });
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
private async prepareSessionAction(call: ToolCall, ctx: ToolContext, nextBranch: () => number,
|
|
1800
|
+
emit: (event: LoopEvent) => void): Promise<DeferredToolResult | (() => Promise<DeferredToolResult>)> {
|
|
1801
|
+
// SDK callers may omit allow or preauthorize an optional name. Permission is
|
|
1802
|
+
// distinct from a declaration: neither direct nor nested calls may run it early.
|
|
1803
|
+
const declared = declaredToolNames(this.config.frozen.tools);
|
|
1804
|
+
if (ctx.deferredTools?.some(tool => tool.name === call.name) && !declared.includes(call.name)) {
|
|
1805
|
+
return prepareNormalizedTool({ ...ctx, allow: (ctx.allow ?? declared).filter(name => name !== call.name) }, call.name, call.input);
|
|
1306
1806
|
}
|
|
1807
|
+
const forkSkill = call.name === "read" && typeof call.input === "object" && call.input !== null ? this.config.skills?.find(skill =>
|
|
1808
|
+
skill.context === "fork" && skill.filePath === resolve(ctx.root, String((call.input as Record<string, unknown>)["path"]))) : undefined;
|
|
1809
|
+
const discovery = call.name === DISCOVERY_TOOL_NAME && ctx.extensions?.get(call.name) === DISCOVERY_TOOL;
|
|
1810
|
+
if (!forkSkill && !discovery && !["note", "branch", "resolve", "history", "target"].includes(call.name)) return prepareNormalizedTool(ctx, call.name, call.input);
|
|
1811
|
+
const branchIndex = forkSkill || call.name === "branch" ? nextBranch() : 0;
|
|
1812
|
+
return prepareNormalizedTool(ctx, call.name, call.input, async () => {
|
|
1813
|
+
if (discovery) return async () => searchDeferredTools(ctx, call.input as Record<string, unknown>, this.allowedNames());
|
|
1814
|
+
if (call.name === "history") return () => runHistoryTool(this.config.logDir, this.state.node, ctx, call.input);
|
|
1815
|
+
if (call.name === "target") return async () => {
|
|
1816
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
1817
|
+
if (!history.ok) throw Error(history.error);
|
|
1818
|
+
const observed = await this.runtimeTargets.perform(ctx, history.nodes, call.input as Parameters<RuntimeTargets["perform"]>[2]);
|
|
1819
|
+
return { ...observed.result, annotation: { t: "target_observation" as const, id: this.clock.nextId(), at: this.clock.now(), observation: observed.observation } };
|
|
1820
|
+
};
|
|
1821
|
+
if (call.name === "note") return async () => {
|
|
1822
|
+
await this.observeEvidence();
|
|
1823
|
+
const parsed = parseNote(call.input);
|
|
1824
|
+
const prepared = await prepareWorkingNote(this.config.logDir, this.state.node, this.config.workNamespace ?? "main",
|
|
1825
|
+
parsed.annotation ? this.toEvent(parsed.annotation) : undefined, parsed.result);
|
|
1826
|
+
return { ...prepared.out, ...(prepared.annotation ? { annotation: prepared.annotation } : {}) };
|
|
1827
|
+
};
|
|
1828
|
+
if (call.name === "resolve") return () => this.runResolve(call.input);
|
|
1829
|
+
if (forkSkill?.disableModelInvocation) return async () => ({ ok: false, content: "this skill requires explicit user invocation" });
|
|
1830
|
+
if (forkSkill) await this.recordSkill(forkSkill, "fork", "model");
|
|
1831
|
+
const task = this.state.events.findLast(event => event.t === "user");
|
|
1832
|
+
const branch = forkSkill ? this.runBranch({ purpose: `skill ${forkSkill.name}`, prompt: `${forkSkill.body}\n\nCurrent task: ${task?.t === "user" ? task.text : ""}` },
|
|
1833
|
+
branchIndex, ctx.signal, forkSkill, skillTools(this.allowedNames(), forkSkill), "model") : this.dispatchBranch(call.input, branchIndex, ctx.signal);
|
|
1834
|
+
while (true) {
|
|
1835
|
+
const step = await branch.next();
|
|
1836
|
+
if (step.done) return async () => step.value;
|
|
1837
|
+
emit(step.value);
|
|
1838
|
+
if (step.value.kind === "branch_start") return async () => {
|
|
1839
|
+
const result = await branch.next();
|
|
1840
|
+
if (!result.done) throw new Error(`unexpected child execution event: ${result.value.kind}`);
|
|
1841
|
+
return result.value;
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
});
|
|
1307
1845
|
}
|
|
1308
1846
|
|
|
1309
1847
|
private async *runBranch(
|
|
1310
1848
|
input: unknown,
|
|
1311
1849
|
indexThisTurn: number,
|
|
1312
1850
|
signal?: AbortSignal,
|
|
1851
|
+
skill?: Skill,
|
|
1852
|
+
allowedOverride?: readonly string[],
|
|
1853
|
+
skillOrigin: "user" | "model" = "user",
|
|
1313
1854
|
): AsyncGenerator<LoopEvent, DeferredToolResult> {
|
|
1314
1855
|
const limits = this.limits;
|
|
1315
1856
|
const depth = this.config.branchDepth ?? 0;
|
|
1857
|
+
if (this.children.size >= (this.config.maxParallelTools ?? 3)) return { ok: false, content: "branch capacity reached; wait or cancel an owned branch first" };
|
|
1316
1858
|
|
|
1317
1859
|
// Every refusal is a normal tool_result: an unanswered tool_use makes the
|
|
1318
1860
|
// next request unpairable, and a refusal can say what to do instead.
|
|
@@ -1349,7 +1891,7 @@ export class Session {
|
|
|
1349
1891
|
if (!purpose || !prompt) {
|
|
1350
1892
|
return { content: "a branch needs both a purpose and a prompt", ok: false };
|
|
1351
1893
|
}
|
|
1352
|
-
const parentAllow = this.config.tools.allow;
|
|
1894
|
+
const parentAllow = allowedOverride ?? this.config.tools.allow;
|
|
1353
1895
|
const branchNames = writes ? WRITING_BRANCH_TOOL_NAMES : BRANCH_TOOL_NAMES;
|
|
1354
1896
|
const allowed: string[] = parentAllow
|
|
1355
1897
|
? branchNames.filter(name => parentAllow.includes(name)) : [...branchNames];
|
|
@@ -1387,6 +1929,7 @@ export class Session {
|
|
|
1387
1929
|
|
|
1388
1930
|
await this.record({
|
|
1389
1931
|
t: "spawn", id: this.clock.nextId(), at: this.clock.now(), branch: ref, purpose,
|
|
1932
|
+
...(args["background"] === true ? { background: true as const } : {}),
|
|
1390
1933
|
input: { source_event: sourceEvent, tools: allowed, max_turns: Math.min(this.config.maxTurns, 15), workspace: inputWorkspace },
|
|
1391
1934
|
...(worktree ? { workspace: worktree } : {}),
|
|
1392
1935
|
});
|
|
@@ -1399,7 +1942,8 @@ export class Session {
|
|
|
1399
1942
|
const branch = new Session(
|
|
1400
1943
|
{
|
|
1401
1944
|
...this.config,
|
|
1402
|
-
|
|
1945
|
+
acceptanceContract: null,
|
|
1946
|
+
frozen: { system: this.config.frozen.system, tools: branchTools(writes, allowed) },
|
|
1403
1947
|
// The same list twice: the schema shapes what the model asks for, the
|
|
1404
1948
|
// allow-list decides what actually runs. Intersected with the parent
|
|
1405
1949
|
// so `--exclude-tools bash` cannot be undone by opening a branch.
|
|
@@ -1407,6 +1951,7 @@ export class Session {
|
|
|
1407
1951
|
...this.config.tools,
|
|
1408
1952
|
...(worktree ? { root: worktree.path } : {}),
|
|
1409
1953
|
allow: allowed,
|
|
1954
|
+
readonlyBranch: !writes,
|
|
1410
1955
|
},
|
|
1411
1956
|
branchDepth: depth + 1,
|
|
1412
1957
|
workNamespace: `branch-${node}`,
|
|
@@ -1419,14 +1964,16 @@ export class Session {
|
|
|
1419
1964
|
branch.state.node = node;
|
|
1420
1965
|
branch.state.nextNode = this.state.nextNode;
|
|
1421
1966
|
branch.state.lastScan = { ...this.state.lastScan };
|
|
1967
|
+
if (skill) await branch.recordSkill(skill, "fork", skillOrigin);
|
|
1968
|
+
this.children.set(node, branch);
|
|
1422
1969
|
yield { kind: "branch_start", node, reason: purpose, at: Date.now() };
|
|
1423
|
-
for await (const event of branch.snapshotTurn([], true)) {
|
|
1424
|
-
if (event.kind === "snapshot_failed") throw new Error(`cannot establish branch baseline: ${event.reason}`);
|
|
1425
|
-
}
|
|
1426
1970
|
|
|
1427
1971
|
let text = "";
|
|
1428
1972
|
let outcome = executionOutcome("unknown");
|
|
1429
1973
|
try {
|
|
1974
|
+
for await (const event of branch.snapshotTurn([], true)) {
|
|
1975
|
+
if (event.kind === "snapshot_failed") throw new Error(`cannot establish branch baseline: ${event.reason}`);
|
|
1976
|
+
}
|
|
1430
1977
|
for await (const e of branch.send(prompt, signal)) {
|
|
1431
1978
|
if (e.kind === "assistant_text" && e.text) text = e.text;
|
|
1432
1979
|
if (e.kind === "done") outcome = e.outcome ?? executionOutcome("unknown");
|
|
@@ -1435,7 +1982,8 @@ export class Session {
|
|
|
1435
1982
|
// Persist a bounded failure classification, never a provider's raw error body.
|
|
1436
1983
|
outcome = executionOutcome(signal?.aborted ? "cancelled" : "execution_error");
|
|
1437
1984
|
await branch.answerUnpairedTools("the branch execution failed; this tool's outcome is unknown; inspect before retrying");
|
|
1438
|
-
}
|
|
1985
|
+
} finally { try { await branch.dispose(); } finally { this.children.delete(node); } }
|
|
1986
|
+
if (args["background"] === true) await branch.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), outcome, reason: "background branch execution ended" });
|
|
1439
1987
|
// Node numbers the branch allocated are taken.
|
|
1440
1988
|
|
|
1441
1989
|
const tip = branch.state.node;
|
|
@@ -1455,29 +2003,40 @@ export class Session {
|
|
|
1455
2003
|
"not in your files. Adopting continues in that checkout without merging it into this one."
|
|
1456
2004
|
: "";
|
|
1457
2005
|
const summary = text.trim() || "(the branch produced no closing message)";
|
|
2006
|
+
const receiptHistory = await readNodeHistory(this.config.logDir, tip, undefined, "context");
|
|
2007
|
+
if (!receiptHistory.ok) throw new Error(receiptHistory.error);
|
|
2008
|
+
const receipt = deriveBranchReceipt({ source: await this.nodeRefFor(tip), outcome, handoff, nodes: receiptHistory.nodes });
|
|
2009
|
+
const receiptRef = await putBlob(join(this.config.logDir, "artifacts"), JSON.stringify(receipt));
|
|
1458
2010
|
const joined: LogEvent = {
|
|
1459
2011
|
t: "joined", id: this.clock.nextId(), at: this.clock.now(),
|
|
1460
2012
|
branch: ref, tip: await this.nodeRefFor(tip), handoff_ref: handoffPath(this.config.logDir, tip), summary,
|
|
1461
2013
|
model: branch.provider.config.spec.id,
|
|
1462
2014
|
outcome,
|
|
2015
|
+
receipt_ref: receiptRef,
|
|
1463
2016
|
};
|
|
1464
2017
|
|
|
1465
2018
|
// Only the summary crosses back. The branch's own calls stay in its node,
|
|
1466
2019
|
// which is the entire reason to branch rather than read in place.
|
|
1467
2020
|
return {
|
|
1468
2021
|
content:
|
|
1469
|
-
`branch ${node} (${purpose}) ended: ${JSON.stringify(outcome)}.\n
|
|
2022
|
+
`branch ${node} (${purpose}) ended: ${JSON.stringify(outcome)}.\n` +
|
|
2023
|
+
`Evidence receipt: ${receipt.commands.length} commands, ${receipt.changes.length} file edits, ${receipt.unfinished.length} unfinished items; verification=${receipt.verification}; task acceptance=not assessed.\n` +
|
|
2024
|
+
(this.adviceNames().includes("history") ? `Read history(artifact='${receiptRef}'), then history(branch=${node}, event='source ID') for original evidence.\n`
|
|
2025
|
+
: `Evidence receipt artifact=${receiptRef}; original-source recovery capability unavailable.\n`) +
|
|
2026
|
+
`Child closing statement (unverified):\n${summary}${where}\n\n` +
|
|
1470
2027
|
`Its full state is at ${handoffPath(this.config.logDir, tip)}. ` +
|
|
1471
|
-
`Resolve it with resolve(node: ${node}, ...)
|
|
2028
|
+
(this.adviceNames().includes("resolve") ? `Resolve it with resolve(node: ${node}, ...).` : "Branch-resolution capability unavailable."),
|
|
1472
2029
|
ok: outcome.status === "completed",
|
|
1473
2030
|
outcome,
|
|
1474
2031
|
accept: async () => {
|
|
2032
|
+
if ((await this.branchStatuses()).some(item => item.node === node && item.handoff_ref)) return [];
|
|
1475
2033
|
this.state.usage.push(...branch.state.usage);
|
|
1476
|
-
await this.saveCursor();
|
|
1477
2034
|
await this.record(joined);
|
|
2035
|
+
await this.saveCursor();
|
|
1478
2036
|
return [{ kind: "branch_done", node, text: summary, outcome, ok: outcome.status === "completed" }];
|
|
1479
2037
|
},
|
|
1480
2038
|
data: { node, outcome, handoff_ref: handoffPath(this.config.logDir, tip), requests: branch.state.usage.length,
|
|
2039
|
+
receipt_ref: receiptRef,
|
|
1481
2040
|
input: { source_event: sourceEvent, workspace: inputWorkspace } },
|
|
1482
2041
|
};
|
|
1483
2042
|
}
|
|
@@ -1502,10 +2061,33 @@ export class Session {
|
|
|
1502
2061
|
return { ok: false, content: `branch ${node} is already resolved` };
|
|
1503
2062
|
}
|
|
1504
2063
|
|
|
2064
|
+
const joined = events.find((e) => e.t === "joined" && e.branch.node === node);
|
|
2065
|
+
if (resolution === "status") {
|
|
2066
|
+
const item = (await this.branchStatuses()).find(branch => branch.node === node)!;
|
|
2067
|
+
return { ok: true, content: `branch ${node}: ${item.status}`, data: { node, status: item.status } };
|
|
2068
|
+
}
|
|
2069
|
+
if (resolution === "wait" || resolution === "cancel") {
|
|
2070
|
+
if (joined) return { ok: true, content: `branch ${node} already joined; receipt ${joined.t === "joined" ? joined.receipt_ref ?? "not recorded" : ""}` };
|
|
2071
|
+
if (resolution === "cancel" && this.jobs.state(node) === undefined) return { ok: false, content: `branch ${node}: unknown after restart; no owned handle to cancel` };
|
|
2072
|
+
if (resolution === "cancel") { this.jobs.cancel(node); await this.jobs.drain(node); }
|
|
2073
|
+
const waitMs = args["wait_ms"] === undefined ? 10_000 : args["wait_ms"];
|
|
2074
|
+
if (typeof waitMs !== "number" || !Number.isFinite(waitMs) || waitMs < 0 || waitMs > 60_000) return { ok: false, content: "wait_ms must be between 0 and 60000" };
|
|
2075
|
+
const result = await this.jobs.wait(node, waitMs);
|
|
2076
|
+
if (result?.running) return { ok: true, content: `branch ${node} running; wait reached its deadline`, data: { node, running: true } };
|
|
2077
|
+
if (result?.error) return { ok: false, content: result.error };
|
|
2078
|
+
const complete = result?.value ?? await this.recoverBranchResult(node);
|
|
2079
|
+
if (!complete) return { ok: false, content: `branch ${node}: unknown after restart; no sealed execution outcome; inspect before retrying` };
|
|
2080
|
+
if (complete.accept) await complete.accept();
|
|
2081
|
+
this.jobs.forget(node);
|
|
2082
|
+
return complete;
|
|
2083
|
+
}
|
|
2084
|
+
if (this.jobs.state(node) === "running") return { ok: false, content: `branch ${node} running; wait or cancel before resolving it` };
|
|
2085
|
+
if (!joined && this.jobs.state(node) === "ready") return { ok: false, content: `branch ${node} has not joined; wait before resolving it` };
|
|
2086
|
+
|
|
1505
2087
|
const ref = await this.nodeRefFor(node);
|
|
1506
2088
|
if (resolution === "adopt") {
|
|
2089
|
+
if ((await this.branchStatuses()).some(item => item.node !== node && item.background && !item.handoff_ref && item.resolution === null)) return { ok: false, content: "wait or cancel other unjoined background branches before adoption" };
|
|
1507
2090
|
if (this.pendingAdoption !== null) return { content: "resolve one adoption per tool round", ok: false };
|
|
1508
|
-
const joined = events.find((e) => e.t === "joined" && e.branch.node === node);
|
|
1509
2091
|
if (!joined) return { content: `branch ${node} has not joined`, ok: false };
|
|
1510
2092
|
await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: ref });
|
|
1511
2093
|
this.pendingAdoption = node;
|
|
@@ -1533,52 +2115,97 @@ export class Session {
|
|
|
1533
2115
|
}
|
|
1534
2116
|
return { content: `discarded branch ${node}`, ok: true };
|
|
1535
2117
|
}
|
|
1536
|
-
return { content: `resolution must be
|
|
2118
|
+
return { content: `resolution must be wait, cancel, status, adopt or discard`, ok: false };
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
private async recoverBranchResult(root: number): Promise<DeferredToolResult | undefined> {
|
|
2122
|
+
// Recover only a unique sealed continuation. An open or ambiguous branch
|
|
2123
|
+
// cannot be replayed safely because tools may already have had effects.
|
|
2124
|
+
const headers = await Promise.all((await listNodes(this.config.logDir)).map(async node => ({ node, header: (await readNode(this.config.logDir, node)).header })));
|
|
2125
|
+
let tip = root;
|
|
2126
|
+
const seen = new Set<number>();
|
|
2127
|
+
for (;;) {
|
|
2128
|
+
if (seen.has(tip)) return undefined;
|
|
2129
|
+
seen.add(tip);
|
|
2130
|
+
const children = headers.filter(item => item.header?.parent?.node === tip && item.header.basis !== "none");
|
|
2131
|
+
if (children.length > 1) return undefined;
|
|
2132
|
+
if (children.length === 0) break;
|
|
2133
|
+
tip = children[0]!.node;
|
|
2134
|
+
}
|
|
2135
|
+
if (!(await readManifest(this.config.logDir)).nodes.some(item => item.node === tip && item.handoff_ref)) return undefined;
|
|
2136
|
+
const history = await readNodeHistory(this.config.logDir, tip, undefined, "context");
|
|
2137
|
+
if (!history.ok) throw new Error(history.error);
|
|
2138
|
+
const outcomeEvent = history.nodes.flatMap(part => part.events).findLast(event => event.t === "outcome");
|
|
2139
|
+
if (outcomeEvent?.t !== "outcome") return undefined;
|
|
2140
|
+
const handoff = await replayContext(this.config.logDir, tip);
|
|
2141
|
+
const full = deriveHandoff({ prev: handoff.fullHandoff, events: handoff.events, depth: handoff.depth, self: await this.nodeRefFor(tip) });
|
|
2142
|
+
const receipt = deriveBranchReceipt({ source: await this.nodeRefFor(tip), outcome: outcomeEvent.outcome, handoff: full, nodes: history.nodes });
|
|
2143
|
+
const receiptRef = await putBlob(join(this.config.logDir, "artifacts"), JSON.stringify(receipt));
|
|
2144
|
+
const last = history.nodes.flatMap(part => part.events).findLast(event => event.t === "assistant");
|
|
2145
|
+
const summary = last?.t === "assistant" ? last.blocks.flatMap(block => block.type === "text" ? [block.text] : []).join("\n") : "(no closing message)";
|
|
2146
|
+
return { ok: outcomeEvent.outcome.status === "completed", outcome: outcomeEvent.outcome,
|
|
2147
|
+
content: `branch ${root} recovered from sealed node ${tip}; receipt ${receiptRef}; closing statement is unverified: ${summary}`,
|
|
2148
|
+
data: { node: root, outcome: outcomeEvent.outcome, receipt_ref: receiptRef, handoff_ref: handoffPath(this.config.logDir, tip) },
|
|
2149
|
+
accept: async () => {
|
|
2150
|
+
if ((await this.branchStatuses()).some(item => item.node === root && item.handoff_ref)) return [];
|
|
2151
|
+
await this.record({ t: "joined", id: this.clock.nextId(), at: this.clock.now(), branch: await this.nodeRefFor(root), tip: await this.nodeRefFor(tip),
|
|
2152
|
+
handoff_ref: handoffPath(this.config.logDir, tip), receipt_ref: receiptRef, summary, outcome: outcomeEvent.outcome });
|
|
2153
|
+
await this.saveCursor(); return [];
|
|
2154
|
+
} };
|
|
1537
2155
|
}
|
|
1538
2156
|
|
|
1539
2157
|
/** D15: finish the parent's tool round before moving to the chosen branch. */
|
|
1540
2158
|
private async adoptPendingBranch(): Promise<void> {
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
2159
|
+
this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
|
|
2160
|
+
const release = await this.rootTools.acquire("exclusive");
|
|
2161
|
+
let checkpoint = false;
|
|
2162
|
+
try {
|
|
2163
|
+
const root = this.pendingAdoption;
|
|
2164
|
+
if (root === null) return;
|
|
2165
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node);
|
|
2166
|
+
if (!history.ok) throw new Error(history.error);
|
|
2167
|
+
const events = history.nodes.flatMap((n) => n.events);
|
|
2168
|
+
const joined = events.find((e) => e.t === "joined" && e.branch.node === root);
|
|
2169
|
+
const spawned = events.find((e) => e.t === "spawn" && e.branch.node === root);
|
|
2170
|
+
if (joined?.t !== "joined" || spawned?.t !== "spawn") throw new Error(`missing provenance for adoption ${root}`);
|
|
2171
|
+
const parent = this.state.node;
|
|
2172
|
+
const annotations = carryForward(events, spawned.id, parent);
|
|
2173
|
+
const tip = joined.tip ?? joined.branch;
|
|
2174
|
+
const intent = events.findLast((e) => e.t === "adopt" && e.branch.node === root && e.next_node !== undefined);
|
|
2175
|
+
const node = intent?.t === "adopt" && intent.next_node !== undefined ? intent.next_node : this.state.nextNode++;
|
|
2176
|
+
if (!intent) await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: joined.branch, next_node: node });
|
|
2177
|
+
const parentRef = await this.nodeRefFor(parent);
|
|
2178
|
+
const existing = await readNode(this.config.logDir, node);
|
|
2179
|
+
if (existing.header === null) await initNode(this.config.logDir, node, tip, this.clock.now(), "inherit", { adopted_from: parentRef });
|
|
2180
|
+
else if (existing.header.adopted_from?.node !== parent || existing.header.parent?.node !== tip.node) {
|
|
2181
|
+
throw new Error(`INTEGRITY: adoption successor ${node} belongs to another transition`);
|
|
2182
|
+
}
|
|
2183
|
+
const rebuilt = await replayContext(this.config.logDir, node);
|
|
2184
|
+
this.state.node = node;
|
|
2185
|
+
this.state.nextNode = Math.max(this.state.nextNode, node + 1);
|
|
2186
|
+
this.state.events = rebuilt.contextEvents;
|
|
2187
|
+
this.resetDerivedToolState();
|
|
2188
|
+
this.state.handoff = rebuilt.handoff;
|
|
2189
|
+
this.state.depth = rebuilt.depth;
|
|
2190
|
+
this.state.anchor = null;
|
|
2191
|
+
const copied = new Set(existing.events.flatMap((e) => "carried_from" in e && e.carried_from ? [e.carried_from.event] : []));
|
|
2192
|
+
for (const event of annotations) if (!copied.has(event.carried_from!.event)) await this.record({ ...event, id: this.clock.nextId() });
|
|
2193
|
+
if (!existing.events.some((e) => e.t === "adopt" && e.applied && e.branch.node === root)) {
|
|
2194
|
+
await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: joined.branch, applied: true });
|
|
2195
|
+
}
|
|
2196
|
+
const checkout = this.state.worktrees.get(root);
|
|
2197
|
+
if (checkout) this.config.tools.root = checkout.path;
|
|
2198
|
+
if (this.config.workspaceTracking === "legacy") {
|
|
2199
|
+
const scan = await scanWorkspace(this.config.tools.root, this.ignore);
|
|
2200
|
+
if (!scan.ok) throw new Error(`cannot adopt workspace: ${scan.error}`);
|
|
2201
|
+
this.state.lastScan = scan.files;
|
|
2202
|
+
}
|
|
2203
|
+
this.installWorkspace();
|
|
2204
|
+
await this.saveCursor();
|
|
2205
|
+
this.pendingAdoption = null;
|
|
2206
|
+
checkpoint = joined.model !== undefined && joined.model !== this.provider.config.spec.id;
|
|
2207
|
+
} finally { release(); }
|
|
2208
|
+
if (checkpoint) await this.performCut("model_checkpoint");
|
|
1582
2209
|
}
|
|
1583
2210
|
|
|
1584
2211
|
private toEvent(a: Annotation): LogEvent {
|
|
@@ -1595,7 +2222,9 @@ export class Session {
|
|
|
1595
2222
|
};
|
|
1596
2223
|
}
|
|
1597
2224
|
case "next_action":
|
|
1598
|
-
return { t: "next_action", ...stamp, what: a.what, next_action: a.next_action, ...(a.work_id ? { work_id: a.work_id } : {}) };
|
|
2225
|
+
return { t: "next_action", ...stamp, what: a.what, next_action: a.next_action, ...(a.work_id ? { work_id: a.work_id } : {}), ...(a.depends_on ? { depends_on: a.depends_on } : {}) };
|
|
2226
|
+
case "work_owner": return { ...a, ...stamp, owner: this.config.workNamespace ?? "main" };
|
|
2227
|
+
case "work_dependencies": return { ...a, ...stamp };
|
|
1599
2228
|
case "decision":
|
|
1600
2229
|
return {
|
|
1601
2230
|
t: "decision", ...stamp, chose: a.chose, because: a.because,
|
|
@@ -1617,12 +2246,85 @@ export class Session {
|
|
|
1617
2246
|
|
|
1618
2247
|
// ---- rewind ---------------------------------------------------------------
|
|
1619
2248
|
|
|
2249
|
+
/** A prepared edit can be reconciled by its exact path; unknown commands are never replayed. */
|
|
2250
|
+
private async recoverFileOperations(): Promise<void> {
|
|
2251
|
+
if (!this.config.blobDir) return;
|
|
2252
|
+
// A cut is only legal after paired tools, so pending operations belong to this node.
|
|
2253
|
+
const history = (await readNode(this.config.logDir, this.state.node)).events.filter((event): event is CheckpointEvent => event.t === "checkpoint");
|
|
2254
|
+
const pending = new Map<string, CheckpointEvent>();
|
|
2255
|
+
for (const event of history) {
|
|
2256
|
+
if (event.phase === "prepared") pending.set(event.operation, event);
|
|
2257
|
+
else pending.delete(event.operation);
|
|
2258
|
+
}
|
|
2259
|
+
for (const event of pending.values()) {
|
|
2260
|
+
let applied = event.source !== "git" && changedPaths(event.changes).length > 0;
|
|
2261
|
+
for (const [path, change] of Object.entries(event.changes)) {
|
|
2262
|
+
try { if (!sameVersion((await readEditableFile(this.config.tools.root, path)).version, change.after)) applied = false; }
|
|
2263
|
+
catch { applied = false; }
|
|
2264
|
+
}
|
|
2265
|
+
await this.record({ ...event, id: this.clock.nextId(), at: this.clock.now(), phase: applied ? "applied" : "unavailable",
|
|
2266
|
+
reason: applied ? "observed the prepared file version after restart; tool outcome remains unknown" : "interrupted checkpoint; inspect files before retrying" });
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
private async rewindJournal(point: RestorePoint, mode: RewindMode): Promise<RewindResult> {
|
|
2271
|
+
const dir = this.config.logDir, root = this.config.tools.root, blobs = this.config.blobDir;
|
|
2272
|
+
if (mode !== "context" && !blobs) return { ok: false, error: "this session stores no file checkpoints" };
|
|
2273
|
+
try {
|
|
2274
|
+
const target = { node: point.node, atEvent: point.atEvent };
|
|
2275
|
+
// Reading the log validates the target. Context-only navigation never reads workspace contents.
|
|
2276
|
+
const history = await readNodeHistory(dir, point.node);
|
|
2277
|
+
if (!history.ok) throw new Error(history.error);
|
|
2278
|
+
if (!history.nodes.find(part => part.node === point.node)?.events.some(event => event.id === point.atEvent)) throw new Error(`NO_SUCH_EVENT: ${point.atEvent}`);
|
|
2279
|
+
const changes: FileChanges = await planCheckpointRestore(dir, this.state.node, target).catch(error => {
|
|
2280
|
+
if (mode !== "context") throw error;
|
|
2281
|
+
return {};
|
|
2282
|
+
});
|
|
2283
|
+
const paths = changedPaths(changes);
|
|
2284
|
+
const historical = await readCursorAt(dir, point.node, point.atEvent);
|
|
2285
|
+
const from: NodeRef = { ...await digestedRef(dir, point.node), at_event: point.atEvent };
|
|
2286
|
+
const latest = (await readNode(dir, this.state.node)).events.at(-1);
|
|
2287
|
+
const filesFrom: NodeRef = { ...await digestedRef(dir, this.state.node), ...(latest ? { at_event: latest.id } : {}) };
|
|
2288
|
+
if (mode !== "context" && blobs) {
|
|
2289
|
+
await preflightChanges(root, blobs, changes);
|
|
2290
|
+
// Persist the intention before files move; recovery never invents command completion.
|
|
2291
|
+
const operation = this.clock.nextId();
|
|
2292
|
+
await this.record({ t: "checkpoint", id: operation, at: this.clock.now(), operation, source: "restore", phase: "prepared", changes });
|
|
2293
|
+
await applyFileChanges(root, blobs, changes);
|
|
2294
|
+
await this.record({ t: "checkpoint", id: this.clock.nextId(), at: this.clock.now(), operation, source: "restore", phase: "applied", changes });
|
|
2295
|
+
}
|
|
2296
|
+
const restored = mode === "context" ? [] : paths.filter(path => changes[path]!.after !== null);
|
|
2297
|
+
const removed = mode === "context" ? [] : paths.filter(path => changes[path]!.after === null);
|
|
2298
|
+
const event: LogEvent = { t: "rewind", id: this.clock.nextId(), at: this.clock.now(), mode, from, prompt: point.prompt,
|
|
2299
|
+
restored, removed, unrecoverable: [], divergence: mode === "both" ? [] : paths,
|
|
2300
|
+
...(mode === "context" ? { files_from: filesFrom } : {}) };
|
|
2301
|
+
if (mode === "files") await this.record(event);
|
|
2302
|
+
else {
|
|
2303
|
+
const node = this.state.nextNode++;
|
|
2304
|
+
await initNode(dir, node, from, this.clock.now(), "inherit");
|
|
2305
|
+
const replayed = await replayContext(dir, node);
|
|
2306
|
+
this.state.node = node; this.state.events = [...replayed.contextEvents]; this.state.handoff = replayed.handoff;
|
|
2307
|
+
this.state.depth = replayed.depth; this.state.anchor = null; this.resetDerivedToolState();
|
|
2308
|
+
await this.record(event);
|
|
2309
|
+
if (historical === null || !sameModel(historical, { model: this.provider.config.spec.id, provider: this.provider.config.spec.provider })) {
|
|
2310
|
+
const cursor = await readCursor(dir);
|
|
2311
|
+
if (cursor) await writeCursor(dir, { ...cursor, node, next_node: this.state.nextNode, needs_model_checkpoint: true });
|
|
2312
|
+
await this.performCut("model_checkpoint", this.provider.config.spec.id);
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
await this.saveCursor();
|
|
2316
|
+
return { ok: true, node: this.state.node, restored, removed, unrecoverable: [], note: mode === "context"
|
|
2317
|
+
? "Only the conversation moved; workspace files were left unchanged."
|
|
2318
|
+
: "Restored recorded file changes. Unrelated files, ignored paths and external effects were left unchanged; command recovery requires captured Git checkpoints." };
|
|
2319
|
+
} catch (error) { return { ok: false, error: String(error) }; }
|
|
2320
|
+
}
|
|
2321
|
+
|
|
1620
2322
|
private get ignore(): readonly string[] {
|
|
1621
2323
|
return this.config.snapshotIgnore ?? DEFAULT_IGNORE;
|
|
1622
2324
|
}
|
|
1623
2325
|
|
|
1624
2326
|
/**
|
|
1625
|
-
* Every completed exchange
|
|
2327
|
+
* Every completed exchange, including abandoned branches and conversation-only turns.
|
|
1626
2328
|
*
|
|
1627
2329
|
* An exchange runs from one user message to just before the next, so its
|
|
1628
2330
|
* last event is where a fork belongs: the conversation is whole there, and
|
|
@@ -1668,6 +2370,8 @@ export class Session {
|
|
|
1668
2370
|
}
|
|
1669
2371
|
};
|
|
1670
2372
|
for (const e of n.events) {
|
|
2373
|
+
if (e.t === "workspace_observation") continue;
|
|
2374
|
+
if (e.t === "checkpoint" && e.source === "restore") { close(); open = null; continue; }
|
|
1671
2375
|
if (e.t === "snapshot" && e.phase === "idle") {
|
|
1672
2376
|
close();
|
|
1673
2377
|
open = null;
|
|
@@ -1683,6 +2387,7 @@ export class Session {
|
|
|
1683
2387
|
}
|
|
1684
2388
|
if (!open) continue;
|
|
1685
2389
|
open.last = e.id;
|
|
2390
|
+
if (e.t === "checkpoint" && e.phase === "applied") open.changed += changedPaths(e.changes).length;
|
|
1686
2391
|
if (e.t === "snapshot") {
|
|
1687
2392
|
open.changed +=
|
|
1688
2393
|
Object.keys(e.added).length + Object.keys(e.changed).length + e.removed.length;
|
|
@@ -1696,12 +2401,26 @@ export class Session {
|
|
|
1696
2401
|
/**
|
|
1697
2402
|
* What a rewind would do, without doing it.
|
|
1698
2403
|
*
|
|
1699
|
-
*
|
|
1700
|
-
*
|
|
1701
|
-
* is the right behaviour -- a half-restored workspace is worse -- but it is
|
|
1702
|
-
* the half of the operation nobody asked for, so it is shown first.
|
|
2404
|
+
* P.3 previews only logged paths and preflights conflicts when files will move.
|
|
2405
|
+
* Context-only navigation consults history without reading workspace contents.
|
|
1703
2406
|
*/
|
|
1704
|
-
async previewRewind(point: RestorePoint): Promise<RewindPreview> {
|
|
2407
|
+
async previewRewind(point: RestorePoint, mode: RewindMode = "both"): Promise<RewindPreview> {
|
|
2408
|
+
if (mode === "context") {
|
|
2409
|
+
const history = await readNodeHistory(this.config.logDir, point.node);
|
|
2410
|
+
if (!history.ok) return history;
|
|
2411
|
+
if (!history.nodes.find(part => part.node === point.node)?.events.some(event => event.id === point.atEvent)) return { ok: false, error: `NO_SUCH_EVENT: ${point.atEvent}` };
|
|
2412
|
+
const changes = await planCheckpointRestore(this.config.logDir, this.state.node, { node: point.node, atEvent: point.atEvent }).catch((): FileChanges => ({}));
|
|
2413
|
+
return { ok: true, willWrite: changedPaths(changes).filter(path => changes[path]!.after !== null),
|
|
2414
|
+
willDelete: changedPaths(changes).filter(path => changes[path]!.after === null) };
|
|
2415
|
+
}
|
|
2416
|
+
if (this.config.workspaceTracking !== "legacy") {
|
|
2417
|
+
try {
|
|
2418
|
+
const changes = await planCheckpointRestore(this.config.logDir, this.state.node, { node: point.node, atEvent: point.atEvent });
|
|
2419
|
+
if (this.config.blobDir) await preflightChanges(this.config.tools.root, this.config.blobDir, changes);
|
|
2420
|
+
return { ok: true, willWrite: changedPaths(changes).filter(path => changes[path]!.after !== null),
|
|
2421
|
+
willDelete: changedPaths(changes).filter(path => changes[path]!.after === null) };
|
|
2422
|
+
} catch (error) { return { ok: false, error: String(error) }; }
|
|
2423
|
+
}
|
|
1705
2424
|
const plan = await planRestoreToNode(
|
|
1706
2425
|
this.config.logDir, { node: point.node, atEvent: point.atEvent },
|
|
1707
2426
|
undefined, this.config.tools.root, this.ignore,
|
|
@@ -1721,14 +2440,12 @@ export class Session {
|
|
|
1721
2440
|
* still a node, so it is still a restore target, and "rewound too far" is
|
|
1722
2441
|
* recoverable rather than terminal.
|
|
1723
2442
|
*
|
|
1724
|
-
*
|
|
1725
|
-
*
|
|
1726
|
-
*
|
|
1727
|
-
* behaviour; we would additionally flag the whole restore as undeclared
|
|
1728
|
-
* damage at the next turn boundary. So the files move first and the node is
|
|
1729
|
-
* opened only if that worked.
|
|
2443
|
+
* P.3 records a separate physical file cursor for context-only navigation.
|
|
2444
|
+
* In combined mode, file restoration must succeed before opening the new
|
|
2445
|
+
* conversation node. File-only restoration appends its observed transition.
|
|
1730
2446
|
*/
|
|
1731
2447
|
async rewind(point: RestorePoint, mode: RewindMode = "both"): Promise<RewindResult> {
|
|
2448
|
+
if (this.config.workspaceTracking !== "legacy") return this.rewindJournal(point, mode);
|
|
1732
2449
|
const dir = this.config.logDir;
|
|
1733
2450
|
const root = this.config.tools.root;
|
|
1734
2451
|
const target = { node: point.node, atEvent: point.atEvent };
|
|
@@ -1817,7 +2534,7 @@ export class Session {
|
|
|
1817
2534
|
this.state.node = node;
|
|
1818
2535
|
this.state.events = [];
|
|
1819
2536
|
this.state.handoff = replayed.handoff;
|
|
1820
|
-
this.
|
|
2537
|
+
this.resetDerivedToolState();
|
|
1821
2538
|
this.state.depth = replayed.depth;
|
|
1822
2539
|
// The new node's prefix has never been sent, so nothing to anchor to.
|
|
1823
2540
|
this.state.anchor = null;
|
|
@@ -1868,6 +2585,20 @@ export class Session {
|
|
|
1868
2585
|
].sort();
|
|
1869
2586
|
}
|
|
1870
2587
|
|
|
2588
|
+
private resetDerivedToolState(): void {
|
|
2589
|
+
this.stall = null;
|
|
2590
|
+
this.recovery = null;
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
private async recoveryGuidance(tools: readonly string[]): Promise<ReturnType<typeof recoveryReview>> {
|
|
2594
|
+
if (this.recovery === null) {
|
|
2595
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
2596
|
+
if (!history.ok) throw new Error(history.error);
|
|
2597
|
+
this.recovery = advanceRecovery(emptyRecoveryState(), history.nodes.flatMap(node => node.events), path => resolve(this.config.tools.root, path));
|
|
2598
|
+
}
|
|
2599
|
+
return recoveryReview(this.recovery, tools);
|
|
2600
|
+
}
|
|
2601
|
+
|
|
1871
2602
|
private async *checkStall(): AsyncGenerator<LoopEvent, boolean> {
|
|
1872
2603
|
const limit = this.config.stallLimit ?? DEFAULT_STALL_LIMIT;
|
|
1873
2604
|
if (limit === 0) return false;
|
|
@@ -1894,8 +2625,8 @@ export class Session {
|
|
|
1894
2625
|
* prompt reads as exactly over the usable window.
|
|
1895
2626
|
*/
|
|
1896
2627
|
private async *maybeWindowCut(counter: TokenCounter, willContinue: boolean, overflowed = false): AsyncGenerator<LoopEvent, boolean> {
|
|
1897
|
-
const budget =
|
|
1898
|
-
if (!willContinue || budget
|
|
2628
|
+
const budget = this.policyLimits();
|
|
2629
|
+
if (!willContinue || budget.kind !== "window" || pendingToolCalls(this.state.events).length > 0) return false;
|
|
1899
2630
|
const project = () => projectContext({ frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events });
|
|
1900
2631
|
let projected = project();
|
|
1901
2632
|
const measured = anchoredCount(projected.messages, projected.system, this.state.anchor, counter, this.config.frozen.tools).tokens;
|
|
@@ -1906,7 +2637,7 @@ export class Session {
|
|
|
1906
2637
|
const replayed = await replayContext(this.config.logDir, this.state.node);
|
|
1907
2638
|
let draft: Handoff;
|
|
1908
2639
|
try {
|
|
1909
|
-
draft =
|
|
2640
|
+
draft = await this.boundCarried(deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events,
|
|
1910
2641
|
depth: replayed.depth, self: await digestedRef(this.config.logDir, this.state.node),
|
|
1911
2642
|
config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity } }));
|
|
1912
2643
|
} catch (error) {
|
|
@@ -1948,8 +2679,19 @@ export class Session {
|
|
|
1948
2679
|
return false;
|
|
1949
2680
|
}
|
|
1950
2681
|
|
|
2682
|
+
/** Every threshold the loop acts at. The footer and banner read the same function. */
|
|
2683
|
+
private policyLimits(): ContextLimits {
|
|
2684
|
+
return contextLimits({
|
|
2685
|
+
policy: this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
|
|
2686
|
+
contextWindow: this.provider.config.spec.contextWindow,
|
|
2687
|
+
outputReserve: this.provider.config.maxTokens,
|
|
2688
|
+
...(this.config.boundary ? { boundary: this.config.boundary } : {}),
|
|
2689
|
+
});
|
|
2690
|
+
}
|
|
2691
|
+
|
|
1951
2692
|
private async *maybeCut(counter: TokenCounter, canPrepare: boolean): AsyncGenerator<LoopEvent, boolean> {
|
|
1952
|
-
|
|
2693
|
+
const limits = this.policyLimits();
|
|
2694
|
+
if (limits.kind === "window") {
|
|
1953
2695
|
return yield* this.maybeWindowCut(counter, canPrepare);
|
|
1954
2696
|
}
|
|
1955
2697
|
const projected = projectContext({
|
|
@@ -1976,20 +2718,19 @@ export class Session {
|
|
|
1976
2718
|
const inheritedTokens = counter.count(floor.messages, floor.system, this.config.frozen.tools);
|
|
1977
2719
|
|
|
1978
2720
|
const policy = this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY;
|
|
1979
|
-
const cfg = { ...DEFAULT_BOUNDARY, ...this.config.boundary };
|
|
1980
2721
|
let draft: Handoff | null = null;
|
|
1981
|
-
if (
|
|
2722
|
+
if (limits.kind === "segment" && tokens >= limits.prepare) {
|
|
1982
2723
|
// Read the same full ancestry used by the actual cut. A bounded carried
|
|
1983
2724
|
// page is insufficient to decide whether all active work is completed.
|
|
1984
2725
|
const replayed = await replayContext(this.config.logDir, this.state.node);
|
|
1985
|
-
draft =
|
|
2726
|
+
draft = await this.boundCarried(deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events,
|
|
1986
2727
|
depth: replayed.depth, self: await digestedRef(this.config.logDir, this.state.node),
|
|
1987
2728
|
config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity } }));
|
|
1988
2729
|
const noteEnabled = this.config.frozen.tools.some((tool) => tool !== null && typeof tool === "object" && "name" in tool && tool.name === "note") &&
|
|
1989
2730
|
(this.config.tools.allow === undefined || this.config.tools.allow.includes("note"));
|
|
1990
|
-
if (canPrepare && this.config.guidance !== false && noteEnabled && tokens <
|
|
2731
|
+
if (canPrepare && this.config.guidance !== false && noteEnabled && tokens < limits.hard) {
|
|
1991
2732
|
const notice = boundaryPreparation({ events: this.state.events, tokens, inheritedTokens,
|
|
1992
|
-
continuationReady: draft.continuation_valid, config:
|
|
2733
|
+
continuationReady: draft.continuation_valid, config: limits.config });
|
|
1993
2734
|
if (notice) {
|
|
1994
2735
|
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...notice });
|
|
1995
2736
|
const updated = projectContext({ frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events });
|
|
@@ -2058,56 +2799,68 @@ export class Session {
|
|
|
2058
2799
|
*/
|
|
2059
2800
|
private async performCut(reason: CutReason, nextModel = this.provider.config.spec.id, retainedFrom?: string,
|
|
2060
2801
|
nextProvider = this.provider.config.spec.provider, nextThinking = this.provider.config.effort, nextContextRef?: string): Promise<void> {
|
|
2061
|
-
|
|
2062
|
-
const
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2802
|
+
this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
|
|
2803
|
+
const release = await this.rootTools.acquire("exclusive");
|
|
2804
|
+
try {
|
|
2805
|
+
const dir = this.config.logDir;
|
|
2806
|
+
const node = this.state.node;
|
|
2807
|
+
|
|
2808
|
+
const replayed = await replayContext(dir, node);
|
|
2809
|
+
const fromDisk = replayed.events;
|
|
2810
|
+
const retained = retainedFrom === undefined ? [] : retainedContext(replayed.contextEvents, retainedFrom);
|
|
2811
|
+
let guidanceTools = this.adviceNames();
|
|
2812
|
+
if (this.config.guidanceVersion === 1 && nextContextRef !== undefined) {
|
|
2813
|
+
const saved = await getBlob(join(dir, "artifacts"), nextContextRef);
|
|
2814
|
+
if (saved === null) throw new Error("missing successor guidance context");
|
|
2815
|
+
guidanceTools = this.adviceNames((JSON.parse(saved) as { frozen: FrozenPrefix }).frozen);
|
|
2816
|
+
}
|
|
2067
2817
|
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2818
|
+
// The cut event is part of the closed file. Digest after appending it
|
|
2819
|
+
// so predecessor.sha256 matches what closeNode writes into the manifest.
|
|
2820
|
+
await this.record({
|
|
2821
|
+
t: "cut",
|
|
2822
|
+
id: this.clock.nextId(),
|
|
2823
|
+
at: this.clock.now(),
|
|
2824
|
+
handoff_ref: handoffPath(dir, node),
|
|
2825
|
+
predecessor: this.state.handoff?.predecessor ?? null,
|
|
2826
|
+
reason,
|
|
2827
|
+
next_node: this.state.nextNode,
|
|
2828
|
+
next_model: nextModel,
|
|
2829
|
+
next_provider: nextProvider,
|
|
2830
|
+
next_thinking: nextThinking,
|
|
2831
|
+
...(nextContextRef === undefined ? {} : { next_context_ref: nextContextRef }),
|
|
2832
|
+
handoff_tokens: DEFAULT_HANDOFF_TOKENS,
|
|
2833
|
+
...(this.config.workingSet ? { working_set_version: this.config.workingSetVersion ?? 1 } : {}),
|
|
2834
|
+
...(this.config.guidanceVersion === 1 ? { guidance_tools: guidanceTools } : {}),
|
|
2835
|
+
...(retainedFrom === undefined ? {} : { retained_from: retainedFrom }),
|
|
2836
|
+
});
|
|
2837
|
+
const self = await digestedRef(dir, node);
|
|
2838
|
+
const handoff = await this.boundCarried(deriveHandoff({
|
|
2839
|
+
prev: replayed.fullHandoff,
|
|
2840
|
+
depth: replayed.depth,
|
|
2841
|
+
events: fromDisk,
|
|
2842
|
+
self,
|
|
2843
|
+
config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity },
|
|
2844
|
+
}), guidanceTools);
|
|
2845
|
+
await closeNode(dir, node, handoff, (await readNode(dir, node)).events.length, this.clock.now());
|
|
2846
|
+
|
|
2847
|
+
// One allocator for the whole session. A cut used to take node + 1 while
|
|
2848
|
+
// a branch took nextNode++, so a cut after a branch reached the branch's
|
|
2849
|
+
// id and overwrote its log.
|
|
2850
|
+
this.state.node = this.state.nextNode++;
|
|
2851
|
+
this.state.depth += 1;
|
|
2852
|
+
this.state.handoff = handoff;
|
|
2853
|
+
this.state.events = retained;
|
|
2854
|
+
// The new node's prefix has never been sent, so there is nothing to anchor
|
|
2855
|
+
// to until its first response comes back.
|
|
2856
|
+
this.state.anchor = null;
|
|
2857
|
+
// A cut declares a handoff basis: the closed node's events do not replay,
|
|
2858
|
+
// its derived state does. Relying on the default here would silently make
|
|
2859
|
+
// every boundary a fork and replay the whole history.
|
|
2860
|
+
await initNode(dir, this.state.node, self, this.clock.now(), "handoff");
|
|
2861
|
+
// The durable successor already belongs to the target provider, even if
|
|
2862
|
+
// the process exits before switchModel installs its in-memory adapter.
|
|
2863
|
+
await this.saveCursor(nextModel, nextProvider, nextThinking, nextContextRef);
|
|
2864
|
+
} finally { release(); }
|
|
2112
2865
|
}
|
|
2113
2866
|
}
|