feat: prune default values from company portability exports
Add default-value pruning for runtime config and adapter config during export, producing cleaner bundles. Includes per-adapter defaults for claude_local, codex_local, and openclaw. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -55,6 +55,36 @@ type AgentLike = {
|
|||||||
adapterConfig: Record<string, unknown>;
|
adapterConfig: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RUNTIME_DEFAULT_RULES: Array<{ path: string[]; value: unknown }> = [
|
||||||
|
{ path: ["heartbeat", "cooldownSec"], value: 10 },
|
||||||
|
{ path: ["heartbeat", "intervalSec"], value: 3600 },
|
||||||
|
{ path: ["heartbeat", "wakeOnOnDemand"], value: true },
|
||||||
|
{ path: ["heartbeat", "wakeOnAssignment"], value: true },
|
||||||
|
{ path: ["heartbeat", "wakeOnAutomation"], value: true },
|
||||||
|
{ path: ["heartbeat", "wakeOnDemand"], value: true },
|
||||||
|
{ path: ["heartbeat", "maxConcurrentRuns"], value: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; value: unknown }>> = {
|
||||||
|
codex_local: [
|
||||||
|
{ path: ["timeoutSec"], value: 0 },
|
||||||
|
{ path: ["graceSec"], value: 15 },
|
||||||
|
],
|
||||||
|
claude_local: [
|
||||||
|
{ path: ["timeoutSec"], value: 0 },
|
||||||
|
{ path: ["graceSec"], value: 15 },
|
||||||
|
{ path: ["maxTurnsPerRun"], value: 80 },
|
||||||
|
],
|
||||||
|
openclaw: [
|
||||||
|
{ path: ["method"], value: "POST" },
|
||||||
|
{ path: ["timeoutSec"], value: 30 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
function asString(value: unknown): string | null {
|
function asString(value: unknown): string | null {
|
||||||
if (typeof value !== "string") return null;
|
if (typeof value !== "string") return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -153,22 +183,122 @@ function normalizePortableConfig(
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function jsonEqual(left: unknown, right: unknown): boolean {
|
||||||
|
return JSON.stringify(left) === JSON.stringify(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathDefault(pathSegments: string[], value: unknown, rules: Array<{ path: string[]; value: unknown }>) {
|
||||||
|
return rules.some((rule) => jsonEqual(rule.path, pathSegments) && jsonEqual(rule.value, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneDefaultLikeValue(
|
||||||
|
value: unknown,
|
||||||
|
opts: {
|
||||||
|
dropFalseBooleans: boolean;
|
||||||
|
path?: string[];
|
||||||
|
defaultRules?: Array<{ path: string[]; value: unknown }>;
|
||||||
|
},
|
||||||
|
): unknown {
|
||||||
|
const pathSegments = opts.path ?? [];
|
||||||
|
if (opts.defaultRules && isPathDefault(pathSegments, value, opts.defaultRules)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((entry) => pruneDefaultLikeValue(entry, { ...opts, path: pathSegments }));
|
||||||
|
}
|
||||||
|
if (isPlainRecord(value)) {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, entry] of Object.entries(value)) {
|
||||||
|
const next = pruneDefaultLikeValue(entry, {
|
||||||
|
...opts,
|
||||||
|
path: [...pathSegments, key],
|
||||||
|
});
|
||||||
|
if (next === undefined) continue;
|
||||||
|
out[key] = next;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (opts.dropFalseBooleans && value === false) return undefined;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderYamlScalar(value: unknown): string {
|
||||||
|
if (value === null) return "null";
|
||||||
|
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
||||||
|
if (typeof value === "string") return JSON.stringify(value);
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmptyObject(value: unknown): boolean {
|
||||||
|
return isPlainRecord(value) && Object.keys(value).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderYamlBlock(value: unknown, indentLevel: number): string[] {
|
||||||
|
const indent = " ".repeat(indentLevel);
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) return [`${indent}[]`];
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const entry of value) {
|
||||||
|
const scalar =
|
||||||
|
entry === null ||
|
||||||
|
typeof entry === "string" ||
|
||||||
|
typeof entry === "boolean" ||
|
||||||
|
typeof entry === "number" ||
|
||||||
|
Array.isArray(entry) && entry.length === 0 ||
|
||||||
|
isEmptyObject(entry);
|
||||||
|
if (scalar) {
|
||||||
|
lines.push(`${indent}- ${renderYamlScalar(entry)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lines.push(`${indent}-`);
|
||||||
|
lines.push(...renderYamlBlock(entry, indentLevel + 1));
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainRecord(value)) {
|
||||||
|
const entries = Object.entries(value);
|
||||||
|
if (entries.length === 0) return [`${indent}{}`];
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const [key, entry] of entries) {
|
||||||
|
const scalar =
|
||||||
|
entry === null ||
|
||||||
|
typeof entry === "string" ||
|
||||||
|
typeof entry === "boolean" ||
|
||||||
|
typeof entry === "number" ||
|
||||||
|
Array.isArray(entry) && entry.length === 0 ||
|
||||||
|
isEmptyObject(entry);
|
||||||
|
if (scalar) {
|
||||||
|
lines.push(`${indent}${key}: ${renderYamlScalar(entry)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lines.push(`${indent}${key}:`);
|
||||||
|
lines.push(...renderYamlBlock(entry, indentLevel + 1));
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [`${indent}${renderYamlScalar(value)}`];
|
||||||
|
}
|
||||||
|
|
||||||
function renderFrontmatter(frontmatter: Record<string, unknown>) {
|
function renderFrontmatter(frontmatter: Record<string, unknown>) {
|
||||||
const lines = ["---"];
|
const lines: string[] = ["---"];
|
||||||
for (const [key, value] of Object.entries(frontmatter)) {
|
for (const [key, value] of Object.entries(frontmatter)) {
|
||||||
if (value === null) {
|
const scalar =
|
||||||
lines.push(`${key}: null`);
|
value === null ||
|
||||||
|
typeof value === "string" ||
|
||||||
|
typeof value === "boolean" ||
|
||||||
|
typeof value === "number" ||
|
||||||
|
Array.isArray(value) && value.length === 0 ||
|
||||||
|
isEmptyObject(value);
|
||||||
|
if (scalar) {
|
||||||
|
lines.push(`${key}: ${renderYamlScalar(value)}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (typeof value === "boolean" || typeof value === "number") {
|
lines.push(`${key}:`);
|
||||||
lines.push(`${key}: ${String(value)}`);
|
lines.push(...renderYamlBlock(value, 1));
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
lines.push(`${key}: ${JSON.stringify(value)}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
lines.push(`${key}: ${JSON.stringify(value)}`);
|
|
||||||
}
|
}
|
||||||
lines.push("---");
|
lines.push("---");
|
||||||
return `${lines.join("\n")}\n`;
|
return `${lines.join("\n")}\n`;
|
||||||
@@ -182,6 +312,18 @@ function buildMarkdown(frontmatter: Record<string, unknown>, body: string) {
|
|||||||
return `${renderFrontmatter(frontmatter)}\n${cleanBody}\n`;
|
return `${renderFrontmatter(frontmatter)}\n${cleanBody}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderCompanyAgentsSection(agentSummaries: Array<{ slug: string; name: string }>) {
|
||||||
|
const lines = ["# Agents", ""];
|
||||||
|
if (agentSummaries.length === 0) {
|
||||||
|
lines.push("- _none_");
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
for (const agent of agentSummaries) {
|
||||||
|
lines.push(`- ${agent.slug} - ${agent.name}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function parseFrontmatterMarkdown(raw: string): MarkdownDoc {
|
function parseFrontmatterMarkdown(raw: string): MarkdownDoc {
|
||||||
const normalized = raw.replace(/\r\n/g, "\n");
|
const normalized = raw.replace(/\r\n/g, "\n");
|
||||||
if (!normalized.startsWith("---\n")) {
|
if (!normalized.startsWith("---\n")) {
|
||||||
@@ -278,25 +420,41 @@ function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath:
|
|||||||
async function readAgentInstructions(agent: AgentLike): Promise<{ body: string; warning: string | null }> {
|
async function readAgentInstructions(agent: AgentLike): Promise<{ body: string; warning: string | null }> {
|
||||||
const config = agent.adapterConfig as Record<string, unknown>;
|
const config = agent.adapterConfig as Record<string, unknown>;
|
||||||
const instructionsFilePath = asString(config.instructionsFilePath);
|
const instructionsFilePath = asString(config.instructionsFilePath);
|
||||||
if (instructionsFilePath && path.isAbsolute(instructionsFilePath)) {
|
if (instructionsFilePath) {
|
||||||
|
const workspaceCwd = asString(process.env.PAPERCLIP_WORKSPACE_CWD);
|
||||||
|
const candidates = new Set<string>();
|
||||||
|
if (path.isAbsolute(instructionsFilePath)) {
|
||||||
|
candidates.add(instructionsFilePath);
|
||||||
|
} else {
|
||||||
|
if (workspaceCwd) candidates.add(path.resolve(workspaceCwd, instructionsFilePath));
|
||||||
|
candidates.add(path.resolve(process.cwd(), instructionsFilePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(instructionsFilePath);
|
const stat = await fs.stat(candidate);
|
||||||
if (stat.isFile() && stat.size <= 1024 * 1024) {
|
if (!stat.isFile() || stat.size > 1024 * 1024) continue;
|
||||||
const body = await Promise.race([
|
const body = await Promise.race([
|
||||||
fs.readFile(instructionsFilePath, "utf8"),
|
fs.readFile(candidate, "utf8"),
|
||||||
new Promise<string>((_, reject) => {
|
new Promise<string>((_, reject) => {
|
||||||
setTimeout(() => reject(new Error("timed out reading instructions file")), 1500);
|
setTimeout(() => reject(new Error("timed out reading instructions file")), 1500);
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
return { body, warning: null };
|
return { body, warning: null };
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// fall through to promptTemplate fallback
|
// try next candidate
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const promptTemplate = asString(config.promptTemplate);
|
const promptTemplate = asString(config.promptTemplate);
|
||||||
if (promptTemplate) {
|
if (promptTemplate) {
|
||||||
return { body: promptTemplate, warning: null };
|
const warning = instructionsFilePath
|
||||||
|
? `Agent ${agent.name} instructionsFilePath was not readable; fell back to promptTemplate.`
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
body: promptTemplate,
|
||||||
|
warning,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
body: "_No AGENTS instructions were resolved from current agent config._",
|
body: "_No AGENTS instructions were resolved from current agent config._",
|
||||||
@@ -383,10 +541,11 @@ export function companyPortabilityService(db: Db) {
|
|||||||
const files: Record<string, string> = {};
|
const files: Record<string, string> = {};
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const requiredSecrets: CompanyPortabilityManifest["requiredSecrets"] = [];
|
const requiredSecrets: CompanyPortabilityManifest["requiredSecrets"] = [];
|
||||||
|
const generatedAt = new Date().toISOString();
|
||||||
|
|
||||||
const manifest: CompanyPortabilityManifest = {
|
const manifest: CompanyPortabilityManifest = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt,
|
||||||
source: {
|
source: {
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
companyName: company.name,
|
companyName: company.name,
|
||||||
@@ -397,15 +556,38 @@ export function companyPortabilityService(db: Db) {
|
|||||||
requiredSecrets: [],
|
requiredSecrets: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const allAgentRows = include.agents ? await agents.list(companyId) : [];
|
||||||
|
const agentRows = allAgentRows.filter((agent) => agent.status !== "terminated");
|
||||||
|
if (include.agents) {
|
||||||
|
const skipped = allAgentRows.length - agentRows.length;
|
||||||
|
if (skipped > 0) {
|
||||||
|
warnings.push(`Skipped ${skipped} terminated agent${skipped === 1 ? "" : "s"} from export.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedSlugs = new Set<string>();
|
||||||
|
const idToSlug = new Map<string, string>();
|
||||||
|
for (const agent of agentRows) {
|
||||||
|
const baseSlug = toSafeSlug(agent.name, "agent");
|
||||||
|
const slug = uniqueSlug(baseSlug, usedSlugs);
|
||||||
|
idToSlug.set(agent.id, slug);
|
||||||
|
}
|
||||||
|
|
||||||
if (include.company) {
|
if (include.company) {
|
||||||
const companyPath = "company/COMPANY.md";
|
const companyPath = "COMPANY.md";
|
||||||
|
const companyAgentSummaries = agentRows.map((agent) => ({
|
||||||
|
slug: idToSlug.get(agent.id) ?? "agent",
|
||||||
|
name: agent.name,
|
||||||
|
}));
|
||||||
files[companyPath] = buildMarkdown(
|
files[companyPath] = buildMarkdown(
|
||||||
{
|
{
|
||||||
kind: "company",
|
kind: "company",
|
||||||
name: company.name,
|
name: company.name,
|
||||||
generatedAt: manifest.generatedAt,
|
description: company.description ?? null,
|
||||||
|
brandColor: company.brandColor ?? null,
|
||||||
|
requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents,
|
||||||
},
|
},
|
||||||
company.description ?? "",
|
renderCompanyAgentsSection(companyAgentSummaries),
|
||||||
);
|
);
|
||||||
manifest.company = {
|
manifest.company = {
|
||||||
path: companyPath,
|
path: companyPath,
|
||||||
@@ -417,32 +599,50 @@ export function companyPortabilityService(db: Db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (include.agents) {
|
if (include.agents) {
|
||||||
const agentRows = await agents.list(companyId);
|
|
||||||
const usedSlugs = new Set<string>();
|
|
||||||
const idToSlug = new Map<string, string>();
|
|
||||||
|
|
||||||
for (const agent of agentRows) {
|
|
||||||
const baseSlug = toSafeSlug(agent.name, "agent");
|
|
||||||
const slug = uniqueSlug(baseSlug, usedSlugs);
|
|
||||||
idToSlug.set(agent.id, slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const agent of agentRows) {
|
for (const agent of agentRows) {
|
||||||
const slug = idToSlug.get(agent.id)!;
|
const slug = idToSlug.get(agent.id)!;
|
||||||
const instructions = await readAgentInstructions(agent);
|
const instructions = await readAgentInstructions(agent);
|
||||||
if (instructions.warning) warnings.push(instructions.warning);
|
if (instructions.warning) warnings.push(instructions.warning);
|
||||||
const agentPath = `agents/${slug}/AGENTS.md`;
|
const agentPath = `agents/${slug}/AGENTS.md`;
|
||||||
|
|
||||||
const portableAdapterConfig = normalizePortableConfig(agent.adapterConfig, slug, requiredSecrets);
|
const secretStart = requiredSecrets.length;
|
||||||
const portableRuntimeConfig = normalizePortableConfig(agent.runtimeConfig, slug, requiredSecrets);
|
const adapterDefaultRules = ADAPTER_DEFAULT_RULES_BY_TYPE[agent.adapterType] ?? [];
|
||||||
|
const portableAdapterConfig = pruneDefaultLikeValue(
|
||||||
|
normalizePortableConfig(agent.adapterConfig, slug, requiredSecrets),
|
||||||
|
{
|
||||||
|
dropFalseBooleans: true,
|
||||||
|
defaultRules: adapterDefaultRules,
|
||||||
|
},
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
const portableRuntimeConfig = pruneDefaultLikeValue(
|
||||||
|
normalizePortableConfig(agent.runtimeConfig, slug, requiredSecrets),
|
||||||
|
{
|
||||||
|
dropFalseBooleans: true,
|
||||||
|
defaultRules: RUNTIME_DEFAULT_RULES,
|
||||||
|
},
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
const portablePermissions = pruneDefaultLikeValue(agent.permissions ?? {}, { dropFalseBooleans: true }) as Record<string, unknown>;
|
||||||
|
const agentRequiredSecrets = dedupeRequiredSecrets(
|
||||||
|
requiredSecrets
|
||||||
|
.slice(secretStart)
|
||||||
|
.filter((requirement) => requirement.agentSlug === slug),
|
||||||
|
);
|
||||||
|
const reportsToSlug = agent.reportsTo ? (idToSlug.get(agent.reportsTo) ?? null) : null;
|
||||||
|
|
||||||
files[agentPath] = buildMarkdown(
|
files[agentPath] = buildMarkdown(
|
||||||
{
|
{
|
||||||
kind: "agent",
|
|
||||||
slug,
|
|
||||||
name: agent.name,
|
name: agent.name,
|
||||||
|
slug,
|
||||||
role: agent.role,
|
role: agent.role,
|
||||||
adapterType: agent.adapterType,
|
adapterType: agent.adapterType,
|
||||||
exportedAt: manifest.generatedAt,
|
kind: "agent",
|
||||||
|
icon: agent.icon ?? null,
|
||||||
|
capabilities: agent.capabilities ?? null,
|
||||||
|
reportsTo: reportsToSlug,
|
||||||
|
runtimeConfig: portableRuntimeConfig,
|
||||||
|
permissions: portablePermissions,
|
||||||
|
adapterConfig: portableAdapterConfig,
|
||||||
|
requiredSecrets: agentRequiredSecrets,
|
||||||
},
|
},
|
||||||
instructions.body,
|
instructions.body,
|
||||||
);
|
);
|
||||||
@@ -455,11 +655,11 @@ export function companyPortabilityService(db: Db) {
|
|||||||
title: agent.title ?? null,
|
title: agent.title ?? null,
|
||||||
icon: agent.icon ?? null,
|
icon: agent.icon ?? null,
|
||||||
capabilities: agent.capabilities ?? null,
|
capabilities: agent.capabilities ?? null,
|
||||||
reportsToSlug: agent.reportsTo ? (idToSlug.get(agent.reportsTo) ?? null) : null,
|
reportsToSlug,
|
||||||
adapterType: agent.adapterType,
|
adapterType: agent.adapterType,
|
||||||
adapterConfig: portableAdapterConfig,
|
adapterConfig: portableAdapterConfig,
|
||||||
runtimeConfig: portableRuntimeConfig,
|
runtimeConfig: portableRuntimeConfig,
|
||||||
permissions: agent.permissions ?? {},
|
permissions: portablePermissions,
|
||||||
budgetMonthlyCents: agent.budgetMonthlyCents ?? 0,
|
budgetMonthlyCents: agent.budgetMonthlyCents ?? 0,
|
||||||
metadata: (agent.metadata as Record<string, unknown> | null) ?? null,
|
metadata: (agent.metadata as Record<string, unknown> | null) ?? null,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user