Skip to content

Commit 7339159

Browse files
authored
feat(agent): instruction-level rtk adoption for codex sessions (#3491)
1 parent 907250c commit 7339159

4 files changed

Lines changed: 168 additions & 6 deletions

File tree

packages/agent/src/adapters/claude/session/rtk.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,17 @@ import { gitSubcommand } from "../git-command";
1515

1616
// Commands RTK compresses faithfully and that have no side effects, so wrapping
1717
// them changes only how much output reaches the model, never what runs.
18-
const RTK_PLAIN_COMMANDS = new Set(["grep", "find", "ls"]);
18+
// Exported so the instruction-level Codex guidance advertises the same set.
19+
export const RTK_PLAIN_COMMANDS = new Set(["grep", "find", "ls"]);
1920

2021
// Git subcommands whose output is worth compressing and that RTK handles
2122
// faithfully. The criterion is compressible output, NOT read-only: RTK never
2223
// changes what runs, so a mutating form (`git tag -d`, `git remote add`,
2324
// `git reflog expire`) still executes its write — its output is just shorter.
2425
// Excludes commit/push: negligible output to compress, and the cloud
2526
// signed-commit guard keys on a leading `git` token that `rtk git …` would hide.
26-
const GIT_COMPRESSIBLE_SUBCOMMANDS = new Set([
27+
// Exported so the instruction-level Codex guidance advertises the same set.
28+
export const GIT_COMPRESSIBLE_SUBCOMMANDS = new Set([
2729
"status",
2830
"diff",
2931
"log",
@@ -44,7 +46,8 @@ const GIT_COMPRESSIBLE_SUBCOMMANDS = new Set([
4446
// wrapping only its head would change the meaning of the rest.
4547
const SHELL_OPERATORS = /[|&;<>`\n]|\$\(/;
4648

47-
function shQuote(value: string): string {
49+
// Exported so the instruction-level Codex guidance quotes the prefix the same way.
50+
export function shQuote(value: string): string {
4851
if (/^[\w./-]+$/.test(value)) return value;
4952
return `'${value.replace(/'/g, `'\\''`)}'`;
5053
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import * as fs from "node:fs";
2+
import * as os from "node:os";
3+
import * as path from "node:path";
4+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
5+
import {
6+
GIT_COMPRESSIBLE_SUBCOMMANDS,
7+
RTK_PLAIN_COMMANDS,
8+
} from "./claude/session/rtk";
9+
import { appendRtkGuidanceForCodex, buildRtkGuidance } from "./rtk-guidance";
10+
11+
describe("rtk guidance for codex", () => {
12+
let dir: string;
13+
let binary: string;
14+
15+
beforeAll(() => {
16+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "rtk-guidance-test-"));
17+
binary = path.join(dir, "rtk");
18+
fs.writeFileSync(binary, "#!/bin/sh\n");
19+
});
20+
21+
afterAll(() => {
22+
fs.rmSync(dir, { recursive: true, force: true });
23+
});
24+
25+
describe("buildRtkGuidance", () => {
26+
// The guidance must advertise exactly the Claude hook's eligibility sets,
27+
// so the token-usage cohorts stay comparable across adapters.
28+
test("advertises every command the Claude hook rewrites", () => {
29+
const guidance = buildRtkGuidance("/usr/local/bin/rtk");
30+
for (const command of RTK_PLAIN_COMMANDS) {
31+
expect(guidance).toContain(command);
32+
}
33+
for (const sub of GIT_COMPRESSIBLE_SUBCOMMANDS) {
34+
expect(guidance).toContain(sub);
35+
}
36+
});
37+
38+
test("uses the resolved binary path in the examples", () => {
39+
const guidance = buildRtkGuidance("/usr/local/bin/rtk");
40+
expect(guidance).toContain("`/usr/local/bin/rtk git status`");
41+
});
42+
43+
// A desktop install can resolve a path with spaces; unquoted it would
44+
// split into multiple shell tokens and every guided command would fail.
45+
test("shell-quotes a binary path containing spaces", () => {
46+
const guidance = buildRtkGuidance("/Apps/PostHog Code/rtk");
47+
expect(guidance).toContain("`'/Apps/PostHog Code/rtk' git status`");
48+
expect(guidance).not.toContain("`/Apps/PostHog Code/rtk git status`");
49+
});
50+
51+
// Parity with the Claude hook's exclusion: prefixing commit/push would
52+
// hide the leading `git` token from the cloud signed-commit guard.
53+
test("forbids prefixing git commit and git push", () => {
54+
const guidance = buildRtkGuidance("rtk");
55+
expect(guidance).toContain("Never prefix `git commit`, `git push`");
56+
});
57+
});
58+
59+
describe("appendRtkGuidanceForCodex", () => {
60+
test("appends guidance when rtk is on PATH", () => {
61+
const result = appendRtkGuidanceForCodex("base instructions", {
62+
PATH: dir,
63+
});
64+
expect(result.startsWith("base instructions\n\n")).toBe(true);
65+
expect(result).toContain("rtk command-output compression");
66+
expect(result).toContain(binary);
67+
});
68+
69+
// POSTHOG_RTK=0 is set per run from the cloud kill-switch flag; it must
70+
// silence the guidance too, which is why the gate is resolveRtkPrefix
71+
// rather than detectRtkBinary.
72+
test.each([["0"], ["false"]])(
73+
"returns instructions unchanged when POSTHOG_RTK is %s",
74+
(value) => {
75+
expect(
76+
appendRtkGuidanceForCodex("base instructions", {
77+
POSTHOG_RTK: value,
78+
PATH: dir,
79+
}),
80+
).toBe("base instructions");
81+
},
82+
);
83+
84+
test("returns instructions unchanged when rtk is not installed", () => {
85+
expect(
86+
appendRtkGuidanceForCodex("base instructions", {
87+
PATH: "/nonexistent",
88+
}),
89+
).toBe("base instructions");
90+
});
91+
92+
test("does not leave a leading separator when instructions are empty", () => {
93+
const result = appendRtkGuidanceForCodex("", { PATH: dir });
94+
expect(result.startsWith("## rtk")).toBe(true);
95+
});
96+
});
97+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {
2+
GIT_COMPRESSIBLE_SUBCOMMANDS,
3+
RTK_PLAIN_COMMANDS,
4+
resolveRtkPrefix,
5+
shQuote,
6+
} from "./claude/session/rtk";
7+
8+
/**
9+
* Instruction-level RTK integration for Codex sessions.
10+
*
11+
* The Claude adapter routes eligible commands through RTK deterministically
12+
* with a PreToolUse hook that rewrites the Bash input. Codex executes shell
13+
* commands internally over JSON-RPC and its app-server protocol has no
14+
* command-rewrite channel — the adapter can only approve or deny — so the
15+
* only integration point is the developer instructions: tell the model to
16+
* prefix eligible commands itself.
17+
*
18+
* The advertised command set and rules mirror the Claude hook exactly
19+
* (RTK_PLAIN_COMMANDS + GIT_COMPRESSIBLE_SUBCOMMANDS, bare invocations only,
20+
* never commit/push), so token-usage cohorts stay comparable across adapters.
21+
*/
22+
export function buildRtkGuidance(rtkPrefix: string): string {
23+
// Same quoting as the Claude rewrite hook: a resolved path containing
24+
// spaces must stay one shell token in the commands the model copies.
25+
const prefix = shQuote(rtkPrefix);
26+
const plainCommands = [...RTK_PLAIN_COMMANDS].join("`, `");
27+
const gitSubcommands = [...GIT_COMPRESSIBLE_SUBCOMMANDS].join(", ");
28+
29+
return `## rtk command-output compression
30+
31+
\`${prefix}\` is installed. It runs a command unchanged and compresses its output before you read it, so prefixed commands cost far less context. When you execute one of these as a single, bare command, prefix it with \`${prefix}\`:
32+
33+
- \`${plainCommands}\`
34+
- these git subcommands: ${gitSubcommands}
35+
36+
Examples: \`${prefix} git status\`, \`${prefix} grep -rn "foo" src\`, \`${prefix} ls -la\`.
37+
38+
Rules:
39+
- Only prefix a single bare invocation. Never use it when the command is part of a pipe, uses \`&&\`, \`;\`, or redirection, or when another program parses the output — compression would corrupt what the consumer reads.
40+
- Never prefix \`git commit\`, \`git push\`, or any other command not listed above.
41+
- Skip the prefix when you need the exact, complete output (for example, copying a diff verbatim).`;
42+
}
43+
44+
/**
45+
* Appends the RTK guidance to Codex developer instructions when an RTK binary
46+
* is usable. Gated on `resolveRtkPrefix` — not `detectRtkBinary` — so the
47+
* per-run `POSTHOG_RTK=0` opt-out (the cloud kill-switch flag) disables the
48+
* guidance along with everything else.
49+
*/
50+
export function appendRtkGuidanceForCodex(
51+
instructions: string,
52+
env: NodeJS.ProcessEnv = process.env,
53+
): string {
54+
const rtkPrefix = resolveRtkPrefix(env);
55+
if (!rtkPrefix) return instructions;
56+
return [instructions, buildRtkGuidance(rtkPrefix)]
57+
.filter(Boolean)
58+
.join("\n\n");
59+
}

packages/agent/src/server/agent-server.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
classifyAgentError,
4646
isPromptTooLongError,
4747
} from "../adapters/error-classification";
48+
import { appendRtkGuidanceForCodex } from "../adapters/rtk-guidance";
4849
import {
4950
SIGNED_COMMIT_QUALIFIED_TOOL_NAME,
5051
SIGNED_MERGE_QUALIFIED_TOOL_NAME,
@@ -3022,9 +3023,11 @@ export class AgentServer {
30223023
private buildCodexInstructions(
30233024
systemPrompt: string | { append: string },
30243025
): string {
3025-
return typeof systemPrompt === "string"
3026-
? systemPrompt
3027-
: systemPrompt.append;
3026+
const instructions =
3027+
typeof systemPrompt === "string" ? systemPrompt : systemPrompt.append;
3028+
// Codex has no command-rewrite hook (see rtk-guidance.ts), so RTK is
3029+
// adopted through the developer instructions instead.
3030+
return appendRtkGuidanceForCodex(instructions);
30283031
}
30293032

30303033
/**

0 commit comments

Comments
 (0)