feat(FN-1170): add instruction bundle support to AgentStore
- Add InstructionsBundleConfig to core agent types, inputs, snapshots, diffing, and package exports - Extend AgentStore persistence and config snapshot cloning to include bundleConfig state - Implement managed bundle directory APIs for listing, reading, writing, deleting, and validating markdown instruction files - Add migrateLegacyInstructions to convert legacy instructionsText/instructionsPath data into managed bundle config - Add comprehensive AgentStore tests for bundle CRUD, validation rules, config updates, and legacy migration behavior
This commit is contained in:
245
packages/core/src/__tests__/agent-instructions-bundle.test.ts
Normal file
245
packages/core/src/__tests__/agent-instructions-bundle.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtemp, rm, mkdir, writeFile, readFile, access } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { AgentStore } from "../agent-store.js";
|
||||
|
||||
describe("AgentStore — instructions bundle", () => {
|
||||
let testDir: string;
|
||||
let store: AgentStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await mkdtemp(join(tmpdir(), "agent-instructions-bundle-test-"));
|
||||
store = new AgentStore({ rootDir: testDir });
|
||||
await store.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("persists bundleConfig through create + load roundtrip", async () => {
|
||||
const created = await store.createAgent({
|
||||
name: "bundle-agent",
|
||||
role: "executor",
|
||||
bundleConfig: {
|
||||
mode: "managed",
|
||||
entryFile: "AGENTS.md",
|
||||
files: ["AGENTS.md", "STYLE.md"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(created.bundleConfig).toEqual({
|
||||
mode: "managed",
|
||||
entryFile: "AGENTS.md",
|
||||
files: ["AGENTS.md", "STYLE.md"],
|
||||
});
|
||||
|
||||
const loaded = await store.getAgent(created.id);
|
||||
expect(loaded?.bundleConfig).toEqual(created.bundleConfig);
|
||||
});
|
||||
|
||||
it("getInstructionsDir returns the managed bundle directory path", async () => {
|
||||
const agent = await store.createAgent({ name: "dir-agent", role: "executor" });
|
||||
expect(store.getInstructionsDir(agent.id)).toBe(join(testDir, "agents", `${agent.id}-instructions`));
|
||||
});
|
||||
|
||||
it("listBundleFiles returns empty for missing directory and sorted .md files only", async () => {
|
||||
const agent = await store.createAgent({ name: "list-agent", role: "executor" });
|
||||
|
||||
expect(await store.listBundleFiles(agent.id)).toEqual([]);
|
||||
|
||||
const dir = store.getInstructionsDir(agent.id);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, "z.md"), "z", "utf-8");
|
||||
await writeFile(join(dir, "a.md"), "a", "utf-8");
|
||||
await writeFile(join(dir, "b.txt"), "not markdown", "utf-8");
|
||||
await mkdir(join(dir, "nested"), { recursive: true });
|
||||
|
||||
expect(await store.listBundleFiles(agent.id)).toEqual(["a.md", "z.md"]);
|
||||
});
|
||||
|
||||
it("readBundleFile reads content and rejects missing/traversal paths", async () => {
|
||||
const agent = await store.createAgent({ name: "read-agent", role: "executor" });
|
||||
|
||||
await store.writeBundleFile(agent.id, "AGENTS.md", "Hello bundle");
|
||||
await expect(store.readBundleFile(agent.id, "AGENTS.md")).resolves.toBe("Hello bundle");
|
||||
|
||||
await expect(store.readBundleFile(agent.id, "missing.md")).rejects.toThrow(/ENOENT|no such file/i);
|
||||
await expect(store.readBundleFile(agent.id, "../etc/passwd")).rejects.toThrow(/traversal/i);
|
||||
});
|
||||
|
||||
it("writeBundleFile creates directories, overwrites, validates paths, and enforces max file count", async () => {
|
||||
const agent = await store.createAgent({ name: "write-agent", role: "executor" });
|
||||
const dir = store.getInstructionsDir(agent.id);
|
||||
|
||||
await store.writeBundleFile(agent.id, "AGENTS.md", "first");
|
||||
expect(await readFile(join(dir, "AGENTS.md"), "utf-8")).toBe("first");
|
||||
|
||||
await store.writeBundleFile(agent.id, "AGENTS.md", "second");
|
||||
expect(await readFile(join(dir, "AGENTS.md"), "utf-8")).toBe("second");
|
||||
|
||||
await expect(store.writeBundleFile(agent.id, "notes.txt", "bad")).rejects.toThrow(/\.md/i);
|
||||
await expect(store.writeBundleFile(agent.id, "../evil.md", "bad")).rejects.toThrow(/traversal/i);
|
||||
await expect(store.writeBundleFile(agent.id, `${"a".repeat(501)}.md`, "bad")).rejects.toThrow(/500/i);
|
||||
|
||||
for (let i = 1; i < 10; i += 1) {
|
||||
await store.writeBundleFile(agent.id, `file-${i}.md`, `content-${i}`);
|
||||
}
|
||||
|
||||
await expect(store.writeBundleFile(agent.id, "overflow.md", "11th")).rejects.toThrow(/10/i);
|
||||
await expect(store.writeBundleFile(agent.id, "file-1.md", "overwrite-allowed")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("deleteBundleFile removes files and throws when missing", async () => {
|
||||
const agent = await store.createAgent({ name: "delete-agent", role: "executor" });
|
||||
const filePath = join(store.getInstructionsDir(agent.id), "AGENTS.md");
|
||||
|
||||
await store.writeBundleFile(agent.id, "AGENTS.md", "to-delete");
|
||||
await store.deleteBundleFile(agent.id, "AGENTS.md");
|
||||
|
||||
await expect(access(filePath)).rejects.toThrow();
|
||||
await expect(store.deleteBundleFile(agent.id, "AGENTS.md")).rejects.toThrow(/ENOENT|no such file/i);
|
||||
});
|
||||
|
||||
it("setBundleConfig validates input and creates managed directory", async () => {
|
||||
const agent = await store.createAgent({ name: "config-agent", role: "executor" });
|
||||
|
||||
const managed = await store.setBundleConfig(agent.id, {
|
||||
mode: "managed",
|
||||
entryFile: "AGENTS.md",
|
||||
files: ["AGENTS.md"],
|
||||
});
|
||||
|
||||
expect(managed.bundleConfig).toEqual({
|
||||
mode: "managed",
|
||||
entryFile: "AGENTS.md",
|
||||
files: ["AGENTS.md"],
|
||||
});
|
||||
|
||||
const dir = store.getInstructionsDir(agent.id);
|
||||
await expect(access(dir)).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
store.setBundleConfig(agent.id, {
|
||||
mode: "external",
|
||||
entryFile: "AGENTS.md",
|
||||
files: [],
|
||||
}),
|
||||
).rejects.toThrow(/externalPath/i);
|
||||
|
||||
await expect(
|
||||
store.setBundleConfig(agent.id, {
|
||||
mode: "managed",
|
||||
entryFile: " ",
|
||||
files: [],
|
||||
}),
|
||||
).rejects.toThrow(/entryFile/i);
|
||||
});
|
||||
|
||||
it("migrateLegacyInstructions migrates instructionsText to managed bundle", async () => {
|
||||
const agent = await store.createAgent({
|
||||
name: "migrate-text",
|
||||
role: "executor",
|
||||
instructionsText: "Legacy text content",
|
||||
});
|
||||
|
||||
const migrated = await store.migrateLegacyInstructions(agent.id);
|
||||
|
||||
expect(migrated.instructionsText).toBeUndefined();
|
||||
expect(migrated.instructionsPath).toBeUndefined();
|
||||
expect(migrated.bundleConfig).toEqual({
|
||||
mode: "managed",
|
||||
entryFile: "AGENTS.md",
|
||||
files: ["AGENTS.md"],
|
||||
});
|
||||
|
||||
await expect(store.readBundleFile(agent.id, "AGENTS.md")).resolves.toBe("Legacy text content");
|
||||
});
|
||||
|
||||
it("migrateLegacyInstructions migrates instructionsPath to AGENTS.md", async () => {
|
||||
const sourcePath = "legacy-path.md";
|
||||
await writeFile(join(testDir, sourcePath), "Legacy path content", "utf-8");
|
||||
|
||||
const agent = await store.createAgent({
|
||||
name: "migrate-path",
|
||||
role: "executor",
|
||||
instructionsPath: sourcePath,
|
||||
});
|
||||
|
||||
const migrated = await store.migrateLegacyInstructions(agent.id);
|
||||
|
||||
expect(migrated.instructionsPath).toBeUndefined();
|
||||
expect(migrated.bundleConfig).toEqual({
|
||||
mode: "managed",
|
||||
entryFile: "AGENTS.md",
|
||||
files: ["AGENTS.md"],
|
||||
});
|
||||
await expect(store.readBundleFile(agent.id, "AGENTS.md")).resolves.toBe("Legacy path content");
|
||||
});
|
||||
|
||||
it("migrateLegacyInstructions migrates both legacy fields", async () => {
|
||||
await mkdir(join(testDir, "legacy"), { recursive: true });
|
||||
const sourcePath = "legacy/extra.md";
|
||||
await writeFile(join(testDir, sourcePath), "Secondary path content", "utf-8");
|
||||
|
||||
const agent = await store.createAgent({
|
||||
name: "migrate-both",
|
||||
role: "executor",
|
||||
instructionsText: "Primary inline content",
|
||||
instructionsPath: sourcePath,
|
||||
});
|
||||
|
||||
const migrated = await store.migrateLegacyInstructions(agent.id);
|
||||
|
||||
expect(migrated.instructionsText).toBeUndefined();
|
||||
expect(migrated.instructionsPath).toBeUndefined();
|
||||
expect(migrated.bundleConfig).toEqual({
|
||||
mode: "managed",
|
||||
entryFile: "AGENTS.md",
|
||||
files: ["AGENTS.md", "extra.md"],
|
||||
});
|
||||
|
||||
await expect(store.readBundleFile(agent.id, "AGENTS.md")).resolves.toBe("Primary inline content");
|
||||
await expect(store.readBundleFile(agent.id, "extra.md")).resolves.toBe("Secondary path content");
|
||||
});
|
||||
|
||||
it("migrateLegacyInstructions is idempotent when bundleConfig already exists", async () => {
|
||||
const agent = await store.createAgent({
|
||||
name: "already-migrated",
|
||||
role: "executor",
|
||||
bundleConfig: {
|
||||
mode: "managed",
|
||||
entryFile: "AGENTS.md",
|
||||
files: ["AGENTS.md"],
|
||||
},
|
||||
instructionsText: "should-stay",
|
||||
});
|
||||
|
||||
const migrated = await store.migrateLegacyInstructions(agent.id);
|
||||
|
||||
expect(migrated.bundleConfig).toEqual({
|
||||
mode: "managed",
|
||||
entryFile: "AGENTS.md",
|
||||
files: ["AGENTS.md"],
|
||||
});
|
||||
expect(migrated.instructionsText).toBe("should-stay");
|
||||
});
|
||||
|
||||
it("migrateLegacyInstructions creates empty managed bundle config when no legacy fields exist", async () => {
|
||||
const agent = await store.createAgent({
|
||||
name: "no-legacy",
|
||||
role: "executor",
|
||||
});
|
||||
|
||||
const migrated = await store.migrateLegacyInstructions(agent.id);
|
||||
|
||||
expect(migrated.bundleConfig).toEqual({
|
||||
mode: "managed",
|
||||
entryFile: "AGENTS.md",
|
||||
files: [],
|
||||
});
|
||||
expect(migrated.instructionsText).toBeUndefined();
|
||||
expect(migrated.instructionsPath).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -13,9 +13,9 @@
|
||||
* - agents/{agentId}-revisions.jsonl: Config revision history
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, writeFile, readdir, unlink } from "node:fs/promises";
|
||||
import { mkdir, readFile, writeFile, readdir, unlink, rename } from "node:fs/promises";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { basename, join } from "node:path";
|
||||
import { randomUUID, randomBytes, createHash } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type {
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
AgentConfigSnapshot,
|
||||
AgentAccessState,
|
||||
OrgTreeNode,
|
||||
InstructionsBundleConfig,
|
||||
} from "./types.js";
|
||||
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots } from "./types.js";
|
||||
import { computeAccessState } from "./agent-permissions.js";
|
||||
@@ -93,6 +94,7 @@ interface AgentData {
|
||||
lastError?: string;
|
||||
instructionsPath?: string;
|
||||
instructionsText?: string;
|
||||
bundleConfig?: InstructionsBundleConfig;
|
||||
}
|
||||
interface AgentLock {
|
||||
promise: Promise<unknown>;
|
||||
@@ -153,6 +155,7 @@ export class AgentStore extends EventEmitter {
|
||||
...(input.permissions && { permissions: input.permissions }),
|
||||
...(input.instructionsPath && { instructionsPath: input.instructionsPath }),
|
||||
...(input.instructionsText && { instructionsText: input.instructionsText }),
|
||||
...(input.bundleConfig && { bundleConfig: input.bundleConfig }),
|
||||
};
|
||||
|
||||
await this.writeAgent(agent);
|
||||
@@ -216,6 +219,165 @@ export class AgentStore extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the managed instructions directory path for an agent.
|
||||
* Does not create the directory.
|
||||
*/
|
||||
getInstructionsDir(agentId: string): string {
|
||||
return this.getBundleDir(agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* List markdown files in an agent's managed instructions bundle.
|
||||
* Returns [] when the bundle directory does not exist.
|
||||
*/
|
||||
async listBundleFiles(agentId: string): Promise<string[]> {
|
||||
const bundleDir = this.getBundleDir(agentId);
|
||||
|
||||
try {
|
||||
const entries = await readdir(bundleDir, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
|
||||
.map((entry) => entry.name)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a markdown file from an agent's managed instructions bundle.
|
||||
*/
|
||||
async readBundleFile(agentId: string, filePath: string): Promise<string> {
|
||||
this.validateBundleFilePath(filePath);
|
||||
const resolvedPath = join(this.getBundleDir(agentId), filePath);
|
||||
return readFile(resolvedPath, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a markdown file to an agent's managed instructions bundle.
|
||||
*/
|
||||
async writeBundleFile(agentId: string, filePath: string, content: string): Promise<void> {
|
||||
return this.withLock(agentId, async () => {
|
||||
this.validateBundleFilePath(filePath);
|
||||
|
||||
const bundleDir = this.getBundleDir(agentId);
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
|
||||
const existingFiles = await this.listBundleFiles(agentId);
|
||||
const isOverwrite = existingFiles.includes(filePath);
|
||||
if (!isOverwrite && existingFiles.length >= 10) {
|
||||
throw new Error("Instruction bundles are limited to 10 markdown files");
|
||||
}
|
||||
|
||||
const resolvedPath = join(bundleDir, filePath);
|
||||
const tempPath = `${resolvedPath}.tmp.${Date.now()}`;
|
||||
await writeFile(tempPath, content, "utf-8");
|
||||
await rename(tempPath, resolvedPath);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a markdown file from an agent's managed instructions bundle.
|
||||
*/
|
||||
async deleteBundleFile(agentId: string, filePath: string): Promise<void> {
|
||||
return this.withLock(agentId, async () => {
|
||||
this.validateBundleFilePath(filePath);
|
||||
await unlink(join(this.getBundleDir(agentId), filePath));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an agent's instructions bundle configuration.
|
||||
*/
|
||||
async setBundleConfig(agentId: string, config: InstructionsBundleConfig): Promise<Agent> {
|
||||
const entryFile = config.entryFile?.trim();
|
||||
if (!entryFile) {
|
||||
throw new Error("Bundle config entryFile is required");
|
||||
}
|
||||
|
||||
if (config.mode === "external" && !config.externalPath?.trim()) {
|
||||
throw new Error("Bundle config externalPath is required when mode is 'external'");
|
||||
}
|
||||
|
||||
const normalizedConfig: InstructionsBundleConfig = {
|
||||
...config,
|
||||
entryFile,
|
||||
files: [...(config.files ?? [])],
|
||||
...(config.externalPath !== undefined ? { externalPath: config.externalPath } : {}),
|
||||
};
|
||||
|
||||
const updated = await this.updateAgent(agentId, { bundleConfig: normalizedConfig });
|
||||
|
||||
if (normalizedConfig.mode === "managed") {
|
||||
await mkdir(this.getBundleDir(agentId), { recursive: true });
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate legacy instructionsText/instructionsPath fields into bundleConfig.
|
||||
*/
|
||||
async migrateLegacyInstructions(agentId: string): Promise<Agent> {
|
||||
const agent = await this.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
if (agent.bundleConfig) {
|
||||
return agent;
|
||||
}
|
||||
|
||||
const entryFile = "AGENTS.md";
|
||||
const hasInstructionsText = typeof agent.instructionsText === "string" && agent.instructionsText.length > 0;
|
||||
const hasInstructionsPath = typeof agent.instructionsPath === "string" && agent.instructionsPath.length > 0;
|
||||
|
||||
if (!hasInstructionsText && !hasInstructionsPath) {
|
||||
return this.updateAgent(agentId, {
|
||||
bundleConfig: { mode: "managed", entryFile, files: [] },
|
||||
});
|
||||
}
|
||||
|
||||
await mkdir(this.getBundleDir(agentId), { recursive: true });
|
||||
|
||||
const files: string[] = [];
|
||||
|
||||
if (hasInstructionsText) {
|
||||
await this.writeBundleFile(agentId, entryFile, agent.instructionsText ?? "");
|
||||
files.push(entryFile);
|
||||
}
|
||||
|
||||
if (hasInstructionsPath) {
|
||||
const sourcePath = join(this.rootDir, agent.instructionsPath ?? "");
|
||||
const sourceContent = await readFile(sourcePath, "utf-8");
|
||||
|
||||
if (hasInstructionsText) {
|
||||
const secondaryFile = basename(agent.instructionsPath ?? "");
|
||||
await this.writeBundleFile(agentId, secondaryFile, sourceContent);
|
||||
if (!files.includes(secondaryFile)) {
|
||||
files.push(secondaryFile);
|
||||
}
|
||||
} else {
|
||||
await this.writeBundleFile(agentId, entryFile, sourceContent);
|
||||
files.push(entryFile);
|
||||
}
|
||||
}
|
||||
|
||||
return this.updateAgent(agentId, {
|
||||
instructionsPath: undefined,
|
||||
instructionsText: undefined,
|
||||
bundleConfig: {
|
||||
mode: "managed",
|
||||
entryFile,
|
||||
files,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an agent with partial updates.
|
||||
* @param agentId - The agent ID
|
||||
@@ -255,6 +417,7 @@ export class AgentStore extends EventEmitter {
|
||||
...("totalOutputTokens" in updates && { totalOutputTokens: updates.totalOutputTokens }),
|
||||
...("instructionsPath" in updates && { instructionsPath: updates.instructionsPath }),
|
||||
...("instructionsText" in updates && { instructionsText: updates.instructionsText }),
|
||||
...("bundleConfig" in updates && { bundleConfig: updates.bundleConfig }),
|
||||
};
|
||||
|
||||
await this.writeAgent(updated);
|
||||
@@ -1141,6 +1304,7 @@ export class AgentStore extends EventEmitter {
|
||||
| "permissions"
|
||||
| "instructionsPath"
|
||||
| "instructionsText"
|
||||
| "bundleConfig"
|
||||
| "metadata"
|
||||
> {
|
||||
return {
|
||||
@@ -1153,6 +1317,12 @@ export class AgentStore extends EventEmitter {
|
||||
permissions: snapshot.permissions ? { ...snapshot.permissions } : undefined,
|
||||
instructionsPath: snapshot.instructionsPath,
|
||||
instructionsText: snapshot.instructionsText,
|
||||
bundleConfig: snapshot.bundleConfig
|
||||
? {
|
||||
...snapshot.bundleConfig,
|
||||
files: [...snapshot.bundleConfig.files],
|
||||
}
|
||||
: undefined,
|
||||
metadata: { ...snapshot.metadata },
|
||||
};
|
||||
}
|
||||
@@ -1173,6 +1343,44 @@ export class AgentStore extends EventEmitter {
|
||||
return null;
|
||||
}
|
||||
|
||||
private getBundleDir(agentId: string): string {
|
||||
return join(this.agentsDir, `${agentId}-instructions`);
|
||||
}
|
||||
|
||||
private validateBundleFilePath(filePath: string): void {
|
||||
if (typeof filePath !== "string") {
|
||||
throw new Error("Bundle file path must be a string");
|
||||
}
|
||||
|
||||
const trimmedPath = filePath.trim();
|
||||
if (!trimmedPath) {
|
||||
throw new Error("Bundle file path cannot be empty");
|
||||
}
|
||||
|
||||
const normalizedPath = trimmedPath.replace(/\\/g, "/");
|
||||
if (normalizedPath.startsWith("/")) {
|
||||
throw new Error("Bundle file path must be relative (absolute paths are not allowed)");
|
||||
}
|
||||
|
||||
const segments = normalizedPath.split("/");
|
||||
if (segments.some((segment) => segment === "..")) {
|
||||
throw new Error("Bundle file path cannot include '..' path traversal segments");
|
||||
}
|
||||
|
||||
if (!normalizedPath.endsWith(".md")) {
|
||||
throw new Error("Bundle file path must end with .md");
|
||||
}
|
||||
|
||||
const filename = basename(normalizedPath);
|
||||
if (!filename) {
|
||||
throw new Error("Bundle file name cannot be empty");
|
||||
}
|
||||
|
||||
if (filename.length > 500) {
|
||||
throw new Error("Bundle file name cannot exceed 500 characters");
|
||||
}
|
||||
}
|
||||
|
||||
private getApiKeysPath(agentId: string): string {
|
||||
return join(this.agentsDir, `${agentId}-keys.jsonl`);
|
||||
}
|
||||
@@ -1253,6 +1461,7 @@ export class AgentStore extends EventEmitter {
|
||||
lastError: data.lastError,
|
||||
instructionsPath: data.instructionsPath,
|
||||
instructionsText: data.instructionsText,
|
||||
bundleConfig: data.bundleConfig,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1279,14 +1488,14 @@ export class AgentStore extends EventEmitter {
|
||||
lastError: agent.lastError,
|
||||
instructionsPath: agent.instructionsPath,
|
||||
instructionsText: agent.instructionsText,
|
||||
bundleConfig: agent.bundleConfig,
|
||||
};
|
||||
|
||||
// Write atomically using temp file
|
||||
const tempPath = `${path}.tmp.${Date.now()}`;
|
||||
await writeFile(tempPath, JSON.stringify(data, null, 2));
|
||||
|
||||
|
||||
// Rename temp file to final path (atomic on most filesystems)
|
||||
const { rename } = await import("node:fs/promises");
|
||||
await rename(tempPath, path);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||
export {
|
||||
BUILTIN_AGENT_PROMPTS,
|
||||
|
||||
@@ -1601,6 +1601,8 @@ export interface Agent {
|
||||
instructionsPath?: string;
|
||||
/** Inline custom instructions appended to the agent's system prompt at execution time. Max 50,000 chars. */
|
||||
instructionsText?: string;
|
||||
/** Structured instruction bundle configuration for managed/external markdown files. */
|
||||
bundleConfig?: InstructionsBundleConfig;
|
||||
}
|
||||
|
||||
/** Recursive node in the agent org tree. */
|
||||
@@ -1629,6 +1631,19 @@ export interface AgentHeartbeatConfig {
|
||||
messageResponseMode?: MessageResponseMode;
|
||||
}
|
||||
|
||||
/** Configuration for an agent's instruction bundle — a collection of markdown files
|
||||
* that together form the agent's custom instructions. */
|
||||
export interface InstructionsBundleConfig {
|
||||
/** Bundle mode — "managed" = system-managed directory, "external" = user-specified path */
|
||||
mode: "managed" | "external";
|
||||
/** Primary instructions file name (default: "AGENTS.md") */
|
||||
entryFile: string;
|
||||
/** List of all file names in the bundle directory */
|
||||
files: string[];
|
||||
/** User-specified directory path for external mode (required when mode is "external") */
|
||||
externalPath?: string;
|
||||
}
|
||||
|
||||
/** Extended agent information including heartbeat history */
|
||||
export interface AgentDetail extends Agent {
|
||||
/** Recent heartbeat events (last N events) */
|
||||
@@ -1651,6 +1666,7 @@ export interface AgentCreateInput {
|
||||
permissions?: Record<string, boolean>;
|
||||
instructionsPath?: string;
|
||||
instructionsText?: string;
|
||||
bundleConfig?: InstructionsBundleConfig;
|
||||
}
|
||||
|
||||
/** Input for updating an existing agent */
|
||||
@@ -1669,6 +1685,7 @@ export interface AgentUpdateInput {
|
||||
totalOutputTokens?: number;
|
||||
instructionsPath?: string;
|
||||
instructionsText?: string;
|
||||
bundleConfig?: InstructionsBundleConfig;
|
||||
}
|
||||
|
||||
/** An API key associated with an agent for bearer token authentication. */
|
||||
@@ -1723,6 +1740,7 @@ export interface AgentConfigSnapshot {
|
||||
permissions?: Record<string, boolean>;
|
||||
instructionsPath?: string;
|
||||
instructionsText?: string;
|
||||
bundleConfig?: InstructionsBundleConfig;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -1767,6 +1785,12 @@ export function agentToConfigSnapshot(agent: Agent): AgentConfigSnapshot {
|
||||
permissions: agent.permissions ? { ...agent.permissions } : undefined,
|
||||
instructionsPath: agent.instructionsPath,
|
||||
instructionsText: agent.instructionsText,
|
||||
bundleConfig: agent.bundleConfig
|
||||
? {
|
||||
...agent.bundleConfig,
|
||||
files: [...agent.bundleConfig.files],
|
||||
}
|
||||
: undefined,
|
||||
metadata: { ...agent.metadata },
|
||||
};
|
||||
}
|
||||
@@ -1786,6 +1810,7 @@ export function diffConfigSnapshots(
|
||||
"permissions",
|
||||
"instructionsPath",
|
||||
"instructionsText",
|
||||
"bundleConfig",
|
||||
"metadata",
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user