feat: add native Node.js RTK output filtering (FAR-66)

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 <noreply@paperclip.ing>
This commit is contained in:
Test User
2026-04-22 02:08:24 +00:00
parent 5926b302e5
commit 99c97c1fb2
3 changed files with 192 additions and 2 deletions
+15
View File
@@ -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 };
+88 -1
View File
@@ -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> = {}): 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");
});
});
});
+89 -1
View File
@@ -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");