From 99c97c1fb28fb60a2cfcc2428415282f7720c432 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 22 Apr 2026 02:08:24 +0000 Subject: [PATCH] feat: add native Node.js RTK output filtering (FAR-66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the init-container RTK binary approach with a self-contained Node.js implementation. When `enableRtk: true` is set in adapter config, the job's main container startup: 1. Writes a Node.js filter script to /tmp/.rtk-filter.js (base64-encoded inline — no curl, no wget, no external binary download required). 2. Merges a PostToolUse hook into ~/.claude/settings.json so Claude Code runs the filter after every tool call. 3. The filter truncates tool_response/tool_result content that exceeds `rtkMaxOutputBytes` (default: 50 000 B), handling both string and array (text-block) content formats. New config fields: enableRtk toggle — off by default rtkMaxOutputBytes number — truncation threshold (default 50 000) 9 new tests cover: command shape, ordering, no-external-binary guarantee, threshold injection, PostToolUse hook presence, and filter-script logic. Co-Authored-By: Paperclip --- src/server/config-schema.ts | 15 ++++++ src/server/job-manifest.test.ts | 89 +++++++++++++++++++++++++++++++- src/server/job-manifest.ts | 90 ++++++++++++++++++++++++++++++++- 3 files changed, 192 insertions(+), 2 deletions(-) diff --git a/src/server/config-schema.ts b/src/server/config-schema.ts index 7c97a0f..9e3de9a 100644 --- a/src/server/config-schema.ts +++ b/src/server/config-schema.ts @@ -133,6 +133,21 @@ export function getConfigSchema(): AdapterConfigSchema { label: "Labels", hint: "Extra labels added to Job metadata. One key=value per line.", }, + // Output filtering (RTK-compatible) + { + type: "toggle", + key: "enableRtk", + label: "Enable Output Filtering", + hint: "Truncate oversized tool outputs before they reach the model, reducing token consumption. Implemented natively in Node.js — no external binary required. Installs a PostToolUse hook in ~/.claude/settings.json for each run.", + default: false, + }, + { + type: "number", + key: "rtkMaxOutputBytes", + label: "Max Tool Output Bytes", + hint: "Maximum bytes of tool output to pass to the model when output filtering is enabled. Outputs exceeding this threshold are truncated with a summary. Default: 50000.", + default: 50000, + }, ]; return { fields }; diff --git a/src/server/job-manifest.test.ts b/src/server/job-manifest.test.ts index b1ef2d1..dde4479 100644 --- a/src/server/job-manifest.test.ts +++ b/src/server/job-manifest.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; -import { buildJobManifest } from "./job-manifest.js"; +import { buildJobManifest, buildRtkSetupCommands } from "./job-manifest.js"; import type { SelfPodInfo } from "./k8s-client.js"; function makeCtx(overrides: Partial = {}): AdapterExecutionContext { @@ -641,4 +641,91 @@ describe("buildJobManifest", () => { expect(init?.env?.[0]?.name).toBe("PROMPT_CONTENT"); }); }); + + describe("rtk output filtering", () => { + it("does not modify main command when enableRtk is false (default)", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + const cmd = job.spec?.template?.spec?.containers[0]?.command; + // Command should be the plain `cat ... | claude ...` form with no rtk setup + expect(cmd?.[2]).toMatch(/^cat \/tmp\/prompt\/prompt\.txt \| claude /); + expect(cmd?.[2]).not.toContain("rtk-filter"); + }); + + it("prepends RTK setup commands when enableRtk is true", () => { + ctx.config = { enableRtk: true }; + const { job } = buildJobManifest({ ctx, selfPod }); + const cmd = job.spec?.template?.spec?.containers[0]?.command; + expect(cmd?.[2]).toContain(".rtk-filter.js"); + expect(cmd?.[2]).toContain("cat /tmp/prompt/prompt.txt | claude"); + }); + + it("RTK setup runs before claude invocation", () => { + ctx.config = { enableRtk: true }; + const { job } = buildJobManifest({ ctx, selfPod }); + const cmd = job.spec?.template?.spec?.containers[0]?.command?.[2] ?? ""; + const rtkIdx = cmd.indexOf(".rtk-filter.js"); + const claudeIdx = cmd.indexOf("cat /tmp/prompt/prompt.txt | claude"); + expect(rtkIdx).toBeGreaterThanOrEqual(0); + expect(claudeIdx).toBeGreaterThan(rtkIdx); + }); + + it("RTK setup uses node (no external binaries)", () => { + ctx.config = { enableRtk: true }; + const { job } = buildJobManifest({ ctx, selfPod }); + const cmd = job.spec?.template?.spec?.containers[0]?.command?.[2] ?? ""; + // Should only use `node` — no curl, wget, apt, pip, etc. + expect(cmd).not.toMatch(/\b(curl|wget|apt|yum|pip|gem|cargo|go\s+get)\b/); + expect(cmd).toContain("node "); + }); + + it("uses default 50000 byte threshold when rtkMaxOutputBytes not set", () => { + ctx.config = { enableRtk: true }; + const setup = buildRtkSetupCommands(50000); + // The filter script base64 should decode to contain the MAX constant + const b64Match = setup.match(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/); + expect(b64Match).not.toBeNull(); + const decoded = Buffer.from(b64Match![1], "base64").toString("utf-8"); + expect(decoded).toContain("50000"); + }); + + it("respects custom rtkMaxOutputBytes", () => { + ctx.config = { enableRtk: true, rtkMaxOutputBytes: 100000 }; + const { job } = buildJobManifest({ ctx, selfPod }); + const cmd = job.spec?.template?.spec?.containers[0]?.command?.[2] ?? ""; + // The custom threshold should appear in the base64-encoded filter script + const b64Matches = [...cmd.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)]; + const decoded = b64Matches.map((m) => Buffer.from(m[1], "base64").toString("utf-8")).join("\n"); + expect(decoded).toContain("100000"); + }); + + it("RTK setup installs a PostToolUse hook in claude settings", () => { + const setup = buildRtkSetupCommands(50000); + // The settings script (second base64 block) should reference PostToolUse + const b64Matches = [...setup.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)]; + expect(b64Matches.length).toBeGreaterThanOrEqual(2); + const settingsScript = Buffer.from(b64Matches[1]![1], "base64").toString("utf-8"); + expect(settingsScript).toContain("PostToolUse"); + expect(settingsScript).toContain("settings.json"); + }); + + it("filter script handles string content truncation", () => { + // Decode the filter script and verify it truncates string content + const setup = buildRtkSetupCommands(1000); + const b64Matches = [...setup.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)]; + const filterScript = Buffer.from(b64Matches[0]![1], "base64").toString("utf-8"); + expect(filterScript).toContain("MAX=1000"); + expect(filterScript).toContain("truncated by paperclip-rtk"); + expect(filterScript).toContain("tool_response"); + expect(filterScript).toContain("tool_result"); + }); + + it("filter script handles array content (block format)", () => { + const setup = buildRtkSetupCommands(50000); + const b64Matches = [...setup.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)]; + const filterScript = Buffer.from(b64Matches[0]![1], "base64").toString("utf-8"); + // Should handle array content blocks (text field on each block) + expect(filterScript).toContain("Array.isArray"); + expect(filterScript).toContain("b.text"); + }); + }); }); diff --git a/src/server/job-manifest.ts b/src/server/job-manifest.ts index b72f272..0ca96da 100644 --- a/src/server/job-manifest.ts +++ b/src/server/job-manifest.ts @@ -11,6 +11,86 @@ import { } from "@paperclipai/adapter-utils/server-utils"; import { createHash } from "node:crypto"; +/** + * Build the shell command prefix that installs a native Node.js PostToolUse + * hook into Claude Code's settings. The hook truncates oversized tool outputs + * before they reach the model — replacing the RTK binary init-container + * approach with a self-contained Node.js implementation. + * + * Both scripts are base64-encoded so they can be embedded in a sh -c command + * string without any quoting or escaping issues. + * + * @param maxOutputBytes Byte threshold above which tool output is truncated. + * @returns A shell command string (suitable for "&&"-chaining + * before the claude invocation). + */ +export function buildRtkSetupCommands(maxOutputBytes: number): string { + // --- Filter script ---------------------------------------------------------- + // This script runs as the PostToolUse hook inside every K8s Job pod. + // Claude Code writes the hook event as JSON to the script's stdin; the script + // truncates the tool_response/tool_result content when it exceeds the + // threshold and writes the (possibly modified) JSON to stdout. + // + // Field-name coverage: + // • tool_response — documented hook event format for PostToolUse + // • tool_result — alternative name seen in some Claude Code versions + // Content may be a plain string or an array of typed blocks (text/image/…). + const filterScript = [ + `const c=[];`, + `process.stdin.on('data',d=>c.push(d));`, + `process.stdin.on('end',()=>{`, + `const raw=Buffer.concat(c).toString('utf-8');`, + `let o;try{o=JSON.parse(raw);}catch{process.stdout.write(raw);return;}`, + `const MAX=${maxOutputBytes};`, + `function trunc(s){`, + `if(typeof s!=='string')return s;`, + `const b=Buffer.from(s,'utf-8');`, + `if(b.length<=MAX)return s;`, + `return b.slice(0,MAX).toString('utf-8')+'\\n[...'+(b.length-MAX)+' bytes truncated by paperclip-rtk]';`, + `}`, + `const tr=o&&(o.tool_response||o.tool_result);`, + `if(tr){`, + `if(typeof tr.content==='string'){tr.content=trunc(tr.content);}`, + `else if(Array.isArray(tr.content)){`, + `tr.content=tr.content.map(function(b){`, + `if(b&&typeof b==='object'&&typeof b.text==='string'){`, + `return Object.assign({},b,{text:trunc(b.text)});`, + `}return b;`, + `});`, + `}`, + `}`, + `process.stdout.write(JSON.stringify(o));`, + `});`, + ].join(""); + + // --- Settings script -------------------------------------------------------- + // Reads the existing ~/.claude/settings.json (if any), merges in the RTK + // PostToolUse hook, and writes the file back. All other settings sections + // are preserved; only PostToolUse is replaced so we own the full hook list + // for this run. + const settingsScript = [ + `const fs=require('fs'),pt=require('path');`, + `const p=pt.join(process.env.HOME,'.claude','settings.json');`, + `let s={};try{s=JSON.parse(fs.readFileSync(p,'utf-8'));}catch(e){}`, + `s.hooks=s.hooks||{};`, + `s.hooks.PostToolUse=[{matcher:'.*',hooks:[{type:'command',command:'node /tmp/.rtk-filter.js'}]}];`, + `fs.mkdirSync(pt.dirname(p),{recursive:true});`, + `fs.writeFileSync(p,JSON.stringify(s));`, + ].join(""); + + // Encode as base64 so the strings can be embedded directly in a shell command + // without any quoting concerns (base64 alphabet: A-Za-z0-9+/=). + const filterB64 = Buffer.from(filterScript, "utf-8").toString("base64"); + const settingsB64 = Buffer.from(settingsScript, "utf-8").toString("base64"); + + return [ + // Write the filter script + `node -e "require('fs').writeFileSync('/tmp/.rtk-filter.js',Buffer.from('${filterB64}','base64').toString('utf-8'))"`, + // Install the Claude Code PostToolUse hook (merge into existing settings) + `node -e "eval(Buffer.from('${settingsB64}','base64').toString('utf-8'))"`, + ].join(" && "); +} + /** Prompts above this size (bytes) are staged via a Secret instead of an * init container env var, protecting against the ~1 MiB PodSpec limit. */ const LARGE_PROMPT_THRESHOLD_BYTES = 256 * 1024; @@ -255,6 +335,8 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { const nodeSelector = parseKeyValueConfig(config.nodeSelector); const tolerations = Array.isArray(config.tolerations) ? config.tolerations : []; const extraLabels = parseKeyValueConfig(config.labels); + const enableRtk = asBoolean(config.enableRtk, false); + const rtkMaxOutputBytes = asNumber(config.rtkMaxOutputBytes, 50000); // Resolve working directory — use workspace cwd, fall back to /paperclip const workspaceContext = parseObject(context.paperclipWorkspace); @@ -408,7 +490,13 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { // Build the claude command string for the main container const claudeArgsEscaped = claudeArgs.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" "); - const mainCommand = `cat /tmp/prompt/prompt.txt | claude ${claudeArgsEscaped}`; + const claudeInvocation = `cat /tmp/prompt/prompt.txt | claude ${claudeArgsEscaped}`; + // When RTK output filtering is enabled, prepend the Node.js hook setup. + // This writes a filter script and a Claude Code settings file that installs + // it as a PostToolUse hook — no external binary or init container required. + const mainCommand = enableRtk + ? `${buildRtkSetupCommands(rtkMaxOutputBytes)} && ${claudeInvocation}` + : claudeInvocation; // Decide prompt delivery strategy: env var (small) or Secret volume (large). const promptBytes = Buffer.byteLength(prompt, "utf-8");