Spaces:
Paused
Paused
| import fs from "node:fs/promises"; | |
| import path from "node:path"; | |
| import { notFound, unprocessable } from "../errors.js"; | |
| import { resolveHomeAwarePath, resolvePaperclipInstanceRoot } from "../home-paths.js"; | |
| const ENTRY_FILE_DEFAULT = "AGENTS.md"; | |
| const MODE_KEY = "instructionsBundleMode"; | |
| const ROOT_KEY = "instructionsRootPath"; | |
| const ENTRY_KEY = "instructionsEntryFile"; | |
| const FILE_KEY = "instructionsFilePath"; | |
| const PROMPT_KEY = "promptTemplate"; | |
| /** @deprecated Use the managed instructions bundle system instead. */ | |
| const BOOTSTRAP_PROMPT_KEY = "bootstrapPromptTemplate"; | |
| const LEGACY_PROMPT_TEMPLATE_PATH = "promptTemplate.legacy.md"; | |
| const IGNORED_INSTRUCTIONS_FILE_NAMES = new Set([".DS_Store", "Thumbs.db", "Desktop.ini"]); | |
| const IGNORED_INSTRUCTIONS_DIRECTORY_NAMES = new Set([ | |
| ".git", | |
| ".nox", | |
| ".pytest_cache", | |
| ".ruff_cache", | |
| ".tox", | |
| ".venv", | |
| "__pycache__", | |
| "node_modules", | |
| "venv", | |
| ]); | |
| type BundleMode = "managed" | "external"; | |
| type AgentLike = { | |
| id: string; | |
| companyId: string; | |
| name: string; | |
| adapterConfig: unknown; | |
| }; | |
| type AgentInstructionsFileSummary = { | |
| path: string; | |
| size: number; | |
| language: string; | |
| markdown: boolean; | |
| isEntryFile: boolean; | |
| editable: boolean; | |
| deprecated: boolean; | |
| virtual: boolean; | |
| }; | |
| type AgentInstructionsFileDetail = AgentInstructionsFileSummary & { | |
| content: string; | |
| editable: boolean; | |
| }; | |
| type AgentInstructionsBundle = { | |
| agentId: string; | |
| companyId: string; | |
| mode: BundleMode | null; | |
| rootPath: string | null; | |
| managedRootPath: string; | |
| entryFile: string; | |
| resolvedEntryPath: string | null; | |
| editable: boolean; | |
| warnings: string[]; | |
| legacyPromptTemplateActive: boolean; | |
| legacyBootstrapPromptTemplateActive: boolean; | |
| files: AgentInstructionsFileSummary[]; | |
| }; | |
| type BundleState = { | |
| config: Record<string, unknown>; | |
| mode: BundleMode | null; | |
| rootPath: string | null; | |
| entryFile: string; | |
| resolvedEntryPath: string | null; | |
| warnings: string[]; | |
| legacyPromptTemplateActive: boolean; | |
| legacyBootstrapPromptTemplateActive: boolean; | |
| }; | |
| function asRecord(value: unknown): Record<string, unknown> { | |
| if (typeof value !== "object" || value === null || Array.isArray(value)) return {}; | |
| return value as Record<string, unknown>; | |
| } | |
| function asString(value: unknown): string | null { | |
| if (typeof value !== "string") return null; | |
| const trimmed = value.trim(); | |
| return trimmed.length > 0 ? trimmed : null; | |
| } | |
| function isBundleMode(value: unknown): value is BundleMode { | |
| return value === "managed" || value === "external"; | |
| } | |
| function inferLanguage(relativePath: string): string { | |
| const lower = relativePath.toLowerCase(); | |
| if (lower.endsWith(".md")) return "markdown"; | |
| if (lower.endsWith(".json")) return "json"; | |
| if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "yaml"; | |
| if (lower.endsWith(".ts") || lower.endsWith(".tsx")) return "typescript"; | |
| if (lower.endsWith(".js") || lower.endsWith(".jsx") || lower.endsWith(".mjs") || lower.endsWith(".cjs")) { | |
| return "javascript"; | |
| } | |
| if (lower.endsWith(".sh")) return "bash"; | |
| if (lower.endsWith(".py")) return "python"; | |
| if (lower.endsWith(".toml")) return "toml"; | |
| if (lower.endsWith(".txt")) return "text"; | |
| return "text"; | |
| } | |
| function isMarkdown(relativePath: string) { | |
| return relativePath.toLowerCase().endsWith(".md"); | |
| } | |
| function normalizeRelativeFilePath(candidatePath: string): string { | |
| const normalized = path.posix.normalize(candidatePath.replaceAll("\\", "/")).replace(/^\/+/, ""); | |
| if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) { | |
| throw unprocessable("Instructions file path must stay within the bundle root"); | |
| } | |
| return normalized; | |
| } | |
| function resolvePathWithinRoot(rootPath: string, relativePath: string): string { | |
| const normalizedRelativePath = normalizeRelativeFilePath(relativePath); | |
| const absoluteRoot = path.resolve(rootPath); | |
| const absolutePath = path.resolve(absoluteRoot, normalizedRelativePath); | |
| const relativeToRoot = path.relative(absoluteRoot, absolutePath); | |
| if (relativeToRoot === ".." || relativeToRoot.startsWith(`..${path.sep}`)) { | |
| throw unprocessable("Instructions file path must stay within the bundle root"); | |
| } | |
| return absolutePath; | |
| } | |
| function resolveManagedInstructionsRoot(agent: AgentLike): string { | |
| return path.resolve( | |
| resolvePaperclipInstanceRoot(), | |
| "companies", | |
| agent.companyId, | |
| "agents", | |
| agent.id, | |
| "instructions", | |
| ); | |
| } | |
| function resolveLegacyInstructionsPath(candidatePath: string, config: Record<string, unknown>): string { | |
| if (path.isAbsolute(candidatePath)) return candidatePath; | |
| const cwd = asString(config.cwd); | |
| if (!cwd || !path.isAbsolute(cwd)) { | |
| throw unprocessable( | |
| "Legacy relative instructionsFilePath requires adapterConfig.cwd to be set to an absolute path", | |
| ); | |
| } | |
| return path.resolve(cwd, candidatePath); | |
| } | |
| async function statIfExists(targetPath: string) { | |
| return fs.stat(targetPath).catch(() => null); | |
| } | |
| function shouldIgnoreInstructionsEntry(entry: { name: string; isDirectory(): boolean; isFile(): boolean }) { | |
| if (entry.name === "." || entry.name === "..") return true; | |
| if (entry.isDirectory()) { | |
| return IGNORED_INSTRUCTIONS_DIRECTORY_NAMES.has(entry.name); | |
| } | |
| if (!entry.isFile()) return false; | |
| return ( | |
| IGNORED_INSTRUCTIONS_FILE_NAMES.has(entry.name) | |
| || entry.name.startsWith("._") | |
| || entry.name.endsWith(".pyc") | |
| || entry.name.endsWith(".pyo") | |
| ); | |
| } | |
| async function listFilesRecursive(rootPath: string): Promise<string[]> { | |
| const output: string[] = []; | |
| async function walk(currentPath: string, relativeDir: string) { | |
| const entries = await fs.readdir(currentPath, { withFileTypes: true }).catch(() => []); | |
| for (const entry of entries) { | |
| if (shouldIgnoreInstructionsEntry(entry)) continue; | |
| const absolutePath = path.join(currentPath, entry.name); | |
| const relativePath = normalizeRelativeFilePath( | |
| relativeDir ? path.posix.join(relativeDir, entry.name) : entry.name, | |
| ); | |
| if (entry.isDirectory()) { | |
| await walk(absolutePath, relativePath); | |
| continue; | |
| } | |
| if (!entry.isFile()) continue; | |
| output.push(relativePath); | |
| } | |
| } | |
| await walk(rootPath, ""); | |
| return output.sort((left, right) => left.localeCompare(right)); | |
| } | |
| async function readFileSummary(rootPath: string, relativePath: string, entryFile: string): Promise<AgentInstructionsFileSummary> { | |
| const absolutePath = resolvePathWithinRoot(rootPath, relativePath); | |
| const stat = await fs.stat(absolutePath); | |
| return { | |
| path: relativePath, | |
| size: stat.size, | |
| language: inferLanguage(relativePath), | |
| markdown: isMarkdown(relativePath), | |
| isEntryFile: relativePath === entryFile, | |
| editable: true, | |
| deprecated: false, | |
| virtual: false, | |
| }; | |
| } | |
| async function readLegacyInstructions(agent: AgentLike, config: Record<string, unknown>): Promise<string> { | |
| const instructionsFilePath = asString(config[FILE_KEY]); | |
| if (instructionsFilePath) { | |
| try { | |
| const resolvedPath = resolveLegacyInstructionsPath(instructionsFilePath, config); | |
| return await fs.readFile(resolvedPath, "utf8"); | |
| } catch { | |
| // Fall back to promptTemplate below. | |
| } | |
| } | |
| return asString(config[PROMPT_KEY]) ?? ""; | |
| } | |
| function deriveBundleState(agent: AgentLike): BundleState { | |
| const config = asRecord(agent.adapterConfig); | |
| const warnings: string[] = []; | |
| const storedModeRaw = config[MODE_KEY]; | |
| const storedRootRaw = asString(config[ROOT_KEY]); | |
| const legacyInstructionsPath = asString(config[FILE_KEY]); | |
| let mode: BundleMode | null = isBundleMode(storedModeRaw) ? storedModeRaw : null; | |
| let rootPath = storedRootRaw ? resolveHomeAwarePath(storedRootRaw) : null; | |
| let entryFile = ENTRY_FILE_DEFAULT; | |
| const storedEntryRaw = asString(config[ENTRY_KEY]); | |
| if (storedEntryRaw) { | |
| try { | |
| entryFile = normalizeRelativeFilePath(storedEntryRaw); | |
| } catch { | |
| warnings.push(`Ignored invalid instructions entry file "${storedEntryRaw}".`); | |
| } | |
| } | |
| if (!rootPath && legacyInstructionsPath) { | |
| try { | |
| const resolvedLegacyPath = resolveLegacyInstructionsPath(legacyInstructionsPath, config); | |
| rootPath = path.dirname(resolvedLegacyPath); | |
| entryFile = path.basename(resolvedLegacyPath); | |
| mode = resolvedLegacyPath.startsWith(`${resolveManagedInstructionsRoot(agent)}${path.sep}`) | |
| || resolvedLegacyPath === path.join(resolveManagedInstructionsRoot(agent), entryFile) | |
| ? "managed" | |
| : "external"; | |
| if (!path.isAbsolute(legacyInstructionsPath)) { | |
| warnings.push("Using legacy relative instructionsFilePath; migrate this agent to a managed or absolute external bundle."); | |
| } | |
| } catch (err) { | |
| warnings.push(err instanceof Error ? err.message : String(err)); | |
| } | |
| } | |
| const resolvedEntryPath = rootPath ? path.resolve(rootPath, entryFile) : null; | |
| return { | |
| config, | |
| mode, | |
| rootPath, | |
| entryFile, | |
| resolvedEntryPath, | |
| warnings, | |
| legacyPromptTemplateActive: Boolean(asString(config[PROMPT_KEY])), | |
| legacyBootstrapPromptTemplateActive: Boolean(asString(config[BOOTSTRAP_PROMPT_KEY])), | |
| }; | |
| } | |
| async function recoverManagedBundleState(agent: AgentLike, state: BundleState): Promise<BundleState> { | |
| const managedRootPath = resolveManagedInstructionsRoot(agent); | |
| const stat = await statIfExists(managedRootPath); | |
| if (!stat?.isDirectory()) return state; | |
| const files = await listFilesRecursive(managedRootPath); | |
| if (files.length === 0) return state; | |
| const recoveredEntryFile = files.includes(state.entryFile) | |
| ? state.entryFile | |
| : files.includes(ENTRY_FILE_DEFAULT) | |
| ? ENTRY_FILE_DEFAULT | |
| : files[0]!; | |
| if (!state.rootPath) { | |
| return { | |
| ...state, | |
| mode: "managed", | |
| rootPath: managedRootPath, | |
| entryFile: recoveredEntryFile, | |
| resolvedEntryPath: path.resolve(managedRootPath, recoveredEntryFile), | |
| }; | |
| } | |
| if (state.mode === "external") return state; | |
| const resolvedConfiguredRoot = path.resolve(state.rootPath); | |
| const configuredRootMatchesManaged = resolvedConfiguredRoot === managedRootPath; | |
| const hasEntryMismatch = recoveredEntryFile !== state.entryFile; | |
| if (configuredRootMatchesManaged && !hasEntryMismatch) { | |
| return state; | |
| } | |
| const warnings = [...state.warnings]; | |
| if (!configuredRootMatchesManaged) { | |
| warnings.push( | |
| `Recovered managed instructions from disk at ${managedRootPath}; ignoring stale configured root ${state.rootPath}.`, | |
| ); | |
| } | |
| if (hasEntryMismatch) { | |
| warnings.push( | |
| `Recovered managed instructions entry file from disk as ${recoveredEntryFile}; previous entry ${state.entryFile} was missing.`, | |
| ); | |
| } | |
| return { | |
| ...state, | |
| mode: "managed", | |
| rootPath: managedRootPath, | |
| entryFile: recoveredEntryFile, | |
| resolvedEntryPath: path.resolve(managedRootPath, recoveredEntryFile), | |
| warnings, | |
| }; | |
| } | |
| function toBundle(agent: AgentLike, state: BundleState, files: AgentInstructionsFileSummary[]): AgentInstructionsBundle { | |
| const nextFiles = [...files]; | |
| if (state.legacyPromptTemplateActive && !nextFiles.some((file) => file.path === LEGACY_PROMPT_TEMPLATE_PATH)) { | |
| const legacyPromptTemplate = asString(state.config[PROMPT_KEY]) ?? ""; | |
| nextFiles.push({ | |
| path: LEGACY_PROMPT_TEMPLATE_PATH, | |
| size: legacyPromptTemplate.length, | |
| language: "markdown", | |
| markdown: true, | |
| isEntryFile: false, | |
| editable: true, | |
| deprecated: true, | |
| virtual: true, | |
| }); | |
| } | |
| nextFiles.sort((left, right) => left.path.localeCompare(right.path)); | |
| return { | |
| agentId: agent.id, | |
| companyId: agent.companyId, | |
| mode: state.mode, | |
| rootPath: state.rootPath, | |
| managedRootPath: resolveManagedInstructionsRoot(agent), | |
| entryFile: state.entryFile, | |
| resolvedEntryPath: state.resolvedEntryPath, | |
| editable: Boolean(state.rootPath), | |
| warnings: state.warnings, | |
| legacyPromptTemplateActive: state.legacyPromptTemplateActive, | |
| legacyBootstrapPromptTemplateActive: state.legacyBootstrapPromptTemplateActive, | |
| files: nextFiles, | |
| }; | |
| } | |
| function applyBundleConfig( | |
| config: Record<string, unknown>, | |
| input: { | |
| mode: BundleMode; | |
| rootPath: string; | |
| entryFile: string; | |
| clearLegacyPromptTemplate?: boolean; | |
| }, | |
| ): Record<string, unknown> { | |
| const next: Record<string, unknown> = { | |
| ...config, | |
| [MODE_KEY]: input.mode, | |
| [ROOT_KEY]: input.rootPath, | |
| [ENTRY_KEY]: input.entryFile, | |
| [FILE_KEY]: path.resolve(input.rootPath, input.entryFile), | |
| }; | |
| if (input.clearLegacyPromptTemplate) { | |
| delete next[PROMPT_KEY]; | |
| delete next[BOOTSTRAP_PROMPT_KEY]; | |
| } | |
| return next; | |
| } | |
| function buildPersistedBundleConfig( | |
| derived: BundleState, | |
| current: BundleState, | |
| options?: { clearLegacyPromptTemplate?: boolean }, | |
| ): Record<string, unknown> { | |
| const currentRootPath = current.rootPath ? path.resolve(current.rootPath) : null; | |
| const derivedRootPath = derived.rootPath ? path.resolve(derived.rootPath) : null; | |
| const configMatchesRecoveredState = | |
| derived.mode === current.mode | |
| && derivedRootPath !== null | |
| && currentRootPath !== null | |
| && derivedRootPath === currentRootPath | |
| && derived.entryFile === current.entryFile; | |
| if (configMatchesRecoveredState && !options?.clearLegacyPromptTemplate) { | |
| return current.config; | |
| } | |
| if (!current.rootPath || !current.mode) { | |
| return current.config; | |
| } | |
| return applyBundleConfig(current.config, { | |
| mode: current.mode, | |
| rootPath: current.rootPath, | |
| entryFile: current.entryFile, | |
| clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate, | |
| }); | |
| } | |
| async function writeBundleFiles( | |
| rootPath: string, | |
| files: Record<string, string>, | |
| options?: { overwriteExisting?: boolean }, | |
| ) { | |
| for (const [relativePath, content] of Object.entries(files)) { | |
| const normalizedPath = normalizeRelativeFilePath(relativePath); | |
| const absolutePath = resolvePathWithinRoot(rootPath, normalizedPath); | |
| const existingStat = await statIfExists(absolutePath); | |
| if (existingStat?.isFile() && !options?.overwriteExisting) continue; | |
| await fs.mkdir(path.dirname(absolutePath), { recursive: true }); | |
| await fs.writeFile(absolutePath, content, "utf8"); | |
| } | |
| } | |
| export function syncInstructionsBundleConfigFromFilePath( | |
| agent: AgentLike, | |
| adapterConfig: Record<string, unknown>, | |
| ): Record<string, unknown> { | |
| const instructionsFilePath = asString(adapterConfig[FILE_KEY]); | |
| const next = { ...adapterConfig }; | |
| if (!instructionsFilePath) { | |
| delete next[MODE_KEY]; | |
| delete next[ROOT_KEY]; | |
| delete next[ENTRY_KEY]; | |
| return next; | |
| } | |
| const resolvedPath = resolveLegacyInstructionsPath(instructionsFilePath, adapterConfig); | |
| const rootPath = path.dirname(resolvedPath); | |
| const entryFile = path.basename(resolvedPath); | |
| const mode: BundleMode = resolvedPath.startsWith(`${resolveManagedInstructionsRoot(agent)}${path.sep}`) | |
| || resolvedPath === path.join(resolveManagedInstructionsRoot(agent), entryFile) | |
| ? "managed" | |
| : "external"; | |
| return applyBundleConfig(next, { mode, rootPath, entryFile }); | |
| } | |
| export function agentInstructionsService() { | |
| async function getBundle(agent: AgentLike): Promise<AgentInstructionsBundle> { | |
| const state = await recoverManagedBundleState(agent, deriveBundleState(agent)); | |
| if (!state.rootPath) return toBundle(agent, state, []); | |
| const stat = await statIfExists(state.rootPath); | |
| if (!stat?.isDirectory()) { | |
| return toBundle(agent, { | |
| ...state, | |
| warnings: [...state.warnings, `Instructions root does not exist: ${state.rootPath}`], | |
| }, []); | |
| } | |
| const files = await listFilesRecursive(state.rootPath); | |
| const summaries = await Promise.all(files.map((relativePath) => readFileSummary(state.rootPath!, relativePath, state.entryFile))); | |
| return toBundle(agent, state, summaries); | |
| } | |
| async function readFile(agent: AgentLike, relativePath: string): Promise<AgentInstructionsFileDetail> { | |
| const state = await recoverManagedBundleState(agent, deriveBundleState(agent)); | |
| if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) { | |
| const content = asString(state.config[PROMPT_KEY]); | |
| if (content === null) throw notFound("Instructions file not found"); | |
| return { | |
| path: LEGACY_PROMPT_TEMPLATE_PATH, | |
| size: content.length, | |
| language: "markdown", | |
| markdown: true, | |
| isEntryFile: false, | |
| editable: true, | |
| deprecated: true, | |
| virtual: true, | |
| content, | |
| }; | |
| } | |
| if (!state.rootPath) throw notFound("Agent instructions bundle is not configured"); | |
| const absolutePath = resolvePathWithinRoot(state.rootPath, relativePath); | |
| const [content, stat] = await Promise.all([ | |
| fs.readFile(absolutePath, "utf8").catch(() => null), | |
| fs.stat(absolutePath).catch(() => null), | |
| ]); | |
| if (content === null || !stat?.isFile()) throw notFound("Instructions file not found"); | |
| const normalizedPath = normalizeRelativeFilePath(relativePath); | |
| return { | |
| path: normalizedPath, | |
| size: stat.size, | |
| language: inferLanguage(normalizedPath), | |
| markdown: isMarkdown(normalizedPath), | |
| isEntryFile: normalizedPath === state.entryFile, | |
| editable: true, | |
| deprecated: false, | |
| virtual: false, | |
| content, | |
| }; | |
| } | |
| async function ensureWritableBundle( | |
| agent: AgentLike, | |
| options?: { clearLegacyPromptTemplate?: boolean }, | |
| ): Promise<{ adapterConfig: Record<string, unknown>; state: BundleState }> { | |
| const derived = deriveBundleState(agent); | |
| const current = await recoverManagedBundleState(agent, derived); | |
| if (current.rootPath && current.mode) { | |
| const adapterConfig = buildPersistedBundleConfig(derived, current, options); | |
| return { | |
| adapterConfig, | |
| state: deriveBundleState({ ...agent, adapterConfig }), | |
| }; | |
| } | |
| const managedRoot = resolveManagedInstructionsRoot(agent); | |
| const entryFile = current.entryFile || ENTRY_FILE_DEFAULT; | |
| const nextConfig = applyBundleConfig(current.config, { | |
| mode: "managed", | |
| rootPath: managedRoot, | |
| entryFile, | |
| clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate, | |
| }); | |
| await fs.mkdir(managedRoot, { recursive: true }); | |
| const entryPath = resolvePathWithinRoot(managedRoot, entryFile); | |
| const entryStat = await statIfExists(entryPath); | |
| if (!entryStat?.isFile()) { | |
| const legacyInstructions = await readLegacyInstructions(agent, current.config); | |
| if (legacyInstructions.trim().length > 0) { | |
| await fs.mkdir(path.dirname(entryPath), { recursive: true }); | |
| await fs.writeFile(entryPath, legacyInstructions, "utf8"); | |
| } | |
| } | |
| return { | |
| adapterConfig: nextConfig, | |
| state: deriveBundleState({ ...agent, adapterConfig: nextConfig }), | |
| }; | |
| } | |
| async function updateBundle( | |
| agent: AgentLike, | |
| input: { | |
| mode?: BundleMode; | |
| rootPath?: string | null; | |
| entryFile?: string; | |
| clearLegacyPromptTemplate?: boolean; | |
| }, | |
| ): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record<string, unknown> }> { | |
| const state = await recoverManagedBundleState(agent, deriveBundleState(agent)); | |
| const nextMode = input.mode ?? state.mode ?? "managed"; | |
| const nextEntryFile = input.entryFile ? normalizeRelativeFilePath(input.entryFile) : state.entryFile; | |
| let nextRootPath: string; | |
| if (nextMode === "managed") { | |
| nextRootPath = resolveManagedInstructionsRoot(agent); | |
| } else { | |
| const rootPath = asString(input.rootPath) ?? state.rootPath; | |
| if (!rootPath) { | |
| throw unprocessable("External instructions bundles require an absolute rootPath"); | |
| } | |
| const resolvedRoot = resolveHomeAwarePath(rootPath); | |
| if (!path.isAbsolute(resolvedRoot)) { | |
| throw unprocessable("External instructions bundles require an absolute rootPath"); | |
| } | |
| nextRootPath = resolvedRoot; | |
| } | |
| await fs.mkdir(nextRootPath, { recursive: true }); | |
| const existingFiles = await listFilesRecursive(nextRootPath); | |
| const exported = await exportFiles(agent); | |
| if (existingFiles.length === 0) { | |
| await writeBundleFiles(nextRootPath, exported.files); | |
| } | |
| const refreshedFiles = existingFiles.length === 0 ? await listFilesRecursive(nextRootPath) : existingFiles; | |
| if (!refreshedFiles.includes(nextEntryFile)) { | |
| const nextEntryContent = exported.files[nextEntryFile] ?? exported.files[exported.entryFile] ?? ""; | |
| await writeBundleFiles(nextRootPath, { [nextEntryFile]: nextEntryContent }); | |
| } | |
| const nextConfig = applyBundleConfig(state.config, { | |
| mode: nextMode, | |
| rootPath: nextRootPath, | |
| entryFile: nextEntryFile, | |
| clearLegacyPromptTemplate: input.clearLegacyPromptTemplate, | |
| }); | |
| const nextBundle = await getBundle({ ...agent, adapterConfig: nextConfig }); | |
| return { bundle: nextBundle, adapterConfig: nextConfig }; | |
| } | |
| async function writeFile( | |
| agent: AgentLike, | |
| relativePath: string, | |
| content: string, | |
| options?: { clearLegacyPromptTemplate?: boolean }, | |
| ): Promise<{ | |
| bundle: AgentInstructionsBundle; | |
| file: AgentInstructionsFileDetail; | |
| adapterConfig: Record<string, unknown>; | |
| }> { | |
| const current = deriveBundleState(agent); | |
| if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) { | |
| const adapterConfig: Record<string, unknown> = { | |
| ...current.config, | |
| [PROMPT_KEY]: content, | |
| }; | |
| const nextAgent = { ...agent, adapterConfig }; | |
| const [bundle, file] = await Promise.all([ | |
| getBundle(nextAgent), | |
| readFile(nextAgent, LEGACY_PROMPT_TEMPLATE_PATH), | |
| ]); | |
| return { bundle, file, adapterConfig }; | |
| } | |
| const prepared = await ensureWritableBundle(agent, options); | |
| const absolutePath = resolvePathWithinRoot(prepared.state.rootPath!, relativePath); | |
| await fs.mkdir(path.dirname(absolutePath), { recursive: true }); | |
| await fs.writeFile(absolutePath, content, "utf8"); | |
| const nextAgent = { ...agent, adapterConfig: prepared.adapterConfig }; | |
| const [bundle, file] = await Promise.all([ | |
| getBundle(nextAgent), | |
| readFile(nextAgent, relativePath), | |
| ]); | |
| return { bundle, file, adapterConfig: prepared.adapterConfig }; | |
| } | |
| async function deleteFile(agent: AgentLike, relativePath: string): Promise<{ | |
| bundle: AgentInstructionsBundle; | |
| adapterConfig: Record<string, unknown>; | |
| }> { | |
| const derived = deriveBundleState(agent); | |
| const state = await recoverManagedBundleState(agent, derived); | |
| if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) { | |
| throw unprocessable("Cannot delete the legacy promptTemplate pseudo-file"); | |
| } | |
| if (!state.rootPath) throw notFound("Agent instructions bundle is not configured"); | |
| const normalizedPath = normalizeRelativeFilePath(relativePath); | |
| if (normalizedPath === state.entryFile) { | |
| throw unprocessable("Cannot delete the bundle entry file"); | |
| } | |
| const absolutePath = resolvePathWithinRoot(state.rootPath, normalizedPath); | |
| await fs.rm(absolutePath, { force: true }); | |
| const adapterConfig = buildPersistedBundleConfig(derived, state); | |
| const bundle = await getBundle({ ...agent, adapterConfig }); | |
| return { bundle, adapterConfig }; | |
| } | |
| async function exportFiles(agent: AgentLike): Promise<{ | |
| files: Record<string, string>; | |
| entryFile: string; | |
| warnings: string[]; | |
| }> { | |
| const state = await recoverManagedBundleState(agent, deriveBundleState(agent)); | |
| if (state.rootPath) { | |
| const stat = await statIfExists(state.rootPath); | |
| if (stat?.isDirectory()) { | |
| const relativePaths = await listFilesRecursive(state.rootPath); | |
| const files = Object.fromEntries(await Promise.all(relativePaths.map(async (relativePath) => { | |
| const absolutePath = resolvePathWithinRoot(state.rootPath!, relativePath); | |
| const content = await fs.readFile(absolutePath, "utf8"); | |
| return [relativePath, content] as const; | |
| }))); | |
| if (Object.keys(files).length > 0) { | |
| return { files, entryFile: state.entryFile, warnings: state.warnings }; | |
| } | |
| } | |
| } | |
| const legacyBody = await readLegacyInstructions(agent, state.config); | |
| return { | |
| files: { [state.entryFile]: legacyBody || "_No AGENTS instructions were resolved from current agent config._" }, | |
| entryFile: state.entryFile, | |
| warnings: state.warnings, | |
| }; | |
| } | |
| async function materializeManagedBundle( | |
| agent: AgentLike, | |
| files: Record<string, string>, | |
| options?: { | |
| clearLegacyPromptTemplate?: boolean; | |
| replaceExisting?: boolean; | |
| entryFile?: string; | |
| }, | |
| ): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record<string, unknown> }> { | |
| const rootPath = resolveManagedInstructionsRoot(agent); | |
| const entryFile = options?.entryFile ? normalizeRelativeFilePath(options.entryFile) : ENTRY_FILE_DEFAULT; | |
| if (options?.replaceExisting) { | |
| await fs.rm(rootPath, { recursive: true, force: true }); | |
| } | |
| await fs.mkdir(rootPath, { recursive: true }); | |
| const normalizedEntries = Object.entries(files).map(([relativePath, content]) => [ | |
| normalizeRelativeFilePath(relativePath), | |
| content, | |
| ] as const); | |
| for (const [relativePath, content] of normalizedEntries) { | |
| const absolutePath = resolvePathWithinRoot(rootPath, relativePath); | |
| await fs.mkdir(path.dirname(absolutePath), { recursive: true }); | |
| await fs.writeFile(absolutePath, content, "utf8"); | |
| } | |
| if (!normalizedEntries.some(([relativePath]) => relativePath === entryFile)) { | |
| await fs.writeFile(resolvePathWithinRoot(rootPath, entryFile), "", "utf8"); | |
| } | |
| const adapterConfig = applyBundleConfig(asRecord(agent.adapterConfig), { | |
| mode: "managed", | |
| rootPath, | |
| entryFile, | |
| clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate, | |
| }); | |
| const bundle = await getBundle({ ...agent, adapterConfig }); | |
| return { bundle, adapterConfig }; | |
| } | |
| return { | |
| getBundle, | |
| readFile, | |
| updateBundle, | |
| writeFile, | |
| deleteFile, | |
| exportFiles, | |
| ensureManagedBundle: ensureWritableBundle, | |
| materializeManagedBundle, | |
| }; | |
| } | |