feat(FN-1171): add agent soul memory and employees workflows

- Add soul and memory fields to agent types and AgentStore with persistence/update test coverage
- Add dashboard routes and API helpers to fetch and update agent soul/memory data
- Extend AgentDetailView with Soul, Memory, and Employees tabs and rename children labels to employees
- Normalize employee route params for type safety and add focused route/component tests for the new flows
This commit is contained in:
gsxdsm
2026-04-08 13:00:12 -07:00
parent d3c5f35f3d
commit 67ce1ca2ca
10 changed files with 770 additions and 28 deletions

View File

@@ -1984,6 +1984,32 @@ export function updateAgentInstructions(
});
}
/** Fetch agent soul/personality text */
export function fetchAgentSoul(agentId: string, projectId?: string): Promise<{ soul: string | null }> {
return api<{ soul: string | null }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/soul`, projectId));
}
/** Update agent soul/personality text */
export function updateAgentSoul(agentId: string, soul: string, projectId?: string): Promise<Agent> {
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/soul`, projectId), {
method: "PATCH",
body: JSON.stringify({ soul }),
});
}
/** Fetch per-agent memory text */
export function fetchAgentMemory(agentId: string, projectId?: string): Promise<{ memory: string | null }> {
return api<{ memory: string | null }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory`, projectId));
}
/** Update per-agent memory text */
export function updateAgentMemory(agentId: string, memory: string, projectId?: string): Promise<Agent> {
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory`, projectId), {
method: "PATCH",
body: JSON.stringify({ memory }),
});
}
/** Update an agent's state */
export function updateAgentState(agentId: string, state: AgentState, projectId?: string): Promise<Agent> {
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/state`, projectId), {
@@ -2073,7 +2099,7 @@ export function resolveAgent(shortname: string, projectId?: string): Promise<{ a
return api<{ agent: Agent }>(withProjectId(`/agents/resolve/${encodeURIComponent(shortname)}`, projectId));
}
/** Fetch child agents that report to a given parent agent */
/** Fetch employees (agents that report to a given parent agent) */
export function fetchAgentChildren(agentId: string, projectId?: string): Promise<Agent[]> {
return api<Agent[]>(withProjectId(`/agents/${encodeURIComponent(agentId)}/children`, projectId)).catch((err: Error) => {
// Return empty array for 404 (agent may have been deleted)
@@ -2082,6 +2108,9 @@ export function fetchAgentChildren(agentId: string, projectId?: string): Promise
});
}
/** Alias for fetchAgentChildren with employee-focused naming */
export const fetchAgentEmployees = fetchAgentChildren;
/** Assign or unassign a task to an explicit agent */
export function assignTask(taskId: string, agentId: string | null, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/assign`, projectId), {

View File

@@ -6,7 +6,7 @@ import {
ChevronDown, ChevronRight
} from "lucide-react";
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, updateAgentInstructions, fetchAgentTasks, fetchChainOfCommand } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand } from "../api";
import type { Agent } from "../api";
import type { AgentLogEntry, Task } from "@fusion/core";
import { AgentLogViewer } from "./AgentLogViewer";
@@ -50,14 +50,16 @@ interface AgentDetailViewProps {
onChildClick?: (childId: string) => void;
}
type TabId = "dashboard" | "logs" | "config" | "runs" | "children" | "tasks";
type TabId = "dashboard" | "logs" | "config" | "runs" | "tasks" | "employees" | "soul" | "memory";
const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [
{ id: "dashboard", label: "Dashboard", icon: ActivitySquare },
{ id: "logs", label: "Logs", icon: FileText },
{ id: "runs", label: "Runs", icon: Activity },
{ id: "tasks", label: "Tasks", icon: ListChecks },
{ id: "children", label: "Children", icon: GitBranch },
{ id: "employees", label: "Employees", icon: GitBranch },
{ id: "soul", label: "Soul", icon: Heart },
{ id: "memory", label: "Memory", icon: FileText },
{ id: "config", label: "Settings", icon: Settings },
];
@@ -407,8 +409,16 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
/>
)}
{activeTab === "config" && (
<ConfigTab
{activeTab === "employees" && (
<EmployeesTab
agentId={agent.id}
projectId={projectId}
onChildClick={onChildClick}
/>
)}
{activeTab === "soul" && (
<SoulTab
agent={agent}
projectId={projectId}
addToast={addToast}
@@ -416,11 +426,21 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
/>
)}
{activeTab === "children" && (
<ChildrenTab
agentId={agent.id}
{activeTab === "memory" && (
<MemoryTab
agent={agent}
projectId={projectId}
onChildClick={onChildClick}
addToast={addToast}
onSaved={loadAgent}
/>
)}
{activeTab === "config" && (
<ConfigTab
agent={agent}
projectId={projectId}
addToast={addToast}
onSaved={loadAgent}
/>
)}
</div>
@@ -1330,6 +1350,211 @@ function validateAdvancedSettings(
return errors;
}
function SoulTab({
agent,
projectId,
addToast,
onSaved,
}: {
agent: AgentDetail;
projectId?: string;
addToast: (message: string, type?: "success" | "error") => void;
onSaved: () => Promise<void>;
}) {
const [soul, setSoul] = useState(agent.soul ?? "");
const [isSaving, setIsSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false);
useEffect(() => {
setSoul(agent.soul ?? "");
setJustSaved(false);
}, [agent.id, agent.soul]);
const hasChanges = soul !== (agent.soul ?? "");
const handleSave = async () => {
if (soul.length > 10000) {
addToast("Soul must be at most 10,000 characters", "error");
return;
}
setIsSaving(true);
try {
await updateAgentSoul(agent.id, soul, projectId);
addToast("Soul saved", "success");
setJustSaved(true);
setTimeout(() => setJustSaved(false), 3000);
await onSaved();
} catch (err: any) {
addToast(`Failed to save soul: ${err.message}`, "error");
} finally {
setIsSaving(false);
}
};
return (
<div className="config-tab">
<div className="config-section">
<h3>Soul</h3>
<p className="config-description">
Define this agent&apos;s personality and identity.
</p>
<div className="config-fields">
<div className="config-field">
<label htmlFor="agent-soul">Agent Soul</label>
<textarea
id="agent-soul"
className="input"
rows={12}
placeholder="Describe this agent's personality, tone, and behavioral traits..."
value={soul}
onChange={(e) => {
setSoul(e.target.value);
setJustSaved(false);
}}
style={{ fontFamily: "monospace", fontSize: "0.875rem", resize: "vertical" }}
/>
<span className="config-hint">Defines the agent&apos;s character and identity. Max 10,000 characters.</span>
</div>
</div>
<div className="config-actions">
<button
className="btn btn--primary"
disabled={!hasChanges || isSaving}
onClick={() => void handleSave()}
>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving
</>
) : (
<>
<CheckCircle size={16} />
Save Soul
</>
)}
</button>
{!hasChanges && justSaved && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
Soul saved
</span>
)}
</div>
</div>
</div>
);
}
function MemoryTab({
agent,
projectId,
addToast,
onSaved,
}: {
agent: AgentDetail;
projectId?: string;
addToast: (message: string, type?: "success" | "error") => void;
onSaved: () => Promise<void>;
}) {
const [memory, setMemory] = useState(agent.memory ?? "");
const [isSaving, setIsSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false);
useEffect(() => {
setMemory(agent.memory ?? "");
setJustSaved(false);
}, [agent.id, agent.memory]);
const isReadOnly = agent.state === "running";
const hasChanges = memory !== (agent.memory ?? "");
const handleSave = async () => {
if (memory.length > 50000) {
addToast("Memory must be at most 50,000 characters", "error");
return;
}
setIsSaving(true);
try {
await updateAgentMemory(agent.id, memory, projectId);
addToast("Memory saved", "success");
setJustSaved(true);
setTimeout(() => setJustSaved(false), 3000);
await onSaved();
} catch (err: any) {
addToast(`Failed to save memory: ${err.message}`, "error");
} finally {
setIsSaving(false);
}
};
return (
<div className="config-tab">
<div className="config-section">
<h3>Memory</h3>
<p className="config-description">
Store accumulated context and learnings for this agent.
</p>
{isReadOnly && (
<p className="config-hint" style={{ marginBottom: 12 }}>
Read-only while this agent is running.
</p>
)}
<div className="config-fields">
<div className="config-field">
<label htmlFor="agent-memory">Agent Memory</label>
<textarea
id="agent-memory"
className="input"
rows={15}
placeholder="Agent's accumulated knowledge, learnings, and preferences..."
value={memory}
readOnly={isReadOnly}
onChange={(e) => {
setMemory(e.target.value);
setJustSaved(false);
}}
style={{ fontFamily: "monospace", fontSize: "0.875rem", resize: "vertical" }}
/>
<span className="config-hint">Per-agent memory stores learnings and context the agent has gathered. Max 50,000 characters.</span>
</div>
</div>
<div className="config-actions">
<button
className="btn btn--primary"
disabled={!hasChanges || isSaving || isReadOnly}
onClick={() => void handleSave()}
>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving
</>
) : (
<>
<CheckCircle size={16} />
Save Memory
</>
)}
</button>
{!hasChanges && justSaved && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
Memory saved
</span>
)}
</div>
</div>
</div>
);
}
function ConfigTab({
agent,
projectId,
@@ -1797,9 +2022,9 @@ function ConfigTab({
);
}
// ── Children Tab ───────────────────────────────────────────────────────────
// ── Employees Tab ───────────────────────────────────────────────────────────
function ChildrenTab({
function EmployeesTab({
agentId,
projectId,
onChildClick,
@@ -1822,11 +2047,11 @@ function ChildrenTab({
return (
<div className="detail-section">
<div className="detail-section-header">
<h3>Child Agents</h3>
<h3>Employees</h3>
</div>
<div className="detail-section-body" style={{ display: "flex", alignItems: "center", gap: 8, padding: 16 }}>
<Loader2 size={16} className="spin" />
<span className="text-secondary">Loading children...</span>
<span className="text-secondary">Loading employees...</span>
</div>
</div>
);
@@ -1835,15 +2060,15 @@ function ChildrenTab({
return (
<div className="detail-section">
<div className="detail-section-header">
<h3>Child Agents</h3>
<h3>Employees</h3>
<span className="text-secondary">({children.length})</span>
</div>
<div className="detail-section-body">
{children.length === 0 ? (
<div className="agent-empty" style={{ padding: 24 }}>
<GitBranch size={32} opacity={0.3} />
<p>No child agents</p>
<p className="text-secondary">This agent has no spawned children</p>
<p>No employees</p>
<p className="text-secondary">This agent has no employees</p>
</div>
) : (
<div className="agent-tree__children">

View File

@@ -69,8 +69,8 @@ function AgentTreeNode({
<button
className={`agent-tree__toggle${childCount === 0 ? " agent-tree__toggle--leaf" : ""}`}
onClick={() => childCount > 0 && onToggle(agent.id)}
title={childCount > 0 ? (expanded ? "Collapse" : "Expand") : "No children"}
aria-label={childCount > 0 ? (expanded ? "Collapse" : "Expand") : "No children"}
title={childCount > 0 ? (expanded ? "Collapse" : "Expand") : "No employees"}
aria-label={childCount > 0 ? (expanded ? "Collapse" : "Expand") : "No employees"}
>
{childCount > 0 ? (
expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />
@@ -171,7 +171,7 @@ function OrgChartNode({
</div>
</div>
{children.length > 0 && (
<div className="org-chart-children" role="group" aria-label={`${agent.name} reports`}>
<div className="org-chart-children" role="group" aria-label={`${agent.name} employees`}>
{children.map((child) => (
<OrgChartNode
key={child.agent.id}

View File

@@ -13,9 +13,13 @@ vi.mock("../../api", () => ({
deleteAgent: vi.fn(),
fetchAgentLogs: vi.fn(),
fetchAgentRunLogs: vi.fn(),
fetchAgentChildren: vi.fn(),
fetchAgentRuns: vi.fn(),
fetchAgentRunDetail: vi.fn(),
startAgentRun: vi.fn(),
updateAgentInstructions: vi.fn(),
updateAgentSoul: vi.fn(),
updateAgentMemory: vi.fn(),
fetchAgentTasks: vi.fn(),
fetchChainOfCommand: vi.fn(),
}));
@@ -28,11 +32,12 @@ vi.mock("../AgentLogViewer", () => ({
),
}));
import { fetchAgent, updateAgent, updateAgentState, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand } from "../../api";
import { fetchAgent, updateAgent, updateAgentState, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand } from "../../api";
const mockFetchAgent = vi.mocked(fetchAgent);
const mockUpdateAgent = vi.mocked(updateAgent);
const mockUpdateAgentState = vi.mocked(updateAgentState);
const mockFetchAgentChildren = vi.mocked(fetchAgentChildren);
const mockFetchAgentRunLogs = vi.mocked(fetchAgentRunLogs);
const mockFetchAgentRuns = vi.mocked(fetchAgentRuns);
const mockFetchAgentRunDetail = vi.mocked(fetchAgentRunDetail);
@@ -90,6 +95,7 @@ describe("AgentDetailView", () => {
...mockAgent.completedRuns,
]);
mockFetchAgentRunDetail.mockResolvedValue(mockAgent.completedRuns[0]);
mockFetchAgentChildren.mockResolvedValue([]);
mockFetchAgentTasks.mockResolvedValue([]);
mockFetchChainOfCommand.mockResolvedValue([mockAgent]);
});
@@ -382,11 +388,34 @@ describe("AgentDetailView", () => {
expect(screen.getByText("Logs")).toBeInTheDocument();
expect(screen.getByText("Runs")).toBeInTheDocument();
expect(screen.getByText("Tasks")).toBeInTheDocument();
expect(screen.getByText("Children")).toBeInTheDocument();
expect(screen.getByText("Employees")).toBeInTheDocument();
expect(screen.getByText("Soul")).toBeInTheDocument();
expect(screen.getByText("Memory")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument();
});
});
it("renders Employees tab empty state", async () => {
const user = userEvent.setup();
mockFetchAgentChildren.mockResolvedValue([]);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await user.click(await screen.findByText("Employees"));
await waitFor(() => {
expect(mockFetchAgentChildren).toHaveBeenCalledWith("agent-001", undefined);
expect(screen.getByText("No employees")).toBeInTheDocument();
expect(screen.getByText("This agent has no employees")).toBeInTheDocument();
});
});
it("shows Pause button for active agent", async () => {
render(
<AgentDetailView

View File

@@ -68,7 +68,7 @@ function buildTree(agents: Agent[], expanded: Set<string>): AgentNode[] {
}
/**
* Hook for managing agent hierarchy (parent-child relationships).
* Hook for managing agent hierarchy (manager-employee relationships).
* Derives the tree structure from the `reportsTo` field on agents.
* Expand/collapse state is persisted to localStorage.
*/