refactor(src): split oversized modules
This commit is contained in:
173
src/agents/pi-embedded-helpers/bootstrap.ts
Normal file
173
src/agents/pi-embedded-helpers/bootstrap.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { WorkspaceBootstrapFile } from "../workspace.js";
|
||||
import type { EmbeddedContextFile } from "./types.js";
|
||||
|
||||
type ContentBlockWithSignature = {
|
||||
thought_signature?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Strips Claude-style thought_signature fields from content blocks.
|
||||
*
|
||||
* Gemini expects thought signatures as base64-encoded bytes, but Claude stores message ids
|
||||
* like "msg_abc123...". We only strip "msg_*" to preserve any provider-valid signatures.
|
||||
*/
|
||||
export function stripThoughtSignatures<T>(content: T): T {
|
||||
if (!Array.isArray(content)) return content;
|
||||
return content.map((block) => {
|
||||
if (!block || typeof block !== "object") return block;
|
||||
const rec = block as ContentBlockWithSignature;
|
||||
const signature = rec.thought_signature;
|
||||
if (typeof signature !== "string" || !signature.startsWith("msg_")) {
|
||||
return block;
|
||||
}
|
||||
const { thought_signature: _signature, ...rest } = rec;
|
||||
return rest;
|
||||
}) as T;
|
||||
}
|
||||
|
||||
export const DEFAULT_BOOTSTRAP_MAX_CHARS = 20_000;
|
||||
const BOOTSTRAP_HEAD_RATIO = 0.7;
|
||||
const BOOTSTRAP_TAIL_RATIO = 0.2;
|
||||
|
||||
type TrimBootstrapResult = {
|
||||
content: string;
|
||||
truncated: boolean;
|
||||
maxChars: number;
|
||||
originalLength: number;
|
||||
};
|
||||
|
||||
export function resolveBootstrapMaxChars(cfg?: ClawdbotConfig): number {
|
||||
const raw = cfg?.agents?.defaults?.bootstrapMaxChars;
|
||||
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
|
||||
return Math.floor(raw);
|
||||
}
|
||||
return DEFAULT_BOOTSTRAP_MAX_CHARS;
|
||||
}
|
||||
|
||||
function trimBootstrapContent(
|
||||
content: string,
|
||||
fileName: string,
|
||||
maxChars: number,
|
||||
): TrimBootstrapResult {
|
||||
const trimmed = content.trimEnd();
|
||||
if (trimmed.length <= maxChars) {
|
||||
return {
|
||||
content: trimmed,
|
||||
truncated: false,
|
||||
maxChars,
|
||||
originalLength: trimmed.length,
|
||||
};
|
||||
}
|
||||
|
||||
const headChars = Math.floor(maxChars * BOOTSTRAP_HEAD_RATIO);
|
||||
const tailChars = Math.floor(maxChars * BOOTSTRAP_TAIL_RATIO);
|
||||
const head = trimmed.slice(0, headChars);
|
||||
const tail = trimmed.slice(-tailChars);
|
||||
|
||||
const marker = [
|
||||
"",
|
||||
`[...truncated, read ${fileName} for full content...]`,
|
||||
`…(truncated ${fileName}: kept ${headChars}+${tailChars} chars of ${trimmed.length})…`,
|
||||
"",
|
||||
].join("\n");
|
||||
const contentWithMarker = [head, marker, tail].join("\n");
|
||||
return {
|
||||
content: contentWithMarker,
|
||||
truncated: true,
|
||||
maxChars,
|
||||
originalLength: trimmed.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureSessionHeader(params: {
|
||||
sessionFile: string;
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
}) {
|
||||
const file = params.sessionFile;
|
||||
try {
|
||||
await fs.stat(file);
|
||||
return;
|
||||
} catch {
|
||||
// create
|
||||
}
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
const sessionVersion = 2;
|
||||
const entry = {
|
||||
type: "session",
|
||||
version: sessionVersion,
|
||||
id: params.sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: params.cwd,
|
||||
};
|
||||
await fs.writeFile(file, `${JSON.stringify(entry)}\n`, "utf-8");
|
||||
}
|
||||
|
||||
export function buildBootstrapContextFiles(
|
||||
files: WorkspaceBootstrapFile[],
|
||||
opts?: { warn?: (message: string) => void; maxChars?: number },
|
||||
): EmbeddedContextFile[] {
|
||||
const maxChars = opts?.maxChars ?? DEFAULT_BOOTSTRAP_MAX_CHARS;
|
||||
const result: EmbeddedContextFile[] = [];
|
||||
for (const file of files) {
|
||||
if (file.missing) {
|
||||
result.push({
|
||||
path: file.name,
|
||||
content: `[MISSING] Expected at: ${file.path}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const trimmed = trimBootstrapContent(
|
||||
file.content ?? "",
|
||||
file.name,
|
||||
maxChars,
|
||||
);
|
||||
if (!trimmed.content) continue;
|
||||
if (trimmed.truncated) {
|
||||
opts?.warn?.(
|
||||
`workspace bootstrap file ${file.name} is ${trimmed.originalLength} chars (limit ${trimmed.maxChars}); truncating in injected context`,
|
||||
);
|
||||
}
|
||||
result.push({
|
||||
path: file.name,
|
||||
content: trimmed.content,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sanitizeGoogleTurnOrdering(
|
||||
messages: AgentMessage[],
|
||||
): AgentMessage[] {
|
||||
const GOOGLE_TURN_ORDER_BOOTSTRAP_TEXT = "(session bootstrap)";
|
||||
const first = messages[0] as
|
||||
| { role?: unknown; content?: unknown }
|
||||
| undefined;
|
||||
const role = first?.role;
|
||||
const content = first?.content;
|
||||
if (
|
||||
role === "user" &&
|
||||
typeof content === "string" &&
|
||||
content.trim() === GOOGLE_TURN_ORDER_BOOTSTRAP_TEXT
|
||||
) {
|
||||
return messages;
|
||||
}
|
||||
if (role !== "assistant") return messages;
|
||||
|
||||
// Cloud Code Assist rejects histories that begin with a model turn (tool call or text).
|
||||
// Prepend a tiny synthetic user turn so the rest of the transcript can be used.
|
||||
const bootstrap: AgentMessage = {
|
||||
role: "user",
|
||||
content: GOOGLE_TURN_ORDER_BOOTSTRAP_TEXT,
|
||||
timestamp: Date.now(),
|
||||
} as AgentMessage;
|
||||
|
||||
return [bootstrap, ...messages];
|
||||
}
|
||||
Reference in New Issue
Block a user