@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
|
@@ -6,16 +6,17 @@
|
|
|
6
6
|
* single channel subscription — no separate cancel manager needed.
|
|
7
7
|
*
|
|
8
8
|
* The session exposes a single factory method — `createRun()` — which returns
|
|
9
|
-
* a Run object with explicit lifecycle methods: start(), pipe(),
|
|
10
|
-
*
|
|
9
|
+
* a Run object with explicit lifecycle methods: start(), pipe(), suspend(),
|
|
10
|
+
* and end() (suspend() and end() are both terminal).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import * as Ably from 'ably';
|
|
14
|
+
// Also augments RealtimeChannel with `.object` (ably/liveobjects side-effect).
|
|
15
|
+
import type * as AblyObjects from 'ably/liveobjects';
|
|
14
16
|
|
|
15
17
|
import {
|
|
16
18
|
EVENT_CANCEL,
|
|
17
19
|
HEADER_CODEC_MESSAGE_ID,
|
|
18
|
-
HEADER_EVENT_ID,
|
|
19
20
|
HEADER_FORK_OF,
|
|
20
21
|
HEADER_INPUT_CODEC_MESSAGE_ID,
|
|
21
22
|
HEADER_MSG_REGENERATE,
|
|
@@ -24,264 +25,42 @@ import {
|
|
|
24
25
|
HEADER_RUN_ID,
|
|
25
26
|
} from '../../constants.js';
|
|
26
27
|
import { ErrorCode } from '../../errors.js';
|
|
27
|
-
import type
|
|
28
|
-
import {
|
|
28
|
+
import { type Logger, LogLevel, makeLogger } from '../../logger.js';
|
|
29
|
+
import { errorCause, errorMessage, getTransportHeaders } from '../../utils.js';
|
|
29
30
|
import { registerAgent } from '../agent.js';
|
|
31
|
+
import { resolveChannelModes } from '../channel-options.js';
|
|
30
32
|
import type { Codec, CodecInputEvent, CodecOutputEvent } from '../codec/types.js';
|
|
33
|
+
import { type AgentView, createAgentView } from './agent-view.js';
|
|
34
|
+
import { createWireApplier, type WireApplier } from './decode-fold.js';
|
|
31
35
|
import { buildTransportHeaders } from './headers.js';
|
|
32
36
|
import { evictOldestIfFull } from './internal/bounded-map.js';
|
|
33
37
|
import { Invocation } from './invocation.js';
|
|
34
|
-
import { loadConversation, loadRunProjection } from './load-conversation.js';
|
|
35
38
|
import { pipeStream } from './pipe-stream.js';
|
|
36
39
|
import type { RunManager } from './run-manager.js';
|
|
37
40
|
import { createRunManager } from './run-manager.js';
|
|
41
|
+
import { bestEffortDetach, continuityLostError, isContinuityLost, requireConnected } from './session-support.js';
|
|
42
|
+
import { createTree, type DefaultTree } from './tree.js';
|
|
38
43
|
import type {
|
|
39
44
|
AgentSession,
|
|
40
45
|
AgentSessionOptions,
|
|
41
46
|
CancelRequest,
|
|
42
|
-
EventsNode,
|
|
43
47
|
LoadConversationOptions,
|
|
44
|
-
MessageNode,
|
|
45
48
|
PipeOptions,
|
|
46
49
|
Run,
|
|
47
|
-
|
|
50
|
+
RunEndParams,
|
|
48
51
|
RunRuntime,
|
|
49
52
|
RunView,
|
|
50
53
|
StreamResult,
|
|
54
|
+
Tree,
|
|
51
55
|
} from './types.js';
|
|
52
56
|
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
// Run-state lookup helpers
|
|
55
|
-
// ---------------------------------------------------------------------------
|
|
56
|
-
|
|
57
57
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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.
|
|
58
|
+
* Upper bound on buffered deferred cancels. Deferred cancels are bounded so
|
|
59
|
+
* a pathological burst can't grow the map without bound. 200 outstanding
|
|
60
|
+
* fresh-send cancels in flight is ample — a typical agent process sees one
|
|
61
|
+
* per HTTP request.
|
|
108
62
|
*/
|
|
109
|
-
|
|
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
|
-
};
|
|
63
|
+
const DEFERRED_CANCEL_LIMIT = 200;
|
|
285
64
|
|
|
286
65
|
// ---------------------------------------------------------------------------
|
|
287
66
|
// Internal run record for cancel routing
|
|
@@ -325,7 +104,7 @@ class DefaultAgentSession<
|
|
|
325
104
|
TMessage,
|
|
326
105
|
> implements AgentSession<TOutput, TProjection, TMessage> {
|
|
327
106
|
private readonly _channel: Ably.RealtimeChannel;
|
|
328
|
-
private readonly _codec:
|
|
107
|
+
private readonly _codec: Codec<TInput, TOutput, TProjection, TMessage>;
|
|
329
108
|
private readonly _logger: Logger | undefined;
|
|
330
109
|
private readonly _onError: ((error: Ably.ErrorInfo) => void) | undefined;
|
|
331
110
|
private readonly _runManager: RunManager;
|
|
@@ -347,33 +126,39 @@ class DefaultAgentSession<
|
|
|
347
126
|
* keyed by the input codec-message-id, and the `inputCodecMessageId → run`
|
|
348
127
|
* linkage doesn't exist until the lookup completes. `Run.start()` consults
|
|
349
128
|
* this buffer as a PULL once it resolves its `resolvedInputCodecMessageId`,
|
|
350
|
-
* honouring any cancel that arrived first.
|
|
351
|
-
* eviction at `_inputEventBufferLimit` entries, cleared on `close()`.
|
|
129
|
+
* honouring any cancel that arrived first. Cleared on `close()`.
|
|
352
130
|
*/
|
|
353
131
|
private readonly _deferredCancels = new Map<string, Ably.InboundMessage>();
|
|
354
132
|
/**
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
133
|
+
* Session-owned materialisation tree. Every message (live + history) folds
|
|
134
|
+
* through `this._applier.apply(msg)`; conversation state is read by
|
|
135
|
+
* walking parent pointers from the input node.
|
|
136
|
+
*
|
|
137
|
+
* Replaced (not cleared in place) on channel continuity loss so that the
|
|
138
|
+
* fresh tree starts empty. The old tree is abandoned to GC once in-flight
|
|
139
|
+
* lookups have aborted.
|
|
140
|
+
*/
|
|
141
|
+
private _tree: DefaultTree<TInput, TOutput, TProjection>;
|
|
142
|
+
/**
|
|
143
|
+
* The Tree's single decode-and-apply engine, binding one inbound decoder
|
|
144
|
+
* instance shared by every fold route (live + history). Streaming across
|
|
145
|
+
* pages folds correctly because the decoder keeps stream-tracker state
|
|
146
|
+
* across messages. Replaced alongside the Tree on continuity loss so the
|
|
147
|
+
* fresh Tree gets a fresh decoder. Outbound encoders (used by `Run.pipe`)
|
|
148
|
+
* manage their own decoders.
|
|
361
149
|
*/
|
|
362
|
-
private
|
|
150
|
+
private _applier: WireApplier;
|
|
363
151
|
/**
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
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).
|
|
152
|
+
* Internal server-side view: input-event lookup + conversation loading over
|
|
153
|
+
* the session Tree. Holds the Tree/applier directly (like the client's
|
|
154
|
+
* DefaultView), so it is RECREATED — not mutated — when the Tree is swapped
|
|
155
|
+
* on continuity loss.
|
|
372
156
|
*/
|
|
373
|
-
private
|
|
374
|
-
private readonly _inputEventBufferLimit: number;
|
|
157
|
+
private _agentView: AgentView<TInput, TOutput, TProjection, TMessage>;
|
|
375
158
|
private readonly _channelListener: (msg: Ably.InboundMessage) => void;
|
|
376
159
|
private readonly _inputEventLookupTimeoutMs: number;
|
|
160
|
+
/** Lookback bound passed to the AgentView's input-event scan (see {@link _createAgentView}). */
|
|
161
|
+
private readonly _inputEventLookbackMs: number;
|
|
377
162
|
|
|
378
163
|
private _state = SessionState.READY;
|
|
379
164
|
private _connectPromise: Promise<void> | undefined;
|
|
@@ -386,20 +171,22 @@ class DefaultAgentSession<
|
|
|
386
171
|
// (options.agents) and channel-attach (params.agent) paths. Idempotent
|
|
387
172
|
// across sessions sharing one client.
|
|
388
173
|
const registerOptions = registerAgent(options.client, options.codec);
|
|
389
|
-
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
// `AgentSessionOptions.rewindWindow`.
|
|
394
|
-
const channelOptions: Ably.ChannelOptions = {
|
|
395
|
-
params: { ...registerOptions.params, rewind: options.rewindWindow ?? '2m' },
|
|
396
|
-
};
|
|
174
|
+
const channelOptions: Ably.ChannelOptions = { ...registerOptions };
|
|
175
|
+
// Spec: AIT-ST16 — request object modes etc. when channelModes opts in.
|
|
176
|
+
const modes = resolveChannelModes(options.channelModes);
|
|
177
|
+
if (modes) channelOptions.modes = modes;
|
|
397
178
|
this._channel = options.client.channels.get(options.channelName, channelOptions);
|
|
398
179
|
this._logger = options.logger?.withContext({ component: 'AgentSession' });
|
|
399
180
|
this._onError = options.onError;
|
|
400
181
|
this._runManager = createRunManager(this._channel, this._logger);
|
|
401
182
|
this._inputEventLookupTimeoutMs = options.inputEventLookupTimeoutMs ?? 30000;
|
|
402
|
-
this.
|
|
183
|
+
this._inputEventLookbackMs = options.inputEventLookbackMs ?? 120_000;
|
|
184
|
+
this._tree = createTree<TInput, TOutput, TProjection>(
|
|
185
|
+
this._codec,
|
|
186
|
+
this._logger ?? makeLogger({ logLevel: LogLevel.Silent }),
|
|
187
|
+
);
|
|
188
|
+
this._applier = createWireApplier(this._tree, this._codec.createDecoder());
|
|
189
|
+
this._agentView = this._createAgentView();
|
|
403
190
|
|
|
404
191
|
this._channelListener = (msg: Ably.InboundMessage) => {
|
|
405
192
|
this._handleChannelMessage(msg);
|
|
@@ -421,6 +208,38 @@ class DefaultAgentSession<
|
|
|
421
208
|
this._logger?.debug('DefaultAgentSession(); session created');
|
|
422
209
|
}
|
|
423
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Build an AgentView bound to the session's CURRENT Tree + applier. Called at
|
|
213
|
+
* construction and again after a continuity-loss swap — the AgentView holds
|
|
214
|
+
* the Tree/applier directly (like DefaultView), so a swap recreates it rather
|
|
215
|
+
* than mutating it in place.
|
|
216
|
+
* @returns A fresh AgentView over the current Tree/applier.
|
|
217
|
+
*/
|
|
218
|
+
private _createAgentView(): AgentView<TInput, TOutput, TProjection, TMessage> {
|
|
219
|
+
return createAgentView<TInput, TOutput, TProjection, TMessage>({
|
|
220
|
+
tree: this._tree,
|
|
221
|
+
channel: this._channel,
|
|
222
|
+
codec: this._codec,
|
|
223
|
+
applier: this._applier,
|
|
224
|
+
logger: this._logger,
|
|
225
|
+
inputEventLookbackMs: this._inputEventLookbackMs,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// -------------------------------------------------------------------------
|
|
230
|
+
// Public accessors
|
|
231
|
+
// -------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
// Spec: AIT-ST14
|
|
234
|
+
get presence(): Ably.RealtimePresence {
|
|
235
|
+
return this._channel.presence;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Spec: AIT-ST15
|
|
239
|
+
get object(): AblyObjects.RealtimeObject {
|
|
240
|
+
return this._channel.object;
|
|
241
|
+
}
|
|
242
|
+
|
|
424
243
|
// -------------------------------------------------------------------------
|
|
425
244
|
// Public API
|
|
426
245
|
// -------------------------------------------------------------------------
|
|
@@ -435,22 +254,19 @@ class DefaultAgentSession<
|
|
|
435
254
|
|
|
436
255
|
this._logger?.trace('DefaultAgentSession.connect();');
|
|
437
256
|
// Subscribe unfiltered (before attach, per RTL7g — subscribe implicitly
|
|
438
|
-
// attaches the channel).
|
|
439
|
-
//
|
|
440
|
-
//
|
|
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.
|
|
257
|
+
// attaches the channel). Unfiltered so the Tree folds every post-attach
|
|
258
|
+
// message regardless of name (cancel control messages are dispatched
|
|
259
|
+
// separately by the channel listener after the Tree fold).
|
|
444
260
|
this._connectPromise = this._channel.subscribe(this._channelListener).then(
|
|
445
261
|
() => {
|
|
446
262
|
this._logger?.debug('DefaultAgentSession.connect(); subscribed and attached');
|
|
447
263
|
},
|
|
448
264
|
(error: unknown) => {
|
|
449
265
|
const errInfo = new Ably.ErrorInfo(
|
|
450
|
-
`unable to subscribe to channel; ${
|
|
266
|
+
`unable to subscribe to channel; ${errorMessage(error)}`,
|
|
451
267
|
ErrorCode.SessionSubscriptionError,
|
|
452
268
|
500,
|
|
453
|
-
error
|
|
269
|
+
errorCause(error),
|
|
454
270
|
);
|
|
455
271
|
this._logger?.error('DefaultAgentSession.connect(); subscribe failed');
|
|
456
272
|
this._onError?.(errInfo);
|
|
@@ -461,47 +277,12 @@ class DefaultAgentSession<
|
|
|
461
277
|
}
|
|
462
278
|
|
|
463
279
|
/**
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
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.
|
|
280
|
+
* The session-owned materialisation tree. Mirrors `ClientSession.tree`
|
|
281
|
+
* for observability and parity.
|
|
282
|
+
* @returns The session's Tree.
|
|
477
283
|
*/
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
};
|
|
284
|
+
get tree(): Tree<TOutput, TProjection> {
|
|
285
|
+
return this._tree;
|
|
505
286
|
}
|
|
506
287
|
|
|
507
288
|
// Spec: AIT-ST3
|
|
@@ -525,23 +306,9 @@ class DefaultAgentSession<
|
|
|
525
306
|
this._registeredRuns.clear();
|
|
526
307
|
this._runIdByInputCodecMessageId.clear();
|
|
527
308
|
this._deferredCancels.clear();
|
|
528
|
-
this._pendingInputEventLookups.clear();
|
|
529
|
-
this._inputEventBuffer.clear();
|
|
530
309
|
this._runManager.close();
|
|
531
310
|
|
|
532
|
-
|
|
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
|
-
}
|
|
311
|
+
await bestEffortDetach(this._channel, this._connectPromise, this._logger, 'DefaultAgentSession');
|
|
545
312
|
|
|
546
313
|
this._logger?.debug('DefaultAgentSession.close(); session closed');
|
|
547
314
|
}
|
|
@@ -593,17 +360,17 @@ class DefaultAgentSession<
|
|
|
593
360
|
/**
|
|
594
361
|
* Buffer a cancel that arrived before its target run was known, keyed by the
|
|
595
362
|
* triggering input's codec-message-id. FIFO-evicts the oldest entry at
|
|
596
|
-
*
|
|
597
|
-
*
|
|
363
|
+
* {@link DEFERRED_CANCEL_LIMIT}. A later cancel for the same input replaces the earlier
|
|
364
|
+
* one — the intent is identical.
|
|
598
365
|
* @param inputCodecMessageId - The triggering input's codec-message-id.
|
|
599
366
|
* @param msg - The raw cancel message (passed to `onCancel`).
|
|
600
367
|
*/
|
|
601
368
|
private _bufferDeferredCancel(inputCodecMessageId: string, msg: Ably.InboundMessage): void {
|
|
602
|
-
const evicted = evictOldestIfFull(this._deferredCancels, inputCodecMessageId,
|
|
369
|
+
const evicted = evictOldestIfFull(this._deferredCancels, inputCodecMessageId, DEFERRED_CANCEL_LIMIT);
|
|
603
370
|
if (evicted !== undefined) {
|
|
604
371
|
this._logger?.warn('DefaultAgentSession._bufferDeferredCancel(); deferred-cancel buffer full, dropping oldest', {
|
|
605
372
|
evictedInputCodecMessageId: evicted,
|
|
606
|
-
limit:
|
|
373
|
+
limit: DEFERRED_CANCEL_LIMIT,
|
|
607
374
|
});
|
|
608
375
|
}
|
|
609
376
|
this._deferredCancels.set(inputCodecMessageId, msg);
|
|
@@ -661,10 +428,10 @@ class DefaultAgentSession<
|
|
|
661
428
|
this._logger?.debug('DefaultAgentSession._cancelRegistration(); run cancelled', { runId });
|
|
662
429
|
} catch (error) {
|
|
663
430
|
const errInfo = new Ably.ErrorInfo(
|
|
664
|
-
`unable to process cancel for run ${runId}; onCancel handler threw: ${
|
|
431
|
+
`unable to process cancel for run ${runId}; onCancel handler threw: ${errorMessage(error)}`,
|
|
665
432
|
ErrorCode.CancelListenerError,
|
|
666
433
|
500,
|
|
667
|
-
error
|
|
434
|
+
errorCause(error),
|
|
668
435
|
);
|
|
669
436
|
this._logger?.error('DefaultAgentSession._cancelRegistration(); onCancel threw', { runId });
|
|
670
437
|
(reg.onError ?? this._onError)?.(errInfo);
|
|
@@ -681,19 +448,13 @@ class DefaultAgentSession<
|
|
|
681
448
|
|
|
682
449
|
const { current, resumed } = stateChange;
|
|
683
450
|
|
|
684
|
-
// Track the initial attach so we don't treat it as a discontinuity
|
|
451
|
+
// Track the initial attach so we don't treat it as a discontinuity.
|
|
685
452
|
if (current === 'attached' && !this._hasAttachedOnce) {
|
|
686
453
|
this._hasAttachedOnce = true;
|
|
687
454
|
return;
|
|
688
455
|
}
|
|
689
456
|
|
|
690
|
-
|
|
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;
|
|
457
|
+
if (!isContinuityLost(stateChange)) return;
|
|
697
458
|
|
|
698
459
|
this._logger?.error('DefaultAgentSession._handleChannelStateChange(); channel continuity lost', {
|
|
699
460
|
current,
|
|
@@ -701,18 +462,59 @@ class DefaultAgentSession<
|
|
|
701
462
|
previous: stateChange.previous,
|
|
702
463
|
});
|
|
703
464
|
|
|
704
|
-
const
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
465
|
+
const continuityErr = continuityLostError(stateChange, 'continue');
|
|
466
|
+
|
|
467
|
+
// Abort every active run's controller FIRST so in-flight
|
|
468
|
+
// `loadConversation` / `findInputEvent` calls observe the abort before
|
|
469
|
+
// the Tree changes underneath them and reject (InvalidArgument from their
|
|
470
|
+
// signal checks; the session-level onError carries ChannelContinuityLost).
|
|
471
|
+
for (const reg of this._registeredRuns.values()) {
|
|
472
|
+
reg.controller.abort();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Then swap the Tree for a fresh empty instance — abandons the old
|
|
476
|
+
// Tree's projections, indices, and ably-message listeners to GC. New
|
|
477
|
+
// runs use the fresh Tree; lingering closures on the old Tree from
|
|
478
|
+
// in-flight (now-aborted) lookups are bounded by the abort propagation.
|
|
479
|
+
this._tree = createTree<TInput, TOutput, TProjection>(
|
|
480
|
+
this._codec,
|
|
481
|
+
this._logger ?? makeLogger({ logLevel: LogLevel.Silent }),
|
|
709
482
|
);
|
|
483
|
+
this._applier = createWireApplier(this._tree, this._codec.createDecoder());
|
|
484
|
+
// The AgentView holds the Tree/applier directly, so rebuild it against the
|
|
485
|
+
// fresh pair — this also resets its cursor and exhaustion state.
|
|
486
|
+
this._agentView = this._createAgentView();
|
|
710
487
|
|
|
711
|
-
// Session-level notification
|
|
488
|
+
// Session-level notification: continuity loss is not scoped to any one
|
|
712
489
|
// run. Per-run onError handlers are reserved for errors from that run's
|
|
713
|
-
// own operations (publish failures, encoder errors).
|
|
714
|
-
|
|
715
|
-
|
|
490
|
+
// own operations (publish failures, encoder errors).
|
|
491
|
+
this._onError?.(continuityErr);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// -------------------------------------------------------------------------
|
|
495
|
+
// Wire fold
|
|
496
|
+
// -------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Fold a single wire message into the session-owned Tree. Mirrors the
|
|
500
|
+
* ClientSession's live decode loop — same engine, same fold path. The
|
|
501
|
+
* applier decodes the message and applies the result to the Tree (or
|
|
502
|
+
* routes lifecycle messages through `applyRunLifecycle`);
|
|
503
|
+
* `emitAblyMessage` notifies Tree subscribers AND populates the event-id
|
|
504
|
+
* index used by the AgentView's input-event lookup.
|
|
505
|
+
*
|
|
506
|
+
* A message that surfaces via more than one path (the live listener and
|
|
507
|
+
* the AgentView's history walk) does not
|
|
508
|
+
* double-fold: the shared decoder's version-guarded trackers drop
|
|
509
|
+
* re-delivered stream content, and the Tree's per-entry `decodedThrough`
|
|
510
|
+
* high-water-mark drops whole-wire replays (including stateless discrete
|
|
511
|
+
* re-decodes) at the correct per-delivery granularity — same-serial live
|
|
512
|
+
* appends each carry their own version and fold exactly once.
|
|
513
|
+
* @param wire - The inbound Ably message to fold.
|
|
514
|
+
*/
|
|
515
|
+
private _foldWire(wire: Ably.InboundMessage): void {
|
|
516
|
+
this._applier.apply(wire);
|
|
517
|
+
this._tree.emitAblyMessage(wire);
|
|
716
518
|
}
|
|
717
519
|
|
|
718
520
|
// -------------------------------------------------------------------------
|
|
@@ -721,67 +523,31 @@ class DefaultAgentSession<
|
|
|
721
523
|
|
|
722
524
|
private _handleChannelMessage(msg: Ably.InboundMessage): void {
|
|
723
525
|
try {
|
|
526
|
+
// Fold first (re-delivered content is dropped by the shared decoder's
|
|
527
|
+
// version guard and the Tree's replay guard), then dispatch cancel
|
|
528
|
+
// control messages.
|
|
529
|
+
this._foldWire(msg);
|
|
530
|
+
|
|
724
531
|
if (msg.name === EVENT_CANCEL) {
|
|
725
532
|
// Fire-and-forget async handler — errors are caught internally.
|
|
726
533
|
this._handleCancelMessage(msg).catch((error: unknown) => {
|
|
727
534
|
const errInfo = new Ably.ErrorInfo(
|
|
728
|
-
`unable to route cancel message; ${
|
|
535
|
+
`unable to route cancel message; ${errorMessage(error)}`,
|
|
729
536
|
ErrorCode.CancelListenerError,
|
|
730
537
|
500,
|
|
731
|
-
error
|
|
538
|
+
errorCause(error),
|
|
732
539
|
);
|
|
733
540
|
this._logger?.error('DefaultAgentSession._handleChannelMessage(); cancel routing error');
|
|
734
541
|
this._onError?.(errInfo);
|
|
735
542
|
});
|
|
736
543
|
return;
|
|
737
544
|
}
|
|
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
545
|
} catch (error) {
|
|
780
546
|
const errInfo = new Ably.ErrorInfo(
|
|
781
|
-
`unable to process channel message; ${
|
|
547
|
+
`unable to process channel message; ${errorMessage(error)}`,
|
|
782
548
|
ErrorCode.SessionSubscriptionError,
|
|
783
549
|
500,
|
|
784
|
-
error
|
|
550
|
+
errorCause(error),
|
|
785
551
|
);
|
|
786
552
|
this._logger?.error('DefaultAgentSession._handleChannelMessage(); subscription error');
|
|
787
553
|
this._onError?.(errInfo);
|
|
@@ -793,14 +559,7 @@ class DefaultAgentSession<
|
|
|
793
559
|
// -------------------------------------------------------------------------
|
|
794
560
|
|
|
795
561
|
private async _requireConnected(method: string): Promise<void> {
|
|
796
|
-
|
|
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;
|
|
562
|
+
return requireConnected(this._connectPromise, method);
|
|
804
563
|
}
|
|
805
564
|
|
|
806
565
|
// -------------------------------------------------------------------------
|
|
@@ -812,9 +571,15 @@ class DefaultAgentSession<
|
|
|
812
571
|
// Mint a provisional id now (or take the `runtime.runId` override for
|
|
813
572
|
// tests / in-process drivers) — this IS the id for a fresh run. A
|
|
814
573
|
// continuation overrides it in `Run.start()` with the existing run-id read
|
|
815
|
-
// off the triggering input event's
|
|
574
|
+
// off the triggering input event's message headers (the run it re-enters).
|
|
816
575
|
// Mirrors the invocationId mint below.
|
|
817
576
|
let runId = runtime.runId ?? crypto.randomUUID();
|
|
577
|
+
// Whether the run-id was supplied via the runtime override. Together with
|
|
578
|
+
// `resolvedContinuation` (set in start() when the triggering input carries
|
|
579
|
+
// a wire run-id) this decides whether the id is "adopted" — an adopted id
|
|
580
|
+
// can name a run that already exists in channel history; a freshly-minted
|
|
581
|
+
// UUID cannot, so hydration must not demand its node from history.
|
|
582
|
+
const runIdOverridden = runtime.runId !== undefined;
|
|
818
583
|
// The agent mints the invocation id — one per HTTP request that invokes
|
|
819
584
|
// it. A per-run override (runtime.invocationId) supports deterministic ids
|
|
820
585
|
// in tests and in-process drivers.
|
|
@@ -854,29 +619,18 @@ class DefaultAgentSession<
|
|
|
854
619
|
const runIdByInputCodecMessageId = this._runIdByInputCodecMessageId;
|
|
855
620
|
const deferredCancels = this._deferredCancels;
|
|
856
621
|
const requireConnected = this._requireConnected.bind(this);
|
|
857
|
-
|
|
622
|
+
// Live accessor (not a captured ref): a continuity-loss swap recreates the
|
|
623
|
+
// AgentView, and reads after the swap must observe the fresh instance.
|
|
624
|
+
const getAgentView = (): AgentView<TInput, TOutput, TProjection, TMessage> => this._agentView;
|
|
858
625
|
const pullDeferredCancel = this._pullDeferredCancel.bind(this);
|
|
859
626
|
const inputEventId = invocation.inputEventId;
|
|
860
627
|
|
|
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
628
|
// Per-run metadata resolved from the input-event lookup result. The first
|
|
872
|
-
// matched
|
|
629
|
+
// matched message message's headers carry the run's `clientId`, `parent`, and
|
|
873
630
|
// `forkOf`, and — for a continuation — the `run-id` it re-enters (a fresh
|
|
874
631
|
// input carries none; the client stamps a run-id only when re-entering a
|
|
875
632
|
// run it already knows). Its Ably-level publisher `clientId` becomes the
|
|
876
|
-
// `inputClientId` re-stamped on the agent's own publishes.
|
|
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.
|
|
633
|
+
// `inputClientId` re-stamped on the agent's own publishes.
|
|
880
634
|
let resolvedClientId: string | undefined;
|
|
881
635
|
let resolvedInputClientId: string | undefined;
|
|
882
636
|
let resolvedParent: string | undefined;
|
|
@@ -885,23 +639,47 @@ class DefaultAgentSession<
|
|
|
885
639
|
let resolvedInputCodecMessageId: string | undefined;
|
|
886
640
|
let resolvedContinuation = false;
|
|
887
641
|
let firstLookupHeaders: Record<string, string> | undefined;
|
|
642
|
+
|
|
643
|
+
// `Run.view.messages` is a LIVE read against the session's Tree:
|
|
644
|
+
// returns the trigger node's currently-folded messages, reflecting any
|
|
645
|
+
// amendments (tool resolutions etc.) that have arrived since
|
|
646
|
+
// `Run.start()`. No internal `viewMessages` array — the Tree is the
|
|
647
|
+
// single source of truth. The trigger node may be an input node (fresh
|
|
648
|
+
// send) or a reply run (continuation re-entry with run-id on the
|
|
649
|
+
// triggering message); both expose a projection the codec can read.
|
|
650
|
+
//
|
|
651
|
+
// Resolved via an arrow accessor so the closure picks up `this._tree`
|
|
652
|
+
// after a continuity-loss swap; capturing `this._tree` into a local at
|
|
653
|
+
// run-creation time would silently keep returning data from the
|
|
654
|
+
// abandoned Tree.
|
|
655
|
+
const getTree = (): DefaultTree<TInput, TOutput, TProjection> => this._tree;
|
|
656
|
+
const view: RunView<TMessage> = {
|
|
657
|
+
get messages() {
|
|
658
|
+
if (resolvedInputCodecMessageId === undefined) return [];
|
|
659
|
+
const node = getTree().getNodeByCodecMessageId(resolvedInputCodecMessageId);
|
|
660
|
+
if (!node) return [];
|
|
661
|
+
const sourceSerial = node.kind === 'input' ? node.serial : node.startSerial;
|
|
662
|
+
const sourceForkOf = node.kind === 'input' ? node.forkOf : undefined;
|
|
663
|
+
return codec.getMessages(node.projection).map((m) => ({
|
|
664
|
+
kind: 'message' as const,
|
|
665
|
+
message: m.message,
|
|
666
|
+
codecMessageId: m.codecMessageId,
|
|
667
|
+
parentId: node.parentCodecMessageId,
|
|
668
|
+
forkOf: sourceForkOf,
|
|
669
|
+
headers: {},
|
|
670
|
+
serial: sourceSerial,
|
|
671
|
+
}));
|
|
672
|
+
},
|
|
673
|
+
};
|
|
888
674
|
/**
|
|
889
675
|
* The reply run's structural-parent fallback, computed once in
|
|
890
|
-
* `Run.start()`
|
|
891
|
-
* and consumed by every `Run.pipe()` publish.
|
|
892
|
-
* `streamOpts.parent` still overrides it. Storing it here
|
|
893
|
-
* across pipes and decouples the assistant's structural
|
|
894
|
-
* run-start
|
|
676
|
+
* `Run.start()` once the input-event lookup resolves the triggering
|
|
677
|
+
* input's codec-message-id, and consumed by every `Run.pipe()` publish.
|
|
678
|
+
* A per-stream `streamOpts.parent` still overrides it. Storing it here
|
|
679
|
+
* keeps it stable across pipes and decouples the assistant's structural
|
|
680
|
+
* parent from the run-start message's own `parent`.
|
|
895
681
|
*/
|
|
896
682
|
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
683
|
/**
|
|
906
684
|
* Remove this run from the session's routing maps. Drops the
|
|
907
685
|
* `_registeredRuns` entry plus the `input-codec-message-id → run-id`
|
|
@@ -917,17 +695,33 @@ class DefaultAgentSession<
|
|
|
917
695
|
}
|
|
918
696
|
};
|
|
919
697
|
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
698
|
+
/**
|
|
699
|
+
* Run a run-lifecycle publish (run-start / run-suspend / run-end) and wrap
|
|
700
|
+
* any failure as a `RunLifecycleError`, logging at error and rethrowing.
|
|
701
|
+
* Shared by start(), suspend(), and end() so the three publishes can't
|
|
702
|
+
* drift on the error code, message shape, or cause preservation.
|
|
703
|
+
* @param phase - The lifecycle wire phase, used in the error message.
|
|
704
|
+
* @param method - The Run method name, used in the log prefix.
|
|
705
|
+
* @param publish - The RunManager publish to run.
|
|
706
|
+
*/
|
|
707
|
+
const publishLifecycle = async (
|
|
708
|
+
phase: 'run-start' | 'run-suspend' | 'run-end',
|
|
709
|
+
method: 'start' | 'suspend' | 'end',
|
|
710
|
+
publish: () => Promise<void>,
|
|
711
|
+
): Promise<void> => {
|
|
712
|
+
try {
|
|
713
|
+
await publish();
|
|
714
|
+
} catch (error) {
|
|
715
|
+
const errInfo = new Ably.ErrorInfo(
|
|
716
|
+
`unable to publish ${phase} for run ${runId}; ${errorMessage(error)}`,
|
|
717
|
+
ErrorCode.RunLifecycleError,
|
|
718
|
+
500,
|
|
719
|
+
errorCause(error),
|
|
720
|
+
);
|
|
721
|
+
logger?.error(`Run.${method}(); failed to publish ${phase}`, { runId });
|
|
722
|
+
throw errInfo;
|
|
723
|
+
}
|
|
724
|
+
};
|
|
931
725
|
|
|
932
726
|
const run: Run<TOutput, TProjection, TMessage> = {
|
|
933
727
|
get runId() {
|
|
@@ -943,13 +737,18 @@ class DefaultAgentSession<
|
|
|
943
737
|
return view;
|
|
944
738
|
},
|
|
945
739
|
get messages() {
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
740
|
+
// Always derive live from the Tree via the AgentView. Walks the parent
|
|
741
|
+
// chain from the run's structural-parent anchor and concatenates each
|
|
742
|
+
// ancestor's projection, then appends the current reply run's messages
|
|
743
|
+
// at the tail. Uses `assistantParentFallback` (which falls back to the
|
|
744
|
+
// input message's `parent` for regenerate carriers whose own
|
|
745
|
+
// codec-message-id has no Tree node) — same anchor `loadConversation`
|
|
746
|
+
// uses, and passes `resolvedRegenerates` so a regenerate's history
|
|
747
|
+
// stops before the message being replaced. No cache: every read
|
|
748
|
+
// reflects the latest folded state. `getAgentView()` dereferences the
|
|
749
|
+
// live AgentView so a continuity-loss swap is observed instead of
|
|
750
|
+
// returning stale data from the abandoned tree.
|
|
751
|
+
return getAgentView().messages(runId, assistantParentFallback, resolvedRegenerates);
|
|
953
752
|
},
|
|
954
753
|
|
|
955
754
|
// Spec: AIT-ST4, AIT-ST4a, AIT-ST4b
|
|
@@ -976,26 +775,21 @@ class DefaultAgentSession<
|
|
|
976
775
|
// when no inputEventId is set (invocation requires no channel lookup).
|
|
977
776
|
if (inputEventId && inputEventLookupTimeoutMs > 0) {
|
|
978
777
|
try {
|
|
979
|
-
const found = await
|
|
980
|
-
register: (callback) => registerInputEventListener([inputEventId], callback),
|
|
981
|
-
codec,
|
|
778
|
+
const found = await getAgentView().findInputEvent({
|
|
982
779
|
invocationId,
|
|
983
780
|
runId,
|
|
984
|
-
|
|
781
|
+
expectedEventIds: [inputEventId],
|
|
985
782
|
timeoutMs: inputEventLookupTimeoutMs,
|
|
986
783
|
signal,
|
|
987
|
-
logger,
|
|
988
784
|
});
|
|
989
|
-
for (const m of found.nodes) viewMessages.push(m);
|
|
990
785
|
if (found.firstHeaders !== undefined) firstLookupHeaders = found.firstHeaders;
|
|
991
786
|
if (found.firstClientId !== undefined) resolvedInputClientId = found.firstClientId;
|
|
992
|
-
liveLookupMessages = found.rawMessages;
|
|
993
787
|
} catch (error) {
|
|
994
788
|
const errInfo =
|
|
995
789
|
error instanceof Ably.ErrorInfo
|
|
996
790
|
? error
|
|
997
791
|
: new Ably.ErrorInfo(
|
|
998
|
-
`unable to look up input event; ${
|
|
792
|
+
`unable to look up input event; ${errorMessage(error)}`,
|
|
999
793
|
ErrorCode.InputEventNotFound,
|
|
1000
794
|
504,
|
|
1001
795
|
);
|
|
@@ -1010,16 +804,14 @@ class DefaultAgentSession<
|
|
|
1010
804
|
}
|
|
1011
805
|
}
|
|
1012
806
|
|
|
1013
|
-
// Resolve per-run metadata from the first matched
|
|
807
|
+
// Resolve per-run metadata from the first matched message message's
|
|
1014
808
|
// headers — they carry `clientId`, `parent`, and `forkOf`.
|
|
1015
809
|
// Continuations of a suspended run pick up the suspended assistant's
|
|
1016
|
-
// parent in the same headers (the continuation
|
|
810
|
+
// parent in the same headers (the continuation message parents off
|
|
1017
811
|
// the assistant). A `run-id` on the triggering input marks a
|
|
1018
812
|
// continuation (re-entry via `ai-run-resume`); a fresh input carries
|
|
1019
|
-
// none and opens the run with `ai-run-start`.
|
|
1020
|
-
|
|
1021
|
-
// `viewMessages` already populated and no `firstHeaders` was captured.
|
|
1022
|
-
const sourceHeaders = firstLookupHeaders ?? viewMessages[0]?.headers;
|
|
813
|
+
// none and opens the run with `ai-run-start`.
|
|
814
|
+
const sourceHeaders = firstLookupHeaders;
|
|
1023
815
|
if (sourceHeaders) {
|
|
1024
816
|
resolvedClientId = sourceHeaders[HEADER_RUN_CLIENT_ID];
|
|
1025
817
|
resolvedParent = sourceHeaders[HEADER_PARENT];
|
|
@@ -1043,12 +835,17 @@ class DefaultAgentSession<
|
|
|
1043
835
|
}
|
|
1044
836
|
}
|
|
1045
837
|
|
|
1046
|
-
// Compute the reply run's structural-parent fallback
|
|
1047
|
-
//
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1050
|
-
//
|
|
1051
|
-
|
|
838
|
+
// Compute the reply run's structural-parent fallback: the triggering
|
|
839
|
+
// user message's codec-message-id ONLY if that codec-message-id is
|
|
840
|
+
// backed by a real node in the Tree (i.e. the message decoded into at
|
|
841
|
+
// least one input event); otherwise — for regenerate carriers that
|
|
842
|
+
// are wire-only signals with no input events — fall back to the
|
|
843
|
+
// input message's own `parent` header.
|
|
844
|
+
assistantParentFallback =
|
|
845
|
+
resolvedInputCodecMessageId !== undefined &&
|
|
846
|
+
this._tree.getNodeByCodecMessageId(resolvedInputCodecMessageId) !== undefined
|
|
847
|
+
? resolvedInputCodecMessageId
|
|
848
|
+
: resolvedParent;
|
|
1052
849
|
|
|
1053
850
|
// The triggering input's codec-message-id is now resolved, so the
|
|
1054
851
|
// `input-codec-message-id → run` linkage exists: index it for live
|
|
@@ -1062,11 +859,11 @@ class DefaultAgentSession<
|
|
|
1062
859
|
await pullDeferredCancel(registration, resolvedInputCodecMessageId);
|
|
1063
860
|
}
|
|
1064
861
|
|
|
1065
|
-
|
|
1066
|
-
|
|
862
|
+
await publishLifecycle('run-start', 'start', async () =>
|
|
863
|
+
runManager.startRun(runId, resolvedClientId, controller, {
|
|
1067
864
|
// Stamp the reply run's STRUCTURAL parent (its input node, M_user) —
|
|
1068
|
-
// the same value the output path stamps — not the input
|
|
1069
|
-
// parent. Makes `parent` structural on every
|
|
865
|
+
// the same value the output path stamps — not the input message's own
|
|
866
|
+
// parent. Makes `parent` structural on every message so the Tree's two
|
|
1070
867
|
// creation paths agree regardless of arrival order. Valid only now
|
|
1071
868
|
// that M_user is a separate input node (the two-node flip).
|
|
1072
869
|
parent: assistantParentFallback,
|
|
@@ -1076,105 +873,48 @@ class DefaultAgentSession<
|
|
|
1076
873
|
inputClientId: resolvedInputClientId,
|
|
1077
874
|
inputCodecMessageId: resolvedInputCodecMessageId,
|
|
1078
875
|
continuation: resolvedContinuation,
|
|
876
|
+
}),
|
|
877
|
+
);
|
|
878
|
+
|
|
879
|
+
// Optimistically insert the fresh run's node into the session Tree so
|
|
880
|
+
// reads that follow start() (loadConversation, Run.messages) see the
|
|
881
|
+
// run immediately rather than depending on the channel echo of the
|
|
882
|
+
// run-start just published. The echo (or a history fold) reconciles
|
|
883
|
+
// through the Tree's run-start handling, promoting startSerial onto
|
|
884
|
+
// this serial-less node. Continuations re-enter an existing run via
|
|
885
|
+
// run-resume, which creates no structure — their node comes from
|
|
886
|
+
// history hydration instead.
|
|
887
|
+
if (!resolvedContinuation) {
|
|
888
|
+
getTree().applyRunLifecycle({
|
|
889
|
+
type: 'start',
|
|
890
|
+
runId,
|
|
891
|
+
clientId: resolvedClientId ?? '',
|
|
892
|
+
serial: undefined,
|
|
893
|
+
invocationId,
|
|
894
|
+
...(assistantParentFallback !== undefined && { parent: assistantParentFallback }),
|
|
895
|
+
...(resolvedForkOf !== undefined && { forkOf: resolvedForkOf }),
|
|
896
|
+
...(resolvedRegenerates !== undefined && { regenerates: resolvedRegenerates }),
|
|
1079
897
|
});
|
|
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
898
|
}
|
|
1090
899
|
|
|
1091
900
|
logger?.debug('Run.start(); run started', { runId, inputEventId });
|
|
1092
901
|
},
|
|
1093
902
|
|
|
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
903
|
loadConversation: async (options?: LoadConversationOptions): Promise<TMessage[]> => {
|
|
1163
904
|
logger?.trace('Run.loadConversation();', { runId });
|
|
1164
905
|
await requireConnected('loadConversation');
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
906
|
+
// No cache. Drives Tree hydration via the AgentView's conversation walk
|
|
907
|
+
// and computes a fresh snapshot of the parent-chain messages at
|
|
908
|
+
// return time. After this call, `Run.messages` continues to work
|
|
909
|
+
// as a live Tree read.
|
|
910
|
+
const { messages } = await getAgentView().loadConversation(
|
|
1168
911
|
runId,
|
|
1169
|
-
signal,
|
|
1170
|
-
logger,
|
|
1171
|
-
liveMessages: liveLookupMessages,
|
|
1172
912
|
assistantParentFallback,
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
913
|
+
signal,
|
|
914
|
+
options?.maxRuns,
|
|
915
|
+
runIdOverridden || resolvedContinuation,
|
|
916
|
+
resolvedRegenerates,
|
|
917
|
+
);
|
|
1178
918
|
return messages;
|
|
1179
919
|
},
|
|
1180
920
|
|
|
@@ -1198,14 +938,14 @@ class DefaultAgentSession<
|
|
|
1198
938
|
// `streamOpts.parent` from the caller, else the reply run's
|
|
1199
939
|
// structural-parent fallback computed once at run-start
|
|
1200
940
|
// (`assistantParentFallback` — the triggering user message, or the
|
|
1201
|
-
// input
|
|
941
|
+
// input message's own parent for regenerate messages that produced no
|
|
1202
942
|
// MessageNodes). Owning the default here means agent routes don't have
|
|
1203
943
|
// to pass `{ parent: lastUserCodecMessageId }` to keep tree threading
|
|
1204
944
|
// correct; edit-then-regenerate sibling resolution relies on the
|
|
1205
945
|
// user→assistant chain being explicit.
|
|
1206
946
|
const assistantParent = streamOpts?.parent ?? assistantParentFallback;
|
|
1207
947
|
const assistantForkOf = streamOpts?.forkOf ?? resolvedForkOf;
|
|
1208
|
-
// Echo `msg-regenerate` on the assistant
|
|
948
|
+
// Echo `msg-regenerate` on the assistant message so that a
|
|
1209
949
|
// client receiving the assistant chunk before `ai-run-start`
|
|
1210
950
|
// (e.g. via history pagination across a page boundary, or a lost
|
|
1211
951
|
// lifecycle publish) can still populate `RunNode.regeneratesCodecMessageId`
|
|
@@ -1239,12 +979,25 @@ class DefaultAgentSession<
|
|
|
1239
979
|
`unable to pipe response for run ${runId}; ${result.error.message}`,
|
|
1240
980
|
ErrorCode.StreamError,
|
|
1241
981
|
500,
|
|
1242
|
-
result.error
|
|
982
|
+
errorCause(result.error),
|
|
1243
983
|
);
|
|
1244
984
|
logger?.error('Run.pipe(); stream error', { runId });
|
|
1245
985
|
runOnError?.(errInfo);
|
|
1246
986
|
}
|
|
1247
987
|
|
|
988
|
+
// Run cancellation is transport-tier: guarantee the run-end terminal so
|
|
989
|
+
// every observer's stream closes even if the caller's handler omits
|
|
990
|
+
// run.end(). Best-effort — pipe must still return the StreamResult; a
|
|
991
|
+
// later run.end() is a no-op via the ENDED guard. The run is past
|
|
992
|
+
// INITIALIZED here (pipe requires start()), so end()'s guards pass.
|
|
993
|
+
if (result.reason === 'cancelled') {
|
|
994
|
+
try {
|
|
995
|
+
await run.end({ reason: 'cancelled' });
|
|
996
|
+
} catch {
|
|
997
|
+
logger?.error('Run.pipe(); run-end on cancel failed', { runId });
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1248
1001
|
logger?.debug('Run.pipe(); stream finished', { runId, reason: result.reason });
|
|
1249
1002
|
return result;
|
|
1250
1003
|
},
|
|
@@ -1267,16 +1020,9 @@ class DefaultAgentSession<
|
|
|
1267
1020
|
state = RunState.ENDED;
|
|
1268
1021
|
|
|
1269
1022
|
try {
|
|
1270
|
-
await
|
|
1271
|
-
|
|
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,
|
|
1023
|
+
await publishLifecycle('run-suspend', 'suspend', async () =>
|
|
1024
|
+
runManager.suspendRun(runId, invocationId, resolvedInputClientId, resolvedInputCodecMessageId),
|
|
1277
1025
|
);
|
|
1278
|
-
logger?.error('Run.suspend(); failed to publish run-suspend', { runId });
|
|
1279
|
-
throw errInfo;
|
|
1280
1026
|
} finally {
|
|
1281
1027
|
deregisterRun();
|
|
1282
1028
|
}
|
|
@@ -1285,7 +1031,9 @@ class DefaultAgentSession<
|
|
|
1285
1031
|
},
|
|
1286
1032
|
|
|
1287
1033
|
// Spec: AIT-ST7, AIT-ST7a, AIT-ST7b
|
|
1288
|
-
end: async (
|
|
1034
|
+
end: async (params: RunEndParams): Promise<void> => {
|
|
1035
|
+
const { reason } = params;
|
|
1036
|
+
const error = params.reason === 'error' ? params.error : undefined;
|
|
1289
1037
|
logger?.trace('Run.end();', { runId, reason });
|
|
1290
1038
|
|
|
1291
1039
|
await requireConnected('end');
|
|
@@ -1301,16 +1049,9 @@ class DefaultAgentSession<
|
|
|
1301
1049
|
state = RunState.ENDED;
|
|
1302
1050
|
|
|
1303
1051
|
try {
|
|
1304
|
-
await
|
|
1305
|
-
|
|
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,
|
|
1052
|
+
await publishLifecycle('run-end', 'end', async () =>
|
|
1053
|
+
runManager.endRun(runId, reason, invocationId, resolvedInputClientId, resolvedInputCodecMessageId, error),
|
|
1311
1054
|
);
|
|
1312
|
-
logger?.error('Run.end(); failed to publish run-end', { runId });
|
|
1313
|
-
throw errInfo;
|
|
1314
1055
|
} finally {
|
|
1315
1056
|
deregisterRun();
|
|
1316
1057
|
}
|