@robota-sdk/agent-transport 3.0.0-beta.100

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +1137 -0
  2. package/LICENSE +661 -0
  3. package/README.md +121 -0
  4. package/dist/browser/client.d.ts +2 -0
  5. package/dist/browser/client.js +1 -0
  6. package/dist/browser/index.d.ts +624 -0
  7. package/dist/browser/index.d.ts.map +1 -0
  8. package/dist/browser/index.js +2 -0
  9. package/dist/browser/index.js.map +1 -0
  10. package/dist/browser/observer-messages-4fRQZU2D.d.ts +499 -0
  11. package/dist/browser/observer-messages-4fRQZU2D.d.ts.map +1 -0
  12. package/dist/browser/observer-messages-O8jTPEQO.js +2 -0
  13. package/dist/browser/observer-messages-O8jTPEQO.js.map +1 -0
  14. package/dist/node/index.cjs +1 -0
  15. package/dist/node/index.d.cts +1115 -0
  16. package/dist/node/index.d.cts.map +1 -0
  17. package/dist/node/index.d.ts +1115 -0
  18. package/dist/node/index.d.ts.map +1 -0
  19. package/dist/node/index.js +2 -0
  20. package/dist/node/index.js.map +1 -0
  21. package/dist/node/node.cjs +1 -0
  22. package/dist/node/node.d.cts +317 -0
  23. package/dist/node/node.d.cts.map +1 -0
  24. package/dist/node/node.d.ts +317 -0
  25. package/dist/node/node.d.ts.map +1 -0
  26. package/dist/node/node.js +2 -0
  27. package/dist/node/node.js.map +1 -0
  28. package/package.json +122 -0
  29. package/src/__tests__/__fixtures__/korean-text.json +6 -0
  30. package/src/__tests__/access-token-verifier.test.ts +672 -0
  31. package/src/__tests__/admission.test.ts +130 -0
  32. package/src/__tests__/app-tool-messages.test.ts +463 -0
  33. package/src/__tests__/bearer-resource-gate.test.ts +120 -0
  34. package/src/__tests__/channel-frames.test.ts +215 -0
  35. package/src/__tests__/driver-attribution-capability.test.ts +83 -0
  36. package/src/__tests__/file-transfer.test.ts +281 -0
  37. package/src/__tests__/handoff-chunking.test.ts +261 -0
  38. package/src/__tests__/handoff-manifest.test.ts +52 -0
  39. package/src/__tests__/history-attached-budget.test.ts +216 -0
  40. package/src/__tests__/message-decoders.test.ts +870 -0
  41. package/src/__tests__/outbound-delivery.test.ts +558 -0
  42. package/src/__tests__/peer-message-ledger.test.ts +200 -0
  43. package/src/__tests__/permission-summary.test.ts +18 -0
  44. package/src/__tests__/personal-usage-report.test.ts +193 -0
  45. package/src/__tests__/project-read-messages.test.ts +118 -0
  46. package/src/__tests__/resume-buffer.test.ts +93 -0
  47. package/src/__tests__/session-detail-loop-status.test.ts +169 -0
  48. package/src/__tests__/session-directory-messages.test.ts +322 -0
  49. package/src/__tests__/session-error-classification.test.ts +113 -0
  50. package/src/__tests__/session-event-delivery.test.ts +101 -0
  51. package/src/__tests__/session-history-wire.test.ts +364 -0
  52. package/src/__tests__/session-message-handler-e5.test.ts +99 -0
  53. package/src/__tests__/session-message-handler-observe.test.ts +223 -0
  54. package/src/__tests__/session-message-handler.test.ts +726 -0
  55. package/src/__tests__/session-resume-bridge.test.ts +472 -0
  56. package/src/__tests__/settings-messages.test.ts +193 -0
  57. package/src/__tests__/stdio-carrier.test.ts +85 -0
  58. package/src/__tests__/submit-prompt-shape.test.ts +78 -0
  59. package/src/__tests__/usage-report-carrier.test.ts +98 -0
  60. package/src/__tests__/ws-broadcast-events.test.ts +94 -0
  61. package/src/app-tool-messages.ts +140 -0
  62. package/src/background-messages.ts +169 -0
  63. package/src/channel-frames.ts +182 -0
  64. package/src/client.ts +17 -0
  65. package/src/handoff-chunking.ts +275 -0
  66. package/src/index.ts +135 -0
  67. package/src/loop-control-messages.ts +34 -0
  68. package/src/message-decoders.ts +485 -0
  69. package/src/message-parser.ts +33 -0
  70. package/src/node/access-token-verifier.ts +363 -0
  71. package/src/node/admission.ts +153 -0
  72. package/src/node/bearer-resource-gate.ts +331 -0
  73. package/src/node/file-transfer.ts +530 -0
  74. package/src/node/handoff-manifest.ts +77 -0
  75. package/src/node/index.ts +45 -0
  76. package/src/node/stdio-carrier.ts +59 -0
  77. package/src/observer-messages.ts +47 -0
  78. package/src/open-prompts.ts +76 -0
  79. package/src/outbound-delivery.ts +240 -0
  80. package/src/peer-message-ledger.ts +193 -0
  81. package/src/permission-summary.ts +18 -0
  82. package/src/project-read-messages.ts +74 -0
  83. package/src/protocol-session.ts +56 -0
  84. package/src/resume-buffer.ts +123 -0
  85. package/src/session-directory-messages.ts +162 -0
  86. package/src/session-error-classification.ts +91 -0
  87. package/src/session-events.ts +290 -0
  88. package/src/session-message-handler.ts +413 -0
  89. package/src/session-query-messages.ts +160 -0
  90. package/src/session-resume-bridge.ts +275 -0
  91. package/src/settings-messages.ts +103 -0
  92. package/src/usage-messages.ts +195 -0
  93. package/src/wire-messages.ts +436 -0
