@omercnet/paseo-omp 0.3.0-next.103.1 → 0.3.0-next.105.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-omp",
3
- "version": "0.3.0-next.103.1",
3
+ "version": "0.3.0-next.105.1",
4
4
  "type": "module",
5
5
  "description": "Paseo integration for OMP, including its direct provider and workspace tooling.",
6
6
  "license": "MIT",
@@ -48,6 +48,27 @@ const THINKING_OPTIONS: readonly ProviderThinkingOption[] = [
48
48
  { id: "xhigh", label: "XHigh", description: "Extra-high reasoning" },
49
49
  { id: "max", label: "Max", description: "Maximum reasoning" },
50
50
  ];
51
+ export const OMP_MAX_CATALOG_MODELS = 256;
52
+
53
+ export function selectOmpModels(
54
+ models: readonly OmpModel[],
55
+ activeModel: OmpModel | null | undefined,
56
+ ): OmpModel[] {
57
+ if (models.length <= OMP_MAX_CATALOG_MODELS) return [...models];
58
+ const selected = models.slice(0, OMP_MAX_CATALOG_MODELS);
59
+ if (!activeModel) return selected;
60
+ const active = models.find(
61
+ (model) => model.provider === activeModel.provider && model.id === activeModel.id,
62
+ );
63
+ if (
64
+ !active ||
65
+ selected.some((model) => model.provider === active.provider && model.id === active.id)
66
+ ) {
67
+ return selected;
68
+ }
69
+ selected[OMP_MAX_CATALOG_MODELS - 1] = active;
70
+ return selected;
71
+ }
51
72
 
