Merge branch 'fusion/fn-1449'

# Conflicts:
#	packages/cli/src/commands/__tests__/serve.test.ts
This commit is contained in:
gsxdsm
2026-04-11 19:30:33 -07:00
9 changed files with 1318 additions and 19 deletions

View File

@@ -357,10 +357,9 @@ name: Zip CEO
name: "CEO",
role: "custom",
title: "Chief Executive Officer",
instructionsText: "Lead strategy",
metadata: {
instructions: "Lead strategy",
skills: ["review"],
reportsTo: null,
sources: [{ kind: "git", repo: "acme/repo" }],
},
});
@@ -391,6 +390,45 @@ name: Zip CEO
const input = agentManifestToAgentCreateInput({ name: "Generalist" });
expect(input.role).toBe("custom");
});
it("maps manifest icon to first-class field", () => {
const input = agentManifestToAgentCreateInput({
name: "Bot",
icon: "🤖",
role: "executor",
});
expect(input).toEqual({
name: "Bot",
role: "executor",
icon: "🤖",
});
});
it("maps manifest reportsTo to first-class field", () => {
const input = agentManifestToAgentCreateInput({
name: "Worker",
reportsTo: "manager-001",
});
expect(input).toEqual({
name: "Worker",
role: "custom",
reportsTo: "manager-001",
});
});
it("maps manifest role to first-class field", () => {
const input = agentManifestToAgentCreateInput({
name: "Reviewer",
role: "reviewer",
});
expect(input).toEqual({
name: "Reviewer",
role: "reviewer",
});
});
});
describe("mapRoleToCapability", () => {

View File

@@ -284,25 +284,29 @@ export async function parseCompanyArchive(archivePath: string): Promise<AgentCom
export function agentManifestToAgentCreateInput(agent: AgentManifest): AgentCreateInput {
const metadata: Record<string, unknown> = {};
if (typeof agent.instructionBody === "string") {
metadata.instructions = agent.instructionBody;
}
// Store skills and metadata sources in metadata (skills is not a first-class field)
if (Array.isArray(agent.skills) && agent.skills.length > 0) {
metadata.skills = agent.skills;
}
if (agent.reportsTo !== undefined) {
metadata.reportsTo = agent.reportsTo;
}
if (Array.isArray(agent.metadata?.sources) && agent.metadata.sources.length > 0) {
metadata.sources = agent.metadata.sources;
}
return {
name: agent.name,
role: mapRoleToCapability("custom"),
role: agent.role ? mapRoleToCapability(agent.role) : mapRoleToCapability("custom"),
...(typeof agent.title === "string" && agent.title.trim().length > 0
? { title: agent.title }
: {}),
...(typeof agent.icon === "string" && agent.icon.trim().length > 0
? { icon: agent.icon.trim() }
: {}),
...(typeof agent.reportsTo === "string" && agent.reportsTo.trim().length > 0
? { reportsTo: agent.reportsTo.trim() }
: {}),
...(typeof agent.instructionBody === "string" && agent.instructionBody.trim().length > 0
? { instructionsText: agent.instructionBody.trim() }
: {}),
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
};
}

View File

@@ -2165,6 +2165,13 @@ function ConfigTab({
addToast: (message: string, type?: "success" | "error") => void;
onSaved: () => Promise<void>;
}) {
// Identity field state
const [nameValue, setNameValue] = useState(agent.name);
const [roleValue, setRoleValue] = useState(agent.role);
const [titleValue, setTitleValue] = useState(agent.title ?? "");
const [iconValue, setIconValue] = useState(agent.icon ?? "");
const [reportsToValue, setReportsToValue] = useState(agent.reportsTo ?? "");
// Local form state initialised from agent.metadata
const [formValues, setFormValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
@@ -2218,6 +2225,12 @@ function ConfigTab({
return initial;
});
// Bundle config state
const [bundleMode, setBundleMode] = useState<string>(agent.bundleConfig?.mode ?? "");
const [bundleEntryFile, setBundleEntryFile] = useState(agent.bundleConfig?.entryFile ?? "AGENTS.md");
const [bundleExternalPath, setBundleExternalPath] = useState(agent.bundleConfig?.externalPath ?? "");
const [bundleFiles, setBundleFiles] = useState<string[]>(agent.bundleConfig?.files ?? []);
// Budget status for progress bar display
const [budgetStatus, setBudgetStatus] = useState<AgentBudgetStatus | null>(null);
const [isResettingBudget, setIsResettingBudget] = useState(false);
@@ -2250,6 +2263,19 @@ function ConfigTab({
/** Detect whether any local value differs from the persisted metadata */
const hasChanges = (() => {
// Check identity fields
if (nameValue !== agent.name) return true;
if (roleValue !== agent.role) return true;
if (titleValue !== (agent.title ?? "")) return true;
if (iconValue !== (agent.icon ?? "")) return true;
if (reportsToValue !== (agent.reportsTo ?? "")) return true;
// Check bundle config
if (bundleMode !== (agent.bundleConfig?.mode ?? "")) return true;
if (bundleEntryFile !== (agent.bundleConfig?.entryFile ?? "AGENTS.md")) return true;
if (bundleExternalPath !== (agent.bundleConfig?.externalPath ?? "")) return true;
if (JSON.stringify(bundleFiles) !== JSON.stringify(agent.bundleConfig?.files ?? [])) return true;
for (const field of ADVANCED_SETTINGS) {
const current = formValues[field.key]?.trim() ?? "";
const persisted = agent.metadata[field.key] !== undefined && agent.metadata[field.key] !== null
@@ -2454,9 +2480,31 @@ function ConfigTab({
delete newRuntimeConfig.budgetConfig;
}
// Build bundleConfig payload — only include if mode is set
let newBundleConfig: { mode: "managed" | "external"; entryFile: string; files: string[]; externalPath?: string } | undefined;
if (bundleMode) {
newBundleConfig = {
mode: bundleMode as "managed" | "external",
entryFile: bundleEntryFile || "AGENTS.md",
files: bundleFiles.length > 0 ? bundleFiles : ["AGENTS.md"],
};
if (bundleMode === "external" && bundleExternalPath.trim()) {
newBundleConfig.externalPath = bundleExternalPath.trim();
}
}
setIsSaving(true);
try {
await updateAgent(agent.id, { metadata: newMetadata, runtimeConfig: newRuntimeConfig }, projectId);
await updateAgent(agent.id, {
name: nameValue.trim() || undefined,
role: roleValue as any,
title: titleValue.trim() || undefined,
icon: iconValue.trim() || undefined,
reportsTo: reportsToValue.trim() || undefined,
metadata: newMetadata,
runtimeConfig: newRuntimeConfig,
bundleConfig: newBundleConfig,
}, projectId);
addToast("Settings saved", "success");
setJustSaved(true);
// Auto-hide the saved indicator after 3 seconds
@@ -2479,19 +2527,24 @@ function ConfigTab({
<div className="config-fields">
<div className="config-field">
<label>Name</label>
<label htmlFor="agent-name">Name</label>
<input
id="agent-name"
type="text"
className="input"
defaultValue={agent.name}
disabled
value={nameValue}
onChange={(e) => setNameValue(e.target.value)}
/>
<span className="config-hint">Name changes coming soon</span>
</div>
<div className="config-field">
<label>Role</label>
<select className="select" defaultValue={agent.role} disabled>
<label htmlFor="agent-role">Role</label>
<select
id="agent-role"
className="select"
value={roleValue}
onChange={(e) => setRoleValue(e.target.value as any)}
>
<option value="triage">Triage</option>
<option value="executor">Executor</option>
<option value="reviewer">Reviewer</option>
@@ -2499,7 +2552,42 @@ function ConfigTab({
<option value="scheduler">Scheduler</option>
<option value="custom">Custom</option>
</select>
<span className="config-hint">Role changes coming soon</span>
</div>
<div className="config-field">
<label htmlFor="agent-title">Title</label>
<input
id="agent-title"
type="text"
className="input"
placeholder="e.g. Senior Code Reviewer"
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
/>
</div>
<div className="config-field">
<label htmlFor="agent-icon">Icon</label>
<input
id="agent-icon"
type="text"
className="input"
placeholder="e.g. 🤖"
value={iconValue}
onChange={(e) => setIconValue(e.target.value)}
/>
</div>
<div className="config-field">
<label htmlFor="agent-reports-to">Reports To</label>
<input
id="agent-reports-to"
type="text"
className="input"
placeholder="e.g. agent-001"
value={reportsToValue}
onChange={(e) => setReportsToValue(e.target.value)}
/>
</div>
</div>
</div>
@@ -2723,6 +2811,81 @@ function ConfigTab({
</div>
</div>
<div className="config-section">
<h3>Instruction Bundle</h3>
<p className="config-description">
Configure the agent's instruction bundle. Leave empty to use inline instructions only.
</p>
<div className="config-fields">
<div className="config-field">
<label htmlFor="bundle-mode">Bundle Mode</label>
<select
id="bundle-mode"
className="select"
value={bundleMode}
onChange={(e) => setBundleMode(e.target.value)}
>
<option value="">None (use inline instructions)</option>
<option value="managed">Managed (system-managed directory)</option>
<option value="external">External (user-specified path)</option>
</select>
<span className="config-hint">
{bundleMode === "managed" && "Files will be stored in a system-managed directory within .fusion/agents/"}
{bundleMode === "external" && "Specify an external directory path for the instruction files"}
{!bundleMode && "Select a mode to enable instruction bundling"}
</span>
</div>
{bundleMode && (
<>
<div className="config-field">
<label htmlFor="bundle-entry-file">Entry File</label>
<input
id="bundle-entry-file"
type="text"
className="input"
placeholder="AGENTS.md"
value={bundleEntryFile}
onChange={(e) => setBundleEntryFile(e.target.value)}
/>
<span className="config-hint">Primary instructions file name (default: AGENTS.md)</span>
</div>
{bundleMode === "external" && (
<div className="config-field">
<label htmlFor="bundle-external-path">External Path</label>
<input
id="bundle-external-path"
type="text"
className="input"
placeholder="e.g. .fusion/agents/my-agent"
value={bundleExternalPath}
onChange={(e) => setBundleExternalPath(e.target.value)}
/>
<span className="config-hint">Absolute or relative path to the external directory</span>
</div>
)}
<div className="config-field">
<label htmlFor="bundle-files">Files (comma-separated)</label>
<input
id="bundle-files"
type="text"
className="input"
placeholder="AGENTS.md, PROMPTS.md"
value={bundleFiles.join(", ")}
onChange={(e) => setBundleFiles(
e.target.value.split(",").map(f => f.trim()).filter(Boolean)
)}
/>
<span className="config-hint">List of file names in the bundle directory</span>
</div>
</>
)}
</div>
</div>
<div className="config-section">
<h3>Advanced Settings</h3>
<p className="config-description">

View File

@@ -13,6 +13,9 @@ interface AgentPreview {
name: string;
role: string;
title?: string;
icon?: string;
reportsTo?: string;
instructionsText?: string;
skills?: string[];
}
@@ -28,6 +31,9 @@ interface ImportResult {
interface DirectoryAgentInput {
name: string;
title?: string;
icon?: string;
role?: string;
reportsTo?: string;
skills?: string[];
instructionBody?: string;
}
@@ -75,6 +81,9 @@ function parseDirectoryAgentManifest(content: string): DirectoryAgentInput {
if (key === "name") result.name = normalizedValue;
if (key === "title") result.title = normalizedValue;
if (key === "icon") result.icon = normalizedValue;
if (key === "role") result.role = normalizedValue;
if (key === "reportsTo") result.reportsTo = normalizedValue;
}
if (!result.name) {
@@ -391,16 +400,24 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
<div className="agent-import-agent-list">
{agents.map((agent, idx) => (
<div key={idx} className="agent-import-agent-item">
<span className="agent-import-agent-icon">🤖</span>
<span className="agent-import-agent-icon">{agent.icon || "🤖"}</span>
<div className="agent-import-agent-details">
<span className="agent-import-agent-name">{agent.name}</span>
<span className="agent-import-agent-meta">
{agent.title && <span className="agent-import-agent-title">{agent.title} · </span>}
<span className="agent-import-agent-role">{agent.role}</span>
{agent.reportsTo && (
<span className="agent-import-agent-reports"> · reports to {agent.reportsTo}</span>
)}
{agent.skills && agent.skills.length > 0 && (
<span className="agent-import-agent-model"> · {agent.skills.join(", ")}</span>
<span className="agent-import-agent-model"> · skills: {agent.skills.join(", ")}</span>
)}
</span>
{agent.instructionsText && (
<span className="agent-import-agent-instructions">
{agent.instructionsText.slice(0, 100)}{agent.instructionsText.length > 100 ? "..." : ""}
</span>
)}
</div>
</div>
))}

View File

@@ -44,6 +44,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
const [instructionsPath, setInstructionsPath] = useState("");
const [instructionsText, setInstructionsText] = useState("");
const [soul, setSoul] = useState("");
const [memory, setMemory] = useState("");
const [runtimeConfig, setRuntimeConfig] = useState<RuntimeConfig>({
model: "",
thinkingLevel: "off",
@@ -90,6 +91,8 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
setTitle(spec.description);
setIcon(spec.icon);
setRole(mappedRole);
// Map generated systemPrompt to instructionsText
setInstructionsText(spec.systemPrompt);
setRuntimeConfig(c => ({
...c,
thinkingLevel: spec.thinkingLevel,
@@ -147,6 +150,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
setInstructionsPath("");
setInstructionsText("");
setSoul("");
setMemory("");
setRuntimeConfig({ model: "", thinkingLevel: "off", maxTurns: 10 });
setSelectedPresetId(null);
setError(null);
@@ -172,6 +176,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
...(instructionsPath.trim() ? { instructionsPath: instructionsPath.trim() } : {}),
...(instructionsText.trim() ? { instructionsText: instructionsText.trim() } : {}),
...(soul.trim() ? { soul: soul.trim() } : {}),
...(memory.trim() ? { memory: memory.trim() } : {}),
...(Object.keys(runtimeCfg).length > 0 ? { runtimeConfig: runtimeCfg } : {}),
}, projectId);
handleClose();
@@ -300,6 +305,17 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
onChange={e => setSoul(e.target.value)}
/>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-memory">Memory <span className="agent-dialog-optional">(optional)</span></label>
<textarea
id="agent-memory"
className="input"
rows={2}
placeholder="Per-agent memory — stores learnings and context the agent has gathered..."
value={memory}
onChange={e => setMemory(e.target.value)}
/>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-instructions-path">Instructions Path <span className="agent-dialog-optional">(optional)</span></label>
<input

View File

@@ -9034,6 +9034,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
instructionsPath,
instructionsText,
soul,
memory,
bundleConfig,
} = req.body ?? {};
if (!name || typeof name !== "string") {
@@ -9069,6 +9071,29 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
if (typeof soul === "string" && soul.length > 10000) {
throw badRequest("soul must be at most 10,000 characters");
}
if (memory !== undefined && memory !== null && typeof memory !== "string") {
throw badRequest("memory must be a string");
}
if (typeof memory === "string" && memory.length > 50000) {
throw badRequest("memory must be at most 50,000 characters");
}
if (bundleConfig !== undefined && bundleConfig !== null) {
if (typeof bundleConfig !== "object" || Array.isArray(bundleConfig)) {
throw badRequest("bundleConfig must be an object");
}
if (typeof bundleConfig.mode !== "string" || !["managed", "external"].includes(bundleConfig.mode)) {
throw badRequest("bundleConfig.mode must be 'managed' or 'external'");
}
if (typeof bundleConfig.entryFile !== "string") {
throw badRequest("bundleConfig.entryFile must be a string");
}
if (!Array.isArray(bundleConfig.files)) {
throw badRequest("bundleConfig.files must be an array");
}
if (bundleConfig.externalPath !== undefined && typeof bundleConfig.externalPath !== "string") {
throw badRequest("bundleConfig.externalPath must be a string");
}
}
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
@@ -9087,6 +9112,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
instructionsPath: instructionsPath ?? undefined,
instructionsText: instructionsText ?? undefined,
soul: soul ?? undefined,
memory: memory ?? undefined,
bundleConfig: bundleConfig ?? undefined,
});
res.status(201).json(agent);
} catch (err: any) {
@@ -9258,6 +9285,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
name: input.name,
role: input.role,
title: typeof input.title === "string" ? input.title : undefined,
icon: typeof input.icon === "string" ? input.icon : undefined,
reportsTo: typeof input.reportsTo === "string" ? input.reportsTo : undefined,
instructionsText: typeof input.instructionsText === "string"
? input.instructionsText.slice(0, 200) + (input.instructionsText.length > 200 ? "..." : "")
: undefined,
skills: Array.isArray(input.metadata?.skills)
? input.metadata.skills.filter((skill: unknown): skill is string => typeof skill === "string")
: undefined,
@@ -9526,6 +9558,37 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
updates.soul = body.soul ?? undefined;
}
if ("memory" in body) {
if (body.memory !== null && typeof body.memory !== "string") {
throw badRequest("memory must be a string");
}
if (typeof body.memory === "string" && body.memory.length > 50000) {
throw badRequest("memory must be at most 50,000 characters");
}
updates.memory = body.memory ?? undefined;
}
if ("bundleConfig" in body) {
if (body.bundleConfig !== null) {
if (typeof body.bundleConfig !== "object" || Array.isArray(body.bundleConfig)) {
throw badRequest("bundleConfig must be an object");
}
if (typeof body.bundleConfig.mode !== "string" || !["managed", "external"].includes(body.bundleConfig.mode)) {
throw badRequest("bundleConfig.mode must be 'managed' or 'external'");
}
if (typeof body.bundleConfig.entryFile !== "string") {
throw badRequest("bundleConfig.entryFile must be a string");
}
if (!Array.isArray(body.bundleConfig.files)) {
throw badRequest("bundleConfig.files must be an array");
}
if (body.bundleConfig.externalPath !== undefined && typeof body.bundleConfig.externalPath !== "string") {
throw badRequest("bundleConfig.externalPath must be a string");
}
}
updates.bundleConfig = body.bundleConfig ?? undefined;
}
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });