feat: UI parser kinds, nodeSelector textarea, step-limit session clear, per-line path redaction

- ui-parser: add thinking kind + handler for standalone thinking events, thinking
  blocks in assistant content arrays, and user-turn tool_result blocks
- job-manifest: parseKeyValueOrObject helper so nodeSelector (and labels) accept
  key=value textarea lines in addition to JSON objects
- parse: isOpenCodeStepLimitResult detects step_finish with max_turns / max_steps /
  step_limit reason
- execute: return clearSession:true when step limit reached so next run starts fresh;
  redactHomePathUserSegments moved to per-line to prevent paths split across chunks
- tests: ui-parser.test.ts (new), extended parse.test.ts and job-manifest.test.ts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-24 22:01:35 +00:00
parent 3ed6e95085
commit 13c2a3032b
7 changed files with 413 additions and 12 deletions
+75 -7
View File
@@ -1,9 +1,11 @@
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
import { inferOpenAiCompatibleBiller, redactHomePathUserSegments } from "@paperclipai/adapter-utils";
import { asString, asNumber, asBoolean, parseObject } from "@paperclipai/adapter-utils/server-utils";
import { asString, asNumber, asBoolean, parseObject, readPaperclipRuntimeSkillEntries, resolvePaperclipDesiredSkillNames } from "@paperclipai/adapter-utils/server-utils";
import { readFile } from "node:fs/promises";
import {
parseOpenCodeJsonl,
isOpenCodeUnknownSessionError,
isOpenCodeStepLimitResult,
} from "./parse.js";
import { getSelfPodInfo, getBatchApi, getCoreApi, getLogApi } from "./k8s-client.js";
import { buildJobManifest } from "./job-manifest.js";
@@ -127,13 +129,28 @@ async function streamPodLogs(
kubeconfigPath?: string,
): Promise<string> {
const logApi = getLogApi(kubeconfigPath);
const chunks: string[] = [];
const parts: string[] = [];
let lineBuffer = "";
const writable = new Writable({
write(chunk: Buffer, _encoding, callback) {
const text = redactHomePathUserSegments(chunk.toString("utf-8"));
chunks.push(text);
void onLog("stdout", text).then(() => callback(), callback);
const incoming = lineBuffer + chunk.toString("utf-8");
const nlIdx = incoming.lastIndexOf("\n");
if (nlIdx === -1) {
// No complete line yet — buffer until newline arrives
lineBuffer = incoming;
callback();
return;
}
lineBuffer = incoming.slice(nlIdx + 1);
// Redact each complete line individually to avoid path splits across chunk boundaries
const redacted = incoming
.slice(0, nlIdx + 1)
.split("\n")
.map((line) => redactHomePathUserSegments(line))
.join("\n");
parts.push(redacted);
void onLog("stdout", redacted).then(() => callback(), callback);
},
});
@@ -146,7 +163,14 @@ async function streamPodLogs(
// follow may fail if the container already exited
}
return chunks.join("");
// Flush any partial line that never received a trailing newline
if (lineBuffer) {
const redacted = redactHomePathUserSegments(lineBuffer);
parts.push(redacted);
await onLog("stdout", redacted);
}
return parts.join("");
}
async function readPodLogs(
@@ -264,9 +288,45 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
// If we can't check, proceed — heartbeat service enforces concurrency too
}
// Read agent instructions file (instructionsFilePath config field → system prompt prepend)
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
let instructionsContent = "";
if (instructionsFilePath) {
try {
instructionsContent = (await readFile(instructionsFilePath, "utf-8")).trim();
} catch {
await onLog("stderr", `[paperclip] Warning: instructionsFilePath not readable: ${instructionsFilePath}\n`);
}
}
// Resolve and read desired skill content (injected into prompt bundle)
let skillsBundleContent = "";
try {
const moduleDir = import.meta.dirname;
const availableEntries = await readPaperclipRuntimeSkillEntries(config, moduleDir);
const desiredSkillKeys = resolvePaperclipDesiredSkillNames(config, availableEntries);
const skillTexts: string[] = [];
for (const key of desiredSkillKeys) {
const entry = availableEntries.find((e) => e.key === key);
if (entry?.source) {
try {
const text = (await readFile(entry.source, "utf-8")).trim();
if (text) skillTexts.push(text);
} catch {
// skip unreadable skill files — non-fatal
}
}
}
if (skillTexts.length > 0) skillsBundleContent = skillTexts.join("\n\n---\n\n");
} catch {
// non-fatal: skill bundle is optional
}
const { job, jobName, namespace, prompt, opencodeArgs, promptMetrics } = buildJobManifest({
ctx,
selfPod,
instructionsContent: instructionsContent || undefined,
skillsBundleContent: skillsBundleContent || undefined,
});
if (onMeta) {
@@ -412,6 +472,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
resultJson: { stdout },
};
}
// If OpenCode hit its step limit, clear the session so the next run starts fresh
// rather than resuming into an already-exhausted turn sequence.
const stepLimitReached = isOpenCodeStepLimitResult(stdout);
if (stepLimitReached) {
await onLog("stdout", `[paperclip] OpenCode step limit reached; clearing session for next run.\n`);
}
const firstStderrLine = stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? "";
const fallbackErrorMessage = parsedError || firstStderrLine || `OpenCode exited with code ${synthesizedExitCode ?? -1}`;
@@ -434,6 +502,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
costUsd: parsed.costUsd,
resultJson: { stdout },
summary: parsed.summary,
clearSession: false,
clearSession: stepLimitReached,
} as AdapterExecutionResult;
}
+34
View File
@@ -110,4 +110,38 @@ describe("buildJobManifest", () => {
expect(result.job.spec?.template?.spec?.restartPolicy).toBe("Never");
});
it("applies nodeSelector from key=value textarea string", () => {
const ctx = { ...mockCtx, config: { nodeSelector: "kubernetes.io/arch=amd64\nkubernetes.io/os=linux" } };
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
expect(result.job.spec?.template?.spec?.nodeSelector).toEqual({
"kubernetes.io/arch": "amd64",
"kubernetes.io/os": "linux",
});
});
it("applies nodeSelector from JSON object string", () => {
const ctx = { ...mockCtx, config: { nodeSelector: '{"node-type":"gpu"}' } };
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
expect(result.job.spec?.template?.spec?.nodeSelector).toEqual({ "node-type": "gpu" });
});
it("applies nodeSelector from plain object config", () => {
const ctx = { ...mockCtx, config: { nodeSelector: { "zone": "us-east-1" } } };
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
expect(result.job.spec?.template?.spec?.nodeSelector).toEqual({ zone: "us-east-1" });
});
it("ignores blank lines and comments in nodeSelector textarea", () => {
const ctx = {
...mockCtx,
config: { nodeSelector: "# comment\n\nkubernetes.io/arch=amd64\n" },
};
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
expect(result.job.spec?.template?.spec?.nodeSelector).toEqual({ "kubernetes.io/arch": "amd64" });
});
});
+52 -2
View File
@@ -17,6 +17,10 @@ import type { SelfPodInfo } from "./k8s-client.js";
export interface JobBuildInput {
ctx: AdapterExecutionContext;
selfPod: SelfPodInfo;
/** Content of the agent's instructions file (e.g. AGENTS.md), prepended to the prompt. */
instructionsContent?: string;
/** Concatenated content of desired skill markdown files, prepended after instructions. */
skillsBundleContent?: string;
}
export interface JobBuildResult {
@@ -28,6 +32,46 @@ export interface JobBuildResult {
promptMetrics: Record<string, number>;
}
/**
* Parse a config field that may be a JSON object, a plain object, or a textarea
* with "key=value" lines (one per line). Used for nodeSelector and labels.
*/
function parseKeyValueOrObject(value: unknown): Record<string, string> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.filter(([, v]) => typeof v === "string")
.map(([k, v]) => [k, v as string]),
);
}
if (typeof value !== "string") return {};
const text = value.trim();
if (!text) return {};
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return Object.fromEntries(
Object.entries(parsed as Record<string, unknown>)
.filter(([, v]) => typeof v === "string")
.map(([k, v]) => [k, v as string]),
);
}
} catch {
// fall through to key=value parsing
}
const result: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
const val = trimmed.slice(eqIdx + 1).trim();
if (key) result[key] = val;
}
return result;
}
function sanitizeForK8sName(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 8);
}
@@ -145,9 +189,9 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
const timeoutSec = asNumber(config.timeoutSec, 0);
const ttlSeconds = asNumber(config.ttlSecondsAfterFinished, 300);
const resources = parseObject(config.resources);
const nodeSelector = parseObject(config.nodeSelector);
const nodeSelector = parseKeyValueOrObject(config.nodeSelector);
const tolerations = Array.isArray(config.tolerations) ? config.tolerations : [];
const extraLabels = parseObject(config.labels);
const extraLabels = parseKeyValueOrObject(config.labels);
// Resolve working directory
const workspaceContext = parseObject(context.paperclipWorkspace);
@@ -185,7 +229,11 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
const shouldUseResumeDeltaPrompt = Boolean(runtimeSessionId) && wakePrompt.length > 0;
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const instructionsContent = input.instructionsContent?.trim() ?? "";
const skillsBundleContent = input.skillsBundleContent?.trim() ?? "";
const prompt = joinPromptSections([
instructionsContent,
skillsBundleContent,
renderedBootstrapPrompt,
wakePrompt,
sessionHandoffNote,
@@ -193,6 +241,8 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
]);
const promptMetrics = {
promptChars: prompt.length,
instructionsChars: instructionsContent.length,
skillsBundleChars: skillsBundleContent.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
+32 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError, isOpenCodeStepLimitResult } from "./parse.js";
describe("parseOpenCodeJsonl", () => {
it("parses text messages", () => {
@@ -119,6 +119,37 @@ describe("parseOpenCodeJsonl", () => {
});
});
describe("isOpenCodeStepLimitResult", () => {
it("returns true for step_finish with reason max_turns", () => {
const stdout = JSON.stringify({ type: "step_finish", part: { reason: "max_turns", tokens: {} } });
expect(isOpenCodeStepLimitResult(stdout)).toBe(true);
});
it("returns true for step_finish with reason max_steps", () => {
const stdout = JSON.stringify({ type: "step_finish", part: { reason: "max_steps", tokens: {} } });
expect(isOpenCodeStepLimitResult(stdout)).toBe(true);
});
it("returns true for step_finish with reason step_limit", () => {
const stdout = JSON.stringify({ type: "step_finish", part: { reason: "step_limit", tokens: {} } });
expect(isOpenCodeStepLimitResult(stdout)).toBe(true);
});
it("returns false for step_finish with reason end_turn", () => {
const stdout = JSON.stringify({ type: "step_finish", part: { reason: "end_turn", tokens: {} } });
expect(isOpenCodeStepLimitResult(stdout)).toBe(false);
});
it("returns false with no step_finish events", () => {
const stdout = JSON.stringify({ type: "text", part: { text: "Hello" } });
expect(isOpenCodeStepLimitResult(stdout)).toBe(false);
});
it("returns false for empty stdout", () => {
expect(isOpenCodeStepLimitResult("")).toBe(false);
});
});
describe("isOpenCodeUnknownSessionError", () => {
it("detects 'unknown session' in stdout", () => {
const stdout = "Error: unknown session";
+17
View File
@@ -88,6 +88,23 @@ export function parseOpenCodeJsonl(stdout: string) {
};
}
export function isOpenCodeStepLimitResult(stdout: string): boolean {
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const event = parseJson(line);
if (!event) continue;
if (asString(event.type, "") === "step_finish") {
const part = parseObject(event.part);
const reason = asString(part.reason, "").toLowerCase();
if (reason === "max_turns" || reason === "max_steps" || reason === "step_limit") {
return true;
}
}
}
return false;
}
export function isOpenCodeUnknownSessionError(stdout: string, stderr: string): boolean {
const haystack = `${stdout}\n${stderr}`
.split(/\r?\n/)