52
73
  export function nativeOmpModelId(model: OmpModel): string {
53
74
  if (model.provider.includes("/")) {
@@ -60,14 +81,9 @@ export function ompModelId(model: OmpModel): string {
60
81
  const nativeIdentity = `${Buffer.byteLength(model.provider, "utf8")}:${model.provider}${Buffer.byteLength(model.id, "utf8")}:${model.id}`;
61
82
  return `omp:model:${createHash("sha256").update(nativeIdentity).digest("hex")}`;
62
83
  }
63
-
64
- export function mapOmpModels(
65
- models: readonly OmpModel[],
66
- serializer = new OmpPublicDataSerializer(),
67
- ): ProviderModel[] {
84
+ export function validateOmpModelIdentities(models: readonly OmpModel[]): void {
68
85
  const seenIds = new Map<string, string>();
69
- return models.map((model) => {
70
- const thinkingOptions = thinkingForModel(model);
86
+ for (const model of models) {
71
87
  const id = ompModelId(model);
72
88
  const nativeIdentity = nativeOmpModelId(model);
73
89
  const existing = seenIds.get(id);
@@ -76,6 +92,17 @@ export function mapOmpModels(
76
92
  }
77
93
  if (existing !== undefined) throw new Error("OMP reported a duplicate model identity");
78
94
  seenIds.set(id, nativeIdentity);
95
+ }
96
+ }
97
+
98
+ export function mapOmpModels(
99
+ models: readonly OmpModel[],
100
+ serializer = new OmpPublicDataSerializer(),
101
+ ): ProviderModel[] {
102
+ validateOmpModelIdentities(models);
103
+ return models.map((model) => {
104
+ const thinkingOptions = thinkingForModel(model);
105
+ const id = ompModelId(model);
79
106
  const provider = serializer.text(model.provider, 256);
80
107
  const modelId = serializer.text(model.id, 256);
81
108
  const name = model.name ? serializer.text(model.name, 256) : modelId;
@@ -143,14 +170,16 @@ export async function discoverOmpCatalog(
143
170
  ? [...configuredValues, ...(session.inheritedRedactionValues ?? [])]
144
171
  : configuredValues,
145
172
  );
146
- const models = mapOmpModels(nativeModels, serializer);
173
+ validateOmpModelIdentities(nativeModels);
174
+ const selectedNativeModels = selectOmpModels(nativeModels, state.model);
175
+ const models = mapOmpModels(selectedNativeModels, serializer);
147
176
  if (models.length === 0) throw new Error("OMP reported no available models");
148
177
  const defaultModel = state.model ? ompModelId(state.model) : models[0]?.id;
149
178
  const currentModel = state.model
150
- ? nativeModels.find(
179
+ ? selectedNativeModels.find(
151
180
  (model) => model.provider === state.model?.provider && model.id === state.model.id,
152
181
  )
153
- : nativeModels[0];
182
+ : selectedNativeModels[0];
154
183
  if (state.model && !currentModel) throw new Error("OMP reported an unadvertised active model");
155
184
  const thinkingOptions = thinkingForModel(currentModel);
156
185
  const defaultThinkingOption = thinkingOptions.some(
@@ -70,6 +70,7 @@ const MAX_OPTIONAL_METADATA_NODES = 4_096;
70
70
  const MAX_TASK_CORRELATION_BYTES = 256 * 1024;
71
71
  const MAX_TASK_CORRELATION_ITEMS = 1_024;
72
72
  const MAX_TASK_CORRELATION_NODES = 4_096;
73
+ const MAX_MODEL_CATALOG_ITEMS = 4_096;
73
74
  // Tool-intensive OMP turns legitimately exceed 64 blocks; transport byte/node budgets remain the
74
75
  // primary resource bounds.
75
76
  export const OMP_MAX_CONTENT_PARTS = 4_096;
@@ -84,6 +85,32 @@ const MAX_COST_USD = 1_000_000_000;
84
85
  const MAX_RPC_ERROR_BYTES = 4_096;
85
86
  const MAX_RPC_ERROR_CODE_BYTES = 256;
86
87
  const PROMPT_SCHEDULING_FAILURE = "OMP prompt scheduling failed";
88
+ const PROTOCOL_VIOLATION_COALESCE_MS = 10_000;
89
+
90
+ export type OmpProtocolViolationCategory =
91
+ | "duplicate-ready"
92
+ | "frame-limit"
93
+ | "incomplete-frame"
94
+ | "interleaved-chunk"
95
+ | "invalid-chunk"
96
+ | "invalid-envelope"
97
+ | "invalid-event"
98
+ | "invalid-event-state"
99
+ | "invalid-json"
100
+ | "invalid-ready"
101
+ | "invalid-response"
102
+ | "remote-frame-error";
103
+
104
+ export interface OmpProtocolViolationDiagnostic {
105
+ category: OmpProtocolViolationCategory;
106
+ occurrenceCount: number;
107
+ frameType?: "ready" | "response" | "rpc_chunk" | "rpc_frame_error";
108
+ maxByteSize?: number;
109
+ }
110
+
111
+ type PendingProtocolViolation = Omit<OmpProtocolViolationDiagnostic, "occurrenceCount"> & {
112
+ occurrenceCount: number;
113
+ };
87
114
  const MAX_CONTEXT_PERCENT = 1_000_000;
88
115
  function boundedJsonString(maxBytes: number, minBytes = 0) {
89
116
  return z.string().refine((value) => {
@@ -115,6 +142,17 @@ function boundedString(maxBytes: number, minBytes = 0) {
115
142
  return bytes >= minBytes && bytes <= maxBytes;
116
143
  });
117
144
  }
145
+ function boundedOptionalStringArray(maxItems: number, maxBytes: number) {
146
+ return z.unknown().transform((value): string[] | undefined => {
147
+ if (!Array.isArray(value)) return undefined;
148
+ return value
149
+ .filter(
150
+ (item): item is string =>
151
+ typeof item === "string" && item.length > 0 && utf8Bytes(item) <= maxBytes,
152
+ )
153
+ .slice(0, maxItems);
154
+ });
155
+ }
118
156
 
119
157
  const IDENTIFIER = boundedString(MAX_ID_LENGTH, 1);
120
158
  const NAME = boundedString(MAX_NAME_LENGTH, 1);
@@ -191,6 +229,7 @@ function sanitizeLiveDisplayFrame(value: unknown): unknown {
191
229
  return changed ? { ...frame, messages } : value;
192
230
  }
193
231
  const OmpThinkingLevelSchema = z.enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
232
+ const OmpCurrentThinkingLevelSchema = boundedString(32).optional().catch(undefined);
194
233
 
195
234
  function isBoundedJson(
196
235
  value: unknown,
@@ -546,19 +585,33 @@ const OmpAvailableCommandSchema = z.object({
546
585
  .optional(),
547
586
  source: boundedString(64).optional(),
548
587
  });
588
+ const OmpThinkingMetadataSchema = z
589
+ .unknown()
590
+ .transform((value): { efforts?: string[]; defaultLevel?: string } | undefined => {
591
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
592
+ const record = value as Record<string, unknown>;
593
+ const efforts = boundedOptionalStringArray(16, 32).parse(record.efforts);
594
+ const defaultLevel = boundedString(32).optional().catch(undefined).parse(record.defaultLevel);
595
+ return {
596
+ ...(efforts !== undefined ? { efforts } : {}),
597
+ ...(defaultLevel !== undefined ? { defaultLevel } : {}),
598
+ };
599
+ });
549
600
  const OmpModelSchema = z.object({
550
601
  provider: OMP_PROVIDER_NAME,
551
602
  id: NAME,
552
- name: boundedString(MAX_NAME_LENGTH).optional(),
553
- reasoning: z.boolean().optional(),
554
- thinking: z
555
- .object({
556
- efforts: z.array(boundedString(32)).max(16).optional(),
557
- defaultLevel: boundedString(32).optional(),
558
- })
559
- .optional(),
560
- input: z.array(NAME).max(16).optional(),
561
- contextWindow: z.number().int().nonnegative().max(100_000_000).nullable().optional(),
603
+ name: boundedString(MAX_NAME_LENGTH).optional().catch(undefined),
604
+ reasoning: z.boolean().optional().catch(undefined),
605
+ thinking: OmpThinkingMetadataSchema.optional(),
606
+ input: boundedOptionalStringArray(16, MAX_NAME_LENGTH).optional(),
607
+ contextWindow: z
608
+ .number()
609
+ .int()
610
+ .nonnegative()
611
+ .max(100_000_000)
612
+ .nullable()
613
+ .optional()
614
+ .catch(undefined),
562
615
  });
563
616
  const TokenCountSchema = z.number().int().nonnegative().max(MAX_TOKEN_COUNT);
564
617
  const OptionalTokenCountSchema = TokenCountSchema.nullable().optional();
@@ -610,13 +663,13 @@ const OmpCompactionResultSchema = z.object({
610
663
  });
611
664
  const OmpSessionStateSchema = z.object({
612
665
  model: OmpModelSchema.nullable().optional(),
613
- thinkingLevel: OmpThinkingLevelSchema.optional(),
666
+ thinkingLevel: OmpCurrentThinkingLevelSchema,
614
667
  isStreaming: z.boolean(),
615
668
  isCompacting: z.boolean(),
616
669
  sessionId: IDENTIFIER,
617
670
  autoCompactionEnabled: z.boolean().optional(),
618
- contextUsage: OmpContextUsageSchema.nullable().optional(),
619
- sessionFile: boundedString(MAX_PATH_LENGTH).optional(),
671
+ contextUsage: OmpContextUsageSchema.nullable().optional().catch(undefined),
672
+ sessionFile: boundedString(MAX_PATH_LENGTH).optional().catch(undefined),
620
673
  });
621
674
  const OmpReadyFrameSchema = z.object({
622
675
  type: z.literal("ready"),
@@ -1217,7 +1270,7 @@ function runtimeFrameCollectionLimit(frame: Record<string, unknown>): number {
1217
1270
  return 1_024;
1218
1271
  }
1219
1272
  const OmpModelsResultSchema = z.object({
1220
- models: z.array(OmpModelSchema).min(1).max(256),
1273
+ models: z.array(OmpModelSchema).min(1).max(MAX_MODEL_CATALOG_ITEMS),
1221
1274
  });
1222
1275
  const OmpPromptAckSchema = z.object({ agentInvoked: z.boolean().optional() }).optional();
1223
1276
  const OmpAvailableCommandsResultSchema = z.object({
@@ -1412,6 +1465,7 @@ export interface OmpRpcRuntimeOptions {
1412
1465
  terminateProcessTree?: (pid: number) => Promise<boolean | "uncertain">;
1413
1466
  environment?: NodeJS.ProcessEnv;
1414
1467
  requestTimeoutMs?: number;
1468
+ reportProtocolViolation?: (diagnostic: OmpProtocolViolationDiagnostic) => void | Promise<void>;
1415
1469
  listSessions?: (
1416
1470
  options: OmpSessionListOptions,
1417
1471
  ) => OmpSessionDescriptor[] | Promise<OmpSessionDescriptor[]>;
@@ -1943,12 +1997,20 @@ class OmpRpcProcess {
1943
1997
  private spawnFailedWithoutProcess = false;
1944
1998
  private readyReceived = false;
1945
1999
  private outputSettled = false;
2000
+ private readonly pendingProtocolViolations = new Map<
2001
+ OmpProtocolViolationCategory,
2002
+ PendingProtocolViolation
2003
+ >();
2004
+ private protocolViolationTimer: TimerHandle | null = null;
1946
2005
 
1947
2006
  constructor(
1948
2007
  options: OmpStartOptions,
1949
2008
  spawnProcess?: OmpRpcRuntimeOptions["spawnProcess"],
1950
2009
  terminateProcessTree?: OmpRpcRuntimeOptions["terminateProcessTree"],
1951
2010
  private readonly requestTimeoutMs = REQUEST_TIMEOUT_MS,
2011
+ private readonly reportProtocolViolation: (
2012
+ diagnostic: OmpProtocolViolationDiagnostic,
2013
+ ) => void | Promise<void> = (diagnostic) => console.error("OMP protocol violation", diagnostic),
1952
2014
  ) {
1953
2015
  const ready = Promise.withResolvers<ReadyFrame>();
1954
2016
  this.rejectReady = ready.reject;
@@ -2037,7 +2099,11 @@ class OmpRpcProcess {
2037
2099
  private settleOutput(): void {
2038
2100
  if (this.outputSettled) return;
2039
2101
  this.outputSettled = true;
2040
- if (this.lineBytes > 0 || this.discardingLine) this.recordProtocolViolation();
2102
+ if (this.lineBytes > 0 || this.discardingLine) {
2103
+ this.recordProtocolViolation("incomplete-frame", {
2104
+ maxByteSize: this.discardingLine ? this.discardedLineBytes : this.lineBytes,
2105
+ });
2106
+ }
2041
2107
  this.lineParts = [];
2042
2108
  this.lineBytes = 0;
2043
2109
  this.discardingLine = false;
@@ -2220,6 +2286,7 @@ class OmpRpcProcess {
2220
2286
 
2221
2287
  private async closeProcess(): Promise<void> {
2222
2288
  this.closed = true;
2289
+ this.flushProtocolViolations();
2223
2290
  this.clearChunk();
2224
2291
  this.failPending(new Error("OMP RPC process was closed"));
2225
2292
  if (!this.exited) {
@@ -2316,7 +2383,7 @@ class OmpRpcProcess {
2316
2383
  this.lineParts = [];
2317
2384
  this.lineBytes = 0;
2318
2385
  this.discardingLine = true;
2319
- this.recordProtocolViolation();
2386
+ this.recordProtocolViolation("frame-limit", { maxByteSize: nextBytes });
2320
2387
  return;
2321
2388
  }
2322
2389
  this.lineParts.push(part);
@@ -2337,7 +2404,7 @@ class OmpRpcProcess {
2337
2404
  try {
2338
2405
  decoded = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(payload));
2339
2406
  } catch {
2340
- this.recordProtocolViolation();
2407
+ this.recordProtocolViolation("invalid-json", { maxByteSize: payload.byteLength });
2341
2408
  return;
2342
2409
  }
2343
2410
  this.receiveDecodedFrame(decoded, payload.byteLength);
@@ -2407,7 +2474,7 @@ class OmpRpcProcess {
2407
2474
  try {
2408
2475
  decodedFrame = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(reassembled));
2409
2476
  } catch {
2410
- this.recordProtocolViolation();
2477
+ this.recordProtocolViolation("invalid-json", { maxByteSize: reassembled.byteLength });
2411
2478
  return;
2412
2479
  }
2413
2480
  this.receiveDecodedFrame(decodedFrame, reassembled.byteLength);
@@ -2433,10 +2500,10 @@ class OmpRpcProcess {
2433
2500
  const sanitized = sanitizeLiveDisplayFrame(value);
2434
2501
  const frame = JsonObjectSchema.safeParse(sanitized);
2435
2502
  if (!frame.success) {
2436
- this.recordProtocolViolation();
2503
+ this.recordProtocolViolation("invalid-envelope", { maxByteSize: rawByteLength });
2437
2504
  return;
2438
2505
  }
2439
- this.receiveFrame(frame.data);
2506
+ this.receiveFrame(frame.data, rawByteLength);
2440
2507
  }
2441
2508
 
2442
2509
  private receiveKnownResponse(value: unknown): boolean {
@@ -2461,7 +2528,7 @@ class OmpRpcProcess {
2461
2528
  if (rawId && knownPending) {
2462
2529
  this.takePending(rawId)?.reject(new Error("OMP RPC response is invalid"));
2463
2530
  } else if (!rawId || !this.emitAcceptedPromptFailure(rawId, frame)) {
2464
- this.recordProtocolViolation();
2531
+ this.recordProtocolViolation("invalid-response", { frameType: "response" });
2465
2532
  }
2466
2533
  return;
2467
2534
  }
@@ -2480,13 +2547,19 @@ class OmpRpcProcess {
2480
2547
  : response.data.data;
2481
2548
  const boundedFrame =
2482
2549
  responseData === response.data.data ? frame : { ...frame, data: responseData };
2483
- const responseItemLimit = isBranchHistory ? 1_024 : isHistory ? 100_000 : MAX_ARRAY_ITEMS;
2550
+ const responseItemLimit = isBranchHistory
2551
+ ? 1_024
2552
+ : isHistory
2553
+ ? 100_000
2554
+ : pending.command === "get_available_models"
2555
+ ? MAX_MODEL_CATALOG_ITEMS
2556
+ : MAX_ARRAY_ITEMS;
2484
2557
  const responseByteLimit =
2485
2558
  isBranchHistory || isHistory
2486
2559
  ? Math.min(MAX_REASSEMBLED_FRAME_BYTES, this.reassembledFrameLimit)
2487
2560
  : 2 * 1024 * 1024;
2488
- // A model catalog contains up to 256 structured models, so its aggregate
2489
- // node budget must exceed the small state/command-response budget.
2561
+ // Model catalogs are truncated before publication, while the transport still enforces
2562
+ // aggregate byte and node budgets over the complete response.
2490
2563
  const responseNodeLimit = isBranchHistory
2491
2564
  ? 4_096
2492
2565
  : isHistory
@@ -2610,10 +2683,10 @@ class OmpRpcProcess {
2610
2683
  return true;
2611
2684
  }
2612
2685
 
2613
- private receiveFrame(frame: Record<string, unknown>): void {
2686
+ private receiveFrame(frame: Record<string, unknown>, rawByteLength: number): void {
2614
2687
  const type = typeof frame.type === "string" && frame.type.length <= 64 ? frame.type : null;
2615
2688
  if (!type) {
2616
- this.recordProtocolViolation();
2689
+ this.recordProtocolViolation("invalid-envelope", { maxByteSize: rawByteLength });
2617
2690
  return;
2618
2691
  }
2619
2692
  const safeFrame = mapRuntimeFrameDetails(frame, createOptionalMetadataSanitizer());
@@ -2627,7 +2700,7 @@ class OmpRpcProcess {
2627
2700
  4_096,
2628
2701
  ) === Number.POSITIVE_INFINITY
2629
2702
  ) {
2630
- this.recordProtocolViolation();
2703
+ this.recordProtocolViolation("frame-limit", { maxByteSize: rawByteLength });
2631
2704
  return;
2632
2705
  }
2633
2706
  if (type === "rpc_chunk") {
@@ -2638,20 +2711,20 @@ class OmpRpcProcess {
2638
2711
  }
2639
2712
  if (this.chunk) {
2640
2713
  this.clearChunk();
2641
- this.recordProtocolViolation();
2714
+ this.recordProtocolViolation("interleaved-chunk");
2642
2715
  }
2643
2716
  if (type === "rpc_frame_error") {
2644
- this.recordProtocolViolation();
2717
+ this.recordProtocolViolation("remote-frame-error", { frameType: "rpc_frame_error" });
2645
2718
  return;
2646
2719
  }
2647
2720
  if (type === "ready") {
2648
2721
  if (this.readyReceived) {
2649
- this.recordProtocolViolation();
2722
+ this.recordProtocolViolation("duplicate-ready", { frameType: "ready" });
2650
2723
  return;
2651
2724
  }
2652
2725
  const ready = OmpReadyFrameSchema.safeParse(safeFrame);
2653
2726
  if (!ready.success) {
2654
- this.recordProtocolViolation();
2727
+ this.recordProtocolViolation("invalid-ready", { frameType: "ready" });
2655
2728
  } else {
2656
2729
  this.readyReceived = true;
2657
2730
  this.resolveReady(ready.data);
@@ -2666,11 +2739,11 @@ class OmpRpcProcess {
2666
2739
  if (!event.success) {
2667
2740
  this.rejectMatchingToolApproval(safeFrame);
2668
2741
  if (type === "agent_end" && this.receiveDegradedAgentEnd(safeFrame, false)) return;
2669
- this.recordProtocolViolation();
2742
+ this.recordProtocolViolation("invalid-event", { maxByteSize: rawByteLength });
2670
2743
  return;
2671
2744
  }
2672
2745
  if (!this.acceptEventState(event.data)) {
2673
- this.recordProtocolViolation();
2746
+ this.recordProtocolViolation("invalid-event-state", { maxByteSize: rawByteLength });
2674
2747
  return;
2675
2748
  }
2676
2749
  this.emit(event.data);
@@ -2775,11 +2848,48 @@ class OmpRpcProcess {
2775
2848
 
2776
2849
  private rejectChunk(): void {
2777
2850
  this.clearChunk();
2778
- this.recordProtocolViolation();
2851
+ this.recordProtocolViolation("invalid-chunk", { frameType: "rpc_chunk" });
2852
+ }
2853
+
2854
+ private recordProtocolViolation(
2855
+ category: OmpProtocolViolationCategory,
2856
+ metadata: Omit<OmpProtocolViolationDiagnostic, "category" | "occurrenceCount"> = {},
2857
+ ): void {
2858
+ if (!this.protocolViolationTimer) {
2859
+ this.emitProtocolViolation({ category, occurrenceCount: 1, ...metadata });
2860
+ this.protocolViolationTimer = setTimeout(
2861
+ () => this.flushProtocolViolations(),
2862
+ PROTOCOL_VIOLATION_COALESCE_MS,
2863
+ );
2864
+ return;
2865
+ }
2866
+ const pending = this.pendingProtocolViolations.get(category);
2867
+ if (!pending) {
2868
+ this.pendingProtocolViolations.set(category, { category, occurrenceCount: 1, ...metadata });
2869
+ return;
2870
+ }
2871
+ pending.occurrenceCount = Math.min(Number.MAX_SAFE_INTEGER, pending.occurrenceCount + 1);
2872
+ pending.maxByteSize =
2873
+ Math.max(pending.maxByteSize ?? 0, metadata.maxByteSize ?? 0) || undefined;
2874
+ if (pending.frameType !== metadata.frameType) pending.frameType = undefined;
2875
+ }
2876
+
2877
+ private flushProtocolViolations(): void {
2878
+ if (this.protocolViolationTimer) clearTimeout(this.protocolViolationTimer);
2879
+ this.protocolViolationTimer = null;
2880
+ for (const diagnostic of this.pendingProtocolViolations.values()) {
2881
+ this.emitProtocolViolation(diagnostic);
2882
+ }
2883
+ this.pendingProtocolViolations.clear();
2779
2884
  }
2780
2885
 
2781
- private recordProtocolViolation(): void {
2782
- // Malformed bounded frames are isolated so the following frame starts from clean state.
2886
+ private emitProtocolViolation(diagnostic: OmpProtocolViolationDiagnostic): void {
2887
+ try {
2888
+ const reporting = this.reportProtocolViolation(diagnostic);
2889
+ if (reporting) void reporting.catch(() => undefined);
2890
+ } catch {
2891
+ // Diagnostics must never alter transport flow or cleanup.
2892
+ }
2783
2893
  }
2784
2894
 
2785
2895
  private fail(error: Error): void {
@@ -3128,6 +3238,7 @@ export class OmpRpcRuntime implements OmpRuntime {
3128
3238
  this.options.spawnProcess,
3129
3239
  this.options.terminateProcessTree,
3130
3240
  options.requestTimeoutMs ?? this.options.requestTimeoutMs,
3241
+ this.options.reportProtocolViolation,
3131
3242
  );
3132
3243
  const abort = () => void process.close().catch(() => undefined);
3133
3244
  options.signal?.addEventListener("abort", abort, { once: true });
@@ -11,7 +11,15 @@ import type {
11
11
  ProviderUsage,
12
12
  } from "@getpaseo/plugin/server/provider";
13
13
  import { getForgeDefinitionOrNeutral } from "@getpaseo/protocol/forge-manifest";
14
- import { mapOmpModels, nativeOmpModelId, OMP_MODES, ompModelId, thinkingForModel } from "./catalog";
14
+ import {
15
+ mapOmpModels,
16
+ nativeOmpModelId,
17
+ OMP_MODES,
18
+ ompModelId,
19
+ selectOmpModels,
20
+ thinkingForModel,
21
+ validateOmpModelIdentities,
22
+ } from "./catalog";
15
23
  import {
16
24
  normalizeOmpSessionConfig,
17
25
  type OmpRecoveryOptions,
@@ -939,6 +947,8 @@ export class OmpProviderSession {
939
947
  private readonly pendingToolPermissions = new Map<string, PendingToolPermission>();
940
948
  private readonly inFlightToolPermissions = new Map<string, PendingToolPermission>();
941
949
  private readonly resolvedToolApprovalIds = new BoundedStringSet(MAX_TRACKED_ENTRY_IDS);
950
+ private unsupportedThinkingNoticePending = false;
951
+ private unsupportedThinkingNoticePublished = false;
942
952
 
943
953
  private constructor(
944
954
  id: string,
@@ -1057,10 +1067,9 @@ export class OmpProviderSession {
1057
1067
  }
1058
1068
  const startOptions: OmpStartOptions = {
1059
1069
  ...normalizedConfig,
1060
- // Model selection is authorized only after this runtime reports its exact catalog.
1061
- ...(resumeSessionId
1062
- ? { thinkingOption: undefined, systemPrompt: undefined, resumeSessionId }
1063
- : {}),
1070
+ // Thinking is authorized only after this runtime reports its exact model catalog.
1071
+ thinkingOption: undefined,
1072
+ ...(resumeSessionId ? { systemPrompt: undefined, resumeSessionId } : {}),
1064
1073
  signal,
1065
1074
  environment,
1066
1075
  };
@@ -1096,6 +1105,7 @@ export class OmpProviderSession {
1096
1105
  () => ({ available: false, commands: [] }),
1097
1106
  ),
1098
1107
  ]);
1108
+ validateOmpModelIdentities(nativeModels);
1099
1109
  if (effectiveConfig.persist && !native.canReplayHistory) {
1100
1110
  throw new OmpPublicError("OMP session persistence requires negotiated RPC protocol v2");
1101
1111
  }
@@ -1109,12 +1119,10 @@ export class OmpProviderSession {
1109
1119
  if (resumeSessionId && initialState.sessionId !== resumeSessionId) {
1110
1120
  throw new OmpPublicError("OMP resumed a different native session");
1111
1121
  }
1112
- const models = mapOmpModels(nativeModels, new OmpPublicDataSerializer(outputRedactionValues));
1113
- const nativeModelsByPublicId = new Map(
1114
- nativeModels.map((model) => [ompModelId(model), model] as const),
1115
- );
1116
1122
  if (!resumeSessionId && input.config.model) {
1117
- const selected = nativeModelsByPublicId.get(input.config.model);
1123
+ const selected = selectOmpModels(nativeModels, state.model).find(
1124
+ (model) => ompModelId(model) === input.config.model,
1125
+ );
1118
1126
  if (!selected) {
1119
1127
  throw new OmpPublicError("OMP model is not advertised by the configured session runtime");
1120
1128
  }
@@ -1123,9 +1131,29 @@ export class OmpProviderSession {
1123
1131
  state = await native.getState();
1124
1132
  }
1125
1133
  }
1134
+ let currentModel = state.model
1135
+ ? nativeModels.find(
1136
+ (model) => model.provider === state.model?.provider && model.id === state.model.id,
1137
+ )
1138
+ : undefined;
1139
+ if (state.model && !currentModel) {
1140
+ throw new OmpPublicError("OMP runtime selected an unadvertised model");
1141
+ }
1142
+ if (!resumeSessionId && input.config.thinkingOption !== undefined) {
1143
+ if (
1144
+ !thinkingForModel(currentModel).some(
1145
+ (option) => option.id === input.config.thinkingOption,
1146
+ )
1147
+ ) {
1148
+ throw new OmpPublicError("OMP thinking level is unavailable for the selected model");
1149
+ }
1150
+ if (state.thinkingLevel !== input.config.thinkingOption) {
1151
+ await native.setThinkingLevel(input.config.thinkingOption);
1152
+ }
1153
+ }
1126
1154
  const reconciledConfigRevision = bootstrapConfigRevision;
1127
1155
  state = await native.getState();
1128
- const currentModel = state.model
1156
+ currentModel = state.model
1129
1157
  ? nativeModels.find(
1130
1158
  (model) => model.provider === state.model?.provider && model.id === state.model.id,
1131
1159
  )
@@ -1133,21 +1161,21 @@ export class OmpProviderSession {
1133
1161
  if (state.model && !currentModel) {
1134
1162
  throw new OmpPublicError("OMP runtime selected an unadvertised model");
1135
1163
  }
1164
+ const selectedNativeModels = selectOmpModels(nativeModels, state.model);
1165
+ const models = mapOmpModels(
1166
+ selectedNativeModels,
1167
+ new OmpPublicDataSerializer(outputRedactionValues),
1168
+ );
1169
+ const nativeModelsByPublicId = new Map(
1170
+ nativeModels.map((model) => [ompModelId(model), model] as const),
1171
+ );
1136
1172
  const thinkingOptions = thinkingForModel(currentModel);
1137
- const committedThinkingLevel = applicableThinkingLevel(currentModel, state.thinkingLevel);
1138
- if (
1139
- !resumeSessionId &&
1140
- input.config.thinkingOption !== undefined &&
1141
- !thinkingOptions.some((option) => option.id === input.config.thinkingOption)
1142
- ) {
1143
- throw new OmpPublicError("OMP thinking level is unavailable for the selected model");
1144
- }
1145
- if (
1146
- committedThinkingLevel &&
1147
- !thinkingOptions.some((option) => option.id === committedThinkingLevel)
1148
- ) {
1149
- throw new OmpPublicError("OMP runtime selected an unsupported thinking level");
1150
- }
1173
+ const applicableLevel = applicableThinkingLevel(currentModel, state.thinkingLevel);
1174
+ const committedThinkingLevel = thinkingOptions.some((option) => option.id === applicableLevel)
1175
+ ? applicableLevel
1176
+ : undefined;
1177
+ const unsupportedThinkingLevel =
1178
+ applicableLevel !== undefined && committedThinkingLevel === undefined;
1151
1179
  const configState: ProviderConfigState = {
1152
1180
  ...(state.model ? { model: ompModelId(state.model) } : {}),
1153
1181
  mode: normalizedConfig.mode ?? "full",
@@ -1210,6 +1238,7 @@ export class OmpProviderSession {
1210
1238
  retireRewindSession,
1211
1239
  scheduler,
1212
1240
  );
1241
+ session.unsupportedThinkingNoticePending = unsupportedThinkingLevel;
1213
1242
 
1214
1243
  if (bootstrapConfigRevision !== reconciledConfigRevision) {
1215
1244
  session.configRefreshDirty = true;
@@ -1268,6 +1297,7 @@ export class OmpProviderSession {
1268
1297
  : {}),
1269
1298
  ...(this.config.title ? { title: this.dataFilter.text(this.config.title, 256) } : {}),
1270
1299
  });
1300
+ this.publishUnsupportedThinkingNotice();
1271
1301
  this.emit({ type: "session.config", sessionId: this.id, config: this.configState });
1272
1302
  if (this.replayHistoryOnOpen) await this.replayHistory(true);
1273
1303
  this.publishCommands(this.commandCatalog);
@@ -1275,6 +1305,22 @@ export class OmpProviderSession {
1275
1305
  this.readyPublished = true;
1276
1306
  if (this.configRefreshDirty) this.scheduleCommittedConfigRefresh();
1277
1307
  }
1308
+ private publishUnsupportedThinkingNotice(): void {
1309
+ if (!this.unsupportedThinkingNoticePending || this.unsupportedThinkingNoticePublished) return;
1310
+ this.unsupportedThinkingNoticePending = false;
1311
+ this.unsupportedThinkingNoticePublished = true;
1312
+ this.emit({
1313
+ type: "session.notice",
1314
+ sessionId: this.id,
1315
+ notice: {
1316
+ id: "omp:unsupported-thinking-level",
1317
+ severity: "warning",
1318
+ title: "OMP thinking level unavailable",
1319
+ description:
1320
+ "OMP reported a thinking level that the active model does not advertise. Paseo omitted it from the session configuration.",
1321
+ },
1322
+ });
1323
+ }
1278
1324
 
1279
1325
  private usageFrom(
1280
1326
  state: OmpSessionState | undefined,
@@ -1685,6 +1731,17 @@ export class OmpProviderSession {
1685
1731
  if (this.activeTurn || beforeState.isStreaming || beforeState.isCompacting) {
1686
1732
  throw new OmpPublicError("Cannot rewind the OMP conversation while a turn is active");
1687
1733
  }
1734
+ const beforeAdvertisedModel = beforeState.model
1735
+ ? this.nativeModelsByPublicId.get(ompModelId(beforeState.model))
1736
+ : undefined;
1737
+ if (beforeState.model && !beforeAdvertisedModel) {
1738
+ throw new OmpPublicError("OMP runtime selected an unadvertised model");
1739
+ }
1740
+ const restorableThinkingLevel = thinkingForModel(beforeAdvertisedModel).some(
1741
+ (option) => option.id === beforeState.thinkingLevel,
1742
+ )
1743
+ ? beforeState.thinkingLevel
1744
+ : undefined;
1688
1745
  branchMutationPossible = true;
1689
1746
  let result: OmpBranchResult;
1690
1747
  try {
@@ -1727,13 +1784,10 @@ export class OmpProviderSession {
1727
1784
  }
1728
1785
  await runtime.setModel(beforeState.model.provider, beforeState.model.id);
1729
1786
  }
1730
- if (thinkingChanged) {
1731
- if (!beforeState.thinkingLevel) {
1732
- throw new OmpPublicError("OMP changed thinking level while rewinding the conversation");
1733
- }
1734
- await runtime.setThinkingLevel(beforeState.thinkingLevel);
1787
+ if (thinkingChanged && restorableThinkingLevel) {
1788
+ await runtime.setThinkingLevel(restorableThinkingLevel);
1735
1789
  }
1736
- if (modelChanged || thinkingChanged) {
1790
+ if (modelChanged || (thinkingChanged && restorableThinkingLevel)) {
1737
1791
  state = await runtime.getState();
1738
1792
  this.requireCurrentRuntime(runtime, generation);
1739
1793
  }
@@ -1741,7 +1795,7 @@ export class OmpProviderSession {
1741
1795
  state.sessionId !== this.nativeSessionId ||
1742
1796
  state.model?.provider !== beforeState.model?.provider ||
1743
1797
  state.model?.id !== beforeState.model?.id ||
1744
- state.thinkingLevel !== beforeState.thinkingLevel
1798
+ (restorableThinkingLevel !== undefined && state.thinkingLevel !== restorableThinkingLevel)
1745
1799
  ) {
1746
1800
  throw new OmpPublicError("OMP did not preserve session configuration while rewinding");
1747
1801
  }
@@ -2341,7 +2395,10 @@ export class OmpProviderSession {
2341
2395
  const targetModel = targetModelId
2342
2396
  ? this.nativeModelsByPublicId.get(targetModelId)
2343
2397
  : undefined;
2344
- if (input.changes.model !== undefined && !targetModel) {
2398
+ const targetModelPublished = this.configState.models.some(
2399
+ (model) => model.id === targetModelId,
2400
+ );
2401
+ if (input.changes.model !== undefined && (!targetModel || !targetModelPublished)) {
2345
2402
  throw new OmpPublicError("OMP model selection is unavailable");
2346
2403
  }
2347
2404
  if (
@@ -2588,13 +2645,18 @@ export class OmpProviderSession {
2588
2645
  throw new OmpCatalogEscape("OMP runtime selected an unadvertised model");
2589
2646
  }
2590
2647
  const thinkingOptions = thinkingForModel(advertisedModel);
2591
- const committedThinkingLevel = applicableThinkingLevel(advertisedModel, state.thinkingLevel);
2592
- if (
2593
- committedThinkingLevel &&
2594
- !thinkingOptions.some((option) => option.id === committedThinkingLevel)
2595
- ) {
2596
- throw new OmpCatalogEscape("OMP runtime selected an unsupported thinking level");
2648
+ const applicableLevel = applicableThinkingLevel(advertisedModel, state.thinkingLevel);
2649
+ const committedThinkingLevel = thinkingOptions.some((option) => option.id === applicableLevel)
2650
+ ? applicableLevel
2651
+ : undefined;
2652
+ if (applicableLevel !== undefined && committedThinkingLevel === undefined) {
2653
+ this.unsupportedThinkingNoticePending = true;
2654
+ this.publishUnsupportedThinkingNotice();
2597
2655
  }
2656
+ const models = mapOmpModels(
2657
+ selectOmpModels([...this.nativeModelsByPublicId.values()], state.model),
2658
+ this.dataFilter,
2659
+ );
2598
2660
  this.configRefreshAttempts = 0;
2599
2661
  this.cancelConfigRefreshRetry();
2600
2662
  const nextConfig: ProviderConfigState = {
@@ -2603,12 +2665,15 @@ export class OmpProviderSession {
2603
2665
  ...(committedThinkingLevel
2604
2666
  ? { thinkingOption: committedThinkingLevel }
2605
2667
  : { thinkingOption: undefined }),
2668
+ models,
2606
2669
  thinkingOptions,
2607
2670
  };
2608
2671
  const changed =
2609
2672
  force ||
2610
2673
  nextConfig.model !== this.configState.model ||
2611
2674
  nextConfig.thinkingOption !== this.configState.thinkingOption ||
2675
+ nextConfig.models.some((model, index) => model.id !== this.configState.models[index]?.id) ||
2676
+ this.configState.models.length !== nextConfig.models.length ||
2612
2677
  nextConfig.thinkingOptions.length !== this.configState.thinkingOptions.length ||
2613
2678
  nextConfig.thinkingOptions.some(
2614
2679
  (option, index) =>
@@ -2929,13 +2994,6 @@ export class OmpProviderSession {
2929
2994
  if (state.model && !advertisedModel) {
2930
2995
  throw new Error("OMP recovered with an unadvertised model");
2931
2996
  }
2932
- const recoveredThinkingLevel = applicableThinkingLevel(advertisedModel, state.thinkingLevel);
2933
- if (
2934
- recoveredThinkingLevel &&
2935
- !thinkingForModel(advertisedModel).some((option) => option.id === recoveredThinkingLevel)
2936
- ) {
2937
- throw new Error("OMP recovered with an unsupported thinking level");
2938
- }
2939
2997
  if (this.closed) throw new Error("OMP session closed while runtime recovery was pending");
2940
2998
  if (!this.hostTools.isBoundTo(recovered)) {
2941
2999
  throw new Error("OMP host tool bridge detached during recovery");