@wowyuarm/dsh-agent-team 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -11
- package/README.zh.md +4 -11
- package/package.json +54 -44
- package/packages/agent-team/README.md +7 -5
- package/packages/agent-team/README.zh.md +7 -4
- package/packages/agent-team/lib/attachments.js +14 -4
- package/packages/agent-team/lib/context-management.js +382 -0
- package/packages/agent-team/lib/context-projection.js +578 -0
- package/packages/agent-team/lib/context-source.js +212 -0
- package/packages/agent-team/lib/index.js +1272 -76
- package/packages/agent-team/lib/ledger.js +333 -44
- package/packages/agent-team/lib/member-context.js +6 -2
- package/packages/agent-team/lib/member-runtime.js +159 -4
- package/packages/agent-team/lib/member-time-context.js +126 -0
- package/packages/agent-team/lib/pressure-policy.js +199 -0
- package/packages/agent-team/lib/progress-nudge.js +12 -4
- package/packages/agent-team/lib/session-remediation.js +481 -0
- package/packages/agent-team/lib/spec.js +21 -2
- package/packages/agent-team/lib/time-format.js +56 -0
- package/packages/agent-team/lib/typert.host.js +168 -54
- package/packages/agent-team/lib/typert.remote-client.d.ts.map +1 -1
- package/packages/agent-team/lib/typert.remote-client.js +59 -25
- package/packages/agent-team/lib/types/attachments.d.ts +1 -1
- package/packages/agent-team/lib/types/attachments.d.ts.map +1 -1
- package/packages/agent-team/lib/types/context-management.d.ts +147 -0
- package/packages/agent-team/lib/types/context-management.d.ts.map +1 -0
- package/packages/agent-team/lib/types/context-projection.d.ts +219 -0
- package/packages/agent-team/lib/types/context-projection.d.ts.map +1 -0
- package/packages/agent-team/lib/types/context-source.d.ts +117 -0
- package/packages/agent-team/lib/types/context-source.d.ts.map +1 -0
- package/packages/agent-team/lib/types/index.d.ts +300 -2
- package/packages/agent-team/lib/types/index.d.ts.map +1 -1
- package/packages/agent-team/lib/types/ledger.d.ts +119 -6
- package/packages/agent-team/lib/types/ledger.d.ts.map +1 -1
- package/packages/agent-team/lib/types/member-context.d.ts.map +1 -1
- package/packages/agent-team/lib/types/member-runtime.d.ts +30 -2
- package/packages/agent-team/lib/types/member-runtime.d.ts.map +1 -1
- package/packages/agent-team/lib/types/member-time-context.d.ts +83 -0
- package/packages/agent-team/lib/types/member-time-context.d.ts.map +1 -0
- package/packages/agent-team/lib/types/pressure-policy.d.ts +106 -0
- package/packages/agent-team/lib/types/pressure-policy.d.ts.map +1 -0
- package/packages/agent-team/lib/types/progress-nudge.d.ts.map +1 -1
- package/packages/agent-team/lib/types/session-remediation.d.ts +149 -0
- package/packages/agent-team/lib/types/session-remediation.d.ts.map +1 -0
- package/packages/agent-team/lib/types/spec.d.ts.map +1 -1
- package/packages/agent-team/lib/types/time-format.d.ts +28 -0
- package/packages/agent-team/lib/types/time-format.d.ts.map +1 -0
- package/packages/agent-team/lib/types/types/entities.d.ts +22 -1
- package/packages/agent-team/lib/types/types/entities.d.ts.map +1 -1
- package/packages/agent-team/lib/types/types/operations.d.ts +38 -3
- package/packages/agent-team/lib/types/types/operations.d.ts.map +1 -1
- package/packages/agent-team/lib/types/types/requests-results.d.ts +58 -1
- package/packages/agent-team/lib/types/types/requests-results.d.ts.map +1 -1
- package/packages/agent-team/preset/team-member/agent.cordis.yml +27 -4
- package/packages/client-agent-team/lib/client.js +369 -352
- package/packages/client-agent-team/lib/client.js.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts +1 -2
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.js +61 -41
- package/packages/client-agent-team/lib/types/client/TeamMessage.js +2 -2
- package/packages/client-agent-team/lib/types/client/TeamThreadPage.js +4 -4
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts +1 -1
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.js +2 -2
- package/packages/client-agent-team/lib/types/client/index.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/index.js +44 -16
- package/packages/client-agent-team/lib/types/client/locales.d.ts +0 -14
- package/packages/client-agent-team/lib/types/client/locales.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/locales.js +0 -14
- package/packages/client-agent-team/lib/types/client/slots.d.ts +6 -1
- package/packages/client-agent-team/lib/types/client/slots.d.ts.map +1 -1
- package/packages/tool-agent-team/README.md +11 -6
- package/packages/tool-agent-team/README.zh.md +11 -6
- package/packages/tool-agent-team/lib/context-tools.js +179 -0
- package/packages/tool-agent-team/lib/index.js +369 -58
- package/packages/tool-agent-team/lib/types/context-tools.d.ts +16 -0
- package/packages/tool-agent-team/lib/types/context-tools.d.ts.map +1 -0
- package/packages/tool-agent-team/lib/types/index.d.ts.map +1 -1
- package/packages/agent-team/lib/auto-compaction.js +0 -201
- package/packages/agent-team/lib/types/auto-compaction.d.ts +0 -47
- package/packages/agent-team/lib/types/auto-compaction.d.ts.map +0 -1
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-only Session projection for Agent Team context management.
|
|
3
|
+
*
|
|
4
|
+
* One pure synchronous fold over a Member Session log derives every
|
|
5
|
+
* context-management fact the Host needs: pending rollover intent (a
|
|
6
|
+
* successful `context_rollover` call/result pair — under the legacy
|
|
7
|
+
* `new_context` name for Sessions recorded before the rename), explicit
|
|
8
|
+
* checkpoints (a
|
|
9
|
+
* successful `context_checkpoint` pair resolved by its containing `turn/end`),
|
|
10
|
+
* handoff/compaction boundaries, and quiet-continuation delivery state. The
|
|
11
|
+
* fold is the single authority for these facts — no second store, and callers
|
|
12
|
+
* never re-derive intent from raw events.
|
|
13
|
+
*
|
|
14
|
+
* A seeded child Session folds with `inheritedEventCount` respected: events
|
|
15
|
+
* inherited from the fork prefix are already resolved history, never fresh
|
|
16
|
+
* intent. The cold fold and the registered unit both start past the inherited
|
|
17
|
+
* cut, so an inherited historical rollover call can never schedule
|
|
18
|
+
* another transition in the child.
|
|
19
|
+
* @module @wowyuarm/dsh-agent-team/context-projection
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import { z } from 'zod';
|
|
23
|
+
import { AGENT_TEAM_PLUGIN_ID, continuationCheckpointRefOf, handoffOf, isAgentTeamContextSource, isCheckpointContinuationMessage } from "./context-source.js";
|
|
24
|
+
/** Summary marker of the pre-compaction memory hint. */
|
|
25
|
+
const PRE_COMPACTION_NOTICE_SUMMARY = 'Compaction is imminent; consider persisting key conclusions.';
|
|
26
|
+
/** Team tool whose successful mutations are semantic timeline candidates. */
|
|
27
|
+
const TEAM_CLAIM_TOOL_NAME = 'team_claim';
|
|
28
|
+
/** Team tools whose successful Thread effects are semantic timeline candidates. */
|
|
29
|
+
const TEAM_MESSAGE_TOOL_NAME = 'team_message';
|
|
30
|
+
const TEAM_THREAD_TOOL_NAME = 'team_thread';
|
|
31
|
+
/** Fixed action-category labels for effect boundaries (mirrors the claim label's shape). */
|
|
32
|
+
const TEAM_MESSAGE_BOUNDARY_LABEL = 'Team message';
|
|
33
|
+
const TEAM_ATTENTION_BOUNDARY_LABEL = 'Team attention change';
|
|
34
|
+
const TEAM_CLAIM_BOUNDARY_LABEL = 'Team task claim change';
|
|
35
|
+
/**
|
|
36
|
+
* Notice summaries that are pure reminders, never semantic Team facts: a
|
|
37
|
+
* progress nudge or a recovery instruction must not become a return anchor.
|
|
38
|
+
*/
|
|
39
|
+
const REMINDER_NOTICE_SUMMARIES = new Set(['Progress visibility reminder', 'Recovery: continue your interrupted work.']);
|
|
40
|
+
/** Whether one notice summary is a pure reminder (never a semantic Team fact). */
|
|
41
|
+
export function isReminderNoticeSummary(summary) {
|
|
42
|
+
return REMINDER_NOTICE_SUMMARIES.has(summary);
|
|
43
|
+
}
|
|
44
|
+
/** Stable tool names the projection recognizes. */
|
|
45
|
+
export const CONTEXT_CHECKPOINT_TOOL_NAME = 'context_checkpoint';
|
|
46
|
+
export const CONTEXT_ROLLOVER_TOOL_NAME = 'context_rollover';
|
|
47
|
+
/**
|
|
48
|
+
* Legacy decoder name: the rollover tool was renamed `new_context` →
|
|
49
|
+
* `context_rollover`, and Sessions recorded before the rename still carry
|
|
50
|
+
* durable `new_context` call/result pairs. The projection keeps folding them
|
|
51
|
+
* — pending rollovers and crash recovery of existing Members depend on old
|
|
52
|
+
* events still resolving intent — but this is a log decoder, not a tool
|
|
53
|
+
* alias: no new call can carry the old name.
|
|
54
|
+
*/
|
|
55
|
+
export const NEW_CONTEXT_TOOL_NAME = 'new_context';
|
|
56
|
+
/** Whether one recorded tool name is a rollover call under the current or the legacy name. */
|
|
57
|
+
function isRolloverToolName(name) {
|
|
58
|
+
return name === CONTEXT_ROLLOVER_TOOL_NAME || name === NEW_CONTEXT_TOOL_NAME;
|
|
59
|
+
}
|
|
60
|
+
/** Whether one tool name can produce a Team-effect boundary from a successful call. */
|
|
61
|
+
function isTeamEffectToolName(name) {
|
|
62
|
+
return name === TEAM_CLAIM_TOOL_NAME || name === TEAM_MESSAGE_TOOL_NAME || name === TEAM_THREAD_TOOL_NAME;
|
|
63
|
+
}
|
|
64
|
+
/** Whether one raw team_thread arguments string is an attention mutation (follow/unfollow). */
|
|
65
|
+
function argumentsAreAttentionMutation(raw) {
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(raw);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
74
|
+
return false;
|
|
75
|
+
const { action } = parsed;
|
|
76
|
+
return action === 'follow' || action === 'unfollow';
|
|
77
|
+
}
|
|
78
|
+
/** Whether one raw team_message arguments string is a Thread-effect attempt (start/reply — dm is not a Thread fact). */
|
|
79
|
+
function argumentsAreMessageCommit(raw) {
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(raw);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
88
|
+
return false;
|
|
89
|
+
const { action } = parsed;
|
|
90
|
+
return action === 'start' || action === 'reply';
|
|
91
|
+
}
|
|
92
|
+
const relatedFileSchema = z.object({ path: z.string().min(1), reason: z.string() }).strict();
|
|
93
|
+
const checkpointRefSchema = z.string().regex(/^(context-checkpoint-[0-9a-f]{64}|team-boundary-[0-9a-f]{64})$/).transform(value => value);
|
|
94
|
+
const openCallSchema = z.object({ callId: z.string().min(1), name: z.string(), arguments: z.string() }).strict();
|
|
95
|
+
const carriedCandidateSchema = z.object({
|
|
96
|
+
message: z.any(),
|
|
97
|
+
surfacedTurn: z.number().int(),
|
|
98
|
+
consumed: z.boolean(),
|
|
99
|
+
});
|
|
100
|
+
const boundarySchema = z.object({
|
|
101
|
+
key: z.string().min(1),
|
|
102
|
+
source: z.enum(['team-boundary', 'handoff', 'compaction']),
|
|
103
|
+
label: z.string(),
|
|
104
|
+
seq: z.number().int().nonnegative(),
|
|
105
|
+
turn: z.number().int().nonnegative(),
|
|
106
|
+
turnEndSeq: z.number().int(),
|
|
107
|
+
}).strict();
|
|
108
|
+
const stateSchema = z.object({
|
|
109
|
+
checkpoints: z.array(z.object({
|
|
110
|
+
checkpointRef: checkpointRefSchema,
|
|
111
|
+
name: z.string(),
|
|
112
|
+
resultSeq: z.number().int().nonnegative(),
|
|
113
|
+
turn: z.number().int().nonnegative(),
|
|
114
|
+
turnEndSeq: z.number().int(),
|
|
115
|
+
}).strict()),
|
|
116
|
+
pending: z.object({
|
|
117
|
+
toolCallId: z.string().min(1),
|
|
118
|
+
resultSeq: z.number().int().nonnegative(),
|
|
119
|
+
turn: z.number().int().nonnegative(),
|
|
120
|
+
handoff: z.string(),
|
|
121
|
+
checkpointRef: checkpointRefSchema.optional(),
|
|
122
|
+
relatedFiles: z.array(relatedFileSchema),
|
|
123
|
+
turnEndSeq: z.number().int(),
|
|
124
|
+
}).strict().nullable(),
|
|
125
|
+
continuations: z.array(z.object({
|
|
126
|
+
checkpointRef: checkpointRefSchema,
|
|
127
|
+
deliveredSeq: z.number().int(),
|
|
128
|
+
}).strict()),
|
|
129
|
+
carriedCandidates: z.array(carriedCandidateSchema),
|
|
130
|
+
lastTurn: z.number().int().nonnegative(),
|
|
131
|
+
openCalls: z.array(openCallSchema),
|
|
132
|
+
boundaries: z.array(boundarySchema),
|
|
133
|
+
seenThreads: z.array(z.string()),
|
|
134
|
+
lastTurnEndSeq: z.number().int(),
|
|
135
|
+
}).strict();
|
|
136
|
+
/**
|
|
137
|
+
* Deterministic checkpoint ref from the recording session and tool call
|
|
138
|
+
* identity: a bounded, collision-resistant opaque token. Provider call ids
|
|
139
|
+
* are arbitrary-length free text that may repeat across generations and even
|
|
140
|
+
* collide between Sessions, so the ref derives from the SHA-256 of the exact
|
|
141
|
+
* `(sessionId, callId)` pair — same pair always reproduces the ref, any
|
|
142
|
+
* other pair is overwhelmingly unlikely to collide, and the token can never
|
|
143
|
+
* smuggle delimiters or unbounded content through a ref field.
|
|
144
|
+
*/
|
|
145
|
+
export function checkpointRefFor(sessionId, callId) {
|
|
146
|
+
return `context-checkpoint-${createHash('sha256').update(JSON.stringify([sessionId, callId])).digest('hex')}`;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Deterministic default-boundary ref for one delivered Team boundary: the
|
|
150
|
+
* same session-scoped hash shape as checkpoint refs, keyed on the boundary's
|
|
151
|
+
* anchoring event seq. Consecutive generations routinely repeat event seqs,
|
|
152
|
+
* so the Session identity must be part of the key or two generations'
|
|
153
|
+
* boundaries at the same seq collide — the timeline would silently drop the
|
|
154
|
+
* ancestor item and a `context_rollover` return would resolve the wrong boundary.
|
|
155
|
+
*/
|
|
156
|
+
export function boundaryRefFor(sessionId, seq) {
|
|
157
|
+
return `team-boundary-${createHash('sha256').update(JSON.stringify([sessionId, seq])).digest('hex')}`;
|
|
158
|
+
}
|
|
159
|
+
function emptyState() {
|
|
160
|
+
return { checkpoints: [], pending: null, continuations: [], carriedCandidates: [], lastTurn: 0, openCalls: [], boundaries: [], seenThreads: [], lastTurnEndSeq: -1 };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Cold-fold one immutable event log into the projection state. Events at or
|
|
164
|
+
* before `inheritedEventCount` belong to the fork prefix and are resolved
|
|
165
|
+
* history in this Session, so they never produce fresh intent. `sessionId` is
|
|
166
|
+
* the log's own Session identity — it keys checkpoint refs, so the same
|
|
167
|
+
* provider call id in two different Sessions produces two distinct refs.
|
|
168
|
+
*/
|
|
169
|
+
export function foldContextProjection(events, inheritedEventCount = 0, sessionId = '') {
|
|
170
|
+
let state = emptyState();
|
|
171
|
+
const inherited = Number(inheritedEventCount);
|
|
172
|
+
for (const event of events) {
|
|
173
|
+
if (event.seq < inherited)
|
|
174
|
+
continue;
|
|
175
|
+
state = applyContextEvent(state, event, sessionId);
|
|
176
|
+
}
|
|
177
|
+
return state;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* The host-only projection unit; no wire view is published. The definition is
|
|
181
|
+
* a factory: each Session folds with its own identity so checkpoint refs
|
|
182
|
+
* derive from that Session's exact `(sessionId, callId)` pairs.
|
|
183
|
+
*/
|
|
184
|
+
export const agentTeamContextProjectionDefinition = (sessionId) => ({
|
|
185
|
+
key: 'agentTeamContext',
|
|
186
|
+
// v4: first-arrival boundary labels — notice-class boundaries label the
|
|
187
|
+
// Threads they first introduce (`First arrival: …`) instead of the
|
|
188
|
+
// notice's own summary, and the claim boundary label names its Task
|
|
189
|
+
// surface (`Team task claim change`). Labels are fold-time synthesized,
|
|
190
|
+
// not stored facts, so a persisted v3 row must not survive next to the
|
|
191
|
+
// new vocabulary: the ver mismatch refolds it from the full log.
|
|
192
|
+
stateVersion: 4,
|
|
193
|
+
stateSchema,
|
|
194
|
+
init: (_header, _inheritedEventCount) => emptyState(),
|
|
195
|
+
apply: (state, event) => applyContextEvent(state, event, sessionId),
|
|
196
|
+
});
|
|
197
|
+
/**
|
|
198
|
+
* Pure transition: previous state + one committed event → next state. Returns
|
|
199
|
+
* the same reference when the event is not this unit's.
|
|
200
|
+
*/
|
|
201
|
+
function applyContextEvent(state, event, sessionId) {
|
|
202
|
+
if (event.type === 'tool/call') {
|
|
203
|
+
if (!isRolloverToolName(event.data.name) && event.data.name !== CONTEXT_CHECKPOINT_TOOL_NAME && !isTeamEffectToolName(event.data.name))
|
|
204
|
+
return state;
|
|
205
|
+
// Only Team-effect calls that can produce a boundary are tracked; `list`
|
|
206
|
+
// and other non-mutations are not semantic timeline candidates.
|
|
207
|
+
if (event.data.name === TEAM_CLAIM_TOOL_NAME && !argumentsAreClaimMutation(event.data.arguments))
|
|
208
|
+
return state;
|
|
209
|
+
if (event.data.name === TEAM_THREAD_TOOL_NAME && !argumentsAreAttentionMutation(event.data.arguments))
|
|
210
|
+
return state;
|
|
211
|
+
if (event.data.name === TEAM_MESSAGE_TOOL_NAME && !argumentsAreMessageCommit(event.data.arguments))
|
|
212
|
+
return state;
|
|
213
|
+
return { ...state, openCalls: [...state.openCalls, { callId: event.data.callId, name: event.data.name, arguments: event.data.arguments }] };
|
|
214
|
+
}
|
|
215
|
+
if (event.type === 'tool/result') {
|
|
216
|
+
return applyToolResult(state, event.seq, event.data.turn, event.data.message, event.data.error !== undefined, sessionId, event.data.meta);
|
|
217
|
+
}
|
|
218
|
+
if (event.type === 'turn/end') {
|
|
219
|
+
return applyTurnEnd(state, event.seq, event.data.turn);
|
|
220
|
+
}
|
|
221
|
+
if (event.type === 'user/message') {
|
|
222
|
+
return applyUserMessage(state, event.seq, event.data, sessionId);
|
|
223
|
+
}
|
|
224
|
+
if (event.type === 'agent/inbox/spliced') {
|
|
225
|
+
return applyInboxSpliced(state, event.data);
|
|
226
|
+
}
|
|
227
|
+
if (event.type === 'assistant/message') {
|
|
228
|
+
return applyAssistantMessage(state, event);
|
|
229
|
+
}
|
|
230
|
+
if (event.type === 'turn/start') {
|
|
231
|
+
return event.data.turn === state.lastTurn ? state : { ...state, lastTurn: event.data.turn };
|
|
232
|
+
}
|
|
233
|
+
return state;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Structural timeline boundary from one delivered user message: a rollover
|
|
237
|
+
* handoff starts a generation; a compaction notice rewrites the visible
|
|
238
|
+
* surface. A structured Team notification is a boundary ONLY on the first
|
|
239
|
+
* arrival of each Thread's facts into this Session — the preserved
|
|
240
|
+
* "work just arrived" anchor; every later re-delivery of the same Thread is
|
|
241
|
+
* noise. Reminder notices (progress nudges, recovery instructions) never
|
|
242
|
+
* anchor. All anchor to the containing completed turn. Plain Human/agent
|
|
243
|
+
* prose and quiet checkpoint continuations are not boundaries.
|
|
244
|
+
*/
|
|
245
|
+
function boundaryFromUserMessage(sessionId, seq, message, seenThreads) {
|
|
246
|
+
const source = message.source;
|
|
247
|
+
if (handoffOf(message) !== undefined) {
|
|
248
|
+
return { key: `handoff:${seq}`, source: 'handoff', label: 'context handoff', seq, turn: -1, turnEndSeq: -1 };
|
|
249
|
+
}
|
|
250
|
+
if (source.kind !== 'plugin' || source.plugin !== AGENT_TEAM_PLUGIN_ID)
|
|
251
|
+
return undefined;
|
|
252
|
+
if (source.form === 'relay')
|
|
253
|
+
return undefined;
|
|
254
|
+
if (source.form === 'notice' && source.summary === PRE_COMPACTION_NOTICE_SUMMARY) {
|
|
255
|
+
return { key: `compaction:${seq}`, source: 'compaction', label: 'compaction notice', seq, turn: -1, turnEndSeq: -1 };
|
|
256
|
+
}
|
|
257
|
+
if (source.form === 'notice' && source.summary !== undefined && REMINDER_NOTICE_SUMMARIES.has(source.summary)) {
|
|
258
|
+
return { reminder: true };
|
|
259
|
+
}
|
|
260
|
+
// The Threads this delivery first introduces anchor the boundary; a
|
|
261
|
+
// delivery that introduces none (pure re-delivery) is noise.
|
|
262
|
+
const firstArrivals = threadsQuotedInMessage(message).filter(ref => !seenThreads.includes(ref));
|
|
263
|
+
if (firstArrivals.length === 0)
|
|
264
|
+
return undefined;
|
|
265
|
+
// The label states WHAT first arrived — the Thread refs this delivery
|
|
266
|
+
// introduced into the context — never the notice's own generic account
|
|
267
|
+
// (e.g. "unread work"): the timeline's decision surface needs the
|
|
268
|
+
// attribution before a checkpointRef pick, and it is only knowable here,
|
|
269
|
+
// at the fold, where first arrival is computed.
|
|
270
|
+
const label = `First arrival: ${firstArrivals.join(', ')}`;
|
|
271
|
+
return { key: boundaryRefFor(sessionId, seq), source: 'team-boundary', label, seq, turn: -1, turnEndSeq: -1, firstArrival: firstArrivals };
|
|
272
|
+
}
|
|
273
|
+
/** Thread refs structurally quoted in one delivered message body (`Thread: <ref>` lines). */
|
|
274
|
+
function threadsQuotedInMessage(message) {
|
|
275
|
+
const refs = [];
|
|
276
|
+
const text = message.content.filter(block => block.type === 'text').map(block => block.text ?? '').join('\n');
|
|
277
|
+
for (const match of text.matchAll(/Thread: (thread:[0-9a-f-]{6,})/g)) {
|
|
278
|
+
if (!refs.includes(match[1]))
|
|
279
|
+
refs.push(match[1]);
|
|
280
|
+
}
|
|
281
|
+
return refs;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Fold one durable inbox splice. After a pending intent exists, every
|
|
285
|
+
* non-Team message inserted into the inbox is a carry candidate; the
|
|
286
|
+
* durable log is the truth, so a claim, removal, or cancel can never make
|
|
287
|
+
* the message vanish silently — only surfacing it onto the model-visible
|
|
288
|
+
* `user/message` surface consumes it.
|
|
289
|
+
*/
|
|
290
|
+
function applyInboxSpliced(state, data) {
|
|
291
|
+
if (state.pending === null || data.inserted.length === 0)
|
|
292
|
+
return state;
|
|
293
|
+
const fresh = data.inserted
|
|
294
|
+
.filter(message => !isTeamNotice(message))
|
|
295
|
+
.filter(message => !state.carriedCandidates.some(candidate => candidate.message.id === message.id))
|
|
296
|
+
.map(message => ({ message, surfacedTurn: -1, consumed: false }));
|
|
297
|
+
if (fresh.length === 0)
|
|
298
|
+
return state;
|
|
299
|
+
return { ...state, carriedCandidates: [...state.carriedCandidates, ...fresh] };
|
|
300
|
+
}
|
|
301
|
+
function applyToolResult(state, seq, turn, message, internalFailure, sessionId, meta) {
|
|
302
|
+
const block = message.content[0];
|
|
303
|
+
if (block === undefined || block.type !== 'tool-result')
|
|
304
|
+
return state;
|
|
305
|
+
// A landed result — success or failure — consumes its paired open call.
|
|
306
|
+
// Leaving a failed result's call open would dangle it forever, and a
|
|
307
|
+
// provider retry reusing the callId would pair its fresh success result
|
|
308
|
+
// with these stale arguments. An unpaired result (no matching open call)
|
|
309
|
+
// touches nothing.
|
|
310
|
+
const index = state.openCalls.findIndex(call => call.callId === block.toolCallId);
|
|
311
|
+
if (index === -1)
|
|
312
|
+
return state;
|
|
313
|
+
const recorded = state.openCalls[index];
|
|
314
|
+
const openCalls = state.openCalls.filter(call => call.callId !== block.toolCallId);
|
|
315
|
+
// A successful pair only: model-visible errors and internal failures carry
|
|
316
|
+
// neither checkpoint, rollover intent, nor an effect boundary — the
|
|
317
|
+
// failure-face contract also holds for a team_message whose presentation
|
|
318
|
+
// meta projection failed (that lands as a ToolOutputError result).
|
|
319
|
+
if (block.isError === true || internalFailure)
|
|
320
|
+
return { ...state, openCalls };
|
|
321
|
+
if (isRolloverToolName(recorded.name)) {
|
|
322
|
+
const parsed = parseRolloverArguments(recorded.arguments);
|
|
323
|
+
if (parsed === undefined)
|
|
324
|
+
return { ...state, openCalls };
|
|
325
|
+
// One pending intent per unresolved turn: a second successful call inside
|
|
326
|
+
// the SAME turn replaces nothing — the first owns the swap. A pending
|
|
327
|
+
// whose turn ended is the ready (or, after a restart, recoverable)
|
|
328
|
+
// intent, NOT spent: the coordinator's process lock normally rejects a
|
|
329
|
+
// later call before it can reach this fold. Only once that lock is gone
|
|
330
|
+
// (the transition failed or never ran) can a later-turn successful
|
|
331
|
+
// rollover replace the ended intent — the retry path that recovers the
|
|
332
|
+
// Member when the tool kept answering `scheduled` but no swap came.
|
|
333
|
+
if (state.pending !== null && state.pending.turnEndSeq === -1)
|
|
334
|
+
return { ...state, openCalls };
|
|
335
|
+
return { ...state, openCalls, pending: { ...parsed, toolCallId: recorded.callId, resultSeq: seq, turn, turnEndSeq: -1 } };
|
|
336
|
+
}
|
|
337
|
+
if (recorded.name === CONTEXT_CHECKPOINT_TOOL_NAME) {
|
|
338
|
+
const parsed = parseCheckpointArguments(recorded.arguments);
|
|
339
|
+
if (parsed === undefined)
|
|
340
|
+
return { ...state, openCalls };
|
|
341
|
+
const checkpointRef = checkpointRefFor(sessionId, block.toolCallId);
|
|
342
|
+
return {
|
|
343
|
+
...state,
|
|
344
|
+
openCalls,
|
|
345
|
+
checkpoints: [...state.checkpoints, { checkpointRef, name: parsed.name, resultSeq: seq, turn, turnEndSeq: -1 }],
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
// Effect boundaries — the attribution matrix:
|
|
349
|
+
// claim mutation → the Task ref from its call arguments (Task → Thread
|
|
350
|
+
// resolution stays a query-time ledger concern);
|
|
351
|
+
// team_message committed → reply/taskRef from args, start from the
|
|
352
|
+
// structured presentation meta (`kind === 'committed'` guards every
|
|
353
|
+
// typed rejection; no meta means no boundary — old logs refold without
|
|
354
|
+
// start anchors by design, never by parsing render text);
|
|
355
|
+
// team_thread follow/unfollow → the threadRef from its call arguments.
|
|
356
|
+
// A boundary without a resolvable ref is not produced.
|
|
357
|
+
const boundary = effectBoundaryFor(recorded, sessionId, seq, turn, meta);
|
|
358
|
+
if (boundary === undefined)
|
|
359
|
+
return { ...state, openCalls };
|
|
360
|
+
return { ...state, openCalls, boundaries: [...state.boundaries, boundary] };
|
|
361
|
+
}
|
|
362
|
+
/** The effect boundary one successful Team-effect call produces, if its attribution resolves. */
|
|
363
|
+
function effectBoundaryFor(recorded, sessionId, seq, turn, meta) {
|
|
364
|
+
if (recorded.name === TEAM_CLAIM_TOOL_NAME) {
|
|
365
|
+
return { key: boundaryRefFor(sessionId, seq), source: 'team-boundary', label: TEAM_CLAIM_BOUNDARY_LABEL, seq, turn, turnEndSeq: -1 };
|
|
366
|
+
}
|
|
367
|
+
if (recorded.name === TEAM_THREAD_TOOL_NAME) {
|
|
368
|
+
return { key: boundaryRefFor(sessionId, seq), source: 'team-boundary', label: TEAM_ATTENTION_BOUNDARY_LABEL, seq, turn, turnEndSeq: -1 };
|
|
369
|
+
}
|
|
370
|
+
if (recorded.name === TEAM_MESSAGE_TOOL_NAME) {
|
|
371
|
+
// The structured meta projection is the single attribution source for a
|
|
372
|
+
// start (the Thread is born in the result); replies carry their ref in
|
|
373
|
+
// the call arguments already, but the committed guard is meta-side for
|
|
374
|
+
// every action — typed rejections are successful calls, not errors.
|
|
375
|
+
if (meta === undefined || typeof meta !== 'object' || meta === null)
|
|
376
|
+
return undefined;
|
|
377
|
+
const { kind, threadRef } = meta;
|
|
378
|
+
if (kind !== 'committed' || typeof threadRef !== 'string' || threadRef === '')
|
|
379
|
+
return undefined;
|
|
380
|
+
return { key: boundaryRefFor(sessionId, seq), source: 'team-boundary', label: TEAM_MESSAGE_BOUNDARY_LABEL, seq, turn, turnEndSeq: -1 };
|
|
381
|
+
}
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
function applyTurnEnd(state, seq, _turn) {
|
|
385
|
+
let changed = false;
|
|
386
|
+
const checkpoints = state.checkpoints.map(entry => {
|
|
387
|
+
if (entry.turnEndSeq !== -1)
|
|
388
|
+
return entry;
|
|
389
|
+
changed = true;
|
|
390
|
+
return { ...entry, turnEndSeq: seq };
|
|
391
|
+
});
|
|
392
|
+
let pending = state.pending;
|
|
393
|
+
if (pending !== null && pending.turnEndSeq === -1) {
|
|
394
|
+
pending = { ...pending, turnEndSeq: seq };
|
|
395
|
+
changed = true;
|
|
396
|
+
}
|
|
397
|
+
const boundaries = state.boundaries.map(entry => {
|
|
398
|
+
if (entry.turnEndSeq !== -1)
|
|
399
|
+
return entry;
|
|
400
|
+
changed = true;
|
|
401
|
+
return { ...entry, turnEndSeq: seq, turn: entry.turn === -1 ? _turn : entry.turn };
|
|
402
|
+
});
|
|
403
|
+
if (state.lastTurnEndSeq !== seq)
|
|
404
|
+
changed = true;
|
|
405
|
+
return changed ? { ...state, checkpoints, pending, boundaries, lastTurnEndSeq: seq } : state;
|
|
406
|
+
}
|
|
407
|
+
function applyUserMessage(state, seq, message, sessionId) {
|
|
408
|
+
let next = state;
|
|
409
|
+
// A surfaced candidate is NOT consumed yet: the loop appends user/message
|
|
410
|
+
// before the step runs, so cancellation can still land between them. Only
|
|
411
|
+
// a completed, uninterrupted assistant answer for the same turn proves the
|
|
412
|
+
// old generation handled it; anything else stays carried, so an aborted
|
|
413
|
+
// partial request re-delivers the input instead of silently dropping it.
|
|
414
|
+
const surfaced = next.carriedCandidates.some(candidate => !candidate.consumed && candidate.message.id === message.id);
|
|
415
|
+
if (surfaced) {
|
|
416
|
+
// The user/message event carries no turn field, but the projection folds
|
|
417
|
+
// in log order: the containing step/start preceded this message, and
|
|
418
|
+
// the fold tracks the current turn from turn/start. Candidates surface
|
|
419
|
+
// inside the turn the inbox claimed them for.
|
|
420
|
+
next = { ...next, carriedCandidates: next.carriedCandidates.map(candidate => candidate.message.id === message.id && !candidate.consumed ? { ...candidate, surfacedTurn: next.lastTurn } : candidate) };
|
|
421
|
+
}
|
|
422
|
+
// A structural boundary delivery (handoff start, first-arrival Team notice,
|
|
423
|
+
// compaction notice) becomes a timeline candidate anchored to the containing
|
|
424
|
+
// turn; it resolves when that turn ends. Threads the delivery first
|
|
425
|
+
// introduces join seenThreads so later re-deliveries produce nothing.
|
|
426
|
+
const boundary = boundaryFromUserMessage(sessionId, seq, message, next.seenThreads);
|
|
427
|
+
if (boundary !== undefined && !('reminder' in boundary)) {
|
|
428
|
+
if ('firstArrival' in boundary) {
|
|
429
|
+
const { firstArrival, ...timelineBoundary } = boundary;
|
|
430
|
+
next = {
|
|
431
|
+
...next,
|
|
432
|
+
boundaries: [...next.boundaries, timelineBoundary],
|
|
433
|
+
seenThreads: [...next.seenThreads, ...firstArrival],
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
next = { ...next, boundaries: [...next.boundaries, boundary] };
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// The quiet continuation notice delivered for one checkpoint completes its
|
|
441
|
+
// delivery state; replay repair reads this to avoid re-scheduling it.
|
|
442
|
+
const continuation = continuationCheckpointRefOf(message);
|
|
443
|
+
if (continuation === undefined)
|
|
444
|
+
return next;
|
|
445
|
+
const checkpointRef = continuation;
|
|
446
|
+
const existing = next.continuations.find(entry => entry.checkpointRef === checkpointRef);
|
|
447
|
+
if (existing !== undefined) {
|
|
448
|
+
if (existing.deliveredSeq !== -1)
|
|
449
|
+
return next;
|
|
450
|
+
return { ...next, continuations: next.continuations.map(entry => entry === existing ? { ...entry, deliveredSeq: seq } : entry) };
|
|
451
|
+
}
|
|
452
|
+
return { ...next, continuations: [...next.continuations, { checkpointRef, deliveredSeq: seq }] };
|
|
453
|
+
}
|
|
454
|
+
/** A completed, uninterrupted assistant turn answers every candidate surfaced into it. */
|
|
455
|
+
function applyAssistantMessage(state, event) {
|
|
456
|
+
if (state.carriedCandidates.length === 0 || event.data.interrupted === true)
|
|
457
|
+
return state;
|
|
458
|
+
let changed = false;
|
|
459
|
+
const carriedCandidates = state.carriedCandidates.map(candidate => {
|
|
460
|
+
if (candidate.consumed || candidate.surfacedTurn !== event.data.turn)
|
|
461
|
+
return candidate;
|
|
462
|
+
changed = true;
|
|
463
|
+
return { ...candidate, consumed: true };
|
|
464
|
+
});
|
|
465
|
+
return changed ? { ...state, carriedCandidates } : state;
|
|
466
|
+
}
|
|
467
|
+
/** The carried input one transition must deliver: unconsumed post-intent candidates, in queue order. */
|
|
468
|
+
export function carriedInputOf(state) {
|
|
469
|
+
return state.carriedCandidates.filter(candidate => !candidate.consumed).map(candidate => candidate.message);
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Whether one queued message is a Team-owned notice the rederived Inbox
|
|
473
|
+
* replaces. Handoff and continuation envelopes carry the same plugin
|
|
474
|
+
* attribution but are ordinary delivered context the new generation keeps, so
|
|
475
|
+
* they are excluded rather than dropped.
|
|
476
|
+
*/
|
|
477
|
+
function isTeamNotice(message) {
|
|
478
|
+
const source = message.source;
|
|
479
|
+
return source.kind === 'plugin' && source.plugin === AGENT_TEAM_PLUGIN_ID && !isAgentTeamContextSource(message);
|
|
480
|
+
}
|
|
481
|
+
/** Whether a quiet continuation for one checkpoint was already delivered in this Session. */
|
|
482
|
+
export function continuationDelivered(state, checkpointRef) {
|
|
483
|
+
return state.continuations.some(entry => entry.checkpointRef === checkpointRef && entry.deliveredSeq !== -1);
|
|
484
|
+
}
|
|
485
|
+
/** Record that a continuation for one checkpoint is scheduled (delivery not yet seen). */
|
|
486
|
+
export function withScheduledContinuation(state, checkpointRef) {
|
|
487
|
+
if (state.continuations.some(entry => entry.checkpointRef === checkpointRef))
|
|
488
|
+
return state;
|
|
489
|
+
return { ...state, continuations: [...state.continuations, { checkpointRef, deliveredSeq: -1 }] };
|
|
490
|
+
}
|
|
491
|
+
/** Whether one raw Team-claim arguments string is a mutation (not `list`). */
|
|
492
|
+
function argumentsAreClaimMutation(raw) {
|
|
493
|
+
let parsed;
|
|
494
|
+
try {
|
|
495
|
+
parsed = JSON.parse(raw);
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
501
|
+
return false;
|
|
502
|
+
const { action } = parsed;
|
|
503
|
+
return action === 'claim' || action === 'done' || action === 'release';
|
|
504
|
+
}
|
|
505
|
+
function parseRolloverArguments(raw) {
|
|
506
|
+
let parsed;
|
|
507
|
+
try {
|
|
508
|
+
parsed = JSON.parse(raw);
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
514
|
+
return undefined;
|
|
515
|
+
const { handoff, checkpointRef, relatedFiles } = parsed;
|
|
516
|
+
if (typeof handoff !== 'string' || handoff.trim() === '')
|
|
517
|
+
return undefined;
|
|
518
|
+
if (checkpointRef !== undefined && typeof checkpointRef !== 'string')
|
|
519
|
+
return undefined;
|
|
520
|
+
if (relatedFiles !== undefined && !isRelatedFiles(relatedFiles))
|
|
521
|
+
return undefined;
|
|
522
|
+
return {
|
|
523
|
+
handoff,
|
|
524
|
+
...(checkpointRef === undefined ? {} : { checkpointRef: checkpointRef }),
|
|
525
|
+
relatedFiles: relatedFiles === undefined ? [] : relatedFiles,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
function parseCheckpointArguments(raw) {
|
|
529
|
+
let parsed;
|
|
530
|
+
try {
|
|
531
|
+
parsed = JSON.parse(raw);
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
return undefined;
|
|
535
|
+
}
|
|
536
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
537
|
+
return undefined;
|
|
538
|
+
const { name } = parsed;
|
|
539
|
+
if (typeof name !== 'string' || name.trim() === '')
|
|
540
|
+
return undefined;
|
|
541
|
+
return { name };
|
|
542
|
+
}
|
|
543
|
+
function isRelatedFiles(value) {
|
|
544
|
+
return Array.isArray(value) && value.every(file => typeof file === 'object' && file !== null
|
|
545
|
+
&& typeof file.path === 'string' && typeof file.reason === 'string');
|
|
546
|
+
}
|
|
547
|
+
// Re-exported for callers that only want the predicate view of continuation
|
|
548
|
+
// delivery without importing the source module's message constructors.
|
|
549
|
+
export { isCheckpointContinuationMessage };
|
|
550
|
+
/**
|
|
551
|
+
* Bounded structural timeline candidates, newest first: explicit checkpoints
|
|
552
|
+
* (resolved only), structural boundaries, then the current head. The Host
|
|
553
|
+
* prices retained/discarded tokens and applies restorability guards on top;
|
|
554
|
+
* this view is the single source of candidate anchors and labels.
|
|
555
|
+
*/
|
|
556
|
+
export function timelineCandidates(state, limit) {
|
|
557
|
+
const candidates = [];
|
|
558
|
+
for (const checkpoint of state.checkpoints) {
|
|
559
|
+
if (checkpoint.turnEndSeq === -1)
|
|
560
|
+
continue;
|
|
561
|
+
candidates.push({ ref: checkpoint.checkpointRef, label: checkpoint.name, source: 'agent', seq: checkpoint.resultSeq, turnEndSeq: checkpoint.turnEndSeq });
|
|
562
|
+
}
|
|
563
|
+
for (const boundary of state.boundaries) {
|
|
564
|
+
if (boundary.turnEndSeq === -1)
|
|
565
|
+
continue;
|
|
566
|
+
candidates.push({ ref: boundary.key, label: boundary.label, source: boundary.source, seq: boundary.seq, turnEndSeq: boundary.turnEndSeq });
|
|
567
|
+
}
|
|
568
|
+
if (state.lastTurnEndSeq !== -1) {
|
|
569
|
+
candidates.push({ ref: `head:${state.lastTurnEndSeq}`, label: 'current head', source: 'head', seq: state.lastTurnEndSeq, turnEndSeq: state.lastTurnEndSeq });
|
|
570
|
+
}
|
|
571
|
+
return candidates
|
|
572
|
+
.sort((a, b) => b.turnEndSeq - a.turnEndSeq || b.seq - a.seq)
|
|
573
|
+
.slice(0, Math.max(1, Math.trunc(limit)));
|
|
574
|
+
}
|
|
575
|
+
/** Find one resolved checkpoint entry by its stable ref, if it exists. */
|
|
576
|
+
export function checkpointByRef(state, checkpointRef) {
|
|
577
|
+
return state.checkpoints.find(entry => entry.checkpointRef === checkpointRef && entry.turnEndSeq !== -1);
|
|
578
|
+
}
|