@ably/ai-transport 0.1.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 +93 -111
- package/dist/ably-ai-transport.js +2401 -1387
- 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 +116 -42
- package/dist/core/agent.d.ts +44 -0
- 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 +24 -24
- package/dist/core/codec/define-codec.d.ts +100 -0
- package/dist/core/codec/encoder.d.ts +10 -12
- 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 -2
- 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/lifecycle-tracker.d.ts +10 -9
- 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 +470 -119
- package/dist/core/codec/well-known-inputs.d.ts +52 -0
- package/dist/core/transport/agent-session.d.ts +10 -0
- package/dist/core/transport/agent-view.d.ts +296 -0
- package/dist/core/transport/client-session.d.ts +13 -0
- package/dist/core/transport/decode-fold.d.ts +55 -0
- package/dist/core/transport/headers.d.ts +121 -14
- package/dist/core/transport/index.d.ts +5 -6
- 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-history-pages.d.ts +71 -0
- package/dist/core/transport/load-history.d.ts +44 -0
- package/dist/core/transport/pipe-stream.d.ts +9 -9
- package/dist/core/transport/run-manager.d.ts +76 -0
- package/dist/core/transport/session-support.d.ts +55 -0
- package/dist/core/transport/tree.d.ts +523 -109
- package/dist/core/transport/types/agent.d.ts +375 -0
- package/dist/core/transport/types/client.d.ts +201 -0
- package/dist/core/transport/types/shared.d.ts +24 -0
- package/dist/core/transport/types/tree.d.ts +357 -0
- package/dist/core/transport/types/view.d.ts +249 -0
- package/dist/core/transport/types.d.ts +13 -553
- package/dist/core/transport/view.d.ts +390 -84
- package/dist/core/transport/wire-log.d.ts +102 -0
- package/dist/errors.d.ts +27 -10
- package/dist/index.d.ts +8 -9
- package/dist/logger.d.ts +12 -0
- package/dist/react/ably-ai-transport-react.js +1365 -1010
- 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 +37 -0
- package/dist/react/contexts/client-session-provider.d.ts +56 -0
- package/dist/react/create-session-hooks.d.ts +116 -0
- package/dist/react/index.d.ts +13 -12
- package/dist/react/internal/skipped-session.d.ts +8 -0
- package/dist/react/internal/use-resolved-session.d.ts +36 -0
- package/dist/react/use-ably-messages.d.ts +17 -14
- package/dist/react/use-client-session.d.ts +81 -0
- package/dist/react/use-create-view.d.ts +14 -13
- package/dist/react/use-tree.d.ts +30 -15
- package/dist/react/use-view.d.ts +81 -50
- package/dist/utils.d.ts +48 -71
- package/dist/vercel/ably-ai-transport-vercel.js +3257 -2499
- 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 +50 -0
- 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 +7 -20
- 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 +62 -0
- package/dist/vercel/codec/tool-transitions.d.ts +2 -8
- package/dist/vercel/codec/wire-data.d.ts +34 -0
- package/dist/vercel/index.d.ts +5 -5
- package/dist/vercel/react/ably-ai-transport-vercel-react.js +2859 -9705
- 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 -45
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
- package/dist/vercel/react/contexts/chat-transport-context.d.ts +9 -7
- package/dist/vercel/react/contexts/chat-transport-provider.d.ts +53 -41
- package/dist/vercel/react/index.d.ts +1 -2
- package/dist/vercel/react/use-chat-transport.d.ts +30 -26
- package/dist/vercel/react/use-message-sync.d.ts +17 -30
- package/dist/vercel/run-end-reason.d.ts +84 -0
- package/dist/vercel/tool-part.d.ts +21 -0
- package/dist/vercel/transport/chat-transport.d.ts +41 -24
- package/dist/vercel/transport/index.d.ts +24 -20
- package/dist/vercel/transport/run-output-stream.d.ts +54 -0
- package/dist/version.d.ts +2 -0
- package/package.json +31 -24
- package/src/constants.ts +124 -51
- package/src/core/agent.ts +92 -0
- package/src/core/channel-options.ts +89 -0
- package/src/core/codec/codec-event.ts +27 -0
- package/src/core/codec/decoder.ts +202 -105
- package/src/core/codec/define-codec.ts +432 -0
- package/src/core/codec/encoder.ts +114 -107
- package/src/core/codec/field-bag.ts +142 -0
- package/src/core/codec/fields.ts +193 -0
- package/src/core/codec/index.ts +56 -6
- 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/lifecycle-tracker.ts +10 -9
- 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 +505 -126
- package/src/core/codec/well-known-inputs.ts +96 -0
- package/src/core/transport/agent-session.ts +1085 -0
- package/src/core/transport/agent-view.ts +738 -0
- package/src/core/transport/client-session.ts +780 -0
- package/src/core/transport/decode-fold.ts +101 -0
- package/src/core/transport/headers.ts +234 -22
- package/src/core/transport/index.ts +27 -27
- package/src/core/transport/internal/bounded-map.ts +27 -0
- package/src/core/transport/invocation.ts +98 -0
- package/src/core/transport/load-history-pages.ts +220 -0
- package/src/core/transport/load-history.ts +271 -0
- package/src/core/transport/pipe-stream.ts +63 -39
- package/src/core/transport/run-manager.ts +243 -0
- package/src/core/transport/session-support.ts +96 -0
- package/src/core/transport/tree.ts +1293 -308
- package/src/core/transport/types/agent.ts +434 -0
- package/src/core/transport/types/client.ts +247 -0
- package/src/core/transport/types/shared.ts +27 -0
- package/src/core/transport/types/tree.ts +393 -0
- package/src/core/transport/types/view.ts +288 -0
- package/src/core/transport/types.ts +13 -706
- package/src/core/transport/view.ts +1229 -450
- package/src/core/transport/wire-log.ts +189 -0
- package/src/errors.ts +29 -9
- package/src/event-emitter.ts +3 -2
- package/src/index.ts +86 -42
- package/src/logger.ts +14 -1
- package/src/react/contexts/client-session-context.ts +41 -0
- package/src/react/contexts/client-session-provider.tsx +222 -0
- package/src/react/create-session-hooks.ts +141 -0
- package/src/react/index.ts +24 -13
- package/src/react/internal/skipped-session.ts +62 -0
- package/src/react/internal/use-resolved-session.ts +63 -0
- package/src/react/use-ably-messages.ts +32 -22
- package/src/react/use-client-session.ts +178 -0
- package/src/react/use-create-view.ts +33 -29
- package/src/react/use-tree.ts +61 -30
- package/src/react/use-view.ts +138 -96
- package/src/utils.ts +83 -131
- package/src/vercel/codec/decode-lifecycle.ts +70 -0
- package/src/vercel/codec/events.ts +85 -0
- 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 +28 -21
- 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 +191 -0
- package/src/vercel/codec/tool-transitions.ts +3 -14
- package/src/vercel/codec/wire-data.ts +64 -0
- package/src/vercel/index.ts +7 -19
- package/src/vercel/react/contexts/chat-transport-context.ts +8 -7
- package/src/vercel/react/contexts/chat-transport-provider.tsx +87 -59
- package/src/vercel/react/index.ts +3 -5
- package/src/vercel/react/use-chat-transport.ts +44 -66
- package/src/vercel/react/use-message-sync.ts +75 -39
- package/src/vercel/run-end-reason.ts +157 -0
- package/src/vercel/tool-part.ts +25 -0
- package/src/vercel/transport/chat-transport.ts +380 -98
- package/src/vercel/transport/index.ts +38 -37
- package/src/vercel/transport/run-output-stream.ts +169 -0
- package/src/version.ts +2 -0
- package/dist/core/transport/client-transport.d.ts +0 -10
- package/dist/core/transport/decode-history.d.ts +0 -43
- package/dist/core/transport/server-transport.d.ts +0 -7
- package/dist/core/transport/stream-router.d.ts +0 -29
- package/dist/core/transport/turn-manager.d.ts +0 -37
- package/dist/react/contexts/transport-context.d.ts +0 -31
- package/dist/react/contexts/transport-provider.d.ts +0 -49
- package/dist/react/create-transport-hooks.d.ts +0 -124
- package/dist/react/use-active-turns.d.ts +0 -12
- package/dist/react/use-client-transport.d.ts +0 -80
- package/dist/vercel/codec/accumulator.d.ts +0 -21
- package/dist/vercel/codec/decoder.d.ts +0 -22
- package/dist/vercel/codec/encoder.d.ts +0 -41
- package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +0 -30
- package/dist/vercel/tool-approvals.d.ts +0 -124
- package/dist/vercel/tool-events.d.ts +0 -26
- package/src/core/transport/client-transport.ts +0 -977
- package/src/core/transport/decode-history.ts +0 -485
- package/src/core/transport/server-transport.ts +0 -612
- package/src/core/transport/stream-router.ts +0 -136
- package/src/core/transport/turn-manager.ts +0 -165
- package/src/react/contexts/transport-context.ts +0 -37
- package/src/react/contexts/transport-provider.tsx +0 -164
- package/src/react/create-transport-hooks.ts +0 -144
- package/src/react/use-active-turns.ts +0 -72
- package/src/react/use-client-transport.ts +0 -197
- package/src/vercel/codec/accumulator.ts +0 -588
- package/src/vercel/codec/decoder.ts +0 -618
- package/src/vercel/codec/encoder.ts +0 -410
- package/src/vercel/react/use-staged-add-tool-approval-response.ts +0 -87
- package/src/vercel/tool-approvals.ts +0 -380
- package/src/vercel/tool-events.ts +0 -53
|
@@ -0,0 +1,1085 @@
|
|
|
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(), suspend(),
|
|
10
|
+
* and end() (suspend() and end() are both terminal).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as Ably from 'ably';
|
|
14
|
+
// Also augments RealtimeChannel with `.object` (ably/liveobjects side-effect).
|
|
15
|
+
import type * as AblyObjects from 'ably/liveobjects';
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
EVENT_CANCEL,
|
|
19
|
+
HEADER_CODEC_MESSAGE_ID,
|
|
20
|
+
HEADER_FORK_OF,
|
|
21
|
+
HEADER_INPUT_CODEC_MESSAGE_ID,
|
|
22
|
+
HEADER_MSG_REGENERATE,
|
|
23
|
+
HEADER_PARENT,
|
|
24
|
+
HEADER_RUN_CLIENT_ID,
|
|
25
|
+
HEADER_RUN_ID,
|
|
26
|
+
} from '../../constants.js';
|
|
27
|
+
import { ErrorCode } from '../../errors.js';
|
|
28
|
+
import { type Logger, LogLevel, makeLogger } from '../../logger.js';
|
|
29
|
+
import { errorCause, errorMessage, getTransportHeaders } from '../../utils.js';
|
|
30
|
+
import { registerAgent } from '../agent.js';
|
|
31
|
+
import { resolveChannelModes } from '../channel-options.js';
|
|
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';
|
|
35
|
+
import { buildTransportHeaders } from './headers.js';
|
|
36
|
+
import { evictOldestIfFull } from './internal/bounded-map.js';
|
|
37
|
+
import { Invocation } from './invocation.js';
|
|
38
|
+
import { pipeStream } from './pipe-stream.js';
|
|
39
|
+
import type { RunManager } from './run-manager.js';
|
|
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';
|
|
43
|
+
import type {
|
|
44
|
+
AgentSession,
|
|
45
|
+
AgentSessionOptions,
|
|
46
|
+
CancelRequest,
|
|
47
|
+
LoadConversationOptions,
|
|
48
|
+
PipeOptions,
|
|
49
|
+
Run,
|
|
50
|
+
RunEndParams,
|
|
51
|
+
RunRuntime,
|
|
52
|
+
RunView,
|
|
53
|
+
StreamResult,
|
|
54
|
+
Tree,
|
|
55
|
+
} from './types.js';
|
|
56
|
+
|
|
57
|
+
/**
|
|
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.
|
|
62
|
+
*/
|
|
63
|
+
const DEFERRED_CANCEL_LIMIT = 200;
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Internal run record for cancel routing
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
interface RegisteredRun {
|
|
70
|
+
runId: string;
|
|
71
|
+
/** Invocation-id this run is associated with, minted by the agent at `createRun` (or the `runtime.invocationId` override). */
|
|
72
|
+
invocationId: string;
|
|
73
|
+
controller: AbortController;
|
|
74
|
+
/** Composite signal that fires when either the internal controller or the external signal aborts. */
|
|
75
|
+
signal: AbortSignal;
|
|
76
|
+
onCancel?: (request: CancelRequest) => Promise<boolean>;
|
|
77
|
+
onError?: (error: Ably.ErrorInfo) => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Internal state machines
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
enum SessionState {
|
|
85
|
+
READY = 'ready',
|
|
86
|
+
CLOSED = 'closed',
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
enum RunState {
|
|
90
|
+
INITIALIZED = 'initialized',
|
|
91
|
+
STARTED = 'started',
|
|
92
|
+
ENDED = 'ended',
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Implementation
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
// Spec: AIT-ST1
|
|
100
|
+
class DefaultAgentSession<
|
|
101
|
+
TInput extends CodecInputEvent,
|
|
102
|
+
TOutput extends CodecOutputEvent,
|
|
103
|
+
TProjection,
|
|
104
|
+
TMessage,
|
|
105
|
+
> implements AgentSession<TOutput, TProjection, TMessage> {
|
|
106
|
+
private readonly _channel: Ably.RealtimeChannel;
|
|
107
|
+
private readonly _codec: Codec<TInput, TOutput, TProjection, TMessage>;
|
|
108
|
+
private readonly _logger: Logger | undefined;
|
|
109
|
+
private readonly _onError: ((error: Ably.ErrorInfo) => void) | undefined;
|
|
110
|
+
private readonly _runManager: RunManager;
|
|
111
|
+
private readonly _registeredRuns = new Map<string, RegisteredRun>();
|
|
112
|
+
/**
|
|
113
|
+
* Reverse index from a run's triggering input codec-message-id to its
|
|
114
|
+
* run-id, populated once `Run.start()`'s input-event lookup resolves the
|
|
115
|
+
* triggering input. Lets `_handleCancelMessage` route a cancel keyed by the
|
|
116
|
+
* input codec-message-id (a fresh send whose run-id the client doesn't know)
|
|
117
|
+
* to the registered run. Entries are removed when the run ends / suspends /
|
|
118
|
+
* the session closes, alongside `_registeredRuns`.
|
|
119
|
+
*/
|
|
120
|
+
private readonly _runIdByInputCodecMessageId = new Map<string, string>();
|
|
121
|
+
/**
|
|
122
|
+
* Cancels buffered by triggering input codec-message-id when they arrived
|
|
123
|
+
* before the run was known — i.e. before `Run.start()`'s input-event lookup
|
|
124
|
+
* resolved that input to a run. A fresh run has no run-id at the client's
|
|
125
|
+
* send time (the agent mints it at run-start), so an early cancel can only be
|
|
126
|
+
* keyed by the input codec-message-id, and the `inputCodecMessageId → run`
|
|
127
|
+
* linkage doesn't exist until the lookup completes. `Run.start()` consults
|
|
128
|
+
* this buffer as a PULL once it resolves its `resolvedInputCodecMessageId`,
|
|
129
|
+
* honouring any cancel that arrived first. Cleared on `close()`.
|
|
130
|
+
*/
|
|
131
|
+
private readonly _deferredCancels = new Map<string, Ably.InboundMessage>();
|
|
132
|
+
/**
|
|
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.
|
|
149
|
+
*/
|
|
150
|
+
private _applier: WireApplier;
|
|
151
|
+
/**
|
|
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.
|
|
156
|
+
*/
|
|
157
|
+
private _agentView: AgentView<TInput, TOutput, TProjection, TMessage>;
|
|
158
|
+
private readonly _channelListener: (msg: Ably.InboundMessage) => void;
|
|
159
|
+
private readonly _inputEventLookupTimeoutMs: number;
|
|
160
|
+
/** Lookback bound passed to the AgentView's input-event scan (see {@link _createAgentView}). */
|
|
161
|
+
private readonly _inputEventLookbackMs: number;
|
|
162
|
+
|
|
163
|
+
private _state = SessionState.READY;
|
|
164
|
+
private _connectPromise: Promise<void> | undefined;
|
|
165
|
+
private _hasAttachedOnce: boolean;
|
|
166
|
+
private readonly _onChannelStateChange: Ably.channelEventCallback;
|
|
167
|
+
|
|
168
|
+
constructor(options: AgentSessionOptions<TInput, TOutput, TProjection, TMessage>) {
|
|
169
|
+
this._codec = options.codec;
|
|
170
|
+
// Spec: AIT-ST1a, AIT-ST1a2 — register this SDK on both the connection
|
|
171
|
+
// (options.agents) and channel-attach (params.agent) paths. Idempotent
|
|
172
|
+
// across sessions sharing one client.
|
|
173
|
+
const registerOptions = registerAgent(options.client, options.codec);
|
|
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;
|
|
178
|
+
this._channel = options.client.channels.get(options.channelName, channelOptions);
|
|
179
|
+
this._logger = options.logger?.withContext({ component: 'AgentSession' });
|
|
180
|
+
this._onError = options.onError;
|
|
181
|
+
this._runManager = createRunManager(this._channel, this._logger);
|
|
182
|
+
this._inputEventLookupTimeoutMs = options.inputEventLookupTimeoutMs ?? 30000;
|
|
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();
|
|
190
|
+
|
|
191
|
+
this._channelListener = (msg: Ably.InboundMessage) => {
|
|
192
|
+
this._handleChannelMessage(msg);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// Spec: AIT-ST12, AIT-ST12a
|
|
196
|
+
// Listen for channel state changes that break message continuity. The
|
|
197
|
+
// session only consumes cancel messages from the channel, so losing one
|
|
198
|
+
// is survivable — but the developer needs to know so they can decide
|
|
199
|
+
// whether to cancel in-flight work. _hasAttachedOnce is seeded from the
|
|
200
|
+
// channel's current state so pre-attached channels are handled correctly;
|
|
201
|
+
// it distinguishes the initial attach from a genuine discontinuity.
|
|
202
|
+
this._hasAttachedOnce = this._channel.state === 'attached';
|
|
203
|
+
this._onChannelStateChange = (stateChange: Ably.ChannelStateChange) => {
|
|
204
|
+
this._handleChannelStateChange(stateChange);
|
|
205
|
+
};
|
|
206
|
+
this._channel.on(this._onChannelStateChange);
|
|
207
|
+
|
|
208
|
+
this._logger?.debug('DefaultAgentSession(); session created');
|
|
209
|
+
}
|
|
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
|
+
|
|
243
|
+
// -------------------------------------------------------------------------
|
|
244
|
+
// Public API
|
|
245
|
+
// -------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
// Spec: AIT-ST2
|
|
248
|
+
// eslint-disable-next-line @typescript-eslint/promise-function-async -- preserve reference equality across calls
|
|
249
|
+
connect(): Promise<void> {
|
|
250
|
+
if (this._state === SessionState.CLOSED) {
|
|
251
|
+
return Promise.reject(new Ably.ErrorInfo('unable to connect; session is closed', ErrorCode.SessionClosed, 400));
|
|
252
|
+
}
|
|
253
|
+
if (this._connectPromise) return this._connectPromise;
|
|
254
|
+
|
|
255
|
+
this._logger?.trace('DefaultAgentSession.connect();');
|
|
256
|
+
// Subscribe unfiltered (before attach, per RTL7g — subscribe implicitly
|
|
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).
|
|
260
|
+
this._connectPromise = this._channel.subscribe(this._channelListener).then(
|
|
261
|
+
() => {
|
|
262
|
+
this._logger?.debug('DefaultAgentSession.connect(); subscribed and attached');
|
|
263
|
+
},
|
|
264
|
+
(error: unknown) => {
|
|
265
|
+
const errInfo = new Ably.ErrorInfo(
|
|
266
|
+
`unable to subscribe to channel; ${errorMessage(error)}`,
|
|
267
|
+
ErrorCode.SessionSubscriptionError,
|
|
268
|
+
500,
|
|
269
|
+
errorCause(error),
|
|
270
|
+
);
|
|
271
|
+
this._logger?.error('DefaultAgentSession.connect(); subscribe failed');
|
|
272
|
+
this._onError?.(errInfo);
|
|
273
|
+
throw errInfo;
|
|
274
|
+
},
|
|
275
|
+
);
|
|
276
|
+
return this._connectPromise;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The session-owned materialisation tree. Mirrors `ClientSession.tree`
|
|
281
|
+
* for observability and parity.
|
|
282
|
+
* @returns The session's Tree.
|
|
283
|
+
*/
|
|
284
|
+
get tree(): Tree<TOutput, TProjection> {
|
|
285
|
+
return this._tree;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Spec: AIT-ST3
|
|
289
|
+
createRun(invocation: Invocation, runtime?: RunRuntime<TOutput>): Run<TOutput, TProjection, TMessage> {
|
|
290
|
+
this._logger?.trace('DefaultAgentSession.createRun();', { inputEventId: invocation.inputEventId });
|
|
291
|
+
return this._createRun(invocation, runtime ?? {});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Spec: AIT-ST11
|
|
295
|
+
async close(): Promise<void> {
|
|
296
|
+
if (this._state === SessionState.CLOSED) return;
|
|
297
|
+
this._state = SessionState.CLOSED;
|
|
298
|
+
this._logger?.trace('DefaultAgentSession.close();');
|
|
299
|
+
if (this._connectPromise) {
|
|
300
|
+
this._channel.unsubscribe(this._channelListener);
|
|
301
|
+
}
|
|
302
|
+
this._channel.off(this._onChannelStateChange);
|
|
303
|
+
for (const reg of this._registeredRuns.values()) {
|
|
304
|
+
reg.controller.abort();
|
|
305
|
+
}
|
|
306
|
+
this._registeredRuns.clear();
|
|
307
|
+
this._runIdByInputCodecMessageId.clear();
|
|
308
|
+
this._deferredCancels.clear();
|
|
309
|
+
this._runManager.close();
|
|
310
|
+
|
|
311
|
+
await bestEffortDetach(this._channel, this._connectPromise, this._logger, 'DefaultAgentSession');
|
|
312
|
+
|
|
313
|
+
this._logger?.debug('DefaultAgentSession.close(); session closed');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// -------------------------------------------------------------------------
|
|
317
|
+
// Cancel message routing
|
|
318
|
+
// -------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
private async _handleCancelMessage(msg: Ably.InboundMessage): Promise<void> {
|
|
321
|
+
const headers = getTransportHeaders(msg);
|
|
322
|
+
const runId = headers[HEADER_RUN_ID];
|
|
323
|
+
const inputCodecMessageId = headers[HEADER_INPUT_CODEC_MESSAGE_ID];
|
|
324
|
+
|
|
325
|
+
// Malformed cancel: drop with warn. A cancel must identify its target by
|
|
326
|
+
// `run-id` (a continuation, whose run-id the client knows) and/or by
|
|
327
|
+
// `input-codec-message-id` (a fresh send, before the agent minted the
|
|
328
|
+
// run-id). Neither present means there is nothing to route to.
|
|
329
|
+
if (!runId && !inputCodecMessageId) {
|
|
330
|
+
this._logger?.warn('DefaultAgentSession._handleCancelMessage(); missing run-id and input-codec-message-id', {
|
|
331
|
+
serial: msg.serial,
|
|
332
|
+
});
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Primary path — match by run-id (continuations, whose run-id the client
|
|
337
|
+
// already knows). Resolve the input-codec-message-id to a run-id when the
|
|
338
|
+
// run-id wasn't supplied (a fresh-send cancel that arrived after the run's
|
|
339
|
+
// input-event lookup resolved, so the linkage already exists).
|
|
340
|
+
const resolvedRunId =
|
|
341
|
+
runId ?? (inputCodecMessageId ? this._runIdByInputCodecMessageId.get(inputCodecMessageId) : undefined);
|
|
342
|
+
const reg = resolvedRunId ? this._registeredRuns.get(resolvedRunId) : undefined;
|
|
343
|
+
|
|
344
|
+
if (!reg) {
|
|
345
|
+
// The run isn't known yet. A fresh-send cancel can race ahead of the
|
|
346
|
+
// run's input-event lookup (which is what establishes the
|
|
347
|
+
// input-codec-message-id → run linkage). Buffer it by
|
|
348
|
+
// input-codec-message-id so `Run.start()` can pull and honour it once it
|
|
349
|
+
// resolves the triggering input. A bare run-id cancel for an unknown run
|
|
350
|
+
// is a no-op (the run never existed here, or already ended).
|
|
351
|
+
if (inputCodecMessageId !== undefined) {
|
|
352
|
+
this._bufferDeferredCancel(inputCodecMessageId, msg);
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
await this._cancelRegistration(reg, msg);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Buffer a cancel that arrived before its target run was known, keyed by the
|
|
362
|
+
* triggering input's codec-message-id. FIFO-evicts the oldest entry at
|
|
363
|
+
* {@link DEFERRED_CANCEL_LIMIT}. A later cancel for the same input replaces the earlier
|
|
364
|
+
* one — the intent is identical.
|
|
365
|
+
* @param inputCodecMessageId - The triggering input's codec-message-id.
|
|
366
|
+
* @param msg - The raw cancel message (passed to `onCancel`).
|
|
367
|
+
*/
|
|
368
|
+
private _bufferDeferredCancel(inputCodecMessageId: string, msg: Ably.InboundMessage): void {
|
|
369
|
+
const evicted = evictOldestIfFull(this._deferredCancels, inputCodecMessageId, DEFERRED_CANCEL_LIMIT);
|
|
370
|
+
if (evicted !== undefined) {
|
|
371
|
+
this._logger?.warn('DefaultAgentSession._bufferDeferredCancel(); deferred-cancel buffer full, dropping oldest', {
|
|
372
|
+
evictedInputCodecMessageId: evicted,
|
|
373
|
+
limit: DEFERRED_CANCEL_LIMIT,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
this._deferredCancels.set(inputCodecMessageId, msg);
|
|
377
|
+
this._logger?.debug('DefaultAgentSession._bufferDeferredCancel(); buffered early cancel', {
|
|
378
|
+
inputCodecMessageId,
|
|
379
|
+
serial: msg.serial,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Pull and honour a cancel buffered before this run was known. Called from
|
|
385
|
+
* `Run.start()` once the input-event lookup resolves the run's triggering
|
|
386
|
+
* input codec-message-id — the point at which the
|
|
387
|
+
* `input-codec-message-id → run` linkage first exists. No-op when no cancel
|
|
388
|
+
* was buffered for that input.
|
|
389
|
+
* @param reg - The now-known run registration.
|
|
390
|
+
* @param inputCodecMessageId - The run's resolved triggering input codec-message-id.
|
|
391
|
+
*/
|
|
392
|
+
private async _pullDeferredCancel(reg: RegisteredRun, inputCodecMessageId: string): Promise<void> {
|
|
393
|
+
const buffered = this._deferredCancels.get(inputCodecMessageId);
|
|
394
|
+
if (buffered === undefined) return;
|
|
395
|
+
this._deferredCancels.delete(inputCodecMessageId);
|
|
396
|
+
this._logger?.debug('DefaultAgentSession._pullDeferredCancel(); honouring buffered cancel', {
|
|
397
|
+
runId: reg.runId,
|
|
398
|
+
inputCodecMessageId,
|
|
399
|
+
});
|
|
400
|
+
await this._cancelRegistration(reg, buffered);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Fire a cancel against a known run: consult its `onCancel` authorization
|
|
405
|
+
* hook (if any), then abort the run's controller. Shared by the run-id match,
|
|
406
|
+
* the input-codec-message-id match, and the buffered-cancel pull so all three
|
|
407
|
+
* honour `onCancel` and surface handler errors identically.
|
|
408
|
+
* @param reg - The target run registration.
|
|
409
|
+
* @param msg - The raw cancel message (passed to `onCancel`).
|
|
410
|
+
*/
|
|
411
|
+
private async _cancelRegistration(reg: RegisteredRun, msg: Ably.InboundMessage): Promise<void> {
|
|
412
|
+
const { runId } = reg;
|
|
413
|
+
this._logger?.debug('DefaultAgentSession._cancelRegistration(); matched run', { runId });
|
|
414
|
+
|
|
415
|
+
const request: CancelRequest = { message: msg, runId };
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
if (reg.onCancel) {
|
|
419
|
+
const allowed = await reg.onCancel(request);
|
|
420
|
+
if (!allowed) {
|
|
421
|
+
this._logger?.debug('DefaultAgentSession._cancelRegistration(); cancel rejected by onCancel', {
|
|
422
|
+
runId,
|
|
423
|
+
});
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
reg.controller.abort();
|
|
428
|
+
this._logger?.debug('DefaultAgentSession._cancelRegistration(); run cancelled', { runId });
|
|
429
|
+
} catch (error) {
|
|
430
|
+
const errInfo = new Ably.ErrorInfo(
|
|
431
|
+
`unable to process cancel for run ${runId}; onCancel handler threw: ${errorMessage(error)}`,
|
|
432
|
+
ErrorCode.CancelListenerError,
|
|
433
|
+
500,
|
|
434
|
+
errorCause(error),
|
|
435
|
+
);
|
|
436
|
+
this._logger?.error('DefaultAgentSession._cancelRegistration(); onCancel threw', { runId });
|
|
437
|
+
(reg.onError ?? this._onError)?.(errInfo);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// -------------------------------------------------------------------------
|
|
442
|
+
// Channel state change handler
|
|
443
|
+
// -------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
// Spec: AIT-ST12, AIT-ST12a
|
|
446
|
+
private _handleChannelStateChange(stateChange: Ably.ChannelStateChange): void {
|
|
447
|
+
if (this._state === SessionState.CLOSED) return;
|
|
448
|
+
|
|
449
|
+
const { current, resumed } = stateChange;
|
|
450
|
+
|
|
451
|
+
// Track the initial attach so we don't treat it as a discontinuity.
|
|
452
|
+
if (current === 'attached' && !this._hasAttachedOnce) {
|
|
453
|
+
this._hasAttachedOnce = true;
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (!isContinuityLost(stateChange)) return;
|
|
458
|
+
|
|
459
|
+
this._logger?.error('DefaultAgentSession._handleChannelStateChange(); channel continuity lost', {
|
|
460
|
+
current,
|
|
461
|
+
resumed,
|
|
462
|
+
previous: stateChange.previous,
|
|
463
|
+
});
|
|
464
|
+
|
|
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 }),
|
|
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();
|
|
487
|
+
|
|
488
|
+
// Session-level notification: continuity loss is not scoped to any one
|
|
489
|
+
// run. Per-run onError handlers are reserved for errors from that run's
|
|
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);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// -------------------------------------------------------------------------
|
|
521
|
+
// Channel subscription handler
|
|
522
|
+
// -------------------------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
private _handleChannelMessage(msg: Ably.InboundMessage): void {
|
|
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
|
+
|
|
531
|
+
if (msg.name === EVENT_CANCEL) {
|
|
532
|
+
// Fire-and-forget async handler — errors are caught internally.
|
|
533
|
+
this._handleCancelMessage(msg).catch((error: unknown) => {
|
|
534
|
+
const errInfo = new Ably.ErrorInfo(
|
|
535
|
+
`unable to route cancel message; ${errorMessage(error)}`,
|
|
536
|
+
ErrorCode.CancelListenerError,
|
|
537
|
+
500,
|
|
538
|
+
errorCause(error),
|
|
539
|
+
);
|
|
540
|
+
this._logger?.error('DefaultAgentSession._handleChannelMessage(); cancel routing error');
|
|
541
|
+
this._onError?.(errInfo);
|
|
542
|
+
});
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
} catch (error) {
|
|
546
|
+
const errInfo = new Ably.ErrorInfo(
|
|
547
|
+
`unable to process channel message; ${errorMessage(error)}`,
|
|
548
|
+
ErrorCode.SessionSubscriptionError,
|
|
549
|
+
500,
|
|
550
|
+
errorCause(error),
|
|
551
|
+
);
|
|
552
|
+
this._logger?.error('DefaultAgentSession._handleChannelMessage(); subscription error');
|
|
553
|
+
this._onError?.(errInfo);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// -------------------------------------------------------------------------
|
|
558
|
+
// Connection guard
|
|
559
|
+
// -------------------------------------------------------------------------
|
|
560
|
+
|
|
561
|
+
private async _requireConnected(method: string): Promise<void> {
|
|
562
|
+
return requireConnected(this._connectPromise, method);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// -------------------------------------------------------------------------
|
|
566
|
+
// Run creation
|
|
567
|
+
// -------------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
private _createRun(invocation: Invocation, runtime: RunRuntime<TOutput>): Run<TOutput, TProjection, TMessage> {
|
|
570
|
+
// The run-id is not carried in the invocation body — the agent mints it.
|
|
571
|
+
// Mint a provisional id now (or take the `runtime.runId` override for
|
|
572
|
+
// tests / in-process drivers) — this IS the id for a fresh run. A
|
|
573
|
+
// continuation overrides it in `Run.start()` with the existing run-id read
|
|
574
|
+
// off the triggering input event's message headers (the run it re-enters).
|
|
575
|
+
// Mirrors the invocationId mint below.
|
|
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;
|
|
583
|
+
// The agent mints the invocation id — one per HTTP request that invokes
|
|
584
|
+
// it. A per-run override (runtime.invocationId) supports deterministic ids
|
|
585
|
+
// in tests and in-process drivers.
|
|
586
|
+
const invocationId = runtime.invocationId ?? crypto.randomUUID();
|
|
587
|
+
const inputEventLookupTimeoutMs = this._inputEventLookupTimeoutMs;
|
|
588
|
+
const { onMessage, onCancelled, onCancel, onError: runOnError, signal: externalSignal } = runtime;
|
|
589
|
+
|
|
590
|
+
const controller = new AbortController();
|
|
591
|
+
let state = RunState.INITIALIZED;
|
|
592
|
+
|
|
593
|
+
// Compose the internal controller signal with the external signal (e.g.
|
|
594
|
+
// req.signal) so platform-level cancellation (request cancellation, function
|
|
595
|
+
// timeout) cancels the run through the same path as Ably cancel messages.
|
|
596
|
+
const signal = externalSignal ? AbortSignal.any([controller.signal, externalSignal]) : controller.signal;
|
|
597
|
+
|
|
598
|
+
// Spec: AIT-ST3a — register immediately so `close()` aborts an in-flight
|
|
599
|
+
// start() and a post-lookup cancel can fire the AbortSignal. Keyed by the
|
|
600
|
+
// provisional run-id; a continuation re-keys to the real id in start()
|
|
601
|
+
// once the triggering input reveals it.
|
|
602
|
+
const registration: RegisteredRun = {
|
|
603
|
+
runId,
|
|
604
|
+
invocationId,
|
|
605
|
+
controller,
|
|
606
|
+
signal,
|
|
607
|
+
onCancel,
|
|
608
|
+
onError: runOnError,
|
|
609
|
+
};
|
|
610
|
+
this._registeredRuns.set(runId, registration);
|
|
611
|
+
|
|
612
|
+
// Capture instance members as locals so arrow functions close over them
|
|
613
|
+
// without needing `this` (avoids unicorn/no-this-assignment).
|
|
614
|
+
const logger = this._logger;
|
|
615
|
+
const runManager = this._runManager;
|
|
616
|
+
const codec = this._codec;
|
|
617
|
+
const channel = this._channel;
|
|
618
|
+
const registeredRuns = this._registeredRuns;
|
|
619
|
+
const runIdByInputCodecMessageId = this._runIdByInputCodecMessageId;
|
|
620
|
+
const deferredCancels = this._deferredCancels;
|
|
621
|
+
const requireConnected = this._requireConnected.bind(this);
|
|
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;
|
|
625
|
+
const pullDeferredCancel = this._pullDeferredCancel.bind(this);
|
|
626
|
+
const inputEventId = invocation.inputEventId;
|
|
627
|
+
|
|
628
|
+
// Per-run metadata resolved from the input-event lookup result. The first
|
|
629
|
+
// matched message message's headers carry the run's `clientId`, `parent`, and
|
|
630
|
+
// `forkOf`, and — for a continuation — the `run-id` it re-enters (a fresh
|
|
631
|
+
// input carries none; the client stamps a run-id only when re-entering a
|
|
632
|
+
// run it already knows). Its Ably-level publisher `clientId` becomes the
|
|
633
|
+
// `inputClientId` re-stamped on the agent's own publishes.
|
|
634
|
+
let resolvedClientId: string | undefined;
|
|
635
|
+
let resolvedInputClientId: string | undefined;
|
|
636
|
+
let resolvedParent: string | undefined;
|
|
637
|
+
let resolvedForkOf: string | undefined;
|
|
638
|
+
let resolvedRegenerates: string | undefined;
|
|
639
|
+
let resolvedInputCodecMessageId: string | undefined;
|
|
640
|
+
let resolvedContinuation = false;
|
|
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
|
+
};
|
|
674
|
+
/**
|
|
675
|
+
* The reply run's structural-parent fallback, computed once in
|
|
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`.
|
|
681
|
+
*/
|
|
682
|
+
let assistantParentFallback: string | undefined;
|
|
683
|
+
/**
|
|
684
|
+
* Remove this run from the session's routing maps. Drops the
|
|
685
|
+
* `_registeredRuns` entry plus the `input-codec-message-id → run-id`
|
|
686
|
+
* reverse index (and any stale deferred cancel still buffered for that
|
|
687
|
+
* input), keeping the cancel-routing state consistent when the run ends,
|
|
688
|
+
* suspends, or its start fails.
|
|
689
|
+
*/
|
|
690
|
+
const deregisterRun = (): void => {
|
|
691
|
+
registeredRuns.delete(runId);
|
|
692
|
+
if (resolvedInputCodecMessageId !== undefined) {
|
|
693
|
+
runIdByInputCodecMessageId.delete(resolvedInputCodecMessageId);
|
|
694
|
+
deferredCancels.delete(resolvedInputCodecMessageId);
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
|
|
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
|
+
};
|
|
725
|
+
|
|
726
|
+
const run: Run<TOutput, TProjection, TMessage> = {
|
|
727
|
+
get runId() {
|
|
728
|
+
return runId;
|
|
729
|
+
},
|
|
730
|
+
get invocationId() {
|
|
731
|
+
return invocationId;
|
|
732
|
+
},
|
|
733
|
+
get abortSignal() {
|
|
734
|
+
return signal;
|
|
735
|
+
},
|
|
736
|
+
get view() {
|
|
737
|
+
return view;
|
|
738
|
+
},
|
|
739
|
+
get messages() {
|
|
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);
|
|
752
|
+
},
|
|
753
|
+
|
|
754
|
+
// Spec: AIT-ST4, AIT-ST4a, AIT-ST4b
|
|
755
|
+
start: async (): Promise<void> => {
|
|
756
|
+
logger?.trace('Run.start();', { runId, inputEventId });
|
|
757
|
+
|
|
758
|
+
await requireConnected('start');
|
|
759
|
+
|
|
760
|
+
// Spec: AIT-ST4a
|
|
761
|
+
if (signal.aborted) {
|
|
762
|
+
throw new Ably.ErrorInfo(
|
|
763
|
+
`unable to start run; run ${runId} was cancelled before start()`,
|
|
764
|
+
ErrorCode.InvalidArgument,
|
|
765
|
+
400,
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
if (state !== RunState.INITIALIZED) return;
|
|
769
|
+
state = RunState.STARTED;
|
|
770
|
+
|
|
771
|
+
// Look up the triggering input event on the channel so the agent
|
|
772
|
+
// can read the user's message and per-run metadata (parent, forkOf,
|
|
773
|
+
// continuation flag) before publishing run-start. Skip when
|
|
774
|
+
// inputEventLookupTimeoutMs === 0 (tests and in-process drivers) or
|
|
775
|
+
// when no inputEventId is set (invocation requires no channel lookup).
|
|
776
|
+
if (inputEventId && inputEventLookupTimeoutMs > 0) {
|
|
777
|
+
try {
|
|
778
|
+
const found = await getAgentView().findInputEvent({
|
|
779
|
+
invocationId,
|
|
780
|
+
runId,
|
|
781
|
+
expectedEventIds: [inputEventId],
|
|
782
|
+
timeoutMs: inputEventLookupTimeoutMs,
|
|
783
|
+
signal,
|
|
784
|
+
});
|
|
785
|
+
if (found.firstHeaders !== undefined) firstLookupHeaders = found.firstHeaders;
|
|
786
|
+
if (found.firstClientId !== undefined) resolvedInputClientId = found.firstClientId;
|
|
787
|
+
} catch (error) {
|
|
788
|
+
const errInfo =
|
|
789
|
+
error instanceof Ably.ErrorInfo
|
|
790
|
+
? error
|
|
791
|
+
: new Ably.ErrorInfo(
|
|
792
|
+
`unable to look up input event; ${errorMessage(error)}`,
|
|
793
|
+
ErrorCode.InputEventNotFound,
|
|
794
|
+
504,
|
|
795
|
+
);
|
|
796
|
+
// The rejection bubbles up to the developer's HTTP handler,
|
|
797
|
+
// which surfaces the failure as a non-2xx response — that is
|
|
798
|
+
// the signal the client sees. No channel publish: an
|
|
799
|
+
// `ai-run-end` without a preceding `ai-run-start` would break
|
|
800
|
+
// the lifecycle invariant for other channel observers.
|
|
801
|
+
deregisterRun();
|
|
802
|
+
logger?.error('Run.start(); input-event lookup failed', { runId, invocationId });
|
|
803
|
+
throw errInfo;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Resolve per-run metadata from the first matched message message's
|
|
808
|
+
// headers — they carry `clientId`, `parent`, and `forkOf`.
|
|
809
|
+
// Continuations of a suspended run pick up the suspended assistant's
|
|
810
|
+
// parent in the same headers (the continuation message parents off
|
|
811
|
+
// the assistant). A `run-id` on the triggering input marks a
|
|
812
|
+
// continuation (re-entry via `ai-run-resume`); a fresh input carries
|
|
813
|
+
// none and opens the run with `ai-run-start`.
|
|
814
|
+
const sourceHeaders = firstLookupHeaders;
|
|
815
|
+
if (sourceHeaders) {
|
|
816
|
+
resolvedClientId = sourceHeaders[HEADER_RUN_CLIENT_ID];
|
|
817
|
+
resolvedParent = sourceHeaders[HEADER_PARENT];
|
|
818
|
+
resolvedForkOf = sourceHeaders[HEADER_FORK_OF];
|
|
819
|
+
resolvedRegenerates = sourceHeaders[HEADER_MSG_REGENERATE];
|
|
820
|
+
resolvedInputCodecMessageId = sourceHeaders[HEADER_CODEC_MESSAGE_ID];
|
|
821
|
+
|
|
822
|
+
// The triggering input's run-id (if any) IS this run's identity.
|
|
823
|
+
// Present → a continuation re-entering that run: adopt the id,
|
|
824
|
+
// overriding the provisional one minted at construction, and re-key
|
|
825
|
+
// the registration so cancel routing / deregistration resolve to the
|
|
826
|
+
// real run. Absent → a fresh run: the provisional id stands and the
|
|
827
|
+
// run opens with run-start.
|
|
828
|
+
const wireRunId = sourceHeaders[HEADER_RUN_ID];
|
|
829
|
+
resolvedContinuation = wireRunId !== undefined;
|
|
830
|
+
if (wireRunId !== undefined && wireRunId !== runId) {
|
|
831
|
+
registeredRuns.delete(runId);
|
|
832
|
+
runId = wireRunId;
|
|
833
|
+
registration.runId = runId;
|
|
834
|
+
registeredRuns.set(runId, registration);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
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;
|
|
849
|
+
|
|
850
|
+
// The triggering input's codec-message-id is now resolved, so the
|
|
851
|
+
// `input-codec-message-id → run` linkage exists: index it for live
|
|
852
|
+
// cancels and pull any cancel that arrived before the run was known
|
|
853
|
+
// (a fresh-send cancel published before the agent minted this run-id).
|
|
854
|
+
// Honouring it here may abort the controller before run-start; that is
|
|
855
|
+
// fine — the abort propagates through the same signal a normal cancel
|
|
856
|
+
// would use.
|
|
857
|
+
if (resolvedInputCodecMessageId !== undefined) {
|
|
858
|
+
runIdByInputCodecMessageId.set(resolvedInputCodecMessageId, runId);
|
|
859
|
+
await pullDeferredCancel(registration, resolvedInputCodecMessageId);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
await publishLifecycle('run-start', 'start', async () =>
|
|
863
|
+
runManager.startRun(runId, resolvedClientId, controller, {
|
|
864
|
+
// Stamp the reply run's STRUCTURAL parent (its input node, M_user) —
|
|
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
|
|
867
|
+
// creation paths agree regardless of arrival order. Valid only now
|
|
868
|
+
// that M_user is a separate input node (the two-node flip).
|
|
869
|
+
parent: assistantParentFallback,
|
|
870
|
+
forkOf: resolvedForkOf,
|
|
871
|
+
regenerates: resolvedRegenerates,
|
|
872
|
+
invocationId,
|
|
873
|
+
inputClientId: resolvedInputClientId,
|
|
874
|
+
inputCodecMessageId: resolvedInputCodecMessageId,
|
|
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 }),
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
logger?.debug('Run.start(); run started', { runId, inputEventId });
|
|
901
|
+
},
|
|
902
|
+
|
|
903
|
+
loadConversation: async (options?: LoadConversationOptions): Promise<TMessage[]> => {
|
|
904
|
+
logger?.trace('Run.loadConversation();', { runId });
|
|
905
|
+
await requireConnected('loadConversation');
|
|
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(
|
|
911
|
+
runId,
|
|
912
|
+
assistantParentFallback,
|
|
913
|
+
signal,
|
|
914
|
+
options?.maxRuns,
|
|
915
|
+
runIdOverridden || resolvedContinuation,
|
|
916
|
+
resolvedRegenerates,
|
|
917
|
+
);
|
|
918
|
+
return messages;
|
|
919
|
+
},
|
|
920
|
+
|
|
921
|
+
// Spec: AIT-ST6, AIT-ST6a, AIT-ST6b, AIT-ST6b1, AIT-ST6b2, AIT-ST6b3, AIT-ST6c
|
|
922
|
+
pipe: async (stream: ReadableStream<TOutput>, streamOpts?: PipeOptions<TOutput>): Promise<StreamResult> => {
|
|
923
|
+
logger?.trace('Run.pipe();', { runId });
|
|
924
|
+
|
|
925
|
+
await requireConnected('pipe');
|
|
926
|
+
|
|
927
|
+
if (state === RunState.INITIALIZED) {
|
|
928
|
+
throw new Ably.ErrorInfo(
|
|
929
|
+
`unable to pipe stream; start() must be called before pipe() (run ${runId})`,
|
|
930
|
+
ErrorCode.InvalidArgument,
|
|
931
|
+
400,
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const runOwnerClientId = runManager.getClientId(runId);
|
|
936
|
+
|
|
937
|
+
// The assistant message's parent: an explicit per-stream
|
|
938
|
+
// `streamOpts.parent` from the caller, else the reply run's
|
|
939
|
+
// structural-parent fallback computed once at run-start
|
|
940
|
+
// (`assistantParentFallback` — the triggering user message, or the
|
|
941
|
+
// input message's own parent for regenerate messages that produced no
|
|
942
|
+
// MessageNodes). Owning the default here means agent routes don't have
|
|
943
|
+
// to pass `{ parent: lastUserCodecMessageId }` to keep tree threading
|
|
944
|
+
// correct; edit-then-regenerate sibling resolution relies on the
|
|
945
|
+
// user→assistant chain being explicit.
|
|
946
|
+
const assistantParent = streamOpts?.parent ?? assistantParentFallback;
|
|
947
|
+
const assistantForkOf = streamOpts?.forkOf ?? resolvedForkOf;
|
|
948
|
+
// Echo `msg-regenerate` on the assistant message so that a
|
|
949
|
+
// client receiving the assistant chunk before `ai-run-start`
|
|
950
|
+
// (e.g. via history pagination across a page boundary, or a lost
|
|
951
|
+
// lifecycle publish) can still populate `RunNode.regeneratesCodecMessageId`
|
|
952
|
+
// when creating the Run from headers. Mirrors the symmetric
|
|
953
|
+
// behaviour for `assistantForkOf` on edit runs.
|
|
954
|
+
const assistantRegenerates = resolvedRegenerates;
|
|
955
|
+
|
|
956
|
+
const codecMessageId = crypto.randomUUID();
|
|
957
|
+
const defaultHeaders = buildTransportHeaders({
|
|
958
|
+
role: 'assistant',
|
|
959
|
+
runId,
|
|
960
|
+
codecMessageId,
|
|
961
|
+
runClientId: runOwnerClientId,
|
|
962
|
+
parent: assistantParent,
|
|
963
|
+
forkOf: assistantForkOf,
|
|
964
|
+
invocationId,
|
|
965
|
+
inputClientId: resolvedInputClientId,
|
|
966
|
+
inputCodecMessageId: resolvedInputCodecMessageId,
|
|
967
|
+
regenerates: assistantRegenerates,
|
|
968
|
+
});
|
|
969
|
+
const encoder = codec.createEncoder(channel, {
|
|
970
|
+
extras: { headers: defaultHeaders },
|
|
971
|
+
onMessage,
|
|
972
|
+
messageId: codecMessageId,
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
const result = await pipeStream(stream, encoder, signal, onCancelled, streamOpts?.resolveWriteOptions, logger);
|
|
976
|
+
|
|
977
|
+
if (result.error) {
|
|
978
|
+
const errInfo = new Ably.ErrorInfo(
|
|
979
|
+
`unable to pipe response for run ${runId}; ${result.error.message}`,
|
|
980
|
+
ErrorCode.StreamError,
|
|
981
|
+
500,
|
|
982
|
+
errorCause(result.error),
|
|
983
|
+
);
|
|
984
|
+
logger?.error('Run.pipe(); stream error', { runId });
|
|
985
|
+
runOnError?.(errInfo);
|
|
986
|
+
}
|
|
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
|
+
|
|
1001
|
+
logger?.debug('Run.pipe(); stream finished', { runId, reason: result.reason });
|
|
1002
|
+
return result;
|
|
1003
|
+
},
|
|
1004
|
+
|
|
1005
|
+
suspend: async (): Promise<void> => {
|
|
1006
|
+
logger?.trace('Run.suspend();', { runId });
|
|
1007
|
+
|
|
1008
|
+
await requireConnected('suspend');
|
|
1009
|
+
|
|
1010
|
+
if (state === RunState.INITIALIZED) {
|
|
1011
|
+
throw new Ably.ErrorInfo(
|
|
1012
|
+
`unable to suspend run; start() must be called before suspend() (run ${runId})`,
|
|
1013
|
+
ErrorCode.InvalidArgument,
|
|
1014
|
+
400,
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
// ENDED is the terminal state for either an end or a suspend on this
|
|
1018
|
+
// Run instance; a second terminal call is a no-op.
|
|
1019
|
+
if (state === RunState.ENDED) return;
|
|
1020
|
+
state = RunState.ENDED;
|
|
1021
|
+
|
|
1022
|
+
try {
|
|
1023
|
+
await publishLifecycle('run-suspend', 'suspend', async () =>
|
|
1024
|
+
runManager.suspendRun(runId, invocationId, resolvedInputClientId, resolvedInputCodecMessageId),
|
|
1025
|
+
);
|
|
1026
|
+
} finally {
|
|
1027
|
+
deregisterRun();
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
logger?.debug('Run.suspend(); run suspended', { runId });
|
|
1031
|
+
},
|
|
1032
|
+
|
|
1033
|
+
// Spec: AIT-ST7, AIT-ST7a, AIT-ST7b
|
|
1034
|
+
end: async (params: RunEndParams): Promise<void> => {
|
|
1035
|
+
const { reason } = params;
|
|
1036
|
+
const error = params.reason === 'error' ? params.error : undefined;
|
|
1037
|
+
logger?.trace('Run.end();', { runId, reason });
|
|
1038
|
+
|
|
1039
|
+
await requireConnected('end');
|
|
1040
|
+
|
|
1041
|
+
if (state === RunState.INITIALIZED) {
|
|
1042
|
+
throw new Ably.ErrorInfo(
|
|
1043
|
+
`unable to end run; start() must be called before end() (run ${runId})`,
|
|
1044
|
+
ErrorCode.InvalidArgument,
|
|
1045
|
+
400,
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
if (state === RunState.ENDED) return;
|
|
1049
|
+
state = RunState.ENDED;
|
|
1050
|
+
|
|
1051
|
+
try {
|
|
1052
|
+
await publishLifecycle('run-end', 'end', async () =>
|
|
1053
|
+
runManager.endRun(runId, reason, invocationId, resolvedInputClientId, resolvedInputCodecMessageId, error),
|
|
1054
|
+
);
|
|
1055
|
+
} finally {
|
|
1056
|
+
deregisterRun();
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
logger?.debug('Run.end(); run ended', { runId, reason });
|
|
1060
|
+
},
|
|
1061
|
+
};
|
|
1062
|
+
|
|
1063
|
+
return run;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// ---------------------------------------------------------------------------
|
|
1068
|
+
// Factory
|
|
1069
|
+
// ---------------------------------------------------------------------------
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Create an agent (server-side) session bound to the given Realtime client
|
|
1073
|
+
* and channel name. The caller owns the client's lifecycle; the session
|
|
1074
|
+
* owns its channel.
|
|
1075
|
+
* @param options - Session configuration.
|
|
1076
|
+
* @returns A new {@link AgentSession} instance.
|
|
1077
|
+
*/
|
|
1078
|
+
export const createAgentSession = <
|
|
1079
|
+
TInput extends CodecInputEvent,
|
|
1080
|
+
TOutput extends CodecOutputEvent,
|
|
1081
|
+
TProjection,
|
|
1082
|
+
TMessage,
|
|
1083
|
+
>(
|
|
1084
|
+
options: AgentSessionOptions<TInput, TOutput, TProjection, TMessage>,
|
|
1085
|
+
): AgentSession<TOutput, TProjection, TMessage> => new DefaultAgentSession(options);
|