@omercnet/paseo-omp 0.3.0-next.108.1 → 0.3.0-next.111.1

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.
@@ -0,0 +1,169 @@
1
+ import {
2
+ OMP_PROTOCOL_VIOLATION_CATEGORIES,
3
+ OMP_PROTOCOL_VIOLATION_REASONS,
4
+ type OmpProtocolViolationCategory,
5
+ type OmpProtocolViolationDiagnostic,
6
+ type OmpProtocolViolationReason,
7
+ } from "./provider/omp-rpc";
8
+
9
+ export interface OmpProtocolViolationSummary {
10
+ category: OmpProtocolViolationCategory;
11
+ occurrenceCount: number;
12
+ batchCount: number;
13
+ maxOccurrenceCount: number;
14
+ firstAt: string | null;
15
+ lastAt: string | null;
16
+ reasonCounts: Record<OmpProtocolViolationReason, number>;
17
+ latestReason: OmpProtocolViolationDiagnostic["reason"] | null;
18
+ latestPhase: OmpProtocolViolationDiagnostic["phase"] | null;
19
+ latestEventType: OmpProtocolViolationDiagnostic["eventType"] | null;
20
+ latestFrameType: OmpProtocolViolationDiagnostic["frameType"] | null;
21
+ latestField: OmpProtocolViolationDiagnostic["field"] | null;
22
+ latestExpected: OmpProtocolViolationDiagnostic["expected"] | null;
23
+ latestActualType: OmpProtocolViolationDiagnostic["actualType"] | null;
24
+ maxByteSize: number | null;
25
+ latestLimitBytes: number | null;
26
+ }
27
+
28
+ type MutableSummary = OmpProtocolViolationSummary;
29
+
30
+ const FRAME_TYPES: Record<NonNullable<OmpProtocolViolationDiagnostic["frameType"]>, true> = {
31
+ ready: true,
32
+ response: true,
33
+ rpc_chunk: true,
34
+ rpc_frame_error: true,
35
+ notice: true,
36
+ };
37
+
38
+ function boundedCount(value: unknown): number {
39
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) return 0;
40
+ return Math.min(value, Number.MAX_SAFE_INTEGER);
41
+ }
42
+
43
+ function boundedAdd(left: number, right: number): number {
44
+ return Math.min(Number.MAX_SAFE_INTEGER, left + right);
45
+ }
46
+
47
+ function safeTimestamp(now: () => Date): string | null {
48
+ try {
49
+ const value = now();
50
+ return Number.isFinite(value.getTime()) ? value.toISOString() : null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function logProtocolViolation(message: string, diagnostic: OmpProtocolViolationDiagnostic): void {
57
+ const fields = [
58
+ `category: ${diagnostic.category}`,
59
+ `reason: ${diagnostic.reason}`,
60
+ `occurrenceCount: ${diagnostic.occurrenceCount}`,
61
+ `phase: ${diagnostic.phase}`,
62
+ ...(diagnostic.eventType ? [`eventType: ${diagnostic.eventType}`] : []),
63
+ ...(diagnostic.frameType ? [`frameType: ${diagnostic.frameType}`] : []),
64
+ ...(diagnostic.field ? [`field: ${diagnostic.field}`] : []),
65
+ ...(diagnostic.expected ? [`expected: ${diagnostic.expected}`] : []),
66
+ ...(diagnostic.actualType ? [`actualType: ${diagnostic.actualType}`] : []),
67
+ ...(diagnostic.maxByteSize ? [`maxByteSize: ${diagnostic.maxByteSize}`] : []),
68
+ ...(diagnostic.limitBytes ? [`limitBytes: ${diagnostic.limitBytes}`] : []),
69
+ ];
70
+ console.error(`${message} { ${fields.join(", ")} }`);
71
+ }
72
+
73
+ /** Fixed-category, saturating in-process aggregation. A new instance is created on each reload. */
74
+ export class OmpProtocolViolationCollector {
75
+ private readonly summaries = Object.fromEntries(
76
+ OMP_PROTOCOL_VIOLATION_CATEGORIES.map((category) => [
77
+ category,
78
+ {
79
+ category,
80
+ occurrenceCount: 0,
81
+ batchCount: 0,
82
+ maxOccurrenceCount: 0,
83
+ firstAt: null,
84
+ lastAt: null,
85
+ reasonCounts: Object.fromEntries(
86
+ OMP_PROTOCOL_VIOLATION_REASONS.map((reason) => [reason, 0]),
87
+ ),
88
+ latestReason: null,
89
+ latestPhase: null,
90
+ latestEventType: null,
91
+ latestFrameType: null,
92
+ latestField: null,
93
+ latestExpected: null,
94
+ latestActualType: null,
95
+ maxByteSize: null,
96
+ latestLimitBytes: null,
97
+ },
98
+ ]),
99
+ ) as Record<OmpProtocolViolationCategory, MutableSummary>;
100
+
101
+ constructor(
102
+ private readonly now: () => Date = () => new Date(),
103
+ private readonly log: (
104
+ message: string,
105
+ diagnostic: OmpProtocolViolationDiagnostic,
106
+ ) => void | PromiseLike<void> = logProtocolViolation,
107
+ ) {}
108
+
109
+ report = (diagnostic: OmpProtocolViolationDiagnostic): void => {
110
+ try {
111
+ const summary = this.summaries[diagnostic.category];
112
+ if (!summary) return;
113
+ const occurrenceCount = boundedCount(diagnostic.occurrenceCount);
114
+ const timestamp = safeTimestamp(this.now);
115
+ summary.occurrenceCount = boundedAdd(summary.occurrenceCount, occurrenceCount);
116
+ summary.batchCount = boundedAdd(summary.batchCount, 1);
117
+ summary.maxOccurrenceCount = Math.max(summary.maxOccurrenceCount, occurrenceCount);
118
+ if (timestamp) {
119
+ summary.firstAt ??= timestamp;
120
+ summary.lastAt = timestamp;
121
+ }
122
+ summary.reasonCounts[diagnostic.reason] = boundedAdd(
123
+ summary.reasonCounts[diagnostic.reason],
124
+ occurrenceCount,
125
+ );
126
+ summary.latestReason = diagnostic.reason;
127
+ summary.latestPhase = diagnostic.phase;
128
+ summary.latestEventType = diagnostic.eventType ?? null;
129
+ summary.latestFrameType =
130
+ diagnostic.frameType && FRAME_TYPES[diagnostic.frameType] ? diagnostic.frameType : null;
131
+ summary.latestField = diagnostic.field ?? null;
132
+ summary.latestExpected = diagnostic.expected ?? null;
133
+ summary.latestActualType = diagnostic.actualType ?? null;
134
+ const byteSize = boundedCount(diagnostic.maxByteSize);
135
+ if (byteSize > 0) summary.maxByteSize = Math.max(summary.maxByteSize ?? 0, byteSize);
136
+ const limitBytes = boundedCount(diagnostic.limitBytes);
137
+ summary.latestLimitBytes = limitBytes || null;
138
+
139
+ const safeDiagnostic: OmpProtocolViolationDiagnostic = {
140
+ category: diagnostic.category,
141
+ reason: diagnostic.reason,
142
+ phase: diagnostic.phase,
143
+ occurrenceCount,
144
+ ...(summary.latestEventType ? { eventType: summary.latestEventType } : {}),
145
+ ...(summary.latestFrameType ? { frameType: summary.latestFrameType } : {}),
146
+ ...(summary.latestField ? { field: summary.latestField } : {}),
147
+ ...(summary.latestExpected ? { expected: summary.latestExpected } : {}),
148
+ ...(summary.latestActualType ? { actualType: summary.latestActualType } : {}),
149
+ ...(byteSize > 0 ? { maxByteSize: byteSize } : {}),
150
+ ...(limitBytes > 0 ? { limitBytes } : {}),
151
+ };
152
+ try {
153
+ const logging = this.log("OMP protocol violation", safeDiagnostic);
154
+ if (logging) void Promise.resolve(logging).catch(() => undefined);
155
+ } catch {
156
+ // Diagnostics must never affect transport or provider flow.
157
+ }
158
+ } catch {
159
+ // Diagnostics must never affect transport or provider flow.
160
+ }
161
+ };
162
+
163
+ snapshot(): OmpProtocolViolationSummary[] {
164
+ return OMP_PROTOCOL_VIOLATION_CATEGORIES.map((category) => ({
165
+ ...this.summaries[category],
166
+ reasonCounts: { ...this.summaries[category].reasonCounts },
167
+ }));
168
+ }
169
+ }
@@ -9,6 +9,10 @@ import {
9
9
  requireProviderCapabilities,
10
10
  } from "@getpaseo/plugin/server/provider";
11
11
  import type { OmpBrowserAuthorizationRegistry } from "../mcp-browser";
12
+ import type {
13
+ OmpOperationalFailure,
14
+ OmpOperationalFailureReporter,
15
+ } from "../operational-failure-diagnostics";
12
16
  import { discoverOmpCatalog } from "./catalog";
13
17
  import { normalizeOmpCatalogOptions } from "./config-normalization";
14
18
  import type { OmpMcpConnector } from "./host-tools";
@@ -683,6 +687,7 @@ export function createOmpConnection(
683
687
  browserAuthorizationRegistry?: OmpBrowserAuthorizationRegistry,
684
688
  reportDiagnostic: (diagnostic: OmpConnectionDiagnostic) => void = (diagnostic) =>
685
689
  console.error("OMP provider failure", diagnostic),
690
+ reportOperationalFailure: OmpOperationalFailureReporter = () => {},
686
691
  ): ProviderConnection {
687
692
  const errorDetails = (error: unknown, fallback: string): { message: string } => {
688
693
  if (isOmpPublicError(error)) return { message: error.message };
@@ -696,6 +701,13 @@ export function createOmpConnection(
696
701
  }
697
702
  return { message: `${fallback} (diagnostic ${diagnosticId})` };
698
703
  };
704
+ const recordOperationalFailure = (failure: OmpOperationalFailure) => {
705
+ try {
706
+ reportOperationalFailure(failure);
707
+ } catch {
708
+ // Diagnostics must never affect provider requests or cleanup.
709
+ }
710
+ };
699
711
  const safeCapabilities = [...new Set(capabilities)].filter(
700
712
  (capability) =>
701
713
  SUPPORTED_CAPABILITIES[capability] &&
@@ -802,6 +814,9 @@ export function createOmpConnection(
802
814
  },
803
815
  });
804
816
  } catch (error) {
817
+ if (!closing) {
818
+ recordOperationalFailure({ category: "session-open", stage: "catalog" });
819
+ }
805
820
  if (isOmpCleanupFailure(error)) catalogCleanup = error.cleanup;
806
821
  if (!closing) requestFailure(input.requestId, error, "OMP catalog discovery failed");
807
822
  }
@@ -925,6 +940,7 @@ export function createOmpConnection(
925
940
  environment,
926
941
  mcpConnector,
927
942
  mcpInitializationTimeoutMs,
943
+ reportOperationalFailure,
928
944
  );
929
945
  const discoveredNativeSessionId = session.persistenceSessionId;
930
946
  if (discoveredNativeSessionId) nativeSessionId = discoveredNativeSessionId;
@@ -977,6 +993,15 @@ export function createOmpConnection(
977
993
  nativeReservations.release(nativeSessionId, token);
978
994
  }
979
995
  } catch (error) {
996
+ const openingCancelled =
997
+ closing || controller.signal.aborted || opening.get(input.sessionId)?.token !== token;
998
+ if (!openingCancelled) {
999
+ recordOperationalFailure(
1000
+ input.history === "replay" || nativeSessionId
1001
+ ? { category: "replay-recovery", stage: "persisted-replay" }
1002
+ : { category: "session-open", stage: "startup" },
1003
+ );
1004
+ }
980
1005
  deleteSession(input.sessionId, token);
981
1006
  let cleanupError: unknown;
982
1007
  if (session) {
@@ -3,6 +3,10 @@ import type {
3
3
  ProviderMcpServerConfig,
4
4
  ProviderSessionConfig,
5
5
  } from "@getpaseo/plugin/server/provider";
6
+ import type {
7
+ OmpOperationalFailure,
8
+ OmpOperationalFailureReporter,
9
+ } from "../operational-failure-diagnostics";
6
10
  import { isValidImagePayload } from "./image";
7
11
  import {
8
12
  type ConnectedMcpClient,
@@ -26,6 +30,11 @@ import {
26
30
  utf8Bytes,
27
31
  } from "./security";
28
32
 
33
+ type HostToolFailureStage = Extract<
34
+ OmpOperationalFailure,
35
+ { category: "tool-projector"; stage: `host-tool-${string}` }
36
+ >["stage"];
37
+
29
38
  const INTERNAL_PASEO_MCP_PATH = "/mcp/agents";
30
39
  const RESERVED_PASEO_NAMESPACE = "paseo";
31
40
  const MAX_MCP_SERVERS = 32;
@@ -79,6 +88,7 @@ export interface OmpHostToolsOpenOptions {
79
88
  initializationTimeoutMs?: number;
80
89
  callTimeoutMs?: number;
81
90
  callScheduler?: OmpHostToolScheduler;
91
+ reportOperationalFailure?: OmpOperationalFailureReporter;
82
92
  }
83
93
 
84
94
  type ClassifiedServer = {
@@ -496,6 +506,7 @@ export class OmpHostToolsBridge {
496
506
  private readonly targets: ReadonlyMap<string, ToolTarget>,
497
507
  private readonly callTimeoutMs: number,
498
508
  private readonly callScheduler: OmpHostToolScheduler,
509
+ private readonly reportOperationalFailure: OmpOperationalFailureReporter,
499
510
  ) {
500
511
  this.labels = new Map(definitions.map(({ name, label }) => [name, label ?? name]));
501
512
  }
@@ -612,6 +623,7 @@ export class OmpHostToolsBridge {
612
623
  targets,
613
624
  callTimeoutMs,
614
625
  callScheduler,
626
+ options.reportOperationalFailure ?? (() => {}),
615
627
  );
616
628
  } catch (error) {
617
629
  const cleanupTasks = [
@@ -740,7 +752,11 @@ export class OmpHostToolsBridge {
740
752
  if (!runtime) return true;
741
753
  const target = this.targets.get(event.toolName);
742
754
  if (!target) {
743
- this.sendTerminal(runtime, errorResult(event.id, "Unknown OMP host tool"));
755
+ this.sendOperationalError(
756
+ "host-tool-unknown",
757
+ runtime,
758
+ errorResult(event.id, "Unknown OMP host tool"),
759
+ );
744
760
  return true;
745
761
  }
746
762
  const retainedBytes = boundedJsonBytes(
@@ -756,7 +772,11 @@ export class OmpHostToolsBridge {
756
772
  retainedBytes === Number.POSITIVE_INFINITY ||
757
773
  this.pendingBytes + retainedBytes > MAX_PENDING_HOST_TOOL_BYTES
758
774
  ) {
759
- this.sendTerminal(runtime, errorResult(event.id, "OMP host tool bridge is at capacity"));
775
+ this.sendOperationalError(
776
+ "host-tool-capacity",
777
+ runtime,
778
+ errorResult(event.id, "OMP host tool bridge is at capacity"),
779
+ );
760
780
  return true;
761
781
  }
762
782
  const pending: PendingCall = {
@@ -794,23 +814,30 @@ export class OmpHostToolsBridge {
794
814
  })
795
815
  .then((result) => {
796
816
  if (!this.isCurrent(event.id, pending)) return;
797
- let terminal: OmpHostToolResult;
798
817
  try {
799
818
  const normalized = normalizeResult(result);
800
- terminal = {
819
+ const terminal: OmpHostToolResult = {
801
820
  type: "host_tool_result",
802
821
  id: event.id,
803
822
  result: normalized,
804
823
  ...(normalized.isError !== undefined ? { isError: normalized.isError } : {}),
805
824
  };
825
+ if (normalized.isError) {
826
+ this.sendOperationalError("host-tool-call", runtime, terminal, pending);
827
+ } else this.sendTerminal(runtime, terminal, pending);
806
828
  } catch {
807
- terminal = errorResult(event.id, "MCP host tool execution failed");
829
+ this.sendOperationalError(
830
+ "host-tool-normalization",
831
+ runtime,
832
+ errorResult(event.id, "MCP host tool execution failed"),
833
+ pending,
834
+ );
808
835
  }
809
- this.sendTerminal(runtime, terminal, pending);
810
836
  })
811
837
  .catch(() => {
812
838
  if (!this.isCurrent(event.id, pending)) return;
813
- this.sendTerminal(
839
+ this.sendOperationalError(
840
+ "host-tool-call",
814
841
  runtime,
815
842
  errorResult(event.id, "MCP host tool execution failed"),
816
843
  pending,
@@ -909,22 +936,44 @@ export class OmpHostToolsBridge {
909
936
  return fits(bounded) ? bounded : errorResult(result.id, OMP_HOST_TOOL_FRAME_LIMIT_ERROR);
910
937
  }
911
938
 
912
- private sendTerminal(
939
+ private recordOperationalFailure(failure: OmpOperationalFailure): void {
940
+ try {
941
+ this.reportOperationalFailure(failure);
942
+ } catch {
943
+ // Diagnostics must never affect host tool execution or result delivery.
944
+ }
945
+ }
946
+
947
+ private sendOperationalError(
948
+ stage: HostToolFailureStage,
913
949
  runtime: OmpRuntimeSession,
914
950
  result: OmpHostToolResult,
915
951
  pending?: PendingCall,
916
952
  ): void {
953
+ if (this.sendTerminal(runtime, result, pending)) {
954
+ this.recordOperationalFailure({ category: "tool-projector", stage });
955
+ }
956
+ }
957
+
958
+ private sendTerminal(
959
+ runtime: OmpRuntimeSession,
960
+ result: OmpHostToolResult,
961
+ pending?: PendingCall,
962
+ ): boolean {
917
963
  const bounded = this.boundedTerminal(runtime, result);
918
- if (pending && !this.isCurrent(bounded.id, pending)) return;
964
+ if (pending && !this.isCurrent(bounded.id, pending)) return false;
919
965
  try {
920
966
  runtime.sendHostToolResult(bounded);
967
+ return true;
921
968
  } catch (error) {
922
969
  this.failRuntime(runtime, error);
970
+ return false;
923
971
  }
924
972
  }
925
973
 
926
974
  private failRuntime(runtime: OmpRuntimeSession, error: unknown): void {
927
975
  if (this.runtime !== runtime) return;
976
+ this.recordOperationalFailure({ category: "tool-projector", stage: "host-tool-delivery" });
928
977
  const failure =
929
978
  error instanceof Error ? error : new Error("OMP host tool result delivery failed");
930
979
  this.detach();
@@ -945,7 +994,11 @@ export class OmpHostToolsBridge {
945
994
  if (!this.isCurrent(id, pending)) return;
946
995
  this.releasePending(id, pending);
947
996
  pending.controller.abort(new Error("OMP MCP host tool call timed out"));
948
- this.sendTerminal(pending.runtime, errorResult(id, "OMP MCP host tool call timed out"));
997
+ this.sendOperationalError(
998
+ "host-tool-timeout",
999
+ pending.runtime,
1000
+ errorResult(id, "OMP MCP host tool call timed out"),
1001
+ );
949
1002
  }
950
1003
 
951
1004
  private releasePending(id: string, pending: PendingCall): void {