@deepseek-ai/dsh-session 0.2.2-rc.1 → 0.2.2-rc.2
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.i18n.yaml +4 -4
- package/README.md +3 -1
- package/README.zh.md +3 -1
- package/lib/index.js +151 -79
- package/lib/types/fork.d.ts.map +1 -0
- package/lib/types/fork.js.map +1 -0
- package/lib/types/index.d.ts +17 -1
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +82 -63
- package/lib/types/index.js.map +1 -0
- package/lib/types/known-event-types.d.ts.map +1 -0
- package/lib/types/known-event-types.js +1 -0
- package/lib/types/known-event-types.js.map +1 -0
- package/lib/types/preparation.d.ts.map +1 -0
- package/lib/types/preparation.js.map +1 -0
- package/lib/types/repair.d.ts.map +1 -0
- package/lib/types/repair.js.map +1 -0
- package/lib/types/request-header.d.ts.map +1 -0
- package/lib/types/request-header.js.map +1 -0
- package/lib/types/seq-ranges.d.ts.map +1 -0
- package/lib/types/seq-ranges.js.map +1 -0
- package/lib/types/surface.d.ts +6 -0
- package/lib/types/surface.d.ts.map +1 -0
- package/lib/types/surface.js +57 -11
- package/lib/types/surface.js.map +1 -0
- package/lib/types/tool-history.d.ts.map +1 -0
- package/lib/types/tool-history.js.map +1 -0
- package/lib/types/types.d.ts +4 -0
- package/lib/types/types.d.ts.map +1 -0
- package/lib/types/types.js.map +1 -0
- package/package.json +12 -9
- package/src/fork.ts +30 -0
- package/src/index.ts +1555 -0
- package/src/known-event-types.ts +91 -0
- package/src/preparation.ts +49 -0
- package/src/repair.ts +211 -0
- package/src/request-header.ts +69 -0
- package/src/seq-ranges.ts +73 -0
- package/src/surface.ts +758 -0
- package/src/tool-history.ts +75 -0
- package/src/types.ts +574 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED by `scripts/gen-persistence-catalog.ts` — do not edit by hand; run
|
|
3
|
+
* `pnpm run gen-persistence-catalog` to regenerate (verified fresh by
|
|
4
|
+
* `pnpm run verify-persistence-catalog`, part of `doc-sync`).
|
|
5
|
+
* @module @deepseek-ai/dsh-session/known-event-types
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Every `SessionEventMap` member declared in this repository — the event
|
|
10
|
+
* vocabulary this build understands. The persistence read path refuses to
|
|
11
|
+
* interpret a log containing a type outside this set unless the event
|
|
12
|
+
* carries the envelope's `ignorable` marker (see `SessionEvent.ignorable`
|
|
13
|
+
* in `./types.ts`): such a log was likely written by a newer harness, and
|
|
14
|
+
* silently skipping a required event would reconstruct a wrong session.
|
|
15
|
+
* Downstream (out-of-repo) plugin events are outside this list by
|
|
16
|
+
* construction. The persisted `SessionEvent.ignorable` marker is the
|
|
17
|
+
* compatibility mechanism; event-name registration was rejected because
|
|
18
|
+
* it does not classify omission safety and would make reads
|
|
19
|
+
* composition-dependent. The rationale is in
|
|
20
|
+
* `.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md`.
|
|
21
|
+
*/
|
|
22
|
+
export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
|
|
23
|
+
'agent-preset/selected',
|
|
24
|
+
'agent/inbox/spliced',
|
|
25
|
+
'approval/asked',
|
|
26
|
+
'approval/decided',
|
|
27
|
+
'approval/policy',
|
|
28
|
+
'assistant/attempt',
|
|
29
|
+
'assistant/message',
|
|
30
|
+
'command/done',
|
|
31
|
+
'command/run',
|
|
32
|
+
'compaction/end',
|
|
33
|
+
'compaction/prune',
|
|
34
|
+
'compaction/start',
|
|
35
|
+
'compaction/summary',
|
|
36
|
+
'deliverables/presented',
|
|
37
|
+
'developer/message',
|
|
38
|
+
'feedback/message-delete',
|
|
39
|
+
'feedback/message-put',
|
|
40
|
+
'feedback/record',
|
|
41
|
+
'goal/change',
|
|
42
|
+
'hook/invoked',
|
|
43
|
+
'hook/result',
|
|
44
|
+
'image/offload',
|
|
45
|
+
'job/registered',
|
|
46
|
+
'job/settled',
|
|
47
|
+
'llm/retry',
|
|
48
|
+
'llm/retry-started',
|
|
49
|
+
'model/selection',
|
|
50
|
+
'permission/preset',
|
|
51
|
+
'plan/mode',
|
|
52
|
+
'request/context',
|
|
53
|
+
'request/header',
|
|
54
|
+
'sandbox/mode',
|
|
55
|
+
'schedule/change',
|
|
56
|
+
'session-log-deepseek/delivery-accepted',
|
|
57
|
+
'session/end-seed',
|
|
58
|
+
'session/title',
|
|
59
|
+
'session/title-llm-request',
|
|
60
|
+
'step/end',
|
|
61
|
+
'step/start',
|
|
62
|
+
'subagent/catalog',
|
|
63
|
+
'subagent/descriptor',
|
|
64
|
+
'subagent/model-selection-policy',
|
|
65
|
+
'system/message',
|
|
66
|
+
'team/member',
|
|
67
|
+
'team/message/delivered',
|
|
68
|
+
'team/message/queued',
|
|
69
|
+
'team/mode',
|
|
70
|
+
'team/task',
|
|
71
|
+
'todo/write',
|
|
72
|
+
'tool-workflow/agent-end',
|
|
73
|
+
'tool-workflow/agent-start',
|
|
74
|
+
'tool-workflow/run-end',
|
|
75
|
+
'tool-workflow/run-start',
|
|
76
|
+
'tool/call',
|
|
77
|
+
'tool/ptc-dispatch',
|
|
78
|
+
'tool/ptc-dispatch-start',
|
|
79
|
+
'tool/result',
|
|
80
|
+
'turn/end',
|
|
81
|
+
'turn/start',
|
|
82
|
+
'user/message',
|
|
83
|
+
'web/deepseek-search-llm-request',
|
|
84
|
+
'working-directory/change',
|
|
85
|
+
'workspace/changes',
|
|
86
|
+
])
|
|
87
|
+
|
|
88
|
+
/** Event types whose model-visible effects require an explicit pure interpreter. */
|
|
89
|
+
export const MESSAGE_PROJECTION_EVENT_TYPES: ReadonlySet<string> = new Set([
|
|
90
|
+
'image/offload',
|
|
91
|
+
])
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ownership of one unpublished Session before registry publication.
|
|
3
|
+
* @module @deepseek-ai/dsh-session/preparation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Session } from './index.ts'
|
|
7
|
+
|
|
8
|
+
/** Options for a preparation whose provider retains unpublished state. */
|
|
9
|
+
export interface SessionPreparationOptions {
|
|
10
|
+
/** Release provider-owned state when the Session was not published. */
|
|
11
|
+
readonly release?: () => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One exact unpublished Session and the provider state that keeps it usable.
|
|
16
|
+
* Disposal is synchronous and idempotent. Providers decide whether release
|
|
17
|
+
* returns the Session to a cache or discards it; publication may consume that
|
|
18
|
+
* state before disposal, making the callback a no-op.
|
|
19
|
+
*/
|
|
20
|
+
export class SessionPreparation implements Disposable {
|
|
21
|
+
private released = false
|
|
22
|
+
|
|
23
|
+
/** The exact Session to use for setup and publication. */
|
|
24
|
+
readonly session: Session
|
|
25
|
+
|
|
26
|
+
private constructor(
|
|
27
|
+
session: Session,
|
|
28
|
+
private readonly options: SessionPreparationOptions,
|
|
29
|
+
) {
|
|
30
|
+
this.session = session
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Wrap an unpublished Session in one preparation lifetime.
|
|
35
|
+
* @param session - exact unpublished Session.
|
|
36
|
+
* @param options - optional provider release behavior.
|
|
37
|
+
* @returns a preparation disposed after publication or rollback.
|
|
38
|
+
*/
|
|
39
|
+
static create(session: Session, options?: SessionPreparationOptions): SessionPreparation {
|
|
40
|
+
return new SessionPreparation(session, options ?? {})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Release provider state once when this preparation leaves its caller. */
|
|
44
|
+
[Symbol.dispose](): void {
|
|
45
|
+
if (this.released) return
|
|
46
|
+
this.released = true
|
|
47
|
+
this.options.release?.()
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/repair.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending tool-result recovery shared by failed live steps, interrupted logs,
|
|
3
|
+
* and fork seeds. Tail repair preserves closed steps and supplies only missing
|
|
4
|
+
* tool results and lifecycle boundaries, with cause-specific retry guidance.
|
|
5
|
+
* @module @deepseek-ai/dsh-session/repair
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { brandString } from '@deepseek-ai/dsh-brand'
|
|
9
|
+
import type { MessageId, ToolCallId, ToolResultMessage } from '@deepseek-ai/dsh-llm'
|
|
10
|
+
import { deepFreeze } from '@deepseek-ai/dsh-util-values'
|
|
11
|
+
import { SessionSeq } from './types.ts'
|
|
12
|
+
import type { SessionEvent, SessionSeq as SessionSeqType } from './types.ts'
|
|
13
|
+
|
|
14
|
+
/** Recovery code for an assistant tool request that never reached a recorded call start. */
|
|
15
|
+
export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED'
|
|
16
|
+
|
|
17
|
+
/** Recovery code for a recorded tool call whose completed outcome was not durably recorded. */
|
|
18
|
+
export const TOOL_OUTCOME_UNKNOWN = 'TOOL_OUTCOME_UNKNOWN'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Why an open tail turn is closed with synthetic events: `interrupted` is
|
|
22
|
+
* crash recovery over a persisted log; `forked` is a fork seed cut inside the
|
|
23
|
+
* source's open turn. The cause selects the synthetic `turn/end` reason, the
|
|
24
|
+
* model-visible wording of synthetic error tool results, and the
|
|
25
|
+
* deterministic synthetic message-id prefix. The error codes
|
|
26
|
+
* ({@link TOOL_NOT_STARTED} / {@link TOOL_OUTCOME_UNKNOWN}) are shared: both
|
|
27
|
+
* causes state the same fact about the call's recorded lifecycle.
|
|
28
|
+
*/
|
|
29
|
+
export type OpenTurnCloseCause = { readonly kind: 'interrupted' } | { readonly kind: 'forked' }
|
|
30
|
+
|
|
31
|
+
/** Model-visible wording of the synthetic error tool results, keyed by cause. */
|
|
32
|
+
const CLOSER_TEXT = {
|
|
33
|
+
interrupted: {
|
|
34
|
+
started: 'The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.',
|
|
35
|
+
notStarted: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
|
|
36
|
+
},
|
|
37
|
+
forked: {
|
|
38
|
+
started: 'The history inherited by this branch records this tool call starting but does not include its result. The parent session may have completed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.',
|
|
39
|
+
notStarted: 'The history inherited by this branch has no record of this tool call starting. The parent session may have executed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.',
|
|
40
|
+
},
|
|
41
|
+
} as const
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Return deterministic synthetic events that close an open tail turn. Unmatched
|
|
45
|
+
* calls receive error results first, followed by an open `step/end` and a
|
|
46
|
+
* cause-specific `turn/end`; sequences continue the log and timestamps reuse the
|
|
47
|
+
* last real event. A balanced or empty log returns no events.
|
|
48
|
+
*
|
|
49
|
+
* @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
|
|
50
|
+
* @param cause - the owning close operation: selects result wording and the turn-ending reason.
|
|
51
|
+
* @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
|
|
52
|
+
*/
|
|
53
|
+
export function openTurnClosers(events: readonly SessionEvent[], cause: OpenTurnCloseCause): SessionEvent[] {
|
|
54
|
+
let openTurn: number | null = null
|
|
55
|
+
let openStep: number | null = null
|
|
56
|
+
const recovery = new ToolCallRecovery(cause)
|
|
57
|
+
for (const event of events) {
|
|
58
|
+
recovery.observe(event)
|
|
59
|
+
switch (event.type) {
|
|
60
|
+
case 'turn/start':
|
|
61
|
+
openTurn = event.data.turn
|
|
62
|
+
openStep = null
|
|
63
|
+
break
|
|
64
|
+
case 'turn/end':
|
|
65
|
+
openTurn = null
|
|
66
|
+
openStep = null
|
|
67
|
+
break
|
|
68
|
+
case 'step/start':
|
|
69
|
+
openStep = event.data.step
|
|
70
|
+
break
|
|
71
|
+
case 'step/end':
|
|
72
|
+
openStep = null
|
|
73
|
+
break
|
|
74
|
+
// Other event types do not move the turn/step boundary cursor.
|
|
75
|
+
default:
|
|
76
|
+
break
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Balanced log (no open tail turn): nothing to close. An open turn implies
|
|
81
|
+
// `events` is non-empty (its turn/start was logged), so `last` exists.
|
|
82
|
+
const last = events.at(-1)
|
|
83
|
+
if (openTurn === null || last === undefined) return []
|
|
84
|
+
|
|
85
|
+
// The last real event supplies the seq base and the timestamp for the
|
|
86
|
+
// synthetic closers (reusing the last timestamp keeps them deterministic and
|
|
87
|
+
// never invents a "future" time).
|
|
88
|
+
const closers: SessionEvent[] = recovery.results()
|
|
89
|
+
let seq = last.seq + closers.length + 1
|
|
90
|
+
const time = last.time
|
|
91
|
+
|
|
92
|
+
// Close an open step before its turn.
|
|
93
|
+
if (openStep !== null) {
|
|
94
|
+
closers.push({ type: 'step/end', seq: SessionSeq(seq++), time, data: { turn: openTurn, step: openStep } })
|
|
95
|
+
}
|
|
96
|
+
closers.push({ type: 'turn/end', seq: SessionSeq(seq++), time, data: { turn: openTurn, reason: { kind: cause.kind } } })
|
|
97
|
+
return closers
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Track unanswered assistant tool requests from one Session's committed events.
|
|
102
|
+
* Observe from the start of the owned step or replay prefix, and recover before
|
|
103
|
+
* its step closes. This state retains pending identities, not event history.
|
|
104
|
+
*/
|
|
105
|
+
export class ToolCallRecovery {
|
|
106
|
+
private readonly pendingCalls = new Map<ToolCallId, { turn: number; step: number; callSeq?: SessionSeqType }>()
|
|
107
|
+
private last: Pick<SessionEvent, 'seq' | 'time'> | undefined
|
|
108
|
+
|
|
109
|
+
/** @param cause - defaults to interrupted live/crash recovery; fork-seed construction supplies its own cause. */
|
|
110
|
+
constructor(private readonly cause: OpenTurnCloseCause = { kind: 'interrupted' }) {}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Consume the next committed event; closed steps and turn boundaries discard pending requests.
|
|
114
|
+
* @param event - the next event from the same Session, in sequence order.
|
|
115
|
+
*/
|
|
116
|
+
observe(event: SessionEvent): void {
|
|
117
|
+
this.last = { seq: event.seq, time: event.time }
|
|
118
|
+
switch (event.type) {
|
|
119
|
+
case 'turn/start':
|
|
120
|
+
case 'turn/end':
|
|
121
|
+
case 'step/end':
|
|
122
|
+
this.pendingCalls.clear()
|
|
123
|
+
break
|
|
124
|
+
case 'assistant/message':
|
|
125
|
+
for (const block of event.data.message.content) {
|
|
126
|
+
if (block.type === 'tool-call') {
|
|
127
|
+
this.pendingCalls.set(block.id, { turn: event.data.turn, step: event.data.step })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
break
|
|
131
|
+
case 'tool/call': {
|
|
132
|
+
const entry = this.pendingCalls.get(event.data.callId)
|
|
133
|
+
if (entry) entry.callSeq = event.seq
|
|
134
|
+
break
|
|
135
|
+
}
|
|
136
|
+
case 'tool/result': {
|
|
137
|
+
const callId = event.data.message.source.callId
|
|
138
|
+
const entry = this.pendingCalls.get(callId)
|
|
139
|
+
if (event.surfaceOp === 'append' && entry !== undefined
|
|
140
|
+
&& entry.turn === event.data.turn && entry.step === event.data.step) {
|
|
141
|
+
this.pendingCalls.delete(callId)
|
|
142
|
+
}
|
|
143
|
+
break
|
|
144
|
+
}
|
|
145
|
+
// SessionEvent is merge-extensible; unrelated events retain pending requests.
|
|
146
|
+
default:
|
|
147
|
+
break
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Build conservative error results in assistant order without changing tracked state.
|
|
153
|
+
* Sequences follow the latest observed event and timestamps reuse its time.
|
|
154
|
+
* Callers commit the results and observe those commits before recovering again.
|
|
155
|
+
* @returns pending tool-result events, empty when no request remains unanswered.
|
|
156
|
+
*/
|
|
157
|
+
results(): SessionEvent<'tool/result'>[] {
|
|
158
|
+
if (this.last === undefined) return []
|
|
159
|
+
let seq = this.last.seq + 1
|
|
160
|
+
const time = this.last.time
|
|
161
|
+
const results: SessionEvent<'tool/result'>[] = []
|
|
162
|
+
|
|
163
|
+
const text = CLOSER_TEXT[this.cause.kind]
|
|
164
|
+
// Close calls before their step: providers reject dangling assistant calls,
|
|
165
|
+
// and Map insertion order preserves their transcript order.
|
|
166
|
+
for (const [callId, { turn, step, callSeq }] of this.pendingCalls) {
|
|
167
|
+
const started = callSeq !== undefined
|
|
168
|
+
const message: ToolResultMessage = deepFreeze({
|
|
169
|
+
id: brandString<MessageId>(`${this.cause.kind}-tool-result-${callId}-${seq}`),
|
|
170
|
+
role: 'tool',
|
|
171
|
+
toolCallId: callId,
|
|
172
|
+
isError: true,
|
|
173
|
+
source: { kind: 'tool', callId },
|
|
174
|
+
content: [{
|
|
175
|
+
type: 'text',
|
|
176
|
+
text: started ? text.started : text.notStarted,
|
|
177
|
+
}],
|
|
178
|
+
})
|
|
179
|
+
results.push({
|
|
180
|
+
type: 'tool/result',
|
|
181
|
+
seq: SessionSeq(seq++),
|
|
182
|
+
time,
|
|
183
|
+
data: {
|
|
184
|
+
turn,
|
|
185
|
+
step,
|
|
186
|
+
message,
|
|
187
|
+
error: started
|
|
188
|
+
? { name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN }
|
|
189
|
+
: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
|
|
190
|
+
},
|
|
191
|
+
surfaceOp: 'append',
|
|
192
|
+
...started ? { sourceEventSeqs: [callSeq] } : {},
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return results
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Crash-recovery entry point: synthetic closers that balance a persisted log
|
|
202
|
+
* whose tail turn was interrupted. Used by crash-recovery callers; fork
|
|
203
|
+
* seeds receive their `forked`-cause closers through `buildForkSeed` in
|
|
204
|
+
* `./fork.ts`.
|
|
205
|
+
*
|
|
206
|
+
* @param events - the persisted log to scan, possibly ending inside an open turn.
|
|
207
|
+
* @returns the synthetic `interrupted` closer events to append after `events`; empty when the log is already balanced.
|
|
208
|
+
*/
|
|
209
|
+
export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] {
|
|
210
|
+
return openTurnClosers(events, { kind: 'interrupted' })
|
|
211
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-header reconstruction utilities over full `request/header` session
|
|
3
|
+
* events. Anyone holding a session log reconstructs the {@link EpochHeader}
|
|
4
|
+
* any request was built under by taking the latest canonical snapshot; the
|
|
5
|
+
* loop uses the same equality helper to avoid logging unchanged headers.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-session/request-header
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { callConfigEquals } from '@deepseek-ai/dsh-llm'
|
|
11
|
+
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
|
12
|
+
import type { EpochHeader, SessionEvent } from './types.ts'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Normalize a header to canonical form: an empty tool list becomes an absent
|
|
16
|
+
* field, matching how requests are built. Logging, folding, and comparison use
|
|
17
|
+
* this one representation.
|
|
18
|
+
* @param header - the header to normalize (not mutated).
|
|
19
|
+
* @returns the canonical header.
|
|
20
|
+
*/
|
|
21
|
+
export function canonicalHeader(header: EpochHeader): EpochHeader {
|
|
22
|
+
const adapterDefaults = header.adapterDefaults
|
|
23
|
+
return {
|
|
24
|
+
config: header.config,
|
|
25
|
+
...adapterDefaults?.reasoningEffort === true || adapterDefaults?.maxTokens === true
|
|
26
|
+
? { adapterDefaults }
|
|
27
|
+
: {},
|
|
28
|
+
...header.tools !== undefined && header.tools.length > 0 ? { tools: header.tools } : {},
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Canonical JSON equality for tool schemas assembled through the same path. */
|
|
33
|
+
function sameSchema(a: ToolSchema, b: ToolSchema): boolean {
|
|
34
|
+
return JSON.stringify(a) === JSON.stringify(b)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Field-wise equality over canonical headers. Tool schemas compare in order.
|
|
39
|
+
* @param a - one canonical header.
|
|
40
|
+
* @param b - the other.
|
|
41
|
+
* @returns whether config, adapter defaults, and tools all match.
|
|
42
|
+
*/
|
|
43
|
+
export function headerEquals(a: EpochHeader, b: EpochHeader): boolean {
|
|
44
|
+
if (
|
|
45
|
+
!callConfigEquals(a.config, b.config)
|
|
46
|
+
|| a.adapterDefaults?.reasoningEffort !== b.adapterDefaults?.reasoningEffort
|
|
47
|
+
|| a.adapterDefaults?.maxTokens !== b.adapterDefaults?.maxTokens
|
|
48
|
+
) return false
|
|
49
|
+
const at = a.tools ?? []
|
|
50
|
+
const bt = b.tools ?? []
|
|
51
|
+
return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Fold the header events of a log (or any prefix) into the
|
|
56
|
+
* {@link EpochHeader} in force after the last snapshot. Non-header events are
|
|
57
|
+
* skipped. This is the pure offline reconstruction path; the live session
|
|
58
|
+
* tracks the same fold incrementally.
|
|
59
|
+
* @param events - session events in log order.
|
|
60
|
+
* @param from - a previously folded state to continue from.
|
|
61
|
+
* @returns the latest canonical header, or undefined when none exists yet.
|
|
62
|
+
*/
|
|
63
|
+
export function foldRequestHeader(events: readonly SessionEvent[], from?: EpochHeader): EpochHeader | undefined {
|
|
64
|
+
let state = from
|
|
65
|
+
for (const event of events) {
|
|
66
|
+
if (event.type === 'request/header') state = canonicalHeader(event.data.header)
|
|
67
|
+
}
|
|
68
|
+
return state
|
|
69
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Lossless range encoding for JSONL `sourceEventSeqs` arrays. */
|
|
2
|
+
|
|
3
|
+
import { SessionSeq } from './types.ts'
|
|
4
|
+
import type { SessionSeq as SessionSeqType } from './types.ts'
|
|
5
|
+
|
|
6
|
+
/** A stored source sequence or inclusive consecutive range. */
|
|
7
|
+
export type EncodedSeq = number | [number, number]
|
|
8
|
+
|
|
9
|
+
function isStrictlyIncreasing(values: readonly SessionSeqType[]): boolean {
|
|
10
|
+
return values.every((value, index) => index === 0 || value > (values[index - 1] as SessionSeqType))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Replace profitable consecutive runs with inclusive pairs.
|
|
15
|
+
* @param values - validated in-memory source sequences.
|
|
16
|
+
* @returns a lossless JSON storage form.
|
|
17
|
+
*/
|
|
18
|
+
export function encodeSeqRanges(values: readonly SessionSeqType[]): EncodedSeq[] {
|
|
19
|
+
if (!isStrictlyIncreasing(values)) return [...values]
|
|
20
|
+
const encoded: EncodedSeq[] = []
|
|
21
|
+
for (let start = 0; start < values.length;) {
|
|
22
|
+
let end = start
|
|
23
|
+
while (end + 1 < values.length && values[end + 1] === (values[end] as number) + 1) end += 1
|
|
24
|
+
if (end - start >= 2) encoded.push([values[start] as number, values[end] as number])
|
|
25
|
+
else for (let index = start; index <= end; index += 1) encoded.push(values[index] as number)
|
|
26
|
+
start = end + 1
|
|
27
|
+
}
|
|
28
|
+
return encoded
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Expand a JSON storage-form source sequence array.
|
|
33
|
+
* @param value - parsed storage value.
|
|
34
|
+
* @param maxEntries - largest list permitted by the owning event.
|
|
35
|
+
* @returns the in-memory source sequences.
|
|
36
|
+
*/
|
|
37
|
+
export function decodeSeqRanges(value: unknown, maxEntries = Number.MAX_SAFE_INTEGER): SessionSeqType[] {
|
|
38
|
+
if (!Array.isArray(value)) throw new TypeError('sourceEventSeqs must be an array')
|
|
39
|
+
const decoded: SessionSeqType[] = []
|
|
40
|
+
let hasRange = false
|
|
41
|
+
for (const entry of value) {
|
|
42
|
+
if (typeof entry === 'number') {
|
|
43
|
+
assertSeq(entry)
|
|
44
|
+
if (decoded.length >= maxEntries) throw new TypeError('sourceEventSeqs exceeds its event sequence')
|
|
45
|
+
decoded.push(SessionSeq(entry))
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
if (!Array.isArray(entry) || entry.length !== 2) {
|
|
49
|
+
throw new TypeError('sourceEventSeqs range entries must be [start, end] pairs')
|
|
50
|
+
}
|
|
51
|
+
const start: unknown = entry[0]
|
|
52
|
+
const end: unknown = entry[1]
|
|
53
|
+
assertSeq(start)
|
|
54
|
+
assertSeq(end)
|
|
55
|
+
if (end < start) throw new TypeError('sourceEventSeqs ranges require start <= end')
|
|
56
|
+
const length = end - start + 1
|
|
57
|
+
if (length > maxEntries - decoded.length) {
|
|
58
|
+
throw new TypeError('sourceEventSeqs range exceeds its event sequence')
|
|
59
|
+
}
|
|
60
|
+
for (let seq = start; seq <= end; seq += 1) decoded.push(SessionSeq(seq))
|
|
61
|
+
hasRange = true
|
|
62
|
+
}
|
|
63
|
+
if (hasRange && !isStrictlyIncreasing(decoded)) {
|
|
64
|
+
throw new TypeError('sourceEventSeqs ranges must be strictly increasing')
|
|
65
|
+
}
|
|
66
|
+
return decoded
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function assertSeq(value: unknown): asserts value is number {
|
|
70
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
|
71
|
+
throw new TypeError('sourceEventSeqs must contain non-negative safe integers')
|
|
72
|
+
}
|
|
73
|
+
}
|