codex@macbookpro
·
2026-04-01
router.ts
1import type {
2 BaaInstructionEnvelope,
3 BaaInstructionRoute,
4 BaaJsonObject
5} from "./types.js";
6import {
7 DEFAULT_BAA_EXEC_INSTRUCTION_TIMEOUT_MS,
8 DEFAULT_BAA_INSTRUCTION_TIMEOUT_MS
9} from "../execution-timeouts.js";
10import { isBaaJsonObject } from "./types.js";
11
12export {
13 DEFAULT_BAA_EXEC_INSTRUCTION_TIMEOUT_MS,
14 DEFAULT_BAA_INSTRUCTION_TIMEOUT_MS
15};
16
17export class BaaInstructionRouteError extends Error {
18 readonly blockIndex: number;
19 readonly stage = "route";
20
21 constructor(blockIndex: number, message: string) {
22 super(message);
23 this.blockIndex = blockIndex;
24 }
25}
26
27const INTERNAL_CONVERSATION_AUTOMATION_CONTROL_PATH = "/v1/internal/automation/conversations/control";
28
29function requireNonEmptyStringParam(
30 instruction: BaaInstructionEnvelope,
31 label: string,
32 allowNewlines = false
33): string {
34 if (typeof instruction.params !== "string") {
35 throw new BaaInstructionRouteError(
36 instruction.blockIndex,
37 `${instruction.target}::${instruction.tool} requires ${label} as a string.`
38 );
39 }
40
41 const normalized = instruction.params.trim();
42
43 if (normalized === "") {
44 throw new BaaInstructionRouteError(
45 instruction.blockIndex,
46 `${instruction.target}::${instruction.tool} requires a non-empty ${label}.`
47 );
48 }
49
50 if (!allowNewlines && normalized.includes("\n")) {
51 throw new BaaInstructionRouteError(
52 instruction.blockIndex,
53 `${instruction.target}::${instruction.tool} does not accept multiline ${label}.`
54 );
55 }
56
57 return normalized;
58}
59
60function requireJsonObjectParams(instruction: BaaInstructionEnvelope): BaaJsonObject {
61 if (!isBaaJsonObject(instruction.params)) {
62 throw new BaaInstructionRouteError(
63 instruction.blockIndex,
64 `${instruction.target}::${instruction.tool} requires JSON object params.`
65 );
66 }
67
68 return instruction.params;
69}
70
71function requireNoParams(instruction: BaaInstructionEnvelope): void {
72 if (instruction.paramsKind !== "none") {
73 throw new BaaInstructionRouteError(
74 instruction.blockIndex,
75 `${instruction.target}::${instruction.tool} does not accept params.`
76 );
77 }
78}
79
80function withRouteTimeout(
81 route: Omit<BaaInstructionRoute, "timeoutMs">,
82 timeoutMs = DEFAULT_BAA_INSTRUCTION_TIMEOUT_MS
83): BaaInstructionRoute {
84 return {
85 ...route,
86 timeoutMs
87 };
88}
89
90function applyDefaultExecTimeout(body: BaaJsonObject): BaaJsonObject {
91 if (
92 Object.prototype.hasOwnProperty.call(body, "timeoutMs")
93 || Object.prototype.hasOwnProperty.call(body, "timeout_ms")
94 ) {
95 return body;
96 }
97
98 return {
99 ...body,
100 timeoutMs: DEFAULT_BAA_EXEC_INSTRUCTION_TIMEOUT_MS
101 };
102}
103
104function normalizeExecBody(instruction: BaaInstructionEnvelope): BaaJsonObject {
105 if (typeof instruction.params === "string") {
106 return applyDefaultExecTimeout({
107 command: requireNonEmptyStringParam(instruction, "command", true)
108 });
109 }
110
111 const params = requireJsonObjectParams(instruction);
112 const command = params.command;
113
114 if (typeof command !== "string" || command.trim() === "") {
115 throw new BaaInstructionRouteError(
116 instruction.blockIndex,
117 `${instruction.target}::${instruction.tool} JSON params must include a non-empty "command".`
118 );
119 }
120
121 return applyDefaultExecTimeout(params);
122}
123
124function normalizeFileReadBody(instruction: BaaInstructionEnvelope): BaaJsonObject {
125 if (typeof instruction.params === "string") {
126 return {
127 path: requireNonEmptyStringParam(instruction, "path")
128 };
129 }
130
131 const params = requireJsonObjectParams(instruction);
132 const path = params.path;
133
134 if (typeof path !== "string" || path.trim() === "") {
135 throw new BaaInstructionRouteError(
136 instruction.blockIndex,
137 `${instruction.target}::${instruction.tool} JSON params must include a non-empty "path".`
138 );
139 }
140
141 return params;
142}
143
144function normalizeFileWriteBody(instruction: BaaInstructionEnvelope): BaaJsonObject {
145 const params = requireJsonObjectParams(instruction);
146 const path = params.path;
147 const content = params.content;
148
149 if (typeof path !== "string" || path.trim() === "") {
150 throw new BaaInstructionRouteError(
151 instruction.blockIndex,
152 `${instruction.target}::${instruction.tool} JSON params must include a non-empty "path".`
153 );
154 }
155
156 if (typeof content !== "string") {
157 throw new BaaInstructionRouteError(
158 instruction.blockIndex,
159 `${instruction.target}::${instruction.tool} JSON params must include string "content".`
160 );
161 }
162
163 return params;
164}
165
166function normalizeBrowserSendBody(instruction: BaaInstructionEnvelope): BaaJsonObject {
167 if (typeof instruction.params === "string") {
168 return {
169 prompt: requireNonEmptyStringParam(instruction, "prompt", true)
170 };
171 }
172
173 const params = requireJsonObjectParams(instruction);
174 const prompt = params.prompt;
175 const unexpectedKeys = Object.keys(params).filter((key) => key !== "prompt");
176
177 if (typeof prompt !== "string" || prompt.trim() === "") {
178 throw new BaaInstructionRouteError(
179 instruction.blockIndex,
180 `${instruction.target}::${instruction.tool} JSON params must include a non-empty "prompt".`
181 );
182 }
183
184 if (unexpectedKeys.length > 0) {
185 throw new BaaInstructionRouteError(
186 instruction.blockIndex,
187 `${instruction.target}::${instruction.tool} JSON params only support string "prompt".`
188 );
189 }
190
191 return {
192 prompt: prompt.trim()
193 };
194}
195
196function normalizeConversationControlScope(
197 instruction: BaaInstructionEnvelope,
198 params: BaaJsonObject | null
199): { scope: "current" } {
200 const scope = params?.scope;
201
202 if (scope == null) {
203 return {
204 scope: "current"
205 };
206 }
207
208 if (scope !== "current") {
209 throw new BaaInstructionRouteError(
210 instruction.blockIndex,
211 `${instruction.target}::${instruction.tool} only supports scope:"current".`
212 );
213 }
214
215 return {
216 scope
217 };
218}
219
220function buildCurrentConversationControlBody(
221 instruction: BaaInstructionEnvelope,
222 input: {
223 action: "mode" | "pause" | "resume";
224 defaultMode?: "auto" | "manual" | "paused";
225 defaultReason?: string | null;
226 requireMode?: boolean;
227 }
228): BaaJsonObject {
229 const params = instruction.paramsKind === "none" ? null : requireJsonObjectParams(instruction);
230 const { scope } = normalizeConversationControlScope(instruction, params);
231 const modeValue = params?.mode;
232 const resolvedMode =
233 input.requireMode === true
234 ? modeValue
235 : (modeValue ?? input.defaultMode ?? null);
236
237 if (
238 resolvedMode != null
239 && resolvedMode !== "auto"
240 && resolvedMode !== "manual"
241 && resolvedMode !== "paused"
242 ) {
243 throw new BaaInstructionRouteError(
244 instruction.blockIndex,
245 `${instruction.target}::${instruction.tool} mode must be one of auto, manual, paused.`
246 );
247 }
248
249 const reasonValue = params?.reason;
250
251 if (reasonValue != null && typeof reasonValue !== "string") {
252 throw new BaaInstructionRouteError(
253 instruction.blockIndex,
254 `${instruction.target}::${instruction.tool} reason must be a string when provided.`
255 );
256 }
257
258 return {
259 action: input.action,
260 assistant_message_id: instruction.assistantMessageId,
261 mode: resolvedMode as BaaJsonObject["mode"],
262 platform: instruction.platform,
263 reason: (reasonValue ?? input.defaultReason ?? null) as BaaJsonObject["reason"],
264 scope,
265 source_conversation_id: instruction.conversationId
266 };
267}
268
269export function isAutomationControlInstruction(
270 instruction: Pick<BaaInstructionEnvelope, "target" | "tool">
271): boolean {
272 if (instruction.target !== "conductor" && instruction.target !== "system") {
273 return false;
274 }
275
276 return [
277 "conversation/mode",
278 "conversation/pause",
279 "conversation/resume",
280 "system/pause",
281 "system/resume"
282 ].includes(instruction.tool);
283}
284
285function routeLocalInstruction(instruction: BaaInstructionEnvelope): BaaInstructionRoute {
286 switch (instruction.tool) {
287 case "describe":
288 requireNoParams(instruction);
289 return withRouteTimeout({
290 body: null,
291 key: "local.describe",
292 method: "GET",
293 path: "/describe",
294 requiresSharedToken: false
295 });
296 case "describe/business":
297 requireNoParams(instruction);
298 return withRouteTimeout({
299 body: null,
300 key: "local.describe.business",
301 method: "GET",
302 path: "/describe/business",
303 requiresSharedToken: false
304 });
305 case "describe/control":
306 requireNoParams(instruction);
307 return withRouteTimeout({
308 body: null,
309 key: "local.describe.control",
310 method: "GET",
311 path: "/describe/control",
312 requiresSharedToken: false
313 });
314 case "status":
315 requireNoParams(instruction);
316 return withRouteTimeout({
317 body: null,
318 key: "local.status",
319 method: "GET",
320 path: "/v1/status",
321 requiresSharedToken: false
322 });
323 case "system/pause":
324 requireNoParams(instruction);
325 return withRouteTimeout({
326 body: null,
327 key: "local.system.pause",
328 method: "POST",
329 path: "/v1/system/pause",
330 requiresSharedToken: false
331 });
332 case "system/resume":
333 requireNoParams(instruction);
334 return withRouteTimeout({
335 body: null,
336 key: "local.system.resume",
337 method: "POST",
338 path: "/v1/system/resume",
339 requiresSharedToken: false
340 });
341 case "conversation/pause":
342 return withRouteTimeout({
343 body: buildCurrentConversationControlBody(instruction, {
344 action: "pause",
345 defaultReason: "ai_pause"
346 }),
347 key: "local.conversation.pause",
348 method: "POST",
349 path: INTERNAL_CONVERSATION_AUTOMATION_CONTROL_PATH,
350 requiresSharedToken: false
351 });
352 case "conversation/resume":
353 return withRouteTimeout({
354 body: buildCurrentConversationControlBody(instruction, {
355 action: "resume"
356 }),
357 key: "local.conversation.resume",
358 method: "POST",
359 path: INTERNAL_CONVERSATION_AUTOMATION_CONTROL_PATH,
360 requiresSharedToken: false
361 });
362 case "conversation/mode":
363 return withRouteTimeout({
364 body: buildCurrentConversationControlBody(instruction, {
365 action: "mode",
366 requireMode: true
367 }),
368 key: "local.conversation.mode",
369 method: "POST",
370 path: INTERNAL_CONVERSATION_AUTOMATION_CONTROL_PATH,
371 requiresSharedToken: false
372 });
373 case "exec":
374 return withRouteTimeout({
375 body: normalizeExecBody(instruction),
376 key: "local.exec",
377 method: "POST",
378 path: "/v1/exec",
379 requiresSharedToken: true
380 }, DEFAULT_BAA_EXEC_INSTRUCTION_TIMEOUT_MS);
381 case "files/read":
382 return withRouteTimeout({
383 body: normalizeFileReadBody(instruction),
384 key: "local.files.read",
385 method: "POST",
386 path: "/v1/files/read",
387 requiresSharedToken: true
388 });
389 case "files/write":
390 return withRouteTimeout({
391 body: normalizeFileWriteBody(instruction),
392 key: "local.files.write",
393 method: "POST",
394 path: "/v1/files/write",
395 requiresSharedToken: true
396 });
397 default:
398 throw new BaaInstructionRouteError(
399 instruction.blockIndex,
400 `No Phase 1 route exists for ${instruction.target}::${instruction.tool}.`
401 );
402 }
403}
404
405function routeBrowserInstruction(
406 instruction: BaaInstructionEnvelope,
407 platform: "claude" | "chatgpt" | "gemini"
408): BaaInstructionRoute {
409 switch (instruction.tool) {
410 case "send":
411 return withRouteTimeout({
412 body: normalizeBrowserSendBody(instruction),
413 key: `local.browser.${platform}.send`,
414 method: "POST",
415 path: `/v1/browser/${platform}/send`,
416 requiresSharedToken: false
417 });
418 case "current":
419 requireNoParams(instruction);
420 return withRouteTimeout({
421 body: null,
422 key: `local.browser.${platform}.current`,
423 method: "GET",
424 path: `/v1/browser/${platform}/current`,
425 requiresSharedToken: false
426 });
427 default:
428 throw new BaaInstructionRouteError(
429 instruction.blockIndex,
430 `No Phase 1 route exists for ${instruction.target}::${instruction.tool}.`
431 );
432 }
433}
434
435export function routeBaaInstruction(instruction: BaaInstructionEnvelope): BaaInstructionRoute {
436 switch (instruction.target) {
437 case "conductor":
438 case "system":
439 return routeLocalInstruction(instruction);
440 case "browser.claude":
441 return routeBrowserInstruction(instruction, "claude");
442 case "browser.chatgpt":
443 return routeBrowserInstruction(instruction, "chatgpt");
444 case "browser.gemini":
445 return routeBrowserInstruction(instruction, "gemini");
446 default:
447 throw new BaaInstructionRouteError(
448 instruction.blockIndex,
449 `No Phase 1 route exists for ${instruction.target}::${instruction.tool}.`
450 );
451 }
452}