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:
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user