mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-04 05:51:03 +00:00
wip: checkpoint transcript-derived spine tree and web/TUI projection
This commit is contained in:
parent
82b009bc54
commit
1cbd845bb9
43 changed files with 2008 additions and 1170 deletions
5
.changeset/spine-status-parent-goal.md
Normal file
5
.changeset/spine-status-parent-goal.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Show the parent goal in the experimental Spine status line, and refine the task-tree prompt so status reports are not treated as task boundaries.
|
||||
5
.changeset/spine-transcript-derived-tree.md
Normal file
5
.changeset/spine-transcript-derived-tree.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Derive the experimental Spine task tree directly from the conversation transcript rather than a separate record, so undo, clear, and session resume keep the tree consistent with what the model saw.
|
||||
|
|
@ -3,9 +3,10 @@
|
|||
*
|
||||
* Pure reducer that mirrors the main agent's Spine task tree from the
|
||||
* transcript alone — no server/protocol changes: the TUI already sees every
|
||||
* top-level tool call and its result, and the Spine control tools answer
|
||||
* with a literal `accepted` receipt, so the panel can be driven entirely
|
||||
* app-side, through the same channel the legacy `TodoList` scrape uses.
|
||||
* top-level tool call and its result, and core's contract is accepted ⟺
|
||||
* non-error tool result (a rejected transition surfaces its reason as an
|
||||
* error), so the panel can be driven entirely app-side, through the same
|
||||
* channel the legacy `TodoList` scrape uses.
|
||||
*
|
||||
* The tree is rebuilt from accepted transitions in transcript order:
|
||||
* spine_open(summary) → push a child under the cursor; cursor = child
|
||||
|
|
@ -14,9 +15,10 @@
|
|||
* The panel renders the mirrored tree directly (closed → done, the open
|
||||
* cursor chain → in_progress, cursor flagged `active`), folding done
|
||||
* subtrees panel-side; see `projectSpineTree`. Rejected transitions (error
|
||||
* results, or stored results whose text is not the `accepted` receipt) never
|
||||
* touch the state; that also bounds drift to a single transition when core
|
||||
* drops a pending move (e.g. abort before the afterStep commit).
|
||||
* results live; stored results whose text lacks the `accepted` receipt
|
||||
* prefix on replay) never touch the state; that also bounds drift to a
|
||||
* single transition when core drops a pending move (e.g. abort before the
|
||||
* afterStep commit).
|
||||
*
|
||||
* Subagents route their sub-tool calls through `subagent.*` events rather
|
||||
* than the top-level tool result stream, so the projection only ever tracks
|
||||
|
|
@ -33,7 +35,10 @@ const SPINE_CONTROL_TOOL_NAMES: ReadonlySet<string> = new Set([
|
|||
'spine_next',
|
||||
]);
|
||||
|
||||
/** Literal output the spine control tools return when core accepts the intent. */
|
||||
/** Prefix of the receipt the spine control tools return when core accepts the
|
||||
* intent — core's ACCEPTED_OUTPUT is `accepted — commits after this step
|
||||
* completes`; the replay scan matches the prefix so suffix wording tweaks
|
||||
* don't break resume. */
|
||||
export const SPINE_ACCEPTED_RECEIPT = 'accepted';
|
||||
|
||||
export function isSpineControlToolName(name: string): name is SpineControlToolName {
|
||||
|
|
@ -69,8 +74,8 @@ function readSummary(args: Record<string, unknown>): string | null {
|
|||
|
||||
/**
|
||||
* Applies one accepted transition. Callers must only pass results core
|
||||
* accepted (`event.isError === false` live; the `accepted` receipt on replay)
|
||||
* — rejected or malformed calls leave the state untouched.
|
||||
* accepted (`event.isError === false` live; an `accepted`-prefixed receipt
|
||||
* on replay) — rejected or malformed calls leave the state untouched.
|
||||
*/
|
||||
export function applyAcceptedSpineTransition(
|
||||
state: SpineProjectionState,
|
||||
|
|
@ -216,7 +221,7 @@ export function scanSpineProjectionFromHistory(
|
|||
const call = pending.get(message.toolCallId);
|
||||
if (call === undefined) continue;
|
||||
pending.delete(message.toolCallId);
|
||||
if (textContent(message.content).trim() !== SPINE_ACCEPTED_RECEIPT) continue;
|
||||
if (!textContent(message.content).trim().startsWith(SPINE_ACCEPTED_RECEIPT)) continue;
|
||||
state = applyAcceptedSpineTransition(state, call.name, call.args);
|
||||
}
|
||||
return state;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
SPINE_ACCEPTED_RECEIPT,
|
||||
applyAcceptedSpineTransition,
|
||||
createSpineProjectionState,
|
||||
isSpineProjectionActive,
|
||||
|
|
@ -9,6 +8,10 @@ import {
|
|||
scanSpineProjectionFromHistory,
|
||||
} from '#/tui/utils/spine-projection';
|
||||
|
||||
/** Core's real accepted receipt (ACCEPTED_OUTPUT in agent-core-v2's
|
||||
* controlResult.ts) — the replay scan must match it. */
|
||||
const ACCEPTED_OUTPUT = 'accepted — commits after this step completes';
|
||||
|
||||
function assistant(callId: string, name: string, args: Record<string, unknown>) {
|
||||
return {
|
||||
role: 'assistant',
|
||||
|
|
@ -151,11 +154,11 @@ describe('spine projection history scan', () => {
|
|||
it('rebuilds the tree from accepted receipts in transcript order', () => {
|
||||
const history = [
|
||||
assistant('c1', 'spine_open', { summary: 'task A' }),
|
||||
tool('c1', SPINE_ACCEPTED_RECEIPT),
|
||||
tool('c1', ACCEPTED_OUTPUT),
|
||||
bash('c2'),
|
||||
tool('c2', 'hi\n'),
|
||||
assistant('c3', 'spine_next', { summary: 'task B', memory: 'A done' }),
|
||||
tool('c3', SPINE_ACCEPTED_RECEIPT),
|
||||
tool('c3', ACCEPTED_OUTPUT),
|
||||
];
|
||||
|
||||
const state = scanSpineProjectionFromHistory(history);
|
||||
|
|
@ -168,11 +171,11 @@ describe('spine projection history scan', () => {
|
|||
it('skips rejected transitions so the tree stays truthful', () => {
|
||||
const history = [
|
||||
assistant('c1', 'spine_open', { summary: 'task A' }),
|
||||
tool('c1', SPINE_ACCEPTED_RECEIPT),
|
||||
tool('c1', ACCEPTED_OUTPUT),
|
||||
assistant('c2', 'spine_close', { memory: 'root close gets rejected' }),
|
||||
tool('c2', 'Root-epoch nodes cannot be closed. Use open to start a child node under the current scope.'),
|
||||
assistant('c3', 'spine_next', { summary: 'task B', memory: 'moving on' }),
|
||||
tool('c3', SPINE_ACCEPTED_RECEIPT),
|
||||
tool('c3', ACCEPTED_OUTPUT),
|
||||
];
|
||||
|
||||
// The rejected close did not pop the cursor, so `task B` landed as a
|
||||
|
|
@ -187,11 +190,11 @@ describe('spine projection history scan', () => {
|
|||
it('rebuilds nested structure from the stored history', () => {
|
||||
const history = [
|
||||
assistant('c1', 'spine_open', { summary: 'parent' }),
|
||||
tool('c1', SPINE_ACCEPTED_RECEIPT),
|
||||
tool('c1', ACCEPTED_OUTPUT),
|
||||
assistant('c2', 'spine_open', { summary: 'child' }),
|
||||
tool('c2', SPINE_ACCEPTED_RECEIPT),
|
||||
tool('c2', ACCEPTED_OUTPUT),
|
||||
assistant('c3', 'spine_close', { memory: 'child done' }),
|
||||
tool('c3', SPINE_ACCEPTED_RECEIPT),
|
||||
tool('c3', ACCEPTED_OUTPUT),
|
||||
];
|
||||
|
||||
const state = scanSpineProjectionFromHistory(history);
|
||||
|
|
@ -208,9 +211,9 @@ describe('spine projection history scan', () => {
|
|||
it('applies a tool result only to the first matching call', () => {
|
||||
const history = [
|
||||
assistant('c1', 'spine_open', { summary: 'task A' }),
|
||||
tool('c1', SPINE_ACCEPTED_RECEIPT),
|
||||
tool('c1', SPINE_ACCEPTED_RECEIPT),
|
||||
tool('c9', SPINE_ACCEPTED_RECEIPT),
|
||||
tool('c1', ACCEPTED_OUTPUT),
|
||||
tool('c1', ACCEPTED_OUTPUT),
|
||||
tool('c9', ACCEPTED_OUTPUT),
|
||||
];
|
||||
|
||||
const state = scanSpineProjectionFromHistory(history);
|
||||
|
|
@ -249,7 +252,7 @@ describe('spine projection history scan', () => {
|
|||
const history = transitions.flatMap(([name, args], index) => {
|
||||
const callId = `c${String(index)}`;
|
||||
live = applyAcceptedSpineTransition(live, name, args);
|
||||
return [assistant(callId, name, args), tool(callId, SPINE_ACCEPTED_RECEIPT)];
|
||||
return [assistant(callId, name, args), tool(callId, ACCEPTED_OUTPUT)];
|
||||
});
|
||||
|
||||
expect(scanSpineProjectionFromHistory(history)).toEqual(live);
|
||||
|
|
|
|||
|
|
@ -717,6 +717,7 @@ function openPr(url: string): void {
|
|||
:git-info="client.gitInfo.value"
|
||||
:tasks="client.tasks.value"
|
||||
:todos="client.todos.value"
|
||||
:todo-tree="client.todoTree.value"
|
||||
:goal="client.goal.value"
|
||||
:activation-badges="client.activationBadges.value"
|
||||
:status="client.status.value"
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ interface SessionState {
|
|||
totalCacheRead: number;
|
||||
totalCacheCreate: number;
|
||||
contextTokens: number;
|
||||
rawContextTokens: number;
|
||||
contextLimit: number;
|
||||
turnCount: number;
|
||||
model: string;
|
||||
|
|
@ -141,6 +142,7 @@ function createSessionState(): SessionState {
|
|||
totalCacheRead: 0,
|
||||
totalCacheCreate: 0,
|
||||
contextTokens: 0,
|
||||
rawContextTokens: 0,
|
||||
contextLimit: 0,
|
||||
turnCount: 0,
|
||||
model: '',
|
||||
|
|
@ -471,6 +473,7 @@ function buildUsageSnapshot(state: SessionState): AppSessionUsage {
|
|||
cacheCreationTokens: state.totalCacheCreate,
|
||||
totalCostUsd: 0,
|
||||
contextTokens: state.contextTokens,
|
||||
rawContextTokens: state.rawContextTokens,
|
||||
contextLimit: state.contextLimit,
|
||||
turnCount: state.turnCount,
|
||||
};
|
||||
|
|
@ -929,6 +932,7 @@ export function createAgentProjector(): AgentProjector {
|
|||
case 'agent.status.updated': {
|
||||
if (p?.model) s.model = p.model;
|
||||
if (p?.contextTokens !== undefined) s.contextTokens = p.contextTokens;
|
||||
if (p?.rawContextTokens !== undefined) s.rawContextTokens = p.rawContextTokens;
|
||||
if (p?.maxContextTokens !== undefined) s.contextLimit = p.maxContextTokens;
|
||||
|
||||
out.push({
|
||||
|
|
|
|||
|
|
@ -459,6 +459,7 @@ export class DaemonKimiWebApi implements KimiWebApi {
|
|||
planMode: data.plan_mode === true,
|
||||
swarmMode: data.swarm_mode === true,
|
||||
contextTokens: data.context_tokens ?? 0,
|
||||
rawContextTokens: data.raw_context_tokens ?? 0,
|
||||
maxContextTokens: data.max_context_tokens ?? 0,
|
||||
contextUsage: data.context_usage ?? 0,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ export interface WireSessionRuntimeStatus {
|
|||
plan_mode: boolean;
|
||||
swarm_mode: boolean;
|
||||
context_tokens: number;
|
||||
raw_context_tokens?: number;
|
||||
max_context_tokens: number;
|
||||
context_usage: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,9 @@ export interface AppSessionUsage {
|
|||
cacheCreationTokens: number;
|
||||
totalCostUsd: number;
|
||||
contextTokens: number;
|
||||
/** Unfolded-request cost (>= contextTokens once a fold reclaimed context).
|
||||
* Absent until the daemon reports it (old daemons never do). */
|
||||
rawContextTokens?: number;
|
||||
contextLimit: number;
|
||||
turnCount: number;
|
||||
}
|
||||
|
|
@ -99,6 +102,8 @@ export interface AppSessionRuntimeStatus {
|
|||
planMode: boolean;
|
||||
swarmMode: boolean;
|
||||
contextTokens: number;
|
||||
/** Unfolded-request cost; 0 when the daemon predates the field. */
|
||||
rawContextTokens: number;
|
||||
maxContextTokens: number;
|
||||
contextUsage: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
<!-- pending question/approval cards, and the composer. Only rendered inside a -->
|
||||
<!-- chat-pane group so it never leaks into files/tasks/preview/btw panes. -->
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted, ref, watch } from 'vue';
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { ActivationBadges, ApprovalBlock, ConversationStatus, PermissionMode, QueuedPromptView, TaskItem, TodoView, UIQuestion } from '../../types';
|
||||
import type { ActivationBadges, ApprovalBlock, ConversationStatus, PermissionMode, QueuedPromptView, TaskItem, TodoTreeNode, TodoView, UIQuestion } from '../../types';
|
||||
import type { AppGoal, AppModel, AppSkill, QuestionResponse, ThinkingLevel } from '../../api/types';
|
||||
import type { FileItem } from './MentionMenu.vue';
|
||||
import Composer from './Composer.vue';
|
||||
|
|
@ -46,6 +46,9 @@ const props = defineProps<{
|
|||
todoDoneCount: number;
|
||||
hasDockWork: boolean;
|
||||
todos?: TodoView[];
|
||||
/** Spine task tree mirrored from the transcript — when non-empty it
|
||||
replaces the flat todo list in the 'todos' dock panel. */
|
||||
todoTree?: TodoTreeNode[];
|
||||
pendingQuestion?: UIQuestion;
|
||||
/** Action kind in flight for the visible question (drives loading state). */
|
||||
questionBusyKind?: 'answer' | 'dismiss';
|
||||
|
|
@ -84,6 +87,24 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
/** True when the session drives its task list via spine — the tree then
|
||||
replaces the flat todo list in the dock. */
|
||||
const spineActive = computed(() => (props.todoTree?.length ?? 0) > 0);
|
||||
const treeStats = computed(() => {
|
||||
let done = 0;
|
||||
let total = 0;
|
||||
const visit = (nodes: TodoTreeNode[]): void => {
|
||||
for (const node of nodes) {
|
||||
total += 1;
|
||||
if (node.status === 'done') done += 1;
|
||||
visit(node.children);
|
||||
}
|
||||
};
|
||||
visit(props.todoTree ?? []);
|
||||
return { done, total };
|
||||
});
|
||||
|
||||
const composerRef = ref<{
|
||||
loadForEdit: (value: string) => boolean;
|
||||
loadAttachmentsForEdit: (atts: { fileId?: string; kind: 'image' | 'video'; url: string; name?: string }[]) => void;
|
||||
|
|
@ -163,7 +184,8 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus });
|
|||
v-else-if="dockPanel === 'todos'"
|
||||
class="dock-work-tab static"
|
||||
>
|
||||
{{ t('tasks.dockTodos') }} · {{ todoDoneCount }}/{{ todos?.length ?? 0 }}
|
||||
<template v-if="spineActive">{{ t('tasks.dockSpine') }} · {{ treeStats.done }}/{{ treeStats.total }}</template>
|
||||
<template v-else>{{ t('tasks.dockTodos') }} · {{ todoDoneCount }}/{{ todos?.length ?? 0 }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="dock-work-body">
|
||||
|
|
@ -181,6 +203,7 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus });
|
|||
<TodoCard
|
||||
v-else-if="dockPanel === 'todos'"
|
||||
:todos="todos ?? []"
|
||||
:tree="todoTree"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -214,14 +237,14 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus });
|
|||
<span class="dw-count">(<b>{{ subagentTasks.length }}</b>)</span>
|
||||
</Pill>
|
||||
<Pill
|
||||
v-if="(todos?.length ?? 0) > 0"
|
||||
v-if="(todos?.length ?? 0) > 0 || spineActive"
|
||||
:active="dockPanel === 'todos'"
|
||||
:aria-pressed="dockPanel === 'todos'"
|
||||
@click="emit('toggle-dock-panel', 'todos')"
|
||||
>
|
||||
<Icon name="check-list" size="md" />
|
||||
<span>{{ t('tasks.dockTodos') }}</span>
|
||||
<span class="dw-count">(<b>{{ todoDoneCount }}/{{ todos?.length ?? 0 }}</b>)</span>
|
||||
<span>{{ spineActive ? t('tasks.dockSpine') : t('tasks.dockTodos') }}</span>
|
||||
<span class="dw-count">(<b>{{ spineActive ? `${treeStats.done}/${treeStats.total}` : `${todoDoneCount}/${todos?.length ?? 0}` }}</b>)</span>
|
||||
</Pill>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -597,9 +597,22 @@ const pct = computed(() => {
|
|||
return Math.min(100, Math.max(0, Math.round(((props.status?.ctxUsed ?? 0) / max) * 100)));
|
||||
});
|
||||
|
||||
const rawPct = computed(() => {
|
||||
const max = props.status?.ctxMax ?? 0;
|
||||
if (max <= 0) return 0;
|
||||
return Math.min(100, Math.max(0, Math.round(((props.status?.ctxRaw ?? 0) / max) * 100)));
|
||||
});
|
||||
// The raw (unfolded) reading only diverges from the projected one once a fold
|
||||
// actually reclaimed context — before that the ring and count stay single-value.
|
||||
const hasRaw = computed(() => (props.status?.ctxRaw ?? 0) > (props.status?.ctxUsed ?? 0));
|
||||
|
||||
const ctxTooltip = computed(() => {
|
||||
const used = (props.status?.ctxUsed ?? 0).toLocaleString();
|
||||
const max = (props.status?.ctxMax ?? 0).toLocaleString();
|
||||
if (hasRaw.value) {
|
||||
const raw = (props.status?.ctxRaw ?? 0).toLocaleString();
|
||||
return t('status.ctxTooltipRaw', { used, raw, max, pct: pct.value });
|
||||
}
|
||||
return t('status.ctxTooltip', { used, max, pct: pct.value });
|
||||
});
|
||||
|
||||
|
|
@ -1092,8 +1105,8 @@ function selectModel(modelId: string): void {
|
|||
tabindex="0"
|
||||
:aria-label="ctxTooltip"
|
||||
>
|
||||
<ContextRing :pct="pct" />
|
||||
<span class="ctx-num">{{ kFmt(status.ctxUsed) }}/{{ kFmt(status.ctxMax) }}</span>
|
||||
<ContextRing :pct="pct" :raw-pct="hasRaw ? rawPct : undefined" />
|
||||
<span class="ctx-num">{{ kFmt(status.ctxUsed) }}/<template v-if="hasRaw">{{ kFmt(status.ctxRaw) }}/</template>{{ kFmt(status.ctxMax) }}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch, type ComponentPublicInstance } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { ActivationBadges, ApprovalBlock, ChatTurn, ConversationStatus, FilePreviewRequest, PermissionMode, QueuedPromptView, TaskItem, TodoView, ToolMedia, UIQuestion, WorkspaceView } from '../../types';
|
||||
import type { ActivationBadges, ApprovalBlock, ChatTurn, ConversationStatus, FilePreviewRequest, PermissionMode, QueuedPromptView, TaskItem, TodoTreeNode, TodoView, ToolMedia, UIQuestion, WorkspaceView } from '../../types';
|
||||
import type { AppGoal, AppModel, AppSkill, QuestionResponse, ThinkingLevel } from '../../api/types';
|
||||
import type { FileItem } from './MentionMenu.vue';
|
||||
import ChatPane from './ChatPane.vue';
|
||||
|
|
@ -26,6 +26,9 @@ const props = defineProps<{
|
|||
tasks: TaskItem[];
|
||||
/** Model-maintained todo list (TodoList tool) — shown as a floating card. */
|
||||
todos?: TodoView[];
|
||||
/** Spine task tree mirrored from the transcript — when non-empty it
|
||||
replaces the flat todo list in the dock (spine sessions never emit TodoList). */
|
||||
todoTree?: TodoTreeNode[];
|
||||
goal?: AppGoal | null;
|
||||
activationBadges?: ActivationBadges;
|
||||
status: ConversationStatus;
|
||||
|
|
@ -247,6 +250,7 @@ const hasDockWork = computed(() =>
|
|||
bashTasks.value.length > 0 ||
|
||||
subagentTasks.value.length > 0 ||
|
||||
(props.todos?.length ?? 0) > 0 ||
|
||||
(props.todoTree?.length ?? 0) > 0 ||
|
||||
(props.queued?.length ?? 0) > 0,
|
||||
);
|
||||
const dockPanel = ref<'bash' | 'subagent' | 'todos' | null>(null);
|
||||
|
|
@ -1450,6 +1454,7 @@ defineExpose({ loadComposerForEdit, focusComposer });
|
|||
:todo-done-count="todoDoneCount"
|
||||
:has-dock-work="hasDockWork"
|
||||
:todos="todos"
|
||||
:todo-tree="todoTree"
|
||||
:pending-question="pendingQuestion"
|
||||
:question-busy-kind="questionBusyKind"
|
||||
:pending-approval="pendingApproval"
|
||||
|
|
|
|||
|
|
@ -5,16 +5,39 @@
|
|||
Rows share StatusGlyph with the background bash/subagent task list so the
|
||||
two stay visually identical. -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { TodoView } from '../../types';
|
||||
import type { TodoTreeNode, TodoView } from '../../types';
|
||||
import StatusGlyph, { type StatusGlyphStatus } from './StatusGlyph.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
todos: TodoView[];
|
||||
/** Spine task tree mirrored from the transcript — when non-empty it
|
||||
replaces the flat todo list (spine sessions never emit TodoList). */
|
||||
tree?: TodoTreeNode[];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
interface TreeRow {
|
||||
node: TodoTreeNode;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/** Depth-first flattening of the spine tree: every node renders (done
|
||||
subtrees included) — the dock panel scrolls, so no folding like the TUI. */
|
||||
const treeRows = computed<TreeRow[]>(() => {
|
||||
const rows: TreeRow[] = [];
|
||||
const visit = (nodes: TodoTreeNode[], depth: number): void => {
|
||||
for (const node of nodes) {
|
||||
rows.push({ node, depth });
|
||||
visit(node.children, depth + 1);
|
||||
}
|
||||
};
|
||||
visit(props.tree ?? [], 0);
|
||||
return rows;
|
||||
});
|
||||
|
||||
function glyphStatus(status: TodoView['status']): StatusGlyphStatus {
|
||||
return status === 'in_progress' ? 'run' : status;
|
||||
}
|
||||
|
|
@ -22,7 +45,7 @@ function glyphStatus(status: TodoView['status']): StatusGlyphStatus {
|
|||
|
||||
<template>
|
||||
<div class="todo-card">
|
||||
<div v-if="props.todos.length === 0" class="tc-empty">
|
||||
<div v-if="treeRows.length === 0 && props.todos.length === 0" class="tc-empty">
|
||||
<svg class="tc-empty-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9 11l2 2 4-4" />
|
||||
<rect x="4" y="4" width="16" height="16" rx="3" />
|
||||
|
|
@ -30,10 +53,29 @@ function glyphStatus(status: TodoView['status']): StatusGlyphStatus {
|
|||
<span>{{ t('tasks.emptyTodo') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-for="(td, i) in props.todos" :key="i" class="tc-row" :class="`s-${td.status}`">
|
||||
<StatusGlyph :status="glyphStatus(td.status)" />
|
||||
<span class="tc-name">{{ td.title }}</span>
|
||||
</div>
|
||||
<template v-if="treeRows.length > 0">
|
||||
<div
|
||||
v-for="(row, i) in treeRows"
|
||||
:key="i"
|
||||
class="tc-row"
|
||||
:class="[`s-${row.node.status}`, { 's-active': row.node.active }]"
|
||||
>
|
||||
<span
|
||||
v-if="row.depth > 0"
|
||||
class="tc-indent"
|
||||
:style="{ width: `${row.depth * 14}px` }"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<StatusGlyph :status="glyphStatus(row.node.status)" />
|
||||
<span class="tc-name">{{ row.node.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-for="(td, i) in props.todos" :key="i" class="tc-row" :class="`s-${td.status}`">
|
||||
<StatusGlyph :status="glyphStatus(td.status)" />
|
||||
<span class="tc-name">{{ td.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -58,6 +100,8 @@ function glyphStatus(status: TodoView['status']): StatusGlyphStatus {
|
|||
color: var(--color-text-faint);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.tc-row.s-active .tc-name { color: var(--color-accent); }
|
||||
.tc-indent { flex: none; }
|
||||
|
||||
.tc-empty {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@
|
|||
visualization (not a line icon), so it lives here rather than in the icon
|
||||
registry. The arc length is derived from `pct`. -->
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ pct: number }>();
|
||||
// `rawPct` is the unfolded-request share of the window; it only diverges from
|
||||
// `pct` once a fold reclaimed context, so the second arc stays hidden before
|
||||
// that (raw >= projected by construction, the projected arc paints over it).
|
||||
const props = defineProps<{ pct: number; rawPct?: number }>();
|
||||
|
||||
const R = 7;
|
||||
const circumference = 2 * Math.PI * R;
|
||||
|
|
@ -12,6 +15,18 @@ const circumference = 2 * Math.PI * R;
|
|||
<template>
|
||||
<svg class="ctx-ring" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<circle class="ctx-ring-track" cx="10" cy="10" :r="R" fill="none" stroke-width="2.5" />
|
||||
<circle
|
||||
v-if="props.rawPct !== undefined"
|
||||
class="ctx-ring-raw"
|
||||
cx="10"
|
||||
cy="10"
|
||||
:r="R"
|
||||
fill="none"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
:stroke-dasharray="`${circumference}`"
|
||||
:stroke-dashoffset="`${circumference * (1 - props.rawPct / 100)}`"
|
||||
/>
|
||||
<circle
|
||||
class="ctx-ring-fill"
|
||||
cx="10"
|
||||
|
|
@ -36,6 +51,10 @@ const circumference = 2 * Math.PI * R;
|
|||
.ctx-ring-track {
|
||||
stroke: var(--line);
|
||||
}
|
||||
.ctx-ring-raw {
|
||||
stroke: var(--color-warning);
|
||||
transition: stroke-dashoffset 0.3s ease;
|
||||
}
|
||||
.ctx-ring-fill {
|
||||
stroke: var(--color-accent);
|
||||
transition: stroke-dashoffset 0.3s ease, stroke 0.3s ease;
|
||||
|
|
|
|||
161
apps/kimi-web/src/composables/spineTree.test.ts
Normal file
161
apps/kimi-web/src/composables/spineTree.test.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { AppMessage, AppMessageContent, AppMessageRole } from '../api/types';
|
||||
import { spineTreeFromMessages } from './spineTree';
|
||||
|
||||
let seq = 0;
|
||||
function msg(role: AppMessageRole, content: AppMessageContent[]): AppMessage {
|
||||
seq += 1;
|
||||
return { id: `m${seq}`, sessionId: 's1', role, content, createdAt: '2026-01-01T00:00:00Z' };
|
||||
}
|
||||
|
||||
function call(id: string, toolName: string, input: unknown): AppMessage {
|
||||
return msg('assistant', [{ type: 'toolUse', toolCallId: id, toolName, input }]);
|
||||
}
|
||||
|
||||
function result(id: string, output: unknown, isError?: boolean): AppMessage {
|
||||
return msg('tool', [{ type: 'toolResult', toolCallId: id, output, isError }]);
|
||||
}
|
||||
|
||||
/** Core's real accepted receipt (ACCEPTED_OUTPUT in agent-core-v2's
|
||||
* controlResult.ts). */
|
||||
const ACCEPTED_RECEIPT = 'accepted — commits after this step completes';
|
||||
const accepted = (id: string): AppMessage => result(id, ACCEPTED_RECEIPT);
|
||||
|
||||
describe('spineTreeFromMessages', () => {
|
||||
it('returns [] for a transcript without spine calls', () => {
|
||||
expect(spineTreeFromMessages([])).toEqual([]);
|
||||
expect(
|
||||
spineTreeFromMessages([
|
||||
call('t1', 'TodoList', { todos: [{ title: 'x', status: 'pending' }] }),
|
||||
result('t1', 'ok'),
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('opens a root node flagged active', () => {
|
||||
const tree = spineTreeFromMessages([call('t1', 'spine_open', { summary: 'task A' }), accepted('t1')]);
|
||||
expect(tree).toEqual([{ title: 'task A', status: 'in_progress', active: true, children: [] }]);
|
||||
});
|
||||
|
||||
it('parses string tool input', () => {
|
||||
const tree = spineTreeFromMessages([
|
||||
call('t1', 'spine_open', JSON.stringify({ summary: 'task A' })),
|
||||
accepted('t1'),
|
||||
]);
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0]!.title).toBe('task A');
|
||||
});
|
||||
|
||||
it('nests children under the cursor and closes via spine_close', () => {
|
||||
const tree = spineTreeFromMessages([
|
||||
call('t1', 'spine_open', { summary: 'A' }),
|
||||
accepted('t1'),
|
||||
call('t2', 'spine_open', { summary: 'B' }),
|
||||
accepted('t2'),
|
||||
call('t3', 'spine_close', { memory: 'done B' }),
|
||||
accepted('t3'),
|
||||
]);
|
||||
expect(tree).toEqual([
|
||||
{
|
||||
title: 'A',
|
||||
status: 'in_progress',
|
||||
active: true,
|
||||
children: [{ title: 'B', status: 'done', children: [] }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('spine_next closes the cursor and opens a sibling', () => {
|
||||
const tree = spineTreeFromMessages([
|
||||
call('t1', 'spine_open', { summary: 'A' }),
|
||||
accepted('t1'),
|
||||
call('t2', 'spine_open', { summary: 'B' }),
|
||||
accepted('t2'),
|
||||
call('t3', 'spine_next', { summary: 'C', memory: 'm' }),
|
||||
accepted('t3'),
|
||||
]);
|
||||
expect(tree).toEqual([
|
||||
{
|
||||
title: 'A',
|
||||
status: 'in_progress',
|
||||
children: [
|
||||
{ title: 'B', status: 'done', children: [] },
|
||||
{ title: 'C', status: 'in_progress', active: true, children: [] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the all-done tree after the last close (root epoch)', () => {
|
||||
const tree = spineTreeFromMessages([
|
||||
call('t1', 'spine_open', { summary: 'A' }),
|
||||
accepted('t1'),
|
||||
call('t2', 'spine_close', { memory: 'm' }),
|
||||
accepted('t2'),
|
||||
]);
|
||||
expect(tree).toEqual([{ title: 'A', status: 'done', children: [] }]);
|
||||
});
|
||||
|
||||
it('keeps closed history across epochs', () => {
|
||||
const tree = spineTreeFromMessages([
|
||||
call('t1', 'spine_open', { summary: 'A' }),
|
||||
accepted('t1'),
|
||||
call('t2', 'spine_close', { memory: 'm' }),
|
||||
accepted('t2'),
|
||||
call('t3', 'spine_open', { summary: 'B' }),
|
||||
accepted('t3'),
|
||||
]);
|
||||
expect(tree).toEqual([
|
||||
{ title: 'A', status: 'done', children: [] },
|
||||
{ title: 'B', status: 'in_progress', active: true, children: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores rejected transitions (error results)', () => {
|
||||
expect(
|
||||
spineTreeFromMessages([
|
||||
call('t1', 'spine_open', { summary: 'A' }),
|
||||
result('t1', 'spine_open failed: summary required', true),
|
||||
]),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
spineTreeFromMessages([
|
||||
call('t1', 'spine_open', { summary: 'A' }),
|
||||
result('t1', 'rejected: another transition pending', true),
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('applies non-error results regardless of output shape', () => {
|
||||
const tree = spineTreeFromMessages([
|
||||
call('t1', 'spine_open', { summary: 'A' }),
|
||||
result('t1', [{ type: 'text', text: ACCEPTED_RECEIPT }]),
|
||||
]);
|
||||
expect(tree).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ignores malformed calls: missing summary, next/close without a cursor', () => {
|
||||
expect(
|
||||
spineTreeFromMessages([
|
||||
call('t1', 'spine_next', { summary: 'A', memory: 'm' }),
|
||||
accepted('t1'),
|
||||
call('t2', 'spine_close', { memory: 'm' }),
|
||||
accepted('t2'),
|
||||
call('t3', 'spine_open', {}),
|
||||
accepted('t3'),
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores results for unknown or already-settled call ids', () => {
|
||||
const tree = spineTreeFromMessages([
|
||||
result('nope', ACCEPTED_RECEIPT),
|
||||
call('t1', 'spine_open', { summary: 'A' }),
|
||||
accepted('t1'),
|
||||
accepted('t1'), // duplicate — the pending entry was already consumed
|
||||
call('t2', 'Read', { path: 'x' }),
|
||||
result('t2', ACCEPTED_RECEIPT),
|
||||
]);
|
||||
expect(tree).toEqual([{ title: 'A', status: 'in_progress', active: true, children: [] }]);
|
||||
});
|
||||
});
|
||||
154
apps/kimi-web/src/composables/spineTree.ts
Normal file
154
apps/kimi-web/src/composables/spineTree.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
// apps/kimi-web/src/composables/spineTree.ts
|
||||
// Derives the CURRENT spine task tree from a session transcript. The agent
|
||||
// drives its Spine task tree via the spine_open / spine_close / spine_next
|
||||
// control tools, and core's contract is accepted ⟺ non-error tool result
|
||||
// (a rejected transition surfaces its reason as an error), so the tree can
|
||||
// be rebuilt app-side from the transcript alone — the same scrape channel
|
||||
// the TodoList projection (latestTodos) uses, and the same isError-only
|
||||
// check the TUI's live path applies.
|
||||
// Replaying the accepted transitions in transcript order mirrors the tree:
|
||||
// spine_open(summary) → push a child under the cursor; cursor = child
|
||||
// spine_close(memory) → close the cursor node; pop
|
||||
// spine_next(summary, memory) → close the cursor node; open a sibling
|
||||
// Closed nodes render as done, the open cursor chain as in_progress, and the
|
||||
// cursor node is flagged `active`. The tree persists after the last close:
|
||||
// back at the root epoch it stays visible as all-done history, so only a
|
||||
// transcript without any spine activity yields [] (the dock then falls back
|
||||
// to the flat todo list). Rejected transitions (error results) never touch
|
||||
// the tree. Differs from the TUI's spine-projection.ts, which returns [] at
|
||||
// the root epoch.
|
||||
|
||||
import type { AppMessage } from '../api/types';
|
||||
import type { TodoTreeNode } from '../types';
|
||||
import { normalizeToolName } from '../lib/toolMeta';
|
||||
|
||||
type SpineControlToolName = 'spine_open' | 'spine_close' | 'spine_next';
|
||||
|
||||
const SPINE_CONTROL_TOOL_NAMES: ReadonlySet<string> = new Set([
|
||||
'spine_open',
|
||||
'spine_close',
|
||||
'spine_next',
|
||||
]);
|
||||
|
||||
function isSpineControlToolName(name: string): name is SpineControlToolName {
|
||||
return SPINE_CONTROL_TOOL_NAMES.has(name);
|
||||
}
|
||||
|
||||
interface SpineNode {
|
||||
summary: string;
|
||||
parentIndex: number | null;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(input: unknown): Record<string, unknown> {
|
||||
let value = input;
|
||||
if (typeof value === 'string') {
|
||||
if (value.trim().length === 0) return {};
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function readSummary(args: Record<string, unknown>): string | null {
|
||||
const summary = args['summary'];
|
||||
return typeof summary === 'string' && summary.trim().length > 0 ? summary : null;
|
||||
}
|
||||
|
||||
function applyTransition(
|
||||
nodes: SpineNode[],
|
||||
cursorStack: number[],
|
||||
name: SpineControlToolName,
|
||||
args: Record<string, unknown>,
|
||||
): void {
|
||||
switch (name) {
|
||||
case 'spine_open': {
|
||||
const summary = readSummary(args);
|
||||
if (summary === null) return;
|
||||
const parentIndex = cursorStack.at(-1) ?? null;
|
||||
nodes.push({ summary, parentIndex, closed: false });
|
||||
cursorStack.push(nodes.length - 1);
|
||||
return;
|
||||
}
|
||||
case 'spine_close': {
|
||||
const cursor = cursorStack.at(-1);
|
||||
if (cursor === undefined) return;
|
||||
nodes[cursor]!.closed = true;
|
||||
cursorStack.pop();
|
||||
return;
|
||||
}
|
||||
case 'spine_next': {
|
||||
const summary = readSummary(args);
|
||||
const cursor = cursorStack.at(-1);
|
||||
if (summary === null || cursor === undefined) return;
|
||||
const parentIndex = nodes[cursor]?.parentIndex ?? null;
|
||||
nodes[cursor]!.closed = true;
|
||||
nodes.push({ summary, parentIndex, closed: false });
|
||||
cursorStack[cursorStack.length - 1] = nodes.length - 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function projectTree(nodes: SpineNode[], cursorStack: number[]): TodoTreeNode[] {
|
||||
// No early return at the root epoch: a fully closed tree stays visible as
|
||||
// all-done history. A transcript without spine activity still yields [] via
|
||||
// the empty children map.
|
||||
const cursor = cursorStack.at(-1);
|
||||
|
||||
const childrenByParent = new Map<number | null, number[]>();
|
||||
for (const [index, node] of nodes.entries()) {
|
||||
const bucket = childrenByParent.get(node.parentIndex);
|
||||
if (bucket === undefined) {
|
||||
childrenByParent.set(node.parentIndex, [index]);
|
||||
} else {
|
||||
bucket.push(index);
|
||||
}
|
||||
}
|
||||
|
||||
const build = (index: number): TodoTreeNode => {
|
||||
const node = nodes[index]!;
|
||||
return {
|
||||
title: node.summary,
|
||||
status: node.closed ? 'done' : 'in_progress',
|
||||
active: index === cursor ? true : undefined,
|
||||
children: (childrenByParent.get(index) ?? []).map(build),
|
||||
};
|
||||
};
|
||||
return (childrenByParent.get(null) ?? []).map(build);
|
||||
}
|
||||
|
||||
export function spineTreeFromMessages(messages: AppMessage[]): TodoTreeNode[] {
|
||||
const nodes: SpineNode[] = [];
|
||||
/** Ancestor chain root → cursor as node indexes; empty at the root epoch. */
|
||||
const cursorStack: number[] = [];
|
||||
const pending = new Map<string, { name: SpineControlToolName; args: Record<string, unknown> }>();
|
||||
|
||||
for (const msg of messages) {
|
||||
for (const c of msg.content) {
|
||||
if (c.type === 'toolUse') {
|
||||
const name = normalizeToolName(c.toolName);
|
||||
if (isSpineControlToolName(name)) {
|
||||
pending.set(c.toolCallId, { name, args: parseArgs(c.input) });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c.type !== 'toolResult') continue;
|
||||
const call = pending.get(c.toolCallId);
|
||||
if (call === undefined) continue;
|
||||
pending.delete(c.toolCallId);
|
||||
// Only accepted transitions touch the tree. Core's contract: accepted ⟺
|
||||
// isError !== true — a rejected transition surfaces its reason as an
|
||||
// error. The same isError-only check the TUI's live path applies.
|
||||
if (c.isError) continue;
|
||||
applyTransition(nodes, cursorStack, call.name, call.args);
|
||||
}
|
||||
}
|
||||
|
||||
return projectTree(nodes, cursorStack);
|
||||
}
|
||||
|
|
@ -71,6 +71,7 @@ import { isPlaceholderSessionUsage, toAppEvent } from '../api/daemon/mappers';
|
|||
|
||||
import { messagesToTurns } from './messagesToTurns';
|
||||
import { latestTodos } from './latestTodos';
|
||||
import { spineTreeFromMessages } from './spineTree';
|
||||
import { buildSwarmGroups, countSwarmMembers, swarmMembersByToolCall } from './swarmGroups';
|
||||
import type { SwarmGroup, SwarmMember } from './swarmGroups';
|
||||
import type {
|
||||
|
|
@ -87,6 +88,7 @@ import type {
|
|||
Session,
|
||||
TaskItem,
|
||||
TaskState,
|
||||
TodoTreeNode,
|
||||
TodoView,
|
||||
UIQuestion,
|
||||
Workspace,
|
||||
|
|
@ -652,6 +654,7 @@ async function refreshSessionStatus(sessionId: string): Promise<void> {
|
|||
usage: {
|
||||
...s.usage,
|
||||
contextTokens: st.contextTokens,
|
||||
rawContextTokens: st.rawContextTokens,
|
||||
contextLimit: st.maxContextTokens,
|
||||
},
|
||||
}));
|
||||
|
|
@ -1911,6 +1914,16 @@ const todos = computed<TodoView[]>(() => {
|
|||
return latestTodos(rawState.messagesBySession[sid] ?? []);
|
||||
});
|
||||
|
||||
/** Spine task tree of the active session, mirrored from the transcript's
|
||||
spine control tools. Persists as all-done history after the last close;
|
||||
empty only when the session never used spine — the dock then falls back
|
||||
to the flat todo list. */
|
||||
const todoTree = computed<TodoTreeNode[]>(() => {
|
||||
const sid = rawState.activeSessionId;
|
||||
if (!sid) return [];
|
||||
return spineTreeFromMessages(rawState.messagesBySession[sid] ?? []);
|
||||
});
|
||||
|
||||
/** Live compaction state of the active session (present only while running). */
|
||||
const compaction = computed<CompactionStatus | null>(() => {
|
||||
const sid = rawState.activeSessionId;
|
||||
|
|
@ -2129,6 +2142,7 @@ const status = computed<ConversationStatus>(() => {
|
|||
// Raw id for exact comparison in pickers (display name diverges from id).
|
||||
modelId: matched?.id ?? rawModel,
|
||||
ctxUsed: activeSession?.usage.contextTokens ?? 0,
|
||||
ctxRaw: activeSession?.usage.rawContextTokens ?? 0,
|
||||
ctxMax: activeSession?.usage.contextLimit ?? 0,
|
||||
permission: rawState.permission,
|
||||
branch,
|
||||
|
|
@ -2632,6 +2646,7 @@ export function useKimiWebClient() {
|
|||
* sources a subagent's streaming `outputLines` from here. */
|
||||
activeAppTasks,
|
||||
todos,
|
||||
todoTree,
|
||||
goal,
|
||||
swarms,
|
||||
swarmMembersByToolCallId,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export default {
|
|||
connectionConnecting: 'Connecting…',
|
||||
connectionDisconnected: 'Disconnected',
|
||||
ctxTooltip: 'Used {used} / {max} tokens ({pct}%)',
|
||||
ctxTooltipRaw: 'Projected {used} · raw {raw} / {max} tokens ({pct}%)',
|
||||
modelLabel: 'Model',
|
||||
permissionManual: 'Manual',
|
||||
permissionAuto: 'Auto',
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export default {
|
|||
dockBash: 'Bash',
|
||||
dockSubagent: 'Sub Agent',
|
||||
dockTodos: 'Todos',
|
||||
dockSpine: 'Spine Tree',
|
||||
running: 'running',
|
||||
closePanel: 'Close panel',
|
||||
timingRunning: 'Running · {time}',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export default {
|
|||
connectionConnecting: '连接中…',
|
||||
connectionDisconnected: '未连接',
|
||||
ctxTooltip: '使用 {used} / {max} tokens ({pct}%)',
|
||||
ctxTooltipRaw: '投影 {used} · 原始 {raw} / {max} tokens ({pct}%)',
|
||||
modelLabel: '模型',
|
||||
permissionManual: '逐条确认',
|
||||
permissionAuto: '完全自主',
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export default {
|
|||
dockBash: '后台 Bash',
|
||||
dockSubagent: '子 Agent',
|
||||
dockTodos: '待办',
|
||||
dockSpine: 'Spine 树',
|
||||
running: '运行中',
|
||||
closePanel: '关闭面板',
|
||||
timingRunning: '运行中 · {time}',
|
||||
|
|
|
|||
|
|
@ -275,6 +275,19 @@ export interface TodoView {
|
|||
status: 'pending' | 'in_progress' | 'done';
|
||||
}
|
||||
|
||||
/**
|
||||
* One node of the Spine task tree mirrored from the transcript (the
|
||||
* spine_open / spine_close / spine_next control tools). Closed nodes render
|
||||
* as done, the open cursor chain as in_progress, and the cursor node — the
|
||||
* one the agent is working on right now — is flagged `active`.
|
||||
*/
|
||||
export interface TodoTreeNode {
|
||||
title: string;
|
||||
status: 'in_progress' | 'done';
|
||||
active?: boolean;
|
||||
children: TodoTreeNode[];
|
||||
}
|
||||
|
||||
export type TaskState = 'run' | 'done' | 'fail';
|
||||
|
||||
export interface TaskItem {
|
||||
|
|
@ -300,6 +313,9 @@ export interface ConversationStatus {
|
|||
/** Raw model id — the value selection lists compare against. */
|
||||
modelId: string;
|
||||
ctxUsed: number;
|
||||
/** Unfolded ("raw") context size; 0 when the daemon hasn't reported it —
|
||||
* renders then fall back to the single-value projected display. */
|
||||
ctxRaw: number;
|
||||
ctxMax: number;
|
||||
permission: 'manual' | 'auto' | 'yolo';
|
||||
branch: string;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import { IAgentContextSizeService } from '#/agent/contextSize/contextSize';
|
|||
import { contextSizeMeasured } from '#/agent/contextSize/contextSizeOps';
|
||||
import { SPINE_FLAG_ID } from '#/agent/spine/flag';
|
||||
import { IAgentSpineService } from '#/agent/spine/spine';
|
||||
import { SpineModel, spineRootCompact } from '#/agent/spine/spineOps';
|
||||
import { IAgentLLMRequesterService, type LLMRequestFinish } from '#/agent/llmRequester/llmRequester';
|
||||
import { retryBackoffDelays, sleepForRetry } from '#/_base/utils/retry';
|
||||
import { IAgentLoopService, type AfterStepContext, type LoopErrorContext } from '#/agent/loop/loop';
|
||||
|
|
@ -794,11 +793,17 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
// appending so the epoch archive records exactly what the model stops
|
||||
// seeing.
|
||||
const foldedMessages = this.context.get().slice(0, summaryAt);
|
||||
// Compute the epoch from the PRE-append derived state and epochStartAt
|
||||
// arithmetically. Reading either off the post-append stream would make them
|
||||
// depend on the append landing synchronously — when a tool exchange defers
|
||||
// it, a stale read would report the CURRENT epoch and archive the new
|
||||
// epoch's folded history under its path, overwriting it.
|
||||
const epoch = this.spine.currentState().rootEpoch + 1;
|
||||
const epochStartAt = summaryAt + 1;
|
||||
this.context.append(summaryMessage);
|
||||
const epochStartAt = this.context.get().length;
|
||||
const tokensAfter = estimateTokensForMessages([summaryMessage]);
|
||||
const spineState = this.wire.getModel(SpineModel);
|
||||
const epoch = spineState.rootEpoch + 1;
|
||||
// The appended summary message IS the epoch boundary: the spine derivation
|
||||
// reports the new epoch once it lands — no tree op to dispatch.
|
||||
const archivePath = await this.spine.archiveEpochRoot({
|
||||
epoch,
|
||||
epochStartAt,
|
||||
|
|
@ -816,12 +821,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
);
|
||||
}
|
||||
this.wire.dispatch(
|
||||
spineRootCompact({
|
||||
epoch,
|
||||
epochStartAt,
|
||||
epochMemoryAt: summaryAt,
|
||||
archivePath,
|
||||
}),
|
||||
contextSizeMeasured({ length: epochStartAt, tokens: tokensAfter, kind: 'estimate' }),
|
||||
);
|
||||
return {
|
||||
|
|
@ -863,7 +862,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
private epochScopedHistory(
|
||||
history: readonly ContextMessage[],
|
||||
): readonly ContextMessage[] {
|
||||
const spineState = this.wire.getModel(SpineModel);
|
||||
const spineState = this.spine.currentState();
|
||||
const start = Math.min(spineState.epochStartAt, history.length);
|
||||
const scoped = history.slice(start);
|
||||
const summaryAt = spineState.epochMemoryAt;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
export * from './flag';
|
||||
export * from './instructions';
|
||||
export * from './spine';
|
||||
export * from './spineDerive';
|
||||
export * from './spineOps';
|
||||
export * from './spineService';
|
||||
export * from './spineTree';
|
||||
|
|
|
|||
|
|
@ -88,14 +88,19 @@ Conventions:
|
|||
memory in a fresh sibling.
|
||||
* \`spine_tree\` is read-only; actual transitions happen only through \`spine_open\`,
|
||||
\`spine_close\`, and \`spine_next\`.
|
||||
* Spine transitions change task scope, not communication state. A final response,
|
||||
status update, or user-facing report does not by itself require a \`spine_open\`,
|
||||
\`spine_next\`, or \`spine_close\` call; never create a reporting node or perform
|
||||
a transition solely for delivery.
|
||||
* Root-epoch ids such as \`1\` or \`2\` cannot be closed. The initial \`1.1\` is a
|
||||
startup work node, not a concrete task node; use \`spine_open\` before doing task work.
|
||||
* \`<spine_status>\` gives current node orientation; \`<spine_memory>\` gives
|
||||
continuation memory from closed work.
|
||||
* \`[U#]\` anchors refer to numbered user requests. When writing memory, preserve
|
||||
\`[U#]\` anchors and record each request's status. After \`<spine_memory>\`
|
||||
continuity or a node transition, use that record to report only new results,
|
||||
blockers, or requested details.
|
||||
\`[U#]\` anchors for user requests that still matter. Do not maintain a separate
|
||||
request-status ledger when the relevant intent is already captured in ordinary
|
||||
continuation state. After \`<spine_memory>\` continuity or a node transition,
|
||||
report only new results, blockers, or requested details.
|
||||
* Place user-facing replies where they are most useful: local intermediate
|
||||
results may wait for later merge, while complete conclusions, blocking status,
|
||||
or decisions needing user input should be surfaced promptly.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { createDecorator } from '#/_base/di/instantiation';
|
|||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
|
||||
import type { SpineEpochArchiveInput } from './spineArchive';
|
||||
import type { SpineState } from './spineOps';
|
||||
|
||||
export const SPINE_TOOL_OPEN = 'spine_open';
|
||||
export const SPINE_TOOL_CLOSE = 'spine_close';
|
||||
|
|
@ -43,15 +44,18 @@ export interface IAgentSpineService {
|
|||
|
||||
readonly enabled: boolean;
|
||||
|
||||
acceptOpen(summary: string, toolCallId: string): SpineTransitionResult;
|
||||
acceptClose(memory: string, toolCallId: string): SpineTransitionResult;
|
||||
acceptNext(summary: string, memory: string, toolCallId: string): SpineTransitionResult;
|
||||
acceptOpen(summary: string): SpineTransitionResult;
|
||||
acceptClose(memory: string): SpineTransitionResult;
|
||||
acceptNext(summary: string, memory: string): SpineTransitionResult;
|
||||
|
||||
archiveEpochRoot(input: SpineEpochArchiveInput): Promise<string | undefined>;
|
||||
|
||||
renderTree(): string;
|
||||
|
||||
fold(messages: readonly ContextMessage[]): readonly ContextMessage[];
|
||||
|
||||
/** The current tree state, derived from the message stream on read. */
|
||||
currentState(): SpineState;
|
||||
}
|
||||
|
||||
export const IAgentSpineService = createDecorator<IAgentSpineService>('agentSpineService');
|
||||
|
|
|
|||
236
packages/agent-core-v2/src/agent/spine/spineDerive.ts
Normal file
236
packages/agent-core-v2/src/agent/spine/spineDerive.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
/**
|
||||
* `spine` domain (L4) — derives the task tree purely from the stored
|
||||
* `contextMemory` message stream.
|
||||
*
|
||||
* The message stream is the single source of truth: a spine control-tool call
|
||||
* whose accepted receipt landed in history IS the transition — no parallel op
|
||||
* records, no commit protocol, and nothing to repair when the history shrinks
|
||||
* (an undo that removes a transition's messages removes the transition).
|
||||
* `deriveSpineState` scans the surviving messages, matches each `spine_open` /
|
||||
* `spine_close` / `spine_next` call to its accepted receipt — the exact
|
||||
* `ACCEPTED_OUTPUT` carrier text, or the legacy bare `accepted` left by older
|
||||
* sessions; persisted metadata can degrade, so the match is textual and a
|
||||
* near-miss does not count — and replays the transitions under the same
|
||||
* guards the legacy ops enforced (cursor position, non-empty bodies, root
|
||||
* epochs never close). Root-epoch boundaries come from the compaction summary
|
||||
* message itself (`origin.kind === 'compaction_summary'`, with the summary
|
||||
* prefix text as the fallback carrier when the origin metadata is absent). A
|
||||
* closing node's memory body is assembled from the live span — user requests
|
||||
* keep their fold ordinals and already-closed children contribute their
|
||||
* assembled bodies — so an undo that rewrites the span rewrites the memory
|
||||
* with it. Consumed by `spineService`; the fold projection and archive
|
||||
* rendering are unchanged.
|
||||
*
|
||||
* Silence is the design, not an oversight: a call whose accepted receipt never
|
||||
* landed, a receipt whose call is missing, or a transition the guards reject
|
||||
* (a close under a stale cursor, an empty body) simply does not happen, with
|
||||
* no lost-commit audit and no repair op. The stream is the whole truth, so a
|
||||
* transition the stream does not fully witness is not a transition — the
|
||||
* legacy op world needed `reportLostCommits` precisely because it kept a
|
||||
* second record that could disagree with the receipts.
|
||||
*/
|
||||
|
||||
import {
|
||||
COMPACTION_SUMMARY_PREFIX,
|
||||
isCompactionSummaryMessage,
|
||||
} from '#/agent/contextMemory/compactionHandoff';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
|
||||
import { SPINE_TOOL_CLOSE, SPINE_TOOL_NEXT, SPINE_TOOL_OPEN } from './spine';
|
||||
import { collectSpanUserRequests } from './spineFold';
|
||||
import type { SpineNode, SpineState } from './spineOps';
|
||||
import {
|
||||
assembleMemoryBody,
|
||||
childNodeId,
|
||||
closedChildMemories,
|
||||
epochStartupNodeId,
|
||||
isRootEpoch,
|
||||
nextChildIndex,
|
||||
parentNodeId,
|
||||
SPINE_VOID_OPENED_AT,
|
||||
} from './spineTree';
|
||||
import { ACCEPTED_OUTPUT } from './tools/controlResult';
|
||||
|
||||
/** Receipt left by sessions predating the delayed-commit receipt wording. */
|
||||
const LEGACY_ACCEPTED_RECEIPT = 'accepted';
|
||||
|
||||
export function deriveSpineState(messages: readonly ContextMessage[]): SpineState {
|
||||
const accepted = collectAcceptedCallIds(messages);
|
||||
const nodes: Record<string, SpineNode> = {};
|
||||
let openStack: readonly string[] = [];
|
||||
let rootEpoch = 0;
|
||||
let epochStartAt = 0;
|
||||
let epochMemoryAt: number | undefined;
|
||||
|
||||
function openEpoch(epoch: number, startupOpenedAt: number): void {
|
||||
const epochId = String(epoch);
|
||||
const startupId = epochStartupNodeId(epoch);
|
||||
nodes[epochId] = {
|
||||
id: epochId,
|
||||
summary: `root epoch ${String(epoch)}`,
|
||||
openedAt: SPINE_VOID_OPENED_AT,
|
||||
children: [startupId],
|
||||
};
|
||||
nodes[startupId] = {
|
||||
id: startupId,
|
||||
summary: 'startup',
|
||||
openedAt: startupOpenedAt,
|
||||
children: [],
|
||||
};
|
||||
openStack = [epochId, startupId];
|
||||
rootEpoch = epoch;
|
||||
}
|
||||
|
||||
function assembleNodeMemory(node: SpineNode, closedAt: number, nodeMemory: string): string {
|
||||
return assembleMemoryBody({
|
||||
userRequests: collectSpanUserRequests(messages, node.openedAt, closedAt),
|
||||
childMemories: closedChildMemories(nodes, node),
|
||||
nodeMemory,
|
||||
});
|
||||
}
|
||||
|
||||
function openNode(summary: string, openedAt: number): void {
|
||||
const parentId = openStack.at(-1);
|
||||
if (parentId === undefined) return;
|
||||
const parent = nodes[parentId];
|
||||
if (parent === undefined || parent.closedAt !== undefined) return;
|
||||
const trimmed = summary.trim();
|
||||
if (trimmed.length === 0) return;
|
||||
const id = childNodeId(parentId, nextChildIndex(parent.children));
|
||||
nodes[id] = { id, summary: trimmed, openedAt, children: [] };
|
||||
nodes[parentId] = { ...parent, children: [...parent.children, id] };
|
||||
openStack = [...openStack, id];
|
||||
}
|
||||
|
||||
function closeNode(memory: string, carrierAt: number): void {
|
||||
const id = openStack.at(-1);
|
||||
if (id === undefined || isRootEpoch(id)) return;
|
||||
const node = nodes[id];
|
||||
if (node === undefined || node.closedAt !== undefined) return;
|
||||
const trimmed = memory.trim();
|
||||
if (trimmed.length === 0) return;
|
||||
// The span ends BEFORE the assistant message carrying the transition call,
|
||||
// so the carrier and its receipt stay visible in the parent context.
|
||||
const closedAt = Math.max(carrierAt - 1, node.openedAt);
|
||||
nodes[id] = { ...node, closedAt, memory: assembleNodeMemory(node, closedAt, trimmed) };
|
||||
openStack = openStack.slice(0, -1);
|
||||
}
|
||||
|
||||
function nextNode(summary: string, memory: string, carrierAt: number): void {
|
||||
const closedId = openStack.at(-1);
|
||||
if (closedId === undefined || isRootEpoch(closedId)) return;
|
||||
const closing = nodes[closedId];
|
||||
if (closing === undefined || closing.closedAt !== undefined) return;
|
||||
const trimmedSummary = summary.trim();
|
||||
const trimmedMemory = memory.trim();
|
||||
if (trimmedSummary.length === 0 || trimmedMemory.length === 0) return;
|
||||
const parentId = parentNodeId(closedId);
|
||||
if (parentId === null) return;
|
||||
const parent = nodes[parentId];
|
||||
if (parent === undefined) return;
|
||||
const closedAt = Math.max(carrierAt - 1, closing.openedAt);
|
||||
const openedId = childNodeId(parentId, nextChildIndex(parent.children));
|
||||
nodes[closedId] = {
|
||||
...closing,
|
||||
closedAt,
|
||||
memory: assembleNodeMemory(closing, closedAt, trimmedMemory),
|
||||
};
|
||||
// The sibling opens right after the closing span — at the carrier's index —
|
||||
// so the carrier and its receipt ride inside the new sibling's span.
|
||||
nodes[openedId] = {
|
||||
id: openedId,
|
||||
summary: trimmedSummary,
|
||||
openedAt: closedAt + 1,
|
||||
children: [],
|
||||
};
|
||||
nodes[parentId] = { ...parent, children: [...parent.children, openedId] };
|
||||
openStack = [...openStack.slice(0, -1), openedId];
|
||||
}
|
||||
|
||||
openEpoch(1, 0);
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
if (message === undefined) continue;
|
||||
if (isEpochBoundary(message)) {
|
||||
openEpoch(rootEpoch + 1, i + 1);
|
||||
epochStartAt = i + 1;
|
||||
epochMemoryAt = i;
|
||||
continue;
|
||||
}
|
||||
if (message.role !== 'assistant') continue;
|
||||
for (const call of message.toolCalls) {
|
||||
if (!accepted.has(call.id)) continue;
|
||||
const args = parseTransitionArgs(call.arguments);
|
||||
if (args === undefined) continue;
|
||||
if (call.name === SPINE_TOOL_OPEN) {
|
||||
openNode(args.summary, i);
|
||||
} else if (call.name === SPINE_TOOL_CLOSE) {
|
||||
closeNode(args.memory, i);
|
||||
} else if (call.name === SPINE_TOOL_NEXT) {
|
||||
nextNode(args.summary, args.memory, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, openStack, rootEpoch, epochStartAt, epochMemoryAt };
|
||||
}
|
||||
|
||||
interface SpineTransitionArgs {
|
||||
readonly summary: string;
|
||||
readonly memory: string;
|
||||
}
|
||||
|
||||
function parseTransitionArgs(raw: string | null | undefined): SpineTransitionArgs | undefined {
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) return undefined;
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const summary = record['summary'];
|
||||
const memory = record['memory'];
|
||||
return {
|
||||
summary: typeof summary === 'string' ? summary : '',
|
||||
memory: typeof memory === 'string' ? memory : '',
|
||||
};
|
||||
}
|
||||
|
||||
function collectAcceptedCallIds(messages: readonly ContextMessage[]): ReadonlySet<string> {
|
||||
const spineCallIds = new Set<string>();
|
||||
for (const message of messages) {
|
||||
if (message === undefined || message.role !== 'assistant') continue;
|
||||
for (const call of message.toolCalls) {
|
||||
if (isSpineTransitionTool(call.name)) spineCallIds.add(call.id);
|
||||
}
|
||||
}
|
||||
const accepted = new Set<string>();
|
||||
for (const message of messages) {
|
||||
if (message === undefined || message.role !== 'tool') continue;
|
||||
const callId = message.toolCallId;
|
||||
if (callId === undefined || !spineCallIds.has(callId)) continue;
|
||||
if (message.isError === true) continue;
|
||||
const text = messageText(message);
|
||||
if (text === ACCEPTED_OUTPUT || text === LEGACY_ACCEPTED_RECEIPT) accepted.add(callId);
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function isSpineTransitionTool(name: string): boolean {
|
||||
return name === SPINE_TOOL_OPEN || name === SPINE_TOOL_CLOSE || name === SPINE_TOOL_NEXT;
|
||||
}
|
||||
|
||||
function isEpochBoundary(message: ContextMessage): boolean {
|
||||
if (message.role !== 'user') return false;
|
||||
if (isCompactionSummaryMessage(message)) return true;
|
||||
// Fallback carrier for degraded persistence: only when the origin metadata
|
||||
// is absent — a message that still carries a non-summary origin is trusted.
|
||||
if (message.origin !== undefined) return false;
|
||||
return messageText(message).startsWith(COMPACTION_SUMMARY_PREFIX);
|
||||
}
|
||||
|
||||
function messageText(message: ContextMessage): string {
|
||||
return message.content.map((part) => (part.type === 'text' ? part.text : '')).join('');
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ export interface SpineFoldStatus {
|
|||
readonly cursorId: string;
|
||||
readonly summary: string;
|
||||
readonly parentId: string | null;
|
||||
readonly parentSummary: string | null;
|
||||
readonly cursorContext: number;
|
||||
readonly contextLeft: number | undefined;
|
||||
/** Per-message estimate of the whole stored history (pre-fold, messages only). */
|
||||
|
|
@ -234,6 +235,8 @@ function prefixFirstText(content: readonly ContentPart[], anchor: string): Conte
|
|||
|
||||
function statusMessage(status: SpineFoldStatus): ContextMessage {
|
||||
const parent = status.parentId === null ? '' : ` parent="${status.parentId}"`;
|
||||
const parentSummary =
|
||||
status.parentSummary === null ? '' : ` parent_summary="${escapeAttr(status.parentSummary)}"`;
|
||||
const cursorContext = ` cursor_context="~${formatTokens(status.cursorContext)}"`;
|
||||
const contextLeft =
|
||||
status.contextLeft === undefined ? '' : ` context_left="~${formatTokens(status.contextLeft)}"`;
|
||||
|
|
@ -242,7 +245,7 @@ function statusMessage(status: SpineFoldStatus): ContextMessage {
|
|||
const projectedContext = ` projected_context="${projectedPrefix}${formatTokens(
|
||||
status.projectedContext,
|
||||
)}"`;
|
||||
const text = `<spine_status cursor="${status.cursorId}" summary="${escapeAttr(status.summary)}"${parent}${cursorContext}${contextLeft}${rawContext}${projectedContext} />`;
|
||||
const text = `<spine_status cursor="${status.cursorId}" summary="${escapeAttr(status.summary)}"${parent}${parentSummary}${cursorContext}${contextLeft}${rawContext}${projectedContext} />`;
|
||||
return {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text }],
|
||||
|
|
|
|||
|
|
@ -1,19 +1,18 @@
|
|||
/**
|
||||
* `spine` domain (L4) — wire Model (`SpineModel`) and the Ops that mutate the
|
||||
* model-driven task tree (`spine.open` / `spine.close` / `spine.next` /
|
||||
* `spine.root_compact` / `spine.truncate_repair`).
|
||||
* `spine` domain (L4) — the LEGACY wire Model (`SpineModel`) and its Ops
|
||||
* (`spine.open` / `spine.close` / `spine.next` / `spine.root_compact` /
|
||||
* `spine.truncate_repair`), plus the `SpineState` / `SpineNode` types the
|
||||
* whole domain shares.
|
||||
*
|
||||
* Declares the tree as `SpineState` (initial root epoch `1` with an open
|
||||
* synthetic startup node `1.1`): a node map, the open-node stack (its top is
|
||||
* the cursor), and the current root-epoch boundary. Every Op's `apply` is a
|
||||
* pure state transform that returns a NEW reference on a real change and the
|
||||
* SAME reference when its guard fails (so the wire's reference-equality gate
|
||||
* stays quiet); guards reject malformed or out-of-order payloads so a replay
|
||||
* never lands the tree in an inconsistent shape — cursor, parent linkage and
|
||||
* child numbering are derived by the live service, never trusted from a record.
|
||||
* Node ids and memory bodies live on the records themselves, so `wire.dispatch`
|
||||
* and `wire.replay` rebuild the same tree. Consumed by the Agent-scope
|
||||
* `spineService` and the `spineFold` projection.
|
||||
* Since the derivation rewrite, the live tree is rebuilt from the
|
||||
* `contextMemory` message stream by `spineDerive.deriveSpineState` and these
|
||||
* ops are NEVER dispatched: they stay registered only so sessions persisted
|
||||
* before the rewrite still replay without unknown-op errors, and their
|
||||
* reducers are kept honest by the `Spine reducers (via wire)` tests. The
|
||||
* state shape below is the derivation's output contract — a node map, the
|
||||
* open-node stack (its top is the cursor), and the current root-epoch
|
||||
* boundary, with `openedAt`/`closedAt` indexing the stored history. Consumed
|
||||
* by the Agent-scope `spineService` and the `spineFold` projection.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
|
|
|||
|
|
@ -1,60 +1,44 @@
|
|||
/**
|
||||
* `spine` domain (L4) — `IAgentSpineService` implementation.
|
||||
*
|
||||
* Owns the model-driven task tree in the wire `SpineModel`: the read-only /
|
||||
* The task tree is DERIVED, not stored: `deriveSpineState` replays the
|
||||
* `contextMemory` message stream and rebuilds the `SpineState` from the spine
|
||||
* control-tool calls and their accepted receipts, so the persisted history is
|
||||
* the single source of truth — a receipt that survived IS the transition, and
|
||||
* a truncation (undo / clear) truncates the tree by construction, with no
|
||||
* commit dance, no repair op, and no lost-commit audit. The read-only /
|
||||
* receipt-only control tools hand validated intent here (`acceptOpen` /
|
||||
* `acceptClose` / `acceptNext`), which registers the single per-step pending
|
||||
* transition; the `loop.afterStep` hook then commits it (`spine.open` /
|
||||
* `spine.close` / `spine.next`) once the matching assistant tool-call and tool
|
||||
* result have both landed in `contextMemory`, so the tree moves only on
|
||||
* observed evidence. A closing span ends before the assistant message carrying
|
||||
* the transition call, so the carrier, its receipt, and any slower tool
|
||||
* results batched in the same response stay visible and paired in the parent
|
||||
* context; `spine.next` hands the carrier to the new sibling, whose span
|
||||
* opens right after the closing one. Acceptance validates non-empty bodies
|
||||
* and the cursor position. At commit the closing span's real user requests are compiled into
|
||||
* the memory body as `## User Message [U#]` sections (with the fold's stable
|
||||
* ordinals), so `[U#]` citations stay resolvable after the span folds away.
|
||||
* Reads the cursor and node layout through
|
||||
* `wire.getModel(SpineModel)`, writes through `wire.dispatch(spineOpen(...))`
|
||||
* etc., records each node's provider-token baseline and closing high-water
|
||||
* mark via `contextSize` — surfaced as per-node cost in `spine_tree` and as
|
||||
* the projected-growth `cursor_context` delta in `<spine_status>` —
|
||||
* assembles continuation memory with `spineTree.assembleMemoryBody`, archives
|
||||
* each closed node's trajectory under the bootstrap-issued per-agent session
|
||||
* homedir (`<sessionDir>/agents/<id>/spine/`), and — for root compactions —
|
||||
* archives the history the new epoch boundary folds away (`archiveEpochRoot`),
|
||||
* with the path published back onto the new epoch node so the folded context
|
||||
* stays one `Read` away. Persistence failures are never swallowed: a failed
|
||||
* commit dispatch or archive write is reported through `onUnexpectedError`,
|
||||
* and a node whose archive could not be written still closes, with the failure
|
||||
* marked in its memory. On restore it audits the rebuilt transcript against
|
||||
* the committed `spine.*` records read through `wireRecord` — every accepted
|
||||
* control-tool receipt must have its op — and reports lost transitions
|
||||
* (detection only, no repair): ops without receipts (receipts a compaction
|
||||
* folded away) are the benign direction and stay silent, and the legacy bare
|
||||
* `accepted` receipt left by older sessions still counts, so resuming them
|
||||
* raises no false alarm. A transition left pending when its step ends
|
||||
* (typically an abort before afterStep ran) is committed at the next step's
|
||||
* start when its receipt already landed, so the tree catches up before the
|
||||
* model sees the context; one with no receipt to commit against is dropped and
|
||||
* reported the same way, unless the owning step aborted (a routine interrupt).
|
||||
* Renders the read-only `spine_tree` view across every
|
||||
* root epoch (current first by numeric order), so a superseded epoch's
|
||||
* closed-node archives stay discoverable after a root compaction. Registers
|
||||
* its history fold into `contextProjector` and its `<spine_view>` prompt block
|
||||
* into `llmRequester` (spine → projector / llmRequester, never the reverse);
|
||||
* the prompt contribution self-gates per request, so only turn requests whose
|
||||
* tool list can act on the protocol (i.e. that offer `spine_open`) carry it —
|
||||
* sub-agents and operations such as compaction never see it. Repairs the tree
|
||||
* when the stored history shrinks beneath it (`context.spliced` with a nonzero
|
||||
* delete count): an undo truncation clamps straddling closed spans to the cut,
|
||||
* voids fully-truncated ones, restarts truncated open spans there, and clamps
|
||||
* the epoch boundary (and its summary anchor) into the surviving range
|
||||
* (`spine.truncate_repair`) — a `/clear` is the cut-at-zero case of the same
|
||||
* repair — so post-truncation messages are never folded against dangling
|
||||
* indices and the observation cursor (`lastObservedIndex`) is re-anchored to
|
||||
* the cut. Self-checks the
|
||||
* `acceptClose` / `acceptNext`); acceptance checks the derived cursor position,
|
||||
* enforces the single-transition-per-step rule, and records the node's
|
||||
* provider-token baseline / closing high-water mark via `contextSize` —
|
||||
* surfaced as per-node cost in `spine_tree` and as the projected-growth
|
||||
* `cursor_context` delta in `<spine_status>`. A closing span ends before the
|
||||
* assistant message carrying the transition call, so the carrier, its receipt,
|
||||
* and any slower tool results batched in the same response stay visible and
|
||||
* paired in the parent context; `spine.next` hands the carrier to the new
|
||||
* sibling, whose span opens right after the closing one. At close the span's
|
||||
* real user requests are compiled into the memory body as `## User Message
|
||||
* [U#]` sections (with the fold's stable ordinals) by the derivation, so
|
||||
* `[U#]` citations stay resolvable after the span folds away — and an undo
|
||||
* that removes a request removes it from the memory too, since the body is
|
||||
* re-assembled from the surviving history on every read. Side effects ride
|
||||
* the derivation delta: the `loop.afterStep` hook archives each newly closed
|
||||
* node's trajectory under the bootstrap-issued per-agent session homedir
|
||||
* (`<sessionDir>/agents/<id>/spine/`), and — for root compactions — the
|
||||
* full-compaction flow archives the history the new epoch boundary folds away
|
||||
* (`archiveEpochRoot`). Archive paths are deterministic (`f(homedir, nodeId)`),
|
||||
* so a restore re-derives them and the first post-restore sweep rewrites any
|
||||
* archive a crash lost. Persistence failures are never swallowed: a failed
|
||||
* archive write is reported through `onUnexpectedError`, and the node's memory
|
||||
* carries the failure note in the projection from then on. Renders the
|
||||
* read-only `spine_tree` view across every root epoch (current first by
|
||||
* numeric order), so a superseded epoch's closed-node archives stay
|
||||
* discoverable after a root compaction. Registers its history fold into
|
||||
* `contextProjector` and its `<spine_view>` prompt block into `llmRequester`
|
||||
* (spine → projector / llmRequester, never the reverse); the prompt
|
||||
* contribution self-gates per request, so only turn requests whose tool list
|
||||
* can act on the protocol (i.e. that offer `spine_open`) carry it — sub-agents
|
||||
* and operations such as compaction never see it. Self-checks the
|
||||
* `KIMI_CODE_SPINE` gate at construction, so a disabled spine never observes
|
||||
* history. Bound at Agent scope.
|
||||
*/
|
||||
|
|
@ -64,6 +48,7 @@ import { InstantiationType } from '#/_base/di/extensions';
|
|||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { onUnexpectedError } from '#/_base/errors/unexpectedError';
|
||||
import { estimateTokensForMessages } from '#/_base/utils/tokens';
|
||||
import { COMPACTION_SUMMARY_PREFIX } from '#/agent/contextMemory/compactionHandoff';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import { IAgentContextProjectorService } from '#/agent/contextProjector/contextProjector';
|
||||
|
|
@ -72,10 +57,6 @@ import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester';
|
|||
import { IAgentLoopService } from '#/agent/loop/loop';
|
||||
import { IAgentProfileService } from '#/agent/profile/profile';
|
||||
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
||||
import {
|
||||
IAgentWireRecordService,
|
||||
type PersistedWireRecord,
|
||||
} from '#/agent/wireRecord/wireRecord';
|
||||
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { IFlagService } from '#/app/flag/flag';
|
||||
|
|
@ -89,8 +70,6 @@ import { SPINE_FLAG_ID } from './flag';
|
|||
import { appendSpineView, loadSpineViewOverride } from './instructions';
|
||||
import {
|
||||
IAgentSpineService,
|
||||
SPINE_TOOL_CLOSE,
|
||||
SPINE_TOOL_NEXT,
|
||||
SPINE_TOOL_OPEN,
|
||||
type SpineTransitionResult,
|
||||
} from './spine';
|
||||
|
|
@ -101,18 +80,10 @@ import {
|
|||
writeNodeArchive,
|
||||
type SpineEpochArchiveInput,
|
||||
} from './spineArchive';
|
||||
import { collectSpanUserRequests, foldSpine, type SpineFoldStatus } from './spineFold';
|
||||
import { deriveSpineState } from './spineDerive';
|
||||
import { foldSpine, type SpineFoldStatus } from './spineFold';
|
||||
import { type SpineNode, type SpineState } from './spineOps';
|
||||
import {
|
||||
SpineModel,
|
||||
spineClose,
|
||||
spineNext,
|
||||
spineOpen,
|
||||
spineTruncateRepair,
|
||||
type SpineNode,
|
||||
type SpineState,
|
||||
} from './spineOps';
|
||||
import {
|
||||
assembleMemoryBody,
|
||||
childNodeId,
|
||||
isRootEpoch,
|
||||
nextChildIndex,
|
||||
|
|
@ -120,16 +91,6 @@ import {
|
|||
renderTree,
|
||||
type SpineTreeNodeView,
|
||||
} from './spineTree';
|
||||
import { ACCEPTED_OUTPUT } from './tools/controlResult';
|
||||
|
||||
type SpinePending = {
|
||||
readonly toolCallId: string;
|
||||
readonly stepSignal: AbortSignal | undefined;
|
||||
} & (
|
||||
| { readonly kind: 'open'; readonly summary: string }
|
||||
| { readonly kind: 'close'; readonly memory: string }
|
||||
| { readonly kind: 'next'; readonly summary: string; readonly memory: string }
|
||||
);
|
||||
|
||||
const REJECT_DISABLED: SpineTransitionResult = {
|
||||
accepted: false,
|
||||
|
|
@ -148,12 +109,40 @@ const REJECT_ROOT_EPOCH: SpineTransitionResult = {
|
|||
'Root-epoch nodes cannot be closed. Use open to start a child node under the current scope.',
|
||||
};
|
||||
|
||||
const ARCHIVE_FAILURE_NOTE =
|
||||
'[spine: the trajectory archive for this node could not be written; its detailed history was not persisted.]';
|
||||
|
||||
export class AgentSpineService extends Disposable implements IAgentSpineService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private pending: SpinePending | null = null;
|
||||
private lastObservedIndex = 0;
|
||||
private stepSignal: AbortSignal | undefined;
|
||||
/** Single-transition-per-step gate; set by an accept, cleared at step bounds. */
|
||||
private transitionThisStep = false;
|
||||
private cachedMessages: readonly ContextMessage[] | undefined;
|
||||
private cachedState: SpineState | undefined;
|
||||
/**
|
||||
* Ephemeral per-node token gauges, recorded at accept time. Token baselines
|
||||
* are not in the message stream, so pure derivation cannot recover them —
|
||||
* and `context_size.measured` is live-only (`persist: false`, v1-compat), so
|
||||
* the measurement history is gone on restore too. Within a session these
|
||||
* maps are complete and request-caliber (better coverage than the FIFO-64
|
||||
* snapshot chain); on restore they reset, so pre-restore nodes lose
|
||||
* `tokenCost` and the cursor's `cursor_context` reads as the full size. That
|
||||
* overstatement fails SAFE for a compaction-trigger gauge (premature close,
|
||||
* never overflow). Persisting measurements would fix it but is
|
||||
* contextSize-domain v1 work, out of spine's scope.
|
||||
*/
|
||||
private readonly baselines = new Map<string, number>();
|
||||
private readonly finals = new Map<string, number>();
|
||||
/** Closed nodes whose trajectory archive is on disk (or rewritten already). */
|
||||
private readonly archivedIds = new Set<string>();
|
||||
/**
|
||||
* Nodes (or epochs) whose archive write failed. For a work node the failure
|
||||
* note is patched into its memory; an epoch node carries no memory, so its id
|
||||
* only suppresses the published archive path — the tree never points at a
|
||||
* missing file, and the failure is reported through `onUnexpectedError`
|
||||
* either way.
|
||||
*/
|
||||
private readonly failedArchiveIds = new Set<string>();
|
||||
private spineViewOverride: string | undefined;
|
||||
private spineViewReady: Promise<void> = Promise.resolve();
|
||||
|
||||
|
|
@ -168,11 +157,10 @@ export class AgentSpineService extends Disposable implements IAgentSpineService
|
|||
@ISessionContext private readonly sessionCtx: ISessionContext,
|
||||
@IAgentScopeContext private readonly agentScope: IAgentScopeContext,
|
||||
@IAgentWireService private readonly wire: IWireService,
|
||||
@IAgentWireRecordService private readonly wireRecord: IAgentWireRecordService,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
@IAgentLoopService loop: IAgentLoopService,
|
||||
@IAgentContextProjectorService projector: IAgentContextProjectorService,
|
||||
@IAgentLLMRequesterService llmRequester: IAgentLLMRequesterService,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
) {
|
||||
super();
|
||||
if (this.enabled) {
|
||||
|
|
@ -201,103 +189,104 @@ export class AgentSpineService extends Disposable implements IAgentSpineService
|
|||
this._register(
|
||||
loop.hooks.onWillBeginStep.register('spine', async (ctx, next) => {
|
||||
await this.spineViewReady;
|
||||
if (this.pending !== null) {
|
||||
// A leftover pending means the previous step ended (usually an abort
|
||||
// before afterStep ran) without committing its transition. Commit it
|
||||
// now — before this step's request is built — so a receipt that
|
||||
// already landed is honored and the tree catches up before the model
|
||||
// sees the context; commitPending drops it when there is no evidence.
|
||||
await this.commitPending();
|
||||
}
|
||||
this.stepSignal = ctx.signal;
|
||||
// A step that ended without its did-finish hook (an abort) may have
|
||||
// left the gate set; every step starts with a clean transition budget.
|
||||
this.transitionThisStep = false;
|
||||
await next();
|
||||
}),
|
||||
);
|
||||
this._register(
|
||||
loop.hooks.onDidFinishStep.register('spine', async (_ctx, next) => {
|
||||
await this.commitPending();
|
||||
this.transitionThisStep = false;
|
||||
await this.archiveNewlyClosed();
|
||||
await next();
|
||||
}),
|
||||
);
|
||||
this._register(
|
||||
this.wire.onRestored(() => {
|
||||
this.lastObservedIndex = this.context.get().length;
|
||||
this.pending = null;
|
||||
if (this.enabled) this.reportLostCommits();
|
||||
// The restored history re-derives the tree on first read; the ephemeral
|
||||
// gauges and archive ledger belong to the pre-restore session. Clearing
|
||||
// the archive ledger makes the first post-restore sweep rewrite every
|
||||
// closed node's archive — deterministic content, so a crash between a
|
||||
// close and its sweep self-heals.
|
||||
this.cachedMessages = undefined;
|
||||
this.cachedState = undefined;
|
||||
this.baselines.clear();
|
||||
this.finals.clear();
|
||||
this.archivedIds.clear();
|
||||
this.failedArchiveIds.clear();
|
||||
}),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('context.spliced', (event) => {
|
||||
if (!this.enabled) return;
|
||||
if (event.deleteCount === 0) return;
|
||||
// The stored history shrank beneath the tree (undo / clear / full
|
||||
// replacement): re-anchor the observation cursor to the cut and clamp
|
||||
// dangling spans and the epoch boundary into the surviving range, so
|
||||
// post-truncation messages are never folded against stale indices.
|
||||
const cut = event.start;
|
||||
this.lastObservedIndex = Math.min(this.lastObservedIndex, cut);
|
||||
// Invariant (today): deleteCount > 0 splices are tail truncations
|
||||
// (undo) or full clears/replacements at 0, so `start` equals the
|
||||
// surviving prefix length. A future mid-history deletion would shift
|
||||
// later messages down and needs index translation, not clamping —
|
||||
// skip the repair rather than void spans that actually survive.
|
||||
if (this.context.get().length !== cut) return;
|
||||
const state = this.state();
|
||||
if (this.needsTruncateRepair(state, cut)) {
|
||||
this.wire.dispatch(spineTruncateRepair({ cut }));
|
||||
}
|
||||
// A truncation (undo / clear) can make the derivation reuse a node id
|
||||
// for a DIFFERENT span: a cleared tree restarts numbering at 1.1.1, and
|
||||
// an undo that removes a close lets the same node close again with a new
|
||||
// span. The archive ledger is keyed by id, so left alone it would skip
|
||||
// the reused id and keep publishing the OLD node's archive path. Clear
|
||||
// it so the next sweep rewrites surviving archives (deterministic
|
||||
// content — a harmless no-op for unaffected nodes) and archives reused
|
||||
// ids fresh.
|
||||
this.archivedIds.clear();
|
||||
this.failedArchiveIds.clear();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private needsTruncateRepair(state: SpineState, cut: number): boolean {
|
||||
if (state.epochStartAt > cut) return true;
|
||||
if (state.epochMemoryAt !== undefined && state.epochMemoryAt >= cut) return true;
|
||||
return Object.values(state.nodes).some(
|
||||
(node) =>
|
||||
node.openedAt >= 0 &&
|
||||
(node.openedAt >= cut || (node.closedAt !== undefined && node.closedAt >= cut)),
|
||||
);
|
||||
}
|
||||
|
||||
get enabled(): boolean {
|
||||
return this.flags.enabled(SPINE_FLAG_ID);
|
||||
}
|
||||
|
||||
acceptOpen(summary: string, toolCallId: string): SpineTransitionResult {
|
||||
acceptOpen(summary: string): SpineTransitionResult {
|
||||
const guard = this.guard();
|
||||
if (guard !== null) return guard;
|
||||
const trimmed = summary.trim();
|
||||
if (trimmed.length === 0) return reject('open summary must not be empty.');
|
||||
this.pending = { kind: 'open', toolCallId, summary: trimmed, stepSignal: this.stepSignal };
|
||||
const state = this.derivedState();
|
||||
const parentId = topOf(state);
|
||||
const parent = state.nodes[parentId];
|
||||
if (parent !== undefined) {
|
||||
this.baselines.set(
|
||||
childNodeId(parentId, nextChildIndex(parent.children)),
|
||||
this.contextSize.get().size,
|
||||
);
|
||||
}
|
||||
this.transitionThisStep = true;
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
acceptClose(memory: string, toolCallId: string): SpineTransitionResult {
|
||||
acceptClose(memory: string): SpineTransitionResult {
|
||||
const guard = this.guard();
|
||||
if (guard !== null) return guard;
|
||||
const trimmed = memory.trim();
|
||||
if (trimmed.length === 0) return reject('close memory must not be empty.');
|
||||
if (isRootEpoch(this.cursorId())) return REJECT_ROOT_EPOCH;
|
||||
this.pending = { kind: 'close', toolCallId, memory: trimmed, stepSignal: this.stepSignal };
|
||||
const cursorId = this.cursorId();
|
||||
if (isRootEpoch(cursorId)) return REJECT_ROOT_EPOCH;
|
||||
this.finals.set(cursorId, this.contextSize.get().size);
|
||||
this.transitionThisStep = true;
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
acceptNext(summary: string, memory: string, toolCallId: string): SpineTransitionResult {
|
||||
acceptNext(summary: string, memory: string): SpineTransitionResult {
|
||||
const guard = this.guard();
|
||||
if (guard !== null) return guard;
|
||||
const trimmedSummary = summary.trim();
|
||||
const trimmedMemory = memory.trim();
|
||||
if (trimmedSummary.length === 0) return reject('next summary must not be empty.');
|
||||
if (trimmedMemory.length === 0) return reject('next memory must not be empty.');
|
||||
if (isRootEpoch(this.cursorId())) return REJECT_ROOT_EPOCH;
|
||||
this.pending = {
|
||||
kind: 'next',
|
||||
toolCallId,
|
||||
summary: trimmedSummary,
|
||||
memory: trimmedMemory,
|
||||
stepSignal: this.stepSignal,
|
||||
};
|
||||
const cursorId = this.cursorId();
|
||||
if (isRootEpoch(cursorId)) return REJECT_ROOT_EPOCH;
|
||||
const state = this.derivedState();
|
||||
const parentId = parentNodeId(cursorId);
|
||||
const parent = parentId === null ? undefined : state.nodes[parentId];
|
||||
const sizeNow = this.contextSize.get().size;
|
||||
this.finals.set(cursorId, sizeNow);
|
||||
if (parentId !== null && parent !== undefined) {
|
||||
this.baselines.set(childNodeId(parentId, nextChildIndex(parent.children)), sizeNow);
|
||||
}
|
||||
this.transitionThisStep = true;
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
|
|
@ -319,11 +308,16 @@ export class AgentSpineService extends Disposable implements IAgentSpineService
|
|||
return foldSpine(messages, { state, status: this.buildStatus(), epochSummaryMessage });
|
||||
}
|
||||
|
||||
currentState(): SpineState {
|
||||
return this.state();
|
||||
}
|
||||
|
||||
private buildStatus(): SpineFoldStatus {
|
||||
const state = this.state();
|
||||
const cursorId = topOf(state);
|
||||
const cursor = state.nodes[cursorId];
|
||||
const summary = cursor?.summary ?? '';
|
||||
const summary = state.nodes[cursorId]?.summary ?? '';
|
||||
const parentId = parentNodeId(cursorId);
|
||||
const parentSummary = parentId === null ? null : (state.nodes[parentId]?.summary ?? null);
|
||||
const maxContextTokens = this.profile.getEffectiveMaxContextTokens();
|
||||
const used = this.contextSize.get().size;
|
||||
const contextLeft =
|
||||
|
|
@ -333,13 +327,14 @@ export class AgentSpineService extends Disposable implements IAgentSpineService
|
|||
return {
|
||||
cursorId,
|
||||
summary,
|
||||
parentId: parentNodeId(cursorId),
|
||||
parentId,
|
||||
parentSummary,
|
||||
// Projected-growth caliber: the live gauge minus the node's open
|
||||
// baseline. The stored-range reading this replaces counted folded-away
|
||||
// child/sibling spans the model no longer sees, drifting the budget
|
||||
// signal apart from the compaction trigger's caliber; a node whose
|
||||
// folds reclaimed more than it added reads as zero.
|
||||
cursorContext: Math.max(0, used - (cursor?.baselineTokens ?? 0)),
|
||||
cursorContext: Math.max(0, used - (this.baselines.get(cursorId) ?? 0)),
|
||||
contextLeft,
|
||||
rawContext: estimateTokensForMessages(this.context.get()),
|
||||
projectedContext: used,
|
||||
|
|
@ -347,198 +342,142 @@ export class AgentSpineService extends Disposable implements IAgentSpineService
|
|||
};
|
||||
}
|
||||
|
||||
private reportLostCommits(): void {
|
||||
const receipts = countAcceptedReceipts(this.context.get());
|
||||
const committed = countCommittedOps(this.wireRecord.getRecords());
|
||||
const lost: string[] = [];
|
||||
for (const kind of SPINE_TRANSITION_KINDS) {
|
||||
if (committed[kind] < receipts[kind]) {
|
||||
lost.push(
|
||||
`${SPINE_TOOL_NAME[kind]}: ${String(receipts[kind])} accepted receipt(s) vs ${String(committed[kind])} ${SPINE_OP_TYPE[kind]} op(s)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (lost.length === 0) return;
|
||||
onUnexpectedError(
|
||||
new Error(
|
||||
`Spine: lost transition(s) detected on restore — ${lost.join('; ')}. ` +
|
||||
'A receipt was persisted but the matching tree op was not; the tree may be missing nodes. No automatic repair is attempted.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private guard(): SpineTransitionResult | null {
|
||||
if (!this.enabled) return REJECT_DISABLED;
|
||||
if (this.pending !== null) return REJECT_CONFLICT;
|
||||
if (this.transitionThisStep) return REJECT_CONFLICT;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The projection-facing state: the derivation plus the archive-failure note
|
||||
* patched into the affected nodes' memory, so the model learns from the next
|
||||
* projection on that the detailed trajectory was not persisted.
|
||||
*/
|
||||
private state(): SpineState {
|
||||
return this.wire.getModel(SpineModel);
|
||||
const derived = this.derivedState();
|
||||
if (this.failedArchiveIds.size === 0) return derived;
|
||||
let nodes: Record<string, SpineNode> | undefined;
|
||||
for (const id of this.failedArchiveIds) {
|
||||
const node = derived.nodes[id];
|
||||
if (node?.memory === undefined) continue;
|
||||
nodes ??= { ...derived.nodes };
|
||||
nodes[id] = { ...node, memory: `${node.memory}\n\n${ARCHIVE_FAILURE_NOTE}` };
|
||||
}
|
||||
return nodes === undefined ? derived : { ...derived, nodes };
|
||||
}
|
||||
|
||||
private derivedState(): SpineState {
|
||||
const messages = this.context.get();
|
||||
// The wire hands back the same array reference until an op mutates it, so
|
||||
// a reference hit means the derivation is still valid.
|
||||
if (this.cachedState !== undefined && this.cachedMessages === messages) {
|
||||
return this.cachedState;
|
||||
}
|
||||
const state = deriveSpineState(messages);
|
||||
this.cachedMessages = messages;
|
||||
this.cachedState = state;
|
||||
return state;
|
||||
}
|
||||
|
||||
private cursorId(): string {
|
||||
const stack = this.state().openStack;
|
||||
const top = stack.at(-1);
|
||||
if (top === undefined) {
|
||||
throw new Error('Spine openStack is empty; the tree must always contain a root epoch.');
|
||||
}
|
||||
return top;
|
||||
return topOf(this.derivedState());
|
||||
}
|
||||
|
||||
private nodeView(state: SpineState, id: string, used: number): SpineTreeNodeView | undefined {
|
||||
const node = state.nodes[id];
|
||||
if (node === undefined) return undefined;
|
||||
const supersededEpoch = isRootEpoch(id) && id !== String(state.rootEpoch);
|
||||
const epoch = isRootEpoch(id);
|
||||
const supersededEpoch = epoch && id !== String(state.rootEpoch);
|
||||
const closed = node.closedAt !== undefined || supersededEpoch;
|
||||
return {
|
||||
id: node.id,
|
||||
summary: node.summary,
|
||||
closed: node.closedAt !== undefined || supersededEpoch,
|
||||
archivePath: node.archivePath,
|
||||
tokenCost: nodeTokenCost(node, used),
|
||||
closed,
|
||||
archivePath: this.nodeArchivePath(id, epoch, closed),
|
||||
tokenCost: nodeTokenCost(node, used, this.baselines, this.finals),
|
||||
children: node.children
|
||||
.map((childId) => this.nodeView(state, childId, used))
|
||||
.filter((child): child is SpineTreeNodeView => child !== undefined),
|
||||
};
|
||||
}
|
||||
|
||||
private async commitPending(): Promise<void> {
|
||||
// Archive paths are deterministic, so the tree publishes them without
|
||||
// persisting anything; a write that failed (this session) suppresses the
|
||||
// path instead of pointing at a missing file. The epoch archive written
|
||||
// when epoch N began (holding the prior epochs' folded history) is named
|
||||
// for N, so epoch 1 predates all archiving and shows none.
|
||||
private nodeArchivePath(id: string, epoch: boolean, closed: boolean): string | undefined {
|
||||
if (this.failedArchiveIds.has(id)) return undefined;
|
||||
if (epoch) return Number(id) > 1 ? this.archivePath(id) : undefined;
|
||||
return closed ? this.archivePath(id) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Projection-delta archiving: every closed node the derivation reports and
|
||||
* the ledger has not archived yet gets its trajectory written. Runs at step
|
||||
* end (and effectively on the first step end after a restore, since the
|
||||
* ledger starts empty), so a close and its archive are at most one step
|
||||
* apart and a lost write self-heals on the next session.
|
||||
*/
|
||||
private async archiveNewlyClosed(): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
const pending = this.pending;
|
||||
if (pending === null) return;
|
||||
|
||||
const history = this.context.get();
|
||||
// Undo / clear may have shrunk the history below the last observation;
|
||||
// clamp the search start so a legitimate post-truncation transition is
|
||||
// not dropped as evidence-less.
|
||||
const evidence = findEvidence(
|
||||
history,
|
||||
Math.min(this.lastObservedIndex, history.length),
|
||||
pending.toolCallId,
|
||||
);
|
||||
this.lastObservedIndex = history.length;
|
||||
if (evidence === null) {
|
||||
this.dropPending(pending, 'the step ended without tool-result evidence');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (pending.kind) {
|
||||
case 'open':
|
||||
this.commitOpen(pending.summary, evidence.assistantIndex);
|
||||
break;
|
||||
case 'close':
|
||||
await this.commitClose(pending.memory, evidence.assistantIndex);
|
||||
break;
|
||||
case 'next':
|
||||
await this.commitNext(pending.summary, pending.memory, evidence.assistantIndex);
|
||||
break;
|
||||
const state = this.derivedState();
|
||||
const messages = this.context.get();
|
||||
for (const node of Object.values(state.nodes)) {
|
||||
if (node.closedAt === undefined || node.openedAt < 0) continue;
|
||||
if (this.archivedIds.has(node.id) || this.failedArchiveIds.has(node.id)) continue;
|
||||
const path = this.archivePath(node.id);
|
||||
const span = messages.slice(Math.max(0, node.openedAt), node.closedAt + 1);
|
||||
const content = buildArchiveContent({ node, messages: span });
|
||||
try {
|
||||
await writeNodeArchive(this.hostFs, path, content);
|
||||
this.archivedIds.add(node.id);
|
||||
} catch (error) {
|
||||
onUnexpectedError(error);
|
||||
this.failedArchiveIds.add(node.id);
|
||||
}
|
||||
} catch (error) {
|
||||
onUnexpectedError(
|
||||
new Error(
|
||||
`Spine: failed to commit ${pending.kind} transition (toolCallId ${pending.toolCallId}); dropping the transition.`,
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
this.pending = null;
|
||||
}
|
||||
await this.archiveCurrentEpochBoundary(state, messages);
|
||||
}
|
||||
|
||||
private dropPending(pending: SpinePending, why: string): void {
|
||||
this.pending = null;
|
||||
if (pending.stepSignal?.aborted === true) return;
|
||||
onUnexpectedError(
|
||||
new Error(
|
||||
`Spine: dropping ${pending.kind} transition (toolCallId ${pending.toolCallId}) — ${why}. The accepted receipt stays in the transcript.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private commitOpen(summary: string, openedAt: number): void {
|
||||
const state = this.state();
|
||||
const parentId = topOf(state);
|
||||
const parent = state.nodes[parentId];
|
||||
if (parent === undefined) return;
|
||||
const id = childNodeId(parentId, nextChildIndex(parent.children));
|
||||
this.wire.dispatch(
|
||||
spineOpen({
|
||||
id,
|
||||
summary,
|
||||
parentId,
|
||||
openedAt,
|
||||
baselineTokens: this.contextSize.get().size,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async commitClose(memory: string, assistantIndex: number): Promise<void> {
|
||||
const state = this.state();
|
||||
const id = topOf(state);
|
||||
const node = state.nodes[id];
|
||||
if (node === undefined || isRootEpoch(id)) return;
|
||||
// The span ends BEFORE the assistant message carrying the transition
|
||||
// call: the carrier, its instant receipt, and any slower tool results
|
||||
// batched in the same response stay visible and paired in the parent
|
||||
// context. (Closing at the receipt index would fold the carrier away and
|
||||
// orphan the late results — the receipt always lands first.) The max()
|
||||
// guards a span start that truncation repair moved past the carrier's
|
||||
// predecessor.
|
||||
const closedAt = Math.max(assistantIndex - 1, node.openedAt);
|
||||
const assembled = assembleMemoryBody({
|
||||
userRequests: collectSpanUserRequests(this.context.get(), node.openedAt, closedAt),
|
||||
childMemories: closedChildMemories(state, node),
|
||||
nodeMemory: memory,
|
||||
/**
|
||||
* The current epoch's boundary archive is written by the full-compaction
|
||||
* flow when the epoch begins, but that write is a side effect the ledger
|
||||
* does not retry: a transient failure (or a crash mid-write) leaves the file
|
||||
* missing, and a later restore clears the failure ledger so the tree
|
||||
* publishes the path again — pointing at a file that was never written.
|
||||
* Reconstruct it here from the derived boundary (the summary message and the
|
||||
* pre-boundary history are both in the surviving stream) so the published
|
||||
* path always names a real file. Only the CURRENT epoch is reconstructible —
|
||||
* the derived state carries its boundary, not older epochs', whose archives
|
||||
* their own compactions already wrote.
|
||||
*/
|
||||
private async archiveCurrentEpochBoundary(
|
||||
state: SpineState,
|
||||
messages: readonly ContextMessage[],
|
||||
): Promise<void> {
|
||||
const epoch = state.rootEpoch;
|
||||
if (epoch <= 1) return;
|
||||
const id = String(epoch);
|
||||
if (this.archivedIds.has(id) || this.failedArchiveIds.has(id)) return;
|
||||
const memoryAt = state.epochMemoryAt;
|
||||
if (memoryAt === undefined) return;
|
||||
const summaryMessage = messages[memoryAt];
|
||||
if (summaryMessage === undefined) return;
|
||||
const content = buildEpochArchiveContent({
|
||||
epoch,
|
||||
epochStartAt: state.epochStartAt,
|
||||
epochMemoryAt: memoryAt,
|
||||
summary: stripCompactionSummaryPrefix(messageText(summaryMessage)),
|
||||
messages: messages.slice(0, memoryAt),
|
||||
});
|
||||
const closing: SpineNode = { ...node, closedAt, memory: assembled };
|
||||
const archivePath = await this.archiveNode(closing);
|
||||
this.wire.dispatch(
|
||||
spineClose({
|
||||
id,
|
||||
closedAt,
|
||||
memory: markArchiveFailure(assembled, archivePath),
|
||||
archivePath,
|
||||
finalTokens: this.contextSize.get().size,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async commitNext(summary: string, memory: string, assistantIndex: number): Promise<void> {
|
||||
const state = this.state();
|
||||
const closedId = topOf(state);
|
||||
const closing = state.nodes[closedId];
|
||||
if (closing === undefined || isRootEpoch(closedId)) return;
|
||||
const parentId = parentNodeId(closedId);
|
||||
if (parentId === null) return;
|
||||
const parent = state.nodes[parentId];
|
||||
if (parent === undefined) return;
|
||||
const openedId = childNodeId(parentId, nextChildIndex(parent.children));
|
||||
// Same boundary rule as commitClose: the span ends before the transition
|
||||
// carrier, and the reducer opens the new sibling right after (at the
|
||||
// carrier's index), so the carrier and its receipt ride inside the new
|
||||
// sibling's span.
|
||||
const closedAt = Math.max(assistantIndex - 1, closing.openedAt);
|
||||
const assembled = assembleMemoryBody({
|
||||
userRequests: collectSpanUserRequests(this.context.get(), closing.openedAt, closedAt),
|
||||
childMemories: closedChildMemories(state, closing),
|
||||
nodeMemory: memory,
|
||||
});
|
||||
const closed: SpineNode = { ...closing, closedAt, memory: assembled };
|
||||
const archivePath = await this.archiveNode(closed);
|
||||
const sizeNow = this.contextSize.get().size;
|
||||
this.wire.dispatch(
|
||||
spineNext({
|
||||
closedId,
|
||||
closedAt,
|
||||
memory: markArchiveFailure(assembled, archivePath),
|
||||
archivePath,
|
||||
finalTokens: sizeNow,
|
||||
openedId,
|
||||
summary,
|
||||
baselineTokens: sizeNow,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await writeNodeArchive(this.hostFs, this.archivePath(id), content);
|
||||
this.archivedIds.add(id);
|
||||
} catch (error) {
|
||||
onUnexpectedError(error);
|
||||
this.failedArchiveIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
async archiveEpochRoot(input: SpineEpochArchiveInput): Promise<string | undefined> {
|
||||
|
|
@ -547,24 +486,11 @@ export class AgentSpineService extends Disposable implements IAgentSpineService
|
|||
const content = buildEpochArchiveContent(input);
|
||||
try {
|
||||
await writeNodeArchive(this.hostFs, path, content);
|
||||
this.archivedIds.add(String(input.epoch));
|
||||
return path;
|
||||
} catch (error) {
|
||||
onUnexpectedError(error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async archiveNode(node: SpineNode): Promise<string | undefined> {
|
||||
const path = this.archivePath(node.id);
|
||||
const openedAt = Math.max(0, node.openedAt);
|
||||
const closedAt = node.closedAt ?? node.openedAt;
|
||||
const messages = this.context.get().slice(openedAt, closedAt + 1);
|
||||
const content = buildArchiveContent({ node, messages });
|
||||
try {
|
||||
await writeNodeArchive(this.hostFs, path, content);
|
||||
return path;
|
||||
} catch (error) {
|
||||
onUnexpectedError(error);
|
||||
this.failedArchiveIds.add(String(input.epoch));
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -586,7 +512,7 @@ export class AgentSpineService extends Disposable implements IAgentSpineService
|
|||
function epochRootIds(state: SpineState): readonly string[] {
|
||||
return Object.keys(state.nodes)
|
||||
.filter((id) => isRootEpoch(id))
|
||||
.sort((a, b) => Number(a) - Number(b));
|
||||
.toSorted((a, b) => Number(a) - Number(b));
|
||||
}
|
||||
|
||||
function topOf(state: SpineState): string {
|
||||
|
|
@ -602,141 +528,32 @@ function topOf(state: SpineState): string {
|
|||
// gauge while still open). Folds committed inside the node can make the net
|
||||
// negative — the tree view only needs the "no lasting cost" signal there, so
|
||||
// it clamps at zero. Nodes without a recorded baseline (root epochs, startup
|
||||
// nodes, records predating the baseline) render no cost.
|
||||
function nodeTokenCost(node: SpineNode, currentUsed: number): number | undefined {
|
||||
const baseline = node.baselineTokens;
|
||||
// nodes, sessions restored before the gauges were recorded) render no cost.
|
||||
function nodeTokenCost(
|
||||
node: SpineNode,
|
||||
currentUsed: number,
|
||||
baselines: ReadonlyMap<string, number>,
|
||||
finals: ReadonlyMap<string, number>,
|
||||
): number | undefined {
|
||||
const baseline = baselines.get(node.id);
|
||||
if (baseline === undefined) return undefined;
|
||||
const end = node.closedAt === undefined ? currentUsed : node.finalTokens;
|
||||
const end = node.closedAt === undefined ? currentUsed : finals.get(node.id);
|
||||
if (end === undefined) return undefined;
|
||||
return Math.max(0, end - baseline);
|
||||
}
|
||||
|
||||
function closedChildMemories(state: SpineState, node: SpineNode): readonly string[] {
|
||||
const bodies: string[] = [];
|
||||
for (const childId of node.children) {
|
||||
const child = state.nodes[childId];
|
||||
if (child !== undefined && child.closedAt !== undefined && child.memory !== undefined) {
|
||||
bodies.push(child.memory);
|
||||
}
|
||||
}
|
||||
return bodies;
|
||||
}
|
||||
|
||||
interface SpineEvidence {
|
||||
readonly assistantIndex: number;
|
||||
readonly toolResultIndex: number;
|
||||
}
|
||||
|
||||
function findEvidence(
|
||||
history: readonly ContextMessage[],
|
||||
from: number,
|
||||
toolCallId: string,
|
||||
): SpineEvidence | null {
|
||||
let assistantIndex = -1;
|
||||
for (let i = from; i < history.length; i++) {
|
||||
const message = history[i];
|
||||
if (message === undefined) continue;
|
||||
if (
|
||||
assistantIndex < 0 &&
|
||||
message.role === 'assistant' &&
|
||||
message.toolCalls.some((call) => call.id === toolCallId)
|
||||
) {
|
||||
assistantIndex = i;
|
||||
}
|
||||
if (message.role === 'tool' && message.toolCallId === toolCallId) {
|
||||
return assistantIndex < 0 ? null : { assistantIndex, toolResultIndex: i };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type SpineTransitionKind = 'open' | 'close' | 'next';
|
||||
|
||||
const SPINE_TRANSITION_KINDS: readonly SpineTransitionKind[] = ['open', 'close', 'next'];
|
||||
|
||||
const SPINE_TOOL_NAME: Record<SpineTransitionKind, string> = {
|
||||
open: SPINE_TOOL_OPEN,
|
||||
close: SPINE_TOOL_CLOSE,
|
||||
next: SPINE_TOOL_NEXT,
|
||||
};
|
||||
|
||||
const SPINE_OP_TYPE: Record<SpineTransitionKind, string> = {
|
||||
open: 'spine.open',
|
||||
close: 'spine.close',
|
||||
next: 'spine.next',
|
||||
};
|
||||
|
||||
const LEGACY_ACCEPTED_RECEIPT = 'accepted';
|
||||
|
||||
const ARCHIVE_FAILURE_NOTE =
|
||||
'[spine: the trajectory archive for this node could not be written; its detailed history was not persisted.]';
|
||||
|
||||
function markArchiveFailure(memory: string, archivePath: string | undefined): string {
|
||||
return archivePath === undefined ? `${memory}\n\n${ARCHIVE_FAILURE_NOTE}` : memory;
|
||||
}
|
||||
|
||||
function countAcceptedReceipts(
|
||||
messages: readonly ContextMessage[],
|
||||
): Record<SpineTransitionKind, number> {
|
||||
const counts: Record<SpineTransitionKind, number> = { open: 0, close: 0, next: 0 };
|
||||
const kindsByCallId = new Map<string, SpineTransitionKind>();
|
||||
for (const message of messages) {
|
||||
if (message.role !== 'assistant') continue;
|
||||
for (const call of message.toolCalls) {
|
||||
const kind = kindOfToolName(call.name);
|
||||
if (kind !== undefined) kindsByCallId.set(call.id, kind);
|
||||
}
|
||||
}
|
||||
for (const message of messages) {
|
||||
if (message.role !== 'tool' || message.toolCallId === undefined || message.isError === true) {
|
||||
continue;
|
||||
}
|
||||
const kind = kindsByCallId.get(message.toolCallId);
|
||||
if (kind === undefined) continue;
|
||||
const text = toolMessageText(message);
|
||||
if (text !== ACCEPTED_OUTPUT && text !== LEGACY_ACCEPTED_RECEIPT) continue;
|
||||
counts[kind]++;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function countCommittedOps(
|
||||
records: readonly PersistedWireRecord[],
|
||||
): Record<SpineTransitionKind, number> {
|
||||
const counts: Record<SpineTransitionKind, number> = { open: 0, close: 0, next: 0 };
|
||||
for (const record of records) {
|
||||
switch (record.type) {
|
||||
case 'spine.open':
|
||||
counts.open++;
|
||||
break;
|
||||
case 'spine.close':
|
||||
counts.close++;
|
||||
break;
|
||||
case 'spine.next':
|
||||
counts.next++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function kindOfToolName(name: string): SpineTransitionKind | undefined {
|
||||
switch (name) {
|
||||
case SPINE_TOOL_OPEN:
|
||||
return 'open';
|
||||
case SPINE_TOOL_CLOSE:
|
||||
return 'close';
|
||||
case SPINE_TOOL_NEXT:
|
||||
return 'next';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function toolMessageText(message: ContextMessage): string {
|
||||
function messageText(message: ContextMessage): string {
|
||||
return message.content.map((part) => (part.type === 'text' ? part.text : '')).join('');
|
||||
}
|
||||
|
||||
// The boundary archive stores the raw summary; the summary message carries it
|
||||
// under the compaction prefix, so strip the carrier (and its separating
|
||||
// newline) when reconstructing the archive from the stream.
|
||||
function stripCompactionSummaryPrefix(text: string): string {
|
||||
if (!text.startsWith(COMPACTION_SUMMARY_PREFIX)) return text;
|
||||
return text.slice(COMPACTION_SUMMARY_PREFIX.length).replace(/^\n+/, '');
|
||||
}
|
||||
|
||||
function reject(reason: string): SpineTransitionResult {
|
||||
return { accepted: false, reason };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,10 +13,13 @@
|
|||
* closable) and work nodes whose closed span a truncation repair voided. The
|
||||
* startup node does NOT use it — it opens at the real epoch boundary and
|
||||
* closes like any other work node. Holds no state and performs no IO;
|
||||
* consumed by `spineOps` (reducers), `spineService` (commit orchestration)
|
||||
* and `spineFold` (projection).
|
||||
* consumed by `spineOps` (reducers), `spineDerive` (message-stream
|
||||
* derivation), `spineService` (commit orchestration) and `spineFold`
|
||||
* (projection).
|
||||
*/
|
||||
|
||||
import type { SpineNode } from './spineOps';
|
||||
|
||||
export const SPINE_VOID_OPENED_AT = -1;
|
||||
|
||||
export function nodeDepth(id: string): number {
|
||||
|
|
@ -71,6 +74,26 @@ export function assembleMemoryBody(input: SpineMemoryAssemblyInput): string {
|
|||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Assembled memory bodies of a node's already-closed children, in child
|
||||
* order — the `## Child Memory` section input for the parent's own assembly.
|
||||
* Children close before their parent by construction (a close pops the
|
||||
* cursor), so every child is closed when the parent's memory assembles.
|
||||
*/
|
||||
export function closedChildMemories(
|
||||
nodes: Readonly<Record<string, SpineNode>>,
|
||||
node: SpineNode,
|
||||
): readonly string[] {
|
||||
const bodies: string[] = [];
|
||||
for (const childId of node.children) {
|
||||
const child = nodes[childId];
|
||||
if (child !== undefined && child.closedAt !== undefined && child.memory !== undefined) {
|
||||
bodies.push(child.memory);
|
||||
}
|
||||
}
|
||||
return bodies;
|
||||
}
|
||||
|
||||
export interface SpineTreeNodeView {
|
||||
readonly id: string;
|
||||
readonly summary: string;
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export class SpineCloseTool implements BuiltinTool<SpineCloseInput> {
|
|||
return {
|
||||
approvalRule: this.name,
|
||||
description: 'Close the current Spine node',
|
||||
execute: async (ctx) => toControlResult(this.spine.acceptClose(input.memory, ctx.toolCallId)),
|
||||
execute: async (_ctx) => toControlResult(this.spine.acceptClose(input.memory)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,8 +49,8 @@ export class SpineNextTool implements BuiltinTool<SpineNextInput> {
|
|||
return {
|
||||
approvalRule: this.name,
|
||||
description: 'Finish this node and open the next sibling',
|
||||
execute: async (ctx) =>
|
||||
toControlResult(this.spine.acceptNext(input.summary, input.memory, ctx.toolCallId)),
|
||||
execute: async (_ctx) =>
|
||||
toControlResult(this.spine.acceptNext(input.summary, input.memory)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export class SpineOpenTool implements BuiltinTool<SpineOpenInput> {
|
|||
return {
|
||||
approvalRule: this.name,
|
||||
description: 'Open a Spine child node',
|
||||
execute: async (ctx) => toControlResult(this.spine.acceptOpen(input.summary, ctx.toolCallId)),
|
||||
execute: async (_ctx) => toControlResult(this.spine.acceptOpen(input.summary)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ export class SessionLegacyService implements ISessionLegacyService {
|
|||
plan_mode: planData !== null,
|
||||
swarm_mode: swarm.isActive,
|
||||
context_tokens: tokens,
|
||||
raw_context_tokens: contextSize.rawSize(),
|
||||
max_context_tokens: maxTokens,
|
||||
context_usage: maxTokens > 0 ? tokens / maxTokens : 0,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -51,13 +51,37 @@ import {
|
|||
} from '#/index';
|
||||
import { IAgentLoopService } from '#/agent/loop/loop';
|
||||
import { IAgentContextSizeService } from '#/agent/contextSize/contextSize';
|
||||
import { spineClose, spineOpen } from '#/agent/spine/spineOps';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import { ACCEPTED_OUTPUT } from '#/agent/spine/tools/controlResult';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import { IAgentGoalService } from '#/agent/goal/goal';
|
||||
import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
|
||||
|
||||
type GenerateFn = NonNullable<TestAgentOptions['generate']>;
|
||||
|
||||
// Appends a real spine transition — the carrier assistant call and its
|
||||
// accepted receipt — so the derivation rebuilds the node from the messages.
|
||||
function spineTransition(
|
||||
ctx: TestAgentContext,
|
||||
id: string,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
): void {
|
||||
const carrier: ContextMessage = {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: `calling ${name}` }],
|
||||
toolCalls: [{ type: 'function', id, name, arguments: JSON.stringify(args) }],
|
||||
};
|
||||
const receipt: ContextMessage = {
|
||||
role: 'tool',
|
||||
content: [{ type: 'text', text: ACCEPTED_OUTPUT }],
|
||||
toolCalls: [],
|
||||
toolCallId: id,
|
||||
};
|
||||
ctx.context.append(carrier);
|
||||
ctx.context.append(receipt);
|
||||
}
|
||||
|
||||
const CATALOGUED_PROVIDER = {
|
||||
type: 'kimi',
|
||||
apiKey: 'test-key',
|
||||
|
|
@ -2921,17 +2945,29 @@ describe('FullCompaction', () => {
|
|||
provider: CATALOGUED_PROVIDER,
|
||||
modelCapabilities: { ...CATALOGUED_MODEL_CAPABILITIES, max_context_tokens: window },
|
||||
});
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// A real closed node: the open carrier rides in first, and the close
|
||||
// carrier lands before the last two exchanges so the undo below leaves
|
||||
// it (and the node's closed span) intact. The user prompts stay tiny so
|
||||
// the assembled memory (which cites the span's requests) stays small and
|
||||
// the folded view stays far below the raw history the assistants inflate.
|
||||
spineTransition(ctx, 's_open', 'spine_open', { summary: 'seed node' });
|
||||
for (let i = 0; i < 8; i++) {
|
||||
ctx.appendExchange(
|
||||
i + 1,
|
||||
`u${String(i)} ${'x'.repeat(2000)}`,
|
||||
`u${String(i)}`,
|
||||
`a${String(i)} ${'y'.repeat(2000)}`,
|
||||
1000,
|
||||
);
|
||||
}
|
||||
spineTransition(ctx, 's_close', 'spine_close', { memory: 'seed memory' });
|
||||
for (let i = 8; i < 10; i++) {
|
||||
ctx.appendExchange(
|
||||
i + 1,
|
||||
`u${String(i)}`,
|
||||
`a${String(i)} ${'y'.repeat(2000)}`,
|
||||
1000,
|
||||
);
|
||||
}
|
||||
const wire = ctx.get(IAgentWireService);
|
||||
wire.dispatch(spineOpen({ id: '1.1.1', parentId: '1.1', summary: 'seed node', openedAt: 0 }));
|
||||
wire.dispatch(spineClose({ id: '1.1.1', closedAt: 15, memory: 'seed memory' }));
|
||||
|
||||
await ctx.rpc.undoHistory({ count: 1 });
|
||||
|
||||
|
|
@ -2969,7 +3005,17 @@ describe('FullCompaction', () => {
|
|||
});
|
||||
// ~140k estimated raw tokens — above 50% of the 256k window — while the
|
||||
// folded view (all but the last two exchanges closed) stays tiny.
|
||||
for (let i = 0; i < 70; i++) {
|
||||
spineTransition(ctx, 's_open', 'spine_open', { summary: 'seed node' });
|
||||
for (let i = 0; i < 68; i++) {
|
||||
ctx.appendExchange(
|
||||
i + 1,
|
||||
`u${String(i)} ${'x'.repeat(4000)}`,
|
||||
`a${String(i)} ${'y'.repeat(4000)}`,
|
||||
1000,
|
||||
);
|
||||
}
|
||||
spineTransition(ctx, 's_close', 'spine_close', { memory: 'seed memory' });
|
||||
for (let i = 68; i < 70; i++) {
|
||||
ctx.appendExchange(
|
||||
i + 1,
|
||||
`u${String(i)} ${'x'.repeat(4000)}`,
|
||||
|
|
@ -2977,9 +3023,6 @@ describe('FullCompaction', () => {
|
|||
1000,
|
||||
);
|
||||
}
|
||||
const wire = ctx.get(IAgentWireService);
|
||||
wire.dispatch(spineOpen({ id: '1.1.1', parentId: '1.1', summary: 'seed node', openedAt: 0 }));
|
||||
wire.dispatch(spineClose({ id: '1.1.1', closedAt: 135, memory: 'seed memory' }));
|
||||
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'go' }] });
|
||||
const events = await ctx.untilTurnEnd();
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ describe('Session legacy status (best-effort runtime state)', () => {
|
|||
kind: LifecycleScope.Agent,
|
||||
accessor: accessor([
|
||||
[IAgentProfileService, profile],
|
||||
[IAgentContextSizeService, { get: () => ({ size: 25, measured: 20, estimated: 5 }) }],
|
||||
[IAgentContextSizeService, { get: () => ({ size: 25, measured: 20, estimated: 5 }), rawSize: () => 30 }],
|
||||
[IAgentPermissionModeService, { mode: 'manual' }],
|
||||
[IAgentPlanService, { status: () => Promise.resolve(null) }],
|
||||
[IAgentSwarmService, { isActive: false }],
|
||||
|
|
|
|||
|
|
@ -5,9 +5,6 @@ import {
|
|||
AGENT_WIRE_PROTOCOL_VERSION,
|
||||
IAgentSpineService,
|
||||
IAgentWireService,
|
||||
SpineModel,
|
||||
spineClose,
|
||||
spineOpen,
|
||||
type PersistedWireRecord,
|
||||
} from '#/index';
|
||||
|
||||
|
|
@ -56,19 +53,21 @@ describe('Spine archive + resume', () => {
|
|||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'start' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
|
||||
const node = readSpine(ctx).nodes['1.1.1'];
|
||||
expect(node?.archivePath).toBeDefined();
|
||||
const archivePath = node?.archivePath as string;
|
||||
expect(readSpine(ctx).nodes['1.1.1']?.closedAt).toBeDefined();
|
||||
// Archives live under the per-agent session homedir, not the project
|
||||
// workDir: `<sessionDir>/agents/<id>/spine/<node-id>.md`.
|
||||
expect(archivePath.endsWith('/agents/main/spine/1-1-1.md')).toBe(true);
|
||||
expect(writes.has(archivePath)).toBe(true);
|
||||
const content = writes.get(archivePath) ?? '';
|
||||
// workDir: `<sessionDir>/agents/<id>/spine/<node-id>.md`. The path is
|
||||
// deterministic and published on the tree view; the node itself carries
|
||||
// no persisted path any more.
|
||||
const archivePath = [...writes.keys()].find((path) =>
|
||||
path.endsWith('/agents/main/spine/1-1-1.md'),
|
||||
);
|
||||
expect(archivePath).toBeDefined();
|
||||
const content = writes.get(archivePath!) ?? '';
|
||||
expect(content).toContain('did A');
|
||||
expect(content).toContain('task A');
|
||||
expect(content).toContain('## Trajectory');
|
||||
|
||||
expect(ctx.get(IAgentSpineService).renderTree()).toContain(archivePath);
|
||||
expect(ctx.get(IAgentSpineService).renderTree()).toContain(archivePath!);
|
||||
});
|
||||
|
||||
it('replays the tree (with memory and archive path) from persisted wire records', async () => {
|
||||
|
|
@ -77,17 +76,13 @@ describe('Spine archive + resume', () => {
|
|||
execEnvServices({ hostFs: recordingHostFs(new Map()) }),
|
||||
wireRecordPersistenceServices(persistence),
|
||||
);
|
||||
await configureLoop(ctx);
|
||||
ctx.mockNextResponse(toolCallPart('c_open', 'spine_open', { summary: 'task A' }));
|
||||
ctx.mockNextResponse(toolCallPart('c_close', 'spine_close', { memory: 'did A' }));
|
||||
ctx.mockNextResponse({ type: 'text', text: 'done' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'start' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
|
||||
const wire = ctx.get(IAgentWireService);
|
||||
wire.dispatch(spineOpen({ id: '1.1.1', summary: 'task A', parentId: '1.1', openedAt: 0 }));
|
||||
wire.dispatch(
|
||||
spineClose({
|
||||
id: '1.1.1',
|
||||
closedAt: 5,
|
||||
memory: 'did A',
|
||||
archivePath: '/ws/spine/main/1-1-1.md',
|
||||
}),
|
||||
);
|
||||
// Upstream's wire persists through an async persistQueue (blob dehydrate
|
||||
// hop runs even without blobs) that only drains on the wire service's own
|
||||
// flush; the wireRecord service flush no longer covers it, so cloning the
|
||||
|
|
@ -107,12 +102,11 @@ describe('Spine archive + resume', () => {
|
|||
const after = readSpine(resumed);
|
||||
expect(after.openStack).toEqual(before.openStack);
|
||||
expect(after.rootEpoch).toBe(before.rootEpoch);
|
||||
expect(after.nodes['1.1.1']).toMatchObject({
|
||||
summary: 'task A',
|
||||
memory: 'did A',
|
||||
closedAt: 5,
|
||||
archivePath: '/ws/spine/main/1-1-1.md',
|
||||
});
|
||||
expect(after.nodes['1.1.1']?.summary).toBe('task A');
|
||||
expect(after.nodes['1.1.1']?.closedAt).toBe(before.nodes['1.1.1']?.closedAt);
|
||||
expect(after.nodes['1.1.1']?.memory).toContain('did A');
|
||||
// The archive path is recomputed deterministically on the tree view.
|
||||
expect(resumed.get(IAgentSpineService).renderTree()).toContain('1-1-1.md');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -122,7 +116,7 @@ async function configureLoop(ctx: TestAgentContext): Promise<void> {
|
|||
}
|
||||
|
||||
function readSpine(ctx: TestAgentContext) {
|
||||
return ctx.get(IAgentWireService).getModel(SpineModel);
|
||||
return ctx.get(IAgentSpineService).currentState();
|
||||
}
|
||||
|
||||
function recordingHostFs(writes: Map<string, string>) {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MASTER_ENV } from '#/app/flag/flagService';
|
||||
import {
|
||||
IAgentSpineService,
|
||||
IAgentWireService,
|
||||
SpineModel,
|
||||
spineClose,
|
||||
spineOpen,
|
||||
spineRootCompact,
|
||||
} from '#/index';
|
||||
buildCompactionSummaryText,
|
||||
createCompactionSummaryMessage,
|
||||
} from '#/agent/contextMemory/compactionHandoff';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import { MASTER_ENV } from '#/app/flag/flagService';
|
||||
import { ACCEPTED_OUTPUT, IAgentSpineService } from '#/index';
|
||||
|
||||
import {
|
||||
execEnvServices,
|
||||
|
|
@ -63,7 +61,9 @@ describe('Spine / compaction interaction', () => {
|
|||
await completed;
|
||||
|
||||
const recordTypes = ctx.recordHistory.map((record) => record.type);
|
||||
expect(recordTypes).toContain('spine.root_compact');
|
||||
// The derivation reads the boundary from the summary message itself — no
|
||||
// tree op is dispatched for a root compaction any more.
|
||||
expect(recordTypes).not.toContain('spine.root_compact');
|
||||
expect(recordTypes).not.toContain('context.apply_compaction');
|
||||
|
||||
const state = readSpine(ctx);
|
||||
|
|
@ -96,13 +96,13 @@ describe('Spine / compaction interaction', () => {
|
|||
await ctx.rpc.beginCompaction({});
|
||||
await completed;
|
||||
|
||||
const state = readSpine(ctx);
|
||||
const epochNode = state.nodes[String(state.rootEpoch)];
|
||||
expect(epochNode?.archivePath).toBeDefined();
|
||||
const archivePath = epochNode?.archivePath as string;
|
||||
expect(archivePath.endsWith('/agents/main/spine/2.md')).toBe(true);
|
||||
expect(writes.has(archivePath)).toBe(true);
|
||||
const content = writes.get(archivePath) ?? '';
|
||||
// The archive path is deterministic and published on the tree view; the
|
||||
// epoch node itself carries no persisted path any more.
|
||||
const archivePath = [...writes.keys()].find((path) =>
|
||||
path.endsWith('/agents/main/spine/2.md'),
|
||||
);
|
||||
expect(archivePath).toBeDefined();
|
||||
const content = writes.get(archivePath!) ?? '';
|
||||
expect(content).toContain('# Spine Root Epoch 2');
|
||||
expect(content).toContain('## Epoch Summary');
|
||||
expect(content).toContain('Summary.');
|
||||
|
|
@ -112,25 +112,7 @@ describe('Spine / compaction interaction', () => {
|
|||
expect(content).toContain('recent user');
|
||||
expect(content).toContain('recent assistant');
|
||||
|
||||
expect(ctx.get(IAgentSpineService).renderTree()).toContain(archivePath);
|
||||
});
|
||||
|
||||
it('keeps the epoch archive path on the new epoch node through dispatch', () => {
|
||||
const ctx = testAgent();
|
||||
const wire = ctx.get(IAgentWireService);
|
||||
|
||||
wire.dispatch(
|
||||
spineRootCompact({
|
||||
epoch: 2,
|
||||
epochStartAt: 10,
|
||||
epochMemoryAt: 9,
|
||||
archivePath: '/work/spine/agent-0/2.md',
|
||||
}),
|
||||
);
|
||||
|
||||
const state = readSpine(ctx);
|
||||
expect(state.nodes['2']?.archivePath).toBe('/work/spine/agent-0/2.md');
|
||||
expect(ctx.get(IAgentSpineService).renderTree()).toContain('archive: /work/spine/agent-0/2.md');
|
||||
expect(ctx.get(IAgentSpineService).renderTree()).toContain(archivePath!);
|
||||
});
|
||||
|
||||
it('completes the root compaction without an archive path when the archive write fails', async () => {
|
||||
|
|
@ -152,10 +134,13 @@ describe('Spine / compaction interaction', () => {
|
|||
await completed;
|
||||
|
||||
const recordTypes = ctx.recordHistory.map((record) => record.type);
|
||||
expect(recordTypes).toContain('spine.root_compact');
|
||||
expect(recordTypes).not.toContain('spine.root_compact');
|
||||
expect(recordTypes).toContain('full_compaction.complete');
|
||||
const state = readSpine(ctx);
|
||||
expect(state.nodes[String(state.rootEpoch)]?.archivePath).toBeUndefined();
|
||||
// The failed epoch archive write is tracked, so the tree view publishes no
|
||||
// path for the new epoch (instead of pointing at a missing file).
|
||||
const tree = ctx.get(IAgentSpineService).renderTree();
|
||||
expect(tree).toContain('2 [open]');
|
||||
expect(tree).not.toContain('2.md');
|
||||
expect(
|
||||
logEntries.some(
|
||||
(entry) => entry.level === 'warn' && entry.message.toLowerCase().includes('archive'),
|
||||
|
|
@ -165,26 +150,23 @@ describe('Spine / compaction interaction', () => {
|
|||
|
||||
it('keeps previous epochs and their archive paths reachable in the tree after a root compaction', () => {
|
||||
const ctx = testAgent();
|
||||
const wire = ctx.get(IAgentWireService);
|
||||
|
||||
wire.dispatch(spineOpen({ id: '1.1.1', summary: 'task A', parentId: '1.1', openedAt: 0 }));
|
||||
wire.dispatch(
|
||||
spineClose({
|
||||
id: '1.1.1',
|
||||
closedAt: 5,
|
||||
memory: 'did A',
|
||||
archivePath: '/work/spine/agent-0/1-1-1.md',
|
||||
}),
|
||||
);
|
||||
wire.dispatch(spineRootCompact({ epoch: 2, epochStartAt: 10, epochMemoryAt: 9 }));
|
||||
// Epoch 1: a real open + close, then the epoch boundary lands.
|
||||
append(ctx, assistantToolCall('c_open', 'spine_open', JSON.stringify({ summary: 'task A' })));
|
||||
append(ctx, spineAcceptedReceipt('c_open'));
|
||||
append(ctx, assistantToolCall('c_close', 'spine_close', JSON.stringify({ memory: 'did A' })));
|
||||
append(ctx, spineAcceptedReceipt('c_close'));
|
||||
append(ctx, createCompactionSummaryMessage(buildCompactionSummaryText('epoch summary')));
|
||||
|
||||
const tree = ctx.get(IAgentSpineService).renderTree();
|
||||
|
||||
expect(tree).toContain('1 [closed]');
|
||||
expect(tree).toContain('1.1.1');
|
||||
expect(tree).toContain('task A');
|
||||
expect(tree).toContain('archive: /work/spine/agent-0/1-1-1.md');
|
||||
expect(tree).toContain('2 [open]');
|
||||
// The closed node's archive path is recomputed deterministically and
|
||||
// published on the tree view.
|
||||
expect(tree).toContain('archive:');
|
||||
expect(tree).toContain('1-1-1.md');
|
||||
expect(tree).toContain('2 [open, archive:');
|
||||
});
|
||||
|
||||
it('summarizes only the current epoch and chains the previous epoch summary', async () => {
|
||||
|
|
@ -226,7 +208,30 @@ describe('Spine / compaction interaction', () => {
|
|||
});
|
||||
|
||||
function readSpine(ctx: TestAgentContext) {
|
||||
return ctx.get(IAgentWireService).getModel(SpineModel);
|
||||
return ctx.get(IAgentSpineService).currentState();
|
||||
}
|
||||
|
||||
function append(ctx: TestAgentContext, message: ContextMessage): number {
|
||||
const index = ctx.context.get().length;
|
||||
ctx.context.append(message);
|
||||
return index;
|
||||
}
|
||||
|
||||
function assistantToolCall(id: string, name: string, args: string = '{}'): ContextMessage {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: `calling ${name}` }],
|
||||
toolCalls: [{ type: 'function', id, name, arguments: args }],
|
||||
};
|
||||
}
|
||||
|
||||
function spineAcceptedReceipt(toolCallId: string): ContextMessage {
|
||||
return {
|
||||
role: 'tool',
|
||||
content: [{ type: 'text', text: ACCEPTED_OUTPUT }],
|
||||
toolCalls: [],
|
||||
toolCallId,
|
||||
};
|
||||
}
|
||||
|
||||
function recordingHostFs(writes: Map<string, string>) {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6,12 +6,6 @@ import {
|
|||
resetUnexpectedErrorHandler,
|
||||
setUnexpectedErrorHandler,
|
||||
} from '#/_base/errors/unexpectedError';
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import {
|
||||
IAgentLoopService,
|
||||
type AfterStepContext,
|
||||
type BeforeStepContext,
|
||||
} from '#/agent/loop/loop';
|
||||
import { ACCEPTED_OUTPUT, toControlResult } from '#/agent/spine/tools/controlResult';
|
||||
import { SPINE_FLAG_ID } from '#/agent/spine/flag';
|
||||
import { SpineCloseTool } from '#/agent/spine/tools/spine-close';
|
||||
|
|
@ -23,11 +17,8 @@ import { IFlagService } from '#/app/flag/flag';
|
|||
import { getToolContributions } from '#/agent/toolRegistry/toolContribution';
|
||||
import type { ServicesAccessor } from '#/_base/di/instantiation';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import type { PersistedWireRecord } from '#/agent/wireRecord/wireRecord';
|
||||
import type { PersistedRecord } from '#/wire/wireService';
|
||||
import {
|
||||
IAgentSpineService,
|
||||
IAgentWireRecordService,
|
||||
IAgentWireService,
|
||||
SPINE_VOID_OPENED_AT,
|
||||
SpineModel,
|
||||
|
|
@ -40,14 +31,11 @@ import {
|
|||
import type { Message } from '#/app/llmProtocol/message';
|
||||
|
||||
import {
|
||||
agentService,
|
||||
createCommandRunner,
|
||||
execEnvServices,
|
||||
InMemoryWireRecordPersistence,
|
||||
testAgent,
|
||||
type TestAgentContext,
|
||||
type TestAgentOptions,
|
||||
type TestAgentServiceOverride,
|
||||
} from '../harness';
|
||||
|
||||
const SPINE_ENV = 'KIMI_CODE_SPINE';
|
||||
|
|
@ -103,7 +91,7 @@ describe('Spine reducers (via wire)', () => {
|
|||
|
||||
it('starts with an open root epoch and startup node', () => {
|
||||
const ctx = testAgent();
|
||||
const state = readSpine(ctx);
|
||||
const state = readOps(ctx);
|
||||
expect(state.rootEpoch).toBe(1);
|
||||
expect(state.openStack).toEqual(['1', '1.1']);
|
||||
expect(state.nodes['1']?.children).toEqual(['1.1']);
|
||||
|
|
@ -116,7 +104,7 @@ describe('Spine reducers (via wire)', () => {
|
|||
|
||||
wire.dispatch(spineOpen({ id: '1.1.1', summary: 'task A', parentId: '1.1', openedAt: 0 }));
|
||||
|
||||
const state = readSpine(ctx);
|
||||
const state = readOps(ctx);
|
||||
expect(state.openStack).toEqual(['1', '1.1', '1.1.1']);
|
||||
expect(state.nodes['1.1']?.children).toEqual(['1.1.1']);
|
||||
expect(state.nodes['1.1.1']?.summary).toBe('task A');
|
||||
|
|
@ -128,10 +116,10 @@ describe('Spine reducers (via wire)', () => {
|
|||
const wire = ctx.get(IAgentWireService);
|
||||
wire.dispatch(spineOpen({ id: '1.1.1', summary: 'task A', parentId: '1.1', openedAt: 0 }));
|
||||
|
||||
const before = readSpine(ctx);
|
||||
const before = readOps(ctx);
|
||||
wire.dispatch(spineClose({ id: '1.1.1', closedAt: 5, memory: 'did A' }));
|
||||
|
||||
const state = readSpine(ctx);
|
||||
const state = readOps(ctx);
|
||||
expect(state.openStack).toEqual(['1', '1.1']);
|
||||
expect(state.nodes['1.1.1']?.closedAt).toBe(5);
|
||||
expect(state.nodes['1.1.1']?.memory).toBe('did A');
|
||||
|
|
@ -144,7 +132,7 @@ describe('Spine reducers (via wire)', () => {
|
|||
|
||||
wire.dispatch(spineClose({ id: '1.1', closedAt: 3, memory: 'startup done' }));
|
||||
|
||||
const state = readSpine(ctx);
|
||||
const state = readOps(ctx);
|
||||
expect(state.openStack).toEqual(['1']);
|
||||
expect(state.nodes['1.1']?.closedAt).toBe(3);
|
||||
expect(state.nodes['1.1']?.memory).toBe('startup done');
|
||||
|
|
@ -162,7 +150,7 @@ describe('Spine reducers (via wire)', () => {
|
|||
|
||||
wire.dispatch(spineTruncateRepair({ cut: 8 }));
|
||||
|
||||
const state = readSpine(ctx);
|
||||
const state = readOps(ctx);
|
||||
// Straddling span [2, 9]: fold only the surviving prefix.
|
||||
expect(state.nodes['1.1.1']?.closedAt).toBe(7);
|
||||
// Span fully inside the truncated range: voided (fold-excluded).
|
||||
|
|
@ -181,11 +169,11 @@ describe('Spine reducers (via wire)', () => {
|
|||
wire.dispatch(spineOpen({ id: '1.1.1', summary: 'task A', parentId: '1.1', openedAt: 22 }));
|
||||
wire.dispatch(spineClose({ id: '1.1.1', closedAt: 30, memory: 'did A' }));
|
||||
wire.dispatch(spineRootCompact({ epoch: 2, epochStartAt: 20, epochMemoryAt: 19 }));
|
||||
const before = readSpine(ctx);
|
||||
const before = readOps(ctx);
|
||||
|
||||
wire.dispatch(spineTruncateRepair({ cut: 25 }));
|
||||
|
||||
const state = readSpine(ctx);
|
||||
const state = readOps(ctx);
|
||||
expect(state.epochStartAt).toBe(20);
|
||||
expect(state.epochMemoryAt).toBe(19);
|
||||
// Only the straddling span is repaired; the boundary is untouched.
|
||||
|
|
@ -197,32 +185,32 @@ describe('Spine reducers (via wire)', () => {
|
|||
const ctx = testAgent();
|
||||
const wire = ctx.get(IAgentWireService);
|
||||
wire.dispatch(spineOpen({ id: '1.1.1', summary: 'task A', parentId: '1.1', openedAt: 2 }));
|
||||
const before = readSpine(ctx);
|
||||
const before = readOps(ctx);
|
||||
|
||||
wire.dispatch(spineTruncateRepair({ cut: 8 }));
|
||||
|
||||
expect(readSpine(ctx)).toBe(before);
|
||||
expect(readOps(ctx)).toBe(before);
|
||||
});
|
||||
|
||||
it('rejects closing a root epoch (no-op, same reference)', () => {
|
||||
const ctx = testAgent();
|
||||
const wire = ctx.get(IAgentWireService);
|
||||
const before = readSpine(ctx);
|
||||
const before = readOps(ctx);
|
||||
|
||||
wire.dispatch(spineClose({ id: '1', closedAt: 5, memory: 'nope' }));
|
||||
|
||||
expect(readSpine(ctx)).toBe(before);
|
||||
expect(readOps(ctx)).toBe(before);
|
||||
});
|
||||
|
||||
it('rejects closing a node that is not the cursor', () => {
|
||||
const ctx = testAgent();
|
||||
const wire = ctx.get(IAgentWireService);
|
||||
wire.dispatch(spineOpen({ id: '1.1.1', summary: 'task A', parentId: '1.1', openedAt: 0 }));
|
||||
const before = readSpine(ctx);
|
||||
const before = readOps(ctx);
|
||||
|
||||
wire.dispatch(spineClose({ id: '1.1', closedAt: 5, memory: 'nope' }));
|
||||
|
||||
expect(readSpine(ctx)).toBe(before);
|
||||
expect(readOps(ctx)).toBe(before);
|
||||
});
|
||||
|
||||
it('commits next atomically (close cursor, open sibling under the same parent)', () => {
|
||||
|
|
@ -240,7 +228,7 @@ describe('Spine reducers (via wire)', () => {
|
|||
}),
|
||||
);
|
||||
|
||||
const state = readSpine(ctx);
|
||||
const state = readOps(ctx);
|
||||
expect(state.openStack).toEqual(['1', '1.1', '1.1.2']);
|
||||
expect(state.nodes['1.1.1']?.closedAt).toBe(5);
|
||||
expect(state.nodes['1.1.1']?.memory).toBe('did A');
|
||||
|
|
@ -254,11 +242,11 @@ describe('Spine reducers (via wire)', () => {
|
|||
it('rejects opening under a parent that is not the cursor', () => {
|
||||
const ctx = testAgent();
|
||||
const wire = ctx.get(IAgentWireService);
|
||||
const before = readSpine(ctx);
|
||||
const before = readOps(ctx);
|
||||
|
||||
wire.dispatch(spineOpen({ id: '1.2', summary: 'task X', parentId: '1', openedAt: 0 }));
|
||||
|
||||
expect(readSpine(ctx)).toBe(before);
|
||||
expect(readOps(ctx)).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -336,10 +324,10 @@ describe('Spine control tools', () => {
|
|||
});
|
||||
|
||||
it('commits a spine transition after an undo shrank the history', async () => {
|
||||
// Reproduces the pre-fix defect: undo truncated the context below
|
||||
// `lastObservedIndex`, so the next transition's evidence search started
|
||||
// past the end of the history, found nothing, and dropped the transition
|
||||
// even though the accepted receipt landed in the transcript.
|
||||
// The pre-fix defect dropped the post-undo transition because the evidence
|
||||
// search started past the shrunk history. Derivation has no such cursor:
|
||||
// the tree rebuilds from the surviving stream and the next transition
|
||||
// nests under the surviving node.
|
||||
const ctx = loopContext();
|
||||
await configureLoop(ctx);
|
||||
for (let i = 0; i < 3; i++) ctx.appendExchange(i + 1, `seed u${i}`, `seed a${i}`, 100);
|
||||
|
|
@ -349,6 +337,10 @@ describe('Spine control tools', () => {
|
|||
await ctx.untilTurnEnd();
|
||||
expect(readSpine(ctx).nodes['1.1.1']?.summary).toBe('task A');
|
||||
|
||||
// A work turn inside the node, then undo it — the node itself survives.
|
||||
ctx.mockNextResponse({ type: 'text', text: 'work' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'work a bit' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
await ctx.rpc.undoHistory({ count: 1 });
|
||||
|
||||
ctx.mockNextResponse(toolCallPart('call_open_2', 'spine_open', { summary: 'task B' }));
|
||||
|
|
@ -361,11 +353,12 @@ describe('Spine control tools', () => {
|
|||
expect(state.openStack).toEqual(['1', '1.1', '1.1.1', '1.1.1.1']);
|
||||
});
|
||||
|
||||
it('keeps post-undo messages out of a truncated closed span', async () => {
|
||||
// Reproduces the pre-fix defect: undo cutting into a closed span left its
|
||||
// indices dangling; the fold emitted the stale memory at `openedAt` and
|
||||
// jumped past the end of the truncated history, swallowing every message
|
||||
// appended after the undo — the model never saw the fresh prompt.
|
||||
it('reopens a closed span when an undo truncates its close evidence', async () => {
|
||||
// Derivation semantics: the tree is rebuilt from the surviving message
|
||||
// stream, so an undo that cuts into a closed span removes the close
|
||||
// transition with its carrier — the node reopens and its memory is gone.
|
||||
// The fold must still show every surviving and post-undo message (the
|
||||
// original defect swallowed everything appended after the undo).
|
||||
let lastRequestText = '';
|
||||
const generate: GenerateFn = async (_provider, _system, _tools, history) => {
|
||||
lastRequestText = historyText(history);
|
||||
|
|
@ -373,26 +366,31 @@ describe('Spine control tools', () => {
|
|||
};
|
||||
const ctx = testAgent(execEnvServices({ hostFs: recordingHostFs().fs }), { generate });
|
||||
await configureLoop(ctx);
|
||||
for (let i = 0; i < 10; i++) ctx.appendExchange(i + 1, `u${String(i)}`, `a${String(i)}`, 100);
|
||||
const wire = ctx.get(IAgentWireService);
|
||||
wire.dispatch(spineOpen({ id: '1.1.1', parentId: '1.1', summary: 'old work', openedAt: 2 }));
|
||||
wire.dispatch(spineClose({ id: '1.1.1', closedAt: 9, memory: 'old memory' }));
|
||||
ctx.appendExchange(1, 'u0', 'a0', 100);
|
||||
ctx.context.append(assistantSpineCall('call_open', 'spine_open', { summary: 'old work' }));
|
||||
ctx.context.append(spineReceipt('call_open'));
|
||||
for (let i = 1; i < 4; i++) ctx.appendExchange(i + 1, `u${String(i)}`, `a${String(i)}`, 100);
|
||||
ctx.context.append(assistantSpineCall('call_close', 'spine_close', { memory: 'old memory' }));
|
||||
ctx.context.append(spineReceipt('call_close'));
|
||||
for (let i = 4; i < 10; i++) ctx.appendExchange(i + 1, `u${String(i)}`, `a${String(i)}`, 100);
|
||||
|
||||
// Cut lands at index 8 — inside the closed span [2, 9].
|
||||
await ctx.rpc.undoHistory({ count: 6 });
|
||||
expect(readSpine(ctx).nodes['1.1.1']?.closedAt).toBe(7);
|
||||
// Cut lands at index 8 — inside the (formerly) closed span [2, 9].
|
||||
await ctx.rpc.undoHistory({ count: 7 });
|
||||
expect(readSpine(ctx).nodes['1.1.1']?.closedAt).toBeUndefined();
|
||||
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'FRESH-PROMPT-MARKER' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
|
||||
expect(lastRequestText).toContain('FRESH-PROMPT-MARKER');
|
||||
expect(lastRequestText).toContain('old memory');
|
||||
expect(lastRequestText).toContain('u1');
|
||||
expect(lastRequestText).not.toContain('old memory');
|
||||
});
|
||||
|
||||
it('keeps the rebuilt history visible after /clear with a dangling epoch boundary', async () => {
|
||||
// Reproduces the pre-fix defect: /clear emptied the context while the tree
|
||||
// kept its epoch boundary, so the fold dropped every rebuilt message
|
||||
// (`i < epochStartAt`) and the model saw nothing but the status line.
|
||||
it('keeps the rebuilt history visible after /clear', async () => {
|
||||
// Derivation semantics: /clear empties the stored history, so the derived
|
||||
// tree resets with it — no dangling epoch boundary, no stale nodes — and
|
||||
// the fold shows everything rebuilt from then on. (The original defect:
|
||||
// a boundary that outlived the history dropped every rebuilt message.)
|
||||
let lastRequestText = '';
|
||||
const generate: GenerateFn = async (_provider, _system, _tools, history) => {
|
||||
lastRequestText = historyText(history);
|
||||
|
|
@ -401,16 +399,16 @@ describe('Spine control tools', () => {
|
|||
const ctx = testAgent(execEnvServices({ hostFs: recordingHostFs().fs }), { generate });
|
||||
await configureLoop(ctx);
|
||||
for (let i = 0; i < 11; i++) ctx.appendExchange(i + 1, `u${String(i)}`, `a${String(i)}`, 100);
|
||||
const wire = ctx.get(IAgentWireService);
|
||||
wire.dispatch(spineRootCompact({ epoch: 2, epochStartAt: 22, epochMemoryAt: 21 }));
|
||||
|
||||
await ctx.rpc.clearContext({});
|
||||
|
||||
const state = readSpine(ctx);
|
||||
expect(state.rootEpoch).toBe(1);
|
||||
expect(state.epochStartAt).toBe(0);
|
||||
expect(state.epochMemoryAt).toBeUndefined();
|
||||
// The old epochs stay in the tree for their archives.
|
||||
expect(state.nodes['2']).toBeDefined();
|
||||
// The cleared history carries no epoch evidence, so the old epoch nodes
|
||||
// are gone from the tree (their archives remain on disk).
|
||||
expect(state.nodes['2']).toBeUndefined();
|
||||
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'AFTER-CLEAR-MARKER' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
|
|
@ -419,33 +417,26 @@ describe('Spine control tools', () => {
|
|||
});
|
||||
|
||||
it('folds a closed startup node memory into the next projection', async () => {
|
||||
let lastRequestText = '';
|
||||
const generate: GenerateFn = async (_provider, _system, _tools, history) => {
|
||||
lastRequestText = historyText(history);
|
||||
return textResult('answer');
|
||||
};
|
||||
const ctx = testAgent(execEnvServices({ hostFs: recordingHostFs().fs }), { generate });
|
||||
const ctx = loopContext();
|
||||
await configureLoop(ctx);
|
||||
|
||||
ctx.mockNextResponse(
|
||||
toolCallPart('call_close', 'spine_close', { memory: 'STARTUP-MEMORY-MARKER' }),
|
||||
);
|
||||
ctx.mockNextResponse({ type: 'text', text: 'done' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'STARTUP-PHASE-PROMPT' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
ctx
|
||||
.get(IAgentWireService)
|
||||
.dispatch(
|
||||
spineClose({
|
||||
id: '1.1',
|
||||
closedAt: ctx.context.get().length - 1,
|
||||
memory: 'STARTUP-MEMORY-MARKER',
|
||||
}),
|
||||
);
|
||||
|
||||
ctx.mockNextResponse({ type: 'text', text: 'answer' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'AFTER-STARTUP-CLOSE' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
|
||||
expect(lastRequestText).toContain('<spine_memory>');
|
||||
expect(lastRequestText).toContain('STARTUP-MEMORY-MARKER');
|
||||
expect(lastRequestText).toContain('AFTER-STARTUP-CLOSE');
|
||||
expect(lastRequestText).not.toContain('STARTUP-PHASE-PROMPT');
|
||||
const projected = historyText(ctx.project());
|
||||
expect(projected).toContain('<spine_memory>');
|
||||
expect(projected).toContain('STARTUP-MEMORY-MARKER');
|
||||
// The closing span's user request is compiled into the memory body.
|
||||
expect(projected).toContain('## User Message [U1]');
|
||||
expect(projected).toContain('STARTUP-PHASE-PROMPT');
|
||||
expect(projected).toContain('AFTER-STARTUP-CLOSE');
|
||||
});
|
||||
|
||||
it('compiles the closing span user requests into the memory body', async () => {
|
||||
|
|
@ -479,12 +470,15 @@ describe('Spine control tools', () => {
|
|||
};
|
||||
const ctx = testAgent(execEnvServices({ hostFs: recordingHostFs().fs }), { generate });
|
||||
await configureLoop(ctx);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
ctx.appendExchange(i + 1, `seed-u${String(i)}`, `seed-a${String(i)}`, 100);
|
||||
}
|
||||
const wire = ctx.get(IAgentWireService);
|
||||
wire.dispatch(spineOpen({ id: '1.1.1', parentId: '1.1', summary: 'old work', openedAt: 2 }));
|
||||
wire.dispatch(spineClose({ id: '1.1.1', closedAt: 5, memory: 'old memory' }));
|
||||
ctx.appendExchange(1, 'seed-u0', 'seed-a0', 100);
|
||||
ctx.context.append(assistantSpineCall('call_open', 'spine_open', { summary: 'old work' }));
|
||||
ctx.context.append(spineReceipt('call_open'));
|
||||
ctx.appendExchange(2, 'seed-u1', 'seed-a1', 100);
|
||||
ctx.context.append(assistantSpineCall('call_close', 'spine_close', { memory: 'old memory' }));
|
||||
ctx.context.append(spineReceipt('call_close'));
|
||||
ctx.appendExchange(3, 'seed-u2', 'seed-a2', 100);
|
||||
ctx.appendExchange(4, 'seed-u3', 'seed-a3', 100);
|
||||
ctx.appendExchange(5, 'seed-u4', 'seed-a4', 100);
|
||||
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'NUMBER-CHECK-PROMPT' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
|
|
@ -493,7 +487,8 @@ describe('Spine control tools', () => {
|
|||
expect(lastRequestText).toContain('[U4] seed-u3');
|
||||
expect(lastRequestText).toContain('[U6] NUMBER-CHECK-PROMPT');
|
||||
expect(lastRequestText).toContain('old memory');
|
||||
expect(lastRequestText).not.toContain('seed-u1');
|
||||
// The folded span's request survives as a citation inside the memory body.
|
||||
expect(lastRequestText).toContain('## User Message [U2]');
|
||||
});
|
||||
|
||||
it('commits next atomically across a single step', async () => {
|
||||
|
|
@ -763,165 +758,6 @@ describe('Spine durability', () => {
|
|||
expect(reported.some((err) => String(err).includes('disk full'))).toBe(true);
|
||||
});
|
||||
|
||||
it('reports an accepted receipt that lost its committed op on restore', async () => {
|
||||
const records = await recordSpineTurn();
|
||||
const stripped = records.filter((record) => record.type !== 'spine.close');
|
||||
|
||||
const reported = await restoreAndCaptureReports(stripped);
|
||||
|
||||
const spineReports = reported.filter((err) => String(err).includes('Spine:'));
|
||||
expect(spineReports).toHaveLength(1);
|
||||
expect(String(spineReports[0])).toContain('spine_close');
|
||||
});
|
||||
|
||||
it('stays quiet on restore when every receipt has its op', async () => {
|
||||
const reported = await restoreAndCaptureReports(await recordSpineTurn());
|
||||
|
||||
expect(reported.filter((err) => String(err).includes('Spine:'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('stays quiet when ops outnumber receipts (a compaction can fold receipts away)', async () => {
|
||||
const records = await recordSpineTurn();
|
||||
const stripped = records.filter((record) => !isToolResultRecord(record, 'call_close'));
|
||||
|
||||
const reported = await restoreAndCaptureReports(stripped);
|
||||
|
||||
expect(reported.filter((err) => String(err).includes('Spine:'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('stays quiet on restore for legacy bare-accepted receipts', async () => {
|
||||
const records = await recordSpineTurn();
|
||||
const legacy = records.map((record) =>
|
||||
isToolResultRecord(record, 'call_close') ? withToolResultText(record, 'accepted') : record,
|
||||
);
|
||||
|
||||
const reported = await restoreAndCaptureReports(legacy);
|
||||
|
||||
expect(reported.filter((err) => String(err).includes('Spine:'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('reports a pending transition dropped when its step ends without evidence', async () => {
|
||||
const { beforeStep, afterStep, reported, spine } = spineWithCapturedStepHooks();
|
||||
await hookOf(beforeStep, 'spine')(beforeCtx(new AbortController().signal), noopNext);
|
||||
expect(spine.acceptOpen('task A', 'call_open').accepted).toBe(true);
|
||||
|
||||
await hookOf(afterStep, 'spine')(afterCtx(new AbortController().signal), noopNext);
|
||||
|
||||
expect(reported).toHaveLength(1);
|
||||
expect(String(reported[0])).toContain('call_open');
|
||||
|
||||
// The drop cleared the pending transition: the next step stays quiet.
|
||||
await hookOf(beforeStep, 'spine')(beforeCtx(new AbortController().signal), noopNext);
|
||||
expect(reported).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('stays quiet when the dropped transition belonged to an aborted step', async () => {
|
||||
const { beforeStep, afterStep, reported, spine } = spineWithCapturedStepHooks();
|
||||
const controller = new AbortController();
|
||||
await hookOf(beforeStep, 'spine')(beforeCtx(controller.signal), noopNext);
|
||||
spine.acceptOpen('task A', 'call_open');
|
||||
controller.abort();
|
||||
|
||||
await hookOf(afterStep, 'spine')(afterCtx(controller.signal), noopNext);
|
||||
|
||||
expect(reported).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('attributes a leftover pending transition to its owning step across turns', async () => {
|
||||
const { beforeStep, reported, spine } = spineWithCapturedStepHooks();
|
||||
// Turn 1: the step owning the pending transition aborts before afterStep
|
||||
// ever runs (turn-level cancel), leaving the pending behind.
|
||||
const controller = new AbortController();
|
||||
await hookOf(beforeStep, 'spine')(beforeCtx(controller.signal), noopNext);
|
||||
spine.acceptOpen('task A', 'call_open');
|
||||
controller.abort();
|
||||
|
||||
// Turn 2: dropping the leftover is routine — quiet.
|
||||
await hookOf(beforeStep, 'spine')(beforeCtx(new AbortController().signal), noopNext);
|
||||
expect(reported).toHaveLength(0);
|
||||
|
||||
// A leftover from a step that did NOT abort is anomalous — reported.
|
||||
spine.acceptOpen('task B', 'call_open_2');
|
||||
await hookOf(beforeStep, 'spine')(beforeCtx(new AbortController().signal), noopNext);
|
||||
expect(reported).toHaveLength(1);
|
||||
expect(String(reported[0])).toContain('call_open_2');
|
||||
});
|
||||
|
||||
it('clamps the closing boundary at the span start', async () => {
|
||||
// A truncation repair can restart an open span at the cut — past the end
|
||||
// of the surviving history — so `assistantIndex - 1` would invert the
|
||||
// span without the clamp.
|
||||
const { beforeStep, afterStep, spine, ctx } = spineWithCapturedStepHooks(
|
||||
execEnvServices({ hostFs: recordingHostFs().fs }),
|
||||
);
|
||||
ctx
|
||||
.get(IAgentWireService)
|
||||
.dispatch(spineOpen({ id: '1.1.1', summary: 'task A', parentId: '1.1', openedAt: 10 }));
|
||||
ctx.context.append({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'closing' }],
|
||||
toolCalls: [{ type: 'function', id: 'call_close', name: 'spine_close', arguments: '{}' }],
|
||||
});
|
||||
ctx.context.append({
|
||||
role: 'tool',
|
||||
content: [{ type: 'text', text: ACCEPTED_OUTPUT }],
|
||||
toolCalls: [],
|
||||
toolCallId: 'call_close',
|
||||
});
|
||||
|
||||
await hookOf(beforeStep, 'spine')(beforeCtx(new AbortController().signal), noopNext);
|
||||
expect(spine.acceptClose('did A', 'call_close').accepted).toBe(true);
|
||||
await hookOf(afterStep, 'spine')(afterCtx(new AbortController().signal), noopNext);
|
||||
|
||||
expect(readSpine(ctx).nodes['1.1.1']?.closedAt).toBe(10);
|
||||
});
|
||||
|
||||
it('commits a leftover close at the next step start when its receipt landed', async () => {
|
||||
// The abort path: afterStep never runs, so the close stays pending past its
|
||||
// owning step. The next step's beforeStep must commit it (the receipt is
|
||||
// already in context) so the tree catches up before the model sees the
|
||||
// context — rather than dropping it and forking the tree from the receipt.
|
||||
const { beforeStep, reported, spine, ctx } = spineWithCapturedStepHooks(
|
||||
execEnvServices({ hostFs: recordingHostFs().fs }),
|
||||
);
|
||||
ctx
|
||||
.get(IAgentWireService)
|
||||
.dispatch(spineOpen({ id: '1.1.1', summary: 'task A', parentId: '1.1', openedAt: 0 }));
|
||||
ctx.context.append({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'work a' }],
|
||||
toolCalls: [],
|
||||
});
|
||||
ctx.context.append({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'work b' }],
|
||||
toolCalls: [],
|
||||
});
|
||||
ctx.context.append({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'closing' }],
|
||||
toolCalls: [toolCallPart('call_close', 'spine_close', {})],
|
||||
});
|
||||
ctx.context.append({
|
||||
role: 'tool',
|
||||
content: [{ type: 'text', text: ACCEPTED_OUTPUT }],
|
||||
toolCalls: [],
|
||||
toolCallId: 'call_close',
|
||||
});
|
||||
|
||||
// Owning step: accept the close, then abort before afterStep can commit it.
|
||||
const controller = new AbortController();
|
||||
await hookOf(beforeStep, 'spine')(beforeCtx(controller.signal), noopNext);
|
||||
expect(spine.acceptClose('did A', 'call_close').accepted).toBe(true);
|
||||
controller.abort();
|
||||
|
||||
// The next step begins: the leftover is committed (boundary = the carrier's
|
||||
// predecessor), not dropped — and nothing is reported.
|
||||
await hookOf(beforeStep, 'spine')(beforeCtx(new AbortController().signal), noopNext);
|
||||
|
||||
expect(readSpine(ctx).nodes['1.1.1']?.closedAt).toBe(1);
|
||||
expect(reported).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spine control tool main-agent gating', () => {
|
||||
|
|
@ -961,7 +797,13 @@ describe('spine control tool main-agent gating', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// The live tree is derived from the message stream; read it through the service.
|
||||
function readSpine(ctx: TestAgentContext) {
|
||||
return ctx.get(IAgentSpineService).currentState();
|
||||
}
|
||||
|
||||
// The legacy op reducers are exercised directly against the wire model.
|
||||
function readOps(ctx: TestAgentContext) {
|
||||
return ctx.get(IAgentWireService).getModel(SpineModel);
|
||||
}
|
||||
|
||||
|
|
@ -1011,110 +853,6 @@ function textOf(message: { content?: readonly { type: string; text?: string }[]
|
|||
);
|
||||
}
|
||||
|
||||
async function recordSpineTurn(): Promise<readonly PersistedWireRecord[]> {
|
||||
const persistence = new InMemoryWireRecordPersistence();
|
||||
const ctx = testAgent({ persistence }, execEnvServices({ hostFs: recordingHostFs().fs }));
|
||||
await configureLoop(ctx);
|
||||
ctx.mockNextResponse(toolCallPart('call_open', 'spine_open', { summary: 'task A' }));
|
||||
ctx.mockNextResponse(toolCallPart('call_close', 'spine_close', { memory: 'did A' }));
|
||||
ctx.mockNextResponse({ type: 'text', text: 'finished' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'start' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
return persistence.records;
|
||||
}
|
||||
|
||||
async function restoreAndCaptureReports(
|
||||
records: readonly PersistedWireRecord[],
|
||||
): Promise<unknown[]> {
|
||||
const reported: unknown[] = [];
|
||||
setUnexpectedErrorHandler((err) => {
|
||||
reported.push(err);
|
||||
});
|
||||
try {
|
||||
const ctx = testAgent();
|
||||
// Force the Eager spine service up so its onRestored audit is registered
|
||||
// before the replay fires the restored handlers.
|
||||
ctx.get(IAgentSpineService);
|
||||
const wireRecord = ctx.get(IAgentWireRecordService);
|
||||
await wireRecord.restore(records);
|
||||
const restored = wireRecord.getRecords() as readonly PersistedRecord[];
|
||||
await ctx.get(IAgentWireService).replay(...restored);
|
||||
} finally {
|
||||
resetUnexpectedErrorHandler();
|
||||
}
|
||||
return reported;
|
||||
}
|
||||
|
||||
function isToolResultRecord(record: PersistedWireRecord, toolCallId: string): boolean {
|
||||
const r = record as {
|
||||
readonly type?: string;
|
||||
readonly event?: { readonly type?: string; readonly toolCallId?: string };
|
||||
};
|
||||
return (
|
||||
r.type === 'context.append_loop_event' &&
|
||||
r.event?.type === 'tool.result' &&
|
||||
r.event?.toolCallId === toolCallId
|
||||
);
|
||||
}
|
||||
|
||||
function withToolResultText(record: PersistedWireRecord, text: string): PersistedWireRecord {
|
||||
const r = record as {
|
||||
readonly event?: { readonly result?: { readonly output?: unknown } };
|
||||
};
|
||||
if (r.event?.result === undefined) return record;
|
||||
return {
|
||||
...record,
|
||||
event: { ...r.event, result: { ...r.event.result, output: text } },
|
||||
} as unknown as PersistedWireRecord;
|
||||
}
|
||||
|
||||
type BeforeStepHook = (ctx: BeforeStepContext, next: () => Promise<void>) => Promise<void>;
|
||||
type AfterStepHook = (ctx: AfterStepContext, next: () => Promise<void>) => Promise<void>;
|
||||
|
||||
const noopNext = async (): Promise<void> => {};
|
||||
|
||||
/**
|
||||
* Stand up the spine service against a fake loop that captures the registered
|
||||
* step hooks, so tests can drive beforeStep / afterStep by hand instead of
|
||||
* racing a real turn.
|
||||
*/
|
||||
function spineWithCapturedStepHooks(...overrides: readonly TestAgentServiceOverride[]): {
|
||||
readonly beforeStep: Map<string, BeforeStepHook>;
|
||||
readonly afterStep: Map<string, AfterStepHook>;
|
||||
readonly reported: unknown[];
|
||||
readonly spine: IAgentSpineService;
|
||||
readonly ctx: TestAgentContext;
|
||||
} {
|
||||
const beforeStep = new Map<string, BeforeStepHook>();
|
||||
const afterStep = new Map<string, AfterStepHook>();
|
||||
const fakeLoop = {
|
||||
_serviceBrand: undefined,
|
||||
run: async () => ({ type: 'completed' as const, steps: 0, truncated: false }),
|
||||
hooks: {
|
||||
onWillBeginStep: {
|
||||
register: (name: string, fn: BeforeStepHook) => {
|
||||
beforeStep.set(name, fn);
|
||||
return Disposable.None;
|
||||
},
|
||||
},
|
||||
onDidFinishStep: {
|
||||
register: (name: string, fn: AfterStepHook) => {
|
||||
afterStep.set(name, fn);
|
||||
return Disposable.None;
|
||||
},
|
||||
},
|
||||
onError: { register: () => Disposable.None },
|
||||
},
|
||||
registerLoopErrorHandler: () => Disposable.None,
|
||||
} as unknown as IAgentLoopService;
|
||||
const reported: unknown[] = [];
|
||||
setUnexpectedErrorHandler((err) => {
|
||||
reported.push(err);
|
||||
});
|
||||
const ctx = testAgent(agentService(IAgentLoopService, fakeLoop), ...overrides);
|
||||
return { beforeStep, afterStep, reported, spine: ctx.get(IAgentSpineService), ctx };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool-call pairing invariant over a folded history: every tool result must
|
||||
* appear after the assistant message carrying its call. Returns the ids of
|
||||
|
|
@ -1137,16 +875,23 @@ function toolPairingGaps(messages: readonly ContextMessage[]): string[] {
|
|||
return gaps;
|
||||
}
|
||||
|
||||
function hookOf<THook>(hooks: Map<string, THook>, name: string): THook {
|
||||
const hook = hooks.get(name);
|
||||
if (hook === undefined) throw new Error(`hook '${name}' was not registered`);
|
||||
return hook;
|
||||
function assistantSpineCall(
|
||||
id: string,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
): ContextMessage {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: `calling ${name}` }],
|
||||
toolCalls: [{ type: 'function', id, name, arguments: JSON.stringify(args) }],
|
||||
};
|
||||
}
|
||||
|
||||
function beforeCtx(signal: AbortSignal): BeforeStepContext {
|
||||
return { turnId: 1, step: 1, signal };
|
||||
}
|
||||
|
||||
function afterCtx(signal: AbortSignal): AfterStepContext {
|
||||
return { turnId: 1, step: 1, signal } as unknown as AfterStepContext;
|
||||
function spineReceipt(toolCallId: string): ContextMessage {
|
||||
return {
|
||||
role: 'tool',
|
||||
content: [{ type: 'text', text: ACCEPTED_OUTPUT }],
|
||||
toolCalls: [],
|
||||
toolCallId,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export const LegacyStatusModel = defineDerivedModel<LegacyStatusState>(
|
|||
export interface LegacyStatusSnapshot {
|
||||
readonly usage?: UsageStatus;
|
||||
readonly contextTokens: number;
|
||||
readonly rawContextTokens: number;
|
||||
readonly maxContextTokens: number;
|
||||
readonly model: string;
|
||||
}
|
||||
|
|
@ -81,9 +82,13 @@ export function readLegacyStatus(agent: IAgentScopeHandle): LegacyStatusSnapshot
|
|||
const contextSize = agent.accessor.get(IAgentContextSizeService);
|
||||
const measured = agent.accessor.get(IAgentWireService).getModel(ContextSizeModel);
|
||||
const contextTokens = Math.max(contextSize.get().size, measured.tokens);
|
||||
// Unfolded twin of contextTokens, floored at the same reading so the wire
|
||||
// invariant raw >= projected also holds in the transient window where the
|
||||
// measured total outruns the live estimate (see above).
|
||||
const rawContextTokens = Math.max(contextSize.rawSize(), contextTokens);
|
||||
const maxContextTokens = profile.getModelCapabilities().max_context_tokens;
|
||||
const model = profile.getModel();
|
||||
return { usage, contextTokens, maxContextTokens, model };
|
||||
return { usage, contextTokens, rawContextTokens, maxContextTokens, model };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -135,6 +135,8 @@ export const sessionStatusResponseSchema = z.object({
|
|||
plan_mode: z.boolean(),
|
||||
swarm_mode: z.boolean(),
|
||||
context_tokens: z.number().int().nonnegative(),
|
||||
// Unfolded-request cost (>= context_tokens); optional — older daemons omit it.
|
||||
raw_context_tokens: z.number().int().nonnegative().optional(),
|
||||
max_context_tokens: z.number().int().nonnegative(),
|
||||
context_usage: z.number().min(0).max(1),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue