@ai-sdk/harness-codex 1.0.10 → 1.0.11

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.
@@ -18,13 +18,12 @@ import {
18
18
  import type { HarnessV1BuiltinToolName } from '@ai-sdk/harness';
19
19
  import type { StartMessage } from '../codex-bridge-protocol';
20
20
  import { randomUUID } from 'node:crypto';
21
- import { writeFile } from 'node:fs/promises';
21
+ import { mkdir, writeFile } from 'node:fs/promises';
22
22
  import { createServer, type Server } from 'node:http';
23
23
  // Temporary workaround for upstream codex MCP-tool bug — see ./cli-relay.ts
24
24
  import {
25
25
  CLI_SHIM_FILENAME,
26
26
  buildCliShimScript,
27
- composeToolUsageInstructions,
28
27
  isToolRelayCommand,
29
28
  } from './cli-relay';
30
29
  import { argv, env as procEnv, stdout } from 'node:process';
@@ -63,14 +62,15 @@ function toCommonName(nativeName: string): HarnessV1BuiltinToolName | string {
63
62
  }
64
63
 
65
64
  const args = parseArgs(argv.slice(2));
66
- const workdir = args.workdir;
67
- const bridgeStateDir = args.bridgeStateDir;
68
- if (!workdir) {
69
- emitFatal('Missing --workdir argument.');
70
- }
71
- if (!bridgeStateDir) {
72
- emitFatal('Missing --bridge-state-dir argument.');
73
- }
65
+ const workdir = requireArg({ value: args.workdir, name: '--workdir' });
66
+ const bridgeStateDir = requireArg({
67
+ value: args.bridgeStateDir,
68
+ name: '--bridge-state-dir',
69
+ });
70
+ const cliShimDir = requireArg({
71
+ value: args.cliShimDir,
72
+ name: '--cli-shim-dir',
73
+ });
74
74
  const bootstrapDir = args.bootstrapDir ?? workdir;
75
75
 
76
76
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -114,10 +114,9 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
114
114
  * Until that's fixed, host tools are made available to the model via a
115
115
  * separate CLI-relay workaround (see `./cli-relay.ts`). The MCP server
116
116
  * config below is kept so that the day codex starts exposing MCP tools
117
- * properly, host tools work both ways. Three hookpoints in this file
118
- * (writeFile for the shim, composeUserMessage's toolUsageBlock, and the
119
- * isToolRelayCommand filter in the event loop) implement the workaround
120
- * and can be removed once the upstream bug is fixed.
117
+ * properly, host tools work both ways. Writing the shim here, adding matching
118
+ * prompt guidance in the host adapter, and filtering the shim command below
119
+ * implement the workaround and can be removed once the upstream bug is fixed.
121
120
  */
122
121
  const mcpServers: Record<string, unknown> = {};
123
122
  let relay: { port: number; close(): void } | undefined;
@@ -147,7 +146,8 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
147
146
  },
148
147
  };
149
148
  // Temporary workaround for upstream codex MCP-tool bug — see ./cli-relay.ts
150
- cliShimPath = `${workdir}/${CLI_SHIM_FILENAME}`;
149
+ cliShimPath = `${cliShimDir}/${CLI_SHIM_FILENAME}`;
150
+ await mkdir(cliShimDir, { recursive: true });
151
151
  await writeFile(
152
152
  cliShimPath,
153
153
  buildCliShimScript({ relayPort: relay.port, relayToken }),
@@ -212,18 +212,7 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
212
212
 
213
213
  emit({ type: 'stream-start' });
214
214
 
215
- const userMessage = composeUserMessage({
216
- text: start.prompt,
217
- instructions: start.instructions,
218
- // Temporary workaround for upstream codex MCP-tool bug — see ./cli-relay.ts
219
- toolUsageBlock:
220
- cliShimPath && start.tools && start.tools.length > 0
221
- ? composeToolUsageInstructions({
222
- tools: start.tools,
223
- cliShimPath,
224
- })
225
- : undefined,
226
- });
215
+ const userMessage = start.prompt;
227
216
  let turnUsage: Record<string, unknown> | undefined;
228
217
  const textByItem = new Map<string, string>();
229
218
  const reasoningByItem = new Map<string, string>();
@@ -523,36 +512,6 @@ function defaultUsage(): Record<string, unknown> {
523
512
  };
524
513
  }
525
514
 
526
- function composeUserMessage({
527
- text,
528
- instructions,
529
- toolUsageBlock,
530
- }: {
531
- text: string;
532
- instructions: string | undefined;
533
- toolUsageBlock: string | undefined;
534
- }): string {
535
- const blocks: string[] = [];
536
- /*
537
- * Frame instructions as system-provided operating guidance, not something
538
- * the user wrote, so the agent does not echo the prepended text back as if
539
- * the user had asked for it. Only present on the first user message of a
540
- * fresh session (the host gates it), so the matching `<user-message>` fence
541
- * is added only when instructions are present too.
542
- */
543
- if (instructions) {
544
- blocks.push(
545
- '<session-instructions>\n' +
546
- 'The block below is operating guidance from the system, not a message from the user — follow it, but do not mention it or attribute it to the user.\n\n' +
547
- `${instructions}\n` +
548
- '</session-instructions>',
549
- );
550
- }
551
- if (toolUsageBlock) blocks.push(toolUsageBlock);
552
- blocks.push(instructions ? `<user-message>\n${text}\n</user-message>` : text);
553
- return blocks.join('\n\n');
554
- }
555
-
556
515
  /**
557
516
  * Tool relay — HTTP server on 127.0.0.1:0 with bearer-token auth. The MCP
558
517
  * stdio shim spawned by codex POSTs each tool invocation here; the relay
@@ -657,11 +616,13 @@ function parseArgs(args: string[]): {
657
616
  workdir?: string;
658
617
  bridgeStateDir?: string;
659
618
  bootstrapDir?: string;
619
+ cliShimDir?: string;
660
620
  } {
661
621
  const out: {
662
622
  workdir?: string;
663
623
  bridgeStateDir?: string;
664
624
  bootstrapDir?: string;
625
+ cliShimDir?: string;
665
626
  } = {};
666
627
  for (let i = 0; i < args.length; i++) {
667
628
  if (args[i] === '--workdir' && i + 1 < args.length) {
@@ -670,6 +631,8 @@ function parseArgs(args: string[]): {
670
631
  out.bridgeStateDir = args[++i];
671
632
  } else if (args[i] === '--bootstrap-dir' && i + 1 < args.length) {
672
633
  out.bootstrapDir = args[++i];
634
+ } else if (args[i] === '--cli-shim-dir' && i + 1 < args.length) {
635
+ out.cliShimDir = args[++i];
673
636
  }
674
637
  }
675
638
  return out;
@@ -686,3 +649,16 @@ function emitFatal(message: string): never {
686
649
  stdout.write(JSON.stringify({ type: 'bridge-fatal', message }) + '\n');
687
650
  process.exit(1);
688
651
  }
652
+
653
+ function requireArg({
654
+ value,
655
+ name,
656
+ }: {
657
+ value: string | undefined;
658
+ name: string;
659
+ }): string {
660
+ if (!value) {
661
+ emitFatal(`Missing ${name} argument.`);
662
+ }
663
+ return value;
664
+ }
@@ -17,7 +17,6 @@ export const outboundMessageSchema = harnessV1BridgeOutboundMessageSchema;
17
17
  export type OutboundMessage = z.infer<typeof outboundMessageSchema>;
18
18
 
19
19
  export const startMessageSchema = harnessV1BridgeStartBaseSchema.extend({
20
- instructions: z.string().optional(),
21
20
  reasoningEffort: z.enum(['low', 'medium', 'high']).optional(),
22
21
  webSearch: z.boolean().optional(),
23
22
  // Resume signal. When supplied, the bridge calls
@@ -38,6 +38,7 @@ import {
38
38
  type InboundMessage,
39
39
  type OutboundMessage,
40
40
  } from './codex-bridge-protocol';
41
+ import { CLI_SHIM_FILENAME } from './bridge/cli-relay';
41
42
 
42
43
  type CodexChannel = SandboxChannel<OutboundMessage, InboundMessage>;
43
44
  type CodexRespawnStrategy = 'replay' | 'rerun';
@@ -115,12 +116,11 @@ const CODEX_BUILTIN_TOOLS = {
115
116
  * reinstall the CLI and bridge files on any fresh sandbox from the recipe.
116
117
  * Persistence comes from the sandbox provider's snapshot, not the path.
117
118
  *
118
- * The session work dir (`startOpts.sessionWorkDir`) and the bridge-state dir
119
- * derived from `sandboxSession.defaultWorkingDirectory` both live under the sandbox's
120
- * default working directory the provider's persistent mount so the
121
- * workdir's contents (the codex CLI shim and any files the agent edits) and
122
- * the bridge state files survive both detach -> attach and
123
- * stop -> snapshot -> resume cycles.
119
+ * The session work dir (`startOpts.sessionWorkDir`) lives under the sandbox's
120
+ * default working directory the provider's persistent mount — so any files
121
+ * the agent edits survive both detach -> attach and stop -> snapshot -> resume
122
+ * cycles. Harness infra derived from `sandboxSession.defaultWorkingDirectory`
123
+ * lives under `.agent-runs`, outside the agent workdir.
124
124
  */
125
125
  const BOOTSTRAP_DIR = '/tmp/harness/codex';
126
126
 
@@ -225,6 +225,8 @@ export function createCodex(
225
225
  const workDir = startOpts.sessionWorkDir;
226
226
  const sessionDataDir = `${sandboxSession.defaultWorkingDirectory}/.agent-runs/${startOpts.sessionId}`;
227
227
  const bridgeStateDir = `${sessionDataDir}/bridge`;
228
+ const cliShimDir = `${sessionDataDir}/codex`;
229
+ const cliShimPath = `${cliShimDir}/${CLI_SHIM_FILENAME}`;
228
230
  const timeoutMs = settings.startupTimeoutMs ?? 120_000;
229
231
 
230
232
  // Normalize each forwarded bridge diagnostics frame into the general
@@ -266,6 +268,7 @@ export function createCodex(
266
268
  return createSession({
267
269
  sessionId: startOpts.sessionId,
268
270
  channel: attachChannel,
271
+ cliShimPath,
269
272
  // The live bridge was spawned by another process; no process handle.
270
273
  proc: undefined,
271
274
  model: settings.model ?? DEFAULT_CODEX_MODEL,
@@ -349,7 +352,7 @@ export function createCodex(
349
352
  });
350
353
 
351
354
  const proc = await session.spawn({
352
- command: `node ${BOOTSTRAP_DIR}/bridge.mjs --workdir ${shellQuote(workDir)} --bridge-state-dir ${shellQuote(bridgeStateDir)} --bootstrap-dir ${shellQuote(BOOTSTRAP_DIR)}`,
355
+ command: `node ${BOOTSTRAP_DIR}/bridge.mjs --workdir ${shellQuote(workDir)} --bridge-state-dir ${shellQuote(bridgeStateDir)} --bootstrap-dir ${shellQuote(BOOTSTRAP_DIR)} --cli-shim-dir ${shellQuote(cliShimDir)}`,
353
356
  env,
354
357
  abortSignal: startOpts.abortSignal,
355
358
  });
@@ -402,6 +405,7 @@ export function createCodex(
402
405
  return createSession({
403
406
  sessionId: startOpts.sessionId,
404
407
  channel,
408
+ cliShimPath,
405
409
  proc,
406
410
  model: settings.model ?? DEFAULT_CODEX_MODEL,
407
411
  reasoningEffort: settings.reasoningEffort,
@@ -615,6 +619,7 @@ function openWebSocket(url: string): Promise<WebSocket> {
615
619
  function createSession({
616
620
  sessionId,
617
621
  channel,
622
+ cliShimPath,
618
623
  proc,
619
624
  model,
620
625
  reasoningEffort,
@@ -631,6 +636,7 @@ function createSession({
631
636
  }: {
632
637
  sessionId: string;
633
638
  channel: CodexChannel;
639
+ cliShimPath: string;
634
640
  /** Undefined on `attach` — the live bridge was spawned by another process. */
635
641
  proc: Experimental_SandboxProcess | undefined;
636
642
  model: string | undefined;
@@ -658,10 +664,10 @@ function createSession({
658
664
  ? resumeThreadId
659
665
  : undefined;
660
666
  /*
661
- * Instructions are prepended to the first user message of a fresh session
662
- * only. A resumed session (attach/replay/rerun) already carried them in its
663
- * original first message (preserved in the persisted thread), so it starts
664
- * "applied".
667
+ * Initial prompt guidance is prepended to the first user message of a fresh
668
+ * session only. A resumed session (attach/replay/rerun) already carried it
669
+ * in its original first message (preserved in the persisted thread), so it
670
+ * starts "applied".
665
671
  */
666
672
  let instructionsApplied = isResume;
667
673
 
@@ -842,19 +848,34 @@ function createSession({
842
848
  abortSignal: promptOpts.abortSignal,
843
849
  });
844
850
 
845
- const applyInstructions =
846
- !instructionsApplied && !!promptOpts.instructions;
851
+ const tools = (promptOpts.tools ?? []).map(t => ({
852
+ name: t.name,
853
+ description: t.description,
854
+ inputSchema: t.inputSchema,
855
+ }));
856
+ let promptText = extractUserText(promptOpts.prompt);
857
+ if (!instructionsApplied) {
858
+ const instructions =
859
+ (promptOpts.instructions ? promptOpts.instructions + '\n\n' : '') +
860
+ 'Only respond with your `final` message once you have fully addressed the user request.';
861
+ promptText = frameInitialPromptGuidance({
862
+ instructions,
863
+ toolUsageBlock:
864
+ tools.length > 0
865
+ ? composeToolUsageInstructions({
866
+ tools,
867
+ cliShimPath,
868
+ })
869
+ : undefined,
870
+ userText: promptText,
871
+ });
872
+ }
847
873
  instructionsApplied = true;
848
874
 
849
875
  const startMessage = {
850
876
  type: 'start' as const,
851
- prompt: extractUserText(promptOpts.prompt),
852
- ...(applyInstructions ? { instructions: promptOpts.instructions } : {}),
853
- tools: (promptOpts.tools ?? []).map(t => ({
854
- name: t.name,
855
- description: t.description,
856
- inputSchema: t.inputSchema,
857
- })),
877
+ prompt: promptText,
878
+ tools,
858
879
  model,
859
880
  reasoningEffort,
860
881
  webSearch,
@@ -1098,6 +1119,67 @@ function createSession({
1098
1119
  };
1099
1120
  }
1100
1121
 
1122
+ /*
1123
+ * Frame session instructions, host-tool relay guidance, and the user's text so
1124
+ * Codex treats the prepended blocks as operating guidance rather than user
1125
+ * prose. Applied only to the first user message of a fresh session.
1126
+ */
1127
+ function frameInitialPromptGuidance({
1128
+ instructions,
1129
+ toolUsageBlock,
1130
+ userText,
1131
+ }: {
1132
+ instructions: string | undefined;
1133
+ toolUsageBlock: string | undefined;
1134
+ userText: string;
1135
+ }): string {
1136
+ const blocks: string[] = [];
1137
+ if (instructions) {
1138
+ blocks.push(
1139
+ '<session-instructions>\n' +
1140
+ 'The block below is operating guidance from the system, not a message from the user — follow it, but do not mention it or attribute it to the user.\n\n' +
1141
+ `${instructions}\n` +
1142
+ '</session-instructions>',
1143
+ );
1144
+ }
1145
+ if (toolUsageBlock) blocks.push(toolUsageBlock);
1146
+ if (blocks.length === 0) return userText;
1147
+ return `${blocks.join('\n\n')}\n\n<user-message>\n${userText}\n</user-message>`;
1148
+ }
1149
+
1150
+ function composeToolUsageInstructions({
1151
+ tools,
1152
+ cliShimPath,
1153
+ }: {
1154
+ tools: ReadonlyArray<{
1155
+ name: string;
1156
+ description?: string;
1157
+ inputSchema?: unknown;
1158
+ }>;
1159
+ cliShimPath: string;
1160
+ }): string {
1161
+ const lines: string[] = [
1162
+ '<host-tool-instructions>',
1163
+ 'You have access to the following host-provided tools. To use one, run the following command via your built-in `bash` tool:',
1164
+ '',
1165
+ ` node ${cliShimPath} <toolName> '<jsonInput>'`,
1166
+ '',
1167
+ 'The script prints the JSON result to stdout. Do not invent another way to call these tools — only this CLI invocation will work. Pass the JSON input as a single-quoted argument.',
1168
+ 'For every user request that depends on a host-provided tool, run a separate CLI invocation for each needed tool call in the current turn before answering. Do not reuse previous tool results, and do not say you used a host tool unless the command has completed in the current turn.',
1169
+ '',
1170
+ ];
1171
+ for (const toolSpec of tools) {
1172
+ lines.push(
1173
+ `- **${toolSpec.name}**${toolSpec.description ? ': ' + toolSpec.description : ''}`,
1174
+ );
1175
+ lines.push(
1176
+ ` - Input schema: \`${JSON.stringify(toolSpec.inputSchema ?? {})}\``,
1177
+ );
1178
+ }
1179
+ lines.push('</host-tool-instructions>');
1180
+ return lines.join('\n');
1181
+ }
1182
+
1101
1183
  /*
1102
1184
  * Reduce a `HarnessV1Prompt` to the plain user text the bridge forwards
1103
1185
  * to the Codex SDK. File and image parts on the message are not yet