@ably/ai-transport 0.0.1 → 0.2.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 +114 -116
- package/dist/ably-ai-transport.js +1743 -961
- 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 +117 -39
- package/dist/core/agent.d.ts +29 -0
- package/dist/core/codec/decoder.d.ts +20 -23
- package/dist/core/codec/encoder.d.ts +11 -8
- package/dist/core/codec/index.d.ts +1 -2
- package/dist/core/codec/lifecycle-tracker.d.ts +10 -9
- package/dist/core/codec/types.d.ts +410 -101
- package/dist/core/transport/agent-session.d.ts +10 -0
- package/dist/core/transport/branch-chain.d.ts +43 -0
- package/dist/core/transport/client-session.d.ts +13 -0
- package/dist/core/transport/decode-fold.d.ts +47 -0
- package/dist/core/transport/headers.d.ts +97 -17
- package/dist/core/transport/index.d.ts +5 -3
- package/dist/core/transport/internal/bounded-map.d.ts +20 -0
- package/dist/core/transport/invocation.d.ts +74 -0
- package/dist/core/transport/load-conversation.d.ts +128 -0
- package/dist/core/transport/load-history.d.ts +39 -0
- package/dist/core/transport/pipe-stream.d.ts +9 -8
- package/dist/core/transport/run-manager.d.ts +78 -0
- package/dist/core/transport/tree.d.ts +435 -0
- package/dist/core/transport/types/agent.d.ts +353 -0
- package/dist/core/transport/types/client.d.ts +168 -0
- package/dist/core/transport/types/shared.d.ts +24 -0
- package/dist/core/transport/types/tree.d.ts +315 -0
- package/dist/core/transport/types/view.d.ts +222 -0
- package/dist/core/transport/types.d.ts +13 -402
- package/dist/core/transport/view.d.ts +354 -0
- package/dist/errors.d.ts +37 -9
- package/dist/index.d.ts +6 -6
- package/dist/logger.d.ts +12 -0
- package/dist/react/ably-ai-transport-react.js +1164 -645
- 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 +36 -0
- package/dist/react/contexts/client-session-provider.d.ts +53 -0
- package/dist/react/create-session-hooks.d.ts +116 -0
- package/dist/react/index.d.ts +16 -10
- package/dist/react/internal/use-resolved-session.d.ts +36 -0
- package/dist/react/use-ably-messages.d.ts +20 -11
- package/dist/react/use-client-session.d.ts +81 -0
- package/dist/react/use-create-view.d.ts +23 -0
- package/dist/react/use-tree.d.ts +35 -0
- package/dist/react/use-view.d.ts +110 -0
- package/dist/utils.d.ts +32 -23
- package/dist/vercel/ably-ai-transport-vercel.js +2748 -1625
- 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/decoder.d.ts +5 -18
- package/dist/vercel/codec/encoder.d.ts +6 -36
- package/dist/vercel/codec/events.d.ts +51 -0
- package/dist/vercel/codec/index.d.ts +24 -12
- package/dist/vercel/codec/reducer.d.ts +144 -0
- package/dist/vercel/codec/tool-transitions.d.ts +50 -0
- package/dist/vercel/index.d.ts +4 -2
- package/dist/vercel/react/ably-ai-transport-vercel-react.js +10298 -1410
- package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +70 -1
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
- package/dist/vercel/react/contexts/chat-transport-context.d.ts +33 -0
- package/dist/vercel/react/contexts/chat-transport-provider.d.ts +96 -0
- package/dist/vercel/react/index.d.ts +4 -0
- package/dist/vercel/react/use-chat-transport.d.ts +66 -21
- package/dist/vercel/react/use-message-sync.d.ts +31 -12
- package/dist/vercel/run-end-reason.d.ts +29 -0
- package/dist/vercel/transport/chat-transport.d.ts +71 -30
- package/dist/vercel/transport/index.d.ts +25 -18
- package/dist/vercel/transport/run-output-stream.d.ts +56 -0
- package/dist/version.d.ts +2 -0
- package/package.json +47 -34
- package/src/constants.ts +126 -47
- package/src/core/agent.ts +68 -0
- package/src/core/codec/decoder.ts +71 -98
- package/src/core/codec/encoder.ts +115 -58
- package/src/core/codec/index.ts +13 -6
- package/src/core/codec/lifecycle-tracker.ts +10 -9
- package/src/core/codec/types.ts +438 -106
- package/src/core/transport/agent-session.ts +1344 -0
- package/src/core/transport/branch-chain.ts +58 -0
- package/src/core/transport/client-session.ts +775 -0
- package/src/core/transport/decode-fold.ts +91 -0
- package/src/core/transport/headers.ts +182 -19
- package/src/core/transport/index.ts +29 -22
- package/src/core/transport/internal/bounded-map.ts +27 -0
- package/src/core/transport/invocation.ts +98 -0
- package/src/core/transport/load-conversation.ts +355 -0
- package/src/core/transport/load-history.ts +269 -0
- package/src/core/transport/pipe-stream.ts +58 -40
- package/src/core/transport/run-manager.ts +249 -0
- package/src/core/transport/tree.ts +1167 -0
- package/src/core/transport/types/agent.ts +407 -0
- package/src/core/transport/types/client.ts +211 -0
- package/src/core/transport/types/shared.ts +27 -0
- package/src/core/transport/types/tree.ts +344 -0
- package/src/core/transport/types/view.ts +259 -0
- package/src/core/transport/types.ts +13 -527
- package/src/core/transport/view.ts +1271 -0
- package/src/errors.ts +42 -9
- package/src/event-emitter.ts +3 -2
- package/src/index.ts +55 -39
- package/src/logger.ts +14 -1
- package/src/react/contexts/client-session-context.ts +41 -0
- package/src/react/contexts/client-session-provider.tsx +186 -0
- package/src/react/create-session-hooks.ts +141 -0
- package/src/react/index.ts +27 -10
- package/src/react/internal/use-resolved-session.ts +63 -0
- package/src/react/use-ably-messages.ts +47 -19
- package/src/react/use-client-session.ts +201 -0
- package/src/react/use-create-view.ts +72 -0
- package/src/react/use-tree.ts +84 -0
- package/src/react/use-view.ts +275 -0
- package/src/react/vite.config.ts +4 -1
- package/src/utils.ts +63 -45
- package/src/vercel/codec/decoder.ts +336 -255
- package/src/vercel/codec/encoder.ts +348 -196
- package/src/vercel/codec/events.ts +87 -0
- package/src/vercel/codec/index.ts +59 -14
- package/src/vercel/codec/reducer.ts +977 -0
- package/src/vercel/codec/tool-transitions.ts +122 -0
- package/src/vercel/index.ts +7 -3
- package/src/vercel/react/contexts/chat-transport-context.ts +41 -0
- package/src/vercel/react/contexts/chat-transport-provider.tsx +150 -0
- package/src/vercel/react/index.ts +13 -1
- package/src/vercel/react/use-chat-transport.ts +162 -42
- package/src/vercel/react/use-message-sync.ts +121 -22
- package/src/vercel/react/vite.config.ts +4 -2
- package/src/vercel/run-end-reason.ts +78 -0
- package/src/vercel/transport/chat-transport.ts +553 -113
- package/src/vercel/transport/index.ts +40 -28
- package/src/vercel/transport/run-output-stream.ts +170 -0
- package/src/version.ts +2 -0
- package/dist/core/transport/client-transport.d.ts +0 -10
- package/dist/core/transport/conversation-tree.d.ts +0 -9
- package/dist/core/transport/decode-history.d.ts +0 -41
- package/dist/core/transport/server-transport.d.ts +0 -7
- package/dist/core/transport/stream-router.d.ts +0 -19
- package/dist/core/transport/turn-manager.d.ts +0 -34
- package/dist/react/use-active-turns.d.ts +0 -8
- package/dist/react/use-client-transport.d.ts +0 -7
- package/dist/react/use-conversation-tree.d.ts +0 -20
- package/dist/react/use-edit.d.ts +0 -7
- package/dist/react/use-history.d.ts +0 -19
- package/dist/react/use-messages.d.ts +0 -7
- package/dist/react/use-regenerate.d.ts +0 -7
- package/dist/react/use-send.d.ts +0 -7
- package/dist/vercel/codec/accumulator.d.ts +0 -21
- package/src/core/transport/client-transport.ts +0 -959
- package/src/core/transport/conversation-tree.ts +0 -434
- package/src/core/transport/decode-history.ts +0 -337
- package/src/core/transport/server-transport.ts +0 -458
- package/src/core/transport/stream-router.ts +0 -118
- package/src/core/transport/turn-manager.ts +0 -147
- package/src/react/use-active-turns.ts +0 -61
- package/src/react/use-client-transport.ts +0 -37
- package/src/react/use-conversation-tree.ts +0 -71
- package/src/react/use-edit.ts +0 -24
- package/src/react/use-history.ts +0 -111
- package/src/react/use-messages.ts +0 -32
- package/src/react/use-regenerate.ts +0 -24
- package/src/react/use-send.ts +0 -25
- package/src/vercel/codec/accumulator.ts +0 -603
|
@@ -0,0 +1,1344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core agent (server-side) session, parameterized by codec.
|
|
3
|
+
*
|
|
4
|
+
* Composes RunManager and pipeStream to handle the full server-side run
|
|
5
|
+
* lifecycle. Cancel message routing is handled directly by the session's
|
|
6
|
+
* single channel subscription — no separate cancel manager needed.
|
|
7
|
+
*
|
|
8
|
+
* The session exposes a single factory method — `createRun()` — which returns
|
|
9
|
+
* a Run object with explicit lifecycle methods: start(), pipe(), addEvents(),
|
|
10
|
+
* suspend(), and end() (suspend() and end() are both terminal).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as Ably from 'ably';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
EVENT_CANCEL,
|
|
17
|
+
HEADER_CODEC_MESSAGE_ID,
|
|
18
|
+
HEADER_EVENT_ID,
|
|
19
|
+
HEADER_FORK_OF,
|
|
20
|
+
HEADER_INPUT_CODEC_MESSAGE_ID,
|
|
21
|
+
HEADER_MSG_REGENERATE,
|
|
22
|
+
HEADER_PARENT,
|
|
23
|
+
HEADER_RUN_CLIENT_ID,
|
|
24
|
+
HEADER_RUN_ID,
|
|
25
|
+
} from '../../constants.js';
|
|
26
|
+
import { ErrorCode } from '../../errors.js';
|
|
27
|
+
import type { Logger } from '../../logger.js';
|
|
28
|
+
import { compareBySerial, getTransportHeaders } from '../../utils.js';
|
|
29
|
+
import { registerAgent } from '../agent.js';
|
|
30
|
+
import type { Codec, CodecInputEvent, CodecOutputEvent } from '../codec/types.js';
|
|
31
|
+
import { buildTransportHeaders } from './headers.js';
|
|
32
|
+
import { evictOldestIfFull } from './internal/bounded-map.js';
|
|
33
|
+
import { Invocation } from './invocation.js';
|
|
34
|
+
import { loadConversation, loadRunProjection } from './load-conversation.js';
|
|
35
|
+
import { pipeStream } from './pipe-stream.js';
|
|
36
|
+
import type { RunManager } from './run-manager.js';
|
|
37
|
+
import { createRunManager } from './run-manager.js';
|
|
38
|
+
import type {
|
|
39
|
+
AgentSession,
|
|
40
|
+
AgentSessionOptions,
|
|
41
|
+
CancelRequest,
|
|
42
|
+
EventsNode,
|
|
43
|
+
LoadConversationOptions,
|
|
44
|
+
MessageNode,
|
|
45
|
+
PipeOptions,
|
|
46
|
+
Run,
|
|
47
|
+
RunEndReason,
|
|
48
|
+
RunRuntime,
|
|
49
|
+
RunView,
|
|
50
|
+
StreamResult,
|
|
51
|
+
} from './types.js';
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Run-state lookup helpers
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Wait for every event-id in `expectedInputEventIds` to arrive as a channel
|
|
59
|
+
* message before letting the run proceed to LLM work. Uses the session's
|
|
60
|
+
* unfiltered channel dispatcher (registered in `connect()`) so that
|
|
61
|
+
* messages replayed via channel rewind on attach reach the lookup — no
|
|
62
|
+
* separate history fetch needed.
|
|
63
|
+
*
|
|
64
|
+
* Scope: this awaits the data-carrying input events a send publishes —
|
|
65
|
+
* fresh prompts, edits, regenerates, tool results, and approvals. Control
|
|
66
|
+
* events (cancel etc.) carry no `event-id`, are dispatched
|
|
67
|
+
* separately, and never enter this lookup.
|
|
68
|
+
*
|
|
69
|
+
* Each client-published event in a send (user-message AND amend events
|
|
70
|
+
* such as tool-approval responses and client tool outputs) is stamped
|
|
71
|
+
* with its own `event-id`.
|
|
72
|
+
* The lookup matches incoming messages against the expected set; ids
|
|
73
|
+
* not in the set are ignored, duplicates (rewind redelivering a message
|
|
74
|
+
* also seen live) are deduped by event-id. The wait completes when
|
|
75
|
+
* every expected id has arrived, guaranteeing the channel state is
|
|
76
|
+
* consistent with what the client promised before any downstream
|
|
77
|
+
* processing (loadProjection, streamText) runs.
|
|
78
|
+
*
|
|
79
|
+
* User-message arrivals decode into MessageNodes that populate
|
|
80
|
+
* `run.view.messages`; amend arrivals fold into a fresh projection that
|
|
81
|
+
* has no target message, so they're orphaned and dropped — they only
|
|
82
|
+
* count toward the wait. Collected nodes are returned sorted by Ably
|
|
83
|
+
* `serial` ascending.
|
|
84
|
+
*
|
|
85
|
+
* Bounded by `timeoutMs` as a total budget across all N arrivals. The
|
|
86
|
+
* caller's `signal` aborts the wait. On partial collection at timeout the
|
|
87
|
+
* promise rejects with `InputEventNotFound` and an error message including
|
|
88
|
+
* "received X of Y". If any decode throws mid-collection, the whole lookup
|
|
89
|
+
* rejects with `InputEventNotFound` wrapping the decode error as cause —
|
|
90
|
+
* already-collected messages are discarded.
|
|
91
|
+
* @param opts - Lookup parameters.
|
|
92
|
+
* @param opts.register - Session-provided registration that delivers the input events for the expected event-ids. Returns an unregister function.
|
|
93
|
+
* @param opts.codec - Codec used to decode arriving messages.
|
|
94
|
+
* @param opts.invocationId - Invocation identifier — used only for diagnostic logging and error messages.
|
|
95
|
+
* @param opts.runId - Run identifier (used for logging and error messages).
|
|
96
|
+
* @param opts.expectedInputEventIds - Input-event ids the lookup must observe before resolving.
|
|
97
|
+
* @param opts.timeoutMs - Maximum total time to wait for all event-id arrivals.
|
|
98
|
+
* @param opts.signal - AbortSignal that cancels the wait when the run is cancelled.
|
|
99
|
+
* @param opts.logger - Optional logger for diagnostic output.
|
|
100
|
+
* @returns The MessageNodes for arriving user-message events (sorted by Ably
|
|
101
|
+
* serial — empty when every input event was a tool-resolution wire message that
|
|
102
|
+
* decoded to a chunk and produced no node), and the transport headers of
|
|
103
|
+
* the first matched wire message. `firstHeaders` is the canonical source for
|
|
104
|
+
* run-level metadata (clientId, parent, forkOf, continuation flag) because
|
|
105
|
+
* it lands whether or not the decode produced a MessageNode. `firstClientId`
|
|
106
|
+
* carries the publisher's Ably-level `clientId` from that same message — the
|
|
107
|
+
* source of `inputClientId` re-stamping on the agent's published events.
|
|
108
|
+
*/
|
|
109
|
+
interface InputEventLookupResult<TMessage> {
|
|
110
|
+
nodes: MessageNode<TMessage>[];
|
|
111
|
+
firstHeaders?: Record<string, string>;
|
|
112
|
+
firstClientId?: string;
|
|
113
|
+
/**
|
|
114
|
+
* Raw Ably messages observed live for the matched input-event ids, in
|
|
115
|
+
* arrival order. The agent forwards these to `loadRunProjection` so a
|
|
116
|
+
* continuation invocation can fold the just-published client wires
|
|
117
|
+
* (e.g. a tool-output-available) without waiting on Ably's channel
|
|
118
|
+
* history indexing window.
|
|
119
|
+
*/
|
|
120
|
+
rawMessages: Ably.InboundMessage[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const lookupInputEvents = async <
|
|
124
|
+
TInput extends CodecInputEvent,
|
|
125
|
+
TOutput extends CodecOutputEvent,
|
|
126
|
+
TProjection,
|
|
127
|
+
TMessage,
|
|
128
|
+
>(opts: {
|
|
129
|
+
register: (callback: (msg: Ably.InboundMessage) => void) => () => void;
|
|
130
|
+
codec: Codec<TInput, TOutput, TProjection, TMessage>;
|
|
131
|
+
invocationId: string;
|
|
132
|
+
runId: string;
|
|
133
|
+
expectedInputEventIds: readonly string[];
|
|
134
|
+
timeoutMs: number;
|
|
135
|
+
signal: AbortSignal;
|
|
136
|
+
logger: Logger | undefined;
|
|
137
|
+
}): Promise<InputEventLookupResult<TMessage>> => {
|
|
138
|
+
const { register, codec, invocationId, runId, expectedInputEventIds, timeoutMs, signal, logger } = opts;
|
|
139
|
+
const expectedSet = new Set(expectedInputEventIds);
|
|
140
|
+
const expectedCount = expectedSet.size;
|
|
141
|
+
|
|
142
|
+
const collected: MessageNode<TMessage>[] = [];
|
|
143
|
+
const rawMessages: Ably.InboundMessage[] = [];
|
|
144
|
+
const matchedInputEventIds = new Set<string>();
|
|
145
|
+
let firstHeaders: Record<string, string> | undefined;
|
|
146
|
+
let firstClientId: string | undefined;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Decode an inbound Ably message into MessageNodes via the codec.
|
|
150
|
+
* @param m - The inbound Ably message to decode.
|
|
151
|
+
* @returns The decoded MessageNodes carrying transport headers and serial.
|
|
152
|
+
*/
|
|
153
|
+
const decode = (m: Ably.InboundMessage): MessageNode<TMessage>[] => {
|
|
154
|
+
const decoder = codec.createDecoder();
|
|
155
|
+
const headers = getTransportHeaders(m);
|
|
156
|
+
const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID] ?? '';
|
|
157
|
+
const { inputs, outputs } = decoder.decode(m);
|
|
158
|
+
const events: (TInput | TOutput)[] = [...inputs, ...outputs];
|
|
159
|
+
let projection = codec.init();
|
|
160
|
+
for (const event of events) {
|
|
161
|
+
projection = codec.fold(projection, event, { serial: m.serial ?? '', messageId: codecMessageId });
|
|
162
|
+
}
|
|
163
|
+
return codec.getMessages(projection).map(({ message }) => ({
|
|
164
|
+
kind: 'message' as const,
|
|
165
|
+
message,
|
|
166
|
+
codecMessageId,
|
|
167
|
+
parentId: headers[HEADER_PARENT],
|
|
168
|
+
forkOf: headers[HEADER_FORK_OF],
|
|
169
|
+
headers,
|
|
170
|
+
serial: m.serial,
|
|
171
|
+
}));
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return new Promise<InputEventLookupResult<TMessage>>((resolve, reject) => {
|
|
175
|
+
let settled = false;
|
|
176
|
+
// Dedupe across rewind-redelivery: rewind may surface a message the
|
|
177
|
+
// listener also saw live. Scoped to the active lookup so it cannot
|
|
178
|
+
// grow unbounded.
|
|
179
|
+
const seenSerials = new Set<string>();
|
|
180
|
+
// Forward-declared so that cleanup() and onCancelled() can reference them
|
|
181
|
+
// before they are assigned. cleanup may run synchronously inside
|
|
182
|
+
// `register(...)` (when buffered input events drain on registration) before
|
|
183
|
+
// `unregister`/`timer` have been assigned — the no-op fallback for
|
|
184
|
+
// unregister and undefined-guard for timer handle that window. The
|
|
185
|
+
// settled-flag re-check after `register` returns reconciles the
|
|
186
|
+
// listener-detach that cleanup couldn't perform inside that window.
|
|
187
|
+
/* eslint-disable prefer-const, unicorn/consistent-function-scoping, @typescript-eslint/no-empty-function -- forward-declared state for the sync-drain reconciliation pattern; see comment above. */
|
|
188
|
+
let unregister: () => void = () => {};
|
|
189
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
190
|
+
/* eslint-enable */
|
|
191
|
+
const cleanup = (): void => {
|
|
192
|
+
unregister();
|
|
193
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
194
|
+
signal.removeEventListener('abort', onCancelled);
|
|
195
|
+
};
|
|
196
|
+
const onCancelled = (): void => {
|
|
197
|
+
if (settled) return;
|
|
198
|
+
settled = true;
|
|
199
|
+
cleanup();
|
|
200
|
+
reject(
|
|
201
|
+
new Ably.ErrorInfo(`unable to look up input event; run ${runId} was cancelled`, ErrorCode.InvalidArgument, 400),
|
|
202
|
+
);
|
|
203
|
+
};
|
|
204
|
+
signal.addEventListener('abort', onCancelled, { once: true });
|
|
205
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- onCancelled may have settled the promise synchronously above when the signal was already aborted.
|
|
206
|
+
if (settled) return;
|
|
207
|
+
unregister = register((m) => {
|
|
208
|
+
if (settled) return;
|
|
209
|
+
if (m.serial !== undefined && seenSerials.has(m.serial)) return;
|
|
210
|
+
if (m.serial !== undefined) seenSerials.add(m.serial);
|
|
211
|
+
|
|
212
|
+
const wireHeaders = getTransportHeaders(m);
|
|
213
|
+
|
|
214
|
+
// Only count messages whose event-id is in the expected set.
|
|
215
|
+
const msgEventId = wireHeaders[HEADER_EVENT_ID];
|
|
216
|
+
if (!msgEventId || !expectedSet.has(msgEventId) || matchedInputEventIds.has(msgEventId)) return;
|
|
217
|
+
matchedInputEventIds.add(msgEventId);
|
|
218
|
+
|
|
219
|
+
// Capture the trigger event's headers AND its Ably channel-level `clientId`
|
|
220
|
+
// so run-level metadata (parent / forkOf / continuation flag from headers;
|
|
221
|
+
// `inputClientId` from the wire publisher) is available even when the decode
|
|
222
|
+
// produces zero MessageNodes — the case for continuation tool-resolution
|
|
223
|
+
// trigger events whose chunks fold into a fresh empty projection without
|
|
224
|
+
// an assistant to land on.
|
|
225
|
+
if (firstHeaders === undefined) {
|
|
226
|
+
firstHeaders = wireHeaders;
|
|
227
|
+
firstClientId = m.clientId;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let decoded: MessageNode<TMessage>[];
|
|
231
|
+
try {
|
|
232
|
+
decoded = decode(m);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
settled = true;
|
|
235
|
+
cleanup();
|
|
236
|
+
const cause = error instanceof Ably.ErrorInfo ? error : undefined;
|
|
237
|
+
reject(
|
|
238
|
+
new Ably.ErrorInfo(
|
|
239
|
+
`unable to look up input event; decode failed for invocation ${invocationId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
240
|
+
ErrorCode.InputEventNotFound,
|
|
241
|
+
504,
|
|
242
|
+
cause,
|
|
243
|
+
),
|
|
244
|
+
);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
for (const node of decoded) collected.push(node);
|
|
248
|
+
rawMessages.push(m);
|
|
249
|
+
if (matchedInputEventIds.size < expectedCount) return;
|
|
250
|
+
settled = true;
|
|
251
|
+
cleanup();
|
|
252
|
+
// Sort by Ably serial ascending so callers see publish order regardless
|
|
253
|
+
// of interleaved rewind+live delivery. Null serials sort last (defensive
|
|
254
|
+
// — input events should always carry a serial).
|
|
255
|
+
collected.sort(compareBySerial);
|
|
256
|
+
logger?.debug('lookupInputEvents(); collected input events', {
|
|
257
|
+
runId,
|
|
258
|
+
invocationId,
|
|
259
|
+
count: collected.length,
|
|
260
|
+
});
|
|
261
|
+
resolve({ nodes: collected, firstHeaders, firstClientId, rawMessages });
|
|
262
|
+
});
|
|
263
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the register callback may have settled the promise synchronously during buffered input-event drain.
|
|
264
|
+
if (settled) {
|
|
265
|
+
// Sync drain inside register settled the promise; cleanup ran but
|
|
266
|
+
// could not detach the listener because `unregister` was still the
|
|
267
|
+
// no-op. Detach it now.
|
|
268
|
+
unregister();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
timer = setTimeout(() => {
|
|
272
|
+
if (settled) return;
|
|
273
|
+
settled = true;
|
|
274
|
+
cleanup();
|
|
275
|
+
reject(
|
|
276
|
+
new Ably.ErrorInfo(
|
|
277
|
+
`unable to look up input event; received ${String(collected.length)} of ${String(expectedCount)} input events for invocation ${invocationId} within ${String(timeoutMs)}ms`,
|
|
278
|
+
ErrorCode.InputEventNotFound,
|
|
279
|
+
504,
|
|
280
|
+
),
|
|
281
|
+
);
|
|
282
|
+
}, timeoutMs);
|
|
283
|
+
});
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Internal run record for cancel routing
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
interface RegisteredRun {
|
|
291
|
+
runId: string;
|
|
292
|
+
/** Invocation-id this run is associated with, minted by the agent at `createRun` (or the `runtime.invocationId` override). */
|
|
293
|
+
invocationId: string;
|
|
294
|
+
controller: AbortController;
|
|
295
|
+
/** Composite signal that fires when either the internal controller or the external signal aborts. */
|
|
296
|
+
signal: AbortSignal;
|
|
297
|
+
onCancel?: (request: CancelRequest) => Promise<boolean>;
|
|
298
|
+
onError?: (error: Ably.ErrorInfo) => void;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// Internal state machines
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
enum SessionState {
|
|
306
|
+
READY = 'ready',
|
|
307
|
+
CLOSED = 'closed',
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
enum RunState {
|
|
311
|
+
INITIALIZED = 'initialized',
|
|
312
|
+
STARTED = 'started',
|
|
313
|
+
ENDED = 'ended',
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// Implementation
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
// Spec: AIT-ST1
|
|
321
|
+
class DefaultAgentSession<
|
|
322
|
+
TInput extends CodecInputEvent,
|
|
323
|
+
TOutput extends CodecOutputEvent,
|
|
324
|
+
TProjection,
|
|
325
|
+
TMessage,
|
|
326
|
+
> implements AgentSession<TOutput, TProjection, TMessage> {
|
|
327
|
+
private readonly _channel: Ably.RealtimeChannel;
|
|
328
|
+
private readonly _codec: AgentSessionOptions<TInput, TOutput, TProjection, TMessage>['codec'];
|
|
329
|
+
private readonly _logger: Logger | undefined;
|
|
330
|
+
private readonly _onError: ((error: Ably.ErrorInfo) => void) | undefined;
|
|
331
|
+
private readonly _runManager: RunManager;
|
|
332
|
+
private readonly _registeredRuns = new Map<string, RegisteredRun>();
|
|
333
|
+
/**
|
|
334
|
+
* Reverse index from a run's triggering input codec-message-id to its
|
|
335
|
+
* run-id, populated once `Run.start()`'s input-event lookup resolves the
|
|
336
|
+
* triggering input. Lets `_handleCancelMessage` route a cancel keyed by the
|
|
337
|
+
* input codec-message-id (a fresh send whose run-id the client doesn't know)
|
|
338
|
+
* to the registered run. Entries are removed when the run ends / suspends /
|
|
339
|
+
* the session closes, alongside `_registeredRuns`.
|
|
340
|
+
*/
|
|
341
|
+
private readonly _runIdByInputCodecMessageId = new Map<string, string>();
|
|
342
|
+
/**
|
|
343
|
+
* Cancels buffered by triggering input codec-message-id when they arrived
|
|
344
|
+
* before the run was known — i.e. before `Run.start()`'s input-event lookup
|
|
345
|
+
* resolved that input to a run. A fresh run has no run-id at the client's
|
|
346
|
+
* send time (the agent mints it at run-start), so an early cancel can only be
|
|
347
|
+
* keyed by the input codec-message-id, and the `inputCodecMessageId → run`
|
|
348
|
+
* linkage doesn't exist until the lookup completes. `Run.start()` consults
|
|
349
|
+
* this buffer as a PULL once it resolves its `resolvedInputCodecMessageId`,
|
|
350
|
+
* honouring any cancel that arrived first. Mirrors `_inputEventBuffer`: FIFO
|
|
351
|
+
* eviction at `_inputEventBufferLimit` entries, cleared on `close()`.
|
|
352
|
+
*/
|
|
353
|
+
private readonly _deferredCancels = new Map<string, Ably.InboundMessage>();
|
|
354
|
+
/**
|
|
355
|
+
* Active input-event lookups keyed by `event-id`. The channel listener
|
|
356
|
+
* dispatches each input event to the lookup that registered for its
|
|
357
|
+
* `event-id`, so that messages replayed via channel rewind (and live
|
|
358
|
+
* messages alike) reach the right lookup without each lookup having to
|
|
359
|
+
* subscribe separately, and without depending on a client-minted
|
|
360
|
+
* `invocation-id`.
|
|
361
|
+
*/
|
|
362
|
+
private readonly _pendingInputEventLookups = new Map<string, (msg: Ably.InboundMessage) => void>();
|
|
363
|
+
/**
|
|
364
|
+
* Input events buffered by `event-id` when no lookup callback was
|
|
365
|
+
* registered at delivery time. Rewind replays user messages on attach —
|
|
366
|
+
* before `run.start()` runs — so without buffering they would be dropped.
|
|
367
|
+
* Each `event-id` maps to an ordered array so rewind redelivery of the
|
|
368
|
+
* same event before registration is preserved (the lookup later dedupes by
|
|
369
|
+
* serial). `_registerInputEventListener` drains the buffer on registration.
|
|
370
|
+
* FIFO eviction at `_inputEventBufferLimit` event entries (each entry counts
|
|
371
|
+
* once regardless of array length).
|
|
372
|
+
*/
|
|
373
|
+
private readonly _inputEventBuffer = new Map<string, Ably.InboundMessage[]>();
|
|
374
|
+
private readonly _inputEventBufferLimit: number;
|
|
375
|
+
private readonly _channelListener: (msg: Ably.InboundMessage) => void;
|
|
376
|
+
private readonly _inputEventLookupTimeoutMs: number;
|
|
377
|
+
|
|
378
|
+
private _state = SessionState.READY;
|
|
379
|
+
private _connectPromise: Promise<void> | undefined;
|
|
380
|
+
private _hasAttachedOnce: boolean;
|
|
381
|
+
private readonly _onChannelStateChange: Ably.channelEventCallback;
|
|
382
|
+
|
|
383
|
+
constructor(options: AgentSessionOptions<TInput, TOutput, TProjection, TMessage>) {
|
|
384
|
+
this._codec = options.codec;
|
|
385
|
+
// Spec: AIT-ST1a, AIT-ST1a2 — register this SDK on both the connection
|
|
386
|
+
// (options.agents) and channel-attach (params.agent) paths. Idempotent
|
|
387
|
+
// across sessions sharing one client.
|
|
388
|
+
const registerOptions = registerAgent(options.client, options.codec);
|
|
389
|
+
// Attach with a rewind window (default 2m) so a freshly-constructed
|
|
390
|
+
// agent session can locate an input event that was published before it
|
|
391
|
+
// attached (closes the lookup race when a per-request agent is spun
|
|
392
|
+
// up after the client has already POSTed). Tunable via
|
|
393
|
+
// `AgentSessionOptions.rewindWindow`.
|
|
394
|
+
const channelOptions: Ably.ChannelOptions = {
|
|
395
|
+
params: { ...registerOptions.params, rewind: options.rewindWindow ?? '2m' },
|
|
396
|
+
};
|
|
397
|
+
this._channel = options.client.channels.get(options.channelName, channelOptions);
|
|
398
|
+
this._logger = options.logger?.withContext({ component: 'AgentSession' });
|
|
399
|
+
this._onError = options.onError;
|
|
400
|
+
this._runManager = createRunManager(this._channel, this._logger);
|
|
401
|
+
this._inputEventLookupTimeoutMs = options.inputEventLookupTimeoutMs ?? 30000;
|
|
402
|
+
this._inputEventBufferLimit = options.inputEventBufferLimit ?? 200;
|
|
403
|
+
|
|
404
|
+
this._channelListener = (msg: Ably.InboundMessage) => {
|
|
405
|
+
this._handleChannelMessage(msg);
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
// Spec: AIT-ST12, AIT-ST12a
|
|
409
|
+
// Listen for channel state changes that break message continuity. The
|
|
410
|
+
// session only consumes cancel messages from the channel, so losing one
|
|
411
|
+
// is survivable — but the developer needs to know so they can decide
|
|
412
|
+
// whether to cancel in-flight work. _hasAttachedOnce is seeded from the
|
|
413
|
+
// channel's current state so pre-attached channels are handled correctly;
|
|
414
|
+
// it distinguishes the initial attach from a genuine discontinuity.
|
|
415
|
+
this._hasAttachedOnce = this._channel.state === 'attached';
|
|
416
|
+
this._onChannelStateChange = (stateChange: Ably.ChannelStateChange) => {
|
|
417
|
+
this._handleChannelStateChange(stateChange);
|
|
418
|
+
};
|
|
419
|
+
this._channel.on(this._onChannelStateChange);
|
|
420
|
+
|
|
421
|
+
this._logger?.debug('DefaultAgentSession(); session created');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// -------------------------------------------------------------------------
|
|
425
|
+
// Public API
|
|
426
|
+
// -------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
// Spec: AIT-ST2
|
|
429
|
+
// eslint-disable-next-line @typescript-eslint/promise-function-async -- preserve reference equality across calls
|
|
430
|
+
connect(): Promise<void> {
|
|
431
|
+
if (this._state === SessionState.CLOSED) {
|
|
432
|
+
return Promise.reject(new Ably.ErrorInfo('unable to connect; session is closed', ErrorCode.SessionClosed, 400));
|
|
433
|
+
}
|
|
434
|
+
if (this._connectPromise) return this._connectPromise;
|
|
435
|
+
|
|
436
|
+
this._logger?.trace('DefaultAgentSession.connect();');
|
|
437
|
+
// Subscribe unfiltered (before attach, per RTL7g — subscribe implicitly
|
|
438
|
+
// attaches the channel). An unfiltered subscribe ensures that messages
|
|
439
|
+
// replayed via channel rewind reach the dispatcher so input-event
|
|
440
|
+
// lookups can match against them; the dispatcher then routes by name
|
|
441
|
+
// (cancel vs. input event). A name-filtered subscribe would silently
|
|
442
|
+
// drop replayed user messages because rewind delivers them to listeners
|
|
443
|
+
// registered at attach time only.
|
|
444
|
+
this._connectPromise = this._channel.subscribe(this._channelListener).then(
|
|
445
|
+
() => {
|
|
446
|
+
this._logger?.debug('DefaultAgentSession.connect(); subscribed and attached');
|
|
447
|
+
},
|
|
448
|
+
(error: unknown) => {
|
|
449
|
+
const errInfo = new Ably.ErrorInfo(
|
|
450
|
+
`unable to subscribe to channel; ${error instanceof Error ? error.message : String(error)}`,
|
|
451
|
+
ErrorCode.SessionSubscriptionError,
|
|
452
|
+
500,
|
|
453
|
+
error instanceof Ably.ErrorInfo ? error : undefined,
|
|
454
|
+
);
|
|
455
|
+
this._logger?.error('DefaultAgentSession.connect(); subscribe failed');
|
|
456
|
+
this._onError?.(errInfo);
|
|
457
|
+
throw errInfo;
|
|
458
|
+
},
|
|
459
|
+
);
|
|
460
|
+
return this._connectPromise;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Register a callback to receive the input events carrying any of the
|
|
465
|
+
* given `eventIds`. Lookups must share the session's unfiltered
|
|
466
|
+
* subscription rather than registering their own subscribe — Ably's
|
|
467
|
+
* rewind only delivers to listeners present at attach time.
|
|
468
|
+
*
|
|
469
|
+
* The listener remains registered after the initial buffer drain so a
|
|
470
|
+
* matching event that arrives live (rather than from the buffer) still
|
|
471
|
+
* reaches the lookup until it unregisters itself. Today the only caller
|
|
472
|
+
* registers a single trigger event-id; the array form keeps the
|
|
473
|
+
* registration capable of awaiting several ids without changing callers.
|
|
474
|
+
* @param eventIds - The `event-id`s this listener cares about.
|
|
475
|
+
* @param callback - Invoked once per matching Ably message, in buffer-insertion order for drained entries.
|
|
476
|
+
* @returns Unregister function. Safe to call multiple times.
|
|
477
|
+
*/
|
|
478
|
+
private _registerInputEventListener(
|
|
479
|
+
eventIds: readonly string[],
|
|
480
|
+
callback: (msg: Ably.InboundMessage) => void,
|
|
481
|
+
): () => void {
|
|
482
|
+
for (const eventId of eventIds) {
|
|
483
|
+
this._pendingInputEventLookups.set(eventId, callback);
|
|
484
|
+
}
|
|
485
|
+
// Drain any buffered input events for these event-ids — rewind replays
|
|
486
|
+
// user messages on attach before run.start() can register the callback.
|
|
487
|
+
// Without this drain, the lookup waits the full
|
|
488
|
+
// `inputEventLookupTimeoutMs` for a live arrival that never comes. Set
|
|
489
|
+
// all listeners before draining so a drain that completes the lookup
|
|
490
|
+
// synchronously cannot leave a later event-id unmapped.
|
|
491
|
+
for (const eventId of eventIds) {
|
|
492
|
+
const buffered = this._inputEventBuffer.get(eventId);
|
|
493
|
+
if (buffered) {
|
|
494
|
+
this._inputEventBuffer.delete(eventId);
|
|
495
|
+
for (const m of buffered) callback(m);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return () => {
|
|
499
|
+
for (const eventId of eventIds) {
|
|
500
|
+
if (this._pendingInputEventLookups.get(eventId) === callback) {
|
|
501
|
+
this._pendingInputEventLookups.delete(eventId);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Spec: AIT-ST3
|
|
508
|
+
createRun(invocation: Invocation, runtime?: RunRuntime<TOutput>): Run<TOutput, TProjection, TMessage> {
|
|
509
|
+
this._logger?.trace('DefaultAgentSession.createRun();', { inputEventId: invocation.inputEventId });
|
|
510
|
+
return this._createRun(invocation, runtime ?? {});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Spec: AIT-ST11
|
|
514
|
+
async close(): Promise<void> {
|
|
515
|
+
if (this._state === SessionState.CLOSED) return;
|
|
516
|
+
this._state = SessionState.CLOSED;
|
|
517
|
+
this._logger?.trace('DefaultAgentSession.close();');
|
|
518
|
+
if (this._connectPromise) {
|
|
519
|
+
this._channel.unsubscribe(this._channelListener);
|
|
520
|
+
}
|
|
521
|
+
this._channel.off(this._onChannelStateChange);
|
|
522
|
+
for (const reg of this._registeredRuns.values()) {
|
|
523
|
+
reg.controller.abort();
|
|
524
|
+
}
|
|
525
|
+
this._registeredRuns.clear();
|
|
526
|
+
this._runIdByInputCodecMessageId.clear();
|
|
527
|
+
this._deferredCancels.clear();
|
|
528
|
+
this._pendingInputEventLookups.clear();
|
|
529
|
+
this._inputEventBuffer.clear();
|
|
530
|
+
this._runManager.close();
|
|
531
|
+
|
|
532
|
+
// Detach the channel this session attached. connect() subscribes (which
|
|
533
|
+
// implicitly attaches), so we only detach when connect() ran. Best-effort:
|
|
534
|
+
// a detach failure (e.g. the channel is already FAILED) must not throw out
|
|
535
|
+
// of close().
|
|
536
|
+
if (this._connectPromise) {
|
|
537
|
+
try {
|
|
538
|
+
await this._channel.detach();
|
|
539
|
+
} catch (error) {
|
|
540
|
+
// Swallowed (see above): a detach failure must not throw out of
|
|
541
|
+
// close(). Logged at debug for observability.
|
|
542
|
+
this._logger?.debug('DefaultAgentSession.close(); channel detach failed', { error });
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
this._logger?.debug('DefaultAgentSession.close(); session closed');
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// -------------------------------------------------------------------------
|
|
550
|
+
// Cancel message routing
|
|
551
|
+
// -------------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
private async _handleCancelMessage(msg: Ably.InboundMessage): Promise<void> {
|
|
554
|
+
const headers = getTransportHeaders(msg);
|
|
555
|
+
const runId = headers[HEADER_RUN_ID];
|
|
556
|
+
const inputCodecMessageId = headers[HEADER_INPUT_CODEC_MESSAGE_ID];
|
|
557
|
+
|
|
558
|
+
// Malformed cancel: drop with warn. A cancel must identify its target by
|
|
559
|
+
// `run-id` (a continuation, whose run-id the client knows) and/or by
|
|
560
|
+
// `input-codec-message-id` (a fresh send, before the agent minted the
|
|
561
|
+
// run-id). Neither present means there is nothing to route to.
|
|
562
|
+
if (!runId && !inputCodecMessageId) {
|
|
563
|
+
this._logger?.warn('DefaultAgentSession._handleCancelMessage(); missing run-id and input-codec-message-id', {
|
|
564
|
+
serial: msg.serial,
|
|
565
|
+
});
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Primary path — match by run-id (continuations, whose run-id the client
|
|
570
|
+
// already knows). Resolve the input-codec-message-id to a run-id when the
|
|
571
|
+
// run-id wasn't supplied (a fresh-send cancel that arrived after the run's
|
|
572
|
+
// input-event lookup resolved, so the linkage already exists).
|
|
573
|
+
const resolvedRunId =
|
|
574
|
+
runId ?? (inputCodecMessageId ? this._runIdByInputCodecMessageId.get(inputCodecMessageId) : undefined);
|
|
575
|
+
const reg = resolvedRunId ? this._registeredRuns.get(resolvedRunId) : undefined;
|
|
576
|
+
|
|
577
|
+
if (!reg) {
|
|
578
|
+
// The run isn't known yet. A fresh-send cancel can race ahead of the
|
|
579
|
+
// run's input-event lookup (which is what establishes the
|
|
580
|
+
// input-codec-message-id → run linkage). Buffer it by
|
|
581
|
+
// input-codec-message-id so `Run.start()` can pull and honour it once it
|
|
582
|
+
// resolves the triggering input. A bare run-id cancel for an unknown run
|
|
583
|
+
// is a no-op (the run never existed here, or already ended).
|
|
584
|
+
if (inputCodecMessageId !== undefined) {
|
|
585
|
+
this._bufferDeferredCancel(inputCodecMessageId, msg);
|
|
586
|
+
}
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
await this._cancelRegistration(reg, msg);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Buffer a cancel that arrived before its target run was known, keyed by the
|
|
595
|
+
* triggering input's codec-message-id. FIFO-evicts the oldest entry at
|
|
596
|
+
* `_inputEventBufferLimit` (mirroring `_inputEventBuffer`). A later cancel
|
|
597
|
+
* for the same input replaces the earlier one — the intent is identical.
|
|
598
|
+
* @param inputCodecMessageId - The triggering input's codec-message-id.
|
|
599
|
+
* @param msg - The raw cancel message (passed to `onCancel`).
|
|
600
|
+
*/
|
|
601
|
+
private _bufferDeferredCancel(inputCodecMessageId: string, msg: Ably.InboundMessage): void {
|
|
602
|
+
const evicted = evictOldestIfFull(this._deferredCancels, inputCodecMessageId, this._inputEventBufferLimit);
|
|
603
|
+
if (evicted !== undefined) {
|
|
604
|
+
this._logger?.warn('DefaultAgentSession._bufferDeferredCancel(); deferred-cancel buffer full, dropping oldest', {
|
|
605
|
+
evictedInputCodecMessageId: evicted,
|
|
606
|
+
limit: this._inputEventBufferLimit,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
this._deferredCancels.set(inputCodecMessageId, msg);
|
|
610
|
+
this._logger?.debug('DefaultAgentSession._bufferDeferredCancel(); buffered early cancel', {
|
|
611
|
+
inputCodecMessageId,
|
|
612
|
+
serial: msg.serial,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Pull and honour a cancel buffered before this run was known. Called from
|
|
618
|
+
* `Run.start()` once the input-event lookup resolves the run's triggering
|
|
619
|
+
* input codec-message-id — the point at which the
|
|
620
|
+
* `input-codec-message-id → run` linkage first exists. No-op when no cancel
|
|
621
|
+
* was buffered for that input.
|
|
622
|
+
* @param reg - The now-known run registration.
|
|
623
|
+
* @param inputCodecMessageId - The run's resolved triggering input codec-message-id.
|
|
624
|
+
*/
|
|
625
|
+
private async _pullDeferredCancel(reg: RegisteredRun, inputCodecMessageId: string): Promise<void> {
|
|
626
|
+
const buffered = this._deferredCancels.get(inputCodecMessageId);
|
|
627
|
+
if (buffered === undefined) return;
|
|
628
|
+
this._deferredCancels.delete(inputCodecMessageId);
|
|
629
|
+
this._logger?.debug('DefaultAgentSession._pullDeferredCancel(); honouring buffered cancel', {
|
|
630
|
+
runId: reg.runId,
|
|
631
|
+
inputCodecMessageId,
|
|
632
|
+
});
|
|
633
|
+
await this._cancelRegistration(reg, buffered);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Fire a cancel against a known run: consult its `onCancel` authorization
|
|
638
|
+
* hook (if any), then abort the run's controller. Shared by the run-id match,
|
|
639
|
+
* the input-codec-message-id match, and the buffered-cancel pull so all three
|
|
640
|
+
* honour `onCancel` and surface handler errors identically.
|
|
641
|
+
* @param reg - The target run registration.
|
|
642
|
+
* @param msg - The raw cancel message (passed to `onCancel`).
|
|
643
|
+
*/
|
|
644
|
+
private async _cancelRegistration(reg: RegisteredRun, msg: Ably.InboundMessage): Promise<void> {
|
|
645
|
+
const { runId } = reg;
|
|
646
|
+
this._logger?.debug('DefaultAgentSession._cancelRegistration(); matched run', { runId });
|
|
647
|
+
|
|
648
|
+
const request: CancelRequest = { message: msg, runId };
|
|
649
|
+
|
|
650
|
+
try {
|
|
651
|
+
if (reg.onCancel) {
|
|
652
|
+
const allowed = await reg.onCancel(request);
|
|
653
|
+
if (!allowed) {
|
|
654
|
+
this._logger?.debug('DefaultAgentSession._cancelRegistration(); cancel rejected by onCancel', {
|
|
655
|
+
runId,
|
|
656
|
+
});
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
reg.controller.abort();
|
|
661
|
+
this._logger?.debug('DefaultAgentSession._cancelRegistration(); run cancelled', { runId });
|
|
662
|
+
} catch (error) {
|
|
663
|
+
const errInfo = new Ably.ErrorInfo(
|
|
664
|
+
`unable to process cancel for run ${runId}; onCancel handler threw: ${error instanceof Error ? error.message : String(error)}`,
|
|
665
|
+
ErrorCode.CancelListenerError,
|
|
666
|
+
500,
|
|
667
|
+
error instanceof Ably.ErrorInfo ? error : undefined,
|
|
668
|
+
);
|
|
669
|
+
this._logger?.error('DefaultAgentSession._cancelRegistration(); onCancel threw', { runId });
|
|
670
|
+
(reg.onError ?? this._onError)?.(errInfo);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// -------------------------------------------------------------------------
|
|
675
|
+
// Channel state change handler
|
|
676
|
+
// -------------------------------------------------------------------------
|
|
677
|
+
|
|
678
|
+
// Spec: AIT-ST12, AIT-ST12a
|
|
679
|
+
private _handleChannelStateChange(stateChange: Ably.ChannelStateChange): void {
|
|
680
|
+
if (this._state === SessionState.CLOSED) return;
|
|
681
|
+
|
|
682
|
+
const { current, resumed } = stateChange;
|
|
683
|
+
|
|
684
|
+
// Track the initial attach so we don't treat it as a discontinuity
|
|
685
|
+
if (current === 'attached' && !this._hasAttachedOnce) {
|
|
686
|
+
this._hasAttachedOnce = true;
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Continuity-breaking states:
|
|
691
|
+
// - FAILED, SUSPENDED, DETACHED: no more messages expected (or gap)
|
|
692
|
+
// - ATTACHED with resumed: false (UPDATE): messages were lost
|
|
693
|
+
const continuityLost =
|
|
694
|
+
current === 'failed' || current === 'suspended' || current === 'detached' || (current === 'attached' && !resumed);
|
|
695
|
+
|
|
696
|
+
if (!continuityLost) return;
|
|
697
|
+
|
|
698
|
+
this._logger?.error('DefaultAgentSession._handleChannelStateChange(); channel continuity lost', {
|
|
699
|
+
current,
|
|
700
|
+
resumed,
|
|
701
|
+
previous: stateChange.previous,
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
const err = new Ably.ErrorInfo(
|
|
705
|
+
`unable to deliver cancel messages; channel continuity lost (${current}${current === 'attached' ? ', resumed: false' : ''})`,
|
|
706
|
+
ErrorCode.ChannelContinuityLost,
|
|
707
|
+
500,
|
|
708
|
+
stateChange.reason,
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
// Session-level notification only: continuity loss is not scoped to any
|
|
712
|
+
// run. Per-run onError handlers are reserved for errors from that run's
|
|
713
|
+
// own operations (publish failures, encoder errors). Developers that need
|
|
714
|
+
// per-run reaction can iterate active runs from the session handler.
|
|
715
|
+
this._onError?.(err);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// -------------------------------------------------------------------------
|
|
719
|
+
// Channel subscription handler
|
|
720
|
+
// -------------------------------------------------------------------------
|
|
721
|
+
|
|
722
|
+
private _handleChannelMessage(msg: Ably.InboundMessage): void {
|
|
723
|
+
try {
|
|
724
|
+
if (msg.name === EVENT_CANCEL) {
|
|
725
|
+
// Fire-and-forget async handler — errors are caught internally.
|
|
726
|
+
this._handleCancelMessage(msg).catch((error: unknown) => {
|
|
727
|
+
const errInfo = new Ably.ErrorInfo(
|
|
728
|
+
`unable to route cancel message; ${error instanceof Error ? error.message : String(error)}`,
|
|
729
|
+
ErrorCode.CancelListenerError,
|
|
730
|
+
500,
|
|
731
|
+
error instanceof Ably.ErrorInfo ? error : undefined,
|
|
732
|
+
);
|
|
733
|
+
this._logger?.error('DefaultAgentSession._handleChannelMessage(); cancel routing error');
|
|
734
|
+
this._onError?.(errInfo);
|
|
735
|
+
});
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Dispatch client-published input events to the lookup registered
|
|
740
|
+
// for their `event-id`. Every client-originated event in an
|
|
741
|
+
// invocation (user-message AND amend events such as tool-approval
|
|
742
|
+
// responses and client tool outputs) carries `event-id`; the lookup
|
|
743
|
+
// waits for every promised id to arrive before letting the run start
|
|
744
|
+
// LLM work. Routing by `event-id` rather than `invocation-id` keeps
|
|
745
|
+
// the dispatcher independent of any client-minted invocation
|
|
746
|
+
// identity. Server-side lifecycle messages (run-start, run-end,
|
|
747
|
+
// cancel, error) never stamp `event-id`, so they're naturally
|
|
748
|
+
// excluded.
|
|
749
|
+
const headers = getTransportHeaders(msg);
|
|
750
|
+
const eventId = headers[HEADER_EVENT_ID];
|
|
751
|
+
if (eventId !== undefined) {
|
|
752
|
+
const listener = this._pendingInputEventLookups.get(eventId);
|
|
753
|
+
if (listener) {
|
|
754
|
+
listener(msg);
|
|
755
|
+
} else {
|
|
756
|
+
// Buffer for a future `_registerInputEventListener` call. This is
|
|
757
|
+
// load-bearing for the "agent attaches after publish" scenario
|
|
758
|
+
// where channel rewind delivers user messages before
|
|
759
|
+
// `run.start()` runs.
|
|
760
|
+
const existing = this._inputEventBuffer.get(eventId);
|
|
761
|
+
if (existing) {
|
|
762
|
+
existing.push(msg);
|
|
763
|
+
} else {
|
|
764
|
+
// FIFO eviction: drop the oldest event entry (and all its buffered
|
|
765
|
+
// redeliveries). Clients whose input event was evicted will fail
|
|
766
|
+
// their lookup with `InputEventNotFound` — this warn is the only
|
|
767
|
+
// operator-visible signal that capacity caused the failure.
|
|
768
|
+
const evicted = evictOldestIfFull(this._inputEventBuffer, eventId, this._inputEventBufferLimit);
|
|
769
|
+
if (evicted !== undefined) {
|
|
770
|
+
this._logger?.warn(
|
|
771
|
+
'DefaultAgentSession._handleChannelMessage(); input-event buffer full, dropping oldest entry',
|
|
772
|
+
{ evictedEventId: evicted, limit: this._inputEventBufferLimit },
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
this._inputEventBuffer.set(eventId, [msg]);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
} catch (error) {
|
|
780
|
+
const errInfo = new Ably.ErrorInfo(
|
|
781
|
+
`unable to process channel message; ${error instanceof Error ? error.message : String(error)}`,
|
|
782
|
+
ErrorCode.SessionSubscriptionError,
|
|
783
|
+
500,
|
|
784
|
+
error instanceof Ably.ErrorInfo ? error : undefined,
|
|
785
|
+
);
|
|
786
|
+
this._logger?.error('DefaultAgentSession._handleChannelMessage(); subscription error');
|
|
787
|
+
this._onError?.(errInfo);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// -------------------------------------------------------------------------
|
|
792
|
+
// Connection guard
|
|
793
|
+
// -------------------------------------------------------------------------
|
|
794
|
+
|
|
795
|
+
private async _requireConnected(method: string): Promise<void> {
|
|
796
|
+
if (!this._connectPromise) {
|
|
797
|
+
throw new Ably.ErrorInfo(
|
|
798
|
+
`unable to ${method}; connect() must be called before ${method}()`,
|
|
799
|
+
ErrorCode.InvalidArgument,
|
|
800
|
+
400,
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
return this._connectPromise;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// -------------------------------------------------------------------------
|
|
807
|
+
// Run creation
|
|
808
|
+
// -------------------------------------------------------------------------
|
|
809
|
+
|
|
810
|
+
private _createRun(invocation: Invocation, runtime: RunRuntime<TOutput>): Run<TOutput, TProjection, TMessage> {
|
|
811
|
+
// The run-id is not carried in the invocation body — the agent mints it.
|
|
812
|
+
// Mint a provisional id now (or take the `runtime.runId` override for
|
|
813
|
+
// tests / in-process drivers) — this IS the id for a fresh run. A
|
|
814
|
+
// continuation overrides it in `Run.start()` with the existing run-id read
|
|
815
|
+
// off the triggering input event's wire headers (the run it re-enters).
|
|
816
|
+
// Mirrors the invocationId mint below.
|
|
817
|
+
let runId = runtime.runId ?? crypto.randomUUID();
|
|
818
|
+
// The agent mints the invocation id — one per HTTP request that invokes
|
|
819
|
+
// it. A per-run override (runtime.invocationId) supports deterministic ids
|
|
820
|
+
// in tests and in-process drivers.
|
|
821
|
+
const invocationId = runtime.invocationId ?? crypto.randomUUID();
|
|
822
|
+
const inputEventLookupTimeoutMs = this._inputEventLookupTimeoutMs;
|
|
823
|
+
const { onMessage, onCancelled, onCancel, onError: runOnError, signal: externalSignal } = runtime;
|
|
824
|
+
|
|
825
|
+
const controller = new AbortController();
|
|
826
|
+
let state = RunState.INITIALIZED;
|
|
827
|
+
|
|
828
|
+
// Compose the internal controller signal with the external signal (e.g.
|
|
829
|
+
// req.signal) so platform-level cancellation (request cancellation, function
|
|
830
|
+
// timeout) cancels the run through the same path as Ably cancel messages.
|
|
831
|
+
const signal = externalSignal ? AbortSignal.any([controller.signal, externalSignal]) : controller.signal;
|
|
832
|
+
|
|
833
|
+
// Spec: AIT-ST3a — register immediately so `close()` aborts an in-flight
|
|
834
|
+
// start() and a post-lookup cancel can fire the AbortSignal. Keyed by the
|
|
835
|
+
// provisional run-id; a continuation re-keys to the real id in start()
|
|
836
|
+
// once the triggering input reveals it.
|
|
837
|
+
const registration: RegisteredRun = {
|
|
838
|
+
runId,
|
|
839
|
+
invocationId,
|
|
840
|
+
controller,
|
|
841
|
+
signal,
|
|
842
|
+
onCancel,
|
|
843
|
+
onError: runOnError,
|
|
844
|
+
};
|
|
845
|
+
this._registeredRuns.set(runId, registration);
|
|
846
|
+
|
|
847
|
+
// Capture instance members as locals so arrow functions close over them
|
|
848
|
+
// without needing `this` (avoids unicorn/no-this-assignment).
|
|
849
|
+
const logger = this._logger;
|
|
850
|
+
const runManager = this._runManager;
|
|
851
|
+
const codec = this._codec;
|
|
852
|
+
const channel = this._channel;
|
|
853
|
+
const registeredRuns = this._registeredRuns;
|
|
854
|
+
const runIdByInputCodecMessageId = this._runIdByInputCodecMessageId;
|
|
855
|
+
const deferredCancels = this._deferredCancels;
|
|
856
|
+
const requireConnected = this._requireConnected.bind(this);
|
|
857
|
+
const registerInputEventListener = this._registerInputEventListener.bind(this);
|
|
858
|
+
const pullDeferredCancel = this._pullDeferredCancel.bind(this);
|
|
859
|
+
const inputEventId = invocation.inputEventId;
|
|
860
|
+
|
|
861
|
+
// `viewMessages` starts empty. `Run.start()` populates it via the
|
|
862
|
+
// channel-rewind input-event lookup, pulling in user-message MessageNodes
|
|
863
|
+
// as they arrive on the channel.
|
|
864
|
+
const viewMessages: MessageNode<TMessage>[] = [];
|
|
865
|
+
const view: RunView<TMessage> = {
|
|
866
|
+
get messages() {
|
|
867
|
+
return viewMessages;
|
|
868
|
+
},
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
// Per-run metadata resolved from the input-event lookup result. The first
|
|
872
|
+
// matched wire message's headers carry the run's `clientId`, `parent`, and
|
|
873
|
+
// `forkOf`, and — for a continuation — the `run-id` it re-enters (a fresh
|
|
874
|
+
// input carries none; the client stamps a run-id only when re-entering a
|
|
875
|
+
// run it already knows). Its Ably-level publisher `clientId` becomes the
|
|
876
|
+
// `inputClientId` re-stamped on the agent's own publishes. Captured
|
|
877
|
+
// separately from `viewMessages` because tool-resolution wire messages
|
|
878
|
+
// (`tool-output-available` etc.) decode to chunks and produce zero
|
|
879
|
+
// MessageNodes — the metadata still needs to surface.
|
|
880
|
+
let resolvedClientId: string | undefined;
|
|
881
|
+
let resolvedInputClientId: string | undefined;
|
|
882
|
+
let resolvedParent: string | undefined;
|
|
883
|
+
let resolvedForkOf: string | undefined;
|
|
884
|
+
let resolvedRegenerates: string | undefined;
|
|
885
|
+
let resolvedInputCodecMessageId: string | undefined;
|
|
886
|
+
let resolvedContinuation = false;
|
|
887
|
+
let firstLookupHeaders: Record<string, string> | undefined;
|
|
888
|
+
/**
|
|
889
|
+
* The reply run's structural-parent fallback, computed once in
|
|
890
|
+
* `Run.start()` (after the input-event lookup has populated `viewMessages`)
|
|
891
|
+
* and consumed by every `Run.pipe()` publish. A per-stream
|
|
892
|
+
* `streamOpts.parent` still overrides it. Storing it here keeps it stable
|
|
893
|
+
* across pipes and decouples the assistant's structural parent from the
|
|
894
|
+
* run-start wire's own `parent`.
|
|
895
|
+
*/
|
|
896
|
+
let assistantParentFallback: string | undefined;
|
|
897
|
+
/**
|
|
898
|
+
* Raw Ably messages observed live by the input-event lookup. Passed to
|
|
899
|
+
* `loadRunProjection` so the just-published client wires don't need
|
|
900
|
+
* to wait on Ably's channel history indexing window. Empty when no
|
|
901
|
+
* lookup ran or no messages matched.
|
|
902
|
+
*/
|
|
903
|
+
let liveLookupMessages: readonly Ably.InboundMessage[] | undefined;
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Remove this run from the session's routing maps. Drops the
|
|
907
|
+
* `_registeredRuns` entry plus the `input-codec-message-id → run-id`
|
|
908
|
+
* reverse index (and any stale deferred cancel still buffered for that
|
|
909
|
+
* input), keeping the cancel-routing state consistent when the run ends,
|
|
910
|
+
* suspends, or its start fails.
|
|
911
|
+
*/
|
|
912
|
+
const deregisterRun = (): void => {
|
|
913
|
+
registeredRuns.delete(runId);
|
|
914
|
+
if (resolvedInputCodecMessageId !== undefined) {
|
|
915
|
+
runIdByInputCodecMessageId.delete(resolvedInputCodecMessageId);
|
|
916
|
+
deferredCancels.delete(resolvedInputCodecMessageId);
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
// Most recently loaded projection for this run only, cached by
|
|
921
|
+
// `Run.loadProjection()` and `Run.loadConversation()` so the `messages`
|
|
922
|
+
// getter can return the run's folded messages. `undefined` before any
|
|
923
|
+
// load call; the getter then falls back to the live `viewMessages`.
|
|
924
|
+
let cachedProjection: TProjection | undefined;
|
|
925
|
+
|
|
926
|
+
// Full multi-turn conversation, set by `Run.loadConversation()`. When set,
|
|
927
|
+
// it takes priority over `cachedProjection` in the `messages` getter —
|
|
928
|
+
// the getter then returns the complete ancestor-chain + current-run
|
|
929
|
+
// messages instead of the current run alone.
|
|
930
|
+
let cachedConversation: TMessage[] | undefined;
|
|
931
|
+
|
|
932
|
+
const run: Run<TOutput, TProjection, TMessage> = {
|
|
933
|
+
get runId() {
|
|
934
|
+
return runId;
|
|
935
|
+
},
|
|
936
|
+
get invocationId() {
|
|
937
|
+
return invocationId;
|
|
938
|
+
},
|
|
939
|
+
get abortSignal() {
|
|
940
|
+
return signal;
|
|
941
|
+
},
|
|
942
|
+
get view() {
|
|
943
|
+
return view;
|
|
944
|
+
},
|
|
945
|
+
get messages() {
|
|
946
|
+
if (cachedConversation !== undefined) {
|
|
947
|
+
return [...cachedConversation];
|
|
948
|
+
}
|
|
949
|
+
if (cachedProjection !== undefined) {
|
|
950
|
+
return codec.getMessages(cachedProjection).map((m) => m.message);
|
|
951
|
+
}
|
|
952
|
+
return viewMessages.map((n) => n.message);
|
|
953
|
+
},
|
|
954
|
+
|
|
955
|
+
// Spec: AIT-ST4, AIT-ST4a, AIT-ST4b
|
|
956
|
+
start: async (): Promise<void> => {
|
|
957
|
+
logger?.trace('Run.start();', { runId, inputEventId });
|
|
958
|
+
|
|
959
|
+
await requireConnected('start');
|
|
960
|
+
|
|
961
|
+
// Spec: AIT-ST4a
|
|
962
|
+
if (signal.aborted) {
|
|
963
|
+
throw new Ably.ErrorInfo(
|
|
964
|
+
`unable to start run; run ${runId} was cancelled before start()`,
|
|
965
|
+
ErrorCode.InvalidArgument,
|
|
966
|
+
400,
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
if (state !== RunState.INITIALIZED) return;
|
|
970
|
+
state = RunState.STARTED;
|
|
971
|
+
|
|
972
|
+
// Look up the triggering input event on the channel so the agent
|
|
973
|
+
// can read the user's message and per-run metadata (parent, forkOf,
|
|
974
|
+
// continuation flag) before publishing run-start. Skip when
|
|
975
|
+
// inputEventLookupTimeoutMs === 0 (tests and in-process drivers) or
|
|
976
|
+
// when no inputEventId is set (invocation requires no channel lookup).
|
|
977
|
+
if (inputEventId && inputEventLookupTimeoutMs > 0) {
|
|
978
|
+
try {
|
|
979
|
+
const found = await lookupInputEvents<TInput, TOutput, TProjection, TMessage>({
|
|
980
|
+
register: (callback) => registerInputEventListener([inputEventId], callback),
|
|
981
|
+
codec,
|
|
982
|
+
invocationId,
|
|
983
|
+
runId,
|
|
984
|
+
expectedInputEventIds: [inputEventId],
|
|
985
|
+
timeoutMs: inputEventLookupTimeoutMs,
|
|
986
|
+
signal,
|
|
987
|
+
logger,
|
|
988
|
+
});
|
|
989
|
+
for (const m of found.nodes) viewMessages.push(m);
|
|
990
|
+
if (found.firstHeaders !== undefined) firstLookupHeaders = found.firstHeaders;
|
|
991
|
+
if (found.firstClientId !== undefined) resolvedInputClientId = found.firstClientId;
|
|
992
|
+
liveLookupMessages = found.rawMessages;
|
|
993
|
+
} catch (error) {
|
|
994
|
+
const errInfo =
|
|
995
|
+
error instanceof Ably.ErrorInfo
|
|
996
|
+
? error
|
|
997
|
+
: new Ably.ErrorInfo(
|
|
998
|
+
`unable to look up input event; ${error instanceof Error ? error.message : String(error)}`,
|
|
999
|
+
ErrorCode.InputEventNotFound,
|
|
1000
|
+
504,
|
|
1001
|
+
);
|
|
1002
|
+
// The rejection bubbles up to the developer's HTTP handler,
|
|
1003
|
+
// which surfaces the failure as a non-2xx response — that is
|
|
1004
|
+
// the signal the client sees. No channel publish: an
|
|
1005
|
+
// `ai-run-end` without a preceding `ai-run-start` would break
|
|
1006
|
+
// the lifecycle invariant for other channel observers.
|
|
1007
|
+
deregisterRun();
|
|
1008
|
+
logger?.error('Run.start(); input-event lookup failed', { runId, invocationId });
|
|
1009
|
+
throw errInfo;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Resolve per-run metadata from the first matched wire message's
|
|
1014
|
+
// headers — they carry `clientId`, `parent`, and `forkOf`.
|
|
1015
|
+
// Continuations of a suspended run pick up the suspended assistant's
|
|
1016
|
+
// parent in the same headers (the continuation wire message parents off
|
|
1017
|
+
// the assistant). A `run-id` on the triggering input marks a
|
|
1018
|
+
// continuation (re-entry via `ai-run-resume`); a fresh input carries
|
|
1019
|
+
// none and opens the run with `ai-run-start`. Fall back to the first
|
|
1020
|
+
// MessageNode's headers for the path where the lookup ran with
|
|
1021
|
+
// `viewMessages` already populated and no `firstHeaders` was captured.
|
|
1022
|
+
const sourceHeaders = firstLookupHeaders ?? viewMessages[0]?.headers;
|
|
1023
|
+
if (sourceHeaders) {
|
|
1024
|
+
resolvedClientId = sourceHeaders[HEADER_RUN_CLIENT_ID];
|
|
1025
|
+
resolvedParent = sourceHeaders[HEADER_PARENT];
|
|
1026
|
+
resolvedForkOf = sourceHeaders[HEADER_FORK_OF];
|
|
1027
|
+
resolvedRegenerates = sourceHeaders[HEADER_MSG_REGENERATE];
|
|
1028
|
+
resolvedInputCodecMessageId = sourceHeaders[HEADER_CODEC_MESSAGE_ID];
|
|
1029
|
+
|
|
1030
|
+
// The triggering input's run-id (if any) IS this run's identity.
|
|
1031
|
+
// Present → a continuation re-entering that run: adopt the id,
|
|
1032
|
+
// overriding the provisional one minted at construction, and re-key
|
|
1033
|
+
// the registration so cancel routing / deregistration resolve to the
|
|
1034
|
+
// real run. Absent → a fresh run: the provisional id stands and the
|
|
1035
|
+
// run opens with run-start.
|
|
1036
|
+
const wireRunId = sourceHeaders[HEADER_RUN_ID];
|
|
1037
|
+
resolvedContinuation = wireRunId !== undefined;
|
|
1038
|
+
if (wireRunId !== undefined && wireRunId !== runId) {
|
|
1039
|
+
registeredRuns.delete(runId);
|
|
1040
|
+
runId = wireRunId;
|
|
1041
|
+
registration.runId = runId;
|
|
1042
|
+
registeredRuns.set(runId, registration);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Compute the reply run's structural-parent fallback now that the
|
|
1047
|
+
// lookup has populated `viewMessages`: the triggering user message,
|
|
1048
|
+
// or — for regenerate wires that match by inputEventId but produce no
|
|
1049
|
+
// MessageNodes — the input wire's own `parent`. `Run.pipe()` consumes
|
|
1050
|
+
// this for every assistant publish.
|
|
1051
|
+
assistantParentFallback = viewMessages.at(-1)?.codecMessageId ?? resolvedParent;
|
|
1052
|
+
|
|
1053
|
+
// The triggering input's codec-message-id is now resolved, so the
|
|
1054
|
+
// `input-codec-message-id → run` linkage exists: index it for live
|
|
1055
|
+
// cancels and pull any cancel that arrived before the run was known
|
|
1056
|
+
// (a fresh-send cancel published before the agent minted this run-id).
|
|
1057
|
+
// Honouring it here may abort the controller before run-start; that is
|
|
1058
|
+
// fine — the abort propagates through the same signal a normal cancel
|
|
1059
|
+
// would use.
|
|
1060
|
+
if (resolvedInputCodecMessageId !== undefined) {
|
|
1061
|
+
runIdByInputCodecMessageId.set(resolvedInputCodecMessageId, runId);
|
|
1062
|
+
await pullDeferredCancel(registration, resolvedInputCodecMessageId);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
try {
|
|
1066
|
+
await runManager.startRun(runId, resolvedClientId, controller, {
|
|
1067
|
+
// Stamp the reply run's STRUCTURAL parent (its input node, M_user) —
|
|
1068
|
+
// the same value the output path stamps — not the input wire's own
|
|
1069
|
+
// parent. Makes `parent` structural on every wire so the Tree's two
|
|
1070
|
+
// creation paths agree regardless of arrival order. Valid only now
|
|
1071
|
+
// that M_user is a separate input node (the two-node flip).
|
|
1072
|
+
parent: assistantParentFallback,
|
|
1073
|
+
forkOf: resolvedForkOf,
|
|
1074
|
+
regenerates: resolvedRegenerates,
|
|
1075
|
+
invocationId,
|
|
1076
|
+
inputClientId: resolvedInputClientId,
|
|
1077
|
+
inputCodecMessageId: resolvedInputCodecMessageId,
|
|
1078
|
+
continuation: resolvedContinuation,
|
|
1079
|
+
});
|
|
1080
|
+
} catch (error) {
|
|
1081
|
+
const errInfo = new Ably.ErrorInfo(
|
|
1082
|
+
`unable to publish run-start for run ${runId}; ${error instanceof Error ? error.message : String(error)}`,
|
|
1083
|
+
ErrorCode.RunLifecycleError,
|
|
1084
|
+
500,
|
|
1085
|
+
error instanceof Ably.ErrorInfo ? error : undefined,
|
|
1086
|
+
);
|
|
1087
|
+
logger?.error('Run.start(); failed to publish run-start', { runId });
|
|
1088
|
+
throw errInfo;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
logger?.debug('Run.start(); run started', { runId, inputEventId });
|
|
1092
|
+
},
|
|
1093
|
+
|
|
1094
|
+
// Spec: AIT-ST5c
|
|
1095
|
+
addEvents: async (nodes: EventsNode<TOutput>[]): Promise<void> => {
|
|
1096
|
+
logger?.trace('Run.addEvents();', { runId, count: nodes.length });
|
|
1097
|
+
|
|
1098
|
+
await requireConnected('addEvents');
|
|
1099
|
+
|
|
1100
|
+
if (state === RunState.INITIALIZED) {
|
|
1101
|
+
throw new Ably.ErrorInfo(
|
|
1102
|
+
`unable to add events; start() must be called before addEvents() (run ${runId})`,
|
|
1103
|
+
ErrorCode.InvalidArgument,
|
|
1104
|
+
400,
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const runOwnerClientId = runManager.getClientId(runId);
|
|
1109
|
+
|
|
1110
|
+
try {
|
|
1111
|
+
for (const node of nodes) {
|
|
1112
|
+
const headers = buildTransportHeaders({
|
|
1113
|
+
role: 'assistant',
|
|
1114
|
+
runId,
|
|
1115
|
+
codecMessageId: node.codecMessageId,
|
|
1116
|
+
runClientId: runOwnerClientId,
|
|
1117
|
+
invocationId,
|
|
1118
|
+
inputClientId: resolvedInputClientId,
|
|
1119
|
+
inputCodecMessageId: resolvedInputCodecMessageId,
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
const encoder = codec.createEncoder(channel, {
|
|
1123
|
+
extras: { headers },
|
|
1124
|
+
onMessage,
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
for (const event of node.events) {
|
|
1128
|
+
await encoder.publishOutput(event);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
await encoder.close();
|
|
1132
|
+
}
|
|
1133
|
+
} catch (error) {
|
|
1134
|
+
const errInfo = new Ably.ErrorInfo(
|
|
1135
|
+
`unable to publish events for run ${runId}; ${error instanceof Error ? error.message : String(error)}`,
|
|
1136
|
+
ErrorCode.RunLifecycleError,
|
|
1137
|
+
500,
|
|
1138
|
+
error instanceof Ably.ErrorInfo ? error : undefined,
|
|
1139
|
+
);
|
|
1140
|
+
logger?.error('Run.addEvents(); publish failed', { runId });
|
|
1141
|
+
throw errInfo;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
logger?.debug('Run.addEvents(); events published', { runId, count: nodes.length });
|
|
1145
|
+
},
|
|
1146
|
+
|
|
1147
|
+
loadProjection: async (): Promise<TProjection> => {
|
|
1148
|
+
logger?.trace('Run.loadProjection();', { runId });
|
|
1149
|
+
await requireConnected('loadProjection');
|
|
1150
|
+
const projection = await loadRunProjection<TInput, TOutput, TProjection, TMessage>({
|
|
1151
|
+
channel,
|
|
1152
|
+
codec,
|
|
1153
|
+
runId,
|
|
1154
|
+
signal,
|
|
1155
|
+
logger,
|
|
1156
|
+
liveMessages: liveLookupMessages,
|
|
1157
|
+
});
|
|
1158
|
+
cachedProjection = projection;
|
|
1159
|
+
return projection;
|
|
1160
|
+
},
|
|
1161
|
+
|
|
1162
|
+
loadConversation: async (options?: LoadConversationOptions): Promise<TMessage[]> => {
|
|
1163
|
+
logger?.trace('Run.loadConversation();', { runId });
|
|
1164
|
+
await requireConnected('loadConversation');
|
|
1165
|
+
const { messages, projection } = await loadConversation<TInput, TOutput, TProjection, TMessage>({
|
|
1166
|
+
channel,
|
|
1167
|
+
codec,
|
|
1168
|
+
runId,
|
|
1169
|
+
signal,
|
|
1170
|
+
logger,
|
|
1171
|
+
liveMessages: liveLookupMessages,
|
|
1172
|
+
assistantParentFallback,
|
|
1173
|
+
pageLimit: options?.pageLimit ?? 200,
|
|
1174
|
+
maxMessages: options?.maxMessages ?? 2000,
|
|
1175
|
+
});
|
|
1176
|
+
cachedProjection = projection;
|
|
1177
|
+
cachedConversation = messages;
|
|
1178
|
+
return messages;
|
|
1179
|
+
},
|
|
1180
|
+
|
|
1181
|
+
// Spec: AIT-ST6, AIT-ST6a, AIT-ST6b, AIT-ST6b1, AIT-ST6b2, AIT-ST6b3, AIT-ST6c
|
|
1182
|
+
pipe: async (stream: ReadableStream<TOutput>, streamOpts?: PipeOptions<TOutput>): Promise<StreamResult> => {
|
|
1183
|
+
logger?.trace('Run.pipe();', { runId });
|
|
1184
|
+
|
|
1185
|
+
await requireConnected('pipe');
|
|
1186
|
+
|
|
1187
|
+
if (state === RunState.INITIALIZED) {
|
|
1188
|
+
throw new Ably.ErrorInfo(
|
|
1189
|
+
`unable to pipe stream; start() must be called before pipe() (run ${runId})`,
|
|
1190
|
+
ErrorCode.InvalidArgument,
|
|
1191
|
+
400,
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const runOwnerClientId = runManager.getClientId(runId);
|
|
1196
|
+
|
|
1197
|
+
// The assistant message's parent: an explicit per-stream
|
|
1198
|
+
// `streamOpts.parent` from the caller, else the reply run's
|
|
1199
|
+
// structural-parent fallback computed once at run-start
|
|
1200
|
+
// (`assistantParentFallback` — the triggering user message, or the
|
|
1201
|
+
// input wire's own parent for regenerate wires that produced no
|
|
1202
|
+
// MessageNodes). Owning the default here means agent routes don't have
|
|
1203
|
+
// to pass `{ parent: lastUserCodecMessageId }` to keep tree threading
|
|
1204
|
+
// correct; edit-then-regenerate sibling resolution relies on the
|
|
1205
|
+
// user→assistant chain being explicit.
|
|
1206
|
+
const assistantParent = streamOpts?.parent ?? assistantParentFallback;
|
|
1207
|
+
const assistantForkOf = streamOpts?.forkOf ?? resolvedForkOf;
|
|
1208
|
+
// Echo `msg-regenerate` on the assistant wire so that a
|
|
1209
|
+
// client receiving the assistant chunk before `ai-run-start`
|
|
1210
|
+
// (e.g. via history pagination across a page boundary, or a lost
|
|
1211
|
+
// lifecycle publish) can still populate `RunNode.regeneratesCodecMessageId`
|
|
1212
|
+
// when creating the Run from headers. Mirrors the symmetric
|
|
1213
|
+
// behaviour for `assistantForkOf` on edit runs.
|
|
1214
|
+
const assistantRegenerates = resolvedRegenerates;
|
|
1215
|
+
|
|
1216
|
+
const codecMessageId = crypto.randomUUID();
|
|
1217
|
+
const defaultHeaders = buildTransportHeaders({
|
|
1218
|
+
role: 'assistant',
|
|
1219
|
+
runId,
|
|
1220
|
+
codecMessageId,
|
|
1221
|
+
runClientId: runOwnerClientId,
|
|
1222
|
+
parent: assistantParent,
|
|
1223
|
+
forkOf: assistantForkOf,
|
|
1224
|
+
invocationId,
|
|
1225
|
+
inputClientId: resolvedInputClientId,
|
|
1226
|
+
inputCodecMessageId: resolvedInputCodecMessageId,
|
|
1227
|
+
regenerates: assistantRegenerates,
|
|
1228
|
+
});
|
|
1229
|
+
const encoder = codec.createEncoder(channel, {
|
|
1230
|
+
extras: { headers: defaultHeaders },
|
|
1231
|
+
onMessage,
|
|
1232
|
+
messageId: codecMessageId,
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
const result = await pipeStream(stream, encoder, signal, onCancelled, streamOpts?.resolveWriteOptions, logger);
|
|
1236
|
+
|
|
1237
|
+
if (result.error) {
|
|
1238
|
+
const errInfo = new Ably.ErrorInfo(
|
|
1239
|
+
`unable to pipe response for run ${runId}; ${result.error.message}`,
|
|
1240
|
+
ErrorCode.StreamError,
|
|
1241
|
+
500,
|
|
1242
|
+
result.error instanceof Ably.ErrorInfo ? result.error : undefined,
|
|
1243
|
+
);
|
|
1244
|
+
logger?.error('Run.pipe(); stream error', { runId });
|
|
1245
|
+
runOnError?.(errInfo);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
logger?.debug('Run.pipe(); stream finished', { runId, reason: result.reason });
|
|
1249
|
+
return result;
|
|
1250
|
+
},
|
|
1251
|
+
|
|
1252
|
+
suspend: async (): Promise<void> => {
|
|
1253
|
+
logger?.trace('Run.suspend();', { runId });
|
|
1254
|
+
|
|
1255
|
+
await requireConnected('suspend');
|
|
1256
|
+
|
|
1257
|
+
if (state === RunState.INITIALIZED) {
|
|
1258
|
+
throw new Ably.ErrorInfo(
|
|
1259
|
+
`unable to suspend run; start() must be called before suspend() (run ${runId})`,
|
|
1260
|
+
ErrorCode.InvalidArgument,
|
|
1261
|
+
400,
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
// ENDED is the terminal state for either an end or a suspend on this
|
|
1265
|
+
// Run instance; a second terminal call is a no-op.
|
|
1266
|
+
if (state === RunState.ENDED) return;
|
|
1267
|
+
state = RunState.ENDED;
|
|
1268
|
+
|
|
1269
|
+
try {
|
|
1270
|
+
await runManager.suspendRun(runId, invocationId, resolvedInputClientId, resolvedInputCodecMessageId);
|
|
1271
|
+
} catch (error) {
|
|
1272
|
+
const errInfo = new Ably.ErrorInfo(
|
|
1273
|
+
`unable to publish run-suspend for run ${runId}; ${error instanceof Error ? error.message : String(error)}`,
|
|
1274
|
+
ErrorCode.RunLifecycleError,
|
|
1275
|
+
500,
|
|
1276
|
+
error instanceof Ably.ErrorInfo ? error : undefined,
|
|
1277
|
+
);
|
|
1278
|
+
logger?.error('Run.suspend(); failed to publish run-suspend', { runId });
|
|
1279
|
+
throw errInfo;
|
|
1280
|
+
} finally {
|
|
1281
|
+
deregisterRun();
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
logger?.debug('Run.suspend(); run suspended', { runId });
|
|
1285
|
+
},
|
|
1286
|
+
|
|
1287
|
+
// Spec: AIT-ST7, AIT-ST7a, AIT-ST7b
|
|
1288
|
+
end: async (reason: RunEndReason): Promise<void> => {
|
|
1289
|
+
logger?.trace('Run.end();', { runId, reason });
|
|
1290
|
+
|
|
1291
|
+
await requireConnected('end');
|
|
1292
|
+
|
|
1293
|
+
if (state === RunState.INITIALIZED) {
|
|
1294
|
+
throw new Ably.ErrorInfo(
|
|
1295
|
+
`unable to end run; start() must be called before end() (run ${runId})`,
|
|
1296
|
+
ErrorCode.InvalidArgument,
|
|
1297
|
+
400,
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
if (state === RunState.ENDED) return;
|
|
1301
|
+
state = RunState.ENDED;
|
|
1302
|
+
|
|
1303
|
+
try {
|
|
1304
|
+
await runManager.endRun(runId, reason, invocationId, resolvedInputClientId, resolvedInputCodecMessageId);
|
|
1305
|
+
} catch (error) {
|
|
1306
|
+
const errInfo = new Ably.ErrorInfo(
|
|
1307
|
+
`unable to publish run-end for run ${runId}; ${error instanceof Error ? error.message : String(error)}`,
|
|
1308
|
+
ErrorCode.RunLifecycleError,
|
|
1309
|
+
500,
|
|
1310
|
+
error instanceof Ably.ErrorInfo ? error : undefined,
|
|
1311
|
+
);
|
|
1312
|
+
logger?.error('Run.end(); failed to publish run-end', { runId });
|
|
1313
|
+
throw errInfo;
|
|
1314
|
+
} finally {
|
|
1315
|
+
deregisterRun();
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
logger?.debug('Run.end(); run ended', { runId, reason });
|
|
1319
|
+
},
|
|
1320
|
+
};
|
|
1321
|
+
|
|
1322
|
+
return run;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// ---------------------------------------------------------------------------
|
|
1327
|
+
// Factory
|
|
1328
|
+
// ---------------------------------------------------------------------------
|
|
1329
|
+
|
|
1330
|
+
/**
|
|
1331
|
+
* Create an agent (server-side) session bound to the given Realtime client
|
|
1332
|
+
* and channel name. The caller owns the client's lifecycle; the session
|
|
1333
|
+
* owns its channel.
|
|
1334
|
+
* @param options - Session configuration.
|
|
1335
|
+
* @returns A new {@link AgentSession} instance.
|
|
1336
|
+
*/
|
|
1337
|
+
export const createAgentSession = <
|
|
1338
|
+
TInput extends CodecInputEvent,
|
|
1339
|
+
TOutput extends CodecOutputEvent,
|
|
1340
|
+
TProjection,
|
|
1341
|
+
TMessage,
|
|
1342
|
+
>(
|
|
1343
|
+
options: AgentSessionOptions<TInput, TOutput, TProjection, TMessage>,
|
|
1344
|
+
): AgentSession<TOutput, TProjection, TMessage> => new DefaultAgentSession(options);
|