@@ -0,0 +1,74 @@
1
+ /**
2
+ * #3282 §4c — the Project panel's reads (git status, one file's diff, project memory).
3
+ *
4
+ * `TProjectReadCapableSession` is `IProtocolSession & Partial<ISessionProjectRead>` (see
5
+ * `protocol-session.ts`'s doc comment): the three methods are runtime-probed, never assumed, so
6
+ * adding this capability changes nothing for a session or test double that does not have it — a
7
+ * request against one answers `protocol_error` (host-neutral, not a made-up "kind"), the same way an
8
+ * unknown message type already does.
9
+ */
10
+ import type { TOutboundDeliver } from './outbound-delivery.js';
11
+ import type { TProjectReadCapableSession } from './protocol-session.js';
12
+ import type { TClientMessage } from './wire-messages.js';
13
+
14
+ type TProjectReadMessage = Extract<
15
+ TClientMessage,
16
+ { type: 'project-status' | 'project-diff' | 'project-memory' }
17
+ >;
18
+
19
+ export function isProjectReadMessage(msg: TClientMessage): msg is TProjectReadMessage {
20
+ return msg.type === 'project-status' || msg.type === 'project-diff' || msg.type === 'project-memory';
21
+ }
22
+
23
+ function notSupported(deliver: TOutboundDeliver, requestId: string): void {
24
+ deliver({
25
+ type: 'protocol_error',
26
+ message: 'This host does not support the Project panel.',
27
+ requestId,
28
+ });
29
+ }
30
+
31
+ export function handleProjectReadMessage(
32
+ session: TProjectReadCapableSession,
33
+ deliver: TOutboundDeliver,
34
+ msg: TProjectReadMessage,
35
+ ): void {
36
+ const { requestId } = msg;
37
+ if (msg.type === 'project-status') {
38
+ if (!session.readProjectStatus) return notSupported(deliver, requestId);
39
+ session.readProjectStatus().then(
40
+ (result) => deliver({ type: 'project_status', requestId, result }),
41
+ (error: unknown) =>
42
+ deliver({
43
+ type: 'project_status',
44
+ requestId,
45
+ result: { kind: 'failed', message: error instanceof Error ? error.message : String(error) },
46
+ }),
47
+ );
48
+ } else if (msg.type === 'project-diff') {
49
+ if (!session.readProjectDiff) return notSupported(deliver, requestId);
50
+ session.readProjectDiff(msg.path).then(
51
+ (result) => deliver({ type: 'project_diff', requestId, result }),
52
+ (error: unknown) =>
53
+ deliver({
54
+ type: 'project_diff',
55
+ requestId,
56
+ result: { kind: 'failed', message: error instanceof Error ? error.message : String(error) },
57
+ }),
58
+ );
59
+ } else {
60
+ if (!session.readProjectMemory) return notSupported(deliver, requestId);
61
+ session.readProjectMemory().then(
62
+ (result) => deliver({ type: 'project_memory', requestId, result }),
63
+ (error: unknown) =>
64
+ deliver({
65
+ type: 'project_memory',
66
+ requestId,
67
+ result: {
68
+ kind: 'unavailable',
69
+ message: error instanceof Error ? error.message : String(error),
70
+ },
71
+ }),
72
+ );
73
+ }
74
+ }
@@ -0,0 +1,56 @@
1
+ import type {
2
+ ISessionAgentJobs,
3
+ ISessionBackgroundGroups,
4
+ ISessionBackgroundTasks,
5
+ ISessionCommands,
6
+ ISessionConversationRead,
7
+ ISessionDriverAttribution,
8
+ ISessionEvents,
9
+ ISessionExecutionDetail,
10
+ ISessionExecutionState,
11
+ ISessionExecutionWorkspace,
12
+ ISessionProjectRead,
13
+ ISessionPromptResolution,
14
+ ISessionRuntimeTools,
15
+ ISessionSelfPacedLoopControl,
16
+ ISessionStatusRead,
17
+ ISessionToolAppViews,
18
+ ISessionTurnControl,
19
+ ISessionTurnSubmission,
20
+ } from '@robota-sdk/agent-interface-session';
21
+
22
+ /** Session roles required by the shared WebSocket/WebRTC protocol. */
23
+ export interface IProtocolSession
24
+ extends
25
+ ISessionTurnSubmission,
26
+ ISessionTurnControl,
27
+ ISessionCommands,
28
+ ISessionStatusRead,
29
+ ISessionEvents,
30
+ ISessionPromptResolution,
31
+ ISessionConversationRead,
32
+ ISessionExecutionState,
33
+ ISessionDriverAttribution,
34
+ ISessionBackgroundTasks,
35
+ ISessionBackgroundGroups,
36
+ ISessionExecutionWorkspace,
37
+ ISessionExecutionDetail,
38
+ ISessionSelfPacedLoopControl,
39
+ // #3282 §4: the agent switcher's read (`listAgentDefinitions`/`getDefaultAgentType`) — the
40
+ // group's write members (`spawnAgentJob` and friends) stay unused by any wire handler; they run
41
+ // only through `executeCommand`, same as every other command-only write.
42
+ ISessionAgentJobs,
43
+ // A driving surface that hosts an app view runs one runtime tool for it (`call-app-tool`) and
44
+ // reads a finished call's view (`read-app-view`).
45
+ ISessionRuntimeTools,
46
+ ISessionToolAppViews {}
47
+
48
+ /**
49
+ * #3282 §4c: a protocol session that ALSO answers the Project panel's reads. `Partial` (not a fourth
50
+ * mandatory role on {@link IProtocolSession} itself) deliberately: every existing test double and
51
+ * every other host that builds an `IProtocolSession` keeps type-checking unchanged, and the panel's
52
+ * three methods are runtime-probed (`typeof session.readProjectStatus === 'function'`) rather than
53
+ * assumed — `InteractiveSession` (the one production implementation) provides all three, satisfying
54
+ * this type with no further wiring.
55
+ */
56
+ export type TProjectReadCapableSession = IProtocolSession & Partial<ISessionProjectRead>;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Bounded un-acked output buffer for session-resume (REMOTE-013 Stage E4).
3
+ *
4
+ * Holds the tail of outbound `TServerMessage`s that a reconnecting client may not have received, keyed by a
5
+ * monotonic sequence number. On reconnect the client sends `resume{lastSeq}` and the host replays
6
+ * {@link ResumeBuffer.tailAfter}; a periodic `ack{seq}` frees everything up to `seq`. The buffer is bounded by
7
+ * a frame count AND a byte budget (drop-oldest) so a never-acking or gone peer cannot exhaust host memory —
8
+ * and when a client's `lastSeq` predates the oldest retained frame it returns an **overrun** marker so the
9
+ * client does a full refresh rather than accept a silent gap.
10
+ */
11
+
12
+ import type { TServerMessage } from './wire-messages.js';
13
+
14
+ /** One retained frame: its sequence number and the message. */
15
+ export interface IBufferedFrame {
16
+ readonly seq: number;
17
+ readonly message: TServerMessage;
18
+ }
19
+
20
+ /** The result of a `tailAfter` — either the ordered replay slice, or an overrun (client must full-refresh). */
21
+ export type TResumeTail =
22
+ | { readonly kind: 'tail'; readonly frames: readonly IBufferedFrame[] }
23
+ | { readonly kind: 'overrun' };
24
+
25
+ export interface IResumeBufferOptions {
26
+ /** Max retained frames (drop-oldest beyond this). Default 512. */
27
+ readonly maxFrames?: number;
28
+ /** Max retained bytes across frames (drop-oldest beyond this). Default 4 MiB. */
29
+ readonly maxBytes?: number;
30
+ /** Serialized size of a message (for the byte budget). Default: JSON length. Injectable for tests. */
31
+ readonly sizeOf?: (message: TServerMessage) => number;
32
+ }
33
+
34
+ const DEFAULT_MAX_FRAMES = 512;
35
+ const DEFAULT_MAX_BYTES = 4 * 1024 * 1024;
36
+
37
+ /**
38
+ * A monotonic, bounded ring of un-acked frames. Sequence numbers are assigned by {@link append} and never
39
+ * reused; `tailAfter` / `ackThrough` operate over the retained window.
40
+ */
41
+ export class ResumeBuffer {
42
+ private readonly frames: IBufferedFrame[] = [];
43
+ private readonly sizes: number[] = []; // parallel to `frames` — retained byte size per frame
44
+ private nextSeq = 1;
45
+ private bytes = 0;
46
+
47
+ private readonly maxFrames: number;
48
+ private readonly maxBytes: number;
49
+ private readonly sizeOf: (message: TServerMessage) => number;
50
+
51
+ public constructor(options: IResumeBufferOptions = {}) {
52
+ this.maxFrames = options.maxFrames ?? DEFAULT_MAX_FRAMES;
53
+ this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
54
+ this.sizeOf = options.sizeOf ?? ((m) => JSON.stringify(m).length);
55
+ }
56
+
57
+ /** Append a message, assign + return its monotonic seq, and evict oldest frames beyond the bounds. */
58
+ public append(message: TServerMessage): number {
59
+ const seq = this.nextSeq;
60
+ this.nextSeq += 1;
61
+ const size = this.sizeOf(message);
62
+ this.frames.push({ seq, message });
63
+ this.sizes.push(size);
64
+ this.bytes += size;
65
+ this.evict();
66
+ return seq;
67
+ }
68
+
69
+ /** Drop all retained frames with `seq <= throughSeq` (the client confirmed receipt up to there). */
70
+ public ackThrough(throughSeq: number): void {
71
+ let drop = 0;
72
+ while (drop < this.frames.length && this.frames[drop].seq <= throughSeq) drop += 1;
73
+ this.removeOldest(drop);
74
+ }
75
+
76
+ /**
77
+ * The ordered frames with `seq > lastSeq`. Returns an `overrun` marker when `lastSeq` is older than the
78
+ * oldest retained frame (i.e. some frame after `lastSeq` was already evicted) — the client must full-refresh.
79
+ * `lastSeq` at or beyond the newest seq yields an empty tail (nothing to replay).
80
+ */
81
+ public tailAfter(lastSeq: number): TResumeTail {
82
+ if (this.frames.length === 0) {
83
+ // Nothing retained. Only an overrun if the client is behind the frames we HAVE emitted+evicted.
84
+ return lastSeq >= this.nextSeq - 1 ? { kind: 'tail', frames: [] } : { kind: 'overrun' };
85
+ }
86
+ const oldest = this.frames[0].seq;
87
+ if (lastSeq < oldest - 1) return { kind: 'overrun' };
88
+ return { kind: 'tail', frames: this.frames.filter((f) => f.seq > lastSeq) };
89
+ }
90
+
91
+ /** The last seq assigned (0 when nothing has been appended). */
92
+ public get lastSeq(): number {
93
+ return this.nextSeq - 1;
94
+ }
95
+
96
+ /** Retained frame count (diagnostics/tests). */
97
+ public get size(): number {
98
+ return this.frames.length;
99
+ }
100
+
101
+ private evict(): void {
102
+ let drop = 0;
103
+ let remainingBytes = this.bytes;
104
+ // Evict oldest while over the frame cap, or over the byte cap (but always keep at least the newest frame).
105
+ while (
106
+ this.frames.length - drop > this.maxFrames ||
107
+ (remainingBytes > this.maxBytes && this.frames.length - drop > 1)
108
+ ) {
109
+ remainingBytes -= this.sizes[drop];
110
+ drop += 1;
111
+ }
112
+ this.removeOldest(drop);
113
+ }
114
+
115
+ /** The single place that mutates `frames`/`sizes`/`bytes` for removal, so byte accounting can't drift. */
116
+ private removeOldest(count: number): void {
117
+ if (count <= 0) return;
118
+ for (let i = 0; i < count; i += 1) this.bytes -= this.sizes[i] ?? 0;
119
+ this.frames.splice(0, count);
120
+ this.sizes.splice(0, count);
121
+ if (this.frames.length === 0) this.bytes = 0;
122
+ }
123
+ }
@@ -0,0 +1,162 @@
1
+ import { isSessionChangeRefusal, isSessionDeleteRefusal } from '@robota-sdk/agent-interface-session';
2
+
3
+ import type { TOutboundDeliver } from './outbound-delivery.js';
4
+ import type { TClientMessage } from './wire-messages.js';
5
+ import type { ISessionDirectory } from '@robota-sdk/agent-interface-session';
6
+
7
+ type TSessionDirectoryMessage = Extract<
8
+ TClientMessage,
9
+ { type: 'list-sessions' | 'new-session' | 'switch-session' }
10
+ >;
11
+
12
+ export function isSessionDirectoryMessage(msg: TClientMessage): msg is TSessionDirectoryMessage {
13
+ return (
14
+ msg.type === 'list-sessions' || msg.type === 'new-session' || msg.type === 'switch-session'
15
+ );
16
+ }
17
+
18
+ type TSessionRenameMessage = Extract<TClientMessage, { type: 'rename-session' }>;
19
+ type TSessionDeleteMessage = Extract<TClientMessage, { type: 'delete-session' }>;
20
+
21
+ export function isSessionRenameMessage(msg: TClientMessage): msg is TSessionRenameMessage {
22
+ return msg.type === 'rename-session';
23
+ }
24
+
25
+ export function isSessionDeleteMessage(msg: TClientMessage): msg is TSessionDeleteMessage {
26
+ return msg.type === 'delete-session';
27
+ }
28
+
29
+ /**
30
+ * #3189: the host's sessions. Listing answers on its own correlated reply. A new session or a switch
31
+ * answers nothing on success: `session_switched` from the connection's own session is the signal. A
32
+ * refusal answers `session_change_failed`, carrying the host's code (or `failed` for an error that is
33
+ * not a declared refusal) and the request's `requestId`.
34
+ */
35
+ export function handleSessionDirectoryMessage(
36
+ deliver: TOutboundDeliver,
37
+ msg: TSessionDirectoryMessage,
38
+ directory: ISessionDirectory | undefined,
39
+ ): void {
40
+ if (msg.type === 'list-sessions') {
41
+ listSessions(deliver, msg.requestId, directory);
42
+ return;
43
+ }
44
+ const request = msg.requestId !== undefined ? { requestId: msg.requestId } : {};
45
+ if (!directory) {
46
+ deliver({
47
+ type: 'session_change_failed',
48
+ code: 'not_available',
49
+ message: 'Sessions cannot be switched on this host.',
50
+ ...request,
51
+ });
52
+ return;
53
+ }
54
+ const change = (): Promise<void> =>
55
+ msg.type === 'new-session' ? directory.newSession() : directory.switchSession(msg.sessionId);
56
+ void Promise.resolve()
57
+ .then(change)
58
+ .catch((error: Error | string) =>
59
+ deliver({
60
+ type: 'session_change_failed',
61
+ code: isSessionChangeRefusal(error) ? error.code : 'failed',
62
+ message: failureMessage(error),
63
+ ...request,
64
+ }),
65
+ );
66
+ }
67
+
68
+ function listSessions(
69
+ deliver: TOutboundDeliver,
70
+ requestId: string,
71
+ directory: ISessionDirectory | undefined,
72
+ ): void {
73
+ if (!directory) {
74
+ deliver({
75
+ type: 'sessions_error',
76
+ requestId,
77
+ code: 'not_available',
78
+ message: 'Session listing is not available on this host.',
79
+ });
80
+ return;
81
+ }
82
+ try {
83
+ deliver({ type: 'sessions', requestId, listing: directory.listSessions() });
84
+ } catch (error) {
85
+ deliver({
86
+ type: 'sessions_error',
87
+ requestId,
88
+ code: 'list_failed',
89
+ message: failureMessage(error instanceof Error ? error : String(error)),
90
+ });
91
+ }
92
+ }
93
+
94
+ function failureMessage(error: Error | string): string {
95
+ return error instanceof Error ? error.message : String(error);
96
+ }
97
+
98
+ /**
99
+ * #3289 §1: rename a session from the list — current or not. Success replies with the new name so
100
+ * the client can update it optimistically; the client also re-lists to pick up everything else a
101
+ * rename can imply (nothing today, but the reply shape matches `delete-session`'s on purpose).
102
+ */
103
+ export function handleSessionRenameMessage(
104
+ deliver: TOutboundDeliver,
105
+ msg: TSessionRenameMessage,
106
+ directory: ISessionDirectory | undefined,
107
+ ): void {
108
+ if (!directory) {
109
+ deliver({
110
+ type: 'session_rename_failed',
111
+ requestId: msg.requestId,
112
+ message: 'Sessions cannot be renamed on this host.',
113
+ });
114
+ return;
115
+ }
116
+ directory.renameSession(msg.sessionId, msg.name).then(
117
+ () =>
118
+ deliver({
119
+ type: 'session_renamed_in_list',
120
+ requestId: msg.requestId,
121
+ sessionId: msg.sessionId,
122
+ name: msg.name,
123
+ }),
124
+ (error: Error | string) =>
125
+ deliver({
126
+ type: 'session_rename_failed',
127
+ requestId: msg.requestId,
128
+ message: failureMessage(error),
129
+ }),
130
+ );
131
+ }
132
+
133
+ /**
134
+ * #3289 §1: remove a stored session's record. Refused when it is live on another client or running a
135
+ * turn; deleting the current session switches this binding away first (the directory's own job), so
136
+ * by the time this answers, the caller's own session is never the one just removed.
137
+ */
138
+ export function handleSessionDeleteMessage(
139
+ deliver: TOutboundDeliver,
140
+ msg: TSessionDeleteMessage,
141
+ directory: ISessionDirectory | undefined,
142
+ ): void {
143
+ if (!directory) {
144
+ deliver({
145
+ type: 'session_delete_failed',
146
+ requestId: msg.requestId,
147
+ code: 'not_available',
148
+ message: 'Sessions cannot be deleted on this host.',
149
+ });
150
+ return;
151
+ }
152
+ directory.deleteSession(msg.sessionId).then(
153
+ () => deliver({ type: 'session_deleted', requestId: msg.requestId, sessionId: msg.sessionId }),
154
+ (error: Error | string) =>
155
+ deliver({
156
+ type: 'session_delete_failed',
157
+ requestId: msg.requestId,
158
+ code: isSessionDeleteRefusal(error) ? error.code : 'failed',
159
+ message: failureMessage(error),
160
+ }),
161
+ );
162
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * What the `error` wire frame should say about a session failure, beyond its raw `message` (#3289
3
+ * §3). A renderer maps `code` to one plain sentence with the next step, keeping `message` behind a
4
+ * "Details" disclosure — see `agent-ui-web`'s `SessionNotices`.
5
+ *
6
+ * Walks wrapped layers (`originalError`/`cause`, a few deep) because a higher-level catch can wrap the
7
+ * provider's own typed error in another one (e.g. a "Conversation failed" `ProviderError`) before it
8
+ * reaches here — the same reason `agent-core`'s `classifyProviderFailure` walks layers rather than
9
+ * looking at the outermost error alone.
10
+ */
11
+
12
+ import {
13
+ AuthenticationError,
14
+ ModelNotAvailableError,
15
+ NetworkError,
16
+ ProviderError,
17
+ RateLimitError,
18
+ } from '@robota-sdk/agent-core';
19
+
20
+ /** Kept in sync with `wire-messages.ts`'s `error` variant — see that type for what each means. */
21
+ export type TSessionErrorCode =
22
+ 'auth' | 'rate_limit' | 'model_unavailable' | 'network' | 'provider';
23
+
24
+ export interface ISessionErrorClassification {
25
+ code?: TSessionErrorCode;
26
+ provider?: string;
27
+ retryAfterSeconds?: number;
28
+ /** For `code: 'model_unavailable'`, the model the request tried, when the failure named one. */
29
+ model?: string;
30
+ }
31
+
32
+ const MAX_WRAP_DEPTH = 8;
33
+
34
+ /** The error and everything it wraps, outermost first, followed to a small depth and never twice. */
35
+ function layersOf(error: unknown): unknown[] {
36
+ const layers: unknown[] = [];
37
+ const seen = new Set<unknown>();
38
+ let frontier: unknown[] = [error];
39
+ for (let depth = 0; depth <= MAX_WRAP_DEPTH && frontier.length > 0; depth++) {
40
+ const next: unknown[] = [];
41
+ for (const layer of frontier) {
42
+ if (layer === undefined || layer === null || seen.has(layer)) continue;
43
+ seen.add(layer);
44
+ layers.push(layer);
45
+ if (typeof layer === 'object') {
46
+ const record = layer as Record<string, unknown>;
47
+ next.push(record['originalError'], record['cause']);
48
+ }
49
+ }
50
+ frontier = next;
51
+ }
52
+ return layers;
53
+ }
54
+
55
+ /**
56
+ * Classify a session failure for the wire, or leave it uncoded when it names no provider at all —
57
+ * an unrelated internal failure keeps showing its raw `message` exactly as before this classification
58
+ * existed.
59
+ */
60
+ export function classifySessionErrorForWire(error: unknown): ISessionErrorClassification {
61
+ // A generic ProviderError never wins outright — a wrapper (e.g. "Conversation failed") can sit
62
+ // above the real, more specific cause, so it is kept only as a fallback while deeper layers are
63
+ // still searched for one of the specific types below.
64
+ let fallback: ISessionErrorClassification | undefined;
65
+ for (const layer of layersOf(error)) {
66
+ if (layer instanceof AuthenticationError) {
67
+ return { code: 'auth', ...(layer.provider !== undefined && { provider: layer.provider }) };
68
+ }
69
+ if (layer instanceof RateLimitError) {
70
+ return {
71
+ code: 'rate_limit',
72
+ ...(layer.provider !== undefined && { provider: layer.provider }),
73
+ ...(layer.retryAfter !== undefined && { retryAfterSeconds: layer.retryAfter }),
74
+ };
75
+ }
76
+ if (layer instanceof ModelNotAvailableError) {
77
+ return {
78
+ code: 'model_unavailable',
79
+ provider: layer.provider,
80
+ ...(layer.model !== undefined && { model: layer.model }),
81
+ };
82
+ }
83
+ if (layer instanceof NetworkError) {
84
+ return { code: 'network', ...(layer.provider !== undefined && { provider: layer.provider }) };
85
+ }
86
+ if (fallback === undefined && layer instanceof ProviderError) {
87
+ fallback = { code: 'provider', provider: layer.provider };
88
+ }
89
+ }
90
+ return fallback ?? {};
91
+ }