@ably/ai-transport 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -19
- package/dist/ably-ai-transport.js +1790 -1091
- package/dist/ably-ai-transport.js.map +1 -1
- package/dist/ably-ai-transport.umd.cjs +1 -1
- package/dist/ably-ai-transport.umd.cjs.map +1 -1
- package/dist/constants.d.ts +2 -2
- package/dist/core/agent.d.ts +20 -5
- package/dist/core/channel-options.d.ts +57 -0
- package/dist/core/codec/codec-event.d.ts +9 -0
- package/dist/core/codec/decoder.d.ts +4 -1
- package/dist/core/codec/define-codec.d.ts +100 -0
- package/dist/core/codec/encoder.d.ts +2 -7
- package/dist/core/codec/field-bag.d.ts +85 -0
- package/dist/core/codec/fields.d.ts +141 -0
- package/dist/core/codec/index.d.ts +8 -1
- package/dist/core/codec/input-descriptor-decoder.d.ts +19 -0
- package/dist/core/codec/input-descriptor-encoder.d.ts +22 -0
- package/dist/core/codec/input-descriptors.d.ts +281 -0
- package/dist/core/codec/output-descriptor-decoder.d.ts +29 -0
- package/dist/core/codec/output-descriptor-encoder.d.ts +31 -0
- package/dist/core/codec/output-descriptors.d.ts +237 -0
- package/dist/core/codec/types.d.ts +95 -36
- package/dist/core/codec/well-known-inputs.d.ts +52 -0
- package/dist/core/transport/agent-view.d.ts +296 -0
- package/dist/core/transport/decode-fold.d.ts +40 -32
- package/dist/core/transport/headers.d.ts +30 -1
- package/dist/core/transport/index.d.ts +1 -1
- package/dist/core/transport/invocation.d.ts +1 -1
- package/dist/core/transport/load-history-pages.d.ts +71 -0
- package/dist/core/transport/load-history.d.ts +21 -16
- package/dist/core/transport/run-manager.d.ts +9 -11
- package/dist/core/transport/session-support.d.ts +55 -0
- package/dist/core/transport/tree.d.ts +165 -15
- package/dist/core/transport/types/agent.d.ts +120 -98
- package/dist/core/transport/types/client.d.ts +45 -12
- package/dist/core/transport/types/tree.d.ts +52 -10
- package/dist/core/transport/types/view.d.ts +55 -28
- package/dist/core/transport/view.d.ts +176 -58
- package/dist/core/transport/wire-log.d.ts +102 -0
- package/dist/errors.d.ts +10 -4
- package/dist/index.d.ts +6 -5
- package/dist/react/ably-ai-transport-react.js +784 -415
- package/dist/react/ably-ai-transport-react.js.map +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
- package/dist/react/contexts/client-session-context.d.ts +2 -1
- package/dist/react/contexts/client-session-provider.d.ts +3 -0
- package/dist/react/index.d.ts +2 -1
- package/dist/react/internal/skipped-session.d.ts +8 -0
- package/dist/react/use-view.d.ts +3 -3
- package/dist/utils.d.ts +22 -54
- package/dist/vercel/ably-ai-transport-vercel.js +2297 -2026
- package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
- package/dist/vercel/codec/decode-lifecycle.d.ts +9 -0
- package/dist/vercel/codec/events.d.ts +1 -2
- package/dist/vercel/codec/fields.d.ts +44 -0
- package/dist/vercel/codec/fold-content.d.ts +16 -0
- package/dist/vercel/codec/fold-data.d.ts +16 -0
- package/dist/vercel/codec/fold-input.d.ts +67 -0
- package/dist/vercel/codec/fold-lifecycle.d.ts +16 -0
- package/dist/vercel/codec/fold-text.d.ts +16 -0
- package/dist/vercel/codec/fold-tool-input.d.ts +17 -0
- package/dist/vercel/codec/fold-tool-output.d.ts +16 -0
- package/dist/vercel/codec/index.d.ts +5 -30
- package/dist/vercel/codec/inputs.d.ts +11 -0
- package/dist/vercel/codec/outputs.d.ts +11 -0
- package/dist/vercel/codec/reducer-state.d.ts +121 -0
- package/dist/vercel/codec/reducer.d.ts +20 -102
- package/dist/vercel/codec/tool-transitions.d.ts +0 -6
- package/dist/vercel/codec/wire-data.d.ts +34 -0
- package/dist/vercel/index.d.ts +1 -0
- package/dist/vercel/react/ably-ai-transport-vercel-react.js +2013 -9500
- package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +1 -70
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
- package/dist/vercel/react/contexts/chat-transport-context.d.ts +2 -1
- package/dist/vercel/run-end-reason.d.ts +66 -11
- package/dist/vercel/tool-part.d.ts +21 -0
- package/dist/vercel/transport/chat-transport.d.ts +0 -2
- package/dist/vercel/transport/index.d.ts +1 -1
- package/dist/vercel/transport/run-output-stream.d.ts +6 -8
- package/dist/version.d.ts +1 -1
- package/package.json +2 -2
- package/src/constants.ts +2 -2
- package/src/core/agent.ts +43 -19
- package/src/core/channel-options.ts +89 -0
- package/src/core/codec/codec-event.ts +27 -0
- package/src/core/codec/decoder.ts +145 -21
- package/src/core/codec/define-codec.ts +432 -0
- package/src/core/codec/encoder.ts +13 -54
- package/src/core/codec/field-bag.ts +142 -0
- package/src/core/codec/fields.ts +193 -0
- package/src/core/codec/index.ts +43 -0
- package/src/core/codec/input-descriptor-decoder.ts +97 -0
- package/src/core/codec/input-descriptor-encoder.ts +150 -0
- package/src/core/codec/input-descriptors.ts +373 -0
- package/src/core/codec/output-descriptor-decoder.ts +139 -0
- package/src/core/codec/output-descriptor-encoder.ts +101 -0
- package/src/core/codec/output-descriptors.ts +307 -0
- package/src/core/codec/types.ts +99 -36
- package/src/core/codec/well-known-inputs.ts +96 -0
- package/src/core/transport/agent-session.ts +330 -589
- package/src/core/transport/agent-view.ts +738 -0
- package/src/core/transport/client-session.ts +74 -69
- package/src/core/transport/decode-fold.ts +57 -47
- package/src/core/transport/headers.ts +57 -4
- package/src/core/transport/index.ts +2 -1
- package/src/core/transport/invocation.ts +1 -1
- package/src/core/transport/load-history-pages.ts +220 -0
- package/src/core/transport/load-history.ts +63 -61
- package/src/core/transport/pipe-stream.ts +10 -1
- package/src/core/transport/run-manager.ts +25 -31
- package/src/core/transport/session-support.ts +96 -0
- package/src/core/transport/tree.ts +414 -47
- package/src/core/transport/types/agent.ts +129 -102
- package/src/core/transport/types/client.ts +49 -13
- package/src/core/transport/types/tree.ts +61 -12
- package/src/core/transport/types/view.ts +57 -28
- package/src/core/transport/view.ts +520 -172
- package/src/core/transport/wire-log.ts +189 -0
- package/src/errors.ts +10 -3
- package/src/index.ts +44 -11
- package/src/react/contexts/client-session-context.ts +1 -1
- package/src/react/contexts/client-session-provider.tsx +38 -2
- package/src/react/index.ts +2 -1
- package/src/react/internal/skipped-session.ts +62 -0
- package/src/react/use-client-session.ts +7 -30
- package/src/react/use-view.ts +3 -3
- package/src/utils.ts +31 -97
- package/src/vercel/codec/decode-lifecycle.ts +70 -0
- package/src/vercel/codec/events.ts +1 -3
- package/src/vercel/codec/fields.ts +58 -0
- package/src/vercel/codec/fold-content.ts +54 -0
- package/src/vercel/codec/fold-data.ts +46 -0
- package/src/vercel/codec/fold-input.ts +255 -0
- package/src/vercel/codec/fold-lifecycle.ts +85 -0
- package/src/vercel/codec/fold-text.ts +55 -0
- package/src/vercel/codec/fold-tool-input.ts +86 -0
- package/src/vercel/codec/fold-tool-output.ts +79 -0
- package/src/vercel/codec/index.ts +23 -63
- package/src/vercel/codec/inputs.ts +116 -0
- package/src/vercel/codec/outputs.ts +207 -0
- package/src/vercel/codec/reducer-state.ts +169 -0
- package/src/vercel/codec/reducer.ts +52 -838
- package/src/vercel/codec/tool-transitions.ts +1 -12
- package/src/vercel/codec/wire-data.ts +64 -0
- package/src/vercel/index.ts +1 -0
- package/src/vercel/react/contexts/chat-transport-context.ts +1 -1
- package/src/vercel/react/use-chat-transport.ts +8 -28
- package/src/vercel/react/use-message-sync.ts +5 -10
- package/src/vercel/run-end-reason.ts +95 -16
- package/src/vercel/tool-part.ts +25 -0
- package/src/vercel/transport/chat-transport.ts +10 -22
- package/src/vercel/transport/index.ts +1 -1
- package/src/vercel/transport/run-output-stream.ts +7 -8
- package/src/version.ts +1 -1
- package/dist/core/transport/branch-chain.d.ts +0 -43
- package/dist/core/transport/load-conversation.d.ts +0 -128
- package/dist/vercel/codec/decoder.d.ts +0 -9
- package/dist/vercel/codec/encoder.d.ts +0 -11
- package/src/core/transport/branch-chain.ts +0 -58
- package/src/core/transport/load-conversation.ts +0 -355
- package/src/vercel/codec/decoder.ts +0 -696
- package/src/vercel/codec/encoder.ts +0 -548
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loadHistoryPages — shared low-level history pagination primitive.
|
|
3
|
+
*
|
|
4
|
+
* Consumed by both client (via `load-history.ts`, which layers a complete-
|
|
5
|
+
* domain-message counter on top) and agent (directly, for input-event lookup
|
|
6
|
+
* and conversation hydration). Returns raw Ably messages; does NOT decode.
|
|
7
|
+
*
|
|
8
|
+
* Behaviour:
|
|
9
|
+
* - Attaches the channel (idempotent) then pages via `channel.history()`,
|
|
10
|
+
* using `untilAttach: true` for gapless continuity with any live subscription.
|
|
11
|
+
* - Exposes the underlying pagination as a cursor with `hasNext()` (cheap,
|
|
12
|
+
* no network) and `next()` (one Ably page per call, newest-first within
|
|
13
|
+
* the page).
|
|
14
|
+
* - Per-page failures are retried with bounded exponential backoff; on
|
|
15
|
+
* exhaustion throws `Ably.ErrorInfo` with code `HistoryFetchFailed`.
|
|
16
|
+
* - `signal.aborted` is checked between pages; rejects with
|
|
17
|
+
* `Ably.ErrorInfo` (InvalidArgument) when aborted.
|
|
18
|
+
*
|
|
19
|
+
* Spec: AIT-CT11 / AIT-ST hydration.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as Ably from 'ably';
|
|
23
|
+
|
|
24
|
+
import { ErrorCode } from '../../errors.js';
|
|
25
|
+
import type { Logger } from '../../logger.js';
|
|
26
|
+
import { errorCause, errorMessage } from '../../utils.js';
|
|
27
|
+
|
|
28
|
+
/** Options for {@link loadHistoryPages}. */
|
|
29
|
+
export interface LoadHistoryPagesOptions {
|
|
30
|
+
/** Wire-message limit per Ably page. */
|
|
31
|
+
pageLimit: number;
|
|
32
|
+
/** Set `untilAttach: true` on the underlying history query for gapless continuity with live subscriptions. Default: true. */
|
|
33
|
+
untilAttach?: boolean;
|
|
34
|
+
/** AbortSignal checked between pages. Rejects with InvalidArgument when aborted. */
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
/** Max retries per `page.next()` / initial `history()` failure. Default: 3. */
|
|
37
|
+
maxRetries?: number;
|
|
38
|
+
/** Initial retry backoff in ms (doubled per attempt). Default: 100. */
|
|
39
|
+
retryBackoffMs?: number;
|
|
40
|
+
/** Logger for diagnostic output. */
|
|
41
|
+
logger?: Logger;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Cursor over the channel's history pages.
|
|
46
|
+
*
|
|
47
|
+
* `hasNext()` is cheap (cursor-only, no network); `next()` issues one Ably
|
|
48
|
+
* page fetch (with retry/backoff) and returns its messages. Once `next()`
|
|
49
|
+
* returns `undefined` the cursor is exhausted.
|
|
50
|
+
*/
|
|
51
|
+
export interface HistoryPagesCursor {
|
|
52
|
+
/** True when another Ably page is available (cheap to check; no network). */
|
|
53
|
+
hasNext(): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Fetch the next Ably page's messages (newest-first within the page).
|
|
56
|
+
* Returns `undefined` when no more pages are available or the abort
|
|
57
|
+
* signal has fired.
|
|
58
|
+
*/
|
|
59
|
+
next(): Promise<readonly Ably.InboundMessage[] | undefined>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Sleep for `ms` milliseconds, honouring an AbortSignal.
|
|
64
|
+
* @param ms - Milliseconds to wait.
|
|
65
|
+
* @param signal - Optional abort signal; rejects when fired.
|
|
66
|
+
*/
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/promise-function-async -- the function body is the Promise constructor; async would wrap it in an extra Promise
|
|
68
|
+
const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
|
|
69
|
+
new Promise<void>((resolve, reject) => {
|
|
70
|
+
if (signal?.aborted) {
|
|
71
|
+
reject(new Ably.ErrorInfo('unable to wait; signal aborted', ErrorCode.InvalidArgument, 400));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const timer: ReturnType<typeof setTimeout> | number = setTimeout(() => {
|
|
75
|
+
signal?.removeEventListener('abort', onAbort);
|
|
76
|
+
resolve();
|
|
77
|
+
}, ms);
|
|
78
|
+
// Node returns an unref-able Timeout; browsers return a number. Unref so
|
|
79
|
+
// a retry backoff cannot keep a Node process alive by itself.
|
|
80
|
+
if (typeof timer === 'object') timer.unref();
|
|
81
|
+
const onAbort = (): void => {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
reject(new Ably.ErrorInfo('unable to wait; signal aborted', ErrorCode.InvalidArgument, 400));
|
|
84
|
+
};
|
|
85
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Invoke `fetchPage`, retrying on failure with exponential backoff. Throws
|
|
90
|
+
* the last failure wrapped as `HistoryFetchFailed` once retries are
|
|
91
|
+
* exhausted.
|
|
92
|
+
* @param fetchPage - The page fetch to retry (initial `channel.history()` call or a `page.next()`).
|
|
93
|
+
* @param maxRetries - Maximum number of attempts after the initial call.
|
|
94
|
+
* @param initialBackoffMs - Starting backoff delay (doubled per attempt).
|
|
95
|
+
* @param signal - Optional abort signal; cancels remaining retries.
|
|
96
|
+
* @param logger - Optional logger.
|
|
97
|
+
* @returns The fetched page, or `undefined` when pagination is exhausted.
|
|
98
|
+
*/
|
|
99
|
+
const fetchPageWithRetry = async (
|
|
100
|
+
fetchPage: () => Promise<Ably.PaginatedResult<Ably.InboundMessage> | undefined>,
|
|
101
|
+
maxRetries: number,
|
|
102
|
+
initialBackoffMs: number,
|
|
103
|
+
signal: AbortSignal | undefined,
|
|
104
|
+
logger: Logger | undefined,
|
|
105
|
+
): Promise<Ably.PaginatedResult<Ably.InboundMessage> | undefined> => {
|
|
106
|
+
let lastError: unknown;
|
|
107
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
108
|
+
if (signal?.aborted) {
|
|
109
|
+
throw new Ably.ErrorInfo(
|
|
110
|
+
'unable to fetch history page; signal aborted',
|
|
111
|
+
ErrorCode.InvalidArgument,
|
|
112
|
+
400,
|
|
113
|
+
errorCause(lastError),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
return await fetchPage();
|
|
118
|
+
} catch (error) {
|
|
119
|
+
lastError = error;
|
|
120
|
+
if (attempt === maxRetries) break;
|
|
121
|
+
const backoff = initialBackoffMs * 2 ** attempt;
|
|
122
|
+
logger?.debug('loadHistoryPages.fetchPageWithRetry(); page fetch failed, retrying', {
|
|
123
|
+
attempt: attempt + 1,
|
|
124
|
+
maxRetries,
|
|
125
|
+
backoff,
|
|
126
|
+
});
|
|
127
|
+
await sleep(backoff, signal);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
throw new Ably.ErrorInfo(
|
|
131
|
+
`unable to fetch history page; ${errorMessage(lastError)}`,
|
|
132
|
+
ErrorCode.HistoryFetchFailed,
|
|
133
|
+
500,
|
|
134
|
+
errorCause(lastError),
|
|
135
|
+
);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Page through channel history, returning a cursor over Ably pages.
|
|
140
|
+
*
|
|
141
|
+
* Newest-first within each yielded page (matching Ably's native ordering).
|
|
142
|
+
* Caller drives the cursor — calling `next()` until it returns `undefined`
|
|
143
|
+
* or stopping early when a domain-specific stop condition is met
|
|
144
|
+
* (e.g. complete-message counter satisfied, target codec-message-id found,
|
|
145
|
+
* parent chain walk reaches root).
|
|
146
|
+
*
|
|
147
|
+
* The initial Ably history call is awaited eagerly so the returned cursor
|
|
148
|
+
* already knows whether there are pages available (via `hasNext()`).
|
|
149
|
+
* @param channel - The Ably channel to read history from.
|
|
150
|
+
* @param options - Pagination options.
|
|
151
|
+
* @returns A cursor with `hasNext()` (cheap, cursor-only) and `next()` (fetches one page with retry).
|
|
152
|
+
* @throws {Ably.ErrorInfo} `HistoryFetchFailed` on exhausted retry of the initial fetch, or `InvalidArgument` on signal abort.
|
|
153
|
+
*/
|
|
154
|
+
export const loadHistoryPages = async (
|
|
155
|
+
channel: Ably.RealtimeChannel,
|
|
156
|
+
options: LoadHistoryPagesOptions,
|
|
157
|
+
): Promise<HistoryPagesCursor> => {
|
|
158
|
+
const { pageLimit, untilAttach = true, signal, maxRetries = 3, retryBackoffMs = 100, logger } = options;
|
|
159
|
+
|
|
160
|
+
if (signal?.aborted) {
|
|
161
|
+
throw new Ably.ErrorInfo('unable to load history; signal aborted', ErrorCode.InvalidArgument, 400);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await channel.attach();
|
|
165
|
+
|
|
166
|
+
const historyParams: Ably.RealtimeHistoryParams = { limit: pageLimit, untilAttach };
|
|
167
|
+
|
|
168
|
+
let currentPage: Ably.PaginatedResult<Ably.InboundMessage> | undefined = await fetchPageWithRetry(
|
|
169
|
+
// eslint-disable-next-line @typescript-eslint/promise-function-async -- channel.history returns a real Promise
|
|
170
|
+
() => channel.history(historyParams),
|
|
171
|
+
maxRetries,
|
|
172
|
+
retryBackoffMs,
|
|
173
|
+
signal,
|
|
174
|
+
logger,
|
|
175
|
+
);
|
|
176
|
+
let firstYielded = false;
|
|
177
|
+
|
|
178
|
+
// Compute whether the cursor has another page available. Cheap — no
|
|
179
|
+
// network. Reflects the latest fetched page's `hasNext()` plus the signal
|
|
180
|
+
// check.
|
|
181
|
+
const hasNext = (): boolean => {
|
|
182
|
+
if (currentPage === undefined) return false;
|
|
183
|
+
if (signal?.aborted) return false;
|
|
184
|
+
if (!firstYielded) return true;
|
|
185
|
+
return currentPage.hasNext();
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const next = async (): Promise<readonly Ably.InboundMessage[] | undefined> => {
|
|
189
|
+
if (currentPage === undefined) return undefined;
|
|
190
|
+
if (signal?.aborted) {
|
|
191
|
+
throw new Ably.ErrorInfo('unable to load history; signal aborted', ErrorCode.InvalidArgument, 400);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!firstYielded) {
|
|
195
|
+
firstYielded = true;
|
|
196
|
+
return currentPage.items;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!currentPage.hasNext()) {
|
|
200
|
+
currentPage = undefined;
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const nextPage: Ably.PaginatedResult<Ably.InboundMessage> | undefined = await fetchPageWithRetry(
|
|
205
|
+
async () => (await currentPage?.next()) ?? undefined,
|
|
206
|
+
maxRetries,
|
|
207
|
+
retryBackoffMs,
|
|
208
|
+
signal,
|
|
209
|
+
logger,
|
|
210
|
+
);
|
|
211
|
+
if (!nextPage) {
|
|
212
|
+
currentPage = undefined;
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
currentPage = nextPage;
|
|
216
|
+
return nextPage.items;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
return { hasNext, next };
|
|
220
|
+
};
|
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* loadHistory — load conversation history from an Ably channel and return
|
|
3
|
-
* the raw
|
|
3
|
+
* the raw Ably messages as a paginated HistoryPage result.
|
|
4
4
|
*
|
|
5
|
-
* This does NOT decode: it pages back through Ably history
|
|
6
|
-
*
|
|
7
|
-
* (oldest-first) to the caller.
|
|
8
|
-
* itself, so load-history only needs
|
|
9
|
-
* counter to decide when to stop paging
|
|
5
|
+
* This does NOT decode: it pages back through Ably history via the shared
|
|
6
|
+
* {@link loadHistoryPages} primitive until `limit` complete messages are
|
|
7
|
+
* present, then hands the raw Ably messages (oldest-first) to the caller.
|
|
8
|
+
* The View re-decodes them into the Tree itself, so load-history only needs
|
|
9
|
+
* a cheap, header-based completion counter to decide when to stop paging.
|
|
10
10
|
*
|
|
11
11
|
* The `limit` option controls the number of complete **messages** per page,
|
|
12
|
-
* not the number of Ably
|
|
12
|
+
* not the number of Ably messages fetched. A message is complete when
|
|
13
13
|
* its terminal wire signal — `status: "complete"` / `"cancelled"`, or a
|
|
14
|
-
* `discrete` create — has been seen. Runs that span a
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* `discrete` create — has been seen. Runs that span a page boundary are
|
|
15
|
+
* handled by the counter requiring both a start and a terminal signal
|
|
16
|
+
* before counting a message complete.
|
|
17
17
|
*
|
|
18
18
|
* Because Ably history returns newest-first, each page's `rawMessages` are
|
|
19
19
|
* reversed to chronological (oldest-first) so the caller can fold them in
|
|
20
20
|
* order.
|
|
21
|
+
*
|
|
22
|
+
* Spec: AIT-CT11, AIT-CT11b.
|
|
21
23
|
*/
|
|
22
24
|
|
|
23
25
|
import type * as Ably from 'ably';
|
|
@@ -25,6 +27,7 @@ import type * as Ably from 'ably';
|
|
|
25
27
|
import { HEADER_CODEC_MESSAGE_ID, HEADER_DISCRETE, HEADER_STATUS, HEADER_STREAM } from '../../constants.js';
|
|
26
28
|
import type { Logger } from '../../logger.js';
|
|
27
29
|
import { getTransportHeaders } from '../../utils.js';
|
|
30
|
+
import { type HistoryPagesCursor, loadHistoryPages } from './load-history-pages.js';
|
|
28
31
|
import type { HistoryPage, LoadHistoryOptions } from './types.js';
|
|
29
32
|
|
|
30
33
|
// ---------------------------------------------------------------------------
|
|
@@ -32,6 +35,8 @@ import type { HistoryPage, LoadHistoryOptions } from './types.js';
|
|
|
32
35
|
// ---------------------------------------------------------------------------
|
|
33
36
|
|
|
34
37
|
interface HistoryState {
|
|
38
|
+
/** Cursor over the shared `loadHistoryPages` primitive; each `next()` returns one Ably page's messages (newest-first within the page). */
|
|
39
|
+
cursor: HistoryPagesCursor;
|
|
35
40
|
/** All raw Ably messages collected so far, in newest-first order (as received from Ably). */
|
|
36
41
|
rawMessages: Ably.InboundMessage[];
|
|
37
42
|
/**
|
|
@@ -43,14 +48,12 @@ interface HistoryState {
|
|
|
43
48
|
returnedCount: number;
|
|
44
49
|
/** How many raw Ably messages have been served to the consumer so far. */
|
|
45
50
|
returnedRawCount: number;
|
|
46
|
-
/** The last Ably page cursor for continued pagination. */
|
|
47
|
-
lastAblyPage: Ably.PaginatedResult<Ably.InboundMessage> | undefined;
|
|
48
51
|
/**
|
|
49
52
|
* `codec-message-id`s for which a start signal has been seen: any
|
|
50
53
|
* `message.create` / `message.update` / `message.append` with
|
|
51
54
|
* `stream: "true"` (the decoder establishes a tracker via create or
|
|
52
55
|
* first-contact), or a `message.create` carrying `discrete` (a discrete
|
|
53
|
-
* message, created and terminated in one
|
|
56
|
+
* message, created and terminated in one Ably message).
|
|
54
57
|
*/
|
|
55
58
|
startedCodecMessageIds: Set<string>;
|
|
56
59
|
/**
|
|
@@ -80,7 +83,7 @@ interface HistoryState {
|
|
|
80
83
|
*
|
|
81
84
|
* A codec-message-id is considered complete only when BOTH of these have been seen:
|
|
82
85
|
* - a "start" signal: either `discrete` on a `message.create`
|
|
83
|
-
* (discrete messages are created and terminated by the same
|
|
86
|
+
* (discrete messages are created and terminated by the same Ably message
|
|
84
87
|
* message), OR any `message.create` / `message.update` / `message.append`
|
|
85
88
|
* with `stream: "true"` (the decoder establishes a tracker via
|
|
86
89
|
* create or first-contact).
|
|
@@ -104,7 +107,7 @@ interface HistoryState {
|
|
|
104
107
|
* - Missing `codec-message-id`: lifecycle events not tied to a domain message.
|
|
105
108
|
* - `message.delete`: clears the tracker, doesn't produce output.
|
|
106
109
|
*
|
|
107
|
-
* Amend-class
|
|
110
|
+
* Amend-class Ably messages (events targeting an existing message via
|
|
108
111
|
* `HEADER_CODEC_MESSAGE_ID`) flow through the same counter — the Sets naturally
|
|
109
112
|
* dedup so a tool-output amend on an already-seen codec-message-id is idempotent.
|
|
110
113
|
*
|
|
@@ -142,39 +145,30 @@ const countNewCompletions = (state: HistoryState, newMessages: readonly Ably.Inb
|
|
|
142
145
|
};
|
|
143
146
|
|
|
144
147
|
// ---------------------------------------------------------------------------
|
|
145
|
-
//
|
|
148
|
+
// Pull pages from the shared iterator until we have enough completions
|
|
146
149
|
// ---------------------------------------------------------------------------
|
|
147
150
|
|
|
148
151
|
/**
|
|
149
|
-
*
|
|
152
|
+
* Pull chunks from the shared {@link loadHistoryPages} iterator until enough
|
|
153
|
+
* completed messages have been collected (target = already-returned +
|
|
154
|
+
* `limit`) or the iterator is exhausted.
|
|
150
155
|
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
156
|
+
* Uses {@link countNewCompletions} — a cheap O(new messages) header scan —
|
|
157
|
+
* to decide when to stop, rather than running the decoder per page.
|
|
153
158
|
* @param state - The shared history traversal state.
|
|
154
|
-
* @param ablyPage - The current Ably paginated result to start from.
|
|
155
159
|
* @param limit - Target number of completed messages beyond what has already been returned.
|
|
156
160
|
*/
|
|
157
|
-
const fetchUntilLimit = async (
|
|
158
|
-
state: HistoryState,
|
|
159
|
-
ablyPage: Ably.PaginatedResult<Ably.InboundMessage>,
|
|
160
|
-
limit: number,
|
|
161
|
-
): Promise<void> => {
|
|
162
|
-
state.rawMessages.push(...ablyPage.items);
|
|
163
|
-
state.lastAblyPage = ablyPage;
|
|
164
|
-
countNewCompletions(state, ablyPage.items);
|
|
165
|
-
|
|
161
|
+
const fetchUntilLimit = async (state: HistoryState, limit: number): Promise<void> => {
|
|
166
162
|
const target = state.returnedCount + limit;
|
|
167
|
-
while (state.completedCodecMessageIds.size < target &&
|
|
168
|
-
state.logger.debug('loadHistory.fetchUntilLimit();
|
|
163
|
+
while (state.completedCodecMessageIds.size < target && state.cursor.hasNext()) {
|
|
164
|
+
state.logger.debug('loadHistory.fetchUntilLimit(); pulling next page', {
|
|
169
165
|
collected: state.rawMessages.length,
|
|
170
166
|
completed: state.completedCodecMessageIds.size,
|
|
171
167
|
});
|
|
172
|
-
const
|
|
173
|
-
if (!
|
|
174
|
-
|
|
175
|
-
state
|
|
176
|
-
state.lastAblyPage = nextPage;
|
|
177
|
-
countNewCompletions(state, nextPage.items);
|
|
168
|
+
const chunk = await state.cursor.next();
|
|
169
|
+
if (!chunk) break;
|
|
170
|
+
state.rawMessages.push(...chunk);
|
|
171
|
+
countNewCompletions(state, chunk);
|
|
178
172
|
}
|
|
179
173
|
};
|
|
180
174
|
|
|
@@ -183,7 +177,7 @@ const fetchUntilLimit = async (
|
|
|
183
177
|
// ---------------------------------------------------------------------------
|
|
184
178
|
|
|
185
179
|
/**
|
|
186
|
-
* Build a HistoryPage of raw
|
|
180
|
+
* Build a HistoryPage of raw Ably messages from the current fetch state.
|
|
187
181
|
* @param state - The shared history traversal state.
|
|
188
182
|
* @param limit - Max complete messages per page.
|
|
189
183
|
* @returns A page of raw history messages with a `next()` cursor.
|
|
@@ -191,13 +185,13 @@ const fetchUntilLimit = async (
|
|
|
191
185
|
const buildResult = (state: HistoryState, limit: number): HistoryPage => {
|
|
192
186
|
// Advance the served-completion counter by up to `limit`, mirroring the
|
|
193
187
|
// page granularity the consumer asked for. `rawMessages` for this page are
|
|
194
|
-
// all
|
|
188
|
+
// all messages fetched since the previous page (empty for buffered pages).
|
|
195
189
|
const totalCompleted = state.completedCodecMessageIds.size;
|
|
196
190
|
const served = Math.min(limit, Math.max(0, totalCompleted - state.returnedCount));
|
|
197
191
|
state.returnedCount += served;
|
|
198
192
|
|
|
199
193
|
const moreCompleted = totalCompleted > state.returnedCount;
|
|
200
|
-
const
|
|
194
|
+
const moreFromCursor = state.cursor.hasNext();
|
|
201
195
|
|
|
202
196
|
// Raw Ably messages for this page in chronological order (oldest first).
|
|
203
197
|
const newRawCount = state.rawMessages.length - state.returnedRawCount;
|
|
@@ -206,15 +200,13 @@ const buildResult = (state: HistoryState, limit: number): HistoryPage => {
|
|
|
206
200
|
|
|
207
201
|
return {
|
|
208
202
|
rawMessages,
|
|
209
|
-
hasNext: () => moreCompleted ||
|
|
203
|
+
hasNext: () => moreCompleted || moreFromCursor,
|
|
210
204
|
next: async () => {
|
|
211
205
|
if (moreCompleted) {
|
|
212
206
|
return buildResult(state, limit);
|
|
213
207
|
}
|
|
214
|
-
if (!
|
|
215
|
-
|
|
216
|
-
if (!nextAbly) return;
|
|
217
|
-
await fetchUntilLimit(state, nextAbly, limit);
|
|
208
|
+
if (!moreFromCursor) return;
|
|
209
|
+
await fetchUntilLimit(state, limit);
|
|
218
210
|
return buildResult(state, limit);
|
|
219
211
|
},
|
|
220
212
|
};
|
|
@@ -225,14 +217,17 @@ const buildResult = (state: HistoryState, limit: number): HistoryPage => {
|
|
|
225
217
|
// ---------------------------------------------------------------------------
|
|
226
218
|
|
|
227
219
|
/**
|
|
228
|
-
* Load conversation history from a channel and return the raw
|
|
220
|
+
* Load conversation history from a channel and return the raw Ably messages.
|
|
229
221
|
*
|
|
230
|
-
*
|
|
231
|
-
* `
|
|
232
|
-
*
|
|
222
|
+
* Drives the shared {@link loadHistoryPages} primitive with
|
|
223
|
+
* `untilAttach: true` (gapless continuity with the live subscription) and a
|
|
224
|
+
* Ably message page limit that overprovisions for the many-Ably-messages-per-domain-message
|
|
225
|
+
* ratio. Each `HistoryPage` returned covers up to `limit` complete domain
|
|
226
|
+
* messages; subsequent pages drain over-collected messages from the buffer
|
|
227
|
+
* before pulling more from the iterator.
|
|
233
228
|
*
|
|
234
|
-
* The `limit` option controls the number of complete messages
|
|
235
|
-
*
|
|
229
|
+
* The `limit` option controls the number of complete messages returned per
|
|
230
|
+
* page, not the number of Ably messages fetched.
|
|
236
231
|
* @param channel - The Ably channel to load history from.
|
|
237
232
|
* @param options - Pagination options.
|
|
238
233
|
* @param logger - Logger for diagnostic output.
|
|
@@ -245,25 +240,32 @@ export const loadHistory = async (
|
|
|
245
240
|
logger: Logger,
|
|
246
241
|
): Promise<HistoryPage> => {
|
|
247
242
|
const limit = options?.limit ?? 100;
|
|
243
|
+
|
|
244
|
+
logger.trace('loadHistory();', { limit });
|
|
245
|
+
|
|
246
|
+
// Request more Ably messages per page than the domain-message limit to
|
|
247
|
+
// account for the many-to-one ratio (multiple Ably messages per domain message).
|
|
248
|
+
// The wire pagination is internal to the iterator; the consumer-visible
|
|
249
|
+
// pagination is by complete domain messages.
|
|
250
|
+
const wireLimit = limit * 10;
|
|
251
|
+
|
|
252
|
+
const cursor = await loadHistoryPages(channel, {
|
|
253
|
+
pageLimit: wireLimit,
|
|
254
|
+
untilAttach: true,
|
|
255
|
+
logger,
|
|
256
|
+
});
|
|
257
|
+
|
|
248
258
|
const state: HistoryState = {
|
|
259
|
+
cursor,
|
|
249
260
|
rawMessages: [],
|
|
250
261
|
returnedCount: 0,
|
|
251
262
|
returnedRawCount: 0,
|
|
252
|
-
lastAblyPage: undefined,
|
|
253
263
|
startedCodecMessageIds: new Set<string>(),
|
|
254
264
|
terminatedCodecMessageIds: new Set<string>(),
|
|
255
265
|
completedCodecMessageIds: new Set<string>(),
|
|
256
266
|
logger,
|
|
257
267
|
};
|
|
258
268
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
// Request more Ably messages than the domain limit to account for
|
|
262
|
-
// the many-to-one ratio (multiple wire messages per message).
|
|
263
|
-
const wireLimit = limit * 10;
|
|
264
|
-
|
|
265
|
-
await channel.attach();
|
|
266
|
-
const ablyPage = await channel.history({ untilAttach: true, limit: wireLimit });
|
|
267
|
-
await fetchUntilLimit(state, ablyPage, limit);
|
|
269
|
+
await fetchUntilLimit(state, limit);
|
|
268
270
|
return buildResult(state, limit);
|
|
269
271
|
};
|
|
@@ -80,12 +80,21 @@ export const pipeStream = async <TInput extends CodecInputEvent, TOutput extends
|
|
|
80
80
|
if (onCancelled) {
|
|
81
81
|
await onCancelled(async (output: TOutput) => encoder.publishOutput(output));
|
|
82
82
|
}
|
|
83
|
-
|
|
83
|
+
// Transport mechanics only — close in-flight streamed messages as
|
|
84
|
+
// cancelled. Run termination is the transport ai-run-end event,
|
|
85
|
+
// guaranteed by Run.pipe on a cancelled result.
|
|
86
|
+
await encoder.cancelStreams();
|
|
84
87
|
break;
|
|
85
88
|
}
|
|
86
89
|
|
|
87
90
|
const { done, value } = result;
|
|
88
91
|
if (done) {
|
|
92
|
+
// An agent-side self-abort (e.g. the AI SDK's abort signal firing)
|
|
93
|
+
// completes the stream without end chunks for in-flight streamed
|
|
94
|
+
// messages. Terminate any still-open wire streams with a cancelled
|
|
95
|
+
// status so decoders and history see a terminal; streams that closed
|
|
96
|
+
// normally are skipped (no-op on a clean completion).
|
|
97
|
+
await encoder.cancelStreams();
|
|
89
98
|
await encoder.close();
|
|
90
99
|
logger?.debug('pipeStream(); stream completed');
|
|
91
100
|
break;
|
|
@@ -19,7 +19,7 @@ import type { RunEndReason } from './types.js';
|
|
|
19
19
|
* continuation (re-entering an existing run) sets `continuation` and omits the
|
|
20
20
|
* structural `parent` / `forkOf` / `regenerates` fields.
|
|
21
21
|
*/
|
|
22
|
-
|
|
22
|
+
interface StartRunMetadata {
|
|
23
23
|
/** Structural parent codec-message-id (fresh run-start only). */
|
|
24
24
|
parent?: string;
|
|
25
25
|
/** Forked user-prompt codec-message-id for an edit (fresh run-start only). */
|
|
@@ -47,14 +47,9 @@ export interface RunManager {
|
|
|
47
47
|
* `ai-run-start` for a fresh run, or `ai-run-resume` when `metadata.continuation`
|
|
48
48
|
* is set (a subsequent invocation re-entering an existing run). A resume omits
|
|
49
49
|
* the structural `parent` / `forkOf` / `regenerates` headers — the original
|
|
50
|
-
* run-start owns the run's structure.
|
|
50
|
+
* run-start owns the run's structure.
|
|
51
51
|
*/
|
|
52
|
-
startRun(
|
|
53
|
-
runId: string,
|
|
54
|
-
clientId?: string,
|
|
55
|
-
controller?: AbortController,
|
|
56
|
-
metadata?: StartRunMetadata,
|
|
57
|
-
): Promise<AbortSignal>;
|
|
52
|
+
startRun(runId: string, clientId?: string, controller?: AbortController, metadata?: StartRunMetadata): Promise<void>;
|
|
58
53
|
/**
|
|
59
54
|
* Suspend a run. Publishes run-suspend on the channel and drops the run's
|
|
60
55
|
* active-run entry — the agent process terminates on suspend, so there is no
|
|
@@ -70,7 +65,10 @@ export interface RunManager {
|
|
|
70
65
|
* run-reason header) and drops the run's active-run entry. Carries the same
|
|
71
66
|
* per-invocation attribution as {@link suspendRun} (`invocationId`,
|
|
72
67
|
* `inputClientId`, `inputCodecMessageId`), since run-end is the terminal event
|
|
73
|
-
* of the ending invocation.
|
|
68
|
+
* of the ending invocation. When `reason` is `'error'` and an `error` is
|
|
69
|
+
* supplied, its `code` and `message` are additionally stamped as the
|
|
70
|
+
* `error-code` / `error-message` headers — a codec-agnostic baseline failure
|
|
71
|
+
* detail for consumers; omitting `error` publishes a bare `reason: 'error'`.
|
|
74
72
|
*/
|
|
75
73
|
endRun(
|
|
76
74
|
runId: string,
|
|
@@ -78,15 +76,10 @@ export interface RunManager {
|
|
|
78
76
|
invocationId?: string,
|
|
79
77
|
inputClientId?: string,
|
|
80
78
|
inputCodecMessageId?: string,
|
|
79
|
+
error?: Ably.ErrorInfo,
|
|
81
80
|
): Promise<void>;
|
|
82
|
-
/** Get the AbortSignal for a run. */
|
|
83
|
-
getSignal(runId: string): AbortSignal | undefined;
|
|
84
81
|
/** Get the clientId that owns a run. */
|
|
85
82
|
getClientId(runId: string): string | undefined;
|
|
86
|
-
/** Fire the AbortSignal for a run to cancel any in-flight work. */
|
|
87
|
-
cancel(runId: string): void;
|
|
88
|
-
/** Get all active run IDs. */
|
|
89
|
-
getActiveRunIds(): string[];
|
|
90
83
|
/** Cancel all active runs and clear state. */
|
|
91
84
|
close(): void;
|
|
92
85
|
}
|
|
@@ -119,7 +112,7 @@ class DefaultRunManager implements RunManager {
|
|
|
119
112
|
clientId?: string,
|
|
120
113
|
externalController?: AbortController,
|
|
121
114
|
metadata?: StartRunMetadata,
|
|
122
|
-
): Promise<
|
|
115
|
+
): Promise<void> {
|
|
123
116
|
this._logger?.trace('DefaultRunManager.startRun();', { runId, clientId });
|
|
124
117
|
|
|
125
118
|
const controller = externalController ?? new AbortController();
|
|
@@ -153,7 +146,6 @@ class DefaultRunManager implements RunManager {
|
|
|
153
146
|
});
|
|
154
147
|
|
|
155
148
|
this._logger?.debug('DefaultRunManager.startRun(); run started', { runId });
|
|
156
|
-
return controller.signal;
|
|
157
149
|
}
|
|
158
150
|
|
|
159
151
|
async suspendRun(
|
|
@@ -173,9 +165,20 @@ class DefaultRunManager implements RunManager {
|
|
|
173
165
|
invocationId?: string,
|
|
174
166
|
inputClientId?: string,
|
|
175
167
|
inputCodecMessageId?: string,
|
|
168
|
+
error?: Ably.ErrorInfo,
|
|
176
169
|
): Promise<void> {
|
|
177
170
|
this._logger?.trace('DefaultRunManager.endRun();', { runId, reason });
|
|
178
|
-
|
|
171
|
+
// Stamp error detail only for a terminal error the agent chose to surface
|
|
172
|
+
// (AIT-ST6b4: explicit, never automatic). error-code / error-message are
|
|
173
|
+
// generic transport headers, so any codec or consumer can read them.
|
|
174
|
+
const errorAttribution = reason === 'error' && error ? { errorCode: error.code, errorMessage: error.message } : {};
|
|
175
|
+
await this._publishTerminal(EVENT_RUN_END, runId, {
|
|
176
|
+
reason,
|
|
177
|
+
invocationId,
|
|
178
|
+
inputClientId,
|
|
179
|
+
inputCodecMessageId,
|
|
180
|
+
...errorAttribution,
|
|
181
|
+
});
|
|
179
182
|
this._logger?.debug('DefaultRunManager.endRun(); run ended', { runId, reason });
|
|
180
183
|
}
|
|
181
184
|
|
|
@@ -192,6 +195,8 @@ class DefaultRunManager implements RunManager {
|
|
|
192
195
|
* @param attribution.invocationId - The invocation's id.
|
|
193
196
|
* @param attribution.inputClientId - ClientId of the triggering input event.
|
|
194
197
|
* @param attribution.inputCodecMessageId - Codec-message-id of the triggering input event.
|
|
198
|
+
* @param attribution.errorCode - Numeric error code; set for run-end only when a terminal error is surfaced.
|
|
199
|
+
* @param attribution.errorMessage - Error message; paired with errorCode.
|
|
195
200
|
*/
|
|
196
201
|
private async _publishTerminal(
|
|
197
202
|
eventName: string,
|
|
@@ -201,6 +206,8 @@ class DefaultRunManager implements RunManager {
|
|
|
201
206
|
invocationId?: string;
|
|
202
207
|
inputClientId?: string;
|
|
203
208
|
inputCodecMessageId?: string;
|
|
209
|
+
errorCode?: number;
|
|
210
|
+
errorMessage?: string;
|
|
204
211
|
},
|
|
205
212
|
): Promise<void> {
|
|
206
213
|
const resolvedClientId = this._activeRuns.get(runId)?.clientId ?? '';
|
|
@@ -209,23 +216,10 @@ class DefaultRunManager implements RunManager {
|
|
|
209
216
|
this._activeRuns.delete(runId);
|
|
210
217
|
}
|
|
211
218
|
|
|
212
|
-
getSignal(runId: string): AbortSignal | undefined {
|
|
213
|
-
return this._activeRuns.get(runId)?.controller.signal;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
219
|
getClientId(runId: string): string | undefined {
|
|
217
220
|
return this._activeRuns.get(runId)?.clientId;
|
|
218
221
|
}
|
|
219
222
|
|
|
220
|
-
cancel(runId: string): void {
|
|
221
|
-
this._logger?.debug('DefaultRunManager.cancel();', { runId });
|
|
222
|
-
this._activeRuns.get(runId)?.controller.abort();
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
getActiveRunIds(): string[] {
|
|
226
|
-
return [...this._activeRuns.keys()];
|
|
227
|
-
}
|
|
228
|
-
|
|
229
223
|
close(): void {
|
|
230
224
|
this._logger?.trace('DefaultRunManager.close();', { activeRuns: this._activeRuns.size });
|
|
231
225
|
for (const state of this._activeRuns.values()) {
|