@visual-z/xeno 0.8.4 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +31 -746
- package/bin/xeno.exe +26 -0
- package/install.cjs +88 -0
- package/package.json +18 -48
- package/docs/app-server.md +0 -115
- package/docs/foundations.md +0 -88
- package/docs/install.md +0 -244
- package/docs/providers.md +0 -172
- package/docs/research/foundation-clients-2026-10-09.md +0 -27
- package/docs/research/foundation-editing-2026-10-09.md +0 -56
- package/docs/research/foundation-network-2026-10-09.md +0 -37
- package/docs/research/foundation-workflow-2026-10-09.md +0 -37
- package/src/apps/acp-server.ts +0 -41
- package/src/apps/agent-profiles.ts +0 -19
- package/src/apps/app-server.ts +0 -30
- package/src/apps/approve.ts +0 -49
- package/src/apps/cli.ts +0 -99
- package/src/apps/commands.ts +0 -319
- package/src/apps/completions.ts +0 -87
- package/src/apps/control-transport.ts +0 -90
- package/src/apps/export-cli.ts +0 -24
- package/src/apps/goal-flow.ts +0 -70
- package/src/apps/image-draft.ts +0 -46
- package/src/apps/input.ts +0 -312
- package/src/apps/model-catalog.ts +0 -69
- package/src/apps/preferences.ts +0 -20
- package/src/apps/project-trust.ts +0 -9
- package/src/apps/provider-connect.ts +0 -346
- package/src/apps/question-ui.ts +0 -27
- package/src/apps/render.ts +0 -181
- package/src/apps/resources.ts +0 -66
- package/src/apps/review.ts +0 -109
- package/src/apps/session-hub.ts +0 -96
- package/src/apps/session-lifecycle.ts +0 -64
- package/src/apps/shell.ts +0 -2071
- package/src/apps/startup-input.ts +0 -135
- package/src/apps/startup.ts +0 -1677
- package/src/apps/ui/app.ts +0 -1818
- package/src/apps/ui/control-model.ts +0 -43
- package/src/apps/ui/extension-host.ts +0 -194
- package/src/apps/ui/image-cache.ts +0 -121
- package/src/apps/ui/palette.ts +0 -88
- package/src/apps/ui/project.ts +0 -127
- package/src/apps/ui/standalone.ts +0 -148
- package/src/apps/ui/status-model.ts +0 -68
- package/src/apps/ui/surface.ts +0 -91
- package/src/apps/ui/theme.ts +0 -86
- package/src/apps/update-check.ts +0 -112
- package/src/apps/workflow.ts +0 -58
- package/src/core/acceptance.ts +0 -139
- package/src/core/action-defaults.ts +0 -58
- package/src/core/activity-status.ts +0 -52
- package/src/core/agent-profile.ts +0 -130
- package/src/core/attachment-input.ts +0 -17
- package/src/core/blind-checks.ts +0 -72
- package/src/core/boundary.ts +0 -174
- package/src/core/branch-receipt.ts +0 -26
- package/src/core/catalog-merge.ts +0 -19
- package/src/core/checkpoints.ts +0 -85
- package/src/core/churn.ts +0 -61
- package/src/core/command-artifacts.ts +0 -94
- package/src/core/command-contract.ts +0 -151
- package/src/core/completion-action.ts +0 -20
- package/src/core/completion-review.ts +0 -98
- package/src/core/context-detail.ts +0 -37
- package/src/core/context-policy.ts +0 -133
- package/src/core/context-window.ts +0 -54
- package/src/core/continuation.ts +0 -130
- package/src/core/control-protocol.ts +0 -69
- package/src/core/control-view.ts +0 -39
- package/src/core/cycle.ts +0 -17
- package/src/core/data-query.ts +0 -201
- package/src/core/deadline.ts +0 -40
- package/src/core/derive.ts +0 -516
- package/src/core/draft-images.ts +0 -56
- package/src/core/edit-plan.ts +0 -200
- package/src/core/environment.ts +0 -181
- package/src/core/event-validation.ts +0 -220
- package/src/core/events.ts +0 -670
- package/src/core/evidence-ladder.ts +0 -62
- package/src/core/evidence-links.ts +0 -68
- package/src/core/evidence.ts +0 -240
- package/src/core/execution.ts +0 -18
- package/src/core/exhaustive.ts +0 -18
- package/src/core/export.ts +0 -179
- package/src/core/extension-flags.ts +0 -14
- package/src/core/external-effects.ts +0 -488
- package/src/core/failure-recovery.ts +0 -173
- package/src/core/file-precondition.ts +0 -78
- package/src/core/file-references.ts +0 -35
- package/src/core/frontmatter.ts +0 -213
- package/src/core/git-status.ts +0 -29
- package/src/core/goal-view.ts +0 -64
- package/src/core/goal.ts +0 -200
- package/src/core/handoff.ts +0 -479
- package/src/core/history-query.ts +0 -139
- package/src/core/history.ts +0 -237
- package/src/core/image-limits.ts +0 -72
- package/src/core/images.ts +0 -46
- package/src/core/integration-health.ts +0 -34
- package/src/core/json-event.ts +0 -131
- package/src/core/json-pointer.ts +0 -46
- package/src/core/keymap.ts +0 -153
- package/src/core/lsp-framing.ts +0 -34
- package/src/core/mcp-config.ts +0 -68
- package/src/core/model-picker.ts +0 -64
- package/src/core/model-preferences.ts +0 -28
- package/src/core/model-replay.ts +0 -22
- package/src/core/model-scope.ts +0 -53
- package/src/core/note-guidance.ts +0 -34
- package/src/core/oauth-fields.ts +0 -44
- package/src/core/observation-view.ts +0 -152
- package/src/core/outcome.ts +0 -44
- package/src/core/overflow.ts +0 -59
- package/src/core/packages.ts +0 -47
- package/src/core/patch.ts +0 -130
- package/src/core/path-suggestions.ts +0 -59
- package/src/core/permissions.ts +0 -410
- package/src/core/process-receipt.ts +0 -105
- package/src/core/process-state.ts +0 -37
- package/src/core/program-status.ts +0 -40
- package/src/core/progress.ts +0 -150
- package/src/core/project.ts +0 -431
- package/src/core/prompt-policy.ts +0 -46
- package/src/core/prompts.ts +0 -111
- package/src/core/question.ts +0 -94
- package/src/core/queue.ts +0 -78
- package/src/core/read-bounds.ts +0 -24
- package/src/core/read-observation.ts +0 -106
- package/src/core/recovery-sources.ts +0 -74
- package/src/core/regex-dialect.ts +0 -25
- package/src/core/request-budget.ts +0 -23
- package/src/core/request-progress.ts +0 -87
- package/src/core/request-state.ts +0 -273
- package/src/core/resource-selection.ts +0 -36
- package/src/core/response-recovery.ts +0 -59
- package/src/core/resume.ts +0 -59
- package/src/core/review.ts +0 -68
- package/src/core/ripgrep-release.ts +0 -64
- package/src/core/runtime-target.ts +0 -94
- package/src/core/scheduling.ts +0 -8
- package/src/core/search-observation.ts +0 -97
- package/src/core/secrets.ts +0 -188
- package/src/core/session-archive.ts +0 -4
- package/src/core/session-browser.ts +0 -82
- package/src/core/session-entry.ts +0 -61
- package/src/core/session-exit.ts +0 -42
- package/src/core/session-format.ts +0 -169
- package/src/core/session-position.ts +0 -26
- package/src/core/session-tree.ts +0 -147
- package/src/core/settings.ts +0 -192
- package/src/core/skills.ts +0 -165
- package/src/core/sql-query.ts +0 -211
- package/src/core/stall.ts +0 -128
- package/src/core/stop-report.ts +0 -68
- package/src/core/text-normalize.ts +0 -81
- package/src/core/theme.ts +0 -166
- package/src/core/thinking.ts +0 -26
- package/src/core/tokens.ts +0 -151
- package/src/core/tool-contract.ts +0 -153
- package/src/core/tool-discovery.ts +0 -87
- package/src/core/tool-failure.ts +0 -81
- package/src/core/tool-input.ts +0 -44
- package/src/core/transcript.ts +0 -108
- package/src/core/tui-mode.ts +0 -29
- package/src/core/update-check.ts +0 -157
- package/src/core/version.ts +0 -55
- package/src/core/web-content.ts +0 -80
- package/src/core/work-state.ts +0 -154
- package/src/core/workflow-mode.ts +0 -34
- package/src/core/working-set.ts +0 -119
- package/src/core/yaml.ts +0 -282
- package/src/runtime/acp-agent.ts +0 -289
- package/src/runtime/agent-profiles.ts +0 -21
- package/src/runtime/auth.ts +0 -367
- package/src/runtime/blind-check.ts +0 -36
- package/src/runtime/branch-jobs.ts +0 -36
- package/src/runtime/catalog.ts +0 -285
- package/src/runtime/clipboard.ts +0 -125
- package/src/runtime/command-artifacts.ts +0 -64
- package/src/runtime/connection-reuse.ts +0 -40
- package/src/runtime/control-client.ts +0 -149
- package/src/runtime/control-connection.ts +0 -139
- package/src/runtime/counter-check.ts +0 -74
- package/src/runtime/environment-tools.ts +0 -60
- package/src/runtime/evidence-notes.ts +0 -35
- package/src/runtime/exec-sessions.ts +0 -456
- package/src/runtime/execution-control.ts +0 -40
- package/src/runtime/extension-loader.ts +0 -37
- package/src/runtime/extension-ui.ts +0 -47
- package/src/runtime/extensions.ts +0 -370
- package/src/runtime/formatter-tool.ts +0 -38
- package/src/runtime/formatter.ts +0 -50
- package/src/runtime/foundation.ts +0 -116
- package/src/runtime/history-cache.ts +0 -57
- package/src/runtime/history-tools.ts +0 -168
- package/src/runtime/images.ts +0 -80
- package/src/runtime/integrations.ts +0 -216
- package/src/runtime/loop.ts +0 -3108
- package/src/runtime/lsp-client.ts +0 -201
- package/src/runtime/mcp-client.ts +0 -264
- package/src/runtime/mcp-oauth.ts +0 -168
- package/src/runtime/mcp-schema.ts +0 -51
- package/src/runtime/model-config.ts +0 -232
- package/src/runtime/model-data.LICENSE +0 -27
- package/src/runtime/model-data.ts +0 -21
- package/src/runtime/model-discovery.ts +0 -208
- package/src/runtime/model-replay.ts +0 -10
- package/src/runtime/model-scope.ts +0 -20
- package/src/runtime/model-sources.ts +0 -151
- package/src/runtime/model.ts +0 -686
- package/src/runtime/models-fetch.ts +0 -57
- package/src/runtime/oauth-device.ts +0 -86
- package/src/runtime/oauth.ts +0 -853
- package/src/runtime/open-image.ts +0 -42
- package/src/runtime/openai-responses.ts +0 -304
- package/src/runtime/openai.ts +0 -697
- package/src/runtime/output-schema.ts +0 -26
- package/src/runtime/package-manager.ts +0 -125
- package/src/runtime/patch-tool.ts +0 -38
- package/src/runtime/permission-resources.ts +0 -18
- package/src/runtime/pi-provider.ts +0 -423
- package/src/runtime/program-status.ts +0 -38
- package/src/runtime/progress.ts +0 -26
- package/src/runtime/provider-access.ts +0 -21
- package/src/runtime/provider-connect.ts +0 -109
- package/src/runtime/provider-presets.ts +0 -98
- package/src/runtime/provider-protocols.ts +0 -29
- package/src/runtime/provider-request.ts +0 -87
- package/src/runtime/provider-tool-policy.ts +0 -33
- package/src/runtime/provider.ts +0 -580
- package/src/runtime/question-tool.ts +0 -62
- package/src/runtime/retry.ts +0 -447
- package/src/runtime/review-tool.ts +0 -48
- package/src/runtime/rich-text.ts +0 -36
- package/src/runtime/runtime-target.ts +0 -149
- package/src/runtime/secrets.ts +0 -79
- package/src/runtime/self-update.ts +0 -74
- package/src/runtime/session-control.ts +0 -265
- package/src/runtime/session-hub.ts +0 -85
- package/src/runtime/stream-idle.ts +0 -80
- package/src/runtime/tool-broker.ts +0 -137
- package/src/runtime/tool-discovery.ts +0 -58
- package/src/runtime/tool-queue.ts +0 -77
- package/src/runtime/tools.ts +0 -1820
- package/src/runtime/turn-effects.ts +0 -31
- package/src/runtime/update-check.ts +0 -48
- package/src/runtime/usage.ts +0 -253
- package/src/runtime/version.ts +0 -12
- package/src/runtime/web-http.ts +0 -56
- package/src/runtime/web-tools.ts +0 -88
- package/src/runtime/working-notes.ts +0 -60
- package/src/runtime/workspace-lifecycle.ts +0 -141
- package/src/store/acceptance.ts +0 -15
- package/src/store/agent-profile-files.ts +0 -13
- package/src/store/atomic.ts +0 -31
- package/src/store/auth-store.ts +0 -50
- package/src/store/blind-checks.ts +0 -32
- package/src/store/blobs.ts +0 -143
- package/src/store/checkpoint-history.ts +0 -32
- package/src/store/command-artifacts.ts +0 -96
- package/src/store/command.ts +0 -111
- package/src/store/config.ts +0 -69
- package/src/store/context-files.ts +0 -436
- package/src/store/file-checkpoints.ts +0 -108
- package/src/store/file-index.ts +0 -174
- package/src/store/foundation-config.ts +0 -24
- package/src/store/git-checkpoints.ts +0 -67
- package/src/store/git-status.ts +0 -15
- package/src/store/history-search.ts +0 -19
- package/src/store/log.ts +0 -725
- package/src/store/lsp-document.ts +0 -30
- package/src/store/mcp-credentials.ts +0 -85
- package/src/store/models.ts +0 -60
- package/src/store/observations.ts +0 -224
- package/src/store/packages.ts +0 -98
- package/src/store/patch.ts +0 -92
- package/src/store/permission-paths.ts +0 -34
- package/src/store/process-receipts.ts +0 -34
- package/src/store/query-source.ts +0 -49
- package/src/store/recovery.ts +0 -44
- package/src/store/replay.ts +0 -76
- package/src/store/resource-index.ts +0 -170
- package/src/store/resources.ts +0 -285
- package/src/store/restore.ts +0 -110
- package/src/store/review.ts +0 -109
- package/src/store/runtime-target.ts +0 -64
- package/src/store/search.ts +0 -728
- package/src/store/secrets.ts +0 -44
- package/src/store/serial.ts +0 -13
- package/src/store/session-archive.ts +0 -165
- package/src/store/session-browser.ts +0 -68
- package/src/store/session-position.ts +0 -27
- package/src/store/sessions.ts +0 -525
- package/src/store/source-cancellation.ts +0 -6
- package/src/store/tool-binaries.ts +0 -90
- package/src/store/trust.ts +0 -82
- package/src/store/update-cache.ts +0 -36
- package/src/store/user-settings.ts +0 -30
- package/src/store/workspace-clone.ts +0 -42
- package/src/store/workspace-observation.ts +0 -113
- package/src/store/workspace.ts +0 -115
- package/src/store/worktree.ts +0 -278
- package/src/tui/ansi-spans.ts +0 -126
- package/src/tui/diff.ts +0 -160
- package/src/tui/exhaustive.ts +0 -14
- package/src/tui/fuzzy.ts +0 -75
- package/src/tui/style.ts +0 -208
- package/src/tui/ui/atomic-draft.ts +0 -121
- package/src/tui/ui/branch-panel.ts +0 -47
- package/src/tui/ui/canvas.ts +0 -96
- package/src/tui/ui/colors.ts +0 -70
- package/src/tui/ui/composer.ts +0 -199
- package/src/tui/ui/control-panel.ts +0 -25
- package/src/tui/ui/control-sidebar.ts +0 -49
- package/src/tui/ui/dialog.ts +0 -398
- package/src/tui/ui/draft.ts +0 -23
- package/src/tui/ui/duration.ts +0 -16
- package/src/tui/ui/edge.ts +0 -186
- package/src/tui/ui/image-size.ts +0 -44
- package/src/tui/ui/ink.ts +0 -210
- package/src/tui/ui/items.ts +0 -42
- package/src/tui/ui/keys.ts +0 -40
- package/src/tui/ui/layout.ts +0 -74
- package/src/tui/ui/list-model.ts +0 -86
- package/src/tui/ui/list-view.ts +0 -172
- package/src/tui/ui/mascot.ts +0 -67
- package/src/tui/ui/messages.ts +0 -518
- package/src/tui/ui/motion.ts +0 -28
- package/src/tui/ui/paths.ts +0 -10
- package/src/tui/ui/pictures.ts +0 -108
- package/src/tui/ui/prompt-history.ts +0 -60
- package/src/tui/ui/screen.ts +0 -579
- package/src/tui/ui/status.ts +0 -403
- package/src/tui/ui/styled.ts +0 -85
- package/src/tui/ui/tool-card.ts +0 -150
- package/src/tui/width.ts +0 -567
- package/src/vendor/pi-rich/LICENSE.upstream +0 -21
- package/src/vendor/pi-rich/UPSTREAM.json +0 -17
- package/src/vendor/pi-rich/clipboard-command.ts +0 -44
- package/src/vendor/pi-rich/exif-orientation.ts +0 -182
- package/src/vendor/pi-rich/html.ts +0 -51
- package/src/vendor/pi-rich/image-convert.ts +0 -49
- package/src/vendor/pi-rich/image-resize-core.ts +0 -164
- package/src/vendor/pi-rich/image-resize-worker.ts +0 -42
- package/src/vendor/pi-rich/image-resize.ts +0 -123
- package/src/vendor/pi-rich/mime.ts +0 -116
- package/src/vendor/pi-rich/photon.ts +0 -139
- package/src/vendor/pi-rich/syntax-highlight.ts +0 -214
package/src/runtime/loop.ts
DELETED
|
@@ -1,3108 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The agent loop.
|
|
3
|
-
*
|
|
4
|
-
* Deliberately thin. Everything that decides anything -- what the model sees,
|
|
5
|
-
* when to cut a node, what the handoff says -- is a pure function in core, so
|
|
6
|
-
* this file only sequences them and performs the I/O between. That split is
|
|
7
|
-
* what makes the interesting behaviour testable without a network, and what
|
|
8
|
-
* keeps the loop cheap to throw away when the next model generation wants a
|
|
9
|
-
* different shape.
|
|
10
|
-
*
|
|
11
|
-
* One turn:
|
|
12
|
-
*
|
|
13
|
-
* project the log -> send -> record the assistant turn -> run tools ->
|
|
14
|
-
* record results -> decide whether to cut -> maybe cut
|
|
15
|
-
*
|
|
16
|
-
* The cut decision happens only here, between complete turns, because a node
|
|
17
|
-
* boundary between a tool_use and its result would produce an unpairable
|
|
18
|
-
* request.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import type { LogEvent, AssistantBlock, NodeRef, CutReason, ToolInvocationEvent, FailureCause } from "../core/events.ts";
|
|
22
|
-
import { userInputData, submissionImages, type UserInput, type ImageRef } from "../core/images.ts";
|
|
23
|
-
import { hydrateImages } from "./images.ts";
|
|
24
|
-
import type { Handoff } from "../core/handoff.ts";
|
|
25
|
-
import { boundHandoff, DEFAULT_HANDOFF_TOKENS } from "../core/history.ts";
|
|
26
|
-
import { CURRENT_WORKING_SET_VERSION } from "../core/working-set.ts";
|
|
27
|
-
import { boundaryPreparation, continuationGuidance } from "../core/continuation.ts";
|
|
28
|
-
import type { FrozenPrefix, Message, ProjectInput } from "../core/project.ts";
|
|
29
|
-
import { projectContext, toolCallIdentityViolations } from "../core/project.ts";
|
|
30
|
-
import { parseSessionContext, type SessionContext } from "../core/session-format.ts";
|
|
31
|
-
import { activeGoal, goalContract, goalDecision, idleGoalChecks, type ActiveGoal } from "../core/goal.ts";
|
|
32
|
-
import { counterCheck } from "./counter-check.ts";
|
|
33
|
-
import { runBlindChecks } from "./blind-check.ts";
|
|
34
|
-
import { blindExposure, type BlindChecks } from "../core/blind-checks.ts";
|
|
35
|
-
import { deriveHandoff, carryForward } from "../core/derive.ts";
|
|
36
|
-
import { latestObservedFileVersion } from "../core/file-precondition.ts";
|
|
37
|
-
import { latestProcessReceipt } from "../core/process-receipt.ts";
|
|
38
|
-
import { advanceRecovery, emptyRecoveryState, recoveryReview, type RecoveryState } from "../core/failure-recovery.ts";
|
|
39
|
-
import { shouldStopAndAsk, type BoundaryConfig } from "../core/boundary.ts";
|
|
40
|
-
import {
|
|
41
|
-
contextLimits, decideContext, DEFAULT_CONTEXT_POLICY, estimateBoundaryCost, isContextPolicy,
|
|
42
|
-
type ContextLimits, type ContextPolicy,
|
|
43
|
-
} from "../core/context-policy.ts";
|
|
44
|
-
import { retainedContext, selectRecentContext } from "../core/context-window.ts";
|
|
45
|
-
import { advanceStall, emptyStallState, stallDecision, DEFAULT_STALL_LIMIT, type StallState } from "../core/stall.ts";
|
|
46
|
-
import { questionRequest, validateQuestionReply } from "../core/question.ts";
|
|
47
|
-
import { DEFAULT_OUTPUT_RECOVERY_LIMIT, isCompleteResponse, incompleteToolText, knownUnexecutedTool, outputRecoveryNotice, validateOutputRecoveryLimit } from "../core/response-recovery.ts";
|
|
48
|
-
import { join, relative, resolve } from "node:path";
|
|
49
|
-
import { randomUUID } from "node:crypto";
|
|
50
|
-
import { pendingToolCalls } from "../core/execution.ts";
|
|
51
|
-
import { declaredToolNames, recordedDeclarations } from "../core/tool-discovery.ts";
|
|
52
|
-
import { DISCOVERY_TOOL, DISCOVERY_TOOL_NAME, currentDeferredTools, discoveryFingerprint, discoveryPermitted, searchDeferredTools } from "./tool-discovery.ts";
|
|
53
|
-
import { executionOutcome, modelOutcome, type ExecutionOutcome, type ExecutionReason } from "../core/outcome.ts";
|
|
54
|
-
import { finalizationNotice, requestPhase, validateRequestBudget } from "../core/request-budget.ts";
|
|
55
|
-
import { stopReport } from "../core/stop-report.ts";
|
|
56
|
-
import { supportsToolDisabling, type ProviderSendOptions } from "./provider-tool-policy.ts";
|
|
57
|
-
import { commandCompletion } from "../core/command-contract.ts";
|
|
58
|
-
import { ExecutionControl } from "./execution-control.ts";
|
|
59
|
-
import { BranchJobs } from "./branch-jobs.ts";
|
|
60
|
-
import { HistoryCache } from "./history-cache.ts";
|
|
61
|
-
import { RuntimeTargets } from "./runtime-target.ts";
|
|
62
|
-
import { ToolBroker, MAX_NESTED_ARGUMENT_BYTES, MAX_INLINE_RESULT_DATA_BYTES, type BrokerReceipt, type BrokerExecution } from "./tool-broker.ts";
|
|
63
|
-
import { ToolQueue } from "./tool-queue.ts";
|
|
64
|
-
import { NATIVE_COMMAND_NAME, nativeCommandContext, nativeCommandResult, type NativeCommandInput, type NativeCommandResult } from "./tools.ts";
|
|
65
|
-
import { isJsonValue, type JsonValue } from "../core/tool-contract.ts";
|
|
66
|
-
import { archiveObservation } from "../store/observations.ts";
|
|
67
|
-
import { disposeExecSessions, runningSessions } from "./exec-sessions.ts";
|
|
68
|
-
import { branchStatus, processStatus, deriveProcesses, type ProcessObservation } from "../core/process-state.ts";
|
|
69
|
-
import { schedulingWindow } from "../core/scheduling.ts";
|
|
70
|
-
import { estimateTokens, anchoredCount, scaledCounter, type TokenCounter, type Anchor } from "../core/tokens.ts";
|
|
71
|
-
import { isContextOverflow } from "../core/overflow.ts";
|
|
72
|
-
import { requestCompletion } from "../core/completion-review.ts";
|
|
73
|
-
import { currentRequestRecords, deriveRequestState, type RequestState } from "../core/request-state.ts";
|
|
74
|
-
import { churnReview } from "../core/churn.ts";
|
|
75
|
-
import { deriveBranchReceipt } from "../core/branch-receipt.ts";
|
|
76
|
-
import { skillTools, type Skill } from "../core/skills.ts";
|
|
77
|
-
import type { WorkItem } from "../core/work-state.ts";
|
|
78
|
-
import { parseAcceptanceContract, type AcceptanceContract, type AcceptanceCriterion, type AcceptanceState } from "../core/acceptance.ts";
|
|
79
|
-
import { deadlineNotice } from "../core/deadline.ts";
|
|
80
|
-
import { describeFailure, failureCause, statusOf } from "./retry.ts";
|
|
81
|
-
import { XENO_VERSION } from "./version.ts";
|
|
82
|
-
import { SECRET_GUIDANCE, SecretKeeper } from "./secrets.ts";
|
|
83
|
-
import { secretEnvironmentValues } from "../core/secrets.ts";
|
|
84
|
-
import { copyVault } from "../store/secrets.ts";
|
|
85
|
-
import { assertNever } from "../core/exhaustive.ts";
|
|
86
|
-
import { isLogEvent } from "../core/event-validation.ts";
|
|
87
|
-
import {
|
|
88
|
-
initNode, appendEvent, readNode, closeNode, digestNode, handoffPath, nodePath, manifestPath,
|
|
89
|
-
listNodes, readManifest, readCursor, readCursorAt, writeCursor,
|
|
90
|
-
} from "../store/log.ts";
|
|
91
|
-
import { replayContext, carriedHandoff } from "../store/replay.ts";
|
|
92
|
-
import { recoverCut } from "../store/recovery.ts";
|
|
93
|
-
import { runHistoryTool } from "./history-tools.ts";
|
|
94
|
-
import { prepareWorkingNote } from "./working-notes.ts";
|
|
95
|
-
import { copyAncestryTo } from "../store/sessions.ts";
|
|
96
|
-
import { FailedResponseError, type Provider, type Turn, type StreamDelta } from "./provider.ts";
|
|
97
|
-
import { putBlob, getBlob } from "../store/blobs.ts";
|
|
98
|
-
import { hydrateProcessReceipts } from "../store/process-receipts.ts";
|
|
99
|
-
import { readNodeHistory } from "../store/restore.ts";
|
|
100
|
-
import { createWorktree, listWorktrees, mainWorktree, persistentWorktreeParent, removeWorktree } from "../store/worktree.ts";
|
|
101
|
-
import {
|
|
102
|
-
parseNote, matchGate, branchTools, finishToolResult, prepareNormalizedTool, presentObservation, toolDefinition, modelToolSchema, adviceTools,
|
|
103
|
-
BRANCH_TOOL_NAMES, WRITING_BRANCH_TOOL_NAMES,
|
|
104
|
-
type ToolContext, type Annotation, type ToolResult,
|
|
105
|
-
} from "./tools.ts";
|
|
106
|
-
import { diagnosePrefix, totalUsage, type UsageSample, type UsageTotals } from "./usage.ts";
|
|
107
|
-
import { planResume } from "../core/resume.ts";
|
|
108
|
-
import type { Phase } from "../core/queue.ts";
|
|
109
|
-
import { sameModel } from "../core/session-position.ts";
|
|
110
|
-
import type { Effort } from "./model.ts";
|
|
111
|
-
import { providerReplayKey, nativeReplay } from "./model-replay.ts";
|
|
112
|
-
import { needsReplayCheckpoint } from "../core/model-replay.ts";
|
|
113
|
-
import { resolveHistoryPosition } from "../store/session-position.ts";
|
|
114
|
-
import { observeCommand } from "../store/command.ts";
|
|
115
|
-
import { headTailView } from "../core/observation-view.ts";
|
|
116
|
-
import { withProgress } from "./progress.ts";
|
|
117
|
-
import { WorkspaceLifecycle } from "./workspace-lifecycle.ts";
|
|
118
|
-
import { changedPaths, invertFileChanges, sameVersion, type CheckpointEvent, type FileChanges } from "../core/checkpoints.ts";
|
|
119
|
-
import type { GitCheckpoint } from "../store/git-checkpoints.ts";
|
|
120
|
-
import { readEditableFile, preflightChanges, applyFileChanges } from "../store/file-checkpoints.ts";
|
|
121
|
-
import { planCheckpointRestore } from "../store/checkpoint-history.ts";
|
|
122
|
-
import { configDir } from "../store/config.ts";
|
|
123
|
-
|
|
124
|
-
/** Ids and timestamps come from here so the loop stays the only impure part. */
|
|
125
|
-
export interface Clock {
|
|
126
|
-
now(): string;
|
|
127
|
-
nextId(): string;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export function systemClock(): Clock {
|
|
131
|
-
return {
|
|
132
|
-
now: () => new Date().toISOString(),
|
|
133
|
-
nextId: () => randomUUID(),
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Per-request options that are not session configuration. */
|
|
138
|
-
export interface RequestOptions {
|
|
139
|
-
/**
|
|
140
|
-
* A read-only planning request (S.5 goal drafts). The Plan-mode tool gate
|
|
141
|
-
* refuses anything that could change the workspace, the request runs at
|
|
142
|
-
* most READ_ONLY_REQUEST_LIMIT model requests with the last one answering
|
|
143
|
-
* without tools, and no goal or completion check applies. A draft written
|
|
144
|
-
* without seeing the workspace guessed its commands, and a request with
|
|
145
|
-
* tools disabled outright was answered with tool calls anyway
|
|
146
|
-
* (fix-goal-read-only-draft).
|
|
147
|
-
*/
|
|
148
|
-
readOnly?: boolean;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Assumed, not measured: room to list the workspace and read a few files
|
|
153
|
-
* before answering. The last request answers with tools disabled.
|
|
154
|
-
*/
|
|
155
|
-
export const READ_ONLY_REQUEST_LIMIT = 8;
|
|
156
|
-
const READ_ONLY_HINT = "this request only reads the workspace. Use read, find or grep, then answer.";
|
|
157
|
-
|
|
158
|
-
export interface LoopEvent {
|
|
159
|
-
kind:
|
|
160
|
-
| "turn_start"
|
|
161
|
-
| "assistant_text"
|
|
162
|
-
| "thinking"
|
|
163
|
-
| "text_delta"
|
|
164
|
-
| "thinking_delta"
|
|
165
|
-
| "discard"
|
|
166
|
-
| "progress"
|
|
167
|
-
| "tool_call"
|
|
168
|
-
| "tool_result"
|
|
169
|
-
| "usage"
|
|
170
|
-
| "cut"
|
|
171
|
-
| "stop_and_ask"
|
|
172
|
-
| "branch_start"
|
|
173
|
-
| "branch_done"
|
|
174
|
-
| "done";
|
|
175
|
-
text?: string;
|
|
176
|
-
tool?: { name: string; input: unknown };
|
|
177
|
-
ok?: boolean;
|
|
178
|
-
usage?: UsageSample;
|
|
179
|
-
prefix?: string;
|
|
180
|
-
node?: number;
|
|
181
|
-
reason?: string;
|
|
182
|
-
/** branch_start: wall-clock milliseconds, for asserting the stagger. */
|
|
183
|
-
at?: number;
|
|
184
|
-
outcome?: ExecutionOutcome;
|
|
185
|
-
/** done: credential-free cause of a failed request, recorded on its outcome. */
|
|
186
|
-
failure?: FailureCause;
|
|
187
|
-
/** Q23: task evidence is separate from the execution outcome; rebuilt from original receipts. */
|
|
188
|
-
request?: RequestState;
|
|
189
|
-
progress?: Extract<StreamDelta, { kind: "tool" | "retry" }> | { kind: "output"; text: string; name: string }
|
|
190
|
-
| { kind: "waiting"; session: number; active: boolean };
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Ceilings on branching. Every one of them is a bound on an otherwise
|
|
195
|
-
* unbounded resource, and the defaults are conservative because the measured
|
|
196
|
-
* evidence says fan-out is usually a loss: 2.6x the tokens and about five
|
|
197
|
-
* times the cost of the sequential path, while being slower.
|
|
198
|
-
*/
|
|
199
|
-
export interface BranchLimits {
|
|
200
|
-
/** How deep branching may nest. 2 means the parent may branch, a branch may not. */
|
|
201
|
-
maxBranchDepth: number;
|
|
202
|
-
/** Branches one assistant turn may open. */
|
|
203
|
-
maxBranchesPerTurn: number;
|
|
204
|
-
/** Branches a whole session may open. */
|
|
205
|
-
maxBranchesPerSession: number;
|
|
206
|
-
/**
|
|
207
|
-
* Delay between starting sibling branches.
|
|
208
|
-
*
|
|
209
|
-
* Simultaneous cold spawns race the prompt cache: a sibling six seconds
|
|
210
|
-
* behind has been measured writing its full 52k prefix with zero cache
|
|
211
|
-
* read, because the first had not finished writing it.
|
|
212
|
-
*/
|
|
213
|
-
branchStaggerMs: number;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
export const DEFAULT_BRANCH_LIMITS: BranchLimits = {
|
|
217
|
-
maxBranchDepth: 2,
|
|
218
|
-
maxBranchesPerTurn: 3,
|
|
219
|
-
maxBranchesPerSession: 10,
|
|
220
|
-
branchStaggerMs: 1500,
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
export interface SessionConfig {
|
|
224
|
-
/**
|
|
225
|
-
* Where secret-named environment values are read from (S.9). Defaults to
|
|
226
|
-
* process.env; a test supplies its own so it never depends on the machine.
|
|
227
|
-
*/
|
|
228
|
-
secretEnvironment?: Readonly<Record<string, string | undefined>>;
|
|
229
|
-
/** Runtime-only output contract, explicitly selected by the current caller. */
|
|
230
|
-
validateOutput?: (text: string) => { ok: boolean; error?: string };
|
|
231
|
-
/** Persist a selector, never persisted profile code or authorization (D6/D13). */
|
|
232
|
-
agentProfile?: { name: string; workflowExplicit: boolean };
|
|
233
|
-
/** An explicit mode command's capability default, independent of role authority. */
|
|
234
|
-
workflowToolProfile?: "foundation";
|
|
235
|
-
shellCheckpoints?: boolean;
|
|
236
|
-
skills?: readonly Skill[];
|
|
237
|
-
acceptanceContract?: AcceptanceContract | null;
|
|
238
|
-
/** feat-blind-checks: hidden checks, frozen at session start; never part of any projection. */
|
|
239
|
-
blindChecks?: BlindChecks;
|
|
240
|
-
strictVerification?: boolean;
|
|
241
|
-
/** O.10: opt-in carried view (source-priority, R9); evaluations compare it against D20's plain handoff. */
|
|
242
|
-
workingSet?: boolean;
|
|
243
|
-
logDir: string;
|
|
244
|
-
frozen: FrozenPrefix;
|
|
245
|
-
tools: ToolContext;
|
|
246
|
-
boundary?: Partial<BoundaryConfig>;
|
|
247
|
-
/** New sessions default to windowed replay; saved sessions keep their policy. */
|
|
248
|
-
contextPolicy?: ContextPolicy;
|
|
249
|
-
/** Complete tool exchanges without new evidence; zero disables this watchdog. */
|
|
250
|
-
stallLimit?: number;
|
|
251
|
-
/** Bounded appended continuations after output truncation; saved sessions keep this policy. */
|
|
252
|
-
outputRecoveryLimit?: number;
|
|
253
|
-
counter?: TokenCounter;
|
|
254
|
-
/** Hard ceiling on turns, so a non-converging run cannot spend forever. */
|
|
255
|
-
maxTurns: number;
|
|
256
|
-
/** Experimental ablation only; normal sessions append durable working-state guidance. */
|
|
257
|
-
guidance?: boolean;
|
|
258
|
-
/**
|
|
259
|
-
* One-time reconciliation notices: an unverified finish
|
|
260
|
-
* (core/completion-review.ts) and a rut of repeated failures or edits
|
|
261
|
-
* (core/churn.ts). On unless false. Benchmark runners turn them off so their
|
|
262
|
-
* protocol matches the runs they are compared against; a runtime policy,
|
|
263
|
-
* not persisted.
|
|
264
|
-
*/
|
|
265
|
-
harnessReviews?: boolean;
|
|
266
|
-
/**
|
|
267
|
-
* Wall-clock budget per request, for runs judged against a deadline
|
|
268
|
-
* (core/deadline.ts). Absent means no notice; nothing is ever stopped by it.
|
|
269
|
-
*/
|
|
270
|
-
timeBudgetMs?: number;
|
|
271
|
-
/** Replies before a missing next step is reminded (core/continuation.ts); absent means the core default. */
|
|
272
|
-
continuationAfter?: number;
|
|
273
|
-
branches?: Partial<BranchLimits>;
|
|
274
|
-
/**
|
|
275
|
-
* Provider for branches. A branch legitimately runs on its own model -- a
|
|
276
|
-
* cheaper one is often right for read-only exploration -- so this is a
|
|
277
|
-
* seam rather than a hook. Defaults to the parent's provider.
|
|
278
|
-
*/
|
|
279
|
-
branchProvider?: () => Provider;
|
|
280
|
-
/** Depth of this session in the branch tree. Zero for a top-level session. */
|
|
281
|
-
branchDepth?: number;
|
|
282
|
-
/** O.2: stable across segment cuts; branches get independent work identities. */
|
|
283
|
-
workNamespace?: string;
|
|
284
|
-
/** O.5: default 3 is a conservative runtime quota, not a context threshold. */
|
|
285
|
-
maxParallelTools?: number;
|
|
286
|
-
/**
|
|
287
|
-
* Where blobs go. Snapshots are off when unset, which keeps the loop
|
|
288
|
-
* usable without the store -- a read-only branch has nothing to snapshot.
|
|
289
|
-
*/
|
|
290
|
-
blobDir?: string;
|
|
291
|
-
imageOptions?: { readonly autoResize?: boolean; readonly blockImages?: boolean };
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
export interface SessionState {
|
|
295
|
-
node: number;
|
|
296
|
-
depth: number;
|
|
297
|
-
handoff: Handoff | null;
|
|
298
|
-
events: LogEvent[];
|
|
299
|
-
usage: UsageSample[];
|
|
300
|
-
/**
|
|
301
|
-
* The last prompt size the provider actually charged for. Boundary decisions
|
|
302
|
-
* anchor to it so only the delta since is estimated -- see core/tokens.ts.
|
|
303
|
-
*/
|
|
304
|
-
anchor: Anchor | null;
|
|
305
|
-
/** Branches opened so far, against maxBranchesPerSession. */
|
|
306
|
-
branchesOpened: number;
|
|
307
|
-
/** Next free node number. Branches take one each. */
|
|
308
|
-
nextNode: number;
|
|
309
|
-
/** The workspace as of the last snapshot, for diffing the next one. */
|
|
310
|
-
/** Worktrees created for writing branches, by node. */
|
|
311
|
-
worktrees: Map<number, { path: string; branch: string }>;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function selfRef(dir: string, node: number, sha: string): NodeRef {
|
|
315
|
-
return { node, log_ref: nodePath(dir, node), sha256: sha };
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/** Digest is I/O; core stays pure and the caller supplies the bytes. */
|
|
319
|
-
async function digestedRef(dir: string, node: number): Promise<NodeRef> {
|
|
320
|
-
return selfRef(dir, node, await digestNode(dir, node));
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* One completed exchange that changed files -- somewhere to go back to.
|
|
325
|
-
*
|
|
326
|
-
* The unit is a whole exchange, not a snapshot. A snapshot lands mid-turn,
|
|
327
|
-
* after the tool results and before the model's closing message, so forking
|
|
328
|
-
* there would cut a turn in half and drop the model's own summary of what it
|
|
329
|
-
* just did. Claude Code lists prompts for the same reason: the thing a
|
|
330
|
-
* person means by "before that" is a message they sent, not a tool call.
|
|
331
|
-
*/
|
|
332
|
-
export interface RestorePoint {
|
|
333
|
-
node: number;
|
|
334
|
-
/** The user message is the editable navigation target; idle snapshots have none. */
|
|
335
|
-
userId?: string;
|
|
336
|
-
/** Last event of the exchange. Inclusive: the exchange itself is kept. */
|
|
337
|
-
atEvent: string;
|
|
338
|
-
/** The prompt that opened the exchange, for a caller that has to show it. */
|
|
339
|
-
prompt: string;
|
|
340
|
-
/** How many paths the exchange added, changed or removed. */
|
|
341
|
-
changed: number;
|
|
342
|
-
/**
|
|
343
|
-
* True when this exchange is in the context the session is currently in.
|
|
344
|
-
*
|
|
345
|
-
* False means a branch the session has left -- usually one it rewound away
|
|
346
|
-
* from. Those are listed too: a node that survives on disk but cannot be
|
|
347
|
-
* found again is undestroyed and unreachable, which is the same loss in a
|
|
348
|
-
* different shape.
|
|
349
|
-
*/
|
|
350
|
-
onPath: boolean;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
export type RewindPreview =
|
|
354
|
-
| { ok: true; willWrite: string[]; willDelete: string[] }
|
|
355
|
-
| { ok: false; error: string };
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
* Which of the two halves move.
|
|
359
|
-
*
|
|
360
|
-
* both the default. Context and disk stay in agreement.
|
|
361
|
-
* files the workspace goes back, the conversation does not.
|
|
362
|
-
* context the conversation goes back, the workspace does not.
|
|
363
|
-
*/
|
|
364
|
-
export type RewindMode = "both" | "files" | "context";
|
|
365
|
-
|
|
366
|
-
export type RewindResult =
|
|
367
|
-
| { ok: true; node: number; restored: string[]; removed: string[]; unrecoverable: string[]; note: string }
|
|
368
|
-
| { ok: false; error: string };
|
|
369
|
-
|
|
370
|
-
/** O.5: child execution returns facts; only the parent coordinator accepts them. */
|
|
371
|
-
type DeferredToolResult = ToolResult & { accept?: () => Promise<LoopEvent[]>; annotation?: LogEvent; finalText?: string };
|
|
372
|
-
type ToolCall = Extract<AssistantBlock, { type: "tool_use" }>;
|
|
373
|
-
type SessionBrokerReceipt = BrokerReceipt<DeferredToolResult> & { events: LoopEvent[] };
|
|
374
|
-
type PreparedBrokerCall = { run(emit?: (event: LoopEvent) => void): Promise<SessionBrokerReceipt> };
|
|
375
|
-
export type CommittedItem = { node: number; id: string; type: LogEvent["t"] };
|
|
376
|
-
|
|
377
|
-
export class Session {
|
|
378
|
-
private readonly execOwner = randomUUID();
|
|
379
|
-
private readonly jobs = new BranchJobs<DeferredToolResult>();
|
|
380
|
-
/**
|
|
381
|
-
* Branches the user started (/review-code), owned apart from `jobs`: an Esc
|
|
382
|
-
* on a parent turn cancels the model's branches, never these. Their joins
|
|
383
|
-
* wait in `pendingJoins` until the parent is idle and pairable.
|
|
384
|
-
*/
|
|
385
|
-
private readonly userJobs = new BranchJobs<Extract<LogEvent, { t: "joined" }>>();
|
|
386
|
-
private readonly pendingJoins: { joined: LogEvent; note: string; usage: readonly UsageSample[] }[] = [];
|
|
387
|
-
/** Spawned but not yet handed to `userJobs` (its prelude is still recording): already running to an observer. */
|
|
388
|
-
private readonly startingUserBranches = new Set<number>();
|
|
389
|
-
private readonly children = new Map<number, Session>();
|
|
390
|
-
private cleanup: Promise<void> | null = null;
|
|
391
|
-
private closing: Promise<void> | null = null;
|
|
392
|
-
private turnTools: ToolContext | null = null;
|
|
393
|
-
/** The running request is read-only (RequestOptions.readOnly); every tool call goes through the Plan gate. */
|
|
394
|
-
private readOnlyRequest = false;
|
|
395
|
-
/** The source snapshot taken when the current goal request began; a counter-check reverts to it. In memory: a request never outlives its process. */
|
|
396
|
-
private goalBaseline: { request: string; snapshot: GitCheckpoint } | null = null;
|
|
397
|
-
private readonly execution = new ExecutionControl();
|
|
398
|
-
private readonly runtimeTargets = new RuntimeTargets();
|
|
399
|
-
private rootTools: ToolQueue | undefined;
|
|
400
|
-
private hostOwner: ReturnType<ExecutionControl["begin"]> | undefined;
|
|
401
|
-
private readonly hostPending = new Set<Promise<NativeCommandResult>>();
|
|
402
|
-
private stall: StallState | null = null;
|
|
403
|
-
private recovery: RecoveryState | null = null;
|
|
404
|
-
private processWake = new AbortController();
|
|
405
|
-
/**
|
|
406
|
-
* The exception the last provider request ended with, compared by identity
|
|
407
|
-
* in send()'s catch. Wrapping the error instead would break every check that
|
|
408
|
-
* reads it (FailedResponseError, statusOf, context overflow).
|
|
409
|
-
*/
|
|
410
|
-
private providerFailure: { error: unknown; retries: number } | undefined;
|
|
411
|
-
private pendingAdoption: number | null = null;
|
|
412
|
-
/** A worktree enter/exit moves the root only once its tool round is paired, like adoption. */
|
|
413
|
-
private pendingRoot: string | null = null;
|
|
414
|
-
private readonly allocatedEventIds = new Set<string>();
|
|
415
|
-
private recordTail: Promise<void> = Promise.resolve();
|
|
416
|
-
private readonly recordListeners = new Set<(item: CommittedItem) => void>();
|
|
417
|
-
private readonly branchRecordListeners = new Set<(item: CommittedItem) => void>();
|
|
418
|
-
readonly config: SessionConfig;
|
|
419
|
-
/**
|
|
420
|
-
* Replaced by switchModel. Not readonly: a live session can change which
|
|
421
|
-
* adapter it talks to, and the replacement has to be the same field every
|
|
422
|
-
* send already reads.
|
|
423
|
-
*/
|
|
424
|
-
provider: Provider;
|
|
425
|
-
readonly clock: Clock;
|
|
426
|
-
state: SessionState;
|
|
427
|
-
private readonly allocation: { next: number };
|
|
428
|
-
/** S.9: the vault behind placeholders. A branch is handed its parent's, like `allocation`. */
|
|
429
|
-
private secrets: SecretKeeper;
|
|
430
|
-
|
|
431
|
-
constructor(config: SessionConfig, provider: Provider, clock: Clock = systemClock(), allocation = { next: 1 }) {
|
|
432
|
-
if (config.acceptanceContract) parseAcceptanceContract(config.acceptanceContract);
|
|
433
|
-
if (config.contextPolicy !== undefined && !isContextPolicy(config.contextPolicy)) throw new Error("unknown context policy");
|
|
434
|
-
stallDecision(emptyStallState(), config.stallLimit ?? DEFAULT_STALL_LIMIT);
|
|
435
|
-
validateOutputRecoveryLimit(config.outputRecoveryLimit ?? DEFAULT_OUTPUT_RECOVERY_LIMIT);
|
|
436
|
-
schedulingWindow([], 0, config.maxParallelTools ?? 3);
|
|
437
|
-
validateRequestBudget(config.maxTurns, config.stallLimit ?? DEFAULT_STALL_LIMIT);
|
|
438
|
-
this.allocation = allocation;
|
|
439
|
-
this.secrets = new SecretKeeper(config.logDir, secretEnvironmentValues(config.secretEnvironment ?? process.env));
|
|
440
|
-
this.config = { ...config, ...(config.acceptanceContract ? { acceptanceContract: parseAcceptanceContract(config.acceptanceContract) } : {}), tools: { ...config.tools, execOwner: this.execOwner, historyCache: new HistoryCache(),
|
|
441
|
-
processWait: { wake: () => this.processWake.signal } } };
|
|
442
|
-
// A child or restored Session must never inherit another live invocation scope.
|
|
443
|
-
delete this.config.tools.invoke;
|
|
444
|
-
delete this.config.tools.invokeHost;
|
|
445
|
-
delete this.config.tools.runOwnedCommand;
|
|
446
|
-
delete this.config.tools.drainNested;
|
|
447
|
-
delete this.config.tools.origin;
|
|
448
|
-
delete this.config.tools.parentCallId;
|
|
449
|
-
delete this.config.tools.callId;
|
|
450
|
-
// Branch capability sets are owned by branchTools; a parent's optional catalog is not a grant.
|
|
451
|
-
if ((config.branchDepth ?? 0) > 0) delete this.config.tools.deferredTools;
|
|
452
|
-
this.config.tools.outputStore ??= join(config.logDir, "artifacts");
|
|
453
|
-
// Read at call time, so a branch that is handed its parent's keeper expands the parent's values.
|
|
454
|
-
this.config.tools.expandSecrets = input => this.secrets.expand(input);
|
|
455
|
-
this.config.tools.historyEvents = async () => {
|
|
456
|
-
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
457
|
-
if (!history.ok) throw Error(history.error);
|
|
458
|
-
return history.nodes.flatMap(node => node.node === this.state.node ? structuredClone(this.state.events) : node.events);
|
|
459
|
-
};
|
|
460
|
-
if (config.blobDir) this.config.tools.checkpointBlobs = config.blobDir;
|
|
461
|
-
this.config.tools.frozenSystem = () => this.config.frozen.system;
|
|
462
|
-
// R4 / D8: full ancestry, not the bounded model view or a second mutable ledger.
|
|
463
|
-
// Each child Session installs its own callback even when contexts are copied.
|
|
464
|
-
this.config.tools.observedFileVersion = async absolute => {
|
|
465
|
-
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
466
|
-
if (!history.ok) throw new Error(history.error);
|
|
467
|
-
return latestObservedFileVersion(history.nodes.flatMap(node => node.events), absolute, path => resolve(this.config.tools.root, path));
|
|
468
|
-
};
|
|
469
|
-
this.config.tools.observedProcess = async session => {
|
|
470
|
-
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
471
|
-
if (!history.ok) throw new Error(history.error);
|
|
472
|
-
return latestProcessReceipt(await hydrateProcessReceipts(history.nodes, this.config.tools.outputStore!), session);
|
|
473
|
-
};
|
|
474
|
-
this.provider = provider;
|
|
475
|
-
this.clock = {
|
|
476
|
-
now: () => clock.now(),
|
|
477
|
-
nextId: () => {
|
|
478
|
-
let id = clock.nextId();
|
|
479
|
-
// Injected clocks may restart their sequence too. Event identity is a
|
|
480
|
-
// session invariant, independent of the clock's implementation.
|
|
481
|
-
if (this.allocatedEventIds.has(id)) id = randomUUID();
|
|
482
|
-
this.allocatedEventIds.add(id);
|
|
483
|
-
return id;
|
|
484
|
-
},
|
|
485
|
-
};
|
|
486
|
-
this.state = {
|
|
487
|
-
node: 0, depth: 1, handoff: null, events: [], usage: [],
|
|
488
|
-
anchor: null, branchesOpened: 0,
|
|
489
|
-
get nextNode() { return allocation.next; },
|
|
490
|
-
set nextNode(value: number) { allocation.next = value; },
|
|
491
|
-
worktrees: new Map(),
|
|
492
|
-
};
|
|
493
|
-
this.installWorkspace();
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
private installWorkspace(): void {
|
|
497
|
-
delete this.config.tools.workspace;
|
|
498
|
-
this.config.tools.ownedStatePaths = [this.config.logDir, ...(this.config.blobDir ? [this.config.blobDir] : [])];
|
|
499
|
-
if (this.config.blobDir) this.config.tools.workspace = new WorkspaceLifecycle(
|
|
500
|
-
this.config.tools.root, this.config.blobDir, this.config.shellCheckpoints === true,
|
|
501
|
-
event => this.record({ ...event, id: this.clock.nextId(), at: this.clock.now() }),
|
|
502
|
-
join(this.config.logDir, "checkpoints"),
|
|
503
|
-
this.config.tools.ownedStatePaths,
|
|
504
|
-
);
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
private async observeEvidence(): Promise<void> {
|
|
508
|
-
let proof = this.state.events.findLast(event => event.t === "tool_result" && event.effect?.kind === "command");
|
|
509
|
-
let prior = proof?.t === "tool_result" && proof.effect?.kind === "command" ? proof.effect.workspace_sha256 :
|
|
510
|
-
this.state.handoff?.state.completed.at(-1)?.evidence.workspace_sha256;
|
|
511
|
-
// A short task need not create a completed note. Its last command still
|
|
512
|
-
// establishes the comparison version when the current segment is empty.
|
|
513
|
-
if (!proof && this.config.tools.workspace) {
|
|
514
|
-
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
515
|
-
if (!history.ok) throw new Error(history.error);
|
|
516
|
-
proof = history.nodes.flatMap(part => part.events).findLast(event => event.t === "tool_result" && event.effect?.kind === "command");
|
|
517
|
-
if (proof?.t === "tool_result" && proof.effect?.kind === "command") prior = proof.effect.workspace_sha256;
|
|
518
|
-
}
|
|
519
|
-
if (proof || prior || this.config.acceptanceContract) await this.config.tools.workspace?.observe(prior);
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
/**
|
|
523
|
-
* Opens the session: a new one, or the one this log already holds.
|
|
524
|
-
*
|
|
525
|
-
* Resuming restores the state a running session would have had, from the
|
|
526
|
-
* same ancestry walk the projection itself uses -- so what the model sees
|
|
527
|
-
* after a restart is what it would have seen had the process never exited.
|
|
528
|
-
* That equality is the whole contract; anything less makes a resumed
|
|
529
|
-
* session quietly different from a continuous one.
|
|
530
|
-
*/
|
|
531
|
-
async start(): Promise<void> {
|
|
532
|
-
this.resetDerivedToolState();
|
|
533
|
-
const dir = this.config.logDir;
|
|
534
|
-
await this.secrets.load();
|
|
535
|
-
const model = this.provider.config.spec.id;
|
|
536
|
-
const existing = await listNodes(dir);
|
|
537
|
-
const recordedCursor = await readCursor(dir);
|
|
538
|
-
const cursor = recordedCursor === null ? null : await recoverCut(dir, recordedCursor);
|
|
539
|
-
const plan = planResume(cursor, model, existing.length > 0, this.provider.config.spec.provider);
|
|
540
|
-
|
|
541
|
-
switch (plan.kind) {
|
|
542
|
-
case "refuse":
|
|
543
|
-
throw new Error(plan.reason);
|
|
544
|
-
|
|
545
|
-
case "fresh":
|
|
546
|
-
this.config.contextPolicy ??= DEFAULT_CONTEXT_POLICY;
|
|
547
|
-
this.config.stallLimit ??= DEFAULT_STALL_LIMIT;
|
|
548
|
-
this.config.outputRecoveryLimit ??= DEFAULT_OUTPUT_RECOVERY_LIMIT;
|
|
549
|
-
await initNode(dir, 0, null, this.clock.now());
|
|
550
|
-
break;
|
|
551
|
-
|
|
552
|
-
case "resume": {
|
|
553
|
-
const replayed = await replayContext(dir, plan.cursor.node);
|
|
554
|
-
if (!plan.cursor.context_ref) throw new Error("missing frozen session context");
|
|
555
|
-
const saved = await getBlob(join(dir, "artifacts"), plan.cursor.context_ref);
|
|
556
|
-
if (saved === null) throw new Error("missing frozen session context");
|
|
557
|
-
// S.3: one format, parsed once. Every field below was written by a
|
|
558
|
-
// session of this format, so it is restored as recorded, not defaulted.
|
|
559
|
-
const context = parseSessionContext(JSON.parse(saved));
|
|
560
|
-
if (context.agentProfile) this.config.agentProfile = { ...context.agentProfile };
|
|
561
|
-
else delete this.config.agentProfile;
|
|
562
|
-
if (context.workflowMode) this.config.tools.workflowMode = context.workflowMode;
|
|
563
|
-
if (context.workflowToolProfile) this.config.workflowToolProfile = context.workflowToolProfile;
|
|
564
|
-
this.config.workingSet = context.workingSet;
|
|
565
|
-
this.config.shellCheckpoints = context.shellCheckpoints;
|
|
566
|
-
this.config.acceptanceContract = context.acceptanceContract ?? null;
|
|
567
|
-
this.config.strictVerification = context.strictVerification;
|
|
568
|
-
this.config.skills = context.skills;
|
|
569
|
-
this.installSkillResources();
|
|
570
|
-
this.config.workNamespace = context.workNamespace;
|
|
571
|
-
this.config.maxParallelTools = context.maxParallelTools;
|
|
572
|
-
this.config.stallLimit = context.stallLimit;
|
|
573
|
-
this.config.outputRecoveryLimit = context.outputRecoveryLimit;
|
|
574
|
-
this.config.frozen = context.frozen;
|
|
575
|
-
this.config.tools.gates = context.gates;
|
|
576
|
-
this.config.contextPolicy = context.contextPolicy;
|
|
577
|
-
if (context.boundary !== undefined) this.config.boundary = context.boundary;
|
|
578
|
-
else delete this.config.boundary;
|
|
579
|
-
if (context.guidance !== undefined) this.config.guidance = context.guidance;
|
|
580
|
-
else delete this.config.guidance;
|
|
581
|
-
if (context.blindChecks) this.config.blindChecks = context.blindChecks;
|
|
582
|
-
else delete this.config.blindChecks;
|
|
583
|
-
if (plan.cursor.workspace !== undefined) this.config.tools.root = plan.cursor.workspace;
|
|
584
|
-
if (plan.cursor.blob_dir !== undefined) this.config.blobDir = plan.cursor.blob_dir;
|
|
585
|
-
for (const id of existing) {
|
|
586
|
-
for (const event of (await readNode(dir, id)).events) {
|
|
587
|
-
this.allocatedEventIds.add(event.id);
|
|
588
|
-
if (event.t === "spawn") {
|
|
589
|
-
this.state.branchesOpened++;
|
|
590
|
-
if (event.workspace) this.state.worktrees.set(event.branch.node, event.workspace);
|
|
591
|
-
}
|
|
592
|
-
if (event.t === "discard") this.state.worktrees.delete(event.branch.node);
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
this.state.node = plan.cursor.node;
|
|
597
|
-
// From the allocator's recorded state, never from a node count: dead
|
|
598
|
-
// limbs and resolved branches hold ids, and recomputing this is the
|
|
599
|
-
// shape of the bug that once had a cut overwrite a branch's log.
|
|
600
|
-
// A crashed branch can reserve a node before the parent updates its
|
|
601
|
-
// allocator. Recovery only raises the recorded high-water mark.
|
|
602
|
-
this.state.nextNode = Math.max(plan.cursor.next_node, ...existing.map((n) => n + 1));
|
|
603
|
-
this.state.depth = replayed.depth;
|
|
604
|
-
this.state.events = replayed.contextEvents;
|
|
605
|
-
this.state.handoff = replayed.handoff;
|
|
606
|
-
await this.answerUnpairedTools("interrupted: tool outcome is unknown after restart; inspect the workspace or external operation before retrying");
|
|
607
|
-
// Nothing has been sent this process, so there is no billed prompt
|
|
608
|
-
// size to anchor to yet; the first response re-establishes it.
|
|
609
|
-
this.state.anchor = null;
|
|
610
|
-
// Compare disk against the durable file projection. Treating current
|
|
611
|
-
// disk as the baseline would erase unlogged effects of a crashed tool.
|
|
612
|
-
const pending = (await readNode(dir, plan.cursor.node)).events.findLast((e) => e.t === "adopt" && !e.applied);
|
|
613
|
-
if (pending?.t === "adopt") {
|
|
614
|
-
this.pendingAdoption = pending.branch.node;
|
|
615
|
-
await this.adoptPendingBranch();
|
|
616
|
-
}
|
|
617
|
-
break;
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
default:
|
|
621
|
-
return assertNever(plan, "Session.start");
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
this.installWorkspace();
|
|
625
|
-
await this.recoverFileOperations();
|
|
626
|
-
this.restoreDeclaredTools();
|
|
627
|
-
// Consume the original pending receipt before another checkpoint can replace this projection.
|
|
628
|
-
const declarationCut = await this.applyDiscoveredTools();
|
|
629
|
-
await this.refreshRuntimeTargets();
|
|
630
|
-
if (!declarationCut && (cursor?.needs_model_checkpoint || needsReplayCheckpoint(this.state.events, providerReplayKey(this.provider.config.spec), nativeReplay(this.provider.config.spec)))) {
|
|
631
|
-
await this.performCut("model_checkpoint", model);
|
|
632
|
-
}
|
|
633
|
-
await this.saveCursor();
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
/**
|
|
637
|
-
* Copies this session's current path into `destLogDir`, optionally
|
|
638
|
-
* stopping at `untilEvent` (inclusive). The source files are only read:
|
|
639
|
-
* a fork that rewrote the original would leave the user with one line
|
|
640
|
-
* when they thought they had two.
|
|
641
|
-
*/
|
|
642
|
-
async fork(destLogDir: string, untilEvent?: string, position: "at" | "before" = "at"): Promise<void> {
|
|
643
|
-
await copyAncestryTo(this.config.logDir, destLogDir, this.clock.now(), untilEvent, position);
|
|
644
|
-
await copyVault(this.config.logDir, destLogDir);
|
|
645
|
-
if (untilEvent !== undefined) {
|
|
646
|
-
const cursor = await readCursor(destLogDir);
|
|
647
|
-
if (!cursor) throw new Error("missing fork cursor");
|
|
648
|
-
const tip = (await readNode(this.config.logDir, this.state.node)).events.at(-1);
|
|
649
|
-
const filesFrom = { ...await digestedRef(this.config.logDir, this.state.node), ...(tip ? { at_event: tip.id } : {}) };
|
|
650
|
-
const header = (await readNode(destLogDir, cursor.node)).header;
|
|
651
|
-
const event: LogEvent = { t: "rewind", id: this.clock.nextId(), at: this.clock.now(), mode: "context", from: header?.parent ?? filesFrom,
|
|
652
|
-
files_from: filesFrom, prompt: "Forked conversation", restored: [], removed: [], unrecoverable: [], divergence: [] };
|
|
653
|
-
await appendEvent(destLogDir, cursor.node, event);
|
|
654
|
-
await writeCursor(destLogDir, { ...cursor, at_event: event.id, updated_at: event.at });
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
async setThinking(effort: Effort): Promise<void> {
|
|
659
|
-
this.provider.config.effort = effort;
|
|
660
|
-
await this.saveCursor();
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
/** Trusted extension commands share the Session owner without inventing a user turn. */
|
|
664
|
-
runExtensionCommand(command: string, args: string[], options?: { cwd?: string; timeout?: number; signal?: AbortSignal }): Promise<NativeCommandResult> {
|
|
665
|
-
if (this.cleanup || this.closing) return Promise.reject(new Error("session is draining or disposed"));
|
|
666
|
-
const input: NativeCommandInput = { argv: [command, ...args], ...(options?.cwd !== undefined ? { cwd: options.cwd } : {}),
|
|
667
|
-
...(options?.timeout !== undefined ? { timeout: options.timeout } : {}) };
|
|
668
|
-
if (!this.execution.running) this.hostOwner = this.execution.begin();
|
|
669
|
-
const signals = [this.execution.signal, options?.signal].filter((signal): signal is AbortSignal => signal !== undefined);
|
|
670
|
-
const signal = AbortSignal.any(signals);
|
|
671
|
-
const result = (async () => {
|
|
672
|
-
const call: ToolCall = { type: "tool_use", call_id: `host-${randomUUID()}`, name: NATIVE_COMMAND_NAME, input };
|
|
673
|
-
const preparation = this.prepareBrokerCall(call, () => 0, false, signal, true);
|
|
674
|
-
let prepared: PreparedBrokerCall;
|
|
675
|
-
for (;;) { const step = await preparation.next(); if (step.done) { prepared = step.value; break; } }
|
|
676
|
-
const receipt = await prepared.run();
|
|
677
|
-
await receipt.commit();
|
|
678
|
-
await this.saveCursor();
|
|
679
|
-
return nativeCommandResult(receipt.value, this.config.tools.outputStore!);
|
|
680
|
-
})();
|
|
681
|
-
this.hostPending.add(result);
|
|
682
|
-
const finished = () => {
|
|
683
|
-
this.hostPending.delete(result);
|
|
684
|
-
if (this.hostPending.size === 0 && this.hostOwner) { this.hostOwner.finish(); this.hostOwner = undefined; }
|
|
685
|
-
};
|
|
686
|
-
void result.then(finished, finished);
|
|
687
|
-
return result;
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
async runUserBash(command: string, excludeFromContext: boolean, signal?: AbortSignal,
|
|
691
|
-
onOutput?: (chunk: Uint8Array) => void): Promise<Extract<LogEvent, { t: "user_bash" }>> {
|
|
692
|
-
if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
|
|
693
|
-
const owned = this.execution.begin(signal);
|
|
694
|
-
let release: (() => void) | undefined;
|
|
695
|
-
try {
|
|
696
|
-
this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
|
|
697
|
-
release = await this.rootTools.acquire("exclusive");
|
|
698
|
-
if (!command.trim()) throw new Error("empty user command");
|
|
699
|
-
const execute = async (): Promise<ToolResult> => {
|
|
700
|
-
const captured = await observeCommand({ command, cwd: this.config.tools.root,
|
|
701
|
-
outputStore: join(this.config.logDir, "artifacts"), maxOutputBytes: this.config.tools.maxOutputBytes,
|
|
702
|
-
timeoutMs: this.config.tools.timeoutMs, signal: owned.signal, ...(onOutput ? { onOutput } : {}) });
|
|
703
|
-
const completion = commandCompletion(captured.exit, captured.stopped, captured.cleanup);
|
|
704
|
-
const preview = headTailView(captured.head, captured.tail, captured.ref.bytes, this.config.tools.maxOutputBytes,
|
|
705
|
-
omitted => `\n[${omitted} bytes omitted; complete output: ${captured.ref.path}]\n`);
|
|
706
|
-
return { ...completion, content: `${preview}\nexit ${captured.exit} · ${completion.outcome.status} · ${completion.outcome.reason}` +
|
|
707
|
-
(captured.cleanup ? `\n${captured.cleanup.message}` : ""),
|
|
708
|
-
content_ref: captured.ref, effect: { kind: "command", cmd: command, exit: captured.exit } };
|
|
709
|
-
};
|
|
710
|
-
const result = this.config.tools.workspace ? await this.config.tools.workspace.command("bash", { command }, execute, owned.signal) : await execute();
|
|
711
|
-
if (result.effect?.kind !== "command" || !result.content_ref || !result.outcome) throw new Error("missing user command observation");
|
|
712
|
-
const event: Extract<LogEvent, { t: "user_bash" }> = { t: "user_bash", id: this.clock.nextId(), at: this.clock.now(),
|
|
713
|
-
command, content: result.content, exit: result.effect.exit, excludeFromContext, outcome: result.outcome, content_ref: result.content_ref };
|
|
714
|
-
await this.record(event);
|
|
715
|
-
await this.saveCursor();
|
|
716
|
-
return event;
|
|
717
|
-
} catch (error) {
|
|
718
|
-
await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
|
|
719
|
-
reason: "user command failed before completion; inspect the workspace before retrying", outcome: executionOutcome("execution_error") });
|
|
720
|
-
throw error;
|
|
721
|
-
} finally {
|
|
722
|
-
release?.();
|
|
723
|
-
while (this.hostPending.size > 0) await Promise.allSettled(this.hostPending);
|
|
724
|
-
owned.finish();
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
async userMessages(): Promise<{ id: string; node: number; text: string; images?: readonly ImageRef[] }[]> {
|
|
729
|
-
const messages: Awaited<ReturnType<Session["userMessages"]>> = [];
|
|
730
|
-
for (const node of await listNodes(this.config.logDir)) {
|
|
731
|
-
for (const event of (await readNode(this.config.logDir, node)).events) {
|
|
732
|
-
if (event.t === "user") messages.push({ id: event.id, node, ...userInputData(event) });
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
return messages;
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
/** Pi keeps the selected runtime model when navigating; N.8 checkpoints incompatible context. */
|
|
739
|
-
async navigateTo(eventId: string): Promise<{ editorText?: string; images?: readonly ImageRef[] }> {
|
|
740
|
-
const dir = this.config.logDir;
|
|
741
|
-
const users = await this.userMessages();
|
|
742
|
-
const target = await resolveHistoryPosition(dir, eventId, users.some(user => user.id === eventId) ? "before" : "at");
|
|
743
|
-
const previous = (await readNode(dir, this.state.node)).events.at(-1);
|
|
744
|
-
const filesFrom = { ...await digestedRef(dir, this.state.node), ...(previous ? { at_event: previous.id } : {}) };
|
|
745
|
-
const node = this.state.nextNode++;
|
|
746
|
-
await initNode(dir, node, target.parent, this.clock.now(), target.basis);
|
|
747
|
-
await appendEvent(dir, node, {
|
|
748
|
-
t: "rewind", id: this.clock.nextId(), at: this.clock.now(), mode: "context", from: target.parent ?? filesFrom,
|
|
749
|
-
files_from: filesFrom, prompt: target.editorText ?? "Selected conversation position", restored: [], removed: [], unrecoverable: [], divergence: [],
|
|
750
|
-
});
|
|
751
|
-
const cursor = await readCursor(dir);
|
|
752
|
-
if (!cursor) throw new Error("missing current session cursor");
|
|
753
|
-
const next = { ...cursor, node, next_node: this.state.nextNode, updated_at: this.clock.now(),
|
|
754
|
-
needs_model_checkpoint: target.parent !== null && (target.selection === null || !sameModel(target.selection, cursor)) };
|
|
755
|
-
delete next.at_event;
|
|
756
|
-
await writeCursor(dir, next);
|
|
757
|
-
await this.start();
|
|
758
|
-
const images = users.find(user => user.id === eventId)?.images;
|
|
759
|
-
return target.editorText === undefined ? {} : { editorText: target.editorText, ...(images?.length ? { images } : {}) };
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
/**
|
|
763
|
-
* Cuts the current node, then runs on a different provider.
|
|
764
|
-
*
|
|
765
|
-
* The cut is the whole point. Thinking blocks belong to the model that
|
|
766
|
-
* produced them, and every provider this harness talks to requires them
|
|
767
|
-
* replayed unchanged. Handing Anthropic's signed block to DeepSeek -- or
|
|
768
|
-
* even to another Anthropic model -- is a bad request. Filtering the
|
|
769
|
-
* blocks out of the projection would make what the model sees depend on
|
|
770
|
-
* a "we switched" flag that is not in the log. Cutting makes the switch
|
|
771
|
-
* a fact in the log: the new node starts from a handoff, which is plain
|
|
772
|
-
* YAML, and inherits none of the old blocks.
|
|
773
|
-
*
|
|
774
|
-
* Bypasses minNodeWorkTokens. That floor is a cost argument (a handoff
|
|
775
|
-
* only pays for itself once the node's own history exceeds it). A switch
|
|
776
|
-
* is a correctness argument, and must be allowed after two turns.
|
|
777
|
-
*
|
|
778
|
-
* Only legal between turns. A cut between tool_use and tool_result would
|
|
779
|
-
* leave the log unpairable (D5).
|
|
780
|
-
*
|
|
781
|
-
* Recorded as model_checkpoint, not trigger. "trigger" means this node
|
|
782
|
-
* reached the token threshold. A switch is not that -- it can happen on
|
|
783
|
-
* turn two -- and a later reader reconstructing the session from the log
|
|
784
|
-
* must see that a person changed models, not that the node filled up.
|
|
785
|
-
* boundary.ts already produces this reason when checkpointRequested is
|
|
786
|
-
* set; switchModel is the other caller that wants it.
|
|
787
|
-
*
|
|
788
|
-
* An empty projection is the exception (fix-switch-model-empty-segment).
|
|
789
|
-
* With no events there is no old model's block to keep away from the new
|
|
790
|
-
* one, and the frozen prefix does not change here, so the cursor alone
|
|
791
|
-
* records the switch. Cutting anyway sealed an empty segment and opened
|
|
792
|
-
* the next from an empty handoff, which the model read as earlier work to
|
|
793
|
-
* resume. The check is the projection, not the node file: an inherit-basis
|
|
794
|
-
* node can be empty on disk while still projecting ancestor thinking.
|
|
795
|
-
*
|
|
796
|
-
* Returns whether a segment was cut, so the caller can say so truthfully.
|
|
797
|
-
*/
|
|
798
|
-
async switchModel(next: Provider): Promise<boolean> {
|
|
799
|
-
if (this.running || this.isHalfRound()) {
|
|
800
|
-
throw new Error(
|
|
801
|
-
"cannot switch models mid-turn: a tool_use is still unpaired. " +
|
|
802
|
-
"Wait for the turn to finish so every call has a result",
|
|
803
|
-
);
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
const cut = this.state.events.length > 0;
|
|
807
|
-
if (cut) await this.performCut("model_checkpoint", next.config.spec.id, undefined, next.config.spec.provider, next.config.effort);
|
|
808
|
-
else this.state.anchor = null;
|
|
809
|
-
this.provider = next;
|
|
810
|
-
await this.saveCursor();
|
|
811
|
-
return cut;
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
/** D6: changing the frozen prefix is an explicit checkpoint, never in-place prompt surgery. */
|
|
815
|
-
assertResourceIdle(): void {
|
|
816
|
-
if (this.running || this.isHalfRound()) throw new Error("cannot reload resources while a turn is active or a tool call is unpaired");
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
async reloadResources(frozen: FrozenPrefix, skills = this.config.skills, nextProvider?: Provider, forceCheckpoint = false): Promise<boolean> {
|
|
820
|
-
if (!nextProvider && !forceCheckpoint && JSON.stringify(frozen) === JSON.stringify(this.config.frozen) && JSON.stringify(skills) === JSON.stringify(this.config.skills)) return false;
|
|
821
|
-
this.assertResourceIdle();
|
|
822
|
-
const contextRef = await this.persistContext(frozen, skills);
|
|
823
|
-
const provider = nextProvider ?? this.provider;
|
|
824
|
-
await this.performCut("model_checkpoint", provider.config.spec.id, undefined,
|
|
825
|
-
provider.config.spec.provider, provider.config.effort, contextRef);
|
|
826
|
-
this.provider = provider;
|
|
827
|
-
this.config.frozen = frozen;
|
|
828
|
-
if (skills !== undefined) this.config.skills = skills;
|
|
829
|
-
this.installSkillResources();
|
|
830
|
-
this.config.tools.allow = frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" ? [tool.name] : []);
|
|
831
|
-
await this.saveCursor();
|
|
832
|
-
return true;
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
/** Restoring a declaration never installs code: only the current selected local catalog can supply it. */
|
|
836
|
-
private restoreDeclaredTools(): void {
|
|
837
|
-
const declared = new Set(declaredToolNames(this.config.frozen.tools));
|
|
838
|
-
const loaded = currentDeferredTools(this.config.tools).filter(tool => declared.has(tool.name)).map(tool => tool.name);
|
|
839
|
-
if (loaded.length && this.config.tools.allow !== undefined) this.config.tools.allow = [...new Set([...this.config.tools.allow, ...loaded])];
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
/** D5/D6: consume original discovery receipts only after every invocation has its result. */
|
|
843
|
-
private async applyDiscoveredTools(): Promise<boolean> {
|
|
844
|
-
if (this.isHalfRound() || (this.config.branchDepth ?? 0) > 0 || this.config.tools.extensions?.get(DISCOVERY_TOOL_NAME) !== DISCOVERY_TOOL) return false;
|
|
845
|
-
const declared = new Set(declaredToolNames(this.config.frozen.tools));
|
|
846
|
-
const requested = new Map(recordedDeclarations(this.state.events).map(item => [item.name, item.fingerprint]));
|
|
847
|
-
const additions = currentDeferredTools(this.config.tools).filter(tool => !declared.has(tool.name) &&
|
|
848
|
-
requested.get(tool.name) === discoveryFingerprint(tool) && discoveryPermitted(this.config.tools, tool.name));
|
|
849
|
-
if (additions.length === 0) return false;
|
|
850
|
-
const frozen = { ...this.config.frozen, tools: [...this.config.frozen.tools, ...additions] };
|
|
851
|
-
const contextRef = await this.persistContext(frozen);
|
|
852
|
-
await this.performCut("model_checkpoint", this.provider.config.spec.id, undefined,
|
|
853
|
-
this.provider.config.spec.provider, this.provider.config.effort, contextRef);
|
|
854
|
-
this.config.frozen = frozen;
|
|
855
|
-
this.restoreDeclaredTools();
|
|
856
|
-
// send() restores its base tool scope at the end; keep accepted declarations there too.
|
|
857
|
-
if (this.turnTools?.allow !== undefined) this.turnTools.allow = [...new Set([...this.turnTools.allow, ...additions.map(tool => tool.name)])];
|
|
858
|
-
await this.saveCursor();
|
|
859
|
-
return true;
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
/**
|
|
863
|
-
* The same pairing check switchModel already used. A user message
|
|
864
|
-
* between tool_use and tool_result is the same unpairable request a
|
|
865
|
-
* model switch would produce — so the queue reuses this, it does not
|
|
866
|
-
* invent a second half-round test.
|
|
867
|
-
*/
|
|
868
|
-
private isHalfRound(): boolean {
|
|
869
|
-
const projected = projectContext({
|
|
870
|
-
frozen: this.config.frozen,
|
|
871
|
-
handoff: this.state.handoff,
|
|
872
|
-
events: this.state.events,
|
|
873
|
-
});
|
|
874
|
-
return projected.violations.some((v) => v.code === "UNPAIRED_TOOL_USE");
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
/**
|
|
878
|
-
* Offers after_tools only when the log is pairable. The UI still owns
|
|
879
|
-
* the queue; we just ask. A half-round returns null even if the UI
|
|
880
|
-
* offered a line — writing it would be the pairing bug this guards.
|
|
881
|
-
*/
|
|
882
|
-
private async pullSteering(
|
|
883
|
-
pull?: (phase: Phase) => Promise<UserInput | null>,
|
|
884
|
-
): Promise<UserInput | null> {
|
|
885
|
-
if (pull === undefined || this.isHalfRound()) return null;
|
|
886
|
-
// Rotate before pulling: an input queued during the async pull still wakes
|
|
887
|
-
// the next wait. A wake never cancels the process's lifetime signal.
|
|
888
|
-
this.processWake = new AbortController();
|
|
889
|
-
const text = await pull("after_tools");
|
|
890
|
-
if (text === null || typeof text === "string" && text.length === 0) return null;
|
|
891
|
-
if (this.turnTools) this.config.tools = { ...this.turnTools };
|
|
892
|
-
return text;
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
/**
|
|
896
|
-
* Records where the session is, after every move.
|
|
897
|
-
*
|
|
898
|
-
* Written at each move rather than at exit, because the failures worth
|
|
899
|
-
* resuming from -- a crash, a kill, a closed laptop -- do not run exit
|
|
900
|
-
* handlers.
|
|
901
|
-
*/
|
|
902
|
-
private async persistContext(frozen = this.config.frozen, skills = this.config.skills): Promise<string> {
|
|
903
|
-
const context: SessionContext = {
|
|
904
|
-
frozen, gates: this.config.tools.gates,
|
|
905
|
-
shellCheckpoints: this.config.shellCheckpoints === true,
|
|
906
|
-
skills: skills ?? [],
|
|
907
|
-
strictVerification: this.config.strictVerification === true,
|
|
908
|
-
workingSet: this.config.workingSet === true,
|
|
909
|
-
workNamespace: this.config.workNamespace ?? "main",
|
|
910
|
-
maxParallelTools: this.config.maxParallelTools ?? 3,
|
|
911
|
-
contextPolicy: this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
|
|
912
|
-
stallLimit: this.config.stallLimit ?? DEFAULT_STALL_LIMIT,
|
|
913
|
-
outputRecoveryLimit: this.config.outputRecoveryLimit ?? DEFAULT_OUTPUT_RECOVERY_LIMIT,
|
|
914
|
-
...(this.config.acceptanceContract ? { acceptanceContract: this.config.acceptanceContract } : {}),
|
|
915
|
-
...(this.config.tools.workflowMode ? { workflowMode: this.config.tools.workflowMode } : {}),
|
|
916
|
-
...(this.config.agentProfile ? { agentProfile: this.config.agentProfile } : {}),
|
|
917
|
-
...(this.config.workflowToolProfile ? { workflowToolProfile: this.config.workflowToolProfile } : {}),
|
|
918
|
-
...(this.config.boundary ? { boundary: this.config.boundary } : {}),
|
|
919
|
-
...(this.config.guidance === undefined ? {} : { guidance: this.config.guidance }),
|
|
920
|
-
...(this.config.blindChecks ? { blindChecks: this.config.blindChecks } : {}),
|
|
921
|
-
};
|
|
922
|
-
return putBlob(join(this.config.logDir, "artifacts"), JSON.stringify(context));
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
private async saveCursor(model = this.provider.config.spec.id, provider = this.provider.config.spec.provider,
|
|
926
|
-
thinking = this.provider.config.effort, contextRef?: string): Promise<void> {
|
|
927
|
-
// Branches share storage but never own the parent's active cursor.
|
|
928
|
-
if ((this.config.branchDepth ?? 0) > 0) return;
|
|
929
|
-
const last = (await readNode(this.config.logDir, this.state.node)).events.at(-1);
|
|
930
|
-
await writeCursor(this.config.logDir, {
|
|
931
|
-
node: this.state.node,
|
|
932
|
-
next_node: this.state.nextNode,
|
|
933
|
-
model,
|
|
934
|
-
provider,
|
|
935
|
-
thinking,
|
|
936
|
-
...(last ? { at_event: last.id } : {}),
|
|
937
|
-
...(this.config.blobDir === undefined ? {} : { blob_dir: this.config.blobDir }),
|
|
938
|
-
updated_at: this.clock.now(),
|
|
939
|
-
workspace: this.config.tools.root,
|
|
940
|
-
context_ref: contextRef ?? await this.persistContext(),
|
|
941
|
-
xeno_version: XENO_VERSION,
|
|
942
|
-
});
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
/** Q24: publish identities only after durable append, including non-model user actions. */
|
|
946
|
-
/** Commits inside this session's branches (their node numbers), for a client showing one live. */
|
|
947
|
-
subscribeBranchRecords(listener: (item: CommittedItem) => void): () => void {
|
|
948
|
-
this.branchRecordListeners.add(listener); return () => { this.branchRecordListeners.delete(listener); };
|
|
949
|
-
}
|
|
950
|
-
subscribeRecords(listener: (item: CommittedItem) => void): () => void {
|
|
951
|
-
this.recordListeners.add(listener); return () => { this.recordListeners.delete(listener); };
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
private async record(event: LogEvent): Promise<void> {
|
|
955
|
-
// D8: runtime callers may retain mutable results. The in-memory projection
|
|
956
|
-
// must own the same JSON value that was appended, independent of later edits.
|
|
957
|
-
event = JSON.parse(JSON.stringify(event)) as LogEvent;
|
|
958
|
-
// S.9: credentials become placeholders before anything is written, so no
|
|
959
|
-
// projection, handoff, export or history page can carry the value.
|
|
960
|
-
const redacted = await this.secrets.redact(event, this.lastAssistantText());
|
|
961
|
-
event = redacted.event;
|
|
962
|
-
const saved = this.recordTail.then(async () => {
|
|
963
|
-
await appendEvent(this.config.logDir, this.state.node, event);
|
|
964
|
-
this.state.events.push(event);
|
|
965
|
-
if (this.stall !== null) this.stall = advanceStall(this.stall, [{ node: this.state.node, event }]);
|
|
966
|
-
if (this.recovery !== null) this.recovery = advanceRecovery(this.recovery, [event], path => resolve(this.config.tools.root, path));
|
|
967
|
-
for (const listener of this.recordListeners) {
|
|
968
|
-
try { listener({ node: this.state.node, id: event.id, type: event.t }); } catch { /* observers never own the append */ }
|
|
969
|
-
}
|
|
970
|
-
});
|
|
971
|
-
this.recordTail = saved.catch(() => {});
|
|
972
|
-
await saved;
|
|
973
|
-
if (redacted.added > 0 && !this.state.events.some(item => item.t === "guidance" && item.reason === "secret")) {
|
|
974
|
-
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "secret", text: SECRET_GUIDANCE, sources: [event.id] });
|
|
975
|
-
}
|
|
976
|
-
if (event.t === "tool_result" && event.ok && event.effect?.kind === "read") {
|
|
977
|
-
const path = resolve(this.config.tools.root, event.effect.path);
|
|
978
|
-
const skill = this.config.skills?.find(item => item.filePath === path);
|
|
979
|
-
if (skill && !skill.disableModelInvocation) {
|
|
980
|
-
await this.recordSkill(skill, "read", "model");
|
|
981
|
-
this.config.tools.allow = skillTools(this.allowedNames(), skill);
|
|
982
|
-
// Restricted skill scopes cannot grow their capability set by searching a parent directory.
|
|
983
|
-
if (skill.allowedTools !== undefined || skill.disallowedTools !== undefined) this.config.tools.deferredTools = [];
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
/** The text the user is answering, for recognising a solicited credential. */
|
|
989
|
-
private lastAssistantText(): string | undefined {
|
|
990
|
-
for (let i = this.state.events.length - 1; i >= 0; i--) {
|
|
991
|
-
const event = this.state.events[i]!;
|
|
992
|
-
if (event.t === "user") return undefined;
|
|
993
|
-
if (event.t === "assistant") {
|
|
994
|
-
const text = event.blocks.flatMap(block => block.type === "text" ? [block.text] : []).join("\n");
|
|
995
|
-
if (text.trim()) return text;
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
return undefined;
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
private allowedNames(): string[] {
|
|
1002
|
-
return this.config.frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" &&
|
|
1003
|
-
(this.config.tools.allow === undefined || this.config.tools.allow.includes(tool.name)) ? [tool.name] : []);
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
private adviceNames(frozen = this.config.frozen): string[] {
|
|
1007
|
-
const declared = frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" ? [tool.name] : []);
|
|
1008
|
-
return adviceTools({ ...this.config.tools, advice: declared, ...(frozen === this.config.frozen ? {} : { allow: declared }) });
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
private installSkillResources(): void {
|
|
1012
|
-
this.config.tools.skillResources = (this.config.skills ?? []).map(skill => ({ path: skill.filePath,
|
|
1013
|
-
text: skill.sourceText ?? skill.body, modelInvocable: !skill.disableModelInvocation && skill.context !== "fork" }));
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
private async recordSkill(skill: Skill, mode: "inline" | "fork" | "read", origin: "user" | "model"): Promise<void> {
|
|
1017
|
-
const artifact = await putBlob(join(this.config.logDir, "artifacts"), skill.sourceText ?? skill.body);
|
|
1018
|
-
await this.record({ t: "skill_loaded", id: this.clock.nextId(), at: this.clock.now(), name: skill.name, path: skill.filePath,
|
|
1019
|
-
sha256: artifact, artifact, mode, origin });
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
/** O.12: main instructions are durable; permissions apply to this invocation only. */
|
|
1023
|
-
async *sendSkill(skill: Skill, prompt: UserInput, signal?: AbortSignal): AsyncGenerator<LoopEvent> {
|
|
1024
|
-
if (this.running || this.cleanup || this.closing || this.isHalfRound()) throw new Error("a skill requires an idle paired session");
|
|
1025
|
-
skill = this.config.skills?.find(item => item.name === skill.name && item.filePath === skill.filePath) ?? skill;
|
|
1026
|
-
if (skill.userInvocable === false) throw new Error("this skill cannot be invoked by users");
|
|
1027
|
-
const savedTools = this.config.tools;
|
|
1028
|
-
this.config.tools = { ...savedTools, allow: skillTools(this.allowedNames(), skill) };
|
|
1029
|
-
if (skill.allowedTools !== undefined || skill.disallowedTools !== undefined) this.config.tools.deferredTools = [];
|
|
1030
|
-
try {
|
|
1031
|
-
await this.recordSkill(skill, skill.context === "fork" ? "fork" : "inline", "user");
|
|
1032
|
-
if (skill.context !== "fork") { yield* this.send(prompt, signal); return; }
|
|
1033
|
-
const owned = this.execution.begin(signal);
|
|
1034
|
-
try {
|
|
1035
|
-
await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), text: `Run isolated skill ${skill.name}; source ${skill.filePath}.` });
|
|
1036
|
-
const writes = skill.allowedTools?.some(name => this.allowedNames().includes(name) &&
|
|
1037
|
-
WRITING_BRANCH_TOOL_NAMES.includes(name) && !BRANCH_TOOL_NAMES.includes(name)) ?? false;
|
|
1038
|
-
const result = yield* this.runBranch({ purpose: `skill ${skill.name}`, prompt: userInputData(prompt).text, writes }, 0, owned.signal, skill);
|
|
1039
|
-
await result.accept?.();
|
|
1040
|
-
await this.record({ t: "extension", id: this.clock.nextId(), at: this.clock.now(), customType: "skill_result", content: result.content,
|
|
1041
|
-
display: true, inContext: true });
|
|
1042
|
-
// Evidence stays in the receipt; structured output validates the child's
|
|
1043
|
-
// actual final message, never the human-readable receipt wrapper (D11).
|
|
1044
|
-
const finalText = this.config.validateOutput ? result.finalText ?? "" : result.content;
|
|
1045
|
-
let outcome = result.outcome ?? executionOutcome("execution_error"), reason = "isolated skill finished";
|
|
1046
|
-
if (outcome.status === "completed") {
|
|
1047
|
-
const error = await this.validateFinalOutput(finalText);
|
|
1048
|
-
if (error !== undefined) { outcome = executionOutcome("execution_error"); reason = error; }
|
|
1049
|
-
}
|
|
1050
|
-
await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), reason, outcome });
|
|
1051
|
-
await this.saveCursor();
|
|
1052
|
-
yield { kind: "assistant_text", text: finalText };
|
|
1053
|
-
yield { kind: "done", outcome, reason };
|
|
1054
|
-
} finally { owned.finish(); }
|
|
1055
|
-
} finally { this.config.tools = savedTools; this.restoreDeclaredTools(); }
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
/** Every final publication path records the same validator receipt (D11). */
|
|
1059
|
-
private async validateFinalOutput(text: string): Promise<string | undefined> {
|
|
1060
|
-
if (!this.config.validateOutput) return undefined;
|
|
1061
|
-
const validation = this.config.validateOutput(text);
|
|
1062
|
-
await this.appendExtension({ customType: "output_validation", content: validation.ok ? "Final JSON output passed the selected schema." : `Final JSON output failed: ${validation.error ?? "schema mismatch"}`,
|
|
1063
|
-
display: false, inContext: false, data: { valid: validation.ok } });
|
|
1064
|
-
return validation.ok ? undefined : `output schema validation failed: ${validation.error ?? "schema mismatch"}`;
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
/** O.8: dispatch and diagnostics share exact source hints, including forks. */
|
|
1068
|
-
contextInput(): ProjectInput {
|
|
1069
|
-
return { frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events,
|
|
1070
|
-
logHint: { manifestPath: manifestPath(this.config.logDir),
|
|
1071
|
-
segmentPath: this.state.handoff?.predecessor?.log_ref ?? nodePath(this.config.logDir, this.state.node) } };
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
private async boundCarried(full: Handoff, tools = this.adviceNames()): Promise<Handoff> {
|
|
1075
|
-
return carriedHandoff(this.config.logDir, this.state.node, full, { handoff_tokens: DEFAULT_HANDOFF_TOKENS,
|
|
1076
|
-
...(this.config.workingSet ? { working_set_version: CURRENT_WORKING_SET_VERSION } : {}), guidance_tools: tools });
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
async acceptanceState(): Promise<AcceptanceState | undefined> {
|
|
1080
|
-
if (!this.running) await this.observeEvidence();
|
|
1081
|
-
// External editor changes must invalidate proof before the user reads it.
|
|
1082
|
-
if (!this.running) await this.refreshRuntimeTargets();
|
|
1083
|
-
return (await this.currentHandoff()).acceptance;
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
/** S.5: the goal in force, derived from the active log ancestry, so it survives restarts and resume. */
|
|
1087
|
-
async goal(): Promise<ActiveGoal | null> {
|
|
1088
|
-
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
1089
|
-
if (!history.ok) throw new Error(history.error);
|
|
1090
|
-
return activeGoal(history.nodes.flatMap(part => part.events));
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
/**
|
|
1094
|
-
* Records a goal from criteria the user accepted out of a model draft. The
|
|
1095
|
-
* draft event must be this session's assistant reply; the contract holds
|
|
1096
|
-
* only what was accepted (S.2).
|
|
1097
|
-
*/
|
|
1098
|
-
async setGoal(input: { objective: string; draft: string; criteria: readonly AcceptanceCriterion[]; accepted: readonly string[] }): Promise<ActiveGoal> {
|
|
1099
|
-
if (this.running || this.isHalfRound()) throw new Error("a goal can be set only while the session is idle");
|
|
1100
|
-
if (!input.objective.trim()) throw new Error("a goal needs an objective");
|
|
1101
|
-
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
1102
|
-
if (!history.ok) throw new Error(history.error);
|
|
1103
|
-
if (!history.nodes.some(part => part.events.some(event => event.t === "assistant" && event.id === input.draft))) throw new Error("the goal draft is not in this session");
|
|
1104
|
-
const contract = goalContract(input.criteria, input.accepted);
|
|
1105
|
-
const accepted = contract.criteria.map(item => item.id);
|
|
1106
|
-
const event: ActiveGoal = { t: "goal", id: this.clock.nextId(), at: this.clock.now(), action: "set", objective: input.objective.trim(),
|
|
1107
|
-
contract, draft: input.draft, accepted, rejected: input.criteria.map(item => item.id).filter(id => !accepted.includes(id)) };
|
|
1108
|
-
await this.record(event);
|
|
1109
|
-
await this.saveCursor();
|
|
1110
|
-
return event;
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
/**
|
|
1114
|
-
* The discriminating rung: each passing accepted check re-run in a clone with
|
|
1115
|
-
* this request's changes undone, recorded as counter_check facts with the
|
|
1116
|
-
* role each criterion was declared with. Guards run too: passing there is
|
|
1117
|
-
* what makes a guard a guard (S.7). The goal's verdict does not depend on it;
|
|
1118
|
-
* it tells the user which checks actually tested the change.
|
|
1119
|
-
*/
|
|
1120
|
-
private async counterCheckRequest(state: RequestState, signal?: AbortSignal): Promise<void> {
|
|
1121
|
-
const checks = state.criteria.flatMap(item => item.kind === "command" && item.status === "pass" && item.command
|
|
1122
|
-
? [{ id: item.id, command: item.command, ...(item.guard ? { guard: true as const } : {}) }] : []);
|
|
1123
|
-
if (checks.length === 0 || signal?.aborted) return;
|
|
1124
|
-
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
1125
|
-
if (!history.ok) throw new Error(history.error);
|
|
1126
|
-
const events = currentRequestRecords(history.nodes).map(record => record.event);
|
|
1127
|
-
// A snapshot taken when this request began reverts shell changes too; otherwise the journal does.
|
|
1128
|
-
const workspace = this.config.tools.workspace, base = this.goalBaseline?.request === state.request.event ? this.goalBaseline.snapshot : null;
|
|
1129
|
-
const now = base && workspace ? await workspace.snapshot(signal) : null;
|
|
1130
|
-
const revert = base && now && workspace ? invertFileChanges(await workspace.snapshotChanges(base, now, signal)) : undefined;
|
|
1131
|
-
const results = !this.config.blobDir ? checks.map(check => ({ ...check, reason: "this session keeps no file history to undo changes from" }))
|
|
1132
|
-
: await counterCheck({ root: this.config.tools.root, blobs: this.config.blobDir, ownedPaths: this.config.tools.ownedStatePaths ?? [],
|
|
1133
|
-
events, checks, timeoutMs: this.config.tools.timeoutMs, maxOutputBytes: this.config.tools.maxOutputBytes,
|
|
1134
|
-
outputStore: join(this.config.logDir, "artifacts"), ...(revert ? { revert } : {}), ...(signal ? { signal } : {}) });
|
|
1135
|
-
for (const result of results) await this.record({ t: "counter_check", id: this.clock.nextId(), at: this.clock.now(), request: state.request.event,
|
|
1136
|
-
criterion: result.id, command: result.command, ...(result.guard ? { guard: true as const } : {}),
|
|
1137
|
-
...("exit" in result ? { exit: result.exit } : { reason: result.reason }) });
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
/**
|
|
1141
|
-
* The independent rung: blind checks run on a clone of the final result once
|
|
1142
|
-
* the goal's own checks pass. The scan for exposure covers the whole log, not
|
|
1143
|
-
* just this request -- a blind file read yesterday is still seen.
|
|
1144
|
-
*/
|
|
1145
|
-
private async blindCheckRequest(state: RequestState, signal?: AbortSignal): Promise<void> {
|
|
1146
|
-
const blind = this.config.blindChecks;
|
|
1147
|
-
if (!blind || signal?.aborted) return;
|
|
1148
|
-
const nodes = await listNodes(this.config.logDir);
|
|
1149
|
-
const everything = (await Promise.all(nodes.map(node => readNode(this.config.logDir, node)))).flatMap(read => read.events);
|
|
1150
|
-
const exposure = blindExposure(everything, blind);
|
|
1151
|
-
const results = await runBlindChecks({ root: this.config.tools.root, blind, timeoutMs: this.config.tools.timeoutMs,
|
|
1152
|
-
maxOutputBytes: this.config.tools.maxOutputBytes, outputStore: join(this.config.logDir, "artifacts"), ...(signal ? { signal } : {}) });
|
|
1153
|
-
for (const result of results) await this.record({ t: "blind_check", id: this.clock.nextId(), at: this.clock.now(), request: state.request.event,
|
|
1154
|
-
check: result.id, exposed: exposure !== null, ...(exposure ? { exposure } : {}), ...("exit" in result ? { exit: result.exit } : { reason: result.reason }) });
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
async clearGoal(): Promise<boolean> {
|
|
1158
|
-
if (this.running) throw new Error("a goal can be cleared only while the session is idle");
|
|
1159
|
-
if (!await this.goal()) return false;
|
|
1160
|
-
await this.record({ t: "goal", id: this.clock.nextId(), at: this.clock.now(), action: "clear", reason: "user" });
|
|
1161
|
-
await this.saveCursor();
|
|
1162
|
-
return true;
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
async requestState(): Promise<RequestState | undefined> {
|
|
1166
|
-
// Use the same idle workspace/target observation boundary as /acceptance.
|
|
1167
|
-
if (!this.running) await this.acceptanceState();
|
|
1168
|
-
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
1169
|
-
if (!history.ok) throw new Error(history.error);
|
|
1170
|
-
return deriveRequestState(history.nodes, { strict: this.config.strictVerification === true, blind: this.config.blindChecks !== undefined });
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
/** Only called at paired, owned boundaries; probes never enter a pending model tool exchange. */
|
|
1174
|
-
private async refreshRuntimeTargets(): Promise<void> {
|
|
1175
|
-
const state = (await this.currentHandoff()).acceptance;
|
|
1176
|
-
const observations: string[] = [];
|
|
1177
|
-
for (const target of state?.targets ?? []) if (target.observation || target.binding) {
|
|
1178
|
-
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
1179
|
-
if (!history.ok) throw Error(history.error);
|
|
1180
|
-
const observed = await this.runtimeTargets.perform(this.config.tools, history.nodes, { action: "inspect", target: target.id });
|
|
1181
|
-
const id = this.clock.nextId();
|
|
1182
|
-
await this.record({ t: "target_observation", id, at: this.clock.now(), observation: observed.observation });
|
|
1183
|
-
observations.push(id);
|
|
1184
|
-
}
|
|
1185
|
-
if (!observations.length) return;
|
|
1186
|
-
const current = (await this.currentHandoff()).acceptance;
|
|
1187
|
-
if (current?.targets?.some(target => {
|
|
1188
|
-
const before = state?.targets?.find(item => item.id === target.id);
|
|
1189
|
-
return before?.status !== target.status || before.binding !== target.binding || before.build_event !== target.build_event;
|
|
1190
|
-
})) await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "runtime_acceptance", sources: observations,
|
|
1191
|
-
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.` });
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
async workItems(): Promise<readonly WorkItem[]> {
|
|
1195
|
-
const replayed = await replayContext(this.config.logDir, this.state.node);
|
|
1196
|
-
return deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events, depth: replayed.depth,
|
|
1197
|
-
self: await this.nodeRefFor(this.state.node) }).state.work_items ?? [];
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
/** What the next handoff would carry if the segment were cut now. Pure derivation; nothing is written. */
|
|
1201
|
-
async previewHandoff(): Promise<Handoff> { return this.currentHandoff(); }
|
|
1202
|
-
|
|
1203
|
-
private async currentHandoff(): Promise<Handoff> {
|
|
1204
|
-
const replayed = await replayContext(this.config.logDir, this.state.node);
|
|
1205
|
-
return deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events, depth: replayed.depth, self: await this.nodeRefFor(this.state.node) });
|
|
1206
|
-
}
|
|
1207
|
-
async branchStatuses() {
|
|
1208
|
-
return (await this.currentHandoff()).branches.map(item => ({ ...item, status: branchStatus(item, this.jobs.state(item.node)) }));
|
|
1209
|
-
}
|
|
1210
|
-
async processStatuses() {
|
|
1211
|
-
const handles = new Map(runningSessions(this.execOwner).map(session => [session.id, session.exit === null ? "running" as const : "ended_unobserved" as const]));
|
|
1212
|
-
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
1213
|
-
if (!history.ok) throw new Error(history.error);
|
|
1214
|
-
let processes: ProcessObservation[] = [];
|
|
1215
|
-
for (const part of await hydrateProcessReceipts(history.nodes, this.config.tools.outputStore!)) processes = deriveProcesses(processes, part.events, part.node);
|
|
1216
|
-
return processes.map(item => ({ ...item, status: processStatus(item, handles.get(item.session)) }));
|
|
1217
|
-
}
|
|
1218
|
-
/** User lifecycle commands share the coordinator; no model request is needed. */
|
|
1219
|
-
async resolveBranch(node: number, resolution: "wait" | "cancel" | "status", waitMs = 10_000): Promise<ToolResult> {
|
|
1220
|
-
if (this.running || this.cleanup || this.closing) throw new Error("branch commands require an idle session");
|
|
1221
|
-
const owned = this.execution.begin();
|
|
1222
|
-
try {
|
|
1223
|
-
const result = await this.runResolve({ node, resolution, wait_ms: waitMs });
|
|
1224
|
-
await this.saveCursor(); return finishToolResult(result);
|
|
1225
|
-
} finally { owned.finish(); }
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
/**
|
|
1229
|
-
* D14 for a branch the user opened rather than the model. Returns once
|
|
1230
|
-
* `spawn` is recorded; the child runs in the background with the caller's
|
|
1231
|
-
* explicit config (its own prompt, tools and roots: nothing of the parent's
|
|
1232
|
-
* transcript, so a review stays independent of the work it judges). When it
|
|
1233
|
-
* ends, `joined` plus one guidance line reach the parent at its next idle,
|
|
1234
|
-
* pairable point -- the line is how the parent model learns to read it.
|
|
1235
|
-
*/
|
|
1236
|
-
async startUserBranch(spec: {
|
|
1237
|
-
purpose: string; prompt: string; config: SessionConfig; provider?: Provider;
|
|
1238
|
-
/** Events the branch records before its first request (a review's premise receipt). */
|
|
1239
|
-
prelude?: (branch: Session) => Promise<void>;
|
|
1240
|
-
/** The guidance the parent model gets once the branch joined. */
|
|
1241
|
-
note: (node: number, joined: Extract<LogEvent, { t: "joined" }>) => string;
|
|
1242
|
-
}): Promise<number> {
|
|
1243
|
-
if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
|
|
1244
|
-
const node = this.state.nextNode++;
|
|
1245
|
-
this.startingUserBranches.add(node);
|
|
1246
|
-
try { return await this.launchUserBranch(node, spec); } finally { this.startingUserBranches.delete(node); }
|
|
1247
|
-
}
|
|
1248
|
-
private async launchUserBranch(node: number, spec: Parameters<Session["startUserBranch"]>[0]): Promise<number> {
|
|
1249
|
-
const sourceEvent = this.state.events.findLast(event => event.t === "user")?.id;
|
|
1250
|
-
const { ref, branch } = await this.openBranch({ node, purpose: spec.purpose, background: true, ...(sourceEvent ? { sourceEvent } : {}),
|
|
1251
|
-
inputWorkspace: { kind: "shared_readonly" }, worktree: null, config: spec.config, ...(spec.provider ? { provider: spec.provider } : {}) });
|
|
1252
|
-
// Not in `children`: the parent's cancel() disposes those, and an Esc must not stop this one.
|
|
1253
|
-
try { await spec.prelude?.(branch); await this.saveCursor(); }
|
|
1254
|
-
catch (error) { await branch.dispose(); throw error; }
|
|
1255
|
-
const controller = new AbortController();
|
|
1256
|
-
this.userJobs.start(node, controller, async () => {
|
|
1257
|
-
const { outcome, text } = await this.runChild(node, branch, spec.prompt, true, controller.signal, true);
|
|
1258
|
-
const { joined } = await this.closeBranch(ref, branch, outcome, text);
|
|
1259
|
-
if (joined.t !== "joined") throw new Error("a closed branch must produce a joined event");
|
|
1260
|
-
this.pendingJoins.push({ joined, note: spec.note(node, joined), usage: [...branch.state.usage] });
|
|
1261
|
-
await this.flushUserJoins();
|
|
1262
|
-
return joined;
|
|
1263
|
-
}, () => {});
|
|
1264
|
-
return node;
|
|
1265
|
-
}
|
|
1266
|
-
/** User branches still running, so a client can show and stop them. */
|
|
1267
|
-
get userBranches(): readonly number[] {
|
|
1268
|
-
return [...this.startingUserBranches, ...this.userJobs.nodes().filter(node => this.userJobs.state(node) === "running")];
|
|
1269
|
-
}
|
|
1270
|
-
/** Every branch with a live child here: the model's (foreground or background) and the user's. */
|
|
1271
|
-
get runningBranches(): readonly number[] { return [...new Set([...this.children.keys(), ...this.userBranches])]; }
|
|
1272
|
-
cancelUserBranch(node: number): void { this.userJobs.cancel(node); }
|
|
1273
|
-
/**
|
|
1274
|
-
* The branch's join once it ended -- recorded, or still waiting for the
|
|
1275
|
-
* parent to be idle and pairable. Undefined if its finalization failed.
|
|
1276
|
-
*/
|
|
1277
|
-
async whenUserBranch(node: number): Promise<Extract<LogEvent, { t: "joined" }> | undefined> {
|
|
1278
|
-
await this.userJobs.drain(node); return (await this.userJobs.wait(node, 0))?.value;
|
|
1279
|
-
}
|
|
1280
|
-
/** Appends joins that waited for an idle, pairable parent. Never mid-turn: the guidance enters model context. */
|
|
1281
|
-
private async flushUserJoins(): Promise<void> {
|
|
1282
|
-
while (this.pendingJoins.length > 0 && !this.running && !this.cleanup && !this.isHalfRound()) {
|
|
1283
|
-
const item = this.pendingJoins.shift()!;
|
|
1284
|
-
this.state.usage.push(...item.usage);
|
|
1285
|
-
await this.record(item.joined);
|
|
1286
|
-
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "branch", text: item.note, sources: [item.joined.id] });
|
|
1287
|
-
await this.saveCursor();
|
|
1288
|
-
}
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
private async drainProcesses(): Promise<void> {
|
|
1292
|
-
const results = await disposeExecSessions(this.execOwner, this.config.tools.outputStore!, this.config.tools.maxOutputBytes);
|
|
1293
|
-
await this.config.tools.workspace?.abandonCommands();
|
|
1294
|
-
for (const result of results) {
|
|
1295
|
-
await this.record({ t: "process_exit", id: this.clock.nextId(), at: this.clock.now(), ...result, reason: "disposed" });
|
|
1296
|
-
}
|
|
1297
|
-
if (results.length > 0) await this.saveCursor();
|
|
1298
|
-
}
|
|
1299
|
-
private async acceptReadyBranches(): Promise<void> {
|
|
1300
|
-
for (const node of this.jobs.nodes()) {
|
|
1301
|
-
const done = await this.jobs.wait(node, 0);
|
|
1302
|
-
if (done?.value?.accept) await done.value.accept();
|
|
1303
|
-
this.jobs.forget(node);
|
|
1304
|
-
}
|
|
1305
|
-
}
|
|
1306
|
-
dispose(): Promise<void> {
|
|
1307
|
-
if (this.closing) return this.closing;
|
|
1308
|
-
this.cancel();
|
|
1309
|
-
// User branches outlive interrupts, not the session. Their joins are still recorded.
|
|
1310
|
-
this.userJobs.cancel();
|
|
1311
|
-
this.closing = this.whenIdle().then(async () => { await this.userJobs.drain(); await this.flushUserJoins(); }).then(() => this.rootTools?.close());
|
|
1312
|
-
return this.closing;
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
/** Only the app's user command calls this; note never grants human acceptance. */
|
|
1316
|
-
async acceptCriterion(criterion: string): Promise<AcceptanceState> {
|
|
1317
|
-
if (this.running || this.isHalfRound()) throw new Error("acceptance requires an idle, paired request");
|
|
1318
|
-
const current = await this.acceptanceState();
|
|
1319
|
-
const item = current?.criteria.find(item => item.id === criterion && item.kind === "human");
|
|
1320
|
-
if (!current || !item || item.kind !== "human") throw new Error("unknown human acceptance criterion");
|
|
1321
|
-
const target = item.target ? current.targets?.find(target => target.id === item.target) : undefined;
|
|
1322
|
-
if (item.target && (target?.status !== "ready" || !target.binding || !target.observation)) throw Error("human acceptance requires a current ready target instance");
|
|
1323
|
-
await this.record({ t: "acceptance_decision", id: this.clock.nextId(), at: this.clock.now(), request: current.request,
|
|
1324
|
-
contract_id: current.contract_id, criterion, actor: "user",
|
|
1325
|
-
...(target?.binding && target.observation ? { target_binding: target.binding, target_observation: target.observation } : {}) });
|
|
1326
|
-
await this.saveCursor();
|
|
1327
|
-
return (await this.acceptanceState())!;
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
async appendExtension(entry: Omit<Extract<LogEvent, { t: "extension" }>, "t" | "id" | "at">): Promise<void> {
|
|
1331
|
-
// Messages are only pairable between tools. Private extension state may
|
|
1332
|
-
// be recorded during execution because it never enters model context.
|
|
1333
|
-
if (entry.inContext && (this.running || this.isHalfRound())) throw new Error("Custom messages must be queued until the active turn finishes");
|
|
1334
|
-
const event = { ...entry, t: "extension" as const, id: this.clock.nextId(), at: this.clock.now() };
|
|
1335
|
-
if (!isLogEvent(event)) throw new Error("Invalid extension entry");
|
|
1336
|
-
await this.record(event);
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
get totals(): UsageTotals {
|
|
1340
|
-
return totalUsage(this.state.usage);
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
cancel(): void {
|
|
1344
|
-
this.execution.cancel(); this.jobs.cancel();
|
|
1345
|
-
for (const child of this.children.values()) child.cancel();
|
|
1346
|
-
if (!this.cleanup) this.cleanup = (async () => {
|
|
1347
|
-
await this.execution.whenIdle(); await this.jobs.drain();
|
|
1348
|
-
// A consumer can close at the preparation marker before execution is
|
|
1349
|
-
// admitted. Those children still have an owner, even without a job.
|
|
1350
|
-
for (const [node, child] of this.children) { await child.dispose(); this.children.delete(node); }
|
|
1351
|
-
await this.acceptReadyBranches(); await this.drainProcesses();
|
|
1352
|
-
})().then(() => { this.cleanup = null; });
|
|
1353
|
-
// A void cancellation caller may await later. Preserve rejection for the
|
|
1354
|
-
// owner while preventing an unhandled rejection from bypassing that path.
|
|
1355
|
-
void this.cleanup.catch(() => {});
|
|
1356
|
-
}
|
|
1357
|
-
async whenIdle(): Promise<void> { await this.execution.whenIdle(); await this.cleanup; }
|
|
1358
|
-
get running(): boolean { return this.execution.running; }
|
|
1359
|
-
|
|
1360
|
-
/**
|
|
1361
|
-
* Runs one user message to completion: model turns and tool calls until the
|
|
1362
|
-
* model stops asking for tools.
|
|
1363
|
-
*
|
|
1364
|
-
* `signal` is per-send, matching Provider.send. Abort is a decision, not a
|
|
1365
|
-
* failure: the generator yields `done` with reason `interrupted` and
|
|
1366
|
-
* returns. Any tool_use already on disk is answered with a tool_result
|
|
1367
|
-
* that says so -- otherwise the next request is unpairable (D8: the log
|
|
1368
|
-
* is append-only, so the alternative of deleting the tool_use is not
|
|
1369
|
-
* available, and would also lie about a call that may have had effects).
|
|
1370
|
-
*/
|
|
1371
|
-
async *send(
|
|
1372
|
-
userText: UserInput,
|
|
1373
|
-
signal?: AbortSignal,
|
|
1374
|
-
pull?: (phase: Phase) => Promise<UserInput | null>,
|
|
1375
|
-
requestId?: string,
|
|
1376
|
-
options: RequestOptions = {},
|
|
1377
|
-
): AsyncGenerator<LoopEvent> {
|
|
1378
|
-
if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
|
|
1379
|
-
const owned = this.execution.begin(signal);
|
|
1380
|
-
this.processWake = new AbortController();
|
|
1381
|
-
const savedTools = this.config.tools;
|
|
1382
|
-
this.turnTools = savedTools;
|
|
1383
|
-
this.config.tools = { ...savedTools };
|
|
1384
|
-
this.installSkillResources();
|
|
1385
|
-
const events = this.run(userText, owned.signal, pull, requestId, options);
|
|
1386
|
-
let terminal = false;
|
|
1387
|
-
let exhausted = false;
|
|
1388
|
-
try {
|
|
1389
|
-
for (;;) {
|
|
1390
|
-
const item = await events.next();
|
|
1391
|
-
if (item.done) { exhausted = true; break; }
|
|
1392
|
-
if (item.value.kind === "done") {
|
|
1393
|
-
terminal = true;
|
|
1394
|
-
if (item.value.outcome && item.value.outcome.reason !== "model_end") await this.record({
|
|
1395
|
-
t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
|
|
1396
|
-
reason: item.value.reason ?? item.value.outcome.reason, outcome: item.value.outcome,
|
|
1397
|
-
...(item.value.failure ? { failure: item.value.failure } : {}),
|
|
1398
|
-
});
|
|
1399
|
-
const request = await this.requestState();
|
|
1400
|
-
if (request) item.value.request = request;
|
|
1401
|
-
}
|
|
1402
|
-
yield item.value;
|
|
1403
|
-
}
|
|
1404
|
-
if (!terminal) {
|
|
1405
|
-
// Every path through run() ends with `done` or throws; this records the impossible case honestly.
|
|
1406
|
-
const outcome = executionOutcome("execution_error");
|
|
1407
|
-
await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), reason: "request ended without a terminal outcome", outcome });
|
|
1408
|
-
const request = await this.requestState();
|
|
1409
|
-
yield { kind: "done", reason: "stopped: request ended without a terminal outcome", outcome, ...(request ? { request } : {}) };
|
|
1410
|
-
}
|
|
1411
|
-
} catch (error) {
|
|
1412
|
-
// Raw exceptions can contain credentials; persist the category, never the message.
|
|
1413
|
-
// Only the provider request's own exception is a provider error: this catch
|
|
1414
|
-
// also sees failures in history reads and in the harness itself.
|
|
1415
|
-
const provider = this.providerFailure?.error === error ? this.providerFailure : undefined;
|
|
1416
|
-
const failure = failureCause(error, provider);
|
|
1417
|
-
await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
|
|
1418
|
-
reason: `request failed before completion (${describeFailure(failure)}); no complete response was committed`,
|
|
1419
|
-
outcome: executionOutcome(provider ? "provider_error" : "execution_error"), failure });
|
|
1420
|
-
throw error;
|
|
1421
|
-
} finally {
|
|
1422
|
-
// Closing a consumer is not a user cancellation. Preserve the existing
|
|
1423
|
-
// crash/recovery points; only an explicit signal can claim cancellation.
|
|
1424
|
-
try {
|
|
1425
|
-
if (!exhausted) {
|
|
1426
|
-
await events.return(undefined);
|
|
1427
|
-
}
|
|
1428
|
-
} finally {
|
|
1429
|
-
while (this.hostPending.size > 0) await Promise.allSettled(this.hostPending);
|
|
1430
|
-
this.config.tools = savedTools; this.turnTools = null; this.readOnlyRequest = false;
|
|
1431
|
-
if (owned.signal.aborted) this.cancel();
|
|
1432
|
-
owned.finish();
|
|
1433
|
-
if (owned.signal.aborted) await this.whenIdle();
|
|
1434
|
-
// A user branch that ended mid-turn joins now that the parent is idle.
|
|
1435
|
-
if (this.pendingJoins.length > 0) await this.flushUserJoins().catch(() => {});
|
|
1436
|
-
}
|
|
1437
|
-
}
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
|
-
private async *run(
|
|
1441
|
-
userText: UserInput,
|
|
1442
|
-
signal?: AbortSignal,
|
|
1443
|
-
pull?: (phase: Phase) => Promise<UserInput | null>,
|
|
1444
|
-
requestId?: string,
|
|
1445
|
-
options: RequestOptions = {},
|
|
1446
|
-
): AsyncGenerator<LoopEvent> {
|
|
1447
|
-
// A crash after a discovery receipt but before its cut is recovered from that same receipt.
|
|
1448
|
-
if (!signal?.aborted) await this.applyDiscoveredTools();
|
|
1449
|
-
if (needsReplayCheckpoint(this.state.events, providerReplayKey(this.provider.config.spec), nativeReplay(this.provider.config.spec))) {
|
|
1450
|
-
if (this.isHalfRound()) throw new Error("Cannot change a wire profile while tool results are pending");
|
|
1451
|
-
await this.performCut("model_checkpoint");
|
|
1452
|
-
}
|
|
1453
|
-
const blocked = this.config.imageOptions?.blockImages ?? false;
|
|
1454
|
-
// A privacy change uses the existing mechanical checkpoint (D5/D8); it never edits old messages.
|
|
1455
|
-
if (blocked && this.state.events.some(e => (e.t === "user" || e.t === "tool_result") && e.images?.some(image => !image.blocked))) {
|
|
1456
|
-
if (this.isHalfRound()) throw new Error("Cannot change image transmission while tool results are pending");
|
|
1457
|
-
await this.performCut("model_checkpoint");
|
|
1458
|
-
}
|
|
1459
|
-
const request = requestId ?? this.clock.nextId();
|
|
1460
|
-
// S.5: a draft request has no goal yet and must not inherit the current one.
|
|
1461
|
-
this.readOnlyRequest = options.readOnly === true;
|
|
1462
|
-
const goal = options.readOnly ? null : await this.goal();
|
|
1463
|
-
const contract = goal?.contract ?? this.config.acceptanceContract;
|
|
1464
|
-
await this.record({ t: "user", id: request, at: this.clock.now(), ...userInputData(userText, blocked) });
|
|
1465
|
-
if (contract) await this.record({ t: "acceptance_contract", id: this.clock.nextId(), at: this.clock.now(),
|
|
1466
|
-
request: this.state.events.at(-1)!.id, contract });
|
|
1467
|
-
// The state a counter-check must rebuild before it may run (feat-counter-check).
|
|
1468
|
-
if (goal) {
|
|
1469
|
-
await this.config.tools.workspace?.baseline(signal);
|
|
1470
|
-
const snapshot = await this.config.tools.workspace?.snapshot(signal);
|
|
1471
|
-
this.goalBaseline = snapshot ? { request, snapshot } : null;
|
|
1472
|
-
}
|
|
1473
|
-
// Idle changes belong to the new prompt. Recording them before `user`
|
|
1474
|
-
// would silently move the preceding exchange's restore point forward.
|
|
1475
|
-
|
|
1476
|
-
const counter = this.config.counter ?? estimateTokens;
|
|
1477
|
-
|
|
1478
|
-
try {
|
|
1479
|
-
let overflowRetried = false;
|
|
1480
|
-
const requestStarted = this.clock.now();
|
|
1481
|
-
const requestLimit = options.readOnly ? Math.min(READ_ONLY_REQUEST_LIMIT, this.config.maxTurns) : this.config.maxTurns;
|
|
1482
|
-
const report = (cause: string, requests: number) => this.terminationReport(cause, requests, requestLimit);
|
|
1483
|
-
for (let turn = 0; turn < requestLimit; turn++) {
|
|
1484
|
-
const phase = requestPhase(turn, requestLimit);
|
|
1485
|
-
if (signal?.aborted) {
|
|
1486
|
-
yield* this.closeInterrupted();
|
|
1487
|
-
return;
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
if (yield* this.checkStall(request, signal)) {
|
|
1491
|
-
yield { kind: "done", reason: await report("repeated tool cycle without new evidence", turn), outcome: executionOutcome("stalled") };
|
|
1492
|
-
return;
|
|
1493
|
-
}
|
|
1494
|
-
if (phase === "finalize" && !supportsToolDisabling(this.provider.config.spec.api)) {
|
|
1495
|
-
yield { kind: "done", reason: await report("work budget exhausted; protocol cannot disable tools while preserving frozen definitions", turn), outcome: executionOutcome("max_turns") };
|
|
1496
|
-
return;
|
|
1497
|
-
}
|
|
1498
|
-
yield { kind: "turn_start", node: this.state.node };
|
|
1499
|
-
|
|
1500
|
-
if (phase === "finalize") {
|
|
1501
|
-
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "budget", sources: [], text: finalizationNotice() });
|
|
1502
|
-
} else if (!options.readOnly && this.config.guidance !== false) {
|
|
1503
|
-
const names = this.adviceNames();
|
|
1504
|
-
const guidance = (this.config.harnessReviews === false ? null : await this.recoveryGuidance(names)) ??
|
|
1505
|
-
continuationGuidance({ events: this.state.events, handoff: this.state.handoff,
|
|
1506
|
-
tools: names, remainingRequests: this.config.maxTurns - turn,
|
|
1507
|
-
...(this.config.continuationAfter === undefined ? {} : { minAssistantTurns: this.config.continuationAfter }) }) ??
|
|
1508
|
-
(this.config.timeBudgetMs === undefined ? null
|
|
1509
|
-
: deadlineNotice(requestStarted, this.clock.now(), this.config.timeBudgetMs, this.state.events)) ??
|
|
1510
|
-
(this.config.harnessReviews === false ? null : churnReview(this.state.events));
|
|
1511
|
-
if (guidance) await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...guidance });
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
// New user/steering input and guidance can overflow a previously safe view.
|
|
1515
|
-
if ((this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY) === "windowed" &&
|
|
1516
|
-
(yield* this.maybeWindowCut(counter, true))) {
|
|
1517
|
-
yield { kind: "done", reason: "stopped: context cannot fit the model window", outcome: executionOutcome("context_budget") };
|
|
1518
|
-
return;
|
|
1519
|
-
}
|
|
1520
|
-
const projected = projectContext(this.contextInput());
|
|
1521
|
-
|
|
1522
|
-
// Pairing violations are a 400 waiting to happen; surface them here,
|
|
1523
|
-
// where the cause is still visible, rather than as a request failure.
|
|
1524
|
-
if (projected.violations.length > 0) {
|
|
1525
|
-
yield {
|
|
1526
|
-
kind: "done",
|
|
1527
|
-
reason: `projection violations: ${projected.violations.map((v) => v.code).join(", ")}`,
|
|
1528
|
-
outcome: executionOutcome("projection_invalid"),
|
|
1529
|
-
};
|
|
1530
|
-
return;
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
const window = this.provider.config.spec.contextWindow;
|
|
1534
|
-
const estimated = anchoredCount(projected.messages, projected.system, this.state.anchor,
|
|
1535
|
-
counter, this.config.frozen.tools).tokens;
|
|
1536
|
-
if (window !== undefined && estimated + this.provider.config.maxTokens > window) {
|
|
1537
|
-
yield { kind: "done", reason: `context budget exceeded: estimated input ${estimated} + output reserve ${this.provider.config.maxTokens} > window ${window}; shorten the input or switch to a larger verified window`, outcome: executionOutcome("context_budget") };
|
|
1538
|
-
return;
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
let result: Turn;
|
|
1542
|
-
try {
|
|
1543
|
-
result = yield* this.awaitProvider(projected.messages, signal, phase === "finalize" ? { toolUse: "disabled" } : undefined);
|
|
1544
|
-
} catch (error) {
|
|
1545
|
-
// D11: retain a typed response receipt once, without anchoring rejected content or trusting arbitrary error fields.
|
|
1546
|
-
if (error instanceof FailedResponseError && error.usage !== undefined) this.state.usage.push({ ...error.usage });
|
|
1547
|
-
if (signal?.aborted) {
|
|
1548
|
-
yield* this.closeInterrupted();
|
|
1549
|
-
return;
|
|
1550
|
-
}
|
|
1551
|
-
if (phase === "finalize") {
|
|
1552
|
-
yield { kind: "discard" };
|
|
1553
|
-
const failure = failureCause(error, this.providerFailure?.error === error ? this.providerFailure : { retries: 0 });
|
|
1554
|
-
yield { kind: "done", reason: await report(`final response failed (${describeFailure(failure)}); no additional request was made`, turn + 1),
|
|
1555
|
-
outcome: executionOutcome("provider_error"), failure };
|
|
1556
|
-
return;
|
|
1557
|
-
}
|
|
1558
|
-
if (!(error instanceof Error) || !isContextOverflow(error.message, statusOf(error))) throw error;
|
|
1559
|
-
// The estimate said this fit and the provider says it does not: the
|
|
1560
|
-
// estimate is wrong for this content. The only projection that can
|
|
1561
|
-
// shrink is a cut, so force one and send once more; a second
|
|
1562
|
-
// overflow means even the retained tail is misjudged, and another
|
|
1563
|
-
// attempt would only be paid for.
|
|
1564
|
-
if (overflowRetried || this.policyLimits().kind !== "window") {
|
|
1565
|
-
yield { kind: "done", reason: `stopped: provider rejected the prompt as too long (${error.message})`, outcome: executionOutcome("context_budget") };
|
|
1566
|
-
return;
|
|
1567
|
-
}
|
|
1568
|
-
overflowRetried = true;
|
|
1569
|
-
if (yield* this.maybeWindowCut(counter, true, true)) {
|
|
1570
|
-
yield { kind: "done", reason: "stopped: context cannot fit the model window", outcome: executionOutcome("context_budget") };
|
|
1571
|
-
return;
|
|
1572
|
-
}
|
|
1573
|
-
continue;
|
|
1574
|
-
}
|
|
1575
|
-
overflowRetried = false;
|
|
1576
|
-
|
|
1577
|
-
const previous = this.state.usage.at(-1);
|
|
1578
|
-
this.state.usage.push(result.usage);
|
|
1579
|
-
// Q24: an adapter may finish after abort. Keep measured usage, but cancellation
|
|
1580
|
-
// owns termination and a late response cannot enter context or execute tools.
|
|
1581
|
-
if (signal?.aborted) {
|
|
1582
|
-
yield { kind: "usage", usage: result.usage, ok: true };
|
|
1583
|
-
yield { kind: "discard" };
|
|
1584
|
-
yield* this.closeInterrupted();
|
|
1585
|
-
return;
|
|
1586
|
-
}
|
|
1587
|
-
const promptTokens =
|
|
1588
|
-
result.usage.input_tokens +
|
|
1589
|
-
result.usage.cache_creation_input_tokens +
|
|
1590
|
-
result.usage.cache_read_input_tokens;
|
|
1591
|
-
// A stream that reported no usage at all maps onto zeros in the
|
|
1592
|
-
// adapter. Anchoring to that fabrication would make shouldCut compare
|
|
1593
|
-
// against a fake small number and never fire. Leave the previous
|
|
1594
|
-
// anchor (null before the first measurement); anchoredCount already
|
|
1595
|
-
// falls back to a plain estimate when anchor is null. Do not invent
|
|
1596
|
-
// a substitute count — diagnosePrefix and /usage must not see a
|
|
1597
|
-
// fabricated prompt size.
|
|
1598
|
-
if (promptTokens > 0 || result.usage.output_tokens > 0) {
|
|
1599
|
-
this.state.anchor = {
|
|
1600
|
-
promptTokens,
|
|
1601
|
-
outputTokens: result.usage.output_tokens,
|
|
1602
|
-
messageCount: projected.messages.length,
|
|
1603
|
-
};
|
|
1604
|
-
}
|
|
1605
|
-
const cache = this.provider.config.spec.cache;
|
|
1606
|
-
if (cache === undefined) {
|
|
1607
|
-
yield {
|
|
1608
|
-
kind: "usage",
|
|
1609
|
-
usage: result.usage,
|
|
1610
|
-
prefix: "cache terms unknown",
|
|
1611
|
-
ok: true,
|
|
1612
|
-
};
|
|
1613
|
-
} else {
|
|
1614
|
-
const verdict = diagnosePrefix(result.usage, previous, cache);
|
|
1615
|
-
yield {
|
|
1616
|
-
kind: "usage",
|
|
1617
|
-
usage: result.usage,
|
|
1618
|
-
prefix: verdict.kind === "healthy"
|
|
1619
|
-
? `cache read ${verdict.read}, wrote ${verdict.wrote}`
|
|
1620
|
-
: verdict.detail,
|
|
1621
|
-
ok: verdict.kind !== "suspicious",
|
|
1622
|
-
};
|
|
1623
|
-
}
|
|
1624
|
-
|
|
1625
|
-
// D5: ambiguous IDs cannot be persisted or paired safely. Keep sampled
|
|
1626
|
-
// usage, but stop before observer hooks or any tool effect can run.
|
|
1627
|
-
const identityViolations = toolCallIdentityViolations(result.blocks);
|
|
1628
|
-
if (identityViolations.length > 0) {
|
|
1629
|
-
yield { kind: "done", outcome: executionOutcome("provider_error"),
|
|
1630
|
-
reason: `stopped: invalid provider tool call identity (${identityViolations.map(violation => `${violation.code}: ${violation.detail}`).join("; ")})` };
|
|
1631
|
-
return;
|
|
1632
|
-
}
|
|
1633
|
-
|
|
1634
|
-
await this.record({
|
|
1635
|
-
t: "assistant", id: this.clock.nextId(), at: this.clock.now(), blocks: result.blocks, stop_reason: result.stopReason,
|
|
1636
|
-
replay_key: providerReplayKey(this.provider.config.spec),
|
|
1637
|
-
});
|
|
1638
|
-
|
|
1639
|
-
for (const b of result.blocks) {
|
|
1640
|
-
if (b.type === "text" && b.text.trim()) yield { kind: "assistant_text", text: b.text };
|
|
1641
|
-
if (b.type === "thinking" && b.text.trim()) yield { kind: "thinking", text: b.text };
|
|
1642
|
-
}
|
|
1643
|
-
|
|
1644
|
-
const calls = result.blocks.filter(
|
|
1645
|
-
(b): b is Extract<AssistantBlock, { type: "tool_use" }> => b.type === "tool_use",
|
|
1646
|
-
);
|
|
1647
|
-
|
|
1648
|
-
// Provider policy is a wire instruction, not authority. Pair every
|
|
1649
|
-
// refused call, but never admit execution in the reserved final request.
|
|
1650
|
-
if (phase === "finalize" && (calls.length > 0 || !isCompleteResponse(result.stopReason) ||
|
|
1651
|
-
!result.blocks.some(block => block.type === "text" && block.text.trim()))) {
|
|
1652
|
-
for (const item of await this.answerUnpairedTools("not executed: tools are disabled in the final request", "not_started"))
|
|
1653
|
-
yield { kind: "tool_result", text: item.content, ok: false };
|
|
1654
|
-
yield { kind: "done", reason: await report("request budget exhausted; final response did not provide a complete text answer", turn + 1), outcome: executionOutcome("max_turns") };
|
|
1655
|
-
return;
|
|
1656
|
-
}
|
|
1657
|
-
|
|
1658
|
-
// N.12: syntactically valid arguments in an unfinished response are not
|
|
1659
|
-
// authorization to execute a partial batch. Pair without side effects.
|
|
1660
|
-
if (!isCompleteResponse(result.stopReason)) {
|
|
1661
|
-
for (const item of await this.answerUnpairedTools(incompleteToolText(result.stopReason))) {
|
|
1662
|
-
yield { kind: "tool_result", text: item.content, ok: false };
|
|
1663
|
-
}
|
|
1664
|
-
const extra = await this.pullSteering(pull);
|
|
1665
|
-
if (extra !== null) {
|
|
1666
|
-
await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), request, ...userInputData(extra, blocked) });
|
|
1667
|
-
continue;
|
|
1668
|
-
}
|
|
1669
|
-
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
1670
|
-
if (!history.ok) throw new Error(history.error);
|
|
1671
|
-
const notice = outputRecoveryNotice(history.nodes.flatMap(node => node.events),
|
|
1672
|
-
this.config.outputRecoveryLimit ?? DEFAULT_OUTPUT_RECOVERY_LIMIT, this.config.maxTurns - turn - 1);
|
|
1673
|
-
if (notice) {
|
|
1674
|
-
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...notice });
|
|
1675
|
-
if (yield* this.maybeCut(counter, true)) {
|
|
1676
|
-
yield { kind: "done", reason: "stopped: no verifiable progress", outcome: executionOutcome("no_progress") };
|
|
1677
|
-
return;
|
|
1678
|
-
}
|
|
1679
|
-
continue;
|
|
1680
|
-
}
|
|
1681
|
-
yield { kind: "done", reason: result.stopReason ?? "unknown model stop", outcome: modelOutcome(result.stopReason) };
|
|
1682
|
-
return;
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
if (calls.length === 0) {
|
|
1686
|
-
const extra = await this.pullSteering(pull);
|
|
1687
|
-
if (extra !== null) {
|
|
1688
|
-
await this.record({
|
|
1689
|
-
t: "user", id: this.clock.nextId(), at: this.clock.now(), request, ...userInputData(extra, blocked),
|
|
1690
|
-
});
|
|
1691
|
-
continue;
|
|
1692
|
-
}
|
|
1693
|
-
if (contract) await this.refreshRuntimeTargets();
|
|
1694
|
-
const state = await this.requestState();
|
|
1695
|
-
const remaining = requestLimit - turn - 1;
|
|
1696
|
-
// S.5: an active goal replaces the one-time completion check. It keeps
|
|
1697
|
-
// the request going until the ledger says the goal is met; a stated
|
|
1698
|
-
// blocker, a spent budget or the stall watchdog still end the run.
|
|
1699
|
-
const verdict = goal && state ? goalDecision(state, remaining, idleGoalChecks(this.state.events)) : null;
|
|
1700
|
-
if (state && (verdict?.action === "met" || verdict?.action === "await_human")) {
|
|
1701
|
-
await this.counterCheckRequest(state, signal);
|
|
1702
|
-
await this.blindCheckRequest(state, signal);
|
|
1703
|
-
}
|
|
1704
|
-
if (verdict?.action === "met") await this.record({ t: "goal", id: this.clock.nextId(), at: this.clock.now(), action: "clear", reason: "met" });
|
|
1705
|
-
const decision = verdict?.action === "continue" ? { action: "continue" as const, guidance: { reason: "goal" as const, sources: [state!.request.event], text: verdict.text } }
|
|
1706
|
-
: verdict || !state || options.readOnly || this.config.guidance === false || this.config.harnessReviews === false ? { action: "finish" as const }
|
|
1707
|
-
: requestCompletion(state, remaining);
|
|
1708
|
-
if (decision.action === "continue") {
|
|
1709
|
-
if (yield* this.maybeCut(counter, true)) {
|
|
1710
|
-
yield { kind: "done", reason: "stopped: no verifiable progress", outcome: executionOutcome("no_progress") };
|
|
1711
|
-
return;
|
|
1712
|
-
}
|
|
1713
|
-
// Q23: a notice appended before a cut can disappear from the next
|
|
1714
|
-
// projection. Append at the destination so the provider sees it once.
|
|
1715
|
-
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...decision.guidance });
|
|
1716
|
-
continue;
|
|
1717
|
-
}
|
|
1718
|
-
const stopped = yield* this.maybeCut(counter, false);
|
|
1719
|
-
if (!stopped) {
|
|
1720
|
-
const error = await this.validateFinalOutput(result.blocks.filter(block => block.type === "text").map(block => block.text).join("\n"));
|
|
1721
|
-
if (error !== undefined) {
|
|
1722
|
-
yield { kind: "done", reason: error, outcome: executionOutcome("execution_error") }; return;
|
|
1723
|
-
}
|
|
1724
|
-
}
|
|
1725
|
-
yield {
|
|
1726
|
-
kind: "done",
|
|
1727
|
-
reason: stopped ? "stopped: no verifiable progress" : result.stopReason ?? "end_turn",
|
|
1728
|
-
outcome: stopped ? executionOutcome("no_progress") : modelOutcome(result.stopReason),
|
|
1729
|
-
};
|
|
1730
|
-
return;
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
|
-
let branchesThisTurn = 0;
|
|
1734
|
-
type Settled = { ok: true; value: SessionBrokerReceipt } | { ok: false; error: unknown };
|
|
1735
|
-
const pending = new Map<string, Promise<Settled>>();
|
|
1736
|
-
const modes = calls.map(call => this.executionMode(call));
|
|
1737
|
-
try { for (let callIndex = 0; callIndex < calls.length; callIndex++) {
|
|
1738
|
-
const call = calls[callIndex]!;
|
|
1739
|
-
if (signal?.aborted && !pending.has(call.call_id)) {
|
|
1740
|
-
yield* this.closeInterrupted();
|
|
1741
|
-
return;
|
|
1742
|
-
}
|
|
1743
|
-
|
|
1744
|
-
if (!pending.has(call.call_id)) {
|
|
1745
|
-
const end = schedulingWindow(modes, callIndex, this.config.maxParallelTools ?? 3);
|
|
1746
|
-
if (end - callIndex > 1) for (let index = callIndex; index < end; index++) {
|
|
1747
|
-
const planned = calls[index]!;
|
|
1748
|
-
yield { kind: "tool_call", tool: { name: planned.name, input: planned.input } };
|
|
1749
|
-
if (signal?.aborted) break;
|
|
1750
|
-
const dispatch = yield* this.prepareBrokerCall(planned, () => branchesThisTurn++, blocked, signal);
|
|
1751
|
-
pending.set(planned.call_id, dispatch.run().then(value => ({ ok: true as const, value }), error => ({ ok: false as const, error })));
|
|
1752
|
-
}
|
|
1753
|
-
}
|
|
1754
|
-
|
|
1755
|
-
let receipt: SessionBrokerReceipt;
|
|
1756
|
-
const dispatched = pending.get(call.call_id);
|
|
1757
|
-
if (dispatched) {
|
|
1758
|
-
const settled = await dispatched;
|
|
1759
|
-
pending.delete(call.call_id);
|
|
1760
|
-
if (!settled.ok) throw settled.error;
|
|
1761
|
-
receipt = settled.value;
|
|
1762
|
-
} else {
|
|
1763
|
-
if (signal?.aborted) { yield* this.closeInterrupted(); return; }
|
|
1764
|
-
yield { kind: "tool_call", tool: { name: call.name, input: call.input } };
|
|
1765
|
-
if (signal?.aborted) { yield* this.closeInterrupted(); return; }
|
|
1766
|
-
const prepared = yield* this.prepareBrokerCall(call, () => branchesThisTurn++, blocked, signal);
|
|
1767
|
-
receipt = yield* withProgress<SessionBrokerReceipt, LoopEvent>(emit => prepared.run(emit));
|
|
1768
|
-
}
|
|
1769
|
-
await receipt.settle();
|
|
1770
|
-
for (const event of receipt.events.splice(0)) yield event;
|
|
1771
|
-
const out = finishToolResult(receipt.value);
|
|
1772
|
-
// Observation still precedes persistence: crashes here remain unknown,
|
|
1773
|
-
// while nested receipts have already been committed before their caller resumed.
|
|
1774
|
-
yield { kind: "tool_result", text: out.content, ok: out.ok, outcome: out.outcome! };
|
|
1775
|
-
await receipt.commit();
|
|
1776
|
-
|
|
1777
|
-
if (signal?.aborted && pending.size === 0) {
|
|
1778
|
-
yield* this.closeInterrupted();
|
|
1779
|
-
return;
|
|
1780
|
-
}
|
|
1781
|
-
} } finally { await Promise.allSettled(pending.values()); }
|
|
1782
|
-
|
|
1783
|
-
// One scan per turn, not per tool call: a turn is the smallest unit
|
|
1784
|
-
// that means anything, and per-call would be the character-level
|
|
1785
|
-
// granularity the research warns against.
|
|
1786
|
-
const declarationNode = this.state.node;
|
|
1787
|
-
if (!signal?.aborted && await this.applyDiscoveredTools()) yield { kind: "cut", node: declarationNode, reason: "model_checkpoint" };
|
|
1788
|
-
if (this.pendingRoot !== null) await this.applyPendingRoot();
|
|
1789
|
-
if (this.pendingAdoption !== null) await this.adoptPendingBranch();
|
|
1790
|
-
|
|
1791
|
-
// Between complete exchanges is the only safe place to cut.
|
|
1792
|
-
if (yield* this.maybeCut(counter, turn + 1 < this.config.maxTurns)) {
|
|
1793
|
-
yield { kind: "done", reason: "stopped: no verifiable progress", outcome: executionOutcome("no_progress") };
|
|
1794
|
-
return;
|
|
1795
|
-
}
|
|
1796
|
-
|
|
1797
|
-
const extra = await this.pullSteering(pull);
|
|
1798
|
-
if (extra !== null) {
|
|
1799
|
-
await this.record({
|
|
1800
|
-
t: "user", id: this.clock.nextId(), at: this.clock.now(), request, ...userInputData(extra, blocked),
|
|
1801
|
-
});
|
|
1802
|
-
}
|
|
1803
|
-
}
|
|
1804
|
-
|
|
1805
|
-
yield { kind: "done", reason: await report("request budget exhausted", requestLimit), outcome: executionOutcome("max_turns") };
|
|
1806
|
-
} finally {
|
|
1807
|
-
// If the consumer aborts and stops pulling, the generator is closed
|
|
1808
|
-
// without running the yield* closeInterrupted path. Pairing still
|
|
1809
|
-
// has to happen: an unanswered tool_use on disk is a 400 next turn.
|
|
1810
|
-
if (signal?.aborted) await this.answerUnpairedTools();
|
|
1811
|
-
}
|
|
1812
|
-
}
|
|
1813
|
-
|
|
1814
|
-
/**
|
|
1815
|
-
* Answers every tool_use that has no tool_result yet.
|
|
1816
|
-
*
|
|
1817
|
-
* Append, never delete: the call was issued, and rolling it back would
|
|
1818
|
-
* rewrite the log (D8) and hide a call that may already have touched
|
|
1819
|
-
* disk. Same shape as a refused branch -- a result that says why.
|
|
1820
|
-
*/
|
|
1821
|
-
private async answerUnpairedTools(
|
|
1822
|
-
content = "interrupted: the user cancelled before this tool completed",
|
|
1823
|
-
reason: ExecutionReason = "unknown",
|
|
1824
|
-
): Promise<{ callId: string; content: string }[]> {
|
|
1825
|
-
const added: { callId: string; content: string }[] = [];
|
|
1826
|
-
for (const block of pendingToolCalls(this.state.events)) {
|
|
1827
|
-
const resultText = knownUnexecutedTool(this.state.events, block.call_id) ?? content;
|
|
1828
|
-
await this.record({
|
|
1829
|
-
t: "tool_result",
|
|
1830
|
-
id: this.clock.nextId(),
|
|
1831
|
-
at: this.clock.now(),
|
|
1832
|
-
call_id: block.call_id,
|
|
1833
|
-
...(block.invocation_id ? { invocation_ref: { node: this.state.node, event: block.invocation_id } } : {}),
|
|
1834
|
-
...(block.parent_call_id ? { parent_call_id: block.parent_call_id } : {}),
|
|
1835
|
-
...(block.origin ? { origin: block.origin } : {}),
|
|
1836
|
-
ok: false,
|
|
1837
|
-
content: resultText,
|
|
1838
|
-
outcome: executionOutcome(reason),
|
|
1839
|
-
});
|
|
1840
|
-
added.push({ callId: block.call_id, content: resultText });
|
|
1841
|
-
}
|
|
1842
|
-
return added;
|
|
1843
|
-
}
|
|
1844
|
-
|
|
1845
|
-
private async *closeInterrupted(): AsyncGenerator<LoopEvent> {
|
|
1846
|
-
const added = await this.answerUnpairedTools();
|
|
1847
|
-
for (const item of added) {
|
|
1848
|
-
yield { kind: "tool_result", text: item.content, ok: false };
|
|
1849
|
-
}
|
|
1850
|
-
await this.recordInterrupt();
|
|
1851
|
-
yield { kind: "done", reason: "interrupted", outcome: executionOutcome("cancelled") };
|
|
1852
|
-
}
|
|
1853
|
-
|
|
1854
|
-
/**
|
|
1855
|
-
* Appends an interrupt event for the user turn this send opened.
|
|
1856
|
-
*
|
|
1857
|
-
* The cancel happened after that user event, so it is a later record --
|
|
1858
|
-
* not a flag written back onto it (D8). Partial assistant text is omitted:
|
|
1859
|
-
* a cancelled generation was never delivered (AGENT_PLAYBOOK: a failed
|
|
1860
|
-
* request is not a conversation that happened).
|
|
1861
|
-
*/
|
|
1862
|
-
private async recordInterrupt(): Promise<void> {
|
|
1863
|
-
for (let i = this.state.events.length - 1; i >= 0; i--) {
|
|
1864
|
-
const event = this.state.events[i]!;
|
|
1865
|
-
if (event.t !== "user") continue;
|
|
1866
|
-
await this.record({
|
|
1867
|
-
t: "interrupt",
|
|
1868
|
-
id: this.clock.nextId(),
|
|
1869
|
-
at: this.clock.now(),
|
|
1870
|
-
user_id: event.id,
|
|
1871
|
-
});
|
|
1872
|
-
return;
|
|
1873
|
-
}
|
|
1874
|
-
}
|
|
1875
|
-
|
|
1876
|
-
/**
|
|
1877
|
-
* Runs the provider and yields display deltas as they arrive, so a long
|
|
1878
|
-
* reply is visible before `send` resolves. The returned Turn is still the
|
|
1879
|
-
* complete message -- that is what gets logged.
|
|
1880
|
-
*/
|
|
1881
|
-
private async *awaitProvider(
|
|
1882
|
-
messages: readonly Message[],
|
|
1883
|
-
signal?: AbortSignal,
|
|
1884
|
-
options?: ProviderSendOptions,
|
|
1885
|
-
): AsyncGenerator<LoopEvent, Turn> {
|
|
1886
|
-
const hydrated = await hydrateImages(messages, this.config.blobDir ?? join(this.config.logDir, "blobs"),
|
|
1887
|
-
this.provider.config.spec.input?.includes("image") ?? false);
|
|
1888
|
-
let retries = 0;
|
|
1889
|
-
this.providerFailure = undefined;
|
|
1890
|
-
const progress = (emit: (event: LoopEvent) => void) => (delta: StreamDelta): void => {
|
|
1891
|
-
switch (delta.kind) {
|
|
1892
|
-
case "text":
|
|
1893
|
-
emit({ kind: "text_delta", text: delta.text });
|
|
1894
|
-
break;
|
|
1895
|
-
case "thinking":
|
|
1896
|
-
emit({ kind: "thinking_delta", text: delta.text });
|
|
1897
|
-
break;
|
|
1898
|
-
case "discard":
|
|
1899
|
-
emit({ kind: "discard" });
|
|
1900
|
-
break;
|
|
1901
|
-
case "retry":
|
|
1902
|
-
retries = Math.max(retries, delta.attempt);
|
|
1903
|
-
emit({ kind: "progress", progress: delta });
|
|
1904
|
-
break;
|
|
1905
|
-
case "tool":
|
|
1906
|
-
emit({ kind: "progress", progress: delta });
|
|
1907
|
-
break;
|
|
1908
|
-
default:
|
|
1909
|
-
return assertNever(delta, "onDelta");
|
|
1910
|
-
}
|
|
1911
|
-
};
|
|
1912
|
-
try {
|
|
1913
|
-
return yield* withProgress<Turn, LoopEvent>(emit => this.provider.send(
|
|
1914
|
-
this.config.frozen, hydrated, this.state.node, signal, progress(emit), options));
|
|
1915
|
-
} catch (error) {
|
|
1916
|
-
// The outcome needs to know this exception came from the request, and after how many retries.
|
|
1917
|
-
this.providerFailure = { error, retries };
|
|
1918
|
-
throw error;
|
|
1919
|
-
}
|
|
1920
|
-
}
|
|
1921
|
-
|
|
1922
|
-
/**
|
|
1923
|
-
* Yields any boundary events and returns true when the session should stop.
|
|
1924
|
-
*
|
|
1925
|
-
* Returning the verdict rather than only announcing it is the point: an
|
|
1926
|
-
* advisory the loop ignores looks like a guard while the budget drains.
|
|
1927
|
-
*/
|
|
1928
|
-
private get limits(): BranchLimits {
|
|
1929
|
-
return { ...DEFAULT_BRANCH_LIMITS, ...this.config.branches };
|
|
1930
|
-
}
|
|
1931
|
-
|
|
1932
|
-
private nodeRefFor(node: number): Promise<NodeRef> {
|
|
1933
|
-
return digestedRef(this.config.logDir, node);
|
|
1934
|
-
}
|
|
1935
|
-
|
|
1936
|
-
/**
|
|
1937
|
-
* Runs a read-only branch in its own node and returns a summary.
|
|
1938
|
-
*
|
|
1939
|
-
* The branch is an ordinary Session with a different node, a reduced tool
|
|
1940
|
-
* set and no handoff -- the recursion is what keeps a branch and a session
|
|
1941
|
-
* the same thing, so nothing here has to reimplement the loop.
|
|
1942
|
-
*/
|
|
1943
|
-
private executionMode(call: ToolCall): "parallel" | "exclusive" {
|
|
1944
|
-
if (this.config.tools.extensionHooks?.serial?.()) return "exclusive";
|
|
1945
|
-
if (call.name === "read" && typeof call.input === "object" && call.input !== null &&
|
|
1946
|
-
this.config.skills?.some(skill => skill.filePath === resolve(this.config.tools.root, String((call.input as Record<string, unknown>)["path"])))) return "exclusive";
|
|
1947
|
-
if (call.name === "branch" && typeof call.input === "object" && call.input !== null &&
|
|
1948
|
-
(call.input as Record<string, unknown>)["writes"] !== true && (call.input as Record<string, unknown>)["background"] !== true) return "parallel";
|
|
1949
|
-
return toolDefinition(call.name, this.config.tools)?.execution ?? "exclusive";
|
|
1950
|
-
}
|
|
1951
|
-
|
|
1952
|
-
private async *dispatchBranch(input: unknown, index: number, signal?: AbortSignal): AsyncGenerator<LoopEvent, DeferredToolResult> {
|
|
1953
|
-
if (typeof input !== "object" || input === null || !("background" in input) || input.background !== true) return yield* this.runBranch(input, index, signal);
|
|
1954
|
-
if (this.jobs.active >= (this.config.maxParallelTools ?? 3)) return { ok: false, content: "background branch capacity reached; wait or cancel an owned branch first" };
|
|
1955
|
-
const controller = new AbortController();
|
|
1956
|
-
const abort = () => controller.abort();
|
|
1957
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
1958
|
-
if (signal?.aborted) abort();
|
|
1959
|
-
const detach = () => signal?.removeEventListener("abort", abort);
|
|
1960
|
-
const child = this.runBranch(input, index, controller.signal);
|
|
1961
|
-
let registered = false;
|
|
1962
|
-
try {
|
|
1963
|
-
for (;;) {
|
|
1964
|
-
const step = await child.next();
|
|
1965
|
-
if (step.done) return step.value;
|
|
1966
|
-
yield step.value;
|
|
1967
|
-
if (step.value.kind !== "branch_start") continue;
|
|
1968
|
-
const node = step.value.node!;
|
|
1969
|
-
this.jobs.start(node, controller, async () => {
|
|
1970
|
-
const result = await child.next();
|
|
1971
|
-
if (!result.done) throw new Error("unexpected background child yield");
|
|
1972
|
-
return result.value;
|
|
1973
|
-
}, detach);
|
|
1974
|
-
registered = true;
|
|
1975
|
-
return { ok: true, content: `branch ${node} running; ` + (this.adviceNames().includes("resolve")
|
|
1976
|
-
? `use resolve(node=${node}, resolution='status'|'wait'|'cancel')` : "branch-observation capability unavailable; completion is unknown"), data: { node, background: true, running: true } };
|
|
1977
|
-
}
|
|
1978
|
-
} finally { if (!registered) detach(); }
|
|
1979
|
-
}
|
|
1980
|
-
|
|
1981
|
-
private async *prepareBrokerCall(call: ToolCall, nextBranch: () => number, blockedImages: boolean,
|
|
1982
|
-
signal?: AbortSignal, native = false): AsyncGenerator<LoopEvent, PreparedBrokerCall> {
|
|
1983
|
-
this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
|
|
1984
|
-
const release = await this.rootTools.acquire(this.executionMode(call));
|
|
1985
|
-
let transferred = false;
|
|
1986
|
-
let scope: BrokerExecution<DeferredToolResult> | undefined;
|
|
1987
|
-
try {
|
|
1988
|
-
const events: LoopEvent[] = [];
|
|
1989
|
-
let emit = (event: LoopEvent) => { events.push(event); };
|
|
1990
|
-
let context: ToolContext = { ...this.config.tools, ...(this.readOnlyRequest ? { workflowMode: "plan" as const, planHint: READ_ONLY_HINT } : {}),
|
|
1991
|
-
imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"),
|
|
1992
|
-
advice: this.allowedNames(),
|
|
1993
|
-
imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}),
|
|
1994
|
-
// feat-approval-events: the broker has already recorded this call's
|
|
1995
|
-
// invocation, and its result comes after execution, so the order is fixed.
|
|
1996
|
-
onApproval: approval => this.record({ t: "approval", id: this.clock.nextId(), at: this.clock.now(), ...approval }) };
|
|
1997
|
-
if (native) context = nativeCommandContext(context);
|
|
1998
|
-
const decoder = new TextDecoder();
|
|
1999
|
-
context.onOutput = chunk => emit({ kind: "progress", progress: { kind: "output", name: call.name, text: decoder.decode(chunk, { stream: true }) } });
|
|
2000
|
-
context.onProcessWait = (session, active) => emit({ kind: "progress", progress: { kind: "waiting", session, active } });
|
|
2001
|
-
const broker = new ToolBroker<DeferredToolResult>({
|
|
2002
|
-
parallelLimit: this.config.maxParallelTools ?? 3,
|
|
2003
|
-
callId: () => `nested-${randomUUID()}`,
|
|
2004
|
-
mode: request => this.executionMode(request),
|
|
2005
|
-
invocation: (request, ctx, parent) => this.recordInvocation(request, ctx, parent),
|
|
2006
|
-
prepare: (request, ctx) => this.prepareSessionAction(request, ctx, nextBranch, event => emit(event)),
|
|
2007
|
-
settle: async result => {
|
|
2008
|
-
if (result.accept) events.push(...await result.accept());
|
|
2009
|
-
if (!result.content_ref) Object.assign(result, await presentObservation(context, result.content));
|
|
2010
|
-
},
|
|
2011
|
-
result: async (invocation, result) => {
|
|
2012
|
-
if (result.annotation) await this.record(result.annotation);
|
|
2013
|
-
await this.recordToolReceipt(invocation, finishToolResult(result), blockedImages);
|
|
2014
|
-
},
|
|
2015
|
-
});
|
|
2016
|
-
const execute = yield* withProgress<BrokerExecution<DeferredToolResult>, LoopEvent>(async output => {
|
|
2017
|
-
emit = output;
|
|
2018
|
-
scope = await broker.prepare(call, context);
|
|
2019
|
-
return scope;
|
|
2020
|
-
});
|
|
2021
|
-
emit = event => { events.push(event); };
|
|
2022
|
-
transferred = true;
|
|
2023
|
-
return { run: async output => {
|
|
2024
|
-
if (output) emit = output;
|
|
2025
|
-
let commitOwnsSlot = false;
|
|
2026
|
-
try {
|
|
2027
|
-
const receipt = await execute();
|
|
2028
|
-
if (native) {
|
|
2029
|
-
// An idle host result has no generator consumer to own its commit.
|
|
2030
|
-
// Retain the slot so a checkpoint cannot split invocation from receipt.
|
|
2031
|
-
commitOwnsSlot = true;
|
|
2032
|
-
return { ...receipt, events, commit: async () => { try { await receipt.commit(); } finally { release(); } } };
|
|
2033
|
-
}
|
|
2034
|
-
return { ...receipt, events };
|
|
2035
|
-
} finally { emit = event => { events.push(event); }; if (!commitOwnsSlot) release(); }
|
|
2036
|
-
} };
|
|
2037
|
-
} finally { if (!transferred) { try { await scope?.close(); } finally { release(); } } }
|
|
2038
|
-
}
|
|
2039
|
-
|
|
2040
|
-
private async recordInvocation(call: ToolCall, ctx: ToolContext, parent?: ToolInvocationEvent): Promise<ToolInvocationEvent> {
|
|
2041
|
-
const store = ctx.outputStore ?? join(this.config.logDir, "artifacts");
|
|
2042
|
-
const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));
|
|
2043
|
-
const archive = async (bytes: Uint8Array) => {
|
|
2044
|
-
async function* source() { yield bytes; }
|
|
2045
|
-
return (await archiveObservation(store, source(), 0)).ref;
|
|
2046
|
-
};
|
|
2047
|
-
let valid = false;
|
|
2048
|
-
try { valid = isJsonValue(call.input); } catch { /* Cyclic or excessively deep host input is not a JSON request. */ }
|
|
2049
|
-
const bytes = encode(valid ? call.input : { unavailable: "non-JSON arguments", type: typeof call.input });
|
|
2050
|
-
const definition = toolDefinition(call.name, ctx);
|
|
2051
|
-
const declared = this.config.frozen.tools.find(tool => tool !== null && typeof tool === "object" && "name" in tool && tool.name === call.name &&
|
|
2052
|
-
"description" in tool && typeof tool.description === "string" && "input_schema" in tool) as ReturnType<typeof modelToolSchema> | undefined;
|
|
2053
|
-
const schema = definition ? declared ?? modelToolSchema(definition, adviceTools(ctx)) : undefined;
|
|
2054
|
-
const descriptor = definition ? { version: 1, ...schema,
|
|
2055
|
-
...(declared && JSON.stringify(declared.input_schema) !== JSON.stringify(definition.input_schema) ? { admission_schema: definition.input_schema } : {}),
|
|
2056
|
-
access: definition.access, execution: definition.execution, branch: definition.branch, hiddenInputs: definition.hiddenInputs ?? [], result_version: 1 } : null;
|
|
2057
|
-
const original = this.state.events.findLast(event => event.t === "assistant" && event.blocks.some(block => block.type === "tool_use" && block.call_id === call.call_id));
|
|
2058
|
-
const originalInput = original?.t === "assistant" ? original.blocks.find(block => block.type === "tool_use" && block.call_id === call.call_id) : undefined;
|
|
2059
|
-
const changed = originalInput?.type !== "tool_use" || valid && JSON.stringify(originalInput.input) !== JSON.stringify(call.input);
|
|
2060
|
-
const event: ToolInvocationEvent = { t: "tool_invocation", id: this.clock.nextId(), at: this.clock.now(), call_id: call.call_id, name: call.name,
|
|
2061
|
-
...(ctx.origin ? { origin: ctx.origin } : {}),
|
|
2062
|
-
input_ref: await archive(bytes), schema_ref: descriptor ? await archive(encode(descriptor)) : null,
|
|
2063
|
-
...(parent ? { parent_call_id: parent.call_id, parent_ref: { node: this.state.node, event: parent.id } } : {}),
|
|
2064
|
-
...(valid && bytes.length <= MAX_NESTED_ARGUMENT_BYTES && (parent || changed) ? { input: call.input as JsonValue } : {}) };
|
|
2065
|
-
await this.record(event); return event;
|
|
2066
|
-
}
|
|
2067
|
-
|
|
2068
|
-
private async recordToolReceipt(invocation: ToolInvocationEvent, out: ToolResult, blockedImages: boolean): Promise<void> {
|
|
2069
|
-
let data = out.data;
|
|
2070
|
-
let dataRef;
|
|
2071
|
-
if (data !== undefined) {
|
|
2072
|
-
const bytes = new TextEncoder().encode(JSON.stringify(data));
|
|
2073
|
-
if (bytes.length > MAX_INLINE_RESULT_DATA_BYTES) {
|
|
2074
|
-
async function* source() { yield bytes; }
|
|
2075
|
-
dataRef = (await archiveObservation(this.config.tools.outputStore!, source(), 0)).ref;
|
|
2076
|
-
data = undefined;
|
|
2077
|
-
}
|
|
2078
|
-
}
|
|
2079
|
-
await this.record({ t: "tool_result", id: this.clock.nextId(), at: this.clock.now(), call_id: invocation.call_id,
|
|
2080
|
-
invocation_ref: { node: this.state.node, event: invocation.id },
|
|
2081
|
-
...(invocation.origin ? { origin: invocation.origin } : {}),
|
|
2082
|
-
...(invocation.parent_call_id ? { parent_call_id: invocation.parent_call_id } : {}), ok: out.ok, content: out.content,
|
|
2083
|
-
...(out.outcome ? { outcome: out.outcome } : {}), ...(data !== undefined ? { data } : {}), ...(dataRef ? { data_ref: dataRef } : {}),
|
|
2084
|
-
...(out.observation_sha256 !== undefined ? { observation_sha256: out.observation_sha256 } : {}),
|
|
2085
|
-
...(out.observation ? { observation: out.observation } : {}), ...(out.diagnostic ? { diagnostic: out.diagnostic } : {}),
|
|
2086
|
-
...(out.warnings ? { warnings: out.warnings } : {}), ...(out.instructions ? { instructions: out.instructions } : {}),
|
|
2087
|
-
...(out.content_sha256 ? { content_sha256: out.content_sha256 } : {}),
|
|
2088
|
-
...(out.content_ref ? { content_ref: out.content_ref } : {}),
|
|
2089
|
-
...(out.images?.length ? { images: submissionImages(out.images, blockedImages) } : {}), ...(out.effect ? { effect: out.effect } : {}) });
|
|
2090
|
-
}
|
|
2091
|
-
|
|
2092
|
-
private async prepareSessionAction(call: ToolCall, ctx: ToolContext, nextBranch: () => number,
|
|
2093
|
-
emit: (event: LoopEvent) => void): Promise<DeferredToolResult | (() => Promise<DeferredToolResult>)> {
|
|
2094
|
-
// SDK callers may omit allow or preauthorize an optional name. Permission is
|
|
2095
|
-
// distinct from a declaration: neither direct nor nested calls may run it early.
|
|
2096
|
-
const declared = declaredToolNames(this.config.frozen.tools);
|
|
2097
|
-
if (ctx.deferredTools?.some(tool => tool.name === call.name) && !declared.includes(call.name)) {
|
|
2098
|
-
return prepareNormalizedTool({ ...ctx, allow: (ctx.allow ?? declared).filter(name => name !== call.name) }, call.name, call.input);
|
|
2099
|
-
}
|
|
2100
|
-
const forkSkill = call.name === "read" && typeof call.input === "object" && call.input !== null ? this.config.skills?.find(skill =>
|
|
2101
|
-
skill.context === "fork" && skill.filePath === resolve(ctx.root, String((call.input as Record<string, unknown>)["path"]))) : undefined;
|
|
2102
|
-
const discovery = call.name === DISCOVERY_TOOL_NAME && ctx.extensions?.get(call.name) === DISCOVERY_TOOL;
|
|
2103
|
-
if (!forkSkill && !discovery && !["note", "branch", "resolve", "history", "target", "worktree"].includes(call.name)) return prepareNormalizedTool(ctx, call.name, call.input);
|
|
2104
|
-
const branchIndex = forkSkill || call.name === "branch" ? nextBranch() : 0;
|
|
2105
|
-
return prepareNormalizedTool(ctx, call.name, call.input, async () => {
|
|
2106
|
-
if (discovery) return async () => searchDeferredTools(ctx, call.input as Record<string, unknown>, this.allowedNames());
|
|
2107
|
-
if (call.name === "history") return () => runHistoryTool(this.config.logDir, this.state.node, ctx, call.input);
|
|
2108
|
-
if (call.name === "target") return async () => {
|
|
2109
|
-
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
2110
|
-
if (!history.ok) throw Error(history.error);
|
|
2111
|
-
const observed = await this.runtimeTargets.perform(ctx, history.nodes, call.input as Parameters<RuntimeTargets["perform"]>[2]);
|
|
2112
|
-
return { ...observed.result, annotation: { t: "target_observation" as const, id: this.clock.nextId(), at: this.clock.now(), observation: observed.observation } };
|
|
2113
|
-
};
|
|
2114
|
-
if (call.name === "note") return async () => {
|
|
2115
|
-
await this.observeEvidence();
|
|
2116
|
-
const parsed = parseNote(call.input);
|
|
2117
|
-
const prepared = await prepareWorkingNote(this.config.logDir, this.state.node, this.config.workNamespace ?? "main",
|
|
2118
|
-
parsed.annotation ? this.toEvent(parsed.annotation) : undefined, parsed.result);
|
|
2119
|
-
return { ...prepared.out, ...(prepared.annotation ? { annotation: prepared.annotation } : {}) };
|
|
2120
|
-
};
|
|
2121
|
-
if (call.name === "resolve") return () => this.runResolve(call.input);
|
|
2122
|
-
if (call.name === "worktree") return () => this.runWorktree(call.input, ctx.pathRoot);
|
|
2123
|
-
if (forkSkill?.disableModelInvocation) return async () => ({ ok: false, content: "this skill requires explicit user invocation" });
|
|
2124
|
-
if (forkSkill) await this.recordSkill(forkSkill, "fork", "model");
|
|
2125
|
-
const task = this.state.events.findLast(event => event.t === "user");
|
|
2126
|
-
const branch = forkSkill ? this.runBranch({ purpose: `skill ${forkSkill.name}`, prompt: `${forkSkill.body}\n\nCurrent task: ${task?.t === "user" ? task.text : ""}` },
|
|
2127
|
-
branchIndex, ctx.signal, forkSkill, skillTools(this.allowedNames(), forkSkill), "model") : this.dispatchBranch(call.input, branchIndex, ctx.signal);
|
|
2128
|
-
while (true) {
|
|
2129
|
-
const step = await branch.next();
|
|
2130
|
-
if (step.done) return async () => step.value;
|
|
2131
|
-
emit(step.value);
|
|
2132
|
-
if (step.value.kind === "branch_start") return async () => {
|
|
2133
|
-
const result = await branch.next();
|
|
2134
|
-
if (!result.done) throw new Error(`unexpected child execution event: ${result.value.kind}`);
|
|
2135
|
-
return result.value;
|
|
2136
|
-
};
|
|
2137
|
-
}
|
|
2138
|
-
});
|
|
2139
|
-
}
|
|
2140
|
-
|
|
2141
|
-
private async *runBranch(
|
|
2142
|
-
input: unknown,
|
|
2143
|
-
indexThisTurn: number,
|
|
2144
|
-
signal?: AbortSignal,
|
|
2145
|
-
skill?: Skill,
|
|
2146
|
-
allowedOverride?: readonly string[],
|
|
2147
|
-
skillOrigin: "user" | "model" = "user",
|
|
2148
|
-
): AsyncGenerator<LoopEvent, DeferredToolResult> {
|
|
2149
|
-
const limits = this.limits;
|
|
2150
|
-
const depth = this.config.branchDepth ?? 0;
|
|
2151
|
-
if (this.children.size >= (this.config.maxParallelTools ?? 3)) return { ok: false, content: "branch capacity reached; wait or cancel an owned branch first" };
|
|
2152
|
-
|
|
2153
|
-
// Every refusal is a normal tool_result: an unanswered tool_use makes the
|
|
2154
|
-
// next request unpairable, and a refusal can say what to do instead.
|
|
2155
|
-
if (depth + 1 >= limits.maxBranchDepth) {
|
|
2156
|
-
return {
|
|
2157
|
-
content:
|
|
2158
|
-
`refused: branch depth limit (${limits.maxBranchDepth}) reached. ` +
|
|
2159
|
-
"Do this work directly with your own tools.",
|
|
2160
|
-
ok: false,
|
|
2161
|
-
};
|
|
2162
|
-
}
|
|
2163
|
-
if (indexThisTurn >= limits.maxBranchesPerTurn) {
|
|
2164
|
-
return {
|
|
2165
|
-
content:
|
|
2166
|
-
`refused: at most ${limits.maxBranchesPerTurn} branches per turn. ` +
|
|
2167
|
-
"Run the rest in a later turn, or do them yourself -- branching is " +
|
|
2168
|
-
"usually more expensive than working sequentially.",
|
|
2169
|
-
ok: false,
|
|
2170
|
-
};
|
|
2171
|
-
}
|
|
2172
|
-
if (this.state.branchesOpened >= limits.maxBranchesPerSession) {
|
|
2173
|
-
return {
|
|
2174
|
-
content:
|
|
2175
|
-
`refused: this session has opened its ${limits.maxBranchesPerSession} branches. ` +
|
|
2176
|
-
"Continue directly.",
|
|
2177
|
-
ok: false,
|
|
2178
|
-
};
|
|
2179
|
-
}
|
|
2180
|
-
|
|
2181
|
-
const args = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
|
|
2182
|
-
const purpose = String(args["purpose"] ?? "").trim();
|
|
2183
|
-
const prompt = String(args["prompt"] ?? "").trim();
|
|
2184
|
-
const writes = args["writes"] === true;
|
|
2185
|
-
if (!purpose || !prompt) {
|
|
2186
|
-
return { content: "a branch needs both a purpose and a prompt", ok: false };
|
|
2187
|
-
}
|
|
2188
|
-
const parentAllow = allowedOverride ?? this.config.tools.allow;
|
|
2189
|
-
const branchNames = writes ? WRITING_BRANCH_TOOL_NAMES : BRANCH_TOOL_NAMES;
|
|
2190
|
-
const allowed: string[] = parentAllow
|
|
2191
|
-
? branchNames.filter(name => parentAllow.includes(name)) : [...branchNames];
|
|
2192
|
-
const sourceEvent = this.state.events.findLast(event => event.t === "assistant")?.id ?? this.state.events.at(-1)!.id;
|
|
2193
|
-
let inputWorkspace: { kind: "shared_readonly" } | { kind: "git_tree"; head: string; tree: string } = { kind: "shared_readonly" };
|
|
2194
|
-
|
|
2195
|
-
// Siblings are staggered so the second does not race the first for the
|
|
2196
|
-
// prompt cache and rewrite the whole prefix.
|
|
2197
|
-
if (indexThisTurn > 0 && limits.branchStaggerMs > 0) {
|
|
2198
|
-
await new Promise((r) => setTimeout(r, limits.branchStaggerMs));
|
|
2199
|
-
}
|
|
2200
|
-
|
|
2201
|
-
const node = this.state.nextNode++;
|
|
2202
|
-
this.state.branchesOpened += 1;
|
|
2203
|
-
// A fork point must include any preceding tool effects in this batch.
|
|
2204
|
-
|
|
2205
|
-
// A writing branch needs its own checkout, or parallel branches overwrite
|
|
2206
|
-
// each other's files with nothing to show isolation was never in place.
|
|
2207
|
-
// Refused rather than downgraded to read-only: the model asked to change
|
|
2208
|
-
// files, and quietly not letting it would look like the work was done.
|
|
2209
|
-
let worktree: { path: string; branch: string } | null = null;
|
|
2210
|
-
if (writes) {
|
|
2211
|
-
const created = await createWorktree(this.config.tools.root, `b${node}`);
|
|
2212
|
-
if (!created.ok) {
|
|
2213
|
-
this.state.branchesOpened -= 1;
|
|
2214
|
-
this.state.nextNode -= 1;
|
|
2215
|
-
return { content: `refused: ${created.error}`, ok: false };
|
|
2216
|
-
}
|
|
2217
|
-
worktree = { path: created.path, branch: created.branch };
|
|
2218
|
-
inputWorkspace = { kind: "git_tree", ...created.snapshot };
|
|
2219
|
-
this.state.worktrees.set(node, worktree);
|
|
2220
|
-
}
|
|
2221
|
-
|
|
2222
|
-
const { ref, branch } = await this.openBranch({
|
|
2223
|
-
node, purpose, background: args["background"] === true, sourceEvent, inputWorkspace, worktree,
|
|
2224
|
-
config: {
|
|
2225
|
-
...this.config,
|
|
2226
|
-
acceptanceContract: null,
|
|
2227
|
-
frozen: { system: this.config.frozen.system, tools: branchTools(writes, allowed) },
|
|
2228
|
-
// The same list twice: the schema shapes what the model asks for, the
|
|
2229
|
-
// allow-list decides what actually runs. Intersected with the parent
|
|
2230
|
-
// so `--exclude-tools bash` cannot be undone by opening a branch.
|
|
2231
|
-
tools: {
|
|
2232
|
-
...this.config.tools,
|
|
2233
|
-
...(worktree ? { root: worktree.path } : {}),
|
|
2234
|
-
allow: allowed,
|
|
2235
|
-
readonlyBranch: !writes,
|
|
2236
|
-
},
|
|
2237
|
-
branchDepth: depth + 1,
|
|
2238
|
-
workNamespace: `branch-${node}`,
|
|
2239
|
-
maxTurns: Math.min(this.config.maxTurns, 15),
|
|
2240
|
-
},
|
|
2241
|
-
});
|
|
2242
|
-
if (skill) await branch.recordSkill(skill, "fork", skillOrigin);
|
|
2243
|
-
this.children.set(node, branch);
|
|
2244
|
-
yield { kind: "branch_start", node, reason: purpose, at: Date.now() };
|
|
2245
|
-
|
|
2246
|
-
const { outcome, text } = await this.runChild(node, branch, prompt, args["background"] === true, signal);
|
|
2247
|
-
const closed = await this.closeBranch(ref, branch, outcome, text);
|
|
2248
|
-
const { tip, summary, receipt, receiptRef, joined } = closed;
|
|
2249
|
-
|
|
2250
|
-
// The worktree stays until the branch is resolved: its uncommitted work
|
|
2251
|
-
// is the branch's result, and removing it here would discard exactly
|
|
2252
|
-
// what the parent is about to decide on.
|
|
2253
|
-
const where = worktree
|
|
2254
|
-
? `\n\nIts changes are in ${worktree.path} on branch ${worktree.branch}, ` +
|
|
2255
|
-
"not in your files. Adopting continues in that checkout without merging it into this one."
|
|
2256
|
-
: "";
|
|
2257
|
-
|
|
2258
|
-
// Only the summary crosses back. The branch's own calls stay in its node,
|
|
2259
|
-
// which is the entire reason to branch rather than read in place.
|
|
2260
|
-
return {
|
|
2261
|
-
content:
|
|
2262
|
-
`branch ${node} (${purpose}) ended: ${JSON.stringify(outcome)}.\n` +
|
|
2263
|
-
`Evidence receipt: ${receipt.commands.length} commands, ${receipt.changes.length} file edits, ${receipt.unfinished.length} unfinished items; verification=${receipt.verification}; task acceptance=not assessed.\n` +
|
|
2264
|
-
(this.adviceNames().includes("history") ? `Read history(artifact='${receiptRef}'), then history(branch=${node}, event='source ID') for original evidence.\n`
|
|
2265
|
-
: `Evidence receipt artifact=${receiptRef}; original-source recovery capability unavailable.\n`) +
|
|
2266
|
-
`Child closing statement (unverified):\n${summary}${where}\n\n` +
|
|
2267
|
-
`Its full state is at ${handoffPath(this.config.logDir, tip)}. ` +
|
|
2268
|
-
(this.adviceNames().includes("resolve") ? `Resolve it with resolve(node: ${node}, ...).` : "Branch-resolution capability unavailable."),
|
|
2269
|
-
ok: outcome.status === "completed",
|
|
2270
|
-
outcome,
|
|
2271
|
-
finalText: text,
|
|
2272
|
-
accept: async () => {
|
|
2273
|
-
if ((await this.branchStatuses()).some(item => item.node === node && item.handoff_ref)) return [];
|
|
2274
|
-
this.state.usage.push(...branch.state.usage);
|
|
2275
|
-
await this.record(joined);
|
|
2276
|
-
await this.saveCursor();
|
|
2277
|
-
return [{ kind: "branch_done", node, text: summary, outcome, ok: outcome.status === "completed" }];
|
|
2278
|
-
},
|
|
2279
|
-
data: { node, outcome, handoff_ref: handoffPath(this.config.logDir, tip), requests: branch.state.usage.length,
|
|
2280
|
-
receipt_ref: receiptRef,
|
|
2281
|
-
input: { source_event: sourceEvent, workspace: inputWorkspace } },
|
|
2282
|
-
};
|
|
2283
|
-
}
|
|
2284
|
-
|
|
2285
|
-
/**
|
|
2286
|
-
* D14: the parent records `spawn`, the branch gets its own node file with
|
|
2287
|
-
* basis "none" (it starts fresh; the parent pointer is provenance only).
|
|
2288
|
-
* Shared by model-spawned branches and user-started ones (/review-code).
|
|
2289
|
-
*/
|
|
2290
|
-
private async openBranch(spec: {
|
|
2291
|
-
/** Omitted for a user command in an empty session: the spawn is then its own request. */
|
|
2292
|
-
node: number; purpose: string; background: boolean; sourceEvent?: string;
|
|
2293
|
-
inputWorkspace: { kind: "shared_readonly" } | { kind: "git_tree"; head: string; tree: string };
|
|
2294
|
-
worktree: { path: string; branch: string } | null; config: SessionConfig; provider?: Provider;
|
|
2295
|
-
}): Promise<{ ref: NodeRef; branch: Session }> {
|
|
2296
|
-
const { node, purpose, worktree } = spec;
|
|
2297
|
-
const ref = await this.nodeRefFor(node), id = this.clock.nextId();
|
|
2298
|
-
await this.record({
|
|
2299
|
-
t: "spawn", id, at: this.clock.now(), branch: ref, purpose,
|
|
2300
|
-
...(spec.background ? { background: true as const } : {}),
|
|
2301
|
-
input: { source_event: spec.sourceEvent ?? id, tools: spec.config.tools.allow ?? [], max_turns: spec.config.maxTurns, workspace: spec.inputWorkspace },
|
|
2302
|
-
...(worktree ? { workspace: worktree } : {}),
|
|
2303
|
-
});
|
|
2304
|
-
await initNode(this.config.logDir, node, {
|
|
2305
|
-
...await this.nodeRefFor(this.state.node), at_event: this.state.events.at(-1)!.id,
|
|
2306
|
-
}, this.clock.now(), "none");
|
|
2307
|
-
const branch = new Session(spec.config, spec.provider ?? (this.config.branchProvider ? this.config.branchProvider() : this.provider), this.clock, this.allocation);
|
|
2308
|
-
branch.state.node = node;
|
|
2309
|
-
branch.state.nextNode = this.state.nextNode;
|
|
2310
|
-
branch.secrets = this.secrets;
|
|
2311
|
-
branch.subscribeRecords(item => { for (const listener of this.branchRecordListeners) { try { listener(item); } catch { /* observers never own the append */ } } });
|
|
2312
|
-
return { ref, branch };
|
|
2313
|
-
}
|
|
2314
|
-
|
|
2315
|
-
/** Runs the child to its end. Its closing text is the last turn's text; a throw becomes a bounded outcome. */
|
|
2316
|
-
private async runChild(node: number, branch: Session, prompt: string, background: boolean, signal?: AbortSignal, keepRecorded = false): Promise<{ outcome: ExecutionOutcome; text: string }> {
|
|
2317
|
-
let finalParts: string[] = [];
|
|
2318
|
-
let outcome = executionOutcome("unknown");
|
|
2319
|
-
try {
|
|
2320
|
-
for await (const e of branch.send(prompt, signal)) {
|
|
2321
|
-
if (e.kind === "turn_start" || e.kind === "discard") finalParts = [];
|
|
2322
|
-
if (e.kind === "assistant_text") finalParts.push(e.text ?? "");
|
|
2323
|
-
if (e.kind === "done") outcome = e.outcome ?? executionOutcome("unknown");
|
|
2324
|
-
}
|
|
2325
|
-
} catch {
|
|
2326
|
-
// Persist a bounded failure classification, never a provider's raw error body.
|
|
2327
|
-
// A model's branch joins as execution_error (its caller sees a failed tool). A user branch keeps the
|
|
2328
|
-
// classification its own send recorded (provider_error vs execution_error), as the detached review did.
|
|
2329
|
-
const recorded = keepRecorded ? branch.state.events.findLast(event => event.t === "outcome") : undefined;
|
|
2330
|
-
outcome = signal?.aborted ? executionOutcome("cancelled") : recorded?.t === "outcome" ? recorded.outcome : executionOutcome("execution_error");
|
|
2331
|
-
await branch.answerUnpairedTools("the branch execution failed; this tool's outcome is unknown; inspect before retrying");
|
|
2332
|
-
} finally { try { await branch.dispose(); } finally { this.children.delete(node); } }
|
|
2333
|
-
if (background) await branch.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), outcome, reason: "background branch execution ended" });
|
|
2334
|
-
return { outcome, text: finalParts.join("\n") };
|
|
2335
|
-
}
|
|
2336
|
-
|
|
2337
|
-
/** Closes the branch's tip with its derived handoff and builds the `joined` the parent will record (D15). */
|
|
2338
|
-
private async closeBranch(ref: NodeRef, branch: Session, outcome: ExecutionOutcome, text: string) {
|
|
2339
|
-
// Node numbers the branch allocated are taken.
|
|
2340
|
-
const tip = branch.state.node;
|
|
2341
|
-
const branchContext = await replayContext(this.config.logDir, tip);
|
|
2342
|
-
// Re-digest now: `ref` was taken at spawn, before this node had events.
|
|
2343
|
-
const handoff = deriveHandoff({
|
|
2344
|
-
prev: branchContext.fullHandoff, depth: branchContext.depth, events: branchContext.events, self: await this.nodeRefFor(tip),
|
|
2345
|
-
config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity },
|
|
2346
|
-
});
|
|
2347
|
-
await closeNode(this.config.logDir, tip, boundHandoff(handoff), (await readNode(this.config.logDir, tip)).events.length, this.clock.now());
|
|
2348
|
-
const summary = text.trim() || "(the branch produced no closing message)";
|
|
2349
|
-
const receiptHistory = await readNodeHistory(this.config.logDir, tip, undefined, "context");
|
|
2350
|
-
if (!receiptHistory.ok) throw new Error(receiptHistory.error);
|
|
2351
|
-
const receipt = deriveBranchReceipt({ source: await this.nodeRefFor(tip), outcome, handoff, nodes: receiptHistory.nodes });
|
|
2352
|
-
const receiptRef = await putBlob(join(this.config.logDir, "artifacts"), JSON.stringify(receipt));
|
|
2353
|
-
const joined: LogEvent = {
|
|
2354
|
-
t: "joined", id: this.clock.nextId(), at: this.clock.now(),
|
|
2355
|
-
branch: ref, tip: await this.nodeRefFor(tip), handoff_ref: handoffPath(this.config.logDir, tip), summary,
|
|
2356
|
-
model: branch.provider.config.spec.id,
|
|
2357
|
-
outcome,
|
|
2358
|
-
receipt_ref: receiptRef,
|
|
2359
|
-
};
|
|
2360
|
-
return { tip, summary, receipt, receiptRef, joined };
|
|
2361
|
-
}
|
|
2362
|
-
|
|
2363
|
-
private async runWorktree(input: unknown, pathRoot?: string): Promise<ToolResult> {
|
|
2364
|
-
const args = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
|
|
2365
|
-
const root = this.config.tools.root;
|
|
2366
|
-
// A branch already runs in its own checkout and never owns the parent's root.
|
|
2367
|
-
if ((this.config.branchDepth ?? 0) > 0) return { content: "worktree is unavailable inside a branch; the branch already has its own checkout", ok: false };
|
|
2368
|
-
if (this.pendingRoot !== null) return { content: "one worktree switch per tool round; the previous one applies after this round", ok: false };
|
|
2369
|
-
const main = await mainWorktree(root);
|
|
2370
|
-
if (main === null) return { content: `${root} is not inside a git repository, so there is nothing to make a worktree of`, ok: false };
|
|
2371
|
-
const outside = (path: string) => pathRoot !== undefined && relative(pathRoot, path).startsWith("..");
|
|
2372
|
-
if (args["action"] === "exit") {
|
|
2373
|
-
if (main === root) return { content: `already in the main checkout ${main}; nothing to exit`, ok: false };
|
|
2374
|
-
if (outside(main)) return { content: `the main checkout ${main} is outside --path-root ${pathRoot}`, ok: false };
|
|
2375
|
-
const branch = (await listWorktrees(main)).find(wt => wt.path === root)?.branch ?? null;
|
|
2376
|
-
this.pendingRoot = main;
|
|
2377
|
-
return { ok: true, content: [`main ${main}`, `kept ${root}${branch ? ` on ${branch.replace(/^refs\/heads\//, "")}` : ""}`,
|
|
2378
|
-
"The session root moves to the main checkout after this tool round. Nothing was merged or deleted; " +
|
|
2379
|
-
`remove the worktree yourself with \`git worktree remove ${root}\` once its branch is merged or no longer needed.`].join("\n") };
|
|
2380
|
-
}
|
|
2381
|
-
if (args["action"] !== "enter") return { content: "worktree action must be enter or exit", ok: false };
|
|
2382
|
-
const name = typeof args["name"] === "string" ? args["name"] : "";
|
|
2383
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name) || name.includes("..")) {
|
|
2384
|
-
return { content: "worktree name must be 1-64 letters, digits, dot, dash or underscore, starting with a letter or digit", ok: false };
|
|
2385
|
-
}
|
|
2386
|
-
const parent = persistentWorktreeParent(configDir(), main);
|
|
2387
|
-
if (outside(parent)) return { content: `xeno's worktree directory ${parent} is outside --path-root ${pathRoot}`, ok: false };
|
|
2388
|
-
const created = await createWorktree(root, name, { location: { parent, leaf: name }, includeChanges: args["include_changes"] === true });
|
|
2389
|
-
if (!created.ok) return { content: `refused: ${created.error}`, ok: false };
|
|
2390
|
-
this.pendingRoot = created.path;
|
|
2391
|
-
return { ok: true, content: [`worktree ${created.path}`, `branch ${created.branch}`, `from ${created.snapshot.head}`,
|
|
2392
|
-
`The session root moves there after this tool round; relative paths, edits and commands then resolve in it. ` +
|
|
2393
|
-
`The working directory in the system prompt still names ${root}. ` +
|
|
2394
|
-
(args["include_changes"] === true ? "It starts from the current uncommitted files." : "It starts from HEAD; uncommitted files in the main checkout were not copied.")].join("\n"),
|
|
2395
|
-
data: { path: created.path, branch: created.branch, head: created.snapshot.head } };
|
|
2396
|
-
}
|
|
2397
|
-
|
|
2398
|
-
private async applyPendingRoot(): Promise<void> {
|
|
2399
|
-
const root = this.pendingRoot;
|
|
2400
|
-
this.pendingRoot = null;
|
|
2401
|
-
if (root === null) return;
|
|
2402
|
-
this.config.tools.root = root;
|
|
2403
|
-
this.installWorkspace();
|
|
2404
|
-
await this.saveCursor();
|
|
2405
|
-
}
|
|
2406
|
-
|
|
2407
|
-
private async runResolve(input: unknown): Promise<ToolResult> {
|
|
2408
|
-
const args = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
|
|
2409
|
-
const node = Number(args["node"]);
|
|
2410
|
-
const resolution = String(args["resolution"] ?? "");
|
|
2411
|
-
const why = typeof args["why"] === "string" ? args["why"].trim() : "";
|
|
2412
|
-
|
|
2413
|
-
if (!Number.isInteger(node)) return { content: "resolve needs a node number", ok: false };
|
|
2414
|
-
const history = await readNodeHistory(this.config.logDir, this.state.node);
|
|
2415
|
-
if (!history.ok) return { ok: false, content: history.error };
|
|
2416
|
-
const events = history.nodes.flatMap((n) => n.events);
|
|
2417
|
-
const spawned = events.some(
|
|
2418
|
-
(e) => (e.t === "spawn" || e.t === "joined") && e.branch.node === node,
|
|
2419
|
-
);
|
|
2420
|
-
if (!spawned) {
|
|
2421
|
-
return { content: `no branch ${node} was opened from this node`, ok: false };
|
|
2422
|
-
}
|
|
2423
|
-
if (events.some((e) => (e.t === "adopt" || e.t === "discard") && e.branch.node === node)) {
|
|
2424
|
-
return { ok: false, content: `branch ${node} is already resolved` };
|
|
2425
|
-
}
|
|
2426
|
-
|
|
2427
|
-
const joined = events.find((e) => e.t === "joined" && e.branch.node === node);
|
|
2428
|
-
if (resolution === "status") {
|
|
2429
|
-
const item = (await this.branchStatuses()).find(branch => branch.node === node)!;
|
|
2430
|
-
return { ok: true, content: `branch ${node}: ${item.status}`, data: { node, status: item.status } };
|
|
2431
|
-
}
|
|
2432
|
-
if (resolution === "wait" || resolution === "cancel") {
|
|
2433
|
-
if (joined) return { ok: true, content: `branch ${node} already joined; receipt ${joined.t === "joined" ? joined.receipt_ref ?? "not recorded" : ""}` };
|
|
2434
|
-
if (resolution === "cancel" && this.jobs.state(node) === undefined) return { ok: false, content: `branch ${node}: unknown after restart; no owned handle to cancel` };
|
|
2435
|
-
if (resolution === "cancel") { this.jobs.cancel(node); await this.jobs.drain(node); }
|
|
2436
|
-
const waitMs = args["wait_ms"] === undefined ? 10_000 : args["wait_ms"];
|
|
2437
|
-
if (typeof waitMs !== "number" || !Number.isFinite(waitMs) || waitMs < 0 || waitMs > 60_000) return { ok: false, content: "wait_ms must be between 0 and 60000" };
|
|
2438
|
-
const result = await this.jobs.wait(node, waitMs);
|
|
2439
|
-
if (result?.running) return { ok: true, content: `branch ${node} running; wait reached its deadline`, data: { node, running: true } };
|
|
2440
|
-
if (result?.error) return { ok: false, content: result.error };
|
|
2441
|
-
const complete = result?.value ?? await this.recoverBranchResult(node);
|
|
2442
|
-
if (!complete) return { ok: false, content: `branch ${node}: unknown after restart; no sealed execution outcome; inspect before retrying` };
|
|
2443
|
-
if (complete.accept) await complete.accept();
|
|
2444
|
-
this.jobs.forget(node);
|
|
2445
|
-
return complete;
|
|
2446
|
-
}
|
|
2447
|
-
if (this.jobs.state(node) === "running") return { ok: false, content: `branch ${node} running; wait or cancel before resolving it` };
|
|
2448
|
-
if (!joined && this.jobs.state(node) === "ready") return { ok: false, content: `branch ${node} has not joined; wait before resolving it` };
|
|
2449
|
-
|
|
2450
|
-
const ref = await this.nodeRefFor(node);
|
|
2451
|
-
if (resolution === "adopt") {
|
|
2452
|
-
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" };
|
|
2453
|
-
if (this.pendingAdoption !== null) return { content: "resolve one adoption per tool round", ok: false };
|
|
2454
|
-
if (!joined) return { content: `branch ${node} has not joined`, ok: false };
|
|
2455
|
-
await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: ref });
|
|
2456
|
-
this.pendingAdoption = node;
|
|
2457
|
-
return { content: `adopted branch ${node}; continuation moves after this tool round is paired`, ok: true };
|
|
2458
|
-
}
|
|
2459
|
-
if (resolution === "discard") {
|
|
2460
|
-
await this.record({
|
|
2461
|
-
t: "discard", id: this.clock.nextId(), at: this.clock.now(),
|
|
2462
|
-
branch: ref, reason: why || "no reason given",
|
|
2463
|
-
});
|
|
2464
|
-
// Discarding is the decision that its worktree can go. A dirty one is
|
|
2465
|
-
// reported rather than forced: those changes are what was discarded,
|
|
2466
|
-
// and silently deleting them is the destructive-restore mistake again.
|
|
2467
|
-
const wt = this.state.worktrees.get(node);
|
|
2468
|
-
if (wt) {
|
|
2469
|
-
const removed = await removeWorktree(this.config.tools.root, wt.path, wt.branch);
|
|
2470
|
-
this.state.worktrees.delete(node);
|
|
2471
|
-
if (!removed.ok) {
|
|
2472
|
-
return {
|
|
2473
|
-
content:
|
|
2474
|
-
`discarded branch ${node}, but its checkout is still at ${wt.path}: ${removed.error}`,
|
|
2475
|
-
ok: true,
|
|
2476
|
-
};
|
|
2477
|
-
}
|
|
2478
|
-
}
|
|
2479
|
-
return { content: `discarded branch ${node}`, ok: true };
|
|
2480
|
-
}
|
|
2481
|
-
return { content: `resolution must be wait, cancel, status, adopt or discard`, ok: false };
|
|
2482
|
-
}
|
|
2483
|
-
|
|
2484
|
-
private async recoverBranchResult(root: number): Promise<DeferredToolResult | undefined> {
|
|
2485
|
-
// Recover only a unique sealed continuation. An open or ambiguous branch
|
|
2486
|
-
// cannot be replayed safely because tools may already have had effects.
|
|
2487
|
-
const headers = await Promise.all((await listNodes(this.config.logDir)).map(async node => ({ node, header: (await readNode(this.config.logDir, node)).header })));
|
|
2488
|
-
let tip = root;
|
|
2489
|
-
const seen = new Set<number>();
|
|
2490
|
-
for (;;) {
|
|
2491
|
-
if (seen.has(tip)) return undefined;
|
|
2492
|
-
seen.add(tip);
|
|
2493
|
-
const children = headers.filter(item => item.header?.parent?.node === tip && item.header.basis !== "none");
|
|
2494
|
-
if (children.length > 1) return undefined;
|
|
2495
|
-
if (children.length === 0) break;
|
|
2496
|
-
tip = children[0]!.node;
|
|
2497
|
-
}
|
|
2498
|
-
if (!(await readManifest(this.config.logDir)).nodes.some(item => item.node === tip && item.handoff_ref)) return undefined;
|
|
2499
|
-
const history = await readNodeHistory(this.config.logDir, tip, undefined, "context");
|
|
2500
|
-
if (!history.ok) throw new Error(history.error);
|
|
2501
|
-
const outcomeEvent = history.nodes.flatMap(part => part.events).findLast(event => event.t === "outcome");
|
|
2502
|
-
if (outcomeEvent?.t !== "outcome") return undefined;
|
|
2503
|
-
const handoff = await replayContext(this.config.logDir, tip);
|
|
2504
|
-
const full = deriveHandoff({ prev: handoff.fullHandoff, events: handoff.events, depth: handoff.depth, self: await this.nodeRefFor(tip) });
|
|
2505
|
-
const receipt = deriveBranchReceipt({ source: await this.nodeRefFor(tip), outcome: outcomeEvent.outcome, handoff: full, nodes: history.nodes });
|
|
2506
|
-
const receiptRef = await putBlob(join(this.config.logDir, "artifacts"), JSON.stringify(receipt));
|
|
2507
|
-
const last = history.nodes.flatMap(part => part.events).findLast(event => event.t === "assistant");
|
|
2508
|
-
const summary = last?.t === "assistant" ? last.blocks.flatMap(block => block.type === "text" ? [block.text] : []).join("\n") : "(no closing message)";
|
|
2509
|
-
return { ok: outcomeEvent.outcome.status === "completed", outcome: outcomeEvent.outcome,
|
|
2510
|
-
content: `branch ${root} recovered from sealed node ${tip}; receipt ${receiptRef}; closing statement is unverified: ${summary}`,
|
|
2511
|
-
data: { node: root, outcome: outcomeEvent.outcome, receipt_ref: receiptRef, handoff_ref: handoffPath(this.config.logDir, tip) },
|
|
2512
|
-
accept: async () => {
|
|
2513
|
-
if ((await this.branchStatuses()).some(item => item.node === root && item.handoff_ref)) return [];
|
|
2514
|
-
await this.record({ t: "joined", id: this.clock.nextId(), at: this.clock.now(), branch: await this.nodeRefFor(root), tip: await this.nodeRefFor(tip),
|
|
2515
|
-
handoff_ref: handoffPath(this.config.logDir, tip), receipt_ref: receiptRef, summary, outcome: outcomeEvent.outcome });
|
|
2516
|
-
await this.saveCursor(); return [];
|
|
2517
|
-
} };
|
|
2518
|
-
}
|
|
2519
|
-
|
|
2520
|
-
/** D15: finish the parent's tool round before moving to the chosen branch. */
|
|
2521
|
-
private async adoptPendingBranch(): Promise<void> {
|
|
2522
|
-
this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
|
|
2523
|
-
const release = await this.rootTools.acquire("exclusive");
|
|
2524
|
-
let checkpoint = false;
|
|
2525
|
-
try {
|
|
2526
|
-
const root = this.pendingAdoption;
|
|
2527
|
-
if (root === null) return;
|
|
2528
|
-
const history = await readNodeHistory(this.config.logDir, this.state.node);
|
|
2529
|
-
if (!history.ok) throw new Error(history.error);
|
|
2530
|
-
const events = history.nodes.flatMap((n) => n.events);
|
|
2531
|
-
const joined = events.find((e) => e.t === "joined" && e.branch.node === root);
|
|
2532
|
-
const spawned = events.find((e) => e.t === "spawn" && e.branch.node === root);
|
|
2533
|
-
if (joined?.t !== "joined" || spawned?.t !== "spawn") throw new Error(`missing provenance for adoption ${root}`);
|
|
2534
|
-
const parent = this.state.node;
|
|
2535
|
-
const annotations = carryForward(events, spawned.id, parent);
|
|
2536
|
-
const tip = joined.tip ?? joined.branch;
|
|
2537
|
-
const intent = events.findLast((e) => e.t === "adopt" && e.branch.node === root && e.next_node !== undefined);
|
|
2538
|
-
const node = intent?.t === "adopt" && intent.next_node !== undefined ? intent.next_node : this.state.nextNode++;
|
|
2539
|
-
if (!intent) await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: joined.branch, next_node: node });
|
|
2540
|
-
const parentRef = await this.nodeRefFor(parent);
|
|
2541
|
-
const existing = await readNode(this.config.logDir, node);
|
|
2542
|
-
if (existing.header === null) await initNode(this.config.logDir, node, tip, this.clock.now(), "inherit", { adopted_from: parentRef });
|
|
2543
|
-
else if (existing.header.adopted_from?.node !== parent || existing.header.parent?.node !== tip.node) {
|
|
2544
|
-
throw new Error(`INTEGRITY: adoption successor ${node} belongs to another transition`);
|
|
2545
|
-
}
|
|
2546
|
-
const rebuilt = await replayContext(this.config.logDir, node);
|
|
2547
|
-
this.state.node = node;
|
|
2548
|
-
this.state.nextNode = Math.max(this.state.nextNode, node + 1);
|
|
2549
|
-
this.state.events = rebuilt.contextEvents;
|
|
2550
|
-
this.resetDerivedToolState();
|
|
2551
|
-
this.state.handoff = rebuilt.handoff;
|
|
2552
|
-
this.state.depth = rebuilt.depth;
|
|
2553
|
-
this.state.anchor = null;
|
|
2554
|
-
const copied = new Set(existing.events.flatMap((e) => "carried_from" in e && e.carried_from ? [e.carried_from.event] : []));
|
|
2555
|
-
for (const event of annotations) if (!copied.has(event.carried_from!.event)) await this.record({ ...event, id: this.clock.nextId() });
|
|
2556
|
-
if (!existing.events.some((e) => e.t === "adopt" && e.applied && e.branch.node === root)) {
|
|
2557
|
-
await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: joined.branch, applied: true });
|
|
2558
|
-
}
|
|
2559
|
-
const checkout = this.state.worktrees.get(root);
|
|
2560
|
-
if (checkout) this.config.tools.root = checkout.path;
|
|
2561
|
-
this.installWorkspace();
|
|
2562
|
-
await this.saveCursor();
|
|
2563
|
-
this.pendingAdoption = null;
|
|
2564
|
-
checkpoint = joined.model !== undefined && joined.model !== this.provider.config.spec.id;
|
|
2565
|
-
} finally { release(); }
|
|
2566
|
-
if (checkpoint) await this.performCut("model_checkpoint");
|
|
2567
|
-
}
|
|
2568
|
-
|
|
2569
|
-
private toEvent(a: Annotation): LogEvent {
|
|
2570
|
-
const stamp = { id: this.clock.nextId(), at: this.clock.now() };
|
|
2571
|
-
switch (a.t) {
|
|
2572
|
-
case "claim": {
|
|
2573
|
-
// A citation naming a configured gate is recorded as a gate query, so
|
|
2574
|
-
// it still resolves when the same suite is invoked differently later.
|
|
2575
|
-
// Everything else stays an exact-command query.
|
|
2576
|
-
const gate = a.proof ? matchGate(this.config.tools, a.proof) : undefined;
|
|
2577
|
-
return {
|
|
2578
|
-
t: "claim", ...stamp, what: a.what, ...(a.work_id ? { work_id: a.work_id } : {}),
|
|
2579
|
-
evidence: gate !== undefined ? { gate } : a.evidence,
|
|
2580
|
-
};
|
|
2581
|
-
}
|
|
2582
|
-
case "next_action":
|
|
2583
|
-
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 } : {}) };
|
|
2584
|
-
case "work_owner": return { ...a, ...stamp, owner: this.config.workNamespace ?? "main" };
|
|
2585
|
-
case "work_dependencies": return { ...a, ...stamp };
|
|
2586
|
-
case "decision":
|
|
2587
|
-
return {
|
|
2588
|
-
t: "decision", ...stamp, chose: a.chose, because: a.because,
|
|
2589
|
-
...(a.rejected ? { rejected: a.rejected } : {}),
|
|
2590
|
-
};
|
|
2591
|
-
case "pitfall":
|
|
2592
|
-
return { t: "pitfall", ...stamp, tried: a.tried, failed_because: a.failed_because };
|
|
2593
|
-
case "blocked":
|
|
2594
|
-
return { t: "blocked", ...stamp, what: a.what, reason: a.reason, ...(a.work_id ? { work_id: a.work_id } : {}) };
|
|
2595
|
-
case "work_status":
|
|
2596
|
-
case "evidence_link":
|
|
2597
|
-
return { ...a, ...stamp };
|
|
2598
|
-
case "question":
|
|
2599
|
-
return { t: "question", ...stamp, text: a.text };
|
|
2600
|
-
default:
|
|
2601
|
-
return assertNever(a, "toEvent");
|
|
2602
|
-
}
|
|
2603
|
-
}
|
|
2604
|
-
|
|
2605
|
-
// ---- rewind ---------------------------------------------------------------
|
|
2606
|
-
|
|
2607
|
-
/** A prepared edit can be reconciled by its exact path; unknown commands are never replayed. */
|
|
2608
|
-
private async recoverFileOperations(): Promise<void> {
|
|
2609
|
-
if (!this.config.blobDir) return;
|
|
2610
|
-
// A cut is only legal after paired tools, so pending operations belong to this node.
|
|
2611
|
-
const history = (await readNode(this.config.logDir, this.state.node)).events.filter((event): event is CheckpointEvent => event.t === "checkpoint");
|
|
2612
|
-
const pending = new Map<string, CheckpointEvent>();
|
|
2613
|
-
for (const event of history) {
|
|
2614
|
-
if (event.phase === "prepared") pending.set(event.operation, event);
|
|
2615
|
-
else pending.delete(event.operation);
|
|
2616
|
-
}
|
|
2617
|
-
for (const event of pending.values()) {
|
|
2618
|
-
let applied = event.source !== "git" && changedPaths(event.changes).length > 0;
|
|
2619
|
-
for (const [path, change] of Object.entries(event.changes)) {
|
|
2620
|
-
try { if (!sameVersion((await readEditableFile(this.config.tools.root, path)).version, change.after)) applied = false; }
|
|
2621
|
-
catch { applied = false; }
|
|
2622
|
-
}
|
|
2623
|
-
await this.record({ ...event, id: this.clock.nextId(), at: this.clock.now(), phase: applied ? "applied" : "unavailable",
|
|
2624
|
-
reason: applied ? "observed the prepared file version after restart; tool outcome remains unknown" : "interrupted checkpoint; inspect files before retrying" });
|
|
2625
|
-
}
|
|
2626
|
-
}
|
|
2627
|
-
|
|
2628
|
-
private async rewindJournal(point: RestorePoint, mode: RewindMode): Promise<RewindResult> {
|
|
2629
|
-
const dir = this.config.logDir, root = this.config.tools.root, blobs = this.config.blobDir;
|
|
2630
|
-
if (mode !== "context" && !blobs) return { ok: false, error: "this session stores no file checkpoints" };
|
|
2631
|
-
try {
|
|
2632
|
-
const target = { node: point.node, atEvent: point.atEvent };
|
|
2633
|
-
// Reading the log validates the target. Context-only navigation never reads workspace contents.
|
|
2634
|
-
const history = await readNodeHistory(dir, point.node);
|
|
2635
|
-
if (!history.ok) throw new Error(history.error);
|
|
2636
|
-
if (!history.nodes.find(part => part.node === point.node)?.events.some(event => event.id === point.atEvent)) throw new Error(`NO_SUCH_EVENT: ${point.atEvent}`);
|
|
2637
|
-
const changes: FileChanges = await planCheckpointRestore(dir, this.state.node, target).catch(error => {
|
|
2638
|
-
if (mode !== "context") throw error;
|
|
2639
|
-
return {};
|
|
2640
|
-
});
|
|
2641
|
-
const paths = changedPaths(changes);
|
|
2642
|
-
const historical = await readCursorAt(dir, point.node, point.atEvent);
|
|
2643
|
-
const from: NodeRef = { ...await digestedRef(dir, point.node), at_event: point.atEvent };
|
|
2644
|
-
const latest = (await readNode(dir, this.state.node)).events.at(-1);
|
|
2645
|
-
const filesFrom: NodeRef = { ...await digestedRef(dir, this.state.node), ...(latest ? { at_event: latest.id } : {}) };
|
|
2646
|
-
if (mode !== "context" && blobs) {
|
|
2647
|
-
await preflightChanges(root, blobs, changes);
|
|
2648
|
-
// Persist the intention before files move; recovery never invents command completion.
|
|
2649
|
-
const operation = this.clock.nextId();
|
|
2650
|
-
await this.record({ t: "checkpoint", id: operation, at: this.clock.now(), operation, source: "restore", phase: "prepared", changes });
|
|
2651
|
-
await applyFileChanges(root, blobs, changes);
|
|
2652
|
-
await this.record({ t: "checkpoint", id: this.clock.nextId(), at: this.clock.now(), operation, source: "restore", phase: "applied", changes });
|
|
2653
|
-
}
|
|
2654
|
-
const restored = mode === "context" ? [] : paths.filter(path => changes[path]!.after !== null);
|
|
2655
|
-
const removed = mode === "context" ? [] : paths.filter(path => changes[path]!.after === null);
|
|
2656
|
-
const event: LogEvent = { t: "rewind", id: this.clock.nextId(), at: this.clock.now(), mode, from, prompt: point.prompt,
|
|
2657
|
-
restored, removed, unrecoverable: [], divergence: mode === "both" ? [] : paths,
|
|
2658
|
-
...(mode === "context" ? { files_from: filesFrom } : {}) };
|
|
2659
|
-
if (mode === "files") await this.record(event);
|
|
2660
|
-
else {
|
|
2661
|
-
const node = this.state.nextNode++;
|
|
2662
|
-
await initNode(dir, node, from, this.clock.now(), "inherit");
|
|
2663
|
-
const replayed = await replayContext(dir, node);
|
|
2664
|
-
this.state.node = node; this.state.events = [...replayed.contextEvents]; this.state.handoff = replayed.handoff;
|
|
2665
|
-
this.state.depth = replayed.depth; this.state.anchor = null; this.resetDerivedToolState();
|
|
2666
|
-
await this.record(event);
|
|
2667
|
-
if (historical === null || !sameModel(historical, { model: this.provider.config.spec.id, provider: this.provider.config.spec.provider })) {
|
|
2668
|
-
const cursor = await readCursor(dir);
|
|
2669
|
-
if (cursor) await writeCursor(dir, { ...cursor, node, next_node: this.state.nextNode, needs_model_checkpoint: true });
|
|
2670
|
-
await this.performCut("model_checkpoint", this.provider.config.spec.id);
|
|
2671
|
-
}
|
|
2672
|
-
}
|
|
2673
|
-
await this.saveCursor();
|
|
2674
|
-
return { ok: true, node: this.state.node, restored, removed, unrecoverable: [], note: mode === "context"
|
|
2675
|
-
? "Only the conversation moved; workspace files were left unchanged."
|
|
2676
|
-
: "Restored recorded file changes. Unrelated files, ignored paths and external effects were left unchanged; command recovery requires captured Git checkpoints." };
|
|
2677
|
-
} catch (error) { return { ok: false, error: String(error) }; }
|
|
2678
|
-
}
|
|
2679
|
-
|
|
2680
|
-
/**
|
|
2681
|
-
* Every completed exchange, including abandoned branches and conversation-only turns.
|
|
2682
|
-
*
|
|
2683
|
-
* An exchange runs from one user message to just before the next, so its
|
|
2684
|
-
* last event is where a fork belongs: the conversation is whole there, and
|
|
2685
|
-
* the files are as the exchange left them.
|
|
2686
|
-
*
|
|
2687
|
-
* EVERY exchange, including ones that changed no file. Going back to
|
|
2688
|
-
* before a badly phrased instruction is the main use of a context-only
|
|
2689
|
-
* rewind, and such a turn often only talked -- filtering on "this exchange
|
|
2690
|
-
* wrote something" removes exactly those points. Claude Code lists every
|
|
2691
|
-
* prompt for the same reason and hides only the code options; whether
|
|
2692
|
-
* files would move is a question the preview answers, since what matters
|
|
2693
|
-
* is changes AFTER the point, not changes in it.
|
|
2694
|
-
*/
|
|
2695
|
-
async restorePoints(): Promise<RestorePoint[]> {
|
|
2696
|
-
const dir = this.config.logDir;
|
|
2697
|
-
const history = await readNodeHistory(dir, this.state.node);
|
|
2698
|
-
if (!history.ok) return [];
|
|
2699
|
-
// Per EVENT, not per node. A rewind usually forks inside the node it is
|
|
2700
|
-
// sitting in, so that node is on the path and the abandoned exchange is
|
|
2701
|
-
// in the same file, just past the fork point. Marking whole nodes would
|
|
2702
|
-
// hide exactly the exchange most worth getting back to.
|
|
2703
|
-
const onPath = new Set(history.nodes.flatMap((n) => n.events.map((e) => e.id)));
|
|
2704
|
-
|
|
2705
|
-
// Every node, read in full. A node the session rewound away from is
|
|
2706
|
-
// still on disk, and listing only the current path would leave it
|
|
2707
|
-
// undestroyed and unfindable at the same time.
|
|
2708
|
-
const all: { node: number; events: LogEvent[] }[] = [];
|
|
2709
|
-
for (const id of await listNodes(dir)) {
|
|
2710
|
-
const { header, events } = await readNode(dir, id);
|
|
2711
|
-
if (header === null) continue;
|
|
2712
|
-
all.push({ node: id, events });
|
|
2713
|
-
}
|
|
2714
|
-
|
|
2715
|
-
const points: RestorePoint[] = [];
|
|
2716
|
-
for (const n of all) {
|
|
2717
|
-
let open: { prompt: string; changed: number; last: string; userId: string } | null = null;
|
|
2718
|
-
const close = () => {
|
|
2719
|
-
if (open) {
|
|
2720
|
-
points.push({
|
|
2721
|
-
node: n.node, atEvent: open.last, prompt: open.prompt, userId: open.userId,
|
|
2722
|
-
changed: open.changed, onPath: onPath.has(open.last),
|
|
2723
|
-
});
|
|
2724
|
-
}
|
|
2725
|
-
};
|
|
2726
|
-
for (const e of n.events) {
|
|
2727
|
-
if (e.t === "workspace_observation") continue;
|
|
2728
|
-
if (e.t === "checkpoint" && e.source === "restore") { close(); open = null; continue; }
|
|
2729
|
-
if (e.t === "user") {
|
|
2730
|
-
close();
|
|
2731
|
-
open = { prompt: e.text, changed: 0, last: e.id, userId: e.id };
|
|
2732
|
-
continue;
|
|
2733
|
-
}
|
|
2734
|
-
if (!open) continue;
|
|
2735
|
-
open.last = e.id;
|
|
2736
|
-
if (e.t === "checkpoint" && e.phase === "applied") open.changed += changedPaths(e.changes).length;
|
|
2737
|
-
}
|
|
2738
|
-
close();
|
|
2739
|
-
}
|
|
2740
|
-
return points;
|
|
2741
|
-
}
|
|
2742
|
-
|
|
2743
|
-
/**
|
|
2744
|
-
* What a rewind would do, without doing it.
|
|
2745
|
-
*
|
|
2746
|
-
* P.3 previews only logged paths and preflights conflicts when files will move.
|
|
2747
|
-
* Context-only navigation consults history without reading workspace contents.
|
|
2748
|
-
*/
|
|
2749
|
-
async previewRewind(point: RestorePoint, mode: RewindMode = "both"): Promise<RewindPreview> {
|
|
2750
|
-
if (mode === "context") {
|
|
2751
|
-
const history = await readNodeHistory(this.config.logDir, point.node);
|
|
2752
|
-
if (!history.ok) return history;
|
|
2753
|
-
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}` };
|
|
2754
|
-
const changes = await planCheckpointRestore(this.config.logDir, this.state.node, { node: point.node, atEvent: point.atEvent }).catch((): FileChanges => ({}));
|
|
2755
|
-
return { ok: true, willWrite: changedPaths(changes).filter(path => changes[path]!.after !== null),
|
|
2756
|
-
willDelete: changedPaths(changes).filter(path => changes[path]!.after === null) };
|
|
2757
|
-
}
|
|
2758
|
-
try {
|
|
2759
|
-
const changes = await planCheckpointRestore(this.config.logDir, this.state.node, { node: point.node, atEvent: point.atEvent });
|
|
2760
|
-
if (this.config.blobDir) await preflightChanges(this.config.tools.root, this.config.blobDir, changes);
|
|
2761
|
-
return { ok: true, willWrite: changedPaths(changes).filter(path => changes[path]!.after !== null),
|
|
2762
|
-
willDelete: changedPaths(changes).filter(path => changes[path]!.after === null) };
|
|
2763
|
-
} catch (error) { return { ok: false, error: String(error) }; }
|
|
2764
|
-
}
|
|
2765
|
-
|
|
2766
|
-
/**
|
|
2767
|
-
* Goes back to a turn: the files and the conversation move together.
|
|
2768
|
-
*
|
|
2769
|
-
* FORKS, never truncates. claude-code#89646 is the argument for this: their
|
|
2770
|
-
* rewind drops the context and starts a new session, so rewinding too far
|
|
2771
|
-
* loses the work with no way back -- even though the transcript was
|
|
2772
|
-
* append-only on disk the whole time. Here the node rewound away from is
|
|
2773
|
-
* still a node, so it is still a restore target, and "rewound too far" is
|
|
2774
|
-
* recoverable rather than terminal.
|
|
2775
|
-
*
|
|
2776
|
-
* P.3 records a separate physical file cursor for context-only navigation.
|
|
2777
|
-
* In combined mode, file restoration must succeed before opening the new
|
|
2778
|
-
* conversation node. File-only restoration appends its observed transition.
|
|
2779
|
-
*/
|
|
2780
|
-
async rewind(point: RestorePoint, mode: RewindMode = "both"): Promise<RewindResult> {
|
|
2781
|
-
return this.rewindJournal(point, mode);
|
|
2782
|
-
}
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
private resetDerivedToolState(): void {
|
|
2786
|
-
this.stall = null;
|
|
2787
|
-
this.recovery = null;
|
|
2788
|
-
}
|
|
2789
|
-
|
|
2790
|
-
private async recoveryGuidance(tools: readonly string[]): Promise<ReturnType<typeof recoveryReview>> {
|
|
2791
|
-
if (this.recovery === null) {
|
|
2792
|
-
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
2793
|
-
if (!history.ok) throw new Error(history.error);
|
|
2794
|
-
this.recovery = advanceRecovery(emptyRecoveryState(), history.nodes.flatMap(node => node.events), path => resolve(this.config.tools.root, path));
|
|
2795
|
-
}
|
|
2796
|
-
return recoveryReview(this.recovery, tools);
|
|
2797
|
-
}
|
|
2798
|
-
|
|
2799
|
-
/**
|
|
2800
|
-
* S.8: a repeated cycle is pointed out to the model once; the next one is
|
|
2801
|
-
* the user's call when a question client can answer -- continue, stop, or
|
|
2802
|
-
* new instructions -- and ends the request only without one (headless) or
|
|
2803
|
-
* when the user stops it. The answer is recorded as an event, never kept
|
|
2804
|
-
* as loop state: a continue is a `stall` guidance, instructions are steering.
|
|
2805
|
-
*/
|
|
2806
|
-
private async *checkStall(request: string, signal?: AbortSignal): AsyncGenerator<LoopEvent, boolean> {
|
|
2807
|
-
const limit = this.config.stallLimit ?? DEFAULT_STALL_LIMIT;
|
|
2808
|
-
if (limit === 0) return false;
|
|
2809
|
-
if (this.stall === null) {
|
|
2810
|
-
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
2811
|
-
if (!history.ok) throw new Error(history.error);
|
|
2812
|
-
this.stall = advanceStall(emptyStallState(), history.nodes.flatMap(({ node, events }) => events.map((event) => ({ node, event }))));
|
|
2813
|
-
}
|
|
2814
|
-
const decision = stallDecision(this.stall, limit);
|
|
2815
|
-
if (decision.cycle === null) return false;
|
|
2816
|
-
const pattern = decision.cycle === 1 ? "the same tool call" : `a cycle of ${decision.cycle} tool calls`;
|
|
2817
|
-
const facts = `${pattern} repeated ${decision.repeats} times without new evidence (${decision.failures} failed receipts; ${decision.emptyWaits} empty waits)`;
|
|
2818
|
-
if (decision.warn) {
|
|
2819
|
-
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "stall", sources: [],
|
|
2820
|
-
text: `Harness: ${facts}. Change the method, run a meaningful check, or give the final result with unresolved limits. If the pattern repeats again, the user decides whether the request continues.` });
|
|
2821
|
-
return false;
|
|
2822
|
-
}
|
|
2823
|
-
const answer = await this.askAboutCycle(facts, signal);
|
|
2824
|
-
if (answer?.kind === "continue") {
|
|
2825
|
-
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "stall", sources: [],
|
|
2826
|
-
text: `Harness: the user reviewed this (${facts}) and chose to continue. Do not repeat the same calls expecting a different result; change the method or finish with the result and its limits.` });
|
|
2827
|
-
return false;
|
|
2828
|
-
}
|
|
2829
|
-
if (answer?.kind === "steer") {
|
|
2830
|
-
await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), request, text: answer.text });
|
|
2831
|
-
return false;
|
|
2832
|
-
}
|
|
2833
|
-
yield { kind: "stop_and_ask", reason: `${facts}; change the approach or provide new input` };
|
|
2834
|
-
return true;
|
|
2835
|
-
}
|
|
2836
|
-
|
|
2837
|
-
/** Undefined means nobody answered: no question client (headless), cancelled, or an invalid reply. */
|
|
2838
|
-
private async askAboutCycle(facts: string, signal?: AbortSignal): Promise<{ kind: "continue" } | { kind: "stop" } | { kind: "steer"; text: string } | undefined> {
|
|
2839
|
-
const slot = this.config.tools.questionSlot, adapter = slot?.current, turn = slot?.activeTurn;
|
|
2840
|
-
if (!adapter || !turn || signal?.aborted) return undefined;
|
|
2841
|
-
const request = questionRequest(turn, `stall-${this.clock.nextId()}`, [{ id: "stall", header: "Repeated tool calls",
|
|
2842
|
-
question: `The agent ${facts}. Continue, stop, or type instructions for it?`, multiple: false, custom: true,
|
|
2843
|
-
options: [{ id: "continue", label: "Continue", description: "Let the agent keep working; it is told to change its method." },
|
|
2844
|
-
{ id: "stop", label: "Stop", description: "End this request; the log and evidence stay." }] }]);
|
|
2845
|
-
try {
|
|
2846
|
-
const reply = validateQuestionReply(request, await adapter(structuredClone(request), signal ?? new AbortController().signal));
|
|
2847
|
-
const [answer] = reply.answers, text = answer?.text?.trim();
|
|
2848
|
-
if (text) return { kind: "steer", text };
|
|
2849
|
-
return answer?.choices[0] === "continue" ? { kind: "continue" } : answer?.choices[0] === "stop" ? { kind: "stop" } : undefined;
|
|
2850
|
-
} catch { return undefined; }
|
|
2851
|
-
}
|
|
2852
|
-
|
|
2853
|
-
/** A steering signal releases only the wait; cancellation owns process termination. */
|
|
2854
|
-
wakeProcessWaits(): void { this.processWake.abort(); }
|
|
2855
|
-
|
|
2856
|
-
private async terminationReport(cause: string, requests: number, limit = this.config.maxTurns): Promise<string> {
|
|
2857
|
-
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
2858
|
-
if (!history.ok) throw new Error(history.error);
|
|
2859
|
-
return stopReport(history.nodes.flatMap(part => part.events), requests, limit, cause);
|
|
2860
|
-
}
|
|
2861
|
-
|
|
2862
|
-
/** N.8: build and check the replacement before committing a durable boundary. */
|
|
2863
|
-
/**
|
|
2864
|
-
* `overflowed` is the provider's own verdict that the current projection
|
|
2865
|
-
* does not fit, which outranks any estimate: the cut is taken as a hard
|
|
2866
|
-
* limit, and retention is sized against the estimate scaled so the rejected
|
|
2867
|
-
* prompt reads as exactly over the usable window.
|
|
2868
|
-
*/
|
|
2869
|
-
private async *maybeWindowCut(counter: TokenCounter, willContinue: boolean, overflowed = false): AsyncGenerator<LoopEvent, boolean> {
|
|
2870
|
-
const budget = this.policyLimits();
|
|
2871
|
-
if (!willContinue || budget.kind !== "window" || pendingToolCalls(this.state.events).length > 0) return false;
|
|
2872
|
-
const project = () => projectContext({ frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events });
|
|
2873
|
-
let projected = project();
|
|
2874
|
-
const measured = anchoredCount(projected.messages, projected.system, this.state.anchor, counter, this.config.frozen.tools).tokens;
|
|
2875
|
-
const scale = overflowed ? Math.max(1, (budget.usable + 1) / Math.max(1, measured)) : 1;
|
|
2876
|
-
if (overflowed) counter = scaledCounter(counter, scale);
|
|
2877
|
-
let tokens = Math.ceil(measured * scale);
|
|
2878
|
-
if (tokens < budget.prepare) return false;
|
|
2879
|
-
const replayed = await replayContext(this.config.logDir, this.state.node);
|
|
2880
|
-
let draft: Handoff;
|
|
2881
|
-
try {
|
|
2882
|
-
draft = await this.boundCarried(deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events,
|
|
2883
|
-
depth: replayed.depth, self: await digestedRef(this.config.logDir, this.state.node),
|
|
2884
|
-
config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity } }));
|
|
2885
|
-
} catch (error) {
|
|
2886
|
-
if (!(error instanceof RangeError)) throw error;
|
|
2887
|
-
// An objective too large for a carried handoff cannot be repaired by another cut.
|
|
2888
|
-
if (tokens <= budget.usable) return false;
|
|
2889
|
-
yield { kind: "stop_and_ask", reason: `context cannot fit the model window: ${error instanceof Error ? error.message : "handoff construction failed"}` };
|
|
2890
|
-
return true;
|
|
2891
|
-
}
|
|
2892
|
-
const noteEnabled = this.config.frozen.tools.some((tool) => tool !== null && typeof tool === "object" && "name" in tool && tool.name === "note") &&
|
|
2893
|
-
(this.config.tools.allow === undefined || this.config.tools.allow.includes("note"));
|
|
2894
|
-
if (this.config.guidance !== false && noteEnabled && tokens < budget.trigger && budget.trigger > 1) {
|
|
2895
|
-
const notice = boundaryPreparation({ events: this.state.events, tokens, inheritedTokens: 0,
|
|
2896
|
-
continuationReady: draft.continuation_valid,
|
|
2897
|
-
config: { triggerTokens: budget.trigger, hardTokens: budget.trigger + (budget.trigger - budget.prepare), minNodeWorkTokens: 0 } });
|
|
2898
|
-
if (notice) {
|
|
2899
|
-
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...notice });
|
|
2900
|
-
projected = project();
|
|
2901
|
-
tokens = anchoredCount(projected.messages, projected.system, this.state.anchor, counter, this.config.frozen.tools).tokens;
|
|
2902
|
-
}
|
|
2903
|
-
}
|
|
2904
|
-
if (tokens < budget.trigger) return false;
|
|
2905
|
-
const lastAssistant = this.state.events.findLastIndex((event) => event.t === "assistant");
|
|
2906
|
-
const pendingUser = this.state.events.slice(lastAssistant + 1).find((event) => event.t === "user");
|
|
2907
|
-
const retained = selectRecentContext({ events: this.state.events, frozen: this.config.frozen, handoff: draft,
|
|
2908
|
-
counter, targetTokens: budget.target,
|
|
2909
|
-
...(pendingUser === undefined ? {} : { requiredUserId: pendingUser.id }) });
|
|
2910
|
-
if (retained === null || retained.tokens >= tokens) {
|
|
2911
|
-
// A large fresh prompt may still fit; let the model answer once. Never discard
|
|
2912
|
-
// it to manufacture space, or record repeated cuts that cannot shrink it.
|
|
2913
|
-
if (tokens <= budget.usable) return false;
|
|
2914
|
-
yield { kind: "stop_and_ask", reason: "context cannot fit the model window while preserving the current request and a complete recent exchange; reduce the input or use a larger verified window" };
|
|
2915
|
-
return true;
|
|
2916
|
-
}
|
|
2917
|
-
const node = this.state.node;
|
|
2918
|
-
const reason = tokens > budget.usable ? "hard_limit" : "trigger";
|
|
2919
|
-
await this.performCut(reason, this.provider.config.spec.id, retained.from);
|
|
2920
|
-
yield { kind: "cut", node, reason };
|
|
2921
|
-
return false;
|
|
2922
|
-
}
|
|
2923
|
-
|
|
2924
|
-
/** Every threshold the loop acts at. The footer and banner read the same function. */
|
|
2925
|
-
private policyLimits(): ContextLimits {
|
|
2926
|
-
return contextLimits({
|
|
2927
|
-
policy: this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
|
|
2928
|
-
contextWindow: this.provider.config.spec.contextWindow,
|
|
2929
|
-
outputReserve: this.provider.config.maxTokens,
|
|
2930
|
-
...(this.config.boundary ? { boundary: this.config.boundary } : {}),
|
|
2931
|
-
});
|
|
2932
|
-
}
|
|
2933
|
-
|
|
2934
|
-
private async *maybeCut(counter: TokenCounter, canPrepare: boolean): AsyncGenerator<LoopEvent, boolean> {
|
|
2935
|
-
const limits = this.policyLimits();
|
|
2936
|
-
if (limits.kind === "window") {
|
|
2937
|
-
return yield* this.maybeWindowCut(counter, canPrepare);
|
|
2938
|
-
}
|
|
2939
|
-
const projected = projectContext({
|
|
2940
|
-
frozen: this.config.frozen,
|
|
2941
|
-
handoff: this.state.handoff,
|
|
2942
|
-
events: this.state.events,
|
|
2943
|
-
});
|
|
2944
|
-
let { tokens } = anchoredCount(
|
|
2945
|
-
projected.messages, projected.system, this.state.anchor, counter,
|
|
2946
|
-
this.config.frozen.tools,
|
|
2947
|
-
);
|
|
2948
|
-
|
|
2949
|
-
// What this node would cost having done nothing: the frozen prefix plus
|
|
2950
|
-
// the rendered handoff. Cutting cannot reduce it, so it does not count
|
|
2951
|
-
// toward the node having earned a boundary.
|
|
2952
|
-
const floor = projectContext({
|
|
2953
|
-
frozen: this.config.frozen,
|
|
2954
|
-
handoff: this.state.handoff,
|
|
2955
|
-
events: [],
|
|
2956
|
-
});
|
|
2957
|
-
// Counted with the tools, because `tokens` above came from the anchor and
|
|
2958
|
-
// the provider bills schemas on every request. Subtracting a figure that
|
|
2959
|
-
// omitted them would read the difference as work the node had done.
|
|
2960
|
-
const inheritedTokens = counter.count(floor.messages, floor.system, this.config.frozen.tools);
|
|
2961
|
-
|
|
2962
|
-
const policy = this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY;
|
|
2963
|
-
let draft: Handoff | null = null;
|
|
2964
|
-
if (limits.kind === "segment" && tokens >= limits.prepare) {
|
|
2965
|
-
// Read the same full ancestry used by the actual cut. A bounded carried
|
|
2966
|
-
// page is insufficient to decide whether all active work is completed.
|
|
2967
|
-
const replayed = await replayContext(this.config.logDir, this.state.node);
|
|
2968
|
-
draft = await this.boundCarried(deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events,
|
|
2969
|
-
depth: replayed.depth, self: await digestedRef(this.config.logDir, this.state.node),
|
|
2970
|
-
config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity } }));
|
|
2971
|
-
const noteEnabled = this.config.frozen.tools.some((tool) => tool !== null && typeof tool === "object" && "name" in tool && tool.name === "note") &&
|
|
2972
|
-
(this.config.tools.allow === undefined || this.config.tools.allow.includes("note"));
|
|
2973
|
-
if (canPrepare && this.config.guidance !== false && noteEnabled && tokens < limits.hard) {
|
|
2974
|
-
const notice = boundaryPreparation({ events: this.state.events, tokens, inheritedTokens,
|
|
2975
|
-
continuationReady: draft.continuation_valid, config: limits.config });
|
|
2976
|
-
if (notice) {
|
|
2977
|
-
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...notice });
|
|
2978
|
-
const updated = projectContext({ frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events });
|
|
2979
|
-
tokens = anchoredCount(updated.messages, updated.system, this.state.anchor, counter, this.config.frozen.tools).tokens;
|
|
2980
|
-
}
|
|
2981
|
-
}
|
|
2982
|
-
}
|
|
2983
|
-
const price = this.provider.config.spec.price;
|
|
2984
|
-
const usage = this.state.usage.at(-1);
|
|
2985
|
-
const replacement = draft ? projectContext({ frozen: this.config.frozen, handoff: draft, events: [] }) : null;
|
|
2986
|
-
const cost = replacement && price?.cacheRead !== undefined && price.cacheWrite !== undefined && usage &&
|
|
2987
|
-
usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens > 0
|
|
2988
|
-
? estimateBoundaryCost({ retainedTokens: tokens,
|
|
2989
|
-
rebuiltTokens: counter.count(replacement.messages, replacement.system, this.config.frozen.tools),
|
|
2990
|
-
observedCachedTokens: usage.node === this.state.node ? usage.cache_read_input_tokens : 0,
|
|
2991
|
-
inputPrice: price.input, cacheReadPrice: price.cacheRead, cacheWritePrice: price.cacheWrite }) : null;
|
|
2992
|
-
const decision = decideContext({
|
|
2993
|
-
policy, continuationReady: draft?.continuation_valid ?? false, cost,
|
|
2994
|
-
tokens,
|
|
2995
|
-
inheritedTokens,
|
|
2996
|
-
atTurnBoundary: true,
|
|
2997
|
-
...(this.config.boundary ? { config: this.config.boundary } : {}),
|
|
2998
|
-
});
|
|
2999
|
-
|
|
3000
|
-
if (!decision.cut) {
|
|
3001
|
-
// A floor past the hard limit cannot be worked around by cutting; the
|
|
3002
|
-
// caller has to widen the thresholds or shrink the frozen prefix.
|
|
3003
|
-
if (decision.reason === "inherited_floor_exceeds_limit") {
|
|
3004
|
-
yield {
|
|
3005
|
-
kind: "stop_and_ask",
|
|
3006
|
-
reason:
|
|
3007
|
-
`the inherited context alone is ${inheritedTokens} tokens, at or past the hard ` +
|
|
3008
|
-
`limit -- widen the thresholds or shrink the system prompt`,
|
|
3009
|
-
};
|
|
3010
|
-
return true;
|
|
3011
|
-
}
|
|
3012
|
-
return false;
|
|
3013
|
-
}
|
|
3014
|
-
|
|
3015
|
-
const node = this.state.node;
|
|
3016
|
-
await this.performCut(decision.reason);
|
|
3017
|
-
yield { kind: "cut", node, reason: decision.reason };
|
|
3018
|
-
|
|
3019
|
-
// D9 uses observations and fresh successful checks, not model assertions.
|
|
3020
|
-
// The closed node's handoff is now this.state.handoff.
|
|
3021
|
-
const handoff = this.state.handoff;
|
|
3022
|
-
if (handoff !== null && shouldStopAndAsk(handoff.budget.nodes_without_progress)) {
|
|
3023
|
-
yield {
|
|
3024
|
-
kind: "stop_and_ask",
|
|
3025
|
-
reason:
|
|
3026
|
-
`${handoff.budget.nodes_without_progress} nodes with no verifiable progress. ` +
|
|
3027
|
-
`blocked: ${handoff.state.blocked.length}, open questions: ${handoff.open_questions.length}`,
|
|
3028
|
-
};
|
|
3029
|
-
return true;
|
|
3030
|
-
}
|
|
3031
|
-
return false;
|
|
3032
|
-
}
|
|
3033
|
-
|
|
3034
|
-
/**
|
|
3035
|
-
* Closes the current node and opens the next with a handoff basis.
|
|
3036
|
-
*
|
|
3037
|
-
* Shared by the automatic boundary and by switchModel. The caller decides
|
|
3038
|
-
* WHETHER to cut -- this only performs it. Putting the work floor in here
|
|
3039
|
-
* would make a model switch wait for minNodeWorkTokens, and the new model
|
|
3040
|
-
* would see the old thinking blocks in the meantime.
|
|
3041
|
-
*/
|
|
3042
|
-
private async performCut(reason: CutReason, nextModel = this.provider.config.spec.id, retainedFrom?: string,
|
|
3043
|
-
nextProvider = this.provider.config.spec.provider, nextThinking = this.provider.config.effort, nextContextRef?: string): Promise<void> {
|
|
3044
|
-
this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
|
|
3045
|
-
const release = await this.rootTools.acquire("exclusive");
|
|
3046
|
-
try {
|
|
3047
|
-
const dir = this.config.logDir;
|
|
3048
|
-
const node = this.state.node;
|
|
3049
|
-
|
|
3050
|
-
const replayed = await replayContext(dir, node);
|
|
3051
|
-
const fromDisk = replayed.events;
|
|
3052
|
-
const retained = retainedFrom === undefined ? [] : retainedContext(replayed.contextEvents, retainedFrom);
|
|
3053
|
-
let guidanceTools = this.adviceNames();
|
|
3054
|
-
if (nextContextRef !== undefined) {
|
|
3055
|
-
const saved = await getBlob(join(dir, "artifacts"), nextContextRef);
|
|
3056
|
-
if (saved === null) throw new Error("missing successor guidance context");
|
|
3057
|
-
guidanceTools = this.adviceNames(parseSessionContext(JSON.parse(saved)).frozen);
|
|
3058
|
-
}
|
|
3059
|
-
|
|
3060
|
-
// The cut event is part of the closed file. Digest after appending it
|
|
3061
|
-
// so predecessor.sha256 matches what closeNode writes into the manifest.
|
|
3062
|
-
await this.record({
|
|
3063
|
-
t: "cut",
|
|
3064
|
-
id: this.clock.nextId(),
|
|
3065
|
-
at: this.clock.now(),
|
|
3066
|
-
handoff_ref: handoffPath(dir, node),
|
|
3067
|
-
predecessor: this.state.handoff?.predecessor ?? null,
|
|
3068
|
-
reason,
|
|
3069
|
-
next_node: this.state.nextNode,
|
|
3070
|
-
next_model: nextModel,
|
|
3071
|
-
next_provider: nextProvider,
|
|
3072
|
-
next_thinking: nextThinking,
|
|
3073
|
-
...(nextContextRef === undefined ? {} : { next_context_ref: nextContextRef }),
|
|
3074
|
-
handoff_tokens: DEFAULT_HANDOFF_TOKENS,
|
|
3075
|
-
...(this.config.workingSet ? { working_set_version: CURRENT_WORKING_SET_VERSION } : {}),
|
|
3076
|
-
guidance_tools: guidanceTools,
|
|
3077
|
-
...(retainedFrom === undefined ? {} : { retained_from: retainedFrom }),
|
|
3078
|
-
});
|
|
3079
|
-
const self = await digestedRef(dir, node);
|
|
3080
|
-
const handoff = await this.boundCarried(deriveHandoff({
|
|
3081
|
-
prev: replayed.fullHandoff,
|
|
3082
|
-
depth: replayed.depth,
|
|
3083
|
-
events: fromDisk,
|
|
3084
|
-
self,
|
|
3085
|
-
config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity },
|
|
3086
|
-
}), guidanceTools);
|
|
3087
|
-
await closeNode(dir, node, handoff, (await readNode(dir, node)).events.length, this.clock.now());
|
|
3088
|
-
|
|
3089
|
-
// One allocator for the whole session. A cut used to take node + 1 while
|
|
3090
|
-
// a branch took nextNode++, so a cut after a branch reached the branch's
|
|
3091
|
-
// id and overwrote its log.
|
|
3092
|
-
this.state.node = this.state.nextNode++;
|
|
3093
|
-
this.state.depth += 1;
|
|
3094
|
-
this.state.handoff = handoff;
|
|
3095
|
-
this.state.events = retained;
|
|
3096
|
-
// The new node's prefix has never been sent, so there is nothing to anchor
|
|
3097
|
-
// to until its first response comes back.
|
|
3098
|
-
this.state.anchor = null;
|
|
3099
|
-
// A cut declares a handoff basis: the closed node's events do not replay,
|
|
3100
|
-
// its derived state does. Relying on the default here would silently make
|
|
3101
|
-
// every boundary a fork and replay the whole history.
|
|
3102
|
-
await initNode(dir, this.state.node, self, this.clock.now(), "handoff");
|
|
3103
|
-
// The durable successor already belongs to the target provider, even if
|
|
3104
|
-
// the process exits before switchModel installs its in-memory adapter.
|
|
3105
|
-
await this.saveCursor(nextModel, nextProvider, nextThinking, nextContextRef);
|
|
3106
|
-
} finally { release(); }
|
|
3107
|
-
}
|
|
3108
|
-
}
|