feat(FN-865): add AI agent generation service with dashboard UI
- Add agent generation service with OpenAI/Anthropic support, streaming, and error handling - Add POST /agent/generate API endpoint with SSE streaming and cost tracking - Add API client functions for agent generation with AbortController support - Create AgentGenerationModal component with live preview, streaming, and import flow - Integrate AI Generate button into NewAgentDialog with configuration options - Add comprehensive unit tests for service and component - Add changeset for @gsxdsm/fusion minor bump
This commit is contained in:
5
.changeset/ai-agent-generation.md
Normal file
5
.changeset/ai-agent-generation.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add AI-assisted agent creation with role-based prompt generation. Users can describe an agent's role and the system generates a complete specification including system prompt, suggested configuration, title, and icon.
|
||||||
@@ -1765,6 +1765,63 @@ export function fetchAgentStats(projectId?: string): Promise<AgentStats> {
|
|||||||
return api<AgentStats>(withProjectId("/agents/stats", projectId));
|
return api<AgentStats>(withProjectId("/agents/stats", projectId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Agent Generation API ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Generated agent specification returned by the AI */
|
||||||
|
export interface AgentGenerationSpec {
|
||||||
|
/** Display name for the agent */
|
||||||
|
title: string;
|
||||||
|
/** Single emoji icon */
|
||||||
|
icon: string;
|
||||||
|
/** Agent capability/role */
|
||||||
|
role: string;
|
||||||
|
/** Brief description of the agent's purpose */
|
||||||
|
description: string;
|
||||||
|
/** Detailed system prompt in markdown */
|
||||||
|
systemPrompt: string;
|
||||||
|
/** Suggested thinking level */
|
||||||
|
thinkingLevel: "off" | "minimal" | "low" | "medium" | "high";
|
||||||
|
/** Suggested max turns (1-500) */
|
||||||
|
maxTurns: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** State of an agent generation session */
|
||||||
|
export interface AgentGenerationSession {
|
||||||
|
id: string;
|
||||||
|
roleDescription: string;
|
||||||
|
spec?: AgentGenerationSpec;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start an agent generation session with a role description */
|
||||||
|
export function startAgentGeneration(role: string, projectId?: string): Promise<{ sessionId: string; roleDescription: string }> {
|
||||||
|
return api<{ sessionId: string; roleDescription: string }>(withProjectId("/agents/generate/start", projectId), {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ role }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate the agent specification for an existing session */
|
||||||
|
export function generateAgentSpec(sessionId: string, projectId?: string): Promise<{ spec: AgentGenerationSpec }> {
|
||||||
|
return api<{ spec: AgentGenerationSpec }>(withProjectId("/agents/generate/spec", projectId), {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ sessionId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the current state of an agent generation session */
|
||||||
|
export function getAgentGenerationSession(sessionId: string, projectId?: string): Promise<{ session: AgentGenerationSession }> {
|
||||||
|
return api<{ session: AgentGenerationSession }>(withProjectId(`/agents/generate/${encodeURIComponent(sessionId)}`, projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel and clean up an agent generation session */
|
||||||
|
export function cancelAgentGeneration(sessionId: string, projectId?: string): Promise<{ success: boolean }> {
|
||||||
|
return api<{ success: boolean }>(withProjectId(`/agents/generate/${encodeURIComponent(sessionId)}`, projectId), {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Backup API ---
|
// --- Backup API ---
|
||||||
|
|
||||||
/** Backup metadata from the API */
|
/** Backup metadata from the API */
|
||||||
|
|||||||
433
packages/dashboard/app/components/AgentGenerationModal.tsx
Normal file
433
packages/dashboard/app/components/AgentGenerationModal.tsx
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
|
import type { AgentGenerationSpec } from "../api";
|
||||||
|
import {
|
||||||
|
startAgentGeneration,
|
||||||
|
generateAgentSpec,
|
||||||
|
cancelAgentGeneration,
|
||||||
|
} from "../api";
|
||||||
|
|
||||||
|
interface AgentGenerationModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onGenerated: (spec: AgentGenerationSpec) => void;
|
||||||
|
projectId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewState =
|
||||||
|
| { type: "input" }
|
||||||
|
| { type: "loading" }
|
||||||
|
| { type: "preview"; spec: AgentGenerationSpec; sessionId: string };
|
||||||
|
|
||||||
|
const MIN_ROLE_LENGTH = 3;
|
||||||
|
const MAX_ROLE_LENGTH = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal for AI-assisted agent creation.
|
||||||
|
*
|
||||||
|
* The user enters a role description and the system generates a complete
|
||||||
|
* agent specification including title, icon, system prompt, and suggested
|
||||||
|
* runtime configuration.
|
||||||
|
*
|
||||||
|
* Follows the same general modal pattern as PlanningModeModal but simplified
|
||||||
|
* (no multi-step Q&A — single input → single generation result).
|
||||||
|
*/
|
||||||
|
export function AgentGenerationModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onGenerated,
|
||||||
|
projectId,
|
||||||
|
}: AgentGenerationModalProps) {
|
||||||
|
const [roleDescription, setRoleDescription] = useState("");
|
||||||
|
const [view, setView] = useState<ViewState>({ type: "input" });
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [systemPromptExpanded, setSystemPromptExpanded] = useState(false);
|
||||||
|
const sessionIdRef = useRef<string | null>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// Focus textarea on open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && view.type === "input") {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [isOpen, view.type]);
|
||||||
|
|
||||||
|
// Cleanup session on unmount or modal close
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen && sessionIdRef.current) {
|
||||||
|
const sid = sessionIdRef.current;
|
||||||
|
sessionIdRef.current = null;
|
||||||
|
cancelAgentGeneration(sid, projectId).catch(() => {
|
||||||
|
/* ignore cleanup errors */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isOpen, projectId]);
|
||||||
|
|
||||||
|
// Handle escape key
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
// Cleanup session server-side
|
||||||
|
if (sessionIdRef.current) {
|
||||||
|
const sid = sessionIdRef.current;
|
||||||
|
sessionIdRef.current = null;
|
||||||
|
cancelAgentGeneration(sid, projectId).catch(() => {
|
||||||
|
/* ignore cleanup errors */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setRoleDescription("");
|
||||||
|
setView({ type: "input" });
|
||||||
|
setError(null);
|
||||||
|
setSystemPromptExpanded(false);
|
||||||
|
onClose();
|
||||||
|
}, [onClose, projectId]);
|
||||||
|
|
||||||
|
const handleGenerate = useCallback(async () => {
|
||||||
|
if (!roleDescription.trim() || roleDescription.trim().length < MIN_ROLE_LENGTH) return;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setView({ type: "loading" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Phase 1: Start session
|
||||||
|
const { sessionId } = await startAgentGeneration(roleDescription.trim(), projectId);
|
||||||
|
sessionIdRef.current = sessionId;
|
||||||
|
|
||||||
|
// Phase 2: Generate spec (single combined loading state)
|
||||||
|
const { spec } = await generateAgentSpec(sessionId, projectId);
|
||||||
|
|
||||||
|
setView({ type: "preview", spec, sessionId });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Failed to generate agent specification";
|
||||||
|
// Handle rate limit errors with user-friendly message
|
||||||
|
if (message.includes("429") || message.toLowerCase().includes("rate limit")) {
|
||||||
|
setError("Too many requests. Please wait a moment and try again.");
|
||||||
|
} else {
|
||||||
|
setError(message);
|
||||||
|
}
|
||||||
|
setView({ type: "input" });
|
||||||
|
sessionIdRef.current = null;
|
||||||
|
}
|
||||||
|
}, [roleDescription, projectId]);
|
||||||
|
|
||||||
|
const handleRegenerate = useCallback(async () => {
|
||||||
|
// Cancel existing session and create a new one
|
||||||
|
if (sessionIdRef.current) {
|
||||||
|
const oldSid = sessionIdRef.current;
|
||||||
|
sessionIdRef.current = null;
|
||||||
|
try {
|
||||||
|
await cancelAgentGeneration(oldSid, projectId);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Re-run generation with same role description
|
||||||
|
await handleGenerate();
|
||||||
|
}, [handleGenerate, projectId]);
|
||||||
|
|
||||||
|
const handleUseSpec = useCallback(() => {
|
||||||
|
if (view.type !== "preview") return;
|
||||||
|
// Clear session ref so we don't cancel on close (we're using the spec)
|
||||||
|
sessionIdRef.current = null;
|
||||||
|
onGenerated(view.spec);
|
||||||
|
// Reset and close
|
||||||
|
setRoleDescription("");
|
||||||
|
setView({ type: "input" });
|
||||||
|
setError(null);
|
||||||
|
setSystemPromptExpanded(false);
|
||||||
|
onClose();
|
||||||
|
}, [view, onGenerated, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const canGenerate =
|
||||||
|
roleDescription.trim().length >= MIN_ROLE_LENGTH &&
|
||||||
|
roleDescription.trim().length <= MAX_ROLE_LENGTH;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="agent-dialog-overlay"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) handleCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="agent-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Generate agent with AI"
|
||||||
|
style={{ width: 520, maxWidth: "90vw" }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="agent-dialog-header">
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 15 }}>
|
||||||
|
<span style={{ marginRight: 8 }}>✨</span>
|
||||||
|
Generate Agent
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={handleCancel}
|
||||||
|
aria-label="Close"
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
fontSize: 18,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="agent-dialog-body">
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "var(--state-error-text, #f85149)",
|
||||||
|
fontSize: 13,
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "var(--state-error-bg, rgba(248,81,73,0.1))",
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view.type === "input" && (
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
fontSize: 13,
|
||||||
|
marginTop: 0,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Describe your agent's role and the AI will generate a complete
|
||||||
|
specification including system prompt, suggested configuration, and
|
||||||
|
more.
|
||||||
|
</p>
|
||||||
|
<div className="agent-dialog-field">
|
||||||
|
<label htmlFor="agent-role-description">Role Description</label>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
id="agent-role-description"
|
||||||
|
className="input"
|
||||||
|
rows={4}
|
||||||
|
placeholder='e.g. "Senior frontend code reviewer who specializes in React accessibility"'
|
||||||
|
value={roleDescription}
|
||||||
|
onChange={(e) => setRoleDescription(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey && canGenerate) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleGenerate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
maxLength={MAX_ROLE_LENGTH}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
resize: "vertical",
|
||||||
|
}}
|
||||||
|
aria-describedby="role-description-hint"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
id="role-description-hint"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
marginTop: 4,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Describe what your agent should do</span>
|
||||||
|
<span>
|
||||||
|
{roleDescription.length}/{MAX_ROLE_LENGTH}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view.type === "loading" && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "32px 16px",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="spin"
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
border: "3px solid var(--border)",
|
||||||
|
borderTopColor: "var(--text-accent, #58a6ff)",
|
||||||
|
borderRadius: "50%",
|
||||||
|
animation: "spin 1s linear infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p style={{ color: "var(--text-muted)", fontSize: 13, margin: 0 }}>
|
||||||
|
Generating agent specification...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view.type === "preview" && (
|
||||||
|
<div>
|
||||||
|
<div className="agent-dialog-summary" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="agent-dialog-summary-row">
|
||||||
|
<span
|
||||||
|
style={{ color: "var(--text-muted)", fontSize: 13, width: 90 }}
|
||||||
|
>
|
||||||
|
Title
|
||||||
|
</span>
|
||||||
|
<span style={{ fontWeight: 600 }}>
|
||||||
|
{view.spec.icon} {view.spec.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="agent-dialog-summary-row">
|
||||||
|
<span
|
||||||
|
style={{ color: "var(--text-muted)", fontSize: 13, width: 90 }}
|
||||||
|
>
|
||||||
|
Role
|
||||||
|
</span>
|
||||||
|
<span>{view.spec.role}</span>
|
||||||
|
</div>
|
||||||
|
<div className="agent-dialog-summary-row">
|
||||||
|
<span
|
||||||
|
style={{ color: "var(--text-muted)", fontSize: 13, width: 90 }}
|
||||||
|
>
|
||||||
|
Description
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 13 }}>{view.spec.description}</span>
|
||||||
|
</div>
|
||||||
|
<div className="agent-dialog-summary-row">
|
||||||
|
<span
|
||||||
|
style={{ color: "var(--text-muted)", fontSize: 13, width: 90 }}
|
||||||
|
>
|
||||||
|
Thinking
|
||||||
|
</span>
|
||||||
|
<span style={{ textTransform: "capitalize" }}>
|
||||||
|
{view.spec.thinkingLevel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="agent-dialog-summary-row">
|
||||||
|
<span
|
||||||
|
style={{ color: "var(--text-muted)", fontSize: 13, width: 90 }}
|
||||||
|
>
|
||||||
|
Max Turns
|
||||||
|
</span>
|
||||||
|
<span>{view.spec.maxTurns}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System prompt preview */}
|
||||||
|
<div className="agent-dialog-field">
|
||||||
|
<label>
|
||||||
|
System Prompt
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--text-accent, #58a6ff)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 12,
|
||||||
|
marginLeft: 8,
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
onClick={() => setSystemPromptExpanded(!systemPromptExpanded)}
|
||||||
|
>
|
||||||
|
{systemPromptExpanded ? "Collapse" : "Expand"}
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-secondary, #161b22)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: 12,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
maxHeight: systemPromptExpanded ? "none" : 150,
|
||||||
|
overflow: systemPromptExpanded ? "auto" : "hidden",
|
||||||
|
position: "relative",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{view.spec.systemPrompt}
|
||||||
|
{!systemPromptExpanded &&
|
||||||
|
view.spec.systemPrompt.length > 500 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 40,
|
||||||
|
background:
|
||||||
|
"linear-gradient(transparent, var(--bg-secondary, #161b22))",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="agent-dialog-footer">
|
||||||
|
<button className="btn" onClick={handleCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{view.type === "input" && (
|
||||||
|
<button
|
||||||
|
className="btn btn--primary"
|
||||||
|
onClick={() => void handleGenerate()}
|
||||||
|
disabled={!canGenerate}
|
||||||
|
>
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{view.type === "preview" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => void handleRegenerate()}
|
||||||
|
>
|
||||||
|
Regenerate
|
||||||
|
</button>
|
||||||
|
<button className="btn btn--primary" onClick={handleUseSpec}>
|
||||||
|
Use This
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import type { AgentCapability, ModelInfo } from "../api";
|
import type { AgentCapability, ModelInfo, AgentGenerationSpec } from "../api";
|
||||||
import { createAgent, fetchModels } from "../api";
|
import { createAgent, fetchModels } from "../api";
|
||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
import { ProviderIcon } from "./ProviderIcon";
|
import { ProviderIcon } from "./ProviderIcon";
|
||||||
|
import { AgentGenerationModal } from "./AgentGenerationModal";
|
||||||
|
|
||||||
export interface NewAgentDialogProps {
|
export interface NewAgentDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -23,6 +24,9 @@ const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
|
|||||||
|
|
||||||
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high";
|
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high";
|
||||||
|
|
||||||
|
/** Set of valid AgentCapability values for mapping generated roles */
|
||||||
|
const VALID_CAPABILITIES = new Set<string>(["triage", "executor", "reviewer", "merger", "scheduler", "engineer", "custom"]);
|
||||||
|
|
||||||
interface RuntimeConfig {
|
interface RuntimeConfig {
|
||||||
model: string;
|
model: string;
|
||||||
thinkingLevel: ThinkingLevel;
|
thinkingLevel: ThinkingLevel;
|
||||||
@@ -33,6 +37,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
|||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
|
const [icon, setIcon] = useState("");
|
||||||
const [role, setRole] = useState<AgentCapability>("custom");
|
const [role, setRole] = useState<AgentCapability>("custom");
|
||||||
const [runtimeConfig, setRuntimeConfig] = useState<RuntimeConfig>({
|
const [runtimeConfig, setRuntimeConfig] = useState<RuntimeConfig>({
|
||||||
model: "",
|
model: "",
|
||||||
@@ -41,6 +46,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
|||||||
});
|
});
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isGenerationModalOpen, setIsGenerationModalOpen] = useState(false);
|
||||||
|
|
||||||
// Model dropdown state
|
// Model dropdown state
|
||||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||||
@@ -68,6 +74,26 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
|||||||
? runtimeConfig.model
|
? runtimeConfig.model
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
const handleGenerated = useCallback((spec: AgentGenerationSpec) => {
|
||||||
|
// Map generated role to AgentCapability, default to "custom" if unrecognized
|
||||||
|
const mappedRole = VALID_CAPABILITIES.has(spec.role)
|
||||||
|
? (spec.role as AgentCapability)
|
||||||
|
: "custom";
|
||||||
|
|
||||||
|
setName(spec.title);
|
||||||
|
setTitle(spec.description);
|
||||||
|
setIcon(spec.icon);
|
||||||
|
setRole(mappedRole);
|
||||||
|
setRuntimeConfig(c => ({
|
||||||
|
...c,
|
||||||
|
thinkingLevel: spec.thinkingLevel,
|
||||||
|
maxTurns: spec.maxTurns,
|
||||||
|
}));
|
||||||
|
setIsGenerationModalOpen(false);
|
||||||
|
// Advance to Step 1 so user can review model selection
|
||||||
|
setStep(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleModelChange = useCallback((value: string) => {
|
const handleModelChange = useCallback((value: string) => {
|
||||||
// value is "provider/modelId" or "" for default
|
// value is "provider/modelId" or "" for default
|
||||||
setRuntimeConfig(c => ({ ...c, model: value }));
|
setRuntimeConfig(c => ({ ...c, model: value }));
|
||||||
@@ -97,9 +123,11 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
|||||||
setStep(0);
|
setStep(0);
|
||||||
setName("");
|
setName("");
|
||||||
setTitle("");
|
setTitle("");
|
||||||
|
setIcon("");
|
||||||
setRole("custom");
|
setRole("custom");
|
||||||
setRuntimeConfig({ model: "", thinkingLevel: "off", maxTurns: 10 });
|
setRuntimeConfig({ model: "", thinkingLevel: "off", maxTurns: 10 });
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setIsGenerationModalOpen(false);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,6 +144,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
|||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
role,
|
role,
|
||||||
...(title.trim() ? { title: title.trim() } : {}),
|
...(title.trim() ? { title: title.trim() } : {}),
|
||||||
|
...(icon.trim() ? { icon: icon.trim() } : {}),
|
||||||
...(Object.keys(runtimeCfg).length > 0 ? { runtimeConfig: runtimeCfg } : {}),
|
...(Object.keys(runtimeCfg).length > 0 ? { runtimeConfig: runtimeCfg } : {}),
|
||||||
}, projectId);
|
}, projectId);
|
||||||
handleClose();
|
handleClose();
|
||||||
@@ -130,7 +159,8 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
|||||||
const selectedRole = AGENT_ROLES.find(r => r.value === role);
|
const selectedRole = AGENT_ROLES.find(r => r.value === role);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="agent-dialog-overlay" onClick={(e) => { if (e.target === e.currentTarget) handleClose(); }}>
|
<>
|
||||||
|
<div className="agent-dialog-overlay" onClick={(e) => { if (e.target === e.currentTarget) handleClose(); }}>
|
||||||
<div className="agent-dialog" role="dialog" aria-modal="true" aria-label="Create new agent">
|
<div className="agent-dialog" role="dialog" aria-modal="true" aria-label="Create new agent">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="agent-dialog-header">
|
<div className="agent-dialog-header">
|
||||||
@@ -201,6 +231,21 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* AI-assisted generation */}
|
||||||
|
<div style={{ marginTop: 8, borderTop: "1px solid var(--border)", paddingTop: 12 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() => setIsGenerationModalOpen(true)}
|
||||||
|
style={{ width: "100%", display: "flex", alignItems: "center", justifyContent: "center", gap: 6 }}
|
||||||
|
>
|
||||||
|
<span>✨</span>
|
||||||
|
Generate with AI
|
||||||
|
</button>
|
||||||
|
<p style={{ color: "var(--text-muted)", fontSize: 11, textAlign: "center", margin: "6px 0 0" }}>
|
||||||
|
Describe your agent's role and let AI generate a specification
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -265,7 +310,10 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
|||||||
<div className="agent-dialog-summary">
|
<div className="agent-dialog-summary">
|
||||||
<div className="agent-dialog-summary-row">
|
<div className="agent-dialog-summary-row">
|
||||||
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Name</span>
|
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Name</span>
|
||||||
<span style={{ fontWeight: 600 }}>{name}</span>
|
<span style={{ fontWeight: 600 }}>
|
||||||
|
{icon && <span style={{ marginRight: 6 }}>{icon}</span>}
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{title && (
|
{title && (
|
||||||
<div className="agent-dialog-summary-row">
|
<div className="agent-dialog-summary-row">
|
||||||
@@ -342,6 +390,15 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* AI-assisted agent generation modal */}
|
||||||
|
<AgentGenerationModal
|
||||||
|
isOpen={isGenerationModalOpen}
|
||||||
|
onClose={() => setIsGenerationModalOpen(false)}
|
||||||
|
onGenerated={handleGenerated}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,56 @@ vi.mock("../ProviderIcon", () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock AgentGenerationModal
|
||||||
|
vi.mock("../AgentGenerationModal", () => ({
|
||||||
|
AgentGenerationModal: ({ isOpen, onClose, onGenerated }: { isOpen: boolean; onClose: () => void; onGenerated: (spec: any) => void }) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
return (
|
||||||
|
<div data-testid="agent-generation-modal">
|
||||||
|
<span data-testid="generation-modal-open">Modal Open</span>
|
||||||
|
<button
|
||||||
|
data-testid="generation-modal-close"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Close Modal
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-testid="generation-modal-apply"
|
||||||
|
onClick={() =>
|
||||||
|
onGenerated({
|
||||||
|
title: "Generated Agent",
|
||||||
|
icon: "🤖",
|
||||||
|
role: "reviewer",
|
||||||
|
description: "Generated description for testing",
|
||||||
|
systemPrompt: "# System prompt\nYou are a helpful agent.",
|
||||||
|
thinkingLevel: "medium",
|
||||||
|
maxTurns: 25,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Apply Generated Spec
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-testid="generation-modal-apply-custom-role"
|
||||||
|
onClick={() =>
|
||||||
|
onGenerated({
|
||||||
|
title: "Custom Role Agent",
|
||||||
|
icon: "🔧",
|
||||||
|
role: "security-auditor",
|
||||||
|
description: "Custom role not in AgentCapability",
|
||||||
|
systemPrompt: "# Security auditor prompt",
|
||||||
|
thinkingLevel: "high",
|
||||||
|
maxTurns: 50,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Apply Custom Role Spec
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const mockCreateAgent = vi.mocked(apiModule.createAgent);
|
const mockCreateAgent = vi.mocked(apiModule.createAgent);
|
||||||
const mockFetchModels = vi.mocked(apiModule.fetchModels);
|
const mockFetchModels = vi.mocked(apiModule.fetchModels);
|
||||||
|
|
||||||
@@ -371,4 +421,169 @@ describe("NewAgentDialog", () => {
|
|||||||
expect(newNameInput.value).toBe("");
|
expect(newNameInput.value).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("AI generation integration", () => {
|
||||||
|
it("shows Generate with AI button in step 0", () => {
|
||||||
|
render(
|
||||||
|
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Generate with AI")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens AgentGenerationModal when Generate with AI is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generation modal should not be open initially
|
||||||
|
expect(screen.queryByTestId("agent-generation-modal")).toBeNull();
|
||||||
|
|
||||||
|
// Click the Generate with AI button
|
||||||
|
await user.click(screen.getByText("Generate with AI"));
|
||||||
|
|
||||||
|
// Generation modal should now be open
|
||||||
|
expect(screen.getByTestId("agent-generation-modal")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("populates form fields and advances to step 1 when spec is applied", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||||
|
|
||||||
|
// Open generation modal and apply spec
|
||||||
|
await user.click(screen.getByText("Generate with AI"));
|
||||||
|
await user.click(screen.getByTestId("generation-modal-apply"));
|
||||||
|
|
||||||
|
// Should advance to step 1 (model config)
|
||||||
|
expect(screen.getByTestId("custom-model-dropdown")).toBeTruthy();
|
||||||
|
|
||||||
|
// Navigate to step 2 to verify the summary
|
||||||
|
await user.click(screen.getByText("Next"));
|
||||||
|
|
||||||
|
// Verify name was populated from spec.title
|
||||||
|
const summaryText = screen.getByText("Generated Agent");
|
||||||
|
expect(summaryText).toBeTruthy();
|
||||||
|
|
||||||
|
// Verify icon is shown
|
||||||
|
expect(screen.getByText("🤖")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps known role to AgentCapability", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||||
|
|
||||||
|
// Open generation modal and apply spec with role "reviewer"
|
||||||
|
await user.click(screen.getByText("Generate with AI"));
|
||||||
|
await user.click(screen.getByTestId("generation-modal-apply"));
|
||||||
|
|
||||||
|
// After generation, we're on Step 1 — navigate to summary (step 2)
|
||||||
|
await user.click(screen.getByText("Next"));
|
||||||
|
|
||||||
|
// Role should be mapped correctly to "Reviewer"
|
||||||
|
const roleRow = screen.getByText("Role").closest(".agent-dialog-summary-row");
|
||||||
|
expect(roleRow?.textContent).toContain("Reviewer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps unknown role to custom", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||||
|
|
||||||
|
// Open generation modal and apply spec with unknown role "security-auditor"
|
||||||
|
await user.click(screen.getByText("Generate with AI"));
|
||||||
|
await user.click(screen.getByTestId("generation-modal-apply-custom-role"));
|
||||||
|
|
||||||
|
// After generation, we're on Step 1 — navigate to summary (step 2)
|
||||||
|
await user.click(screen.getByText("Next"));
|
||||||
|
|
||||||
|
// Role should default to "Custom"
|
||||||
|
const roleRow = screen.getByText("Role").closest(".agent-dialog-summary-row");
|
||||||
|
expect(roleRow?.textContent).toContain("Custom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies runtime config from generated spec", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||||
|
|
||||||
|
// Open generation modal and apply spec
|
||||||
|
await user.click(screen.getByText("Generate with AI"));
|
||||||
|
await user.click(screen.getByTestId("generation-modal-apply"));
|
||||||
|
|
||||||
|
// Step 1: verify thinking level and max turns were applied
|
||||||
|
const thinkingSelect = screen.getByLabelText(/Thinking Level/) as HTMLSelectElement;
|
||||||
|
expect(thinkingSelect.value).toBe("medium");
|
||||||
|
|
||||||
|
const maxTurnsInput = screen.getByLabelText(/Max Turns/) as HTMLInputElement;
|
||||||
|
expect(maxTurnsInput.value).toBe("25");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes generation modal without affecting form on cancel", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fill in a name first
|
||||||
|
const nameInput = screen.getByLabelText(/Name/);
|
||||||
|
await user.type(nameInput, "Manual Name");
|
||||||
|
|
||||||
|
// Open generation modal
|
||||||
|
await user.click(screen.getByText("Generate with AI"));
|
||||||
|
expect(screen.getByTestId("agent-generation-modal")).toBeTruthy();
|
||||||
|
|
||||||
|
// Close the generation modal without applying
|
||||||
|
await user.click(screen.getByTestId("generation-modal-close"));
|
||||||
|
|
||||||
|
// Should still be on step 0 with original name
|
||||||
|
const nameAfter = screen.getByLabelText(/Name/) as HTMLInputElement;
|
||||||
|
expect(nameAfter.value).toBe("Manual Name");
|
||||||
|
expect(screen.queryByTestId("agent-generation-modal")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates agent with icon from generated spec", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||||
|
|
||||||
|
// Open generation modal and apply spec
|
||||||
|
await user.click(screen.getByText("Generate with AI"));
|
||||||
|
await user.click(screen.getByTestId("generation-modal-apply"));
|
||||||
|
|
||||||
|
// After generation, we're on Step 1 — navigate to summary (step 2) and create
|
||||||
|
await user.click(screen.getByText("Next"));
|
||||||
|
await user.click(screen.getByText("Create"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCreateAgent).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCall = mockCreateAgent.mock.calls[0][0];
|
||||||
|
expect(createCall.name).toBe("Generated Agent");
|
||||||
|
expect(createCall.icon).toBe("🤖");
|
||||||
|
expect(createCall.title).toBe("Generated description for testing");
|
||||||
|
expect(createCall.role).toBe("reviewer");
|
||||||
|
expect(createCall.runtimeConfig).toEqual({
|
||||||
|
thinkingLevel: "medium",
|
||||||
|
maxTurns: 25,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
338
packages/dashboard/src/agent-generation.test.ts
Normal file
338
packages/dashboard/src/agent-generation.test.ts
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
startAgentGeneration,
|
||||||
|
generateAgentSpec,
|
||||||
|
getAgentGenerationSession,
|
||||||
|
cleanupAgentGenerationSession,
|
||||||
|
checkRateLimit,
|
||||||
|
getRateLimitResetTime,
|
||||||
|
parseGenerationResponse,
|
||||||
|
__resetAgentGenerationState,
|
||||||
|
RateLimitError,
|
||||||
|
SessionNotFoundError,
|
||||||
|
} from "./agent-generation.js";
|
||||||
|
|
||||||
|
// Counter for unique IPs per test
|
||||||
|
let ipCounter = 0;
|
||||||
|
function getUniqueIp(): string {
|
||||||
|
return `127.0.0.${++ipCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("agent-generation module", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
__resetAgentGenerationState();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("startAgentGeneration", () => {
|
||||||
|
it("creates a session with valid role description", async () => {
|
||||||
|
const mockIp = getUniqueIp();
|
||||||
|
const session = await startAgentGeneration(mockIp, "Senior frontend code reviewer");
|
||||||
|
|
||||||
|
expect(session.id).toBeDefined();
|
||||||
|
expect(typeof session.id).toBe("string");
|
||||||
|
expect(session.roleDescription).toBe("Senior frontend code reviewer");
|
||||||
|
expect(session.spec).toBeUndefined();
|
||||||
|
expect(session.createdAt).toBeInstanceOf(Date);
|
||||||
|
expect(session.updatedAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose IP in the public session", async () => {
|
||||||
|
const mockIp = getUniqueIp();
|
||||||
|
const session = await startAgentGeneration(mockIp, "Test role");
|
||||||
|
|
||||||
|
expect((session as Record<string, unknown>).ip).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces rate limiting", async () => {
|
||||||
|
const mockIp = getUniqueIp();
|
||||||
|
// Create max sessions (10 per hour)
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await startAgentGeneration(mockIp, `Role ${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11th session should fail
|
||||||
|
await expect(startAgentGeneration(mockIp, "One more")).rejects.toThrow(RateLimitError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows new sessions after rate limit window expires", async () => {
|
||||||
|
const mockIp = getUniqueIp();
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await startAgentGeneration(mockIp, `Role ${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance time by 1 hour + 1 minute
|
||||||
|
vi.advanceTimersByTime(61 * 60 * 1000);
|
||||||
|
|
||||||
|
const session = await startAgentGeneration(mockIp, "New role after reset");
|
||||||
|
expect(session.id).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates different session IDs for each session", async () => {
|
||||||
|
const ip1 = getUniqueIp();
|
||||||
|
const ip2 = getUniqueIp();
|
||||||
|
const session1 = await startAgentGeneration(ip1, "Role 1");
|
||||||
|
const session2 = await startAgentGeneration(ip2, "Role 2");
|
||||||
|
|
||||||
|
expect(session1.id).not.toBe(session2.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rate limits independently per IP", async () => {
|
||||||
|
const ip1 = getUniqueIp();
|
||||||
|
const ip2 = getUniqueIp();
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await startAgentGeneration(ip1, `Role ${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ip2 should still work
|
||||||
|
const session = await startAgentGeneration(ip2, "Role from another IP");
|
||||||
|
expect(session.id).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateAgentSpec", () => {
|
||||||
|
it("throws SessionNotFoundError for non-existent session", async () => {
|
||||||
|
await expect(
|
||||||
|
generateAgentSpec("non-existent-session-id", "/tmp")
|
||||||
|
).rejects.toThrow(SessionNotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAgentGenerationSession", () => {
|
||||||
|
it("returns session after creation", async () => {
|
||||||
|
const mockIp = getUniqueIp();
|
||||||
|
const created = await startAgentGeneration(mockIp, "Test role");
|
||||||
|
|
||||||
|
const retrieved = getAgentGenerationSession(created.id);
|
||||||
|
expect(retrieved).toBeDefined();
|
||||||
|
expect(retrieved!.id).toBe(created.id);
|
||||||
|
expect(retrieved!.roleDescription).toBe("Test role");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for non-existent session", () => {
|
||||||
|
const result = getAgentGenerationSession("non-existent");
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined after cleanup", async () => {
|
||||||
|
const mockIp = getUniqueIp();
|
||||||
|
const created = await startAgentGeneration(mockIp, "Test role");
|
||||||
|
|
||||||
|
cleanupAgentGenerationSession(created.id);
|
||||||
|
|
||||||
|
const result = getAgentGenerationSession(created.id);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cleanupAgentGenerationSession", () => {
|
||||||
|
it("removes session", async () => {
|
||||||
|
const mockIp = getUniqueIp();
|
||||||
|
const created = await startAgentGeneration(mockIp, "Test role");
|
||||||
|
|
||||||
|
cleanupAgentGenerationSession(created.id);
|
||||||
|
|
||||||
|
expect(getAgentGenerationSession(created.id)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent for non-existent session", () => {
|
||||||
|
expect(() => cleanupAgentGenerationSession("non-existent")).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkRateLimit", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetAgentGenerationState();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows first request from new IP", () => {
|
||||||
|
expect(checkRateLimit("1.2.3.4")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks after exceeding limit", () => {
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
checkRateLimit("1.2.3.4");
|
||||||
|
}
|
||||||
|
expect(checkRateLimit("1.2.3.4")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows requests after window expires", () => {
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
checkRateLimit("1.2.3.4");
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(61 * 60 * 1000);
|
||||||
|
|
||||||
|
expect(checkRateLimit("1.2.3.4")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getRateLimitResetTime", () => {
|
||||||
|
it("returns null for unknown IP", () => {
|
||||||
|
expect(getRateLimitResetTime("unknown")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a date after first request", () => {
|
||||||
|
checkRateLimit("1.2.3.4");
|
||||||
|
const resetTime = getRateLimitResetTime("1.2.3.4");
|
||||||
|
|
||||||
|
expect(resetTime).toBeInstanceOf(Date);
|
||||||
|
expect(resetTime!.getTime()).toBeGreaterThan(Date.now());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseGenerationResponse", () => {
|
||||||
|
it("parses valid JSON response", () => {
|
||||||
|
const json = JSON.stringify({
|
||||||
|
title: "Senior Frontend Reviewer",
|
||||||
|
icon: "🔍",
|
||||||
|
role: "reviewer",
|
||||||
|
description: "Reviews frontend code for quality",
|
||||||
|
systemPrompt: "# Role\nYou are a senior frontend reviewer.",
|
||||||
|
thinkingLevel: "medium",
|
||||||
|
maxTurns: 25,
|
||||||
|
});
|
||||||
|
|
||||||
|
const spec = parseGenerationResponse(json);
|
||||||
|
|
||||||
|
expect(spec.title).toBe("Senior Frontend Reviewer");
|
||||||
|
expect(spec.icon).toBe("🔍");
|
||||||
|
expect(spec.role).toBe("reviewer");
|
||||||
|
expect(spec.description).toBe("Reviews frontend code for quality");
|
||||||
|
expect(spec.systemPrompt).toBe("# Role\nYou are a senior frontend reviewer.");
|
||||||
|
expect(spec.thinkingLevel).toBe("medium");
|
||||||
|
expect(spec.maxTurns).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses JSON wrapped in markdown code block", () => {
|
||||||
|
const inner = JSON.stringify({
|
||||||
|
title: "Test Agent",
|
||||||
|
icon: "🤖",
|
||||||
|
role: "custom",
|
||||||
|
description: "A test agent",
|
||||||
|
systemPrompt: "Test prompt",
|
||||||
|
thinkingLevel: "off",
|
||||||
|
maxTurns: 10,
|
||||||
|
});
|
||||||
|
const wrapped = "```json\n" + inner + "\n```";
|
||||||
|
|
||||||
|
const spec = parseGenerationResponse(wrapped);
|
||||||
|
expect(spec.title).toBe("Test Agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses JSON with surrounding text", () => {
|
||||||
|
const json = JSON.stringify({
|
||||||
|
title: "Test Agent",
|
||||||
|
icon: "🤖",
|
||||||
|
role: "custom",
|
||||||
|
description: "A test agent",
|
||||||
|
systemPrompt: "Test prompt",
|
||||||
|
thinkingLevel: "off",
|
||||||
|
maxTurns: 10,
|
||||||
|
});
|
||||||
|
const text = `Here is the specification:\n${json}\nHope this helps!`;
|
||||||
|
|
||||||
|
const spec = parseGenerationResponse(text);
|
||||||
|
expect(spec.title).toBe("Test Agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies defaults for missing fields", () => {
|
||||||
|
const json = JSON.stringify({});
|
||||||
|
|
||||||
|
const spec = parseGenerationResponse(json);
|
||||||
|
|
||||||
|
expect(spec.title).toBe("Custom Agent");
|
||||||
|
expect(spec.icon).toBe("🤖");
|
||||||
|
expect(spec.role).toBe("custom");
|
||||||
|
expect(spec.description).toBe("");
|
||||||
|
expect(spec.systemPrompt).toBe("");
|
||||||
|
expect(spec.thinkingLevel).toBe("off");
|
||||||
|
expect(spec.maxTurns).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates title to 60 characters", () => {
|
||||||
|
const longTitle = "A".repeat(100);
|
||||||
|
const json = JSON.stringify({
|
||||||
|
title: longTitle,
|
||||||
|
icon: "🤖",
|
||||||
|
role: "custom",
|
||||||
|
description: "test",
|
||||||
|
systemPrompt: "test",
|
||||||
|
thinkingLevel: "off",
|
||||||
|
maxTurns: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const spec = parseGenerationResponse(json);
|
||||||
|
expect(spec.title.length).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps maxTurns to valid range", () => {
|
||||||
|
const json = JSON.stringify({
|
||||||
|
title: "Test",
|
||||||
|
maxTurns: 999,
|
||||||
|
});
|
||||||
|
|
||||||
|
const spec = parseGenerationResponse(json);
|
||||||
|
expect(spec.maxTurns).toBe(500);
|
||||||
|
|
||||||
|
const json2 = JSON.stringify({ title: "Test", maxTurns: -5 });
|
||||||
|
const spec2 = parseGenerationResponse(json2);
|
||||||
|
expect(spec2.maxTurns).toBe(1); // clamped to minimum
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults invalid thinkingLevel to off", () => {
|
||||||
|
const json = JSON.stringify({
|
||||||
|
title: "Test",
|
||||||
|
thinkingLevel: "ultra",
|
||||||
|
});
|
||||||
|
|
||||||
|
const spec = parseGenerationResponse(json);
|
||||||
|
expect(spec.thinkingLevel).toBe("off");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repairs JSON with trailing commas", () => {
|
||||||
|
const broken = '{"title":"Test","icon":"X","role":"custom","description":"d","systemPrompt":"s","thinkingLevel":"off","maxTurns":10,}';
|
||||||
|
const spec = parseGenerationResponse(broken);
|
||||||
|
expect(spec.title).toBe("Test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for non-JSON text", () => {
|
||||||
|
expect(() => parseGenerationResponse("Hello world, this is not JSON")).toThrow(
|
||||||
|
"AI returned no valid JSON"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for empty text", () => {
|
||||||
|
expect(() => parseGenerationResponse("")).toThrow("AI returned no valid JSON");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TTL cleanup", () => {
|
||||||
|
it("sessions are retrievable within TTL", async () => {
|
||||||
|
const mockIp = getUniqueIp();
|
||||||
|
const session = await startAgentGeneration(mockIp, "Test role");
|
||||||
|
|
||||||
|
// Session should exist within TTL
|
||||||
|
expect(getAgentGenerationSession(session.id)).toBeDefined();
|
||||||
|
|
||||||
|
// Advance to just before TTL
|
||||||
|
vi.advanceTimersByTime(29 * 60 * 1000);
|
||||||
|
|
||||||
|
expect(getAgentGenerationSession(session.id)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("session data is accessible after creation", async () => {
|
||||||
|
const mockIp = getUniqueIp();
|
||||||
|
const session = await startAgentGeneration(mockIp, "Security auditor role");
|
||||||
|
|
||||||
|
const retrieved = getAgentGenerationSession(session.id);
|
||||||
|
expect(retrieved).toBeDefined();
|
||||||
|
expect(retrieved!.roleDescription).toBe("Security auditor role");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
556
packages/dashboard/src/agent-generation.ts
Normal file
556
packages/dashboard/src/agent-generation.ts
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
/**
|
||||||
|
* Agent Generation Session Management
|
||||||
|
*
|
||||||
|
* Manages AI-guided sessions for generating agent specifications from role descriptions.
|
||||||
|
* Sessions are stored in-memory with TTL cleanup.
|
||||||
|
*
|
||||||
|
* Pattern follows planning.ts for consistency.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - AI agent integration with streaming via callbacks
|
||||||
|
* - Rate limiting per IP
|
||||||
|
* - Session expiration and cleanup
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
// Dynamic import for @fusion/engine to avoid resolution issues in test environment
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports, @typescript-eslint/no-explicit-any
|
||||||
|
type AgentResult = any;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let createKbAgent: any;
|
||||||
|
|
||||||
|
// Initialize the import (this runs in actual server, mocked in tests)
|
||||||
|
async function initEngine() {
|
||||||
|
if (!createKbAgent) {
|
||||||
|
try {
|
||||||
|
const engineModule = "@fusion/engine";
|
||||||
|
const engine = await import(/* @vite-ignore */ engineModule);
|
||||||
|
createKbAgent = engine.createKbAgent;
|
||||||
|
} catch {
|
||||||
|
// Allow failure in test environments - agent functionality will be stubbed
|
||||||
|
createKbAgent = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on module load (will be awaited in actual usage)
|
||||||
|
const engineReady = initEngine();
|
||||||
|
|
||||||
|
// ── Constants ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** System prompt for the AI agent that generates agent specifications */
|
||||||
|
export const AGENT_GENERATION_SYSTEM_PROMPT = `You are an agent specification generator for the kb task board system.
|
||||||
|
|
||||||
|
Your job: given a user-provided role description, generate a complete agent specification suitable for creating an AI agent.
|
||||||
|
|
||||||
|
## Input
|
||||||
|
The user will provide a role description like:
|
||||||
|
- "Senior frontend code reviewer who specializes in React accessibility"
|
||||||
|
- "Security-focused DevOps engineer"
|
||||||
|
- "Performance optimization specialist for Node.js applications"
|
||||||
|
|
||||||
|
## Output
|
||||||
|
You MUST respond with ONLY valid JSON (no markdown, no explanation):
|
||||||
|
|
||||||
|
{
|
||||||
|
"title": "A concise display name (max 60 chars)",
|
||||||
|
"icon": "A single emoji representing the agent",
|
||||||
|
"role": "The most appropriate capability: triage | executor | reviewer | merger | scheduler | engineer | custom",
|
||||||
|
"description": "A brief 1-2 sentence description of the agent's purpose and expertise",
|
||||||
|
"systemPrompt": "A detailed markdown system prompt for the agent. This should be comprehensive and include:\\n- Role definition\\n- Core responsibilities\\n- Specific areas of expertise\\n- Behavioral guidelines\\n- Output format expectations\\n- Edge case handling instructions",
|
||||||
|
"thinkingLevel": "off | minimal | low | medium | high",
|
||||||
|
"maxTurns": 10
|
||||||
|
}
|
||||||
|
|
||||||
|
## Guidelines for System Prompt Generation
|
||||||
|
- Be specific about the agent's domain expertise
|
||||||
|
- Include concrete behavioral rules and constraints
|
||||||
|
- Define the expected output format clearly
|
||||||
|
- Add error handling and edge case guidance
|
||||||
|
- Keep the prompt focused and actionable (aim for 200-800 words)
|
||||||
|
- Use markdown formatting for readability
|
||||||
|
|
||||||
|
## Thinking Level Guidelines
|
||||||
|
- "off": For simple, well-defined tasks (basic CRUD, simple checks)
|
||||||
|
- "minimal": For straightforward tasks requiring some reasoning
|
||||||
|
- "low": For moderate complexity tasks
|
||||||
|
- "medium": For complex analysis, code review, architecture decisions
|
||||||
|
- "high": For critical decisions, security analysis, complex debugging
|
||||||
|
|
||||||
|
## Max Turns Guidelines
|
||||||
|
- 5-10: Simple, focused tasks (quick reviews, status checks)
|
||||||
|
- 10-25: Standard tasks (code review, feature planning)
|
||||||
|
- 25-50: Complex tasks (multi-file changes, architecture analysis)
|
||||||
|
- 50+: Extended tasks (large refactors, comprehensive audits)
|
||||||
|
|
||||||
|
## Role Selection Guidelines
|
||||||
|
- "reviewer": Agents focused on reviewing, auditing, analyzing
|
||||||
|
- "executor": Agents that perform implementation work
|
||||||
|
- "engineer": Agents that do engineering work with broader scope
|
||||||
|
- "triage": Agents focused on classification and routing
|
||||||
|
- "custom": Any agent that doesn't fit standard roles
|
||||||
|
- Default to "custom" if unclear`;
|
||||||
|
|
||||||
|
/** Session TTL in milliseconds (30 minutes) */
|
||||||
|
const SESSION_TTL_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Cleanup interval in milliseconds (5 minutes) */
|
||||||
|
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Max agent generation sessions per IP per hour */
|
||||||
|
const MAX_SESSIONS_PER_IP_PER_HOUR = 10;
|
||||||
|
|
||||||
|
/** Rate limiting window in milliseconds (1 hour) */
|
||||||
|
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Generated agent specification returned by the AI */
|
||||||
|
export interface AgentGenerationSpec {
|
||||||
|
/** Display name for the agent */
|
||||||
|
title: string;
|
||||||
|
/** Single emoji icon */
|
||||||
|
icon: string;
|
||||||
|
/** Agent capability/role */
|
||||||
|
role: string;
|
||||||
|
/** Brief description of the agent's purpose */
|
||||||
|
description: string;
|
||||||
|
/** Detailed system prompt in markdown */
|
||||||
|
systemPrompt: string;
|
||||||
|
/** Suggested thinking level */
|
||||||
|
thinkingLevel: "off" | "minimal" | "low" | "medium" | "high";
|
||||||
|
/** Suggested max turns (1-500) */
|
||||||
|
maxTurns: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public state of an agent generation session (no sensitive fields like IP) */
|
||||||
|
export interface AgentGenerationSession {
|
||||||
|
id: string;
|
||||||
|
roleDescription: string;
|
||||||
|
spec?: AgentGenerationSpec;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
id: string;
|
||||||
|
ip: string;
|
||||||
|
roleDescription: string;
|
||||||
|
spec?: AgentGenerationSpec;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RateLimitEntry {
|
||||||
|
count: number;
|
||||||
|
firstRequestAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── In-Memory Storage ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Active agent generation sessions indexed by session ID */
|
||||||
|
const sessions = new Map<string, Session>();
|
||||||
|
|
||||||
|
/** Rate limiting state indexed by IP */
|
||||||
|
const rateLimits = new Map<string, RateLimitEntry>();
|
||||||
|
|
||||||
|
// ── Cleanup Interval ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove expired sessions and stale rate limit entries.
|
||||||
|
*/
|
||||||
|
function cleanupExpiredSessions(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
let cleanedSessions = 0;
|
||||||
|
let cleanedRateLimits = 0;
|
||||||
|
|
||||||
|
for (const [id, session] of sessions) {
|
||||||
|
if (now - session.updatedAt.getTime() > SESSION_TTL_MS) {
|
||||||
|
sessions.delete(id);
|
||||||
|
cleanedSessions++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [ip, entry] of rateLimits) {
|
||||||
|
if (now - entry.firstRequestAt.getTime() > RATE_LIMIT_WINDOW_MS) {
|
||||||
|
rateLimits.delete(ip);
|
||||||
|
cleanedRateLimits++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanedSessions > 0 || cleanedRateLimits > 0) {
|
||||||
|
console.log(
|
||||||
|
`[agent-generation] Cleanup: removed ${cleanedSessions} sessions, ${cleanedRateLimits} rate limit entries`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupInterval = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
|
||||||
|
|
||||||
|
process.on("beforeExit", () => {
|
||||||
|
clearInterval(cleanupInterval);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Rate Limiting ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if IP can create a new agent generation session.
|
||||||
|
* Returns true if allowed, false if rate limited.
|
||||||
|
*/
|
||||||
|
export function checkRateLimit(ip: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = rateLimits.get(ip);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
rateLimits.set(ip, { count: 1, firstRequestAt: new Date() });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now - entry.firstRequestAt.getTime() > RATE_LIMIT_WINDOW_MS) {
|
||||||
|
rateLimits.set(ip, { count: 1, firstRequestAt: new Date() });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.count >= MAX_SESSIONS_PER_IP_PER_HOUR) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rate limit reset time for an IP.
|
||||||
|
* Returns null if no rate limit entry exists.
|
||||||
|
*/
|
||||||
|
export function getRateLimitResetTime(ip: string): Date | null {
|
||||||
|
const entry = rateLimits.get(ip);
|
||||||
|
if (!entry) return null;
|
||||||
|
return new Date(entry.firstRequestAt.getTime() + RATE_LIMIT_WINDOW_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON Extraction ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract JSON candidate from AI response text.
|
||||||
|
* Handles markdown code blocks and embedded JSON.
|
||||||
|
*/
|
||||||
|
function extractJsonCandidate(text: string): string | null {
|
||||||
|
if (!text || !text.trim()) return null;
|
||||||
|
|
||||||
|
// 1. Try markdown code blocks
|
||||||
|
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
||||||
|
if (codeBlockMatch?.[1]) {
|
||||||
|
const candidate = codeBlockMatch[1].trim();
|
||||||
|
if (candidate.startsWith("{")) return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Find balanced brace-delimited objects
|
||||||
|
const candidates: Array<{ text: string }> = [];
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (text[i] === "{") {
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escape = false;
|
||||||
|
for (let j = i; j < text.length; j++) {
|
||||||
|
const ch = text[j];
|
||||||
|
if (escape) { escape = false; continue; }
|
||||||
|
if (ch === "\\") { escape = true; continue; }
|
||||||
|
if (ch === '"') { inString = !inString; continue; }
|
||||||
|
if (inString) continue;
|
||||||
|
if (ch === "{") depth++;
|
||||||
|
if (ch === "}") depth--;
|
||||||
|
if (depth === 0) {
|
||||||
|
const candidate = text.slice(i, j + 1).trim();
|
||||||
|
try {
|
||||||
|
JSON.parse(candidate);
|
||||||
|
candidates.push({ text: candidate });
|
||||||
|
} catch {
|
||||||
|
// Not valid JSON, skip
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length > 0) {
|
||||||
|
candidates.sort((a, b) => b.text.length - a.text.length);
|
||||||
|
return candidates[0].text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Last resort: try the full trimmed text
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.startsWith("{")) return trimmed;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to repair common JSON issues (truncated, trailing commas, etc.).
|
||||||
|
*/
|
||||||
|
function repairJson(text: string): string {
|
||||||
|
let repaired = text;
|
||||||
|
repaired = repaired.replace(/,\s*([}\]])/g, "$1");
|
||||||
|
|
||||||
|
let openBraces = 0;
|
||||||
|
let openBrackets = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escape = false;
|
||||||
|
for (const ch of repaired) {
|
||||||
|
if (escape) { escape = false; continue; }
|
||||||
|
if (ch === "\\") { escape = true; continue; }
|
||||||
|
if (ch === '"') { inString = !inString; continue; }
|
||||||
|
if (inString) continue;
|
||||||
|
if (ch === "{") openBraces++;
|
||||||
|
if (ch === "}") openBraces--;
|
||||||
|
if (ch === "[") openBrackets++;
|
||||||
|
if (ch === "]") openBrackets--;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inString) repaired += '"';
|
||||||
|
|
||||||
|
// Re-count after potential string fix
|
||||||
|
openBraces = 0;
|
||||||
|
openBrackets = 0;
|
||||||
|
inString = false;
|
||||||
|
escape = false;
|
||||||
|
for (const ch of repaired) {
|
||||||
|
if (escape) { escape = false; continue; }
|
||||||
|
if (ch === "\\") { escape = true; continue; }
|
||||||
|
if (ch === '"') { inString = !inString; continue; }
|
||||||
|
if (inString) continue;
|
||||||
|
if (ch === "{") openBraces++;
|
||||||
|
if (ch === "}") openBraces--;
|
||||||
|
if (ch === "[") openBrackets++;
|
||||||
|
if (ch === "]") openBrackets--;
|
||||||
|
}
|
||||||
|
|
||||||
|
repaired += "]".repeat(Math.max(0, openBrackets));
|
||||||
|
repaired += "}".repeat(Math.max(0, openBraces));
|
||||||
|
|
||||||
|
return repaired;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the AI response text into an AgentGenerationSpec.
|
||||||
|
*/
|
||||||
|
export function parseGenerationResponse(text: string): AgentGenerationSpec {
|
||||||
|
const candidate = extractJsonCandidate(text);
|
||||||
|
if (!candidate) {
|
||||||
|
throw new Error("AI returned no valid JSON. Please try again.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(candidate);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const repaired = repairJson(candidate);
|
||||||
|
parsed = JSON.parse(repaired);
|
||||||
|
} catch (repairErr) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to parse AI response: ${repairErr instanceof Error ? repairErr.message : "Unknown error"}. Please try again.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof parsed !== "object" || parsed === null) {
|
||||||
|
throw new Error("AI returned an invalid response structure. Please try again.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = parsed as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Validate required fields with defaults
|
||||||
|
return {
|
||||||
|
title: typeof obj.title === "string" ? obj.title.slice(0, 60) : "Custom Agent",
|
||||||
|
icon: typeof obj.icon === "string" ? obj.icon : "🤖",
|
||||||
|
role: typeof obj.role === "string" ? obj.role : "custom",
|
||||||
|
description: typeof obj.description === "string" ? obj.description : "",
|
||||||
|
systemPrompt: typeof obj.systemPrompt === "string" ? obj.systemPrompt : "",
|
||||||
|
thinkingLevel: ["off", "minimal", "low", "medium", "high"].includes(obj.thinkingLevel as string)
|
||||||
|
? (obj.thinkingLevel as AgentGenerationSpec["thinkingLevel"])
|
||||||
|
: "off",
|
||||||
|
maxTurns: typeof obj.maxTurns === "number"
|
||||||
|
? Math.max(1, Math.min(500, Math.round(obj.maxTurns)))
|
||||||
|
: 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session Management ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a new agent generation session.
|
||||||
|
* Creates the session in memory but does not yet generate the spec.
|
||||||
|
* Call `generateAgentSpec()` to trigger AI generation.
|
||||||
|
*
|
||||||
|
* @param ip - Client IP for rate limiting
|
||||||
|
* @param roleDescription - The user's description of the desired agent role
|
||||||
|
* @returns Session object (without spec — call generateAgentSpec to populate)
|
||||||
|
*/
|
||||||
|
export async function startAgentGeneration(
|
||||||
|
ip: string,
|
||||||
|
roleDescription: string,
|
||||||
|
): Promise<AgentGenerationSession> {
|
||||||
|
if (!checkRateLimit(ip)) {
|
||||||
|
const resetTime = getRateLimitResetTime(ip);
|
||||||
|
throw new RateLimitError(
|
||||||
|
`Rate limit exceeded. Maximum ${MAX_SESSIONS_PER_IP_PER_HOUR} generation sessions per hour. ` +
|
||||||
|
`Reset at ${resetTime?.toISOString() || "unknown"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
const session: Session = {
|
||||||
|
id: sessionId,
|
||||||
|
ip,
|
||||||
|
roleDescription,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
sessions.set(sessionId, session);
|
||||||
|
|
||||||
|
return toPublicSession(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the agent specification for an existing session using AI.
|
||||||
|
* This calls the AI model with the session's role description and populates
|
||||||
|
* the session's spec field.
|
||||||
|
*
|
||||||
|
* @param sessionId - The session identifier
|
||||||
|
* @param rootDir - Project root directory for AI agent context
|
||||||
|
* @returns The generated agent specification
|
||||||
|
*/
|
||||||
|
export async function generateAgentSpec(
|
||||||
|
sessionId: string,
|
||||||
|
rootDir: string
|
||||||
|
): Promise<AgentGenerationSpec> {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
throw new SessionNotFoundError(`Agent generation session ${sessionId} not found or expired`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await engineReady;
|
||||||
|
const spec = await generateSpecWithAI(session, rootDir);
|
||||||
|
session.spec = spec;
|
||||||
|
session.updatedAt = new Date();
|
||||||
|
return spec;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[agent-generation] AI generation failed for session ${sessionId}:`, err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an agent specification using the AI agent.
|
||||||
|
*/
|
||||||
|
async function generateSpecWithAI(session: Session, rootDir: string): Promise<AgentGenerationSpec> {
|
||||||
|
if (!createKbAgent) {
|
||||||
|
throw new Error("AI agent not available. Ensure the engine is properly configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = await createKbAgent({
|
||||||
|
cwd: rootDir,
|
||||||
|
systemPrompt: AGENT_GENERATION_SYSTEM_PROMPT,
|
||||||
|
tools: "none",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await agent.session.prompt(
|
||||||
|
`Generate an agent specification for the following role:\n\n${session.roleDescription}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract response text
|
||||||
|
interface AgentMessage {
|
||||||
|
role: string;
|
||||||
|
content?: string | Array<{ type: string; text: string }>;
|
||||||
|
}
|
||||||
|
const lastMessage = (agent.session.state.messages as AgentMessage[])
|
||||||
|
.filter((m: AgentMessage) => m.role === "assistant")
|
||||||
|
.pop();
|
||||||
|
|
||||||
|
let responseText = "";
|
||||||
|
if (lastMessage?.content) {
|
||||||
|
if (typeof lastMessage.content === "string") {
|
||||||
|
responseText = lastMessage.content;
|
||||||
|
} else if (Array.isArray(lastMessage.content)) {
|
||||||
|
responseText = lastMessage.content
|
||||||
|
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
|
||||||
|
.map((c: { type: string; text: string }) => c.text)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseGenerationResponse(responseText);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
agent.session.dispose?.();
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a session by ID.
|
||||||
|
*
|
||||||
|
* @param sessionId - The session identifier
|
||||||
|
* @returns The session, or undefined if not found
|
||||||
|
*/
|
||||||
|
export function getAgentGenerationSession(sessionId: string): AgentGenerationSession | undefined {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) return undefined;
|
||||||
|
return toPublicSession(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up and remove a session.
|
||||||
|
*
|
||||||
|
* @param sessionId - The session identifier
|
||||||
|
*/
|
||||||
|
export function cleanupAgentGenerationSession(sessionId: string): void {
|
||||||
|
sessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert internal session to public session type.
|
||||||
|
*/
|
||||||
|
function toPublicSession(session: Session): AgentGenerationSession {
|
||||||
|
return {
|
||||||
|
id: session.id,
|
||||||
|
roleDescription: session.roleDescription,
|
||||||
|
spec: session.spec,
|
||||||
|
createdAt: session.createdAt,
|
||||||
|
updatedAt: session.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all agent generation state. Used for testing only.
|
||||||
|
*/
|
||||||
|
export function __resetAgentGenerationState(): void {
|
||||||
|
sessions.clear();
|
||||||
|
rateLimits.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Custom Errors ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class RateLimitError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "RateLimitError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionNotFoundError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "SessionNotFoundError";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,14 @@ import { getOrCreateProjectStore } from "./project-store-resolver.js";
|
|||||||
import { AiSessionStore } from "./ai-session-store.js";
|
import { AiSessionStore } from "./ai-session-store.js";
|
||||||
import { getSession as getPlanningSession, cleanupSession as cleanupPlanningSession } from "./planning.js";
|
import { getSession as getPlanningSession, cleanupSession as cleanupPlanningSession } from "./planning.js";
|
||||||
import { getSubtaskSession, cleanupSubtaskSession } from "./subtask-breakdown.js";
|
import { getSubtaskSession, cleanupSubtaskSession } from "./subtask-breakdown.js";
|
||||||
|
import {
|
||||||
|
startAgentGeneration,
|
||||||
|
generateAgentSpec,
|
||||||
|
getAgentGenerationSession,
|
||||||
|
cleanupAgentGenerationSession,
|
||||||
|
RateLimitError as AgentGenerationRateLimitError,
|
||||||
|
SessionNotFoundError as AgentGenerationSessionNotFoundError,
|
||||||
|
} from "./agent-generation.js";
|
||||||
import { getMissionInterviewSession, cleanupMissionInterviewSession } from "./mission-interview.js";
|
import { getMissionInterviewSession, cleanupMissionInterviewSession } from "./mission-interview.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -6736,6 +6744,114 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Agent Generation Routes ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/agents/generate/start
|
||||||
|
* Start a new agent generation session.
|
||||||
|
* Body: { role: string }
|
||||||
|
* Response: { sessionId, roleDescription }
|
||||||
|
*/
|
||||||
|
router.post("/agents/generate/start", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { role } = req.body as { role?: string };
|
||||||
|
if (!role || typeof role !== "string") {
|
||||||
|
res.status(400).json({ error: "role is required and must be a string" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedRole = role.trim();
|
||||||
|
if (trimmedRole.length === 0) {
|
||||||
|
res.status(400).json({ error: "role must not be empty" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trimmedRole.length > 1000) {
|
||||||
|
res.status(400).json({ error: "role must not exceed 1000 characters" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||||
|
const session = await startAgentGeneration(ip, trimmedRole);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
sessionId: session.id,
|
||||||
|
roleDescription: session.roleDescription,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err instanceof AgentGenerationRateLimitError) {
|
||||||
|
res.status(429).json({ error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("[agent-generation] Error starting session:", err);
|
||||||
|
res.status(500).json({ error: err.message || "Failed to start agent generation session" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/agents/generate/spec
|
||||||
|
* Generate the agent specification for an existing session.
|
||||||
|
* Body: { sessionId: string }
|
||||||
|
* Response: { spec: AgentGenerationSpec }
|
||||||
|
*/
|
||||||
|
router.post("/agents/generate/spec", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = req.body as { sessionId?: string };
|
||||||
|
if (!sessionId || typeof sessionId !== "string") {
|
||||||
|
res.status(400).json({ error: "sessionId is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopedStore = await getScopedStore(req);
|
||||||
|
const rootDir = scopedStore.getRootDir();
|
||||||
|
|
||||||
|
const spec = await generateAgentSpec(sessionId, rootDir);
|
||||||
|
res.json({ spec });
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err instanceof AgentGenerationSessionNotFoundError) {
|
||||||
|
res.status(404).json({ error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("[agent-generation] Error generating spec:", err);
|
||||||
|
res.status(500).json({ error: err.message || "Failed to generate agent specification" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/agents/generate/:sessionId
|
||||||
|
* Get the current state of an agent generation session.
|
||||||
|
* Response: { session: AgentGenerationSession }
|
||||||
|
*/
|
||||||
|
router.get("/agents/generate/:sessionId", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const session = getAgentGenerationSession(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
res.status(404).json({ error: `Session ${sessionId} not found or expired` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ session });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/agents/generate/:sessionId
|
||||||
|
* Cancel and clean up an agent generation session.
|
||||||
|
* Response: { success: true }
|
||||||
|
*/
|
||||||
|
router.delete("/agents/generate/:sessionId", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
cleanupAgentGenerationSession(sessionId);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Mission Routes ─────────────────────────────────────────────────────────
|
// ── Mission Routes ─────────────────────────────────────────────────────────
|
||||||
// Mount mission routes at /api/missions
|
// Mount mission routes at /api/missions
|
||||||
router.use("/missions", createMissionRouter(store));
|
router.use("/missions", createMissionRouter(store));
|
||||||
|
|||||||
Reference in New Issue
Block a user