From 2bcea3294f341ba9cbdc19b40fa7598e78281b35 Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Thu, 16 Jul 2026 11:06:56 +0100 Subject: [PATCH] feat(agent): instruction-level rtk adoption for codex sessions The Claude adapter routes eligible commands through rtk deterministically via a PreToolUse hook, but the Codex app-server protocol has no command-rewrite channel, so cloud Codex runs never used the rtk binary installed in the sandbox. Append rtk usage guidance to the Codex developer instructions instead, mirroring the Claude hook's eligibility sets and gated on resolveRtkPrefix so the per-run POSTHOG_RTK=0 kill switch also disables it. Generated-By: PostHog Code Task-Id: b005dede-2677-4364-94a2-c2a7e90e376a --- .../agent/src/adapters/claude/session/rtk.ts | 9 +- .../agent/src/adapters/rtk-guidance.test.ts | 97 +++++++++++++++++++ packages/agent/src/adapters/rtk-guidance.ts | 59 +++++++++++ packages/agent/src/server/agent-server.ts | 9 +- 4 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 packages/agent/src/adapters/rtk-guidance.test.ts create mode 100644 packages/agent/src/adapters/rtk-guidance.ts diff --git a/packages/agent/src/adapters/claude/session/rtk.ts b/packages/agent/src/adapters/claude/session/rtk.ts index c609d7ee4d..0b7d256826 100644 --- a/packages/agent/src/adapters/claude/session/rtk.ts +++ b/packages/agent/src/adapters/claude/session/rtk.ts @@ -15,7 +15,8 @@ import { gitSubcommand } from "../git-command"; // Commands RTK compresses faithfully and that have no side effects, so wrapping // them changes only how much output reaches the model, never what runs. -const RTK_PLAIN_COMMANDS = new Set(["grep", "find", "ls"]); +// Exported so the instruction-level Codex guidance advertises the same set. +export const RTK_PLAIN_COMMANDS = new Set(["grep", "find", "ls"]); // Git subcommands whose output is worth compressing and that RTK handles // faithfully. The criterion is compressible output, NOT read-only: RTK never @@ -23,7 +24,8 @@ const RTK_PLAIN_COMMANDS = new Set(["grep", "find", "ls"]); // `git reflog expire`) still executes its write — its output is just shorter. // Excludes commit/push: negligible output to compress, and the cloud // signed-commit guard keys on a leading `git` token that `rtk git …` would hide. -const GIT_COMPRESSIBLE_SUBCOMMANDS = new Set([ +// Exported so the instruction-level Codex guidance advertises the same set. +export const GIT_COMPRESSIBLE_SUBCOMMANDS = new Set([ "status", "diff", "log", @@ -44,7 +46,8 @@ const GIT_COMPRESSIBLE_SUBCOMMANDS = new Set([ // wrapping only its head would change the meaning of the rest. const SHELL_OPERATORS = /[|&;<>`\n]|\$\(/; -function shQuote(value: string): string { +// Exported so the instruction-level Codex guidance quotes the prefix the same way. +export function shQuote(value: string): string { if (/^[\w./-]+$/.test(value)) return value; return `'${value.replace(/'/g, `'\\''`)}'`; } diff --git a/packages/agent/src/adapters/rtk-guidance.test.ts b/packages/agent/src/adapters/rtk-guidance.test.ts new file mode 100644 index 0000000000..1664ca1a1c --- /dev/null +++ b/packages/agent/src/adapters/rtk-guidance.test.ts @@ -0,0 +1,97 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + GIT_COMPRESSIBLE_SUBCOMMANDS, + RTK_PLAIN_COMMANDS, +} from "./claude/session/rtk"; +import { appendRtkGuidanceForCodex, buildRtkGuidance } from "./rtk-guidance"; + +describe("rtk guidance for codex", () => { + let dir: string; + let binary: string; + + beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "rtk-guidance-test-")); + binary = path.join(dir, "rtk"); + fs.writeFileSync(binary, "#!/bin/sh\n"); + }); + + afterAll(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + describe("buildRtkGuidance", () => { + // The guidance must advertise exactly the Claude hook's eligibility sets, + // so the token-usage cohorts stay comparable across adapters. + test("advertises every command the Claude hook rewrites", () => { + const guidance = buildRtkGuidance("/usr/local/bin/rtk"); + for (const command of RTK_PLAIN_COMMANDS) { + expect(guidance).toContain(command); + } + for (const sub of GIT_COMPRESSIBLE_SUBCOMMANDS) { + expect(guidance).toContain(sub); + } + }); + + test("uses the resolved binary path in the examples", () => { + const guidance = buildRtkGuidance("/usr/local/bin/rtk"); + expect(guidance).toContain("`/usr/local/bin/rtk git status`"); + }); + + // A desktop install can resolve a path with spaces; unquoted it would + // split into multiple shell tokens and every guided command would fail. + test("shell-quotes a binary path containing spaces", () => { + const guidance = buildRtkGuidance("/Apps/PostHog Code/rtk"); + expect(guidance).toContain("`'/Apps/PostHog Code/rtk' git status`"); + expect(guidance).not.toContain("`/Apps/PostHog Code/rtk git status`"); + }); + + // Parity with the Claude hook's exclusion: prefixing commit/push would + // hide the leading `git` token from the cloud signed-commit guard. + test("forbids prefixing git commit and git push", () => { + const guidance = buildRtkGuidance("rtk"); + expect(guidance).toContain("Never prefix `git commit`, `git push`"); + }); + }); + + describe("appendRtkGuidanceForCodex", () => { + test("appends guidance when rtk is on PATH", () => { + const result = appendRtkGuidanceForCodex("base instructions", { + PATH: dir, + }); + expect(result.startsWith("base instructions\n\n")).toBe(true); + expect(result).toContain("rtk command-output compression"); + expect(result).toContain(binary); + }); + + // POSTHOG_RTK=0 is set per run from the cloud kill-switch flag; it must + // silence the guidance too, which is why the gate is resolveRtkPrefix + // rather than detectRtkBinary. + test.each([["0"], ["false"]])( + "returns instructions unchanged when POSTHOG_RTK is %s", + (value) => { + expect( + appendRtkGuidanceForCodex("base instructions", { + POSTHOG_RTK: value, + PATH: dir, + }), + ).toBe("base instructions"); + }, + ); + + test("returns instructions unchanged when rtk is not installed", () => { + expect( + appendRtkGuidanceForCodex("base instructions", { + PATH: "/nonexistent", + }), + ).toBe("base instructions"); + }); + + test("does not leave a leading separator when instructions are empty", () => { + const result = appendRtkGuidanceForCodex("", { PATH: dir }); + expect(result.startsWith("## rtk")).toBe(true); + }); + }); +}); diff --git a/packages/agent/src/adapters/rtk-guidance.ts b/packages/agent/src/adapters/rtk-guidance.ts new file mode 100644 index 0000000000..962913a32c --- /dev/null +++ b/packages/agent/src/adapters/rtk-guidance.ts @@ -0,0 +1,59 @@ +import { + GIT_COMPRESSIBLE_SUBCOMMANDS, + RTK_PLAIN_COMMANDS, + resolveRtkPrefix, + shQuote, +} from "./claude/session/rtk"; + +/** + * Instruction-level RTK integration for Codex sessions. + * + * The Claude adapter routes eligible commands through RTK deterministically + * with a PreToolUse hook that rewrites the Bash input. Codex executes shell + * commands internally over JSON-RPC and its app-server protocol has no + * command-rewrite channel — the adapter can only approve or deny — so the + * only integration point is the developer instructions: tell the model to + * prefix eligible commands itself. + * + * The advertised command set and rules mirror the Claude hook exactly + * (RTK_PLAIN_COMMANDS + GIT_COMPRESSIBLE_SUBCOMMANDS, bare invocations only, + * never commit/push), so token-usage cohorts stay comparable across adapters. + */ +export function buildRtkGuidance(rtkPrefix: string): string { + // Same quoting as the Claude rewrite hook: a resolved path containing + // spaces must stay one shell token in the commands the model copies. + const prefix = shQuote(rtkPrefix); + const plainCommands = [...RTK_PLAIN_COMMANDS].join("`, `"); + const gitSubcommands = [...GIT_COMPRESSIBLE_SUBCOMMANDS].join(", "); + + return `## rtk command-output compression + +\`${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}\`: + +- \`${plainCommands}\` +- these git subcommands: ${gitSubcommands} + +Examples: \`${prefix} git status\`, \`${prefix} grep -rn "foo" src\`, \`${prefix} ls -la\`. + +Rules: +- 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. +- Never prefix \`git commit\`, \`git push\`, or any other command not listed above. +- Skip the prefix when you need the exact, complete output (for example, copying a diff verbatim).`; +} + +/** + * Appends the RTK guidance to Codex developer instructions when an RTK binary + * is usable. Gated on `resolveRtkPrefix` — not `detectRtkBinary` — so the + * per-run `POSTHOG_RTK=0` opt-out (the cloud kill-switch flag) disables the + * guidance along with everything else. + */ +export function appendRtkGuidanceForCodex( + instructions: string, + env: NodeJS.ProcessEnv = process.env, +): string { + const rtkPrefix = resolveRtkPrefix(env); + if (!rtkPrefix) return instructions; + return [instructions, buildRtkGuidance(rtkPrefix)] + .filter(Boolean) + .join("\n\n"); +} diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 7de8e1eb78..55a88fe26d 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -43,6 +43,7 @@ import { classifyAgentError, isPromptTooLongError, } from "../adapters/error-classification"; +import { appendRtkGuidanceForCodex } from "../adapters/rtk-guidance"; import { SIGNED_COMMIT_QUALIFIED_TOOL_NAME, SIGNED_MERGE_QUALIFIED_TOOL_NAME, @@ -2941,9 +2942,11 @@ export class AgentServer { private buildCodexInstructions( systemPrompt: string | { append: string }, ): string { - return typeof systemPrompt === "string" - ? systemPrompt - : systemPrompt.append; + const instructions = + typeof systemPrompt === "string" ? systemPrompt : systemPrompt.append; + // Codex has no command-rewrite hook (see rtk-guidance.ts), so RTK is + // adopted through the developer instructions instead. + return appendRtkGuidanceForCodex(instructions); } /**