baa-conductor


baa-conductor / apps / conductor-daemon / src / instructions
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}