Merge pull request #252 from multica-ai/forrestchang/skills-gap-analysis
feat: structured skills system with meta skill runtime injection
This commit is contained in:
commit
daaee733bf
34 changed files with 2380 additions and 196 deletions
|
|
@ -13,6 +13,7 @@ import {
|
|||
LogOut,
|
||||
Plus,
|
||||
Check,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
import {
|
||||
|
|
@ -52,6 +53,7 @@ import { useWorkspaceStore } from "@/features/workspace";
|
|||
const navItems = [
|
||||
{ href: "/inbox", label: "Inbox", icon: Inbox },
|
||||
{ href: "/agents", label: "Agents", icon: Bot },
|
||||
{ href: "/skills", label: "Skills", icon: Sparkles },
|
||||
{ href: "/issues", label: "Issues", icon: ListTodo },
|
||||
{ href: "/knowledge-base", label: "Knowledge Base", icon: BookOpen },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ import {
|
|||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/shared/api";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
|
|
@ -305,28 +304,40 @@ function AgentListItem({
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skills Tab
|
||||
// Skills Tab (picker — skills are managed on /skills page)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SkillsTab({
|
||||
agent,
|
||||
onSave,
|
||||
}: {
|
||||
agent: Agent;
|
||||
onSave: (skills: string) => Promise<void>;
|
||||
}) {
|
||||
const [skills, setSkills] = useState(agent.skills);
|
||||
const workspaceSkills = useWorkspaceStore((s) => s.skills);
|
||||
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const isDirty = skills !== agent.skills;
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSkills(agent.skills);
|
||||
}, [agent.id, agent.skills]);
|
||||
const agentSkillIds = new Set(agent.skills.map((s) => s.id));
|
||||
const availableSkills = workspaceSkills.filter((s) => !agentSkillIds.has(s.id));
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleAdd = async (skillId: string) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(skills);
|
||||
const newIds = [...agent.skills.map((s) => s.id), skillId];
|
||||
await api.setAgentSkills(agent.id, { skill_ids: newIds });
|
||||
await refreshAgents();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setShowPicker(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (skillId: string) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const newIds = agent.skills.filter((s) => s.id !== skillId).map((s) => s.id);
|
||||
await api.setAgentSkills(agent.id, { skill_ids: newIds });
|
||||
await refreshAgents();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -338,26 +349,114 @@ function SkillsTab({
|
|||
<div>
|
||||
<h3 className="text-sm font-semibold">Skills</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Define what this agent does and how it should accomplish tasks. Supports Markdown.
|
||||
Reusable skills assigned to this agent. Manage skills on the Skills page.
|
||||
</p>
|
||||
</div>
|
||||
{isDirty && (
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
size="xs"
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => setShowPicker(true)}
|
||||
disabled={saving || availableSkills.length === 0}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Skill
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={skills}
|
||||
onChange={(e) => setSkills(e.target.value)}
|
||||
placeholder={`# Agent Name\n\nDescribe what this agent does and how it should work.\n\n## Workflow\n1. Step one\n2. Step two\n3. Step three\n\n## Output Format\nDescribe the expected output...`}
|
||||
className="h-96 resize-none font-mono leading-relaxed"
|
||||
/>
|
||||
|
||||
{agent.skills.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
|
||||
<FileText className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">No skills assigned</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Add skills from the workspace to this agent.
|
||||
</p>
|
||||
{availableSkills.length > 0 && (
|
||||
<Button
|
||||
onClick={() => setShowPicker(true)}
|
||||
size="xs"
|
||||
className="mt-3"
|
||||
disabled={saving}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Skill
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{agent.skills.map((skill) => (
|
||||
<div
|
||||
key={skill.id}
|
||||
className="flex items-center gap-3 rounded-lg border px-4 py-3"
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{skill.name}</div>
|
||||
{skill.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{skill.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => handleRemove(skill.id)}
|
||||
disabled={saving}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skill Picker Dialog */}
|
||||
{showPicker && (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) setShowPicker(false); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Add Skill</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Select a skill to assign to this agent.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-64 overflow-y-auto space-y-1">
|
||||
{availableSkills.map((skill) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
onClick={() => handleAdd(skill.id)}
|
||||
disabled={saving}
|
||||
className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">{skill.name}</div>
|
||||
{skill.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{skill.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{availableSkills.length === 0 && (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">
|
||||
All workspace skills are already assigned.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setShowPicker(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -970,10 +1069,7 @@ function AgentDetail({
|
|||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{activeTab === "skills" && (
|
||||
<SkillsTab
|
||||
agent={agent}
|
||||
onSave={(skills) => onUpdate(agent.id, { skills })}
|
||||
/>
|
||||
<SkillsTab agent={agent} />
|
||||
)}
|
||||
{activeTab === "tools" && (
|
||||
<ToolsTab
|
||||
|
|
|
|||
1
apps/web/app/(dashboard)/skills/page.tsx
Normal file
1
apps/web/app/(dashboard)/skills/page.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { SkillsPage as default } from "@/features/skills";
|
||||
1
apps/web/features/skills/components/index.ts
Normal file
1
apps/web/features/skills/components/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as SkillsPage } from "./skills-page";
|
||||
548
apps/web/features/skills/components/skills-page.tsx
Normal file
548
apps/web/features/skills/components/skills-page.tsx
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Sparkles,
|
||||
Plus,
|
||||
Trash2,
|
||||
Save,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
AlertCircle,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { Skill, CreateSkillRequest, UpdateSkillRequest } from "@multica/types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/shared/api";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useWSEvent } from "@/features/realtime";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create Skill Dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CreateSkillDialog({
|
||||
onClose,
|
||||
onCreate,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onCreate: (data: CreateSkillRequest) => Promise<void>;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await onCreate({ name: name.trim(), description: description.trim() });
|
||||
onClose();
|
||||
} catch {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Skill</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a reusable skill that can be assigned to agents.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Name</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Code Review, Bug Triage"
|
||||
className="mt-1"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Description</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of what this skill does"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={creating || !name.trim()}>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill List Item
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SkillListItem({
|
||||
skill,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
skill: Skill;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors ${
|
||||
isSelected ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{skill.name}</div>
|
||||
{skill.description && (
|
||||
<div className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{skill.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(skill.files?.length ?? 0) > 0 && (
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{skill.files.length} file{skill.files.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File Editor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FileEditor({
|
||||
files,
|
||||
onFilesChange,
|
||||
}: {
|
||||
files: { path: string; content: string }[];
|
||||
onFilesChange: (files: { path: string; content: string }[]) => void;
|
||||
}) {
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
|
||||
const addFile = () => {
|
||||
onFilesChange([...files, { path: "", content: "" }]);
|
||||
setEditingIndex(files.length);
|
||||
};
|
||||
|
||||
const updateFile = (index: number, field: "path" | "content", value: string) => {
|
||||
const updated = files.map((f, i) =>
|
||||
i === index ? { ...f, [field]: value } : f,
|
||||
);
|
||||
onFilesChange(updated);
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
onFilesChange(files.filter((_, i) => i !== index));
|
||||
if (editingIndex === index) setEditingIndex(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">Supporting Files</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Templates, scripts, or reference files available to the agent.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="xs" onClick={addFile}>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add File
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{files.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-8">
|
||||
<FolderOpen className="h-6 w-6 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">No supporting files</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{files.map((file, index) => (
|
||||
<div key={index} className="rounded-lg border">
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
value={file.path}
|
||||
onChange={(e) => updateFile(index, "path", e.target.value)}
|
||||
placeholder="path/to/file.md"
|
||||
className="h-7 border-0 p-0 text-xs font-mono shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() =>
|
||||
setEditingIndex(editingIndex === index ? null : index)
|
||||
}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
>
|
||||
<FileText className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => removeFile(index)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{editingIndex === index && (
|
||||
<Textarea
|
||||
value={file.content}
|
||||
onChange={(e) => updateFile(index, "content", e.target.value)}
|
||||
placeholder="File content..."
|
||||
className="min-h-32 resize-none rounded-none rounded-b-lg border-0 font-mono text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill Detail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SkillDetail({
|
||||
skill,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: {
|
||||
skill: Skill;
|
||||
onUpdate: (id: string, data: UpdateSkillRequest) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
}) {
|
||||
const [name, setName] = useState(skill.name);
|
||||
const [description, setDescription] = useState(skill.description);
|
||||
const [content, setContent] = useState(skill.content);
|
||||
const [files, setFiles] = useState<{ path: string; content: string }[]>(
|
||||
(skill.files ?? []).map((f) => ({ path: f.path, content: f.content })),
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
// Sync basic fields from store updates
|
||||
useEffect(() => {
|
||||
setName(skill.name);
|
||||
setDescription(skill.description);
|
||||
setContent(skill.content);
|
||||
}, [skill.id, skill.name, skill.description, skill.content]);
|
||||
|
||||
// Fetch full skill (with files) on selection change
|
||||
useEffect(() => {
|
||||
api.getSkill(skill.id).then((full) => {
|
||||
useWorkspaceStore.getState().upsertSkill(full);
|
||||
setFiles((full.files ?? []).map((f) => ({ path: f.path, content: f.content })));
|
||||
});
|
||||
}, [skill.id]);
|
||||
|
||||
const isDirty =
|
||||
name !== skill.name ||
|
||||
description !== skill.description ||
|
||||
content !== skill.content ||
|
||||
JSON.stringify(files) !==
|
||||
JSON.stringify((skill.files ?? []).map((f) => ({ path: f.path, content: f.content })));
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onUpdate(skill.id, {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
content,
|
||||
files: files.filter((f) => f.path.trim()),
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">{skill.name}</h2>
|
||||
{skill.description && (
|
||||
<p className="text-xs text-muted-foreground">{skill.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isDirty && (
|
||||
<Button onClick={handleSave} disabled={saving || !name.trim()} size="xs">
|
||||
<Save className="h-3 w-3" />
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Name</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Description</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Editor */}
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Content (SKILL.md)
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
|
||||
Main skill instructions in Markdown. This becomes the SKILL.md file in the agent's execution environment.
|
||||
</p>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={`# Skill Name\n\nDescribe what this skill does and provide instructions.\n\n## Workflow\n1. Step one\n2. Step two\n\n## Rules\n- Rule one\n- Rule two`}
|
||||
className="h-64 resize-none font-mono text-sm leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Files */}
|
||||
<FileEditor files={files} onFilesChange={setFiles} />
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
{confirmDelete && (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) setConfirmDelete(false); }}>
|
||||
<DialogContent className="max-w-sm" showCloseButton={false}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Delete skill?</h3>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
This will permanently delete "{skill.name}" and remove it from all agents.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setConfirmDelete(false);
|
||||
onDelete(skill.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function SkillsPage() {
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const skills = useWorkspaceStore((s) => s.skills);
|
||||
const refreshSkills = useWorkspaceStore((s) => s.refreshSkills);
|
||||
const upsertSkill = useWorkspaceStore((s) => s.upsertSkill);
|
||||
const removeSkill = useWorkspaceStore((s) => s.removeSkill);
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (skills.length > 0 && !selectedId) {
|
||||
setSelectedId(skills[0]!.id);
|
||||
}
|
||||
}, [skills, selectedId]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
refreshSkills();
|
||||
}, [refreshSkills]);
|
||||
|
||||
useWSEvent("skill:created", handleRefresh);
|
||||
useWSEvent("skill:updated", handleRefresh);
|
||||
useWSEvent("skill:deleted", handleRefresh);
|
||||
|
||||
const handleCreate = async (data: CreateSkillRequest) => {
|
||||
const skill = await api.createSkill(data);
|
||||
upsertSkill(skill);
|
||||
setSelectedId(skill.id);
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string, data: UpdateSkillRequest) => {
|
||||
const updated = await api.updateSkill(id, data);
|
||||
upsertSkill(updated);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await api.deleteSkill(id);
|
||||
if (selectedId === id) {
|
||||
const remaining = skills.filter((s) => s.id !== id);
|
||||
setSelectedId(remaining[0]?.id ?? "");
|
||||
}
|
||||
removeSkill(id);
|
||||
};
|
||||
|
||||
const selected = skills.find((s) => s.id === selectedId) ?? null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Left column — skill list */}
|
||||
<div className="w-72 shrink-0 overflow-y-auto border-r">
|
||||
<div className="flex h-12 items-center justify-between border-b px-4">
|
||||
<h1 className="text-sm font-semibold">Skills</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
{skills.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-12">
|
||||
<Sparkles className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">No skills yet</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground text-center">
|
||||
Skills define reusable instructions for agents.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setShowCreate(true)}
|
||||
size="xs"
|
||||
className="mt-3"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Create Skill
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{skills.map((skill) => (
|
||||
<SkillListItem
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
isSelected={skill.id === selectedId}
|
||||
onClick={() => setSelectedId(skill.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right column — skill detail */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{selected ? (
|
||||
<SkillDetail
|
||||
key={selected.id}
|
||||
skill={selected}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<Sparkles className="h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="mt-3 text-sm">Select a skill to view details</p>
|
||||
<Button
|
||||
onClick={() => setShowCreate(true)}
|
||||
size="xs"
|
||||
className="mt-3"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Create Skill
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<CreateSkillDialog
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
apps/web/features/skills/index.ts
Normal file
1
apps/web/features/skills/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { SkillsPage } from "./components";
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { Workspace, MemberWithUser, Agent } from "@multica/types";
|
||||
import type { Workspace, MemberWithUser, Agent, Skill } from "@multica/types";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
interface WorkspaceState {
|
||||
|
|
@ -9,6 +9,7 @@ interface WorkspaceState {
|
|||
workspaces: Workspace[];
|
||||
members: MemberWithUser[];
|
||||
agents: Agent[];
|
||||
skills: Skill[];
|
||||
}
|
||||
|
||||
interface WorkspaceActions {
|
||||
|
|
@ -20,6 +21,9 @@ interface WorkspaceActions {
|
|||
refreshWorkspaces: () => Promise<Workspace[]>;
|
||||
refreshMembers: () => Promise<void>;
|
||||
refreshAgents: () => Promise<void>;
|
||||
refreshSkills: () => Promise<void>;
|
||||
upsertSkill: (skill: Skill) => void;
|
||||
removeSkill: (id: string) => void;
|
||||
createWorkspace: (data: {
|
||||
name: string;
|
||||
slug: string;
|
||||
|
|
@ -39,6 +43,7 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
|||
workspaces: [],
|
||||
members: [],
|
||||
agents: [],
|
||||
skills: [],
|
||||
|
||||
// Actions
|
||||
hydrateWorkspace: async (wsList, preferredWorkspaceId) => {
|
||||
|
|
@ -54,7 +59,7 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
|||
if (!nextWorkspace) {
|
||||
api.setWorkspaceId(null);
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
set({ workspace: null, members: [], agents: [] });
|
||||
set({ workspace: null, members: [], agents: [], skills: [] });
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -62,11 +67,12 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
|||
localStorage.setItem("multica_workspace_id", nextWorkspace.id);
|
||||
set({ workspace: nextWorkspace });
|
||||
|
||||
const [nextMembers, nextAgents] = await Promise.all([
|
||||
const [nextMembers, nextAgents, nextSkills] = await Promise.all([
|
||||
api.listMembers(nextWorkspace.id),
|
||||
api.listAgents({ workspace_id: nextWorkspace.id }),
|
||||
api.listSkills().catch(() => [] as Skill[]),
|
||||
]);
|
||||
set({ members: nextMembers, agents: nextAgents });
|
||||
set({ members: nextMembers, agents: nextAgents, skills: nextSkills });
|
||||
|
||||
return nextWorkspace;
|
||||
},
|
||||
|
|
@ -101,6 +107,37 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
|||
set({ agents });
|
||||
},
|
||||
|
||||
refreshSkills: async () => {
|
||||
const { workspace, skills: existing } = get();
|
||||
if (!workspace) return;
|
||||
const fetched = await api.listSkills();
|
||||
// listSkills doesn't include files — preserve files from existing entries
|
||||
const filesById = new Map(
|
||||
existing.filter((s) => s.files?.length).map((s) => [s.id, s.files]),
|
||||
);
|
||||
const merged = fetched.map((s) => ({
|
||||
...s,
|
||||
files: s.files ?? filesById.get(s.id) ?? [],
|
||||
}));
|
||||
set({ skills: merged });
|
||||
},
|
||||
|
||||
upsertSkill: (skill) => {
|
||||
set((state) => {
|
||||
const idx = state.skills.findIndex((s) => s.id === skill.id);
|
||||
if (idx >= 0) {
|
||||
const next = [...state.skills];
|
||||
next[idx] = skill;
|
||||
return { skills: next };
|
||||
}
|
||||
return { skills: [...state.skills, skill] };
|
||||
});
|
||||
},
|
||||
|
||||
removeSkill: (id) => {
|
||||
set((state) => ({ skills: state.skills.filter((s) => s.id !== id) }));
|
||||
},
|
||||
|
||||
createWorkspace: async (data) => {
|
||||
const ws = await api.createWorkspace(data);
|
||||
set((state) => ({ workspaces: [...state.workspaces, ws] }));
|
||||
|
|
@ -137,6 +174,6 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
|||
clearWorkspace: () => {
|
||||
api.setWorkspaceId(null);
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
set({ workspace: null, workspaces: [], members: [], agents: [] });
|
||||
set({ workspace: null, workspaces: [], members: [], agents: [], skills: [] });
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export const mockAgents: Agent[] = [
|
|||
visibility: "workspace",
|
||||
max_concurrent_tasks: 3,
|
||||
owner_id: null,
|
||||
skills: "",
|
||||
skills: [],
|
||||
tools: [],
|
||||
triggers: [],
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ import type {
|
|||
Workspace,
|
||||
MemberWithUser,
|
||||
User,
|
||||
Skill,
|
||||
CreateSkillRequest,
|
||||
UpdateSkillRequest,
|
||||
SetAgentSkillsRequest,
|
||||
} from "@multica/types";
|
||||
|
||||
export interface LoginResponse {
|
||||
|
|
@ -287,4 +291,42 @@ export class ApiClient {
|
|||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
// Skills
|
||||
async listSkills(): Promise<Skill[]> {
|
||||
return this.fetch("/api/skills");
|
||||
}
|
||||
|
||||
async getSkill(id: string): Promise<Skill> {
|
||||
return this.fetch(`/api/skills/${id}`);
|
||||
}
|
||||
|
||||
async createSkill(data: CreateSkillRequest): Promise<Skill> {
|
||||
return this.fetch("/api/skills", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateSkill(id: string, data: UpdateSkillRequest): Promise<Skill> {
|
||||
return this.fetch(`/api/skills/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSkill(id: string): Promise<void> {
|
||||
await this.fetch(`/api/skills/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async listAgentSkills(agentId: string): Promise<Skill[]> {
|
||||
return this.fetch(`/api/agents/${agentId}/skills`);
|
||||
}
|
||||
|
||||
async setAgentSkills(agentId: string, data: SetAgentSkillsRequest): Promise<void> {
|
||||
await this.fetch(`/api/agents/${agentId}/skills`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export interface Agent {
|
|||
status: AgentStatus;
|
||||
max_concurrent_tasks: number;
|
||||
owner_id: string | null;
|
||||
skills: string;
|
||||
skills: Skill[];
|
||||
tools: AgentTool[];
|
||||
triggers: AgentTrigger[];
|
||||
created_at: string;
|
||||
|
|
@ -82,7 +82,6 @@ export interface CreateAgentRequest {
|
|||
runtime_config?: Record<string, unknown>;
|
||||
visibility?: AgentVisibility;
|
||||
max_concurrent_tasks?: number;
|
||||
skills?: string;
|
||||
tools?: AgentTool[];
|
||||
triggers?: AgentTrigger[];
|
||||
}
|
||||
|
|
@ -96,7 +95,50 @@ export interface UpdateAgentRequest {
|
|||
visibility?: AgentVisibility;
|
||||
status?: AgentStatus;
|
||||
max_concurrent_tasks?: number;
|
||||
skills?: string;
|
||||
tools?: AgentTool[];
|
||||
triggers?: AgentTrigger[];
|
||||
}
|
||||
|
||||
// Skills
|
||||
|
||||
export interface Skill {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
config: Record<string, unknown>;
|
||||
files: SkillFile[];
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SkillFile {
|
||||
id: string;
|
||||
skill_id: string;
|
||||
path: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateSkillRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
content?: string;
|
||||
config?: Record<string, unknown>;
|
||||
files?: { path: string; content: string }[];
|
||||
}
|
||||
|
||||
export interface UpdateSkillRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
content?: string;
|
||||
config?: Record<string, unknown>;
|
||||
files?: { path: string; content: string }[];
|
||||
}
|
||||
|
||||
export interface SetAgentSkillsRequest {
|
||||
skill_ids: string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ export type WSEventType =
|
|||
| "task:failed"
|
||||
| "inbox:new"
|
||||
| "daemon:heartbeat"
|
||||
| "daemon:register";
|
||||
| "daemon:register"
|
||||
| "skill:created"
|
||||
| "skill:updated"
|
||||
| "skill:deleted";
|
||||
|
||||
export interface WSMessage<T = unknown> {
|
||||
type: WSEventType;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ export type {
|
|||
RuntimeDevice,
|
||||
CreateAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
Skill,
|
||||
SkillFile,
|
||||
CreateSkillRequest,
|
||||
UpdateSkillRequest,
|
||||
SetAgentSkillsRequest,
|
||||
} from "./agent.js";
|
||||
export type { Workspace, Member, MemberRole, User, MemberWithUser } from "./workspace.js";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox.js";
|
||||
|
|
|
|||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
|
|
@ -197,15 +197,6 @@ importers:
|
|||
specifier: ^4.1.0
|
||||
version: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
|
||||
|
||||
packages/agent-sdk:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: 'catalog:'
|
||||
version: 25.5.0
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
||||
packages/hooks:
|
||||
dependencies:
|
||||
'@multica/sdk':
|
||||
|
|
|
|||
|
|
@ -143,9 +143,9 @@ func setupIntegrationTestFixture(ctx context.Context, pool *pgxpool.Pool) (strin
|
|||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO agent (
|
||||
workspace_id, name, description, runtime_mode, runtime_config,
|
||||
runtime_id, visibility, max_concurrent_tasks, owner_id, skills, tools, triggers
|
||||
runtime_id, visibility, max_concurrent_tasks, owner_id, tools, triggers
|
||||
)
|
||||
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4, '', '[]'::jsonb, '[]'::jsonb)
|
||||
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4, '[]'::jsonb, '[]'::jsonb)
|
||||
`, workspaceID, "Integration Test Agent", runtimeID, userID); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,22 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub) chi.Router {
|
|||
r.Put("/", h.UpdateAgent)
|
||||
r.Delete("/", h.DeleteAgent)
|
||||
r.Get("/tasks", h.ListAgentTasks)
|
||||
r.Get("/skills", h.ListAgentSkills)
|
||||
r.Put("/skills", h.SetAgentSkills)
|
||||
})
|
||||
})
|
||||
|
||||
// Skills
|
||||
r.Route("/api/skills", func(r chi.Router) {
|
||||
r.Get("/", h.ListSkills)
|
||||
r.Post("/", h.CreateSkill)
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Get("/", h.GetSkill)
|
||||
r.Put("/", h.UpdateSkill)
|
||||
r.Delete("/", h.DeleteSkill)
|
||||
r.Get("/files", h.ListSkillFiles)
|
||||
r.Put("/files", h.UpsertSkillFile)
|
||||
r.Delete("/files/{fileId}", h.DeleteSkillFile)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -269,24 +269,30 @@ func (d *Daemon) runTask(ctx context.Context, task Task) (TaskResult, error) {
|
|||
}
|
||||
|
||||
// Prepare isolated execution environment.
|
||||
taskCtx := execenv.TaskContextForEnv{
|
||||
IssueTitle: task.Context.Issue.Title,
|
||||
IssueDescription: task.Context.Issue.Description,
|
||||
AcceptanceCriteria: task.Context.Issue.AcceptanceCriteria,
|
||||
ContextRefs: task.Context.Issue.ContextRefs,
|
||||
WorkspaceContext: task.Context.WorkspaceContext,
|
||||
AgentName: task.Context.Agent.Name,
|
||||
AgentSkills: convertSkillsForEnv(task.Context.Agent.Skills),
|
||||
}
|
||||
env, err := execenv.Prepare(execenv.PrepareParams{
|
||||
WorkspacesRoot: d.cfg.WorkspacesRoot,
|
||||
ReposRoot: d.cfg.ReposRoot,
|
||||
TaskID: task.ID,
|
||||
AgentName: task.Context.Agent.Name,
|
||||
Task: execenv.TaskContextForEnv{
|
||||
IssueTitle: task.Context.Issue.Title,
|
||||
IssueDescription: task.Context.Issue.Description,
|
||||
AcceptanceCriteria: task.Context.Issue.AcceptanceCriteria,
|
||||
ContextRefs: task.Context.Issue.ContextRefs,
|
||||
WorkspaceContext: task.Context.WorkspaceContext,
|
||||
AgentName: task.Context.Agent.Name,
|
||||
AgentSkills: task.Context.Agent.Skills,
|
||||
},
|
||||
Task: taskCtx,
|
||||
}, d.logger)
|
||||
if err != nil {
|
||||
return TaskResult{}, fmt.Errorf("prepare execution environment: %w", err)
|
||||
}
|
||||
|
||||
// Inject runtime-specific config (meta skill) so the agent discovers .agent_context/.
|
||||
if err := execenv.InjectRuntimeConfig(env.WorkDir, provider, taskCtx); err != nil {
|
||||
d.logger.Printf("execenv: inject runtime config failed (non-fatal): %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := env.Cleanup(!d.cfg.KeepEnvAfterTask); cleanupErr != nil {
|
||||
d.logger.Printf("cleanup env for task %s: %v", task.ID, cleanupErr)
|
||||
|
|
@ -352,3 +358,23 @@ func (d *Daemon) runTask(ctx context.Context, task Task) (TaskResult, error) {
|
|||
return TaskResult{Status: "blocked", Comment: errMsg}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func convertSkillsForEnv(skills []SkillData) []execenv.SkillContextForEnv {
|
||||
if len(skills) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]execenv.SkillContextForEnv, len(skills))
|
||||
for i, s := range skills {
|
||||
result[i] = execenv.SkillContextForEnv{
|
||||
Name: s.Name,
|
||||
Content: s.Content,
|
||||
}
|
||||
for _, f := range s.Files {
|
||||
result[i].Files = append(result[i].Files, execenv.SkillFileContextForEnv{
|
||||
Path: f.Path,
|
||||
Content: f.Content,
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,22 +29,31 @@ func TestBuildPromptIncludesIssueAndContext(t *testing.T) {
|
|||
AcceptanceCriteria: []string{"tests pass"},
|
||||
},
|
||||
Agent: AgentContext{
|
||||
Name: "Local Codex",
|
||||
Skills: "Be concise.",
|
||||
Name: "Local Codex",
|
||||
Skills: []SkillData{
|
||||
{Name: "Concise", Content: "Be concise."},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Lean prompt: issue + acceptance criteria only. No inlined skill content.
|
||||
for _, want := range []string{
|
||||
"Fix failing test",
|
||||
"Investigate and fix the test failure.",
|
||||
"tests pass",
|
||||
".agent_context/issue_context.md",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("prompt missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// Skills should NOT be inlined in the prompt (they're in runtime config).
|
||||
for _, absent := range []string{"## Agent Skills", "Be concise."} {
|
||||
if strings.Contains(prompt, absent) {
|
||||
t.Fatalf("prompt should NOT contain %q (skills are in runtime config)", absent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPromptTruncatesLongDescription(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// writeContextFiles renders and writes .agent_context/issue_context.md into workDir.
|
||||
// writeContextFiles renders and writes .agent_context/issue_context.md and skills into workDir.
|
||||
func writeContextFiles(workDir string, ctx TaskContextForEnv) error {
|
||||
contextDir := filepath.Join(workDir, ".agent_context")
|
||||
if err := os.MkdirAll(contextDir, 0o755); err != nil {
|
||||
|
|
@ -19,6 +20,59 @@ func writeContextFiles(workDir string, ctx TaskContextForEnv) error {
|
|||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
return fmt.Errorf("write issue_context.md: %w", err)
|
||||
}
|
||||
|
||||
if len(ctx.AgentSkills) > 0 {
|
||||
if err := writeSkillFiles(contextDir, ctx.AgentSkills); err != nil {
|
||||
return fmt.Errorf("write skill files: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var nonAlphaNum = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// sanitizeSkillName converts a skill name to a safe directory name.
|
||||
func sanitizeSkillName(name string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(name))
|
||||
s = nonAlphaNum.ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
if s == "" {
|
||||
s = "skill"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// writeSkillFiles creates a skills/ directory with one subdirectory per skill.
|
||||
func writeSkillFiles(contextDir string, skills []SkillContextForEnv) error {
|
||||
skillsDir := filepath.Join(contextDir, "skills")
|
||||
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create skills dir: %w", err)
|
||||
}
|
||||
|
||||
for _, skill := range skills {
|
||||
dir := filepath.Join(skillsDir, sanitizeSkillName(skill.Name))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write main SKILL.md
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(skill.Content), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write supporting files
|
||||
for _, f := range skill.Files {
|
||||
fpath := filepath.Join(dir, f.Path)
|
||||
if err := os.MkdirAll(filepath.Dir(fpath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(fpath, []byte(f.Content), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -59,9 +113,13 @@ func renderIssueContext(ctx TaskContextForEnv) string {
|
|||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if ctx.AgentSkills != "" {
|
||||
b.WriteString("## Agent Instructions\n\n")
|
||||
b.WriteString(ctx.AgentSkills)
|
||||
if len(ctx.AgentSkills) > 0 {
|
||||
b.WriteString("## Agent Skills\n\n")
|
||||
b.WriteString("Detailed skill instructions are in `.agent_context/skills/`.\n")
|
||||
b.WriteString("Each subdirectory contains a `SKILL.md` with instructions and any supporting files.\n\n")
|
||||
for _, skill := range ctx.AgentSkills {
|
||||
fmt.Fprintf(&b, "- **%s**\n", skill.Name)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,20 @@ type TaskContextForEnv struct {
|
|||
ContextRefs []string
|
||||
WorkspaceContext string
|
||||
AgentName string
|
||||
AgentSkills string
|
||||
AgentSkills []SkillContextForEnv
|
||||
}
|
||||
|
||||
// SkillContextForEnv represents a skill to be written into the execution environment.
|
||||
type SkillContextForEnv struct {
|
||||
Name string
|
||||
Content string
|
||||
Files []SkillFileContextForEnv
|
||||
}
|
||||
|
||||
// SkillFileContextForEnv represents a supporting file within a skill.
|
||||
type SkillFileContextForEnv struct {
|
||||
Path string
|
||||
Content string
|
||||
}
|
||||
|
||||
// Environment represents a prepared, isolated execution environment.
|
||||
|
|
@ -101,9 +114,11 @@ func Prepare(params PrepareParams, logger *log.Logger) (*Environment, error) {
|
|||
env.BranchName = branchName
|
||||
env.gitRoot = gitRoot
|
||||
|
||||
// Exclude .agent_context from git tracking.
|
||||
if err := excludeFromGit(workDir, ".agent_context"); err != nil {
|
||||
logger.Printf("execenv: failed to exclude .agent_context from git: %v", err)
|
||||
// Exclude injected directories from git tracking.
|
||||
for _, pattern := range []string{".agent_context", ".claude", "AGENTS.md"} {
|
||||
if err := excludeFromGit(workDir, pattern); err != nil {
|
||||
logger.Printf("execenv: failed to exclude %s from git: %v", pattern, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,9 @@ func TestPrepareDirectoryMode(t *testing.T) {
|
|||
"Login works",
|
||||
"Tests pass",
|
||||
},
|
||||
AgentSkills: "Be concise.",
|
||||
AgentSkills: []SkillContextForEnv{
|
||||
{Name: "Code Review", Content: "Be concise."},
|
||||
},
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
|
|
@ -134,11 +136,21 @@ func TestPrepareDirectoryMode(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("failed to read issue_context.md: %v", err)
|
||||
}
|
||||
for _, want := range []string{"Fix the bug", "login flow", "Login works", "Tests pass", "Be concise."} {
|
||||
for _, want := range []string{"Fix the bug", "login flow", "Login works", "Tests pass", "Code Review"} {
|
||||
if !strings.Contains(string(content), want) {
|
||||
t.Fatalf("issue_context.md missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify skill files.
|
||||
skillContent, err := os.ReadFile(filepath.Join(env.WorkDir, ".agent_context", "skills", "code-review", "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read SKILL.md: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(skillContent), "Be concise.") {
|
||||
t.Fatal("SKILL.md missing content")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestPrepareGitWorktreeMode(t *testing.T) {
|
||||
|
|
@ -213,7 +225,15 @@ func TestWriteContextFiles(t *testing.T) {
|
|||
AcceptanceCriteria: []string{"Criterion A", "Criterion B"},
|
||||
ContextRefs: []string{"ref-1", "ref-2"},
|
||||
WorkspaceContext: "We use Go and TypeScript.",
|
||||
AgentSkills: "Follow Go conventions.",
|
||||
AgentSkills: []SkillContextForEnv{
|
||||
{
|
||||
Name: "Go Conventions",
|
||||
Content: "Follow Go conventions.",
|
||||
Files: []SkillFileContextForEnv{
|
||||
{Path: "templates/example.go", Content: "package main"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := writeContextFiles(dir, ctx); err != nil {
|
||||
|
|
@ -237,13 +257,30 @@ func TestWriteContextFiles(t *testing.T) {
|
|||
"- ref-1",
|
||||
"## Workspace Context",
|
||||
"Go and TypeScript",
|
||||
"## Agent Instructions",
|
||||
"Go conventions",
|
||||
"## Agent Skills",
|
||||
"Go Conventions",
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("content missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify skill directory and files.
|
||||
skillMd, err := os.ReadFile(filepath.Join(dir, ".agent_context", "skills", "go-conventions", "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read SKILL.md: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(skillMd), "Follow Go conventions.") {
|
||||
t.Error("SKILL.md missing content")
|
||||
}
|
||||
|
||||
supportFile, err := os.ReadFile(filepath.Join(dir, ".agent_context", "skills", "go-conventions", "templates", "example.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read supporting file: %v", err)
|
||||
}
|
||||
if string(supportFile) != "package main" {
|
||||
t.Errorf("supporting file content = %q, want %q", string(supportFile), "package main")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteContextFilesOmitsEmpty(t *testing.T) {
|
||||
|
|
@ -267,7 +304,7 @@ func TestWriteContextFilesOmitsEmpty(t *testing.T) {
|
|||
if !strings.Contains(s, "Minimal Issue") {
|
||||
t.Error("expected title to be present")
|
||||
}
|
||||
for _, absent := range []string{"## Description", "## Acceptance Criteria", "## Context References", "## Workspace Context", "## Agent Instructions"} {
|
||||
for _, absent := range []string{"## Description", "## Acceptance Criteria", "## Context References", "## Workspace Context", "## Agent Skills"} {
|
||||
if strings.Contains(s, absent) {
|
||||
t.Errorf("expected %q to be omitted for empty content", absent)
|
||||
}
|
||||
|
|
@ -327,6 +364,113 @@ func TestCleanupGitWorktree(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestInjectRuntimeConfigClaude(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
ctx := TaskContextForEnv{
|
||||
IssueTitle: "Test Issue",
|
||||
AgentSkills: []SkillContextForEnv{
|
||||
{Name: "Go Conventions", Content: "Follow Go conventions.", Files: []SkillFileContextForEnv{
|
||||
{Path: "example.go", Content: "package main"},
|
||||
}},
|
||||
{Name: "PR Review", Content: "Review PRs carefully."},
|
||||
},
|
||||
}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(dir, ".claude", "CLAUDE.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read .claude/CLAUDE.md: %v", err)
|
||||
}
|
||||
|
||||
s := string(content)
|
||||
for _, want := range []string{
|
||||
"Multica Agent Runtime",
|
||||
".agent_context/issue_context.md",
|
||||
".agent_context/skills/",
|
||||
"Go Conventions",
|
||||
"PR Review",
|
||||
"go-conventions/SKILL.md",
|
||||
"pr-review/SKILL.md",
|
||||
"1 supporting files",
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("CLAUDE.md missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectRuntimeConfigCodex(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
ctx := TaskContextForEnv{
|
||||
IssueTitle: "Test Issue",
|
||||
AgentSkills: []SkillContextForEnv{{Name: "Coding", Content: "Write good code."}},
|
||||
}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "codex", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read AGENTS.md: %v", err)
|
||||
}
|
||||
|
||||
s := string(content)
|
||||
if !strings.Contains(s, "Multica Agent Runtime") {
|
||||
t.Error("AGENTS.md missing meta skill header")
|
||||
}
|
||||
if !strings.Contains(s, "Coding") {
|
||||
t.Error("AGENTS.md missing skill name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectRuntimeConfigNoSkills(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
ctx := TaskContextForEnv{IssueTitle: "Test Issue"}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(dir, ".claude", "CLAUDE.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read .claude/CLAUDE.md: %v", err)
|
||||
}
|
||||
|
||||
s := string(content)
|
||||
if !strings.Contains(s, "issue_context.md") {
|
||||
t.Error("should reference issue_context.md even without skills")
|
||||
}
|
||||
if strings.Contains(s, "## Skills") {
|
||||
t.Error("should not have Skills section when there are no skills")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectRuntimeConfigUnknownProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
// Unknown provider should be a no-op.
|
||||
if err := InjectRuntimeConfig(dir, "unknown", TaskContextForEnv{}); err != nil {
|
||||
t.Fatalf("expected no error for unknown provider, got: %v", err)
|
||||
}
|
||||
|
||||
// No files should be created.
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("expected empty dir for unknown provider, got %d entries", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupPreservesLogs(t *testing.T) {
|
||||
t.Parallel()
|
||||
workspacesRoot := t.TempDir()
|
||||
|
|
|
|||
72
server/internal/daemon/execenv/runtime_config.go
Normal file
72
server/internal/daemon/execenv/runtime_config.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package execenv
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// InjectRuntimeConfig writes the meta skill content into the runtime-specific
|
||||
// config file so the agent discovers .agent_context/ through its native mechanism.
|
||||
//
|
||||
// For Claude: writes {workDir}/.claude/CLAUDE.md
|
||||
// For Codex: writes {workDir}/AGENTS.md
|
||||
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error {
|
||||
content := buildMetaSkillContent(ctx)
|
||||
|
||||
switch provider {
|
||||
case "claude":
|
||||
dir := filepath.Join(workDir, ".claude")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create .claude dir: %w", err)
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte(content), 0o644)
|
||||
case "codex":
|
||||
return os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644)
|
||||
default:
|
||||
// Unknown provider — skip config injection, prompt-only mode.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// buildMetaSkillContent generates the meta skill markdown that teaches the agent
|
||||
// about the Multica runtime environment and where to find task context/skills.
|
||||
func buildMetaSkillContent(ctx TaskContextForEnv) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("# Multica Agent Runtime\n\n")
|
||||
b.WriteString("You are running as a coding agent in the Multica platform.\n")
|
||||
b.WriteString("Your task context and skill instructions are in the `.agent_context/` directory.\n\n")
|
||||
|
||||
b.WriteString("## Getting Started\n\n")
|
||||
b.WriteString("1. Read `.agent_context/issue_context.md` for the full issue description, acceptance criteria, and context.\n")
|
||||
|
||||
if len(ctx.AgentSkills) > 0 {
|
||||
b.WriteString("2. Read your skill files in `.agent_context/skills/` for detailed instructions on how to work.\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
|
||||
if len(ctx.AgentSkills) > 0 {
|
||||
b.WriteString("## Skills\n\n")
|
||||
b.WriteString("Each skill directory contains a `SKILL.md` with instructions and optionally supporting files.\n\n")
|
||||
for _, skill := range ctx.AgentSkills {
|
||||
dirName := sanitizeSkillName(skill.Name)
|
||||
fmt.Fprintf(&b, "- **%s** → `.agent_context/skills/%s/SKILL.md`", skill.Name, dirName)
|
||||
if len(skill.Files) > 0 {
|
||||
fmt.Fprintf(&b, " (+ %d supporting files)", len(skill.Files))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString("## Output\n\n")
|
||||
b.WriteString("When done, return a concise Markdown comment suitable for posting back to the issue.\n")
|
||||
b.WriteString("- Lead with the outcome.\n")
|
||||
b.WriteString("- Mention concrete files or commands if you changed anything.\n")
|
||||
b.WriteString("- If blocked, explain the blocker clearly.\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
|
@ -6,17 +6,14 @@ import (
|
|||
)
|
||||
|
||||
// BuildPrompt constructs the task prompt for an agent CLI.
|
||||
// Full context is available in .agent_context/issue_context.md (written by execenv).
|
||||
// The prompt contains a brief summary for immediate context.
|
||||
// This is kept lean — only the issue summary and acceptance criteria.
|
||||
// Detailed skill instructions are injected via the runtime's native config
|
||||
// mechanism (e.g., .claude/CLAUDE.md, AGENTS.md) by execenv.InjectRuntimeConfig.
|
||||
func BuildPrompt(task Task) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("You are running as a local coding agent for a Multica workspace.\n")
|
||||
b.WriteString("Complete the assigned issue using the local environment.\n\n")
|
||||
|
||||
b.WriteString("## Context\n\n")
|
||||
b.WriteString("Full issue context is available in `.agent_context/issue_context.md` in your working directory.\n")
|
||||
b.WriteString("Read this file first for the complete issue description, acceptance criteria, and instructions.\n\n")
|
||||
|
||||
fmt.Fprintf(&b, "**Issue:** %s\n", task.Context.Issue.Title)
|
||||
fmt.Fprintf(&b, "**Agent:** %s\n\n", task.Context.Agent.Name)
|
||||
|
||||
|
|
@ -36,11 +33,5 @@ func BuildPrompt(task Task) string {
|
|||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString("## Output Requirements\n\n")
|
||||
b.WriteString("Return a concise Markdown comment suitable for posting back to the issue.\n")
|
||||
b.WriteString("- Lead with the outcome.\n")
|
||||
b.WriteString("- Mention concrete files or commands if you changed anything.\n")
|
||||
b.WriteString("- If blocked, explain the blocker clearly.\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,9 +62,22 @@ type IssueContext struct {
|
|||
|
||||
// AgentContext holds agent details for task execution.
|
||||
type AgentContext struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Skills string `json:"skills"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Skills []SkillData `json:"skills"`
|
||||
}
|
||||
|
||||
// SkillData represents a structured skill in the task context.
|
||||
type SkillData struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Files []SkillFileData `json:"files,omitempty"`
|
||||
}
|
||||
|
||||
// SkillFileData represents a supporting file within a skill.
|
||||
type SkillFileData struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// RuntimeContext holds runtime details for task execution.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
|
@ -13,23 +10,23 @@ import (
|
|||
)
|
||||
|
||||
type AgentResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
RuntimeMode string `json:"runtime_mode"`
|
||||
RuntimeConfig any `json:"runtime_config"`
|
||||
Visibility string `json:"visibility"`
|
||||
Status string `json:"status"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
OwnerID *string `json:"owner_id"`
|
||||
Skills string `json:"skills"`
|
||||
Tools any `json:"tools"`
|
||||
Triggers any `json:"triggers"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
RuntimeMode string `json:"runtime_mode"`
|
||||
RuntimeConfig any `json:"runtime_config"`
|
||||
Visibility string `json:"visibility"`
|
||||
Status string `json:"status"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
OwnerID *string `json:"owner_id"`
|
||||
Skills []SkillResponse `json:"skills"`
|
||||
Tools any `json:"tools"`
|
||||
Triggers any `json:"triggers"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func agentToResponse(a db.Agent) AgentResponse {
|
||||
|
|
@ -70,7 +67,7 @@ func agentToResponse(a db.Agent) AgentResponse {
|
|||
Status: a.Status,
|
||||
MaxConcurrentTasks: a.MaxConcurrentTasks,
|
||||
OwnerID: uuidToPtr(a.OwnerID),
|
||||
Skills: a.Skills,
|
||||
Skills: []SkillResponse{},
|
||||
Tools: tools,
|
||||
Triggers: triggers,
|
||||
CreatedAt: timestampToString(a.CreatedAt),
|
||||
|
|
@ -132,9 +129,28 @@ func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Batch-load skills for all agents to avoid N+1.
|
||||
skillRows, err := h.Queries.ListAgentSkillsByWorkspace(r.Context(), parseUUID(workspaceID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load agent skills")
|
||||
return
|
||||
}
|
||||
skillMap := map[string][]SkillResponse{}
|
||||
for _, row := range skillRows {
|
||||
agentID := uuidToString(row.AgentID)
|
||||
skillMap[agentID] = append(skillMap[agentID], SkillResponse{
|
||||
ID: uuidToString(row.ID),
|
||||
Name: row.Name,
|
||||
Description: row.Description,
|
||||
})
|
||||
}
|
||||
|
||||
resp := make([]AgentResponse, len(agents))
|
||||
for i, a := range agents {
|
||||
resp[i] = agentToResponse(a)
|
||||
if skills, ok := skillMap[resp[i].ID]; ok {
|
||||
resp[i].Skills = skills
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
|
|
@ -146,7 +162,19 @@ func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, agentToResponse(agent))
|
||||
resp := agentToResponse(agent)
|
||||
skills, err := h.Queries.ListAgentSkills(r.Context(), agent.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load agent skills")
|
||||
return
|
||||
}
|
||||
if len(skills) > 0 {
|
||||
resp.Skills = make([]SkillResponse, len(skills))
|
||||
for i, s := range skills {
|
||||
resp.Skills[i] = skillToResponse(s)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type CreateAgentRequest struct {
|
||||
|
|
@ -157,7 +185,6 @@ type CreateAgentRequest struct {
|
|||
RuntimeConfig any `json:"runtime_config"`
|
||||
Visibility string `json:"visibility"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
Skills string `json:"skills"`
|
||||
Tools any `json:"tools"`
|
||||
Triggers any `json:"triggers"`
|
||||
}
|
||||
|
|
@ -229,7 +256,6 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
|||
Visibility: req.Visibility,
|
||||
MaxConcurrentTasks: req.MaxConcurrentTasks,
|
||||
OwnerID: parseUUID(ownerID),
|
||||
Skills: req.Skills,
|
||||
Tools: tools,
|
||||
Triggers: triggers,
|
||||
})
|
||||
|
|
@ -243,54 +269,9 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
|||
agent, _ = h.Queries.GetAgent(r.Context(), agent.ID)
|
||||
}
|
||||
|
||||
// Best-effort: create an initialization issue assigned to the new agent.
|
||||
h.createAgentInitIssue(r.Context(), agent, parseUUID(ownerID))
|
||||
|
||||
writeJSON(w, http.StatusCreated, agentToResponse(agent))
|
||||
}
|
||||
|
||||
// createAgentInitIssue creates an initialization issue assigned to a newly created agent.
|
||||
// It incorporates workspace context so the agent can set up its environment.
|
||||
// Failures are silently ignored — the agent creation itself has already succeeded.
|
||||
func (h *Handler) createAgentInitIssue(ctx context.Context, agent db.Agent, creatorID pgtype.UUID) {
|
||||
ws, err := h.Queries.GetWorkspace(ctx, agent.WorkspaceID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var desc string
|
||||
if ws.Context.Valid && ws.Context.String != "" {
|
||||
desc = fmt.Sprintf("Initialize the development environment for agent **%s**.\n\n## Workspace Context\n\n%s\n\n## Instructions\n\n- Set up the local development environment based on the workspace context above\n- Clone and configure any referenced repositories\n- Verify access to the codebase and tools\n- Report back on what was set up and any issues encountered", agent.Name, ws.Context.String)
|
||||
} else {
|
||||
desc = fmt.Sprintf("Initialize the development environment for agent **%s**.\n\n## Instructions\n\n- Explore the local working directory and understand the project structure\n- Verify access to the codebase and tools\n- Report back on what was found and any issues encountered", agent.Name)
|
||||
}
|
||||
|
||||
issue, err := h.Queries.CreateIssue(ctx, db.CreateIssueParams{
|
||||
WorkspaceID: agent.WorkspaceID,
|
||||
Title: "Initialize environment for " + agent.Name,
|
||||
Description: strToText(desc),
|
||||
Status: "todo",
|
||||
Priority: "medium",
|
||||
AssigneeType: pgtype.Text{String: "agent", Valid: true},
|
||||
AssigneeID: agent.ID,
|
||||
CreatorType: "member",
|
||||
CreatorID: creatorID,
|
||||
AcceptanceCriteria: []byte("[]"),
|
||||
ContextRefs: []byte("[]"),
|
||||
Position: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.broadcast("issue:created", map[string]any{"issue": issueToResponse(issue)})
|
||||
|
||||
// Enqueue the task directly — we know the agent is assigned and status is "todo".
|
||||
if _, err := h.TaskService.EnqueueTaskForIssue(ctx, issue); err != nil {
|
||||
log.Printf("createAgentInitIssue: enqueue task failed for issue %s: %v", issue.Title, err)
|
||||
}
|
||||
}
|
||||
|
||||
type UpdateAgentRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
|
|
@ -300,7 +281,6 @@ type UpdateAgentRequest struct {
|
|||
Visibility *string `json:"visibility"`
|
||||
Status *string `json:"status"`
|
||||
MaxConcurrentTasks *int32 `json:"max_concurrent_tasks"`
|
||||
Skills *string `json:"skills"`
|
||||
Tools any `json:"tools"`
|
||||
Triggers any `json:"triggers"`
|
||||
}
|
||||
|
|
@ -358,9 +338,6 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
|||
if req.MaxConcurrentTasks != nil {
|
||||
params.MaxConcurrentTasks = pgtype.Int4{Int32: *req.MaxConcurrentTasks, Valid: true}
|
||||
}
|
||||
if req.Skills != nil {
|
||||
params.Skills = pgtype.Text{String: *req.Skills, Valid: true}
|
||||
}
|
||||
if req.Tools != nil {
|
||||
tools, _ := json.Marshal(req.Tools)
|
||||
params.Tools = tools
|
||||
|
|
|
|||
|
|
@ -109,9 +109,9 @@ func setupHandlerTestFixture(ctx context.Context, pool *pgxpool.Pool) (string, s
|
|||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO agent (
|
||||
workspace_id, name, description, runtime_mode, runtime_config,
|
||||
runtime_id, visibility, max_concurrent_tasks, owner_id, skills, tools, triggers
|
||||
runtime_id, visibility, max_concurrent_tasks, owner_id, tools, triggers
|
||||
)
|
||||
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4, '', '[]'::jsonb, '[]'::jsonb)
|
||||
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4, '[]'::jsonb, '[]'::jsonb)
|
||||
`, workspaceID, "Handler Test Agent", runtimeID, userID); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
|
|
|||
540
server/internal/handler/skill.go
Normal file
540
server/internal/handler/skill.go
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// --- Response structs ---
|
||||
|
||||
type SkillResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content"`
|
||||
Config any `json:"config"`
|
||||
CreatedBy *string `json:"created_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type SkillFileResponse struct {
|
||||
ID string `json:"id"`
|
||||
SkillID string `json:"skill_id"`
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type SkillWithFilesResponse struct {
|
||||
SkillResponse
|
||||
Files []SkillFileResponse `json:"files"`
|
||||
}
|
||||
|
||||
func skillToResponse(s db.Skill) SkillResponse {
|
||||
var config any
|
||||
if s.Config != nil {
|
||||
json.Unmarshal(s.Config, &config)
|
||||
}
|
||||
if config == nil {
|
||||
config = map[string]any{}
|
||||
}
|
||||
|
||||
return SkillResponse{
|
||||
ID: uuidToString(s.ID),
|
||||
WorkspaceID: uuidToString(s.WorkspaceID),
|
||||
Name: s.Name,
|
||||
Description: s.Description,
|
||||
Content: s.Content,
|
||||
Config: config,
|
||||
CreatedBy: uuidToPtr(s.CreatedBy),
|
||||
CreatedAt: timestampToString(s.CreatedAt),
|
||||
UpdatedAt: timestampToString(s.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func skillFileToResponse(f db.SkillFile) SkillFileResponse {
|
||||
return SkillFileResponse{
|
||||
ID: uuidToString(f.ID),
|
||||
SkillID: uuidToString(f.SkillID),
|
||||
Path: f.Path,
|
||||
Content: f.Content,
|
||||
CreatedAt: timestampToString(f.CreatedAt),
|
||||
UpdatedAt: timestampToString(f.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Request structs ---
|
||||
|
||||
type CreateSkillRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content"`
|
||||
Config any `json:"config"`
|
||||
Files []CreateSkillFileRequest `json:"files,omitempty"`
|
||||
}
|
||||
|
||||
type CreateSkillFileRequest struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type UpdateSkillRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Content *string `json:"content"`
|
||||
Config any `json:"config"`
|
||||
Files []CreateSkillFileRequest `json:"files,omitempty"`
|
||||
}
|
||||
|
||||
type SetAgentSkillsRequest struct {
|
||||
SkillIDs []string `json:"skill_ids"`
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// validateFilePath checks that a file path is safe (no traversal, no absolute paths).
|
||||
func validateFilePath(p string) bool {
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
if filepath.IsAbs(p) {
|
||||
return false
|
||||
}
|
||||
cleaned := filepath.Clean(p)
|
||||
if strings.HasPrefix(cleaned, "..") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Handler) loadSkillForUser(w http.ResponseWriter, r *http.Request, id string) (db.Skill, bool) {
|
||||
skill, err := h.Queries.GetSkill(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "skill not found")
|
||||
} else {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load skill")
|
||||
}
|
||||
return skill, false
|
||||
}
|
||||
if _, ok := h.requireWorkspaceMember(w, r, uuidToString(skill.WorkspaceID), "skill not found"); !ok {
|
||||
return skill, false
|
||||
}
|
||||
return skill, true
|
||||
}
|
||||
|
||||
// --- Skill CRUD ---
|
||||
|
||||
func (h *Handler) ListSkills(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
skills, err := h.Queries.ListSkillsByWorkspace(r.Context(), parseUUID(workspaceID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list skills")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]SkillResponse, len(skills))
|
||||
for i, s := range skills {
|
||||
resp[i] = skillToResponse(s)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) GetSkill(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
skill, ok := h.loadSkillForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
files, err := h.Queries.ListSkillFiles(r.Context(), skill.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list skill files")
|
||||
return
|
||||
}
|
||||
|
||||
fileResps := make([]SkillFileResponse, len(files))
|
||||
for i, f := range files {
|
||||
fileResps[i] = skillFileToResponse(f)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, SkillWithFilesResponse{
|
||||
SkillResponse: skillToResponse(skill),
|
||||
Files: fileResps,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateSkill(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
creatorID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateSkillRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
|
||||
for _, f := range req.Files {
|
||||
if !validateFilePath(f.Path) {
|
||||
writeError(w, http.StatusBadRequest, "invalid file path: "+f.Path)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
config, _ := json.Marshal(req.Config)
|
||||
if req.Config == nil {
|
||||
config = []byte("{}")
|
||||
}
|
||||
|
||||
tx, err := h.TxStarter.Begin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to start transaction")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
|
||||
skill, err := qtx.CreateSkill(r.Context(), db.CreateSkillParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Content: req.Content,
|
||||
Config: config,
|
||||
CreatedBy: parseUUID(creatorID),
|
||||
})
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "a skill with this name already exists")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to create skill: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
fileResps := make([]SkillFileResponse, 0, len(req.Files))
|
||||
for _, f := range req.Files {
|
||||
sf, err := qtx.UpsertSkillFile(r.Context(), db.UpsertSkillFileParams{
|
||||
SkillID: skill.ID,
|
||||
Path: f.Path,
|
||||
Content: f.Content,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create skill file: "+err.Error())
|
||||
return
|
||||
}
|
||||
fileResps = append(fileResps, skillFileToResponse(sf))
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to commit")
|
||||
return
|
||||
}
|
||||
|
||||
resp := SkillWithFilesResponse{
|
||||
SkillResponse: skillToResponse(skill),
|
||||
Files: fileResps,
|
||||
}
|
||||
h.broadcast("skill:created", map[string]any{"skill": resp})
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateSkill(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
skill, ok := h.loadSkillForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := h.requireWorkspaceRole(w, r, uuidToString(skill.WorkspaceID), "skill not found", "owner", "admin"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateSkillRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
for _, f := range req.Files {
|
||||
if !validateFilePath(f.Path) {
|
||||
writeError(w, http.StatusBadRequest, "invalid file path: "+f.Path)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := h.TxStarter.Begin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to start transaction")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
|
||||
params := db.UpdateSkillParams{
|
||||
ID: parseUUID(id),
|
||||
}
|
||||
if req.Name != nil {
|
||||
params.Name = pgtype.Text{String: *req.Name, Valid: true}
|
||||
}
|
||||
if req.Description != nil {
|
||||
params.Description = pgtype.Text{String: *req.Description, Valid: true}
|
||||
}
|
||||
if req.Content != nil {
|
||||
params.Content = pgtype.Text{String: *req.Content, Valid: true}
|
||||
}
|
||||
if req.Config != nil {
|
||||
config, _ := json.Marshal(req.Config)
|
||||
params.Config = config
|
||||
}
|
||||
|
||||
skill, err = qtx.UpdateSkill(r.Context(), params)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "a skill with this name already exists")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to update skill: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// If files are provided, replace all files.
|
||||
var fileResps []SkillFileResponse
|
||||
if req.Files != nil {
|
||||
if err := qtx.DeleteSkillFilesBySkill(r.Context(), skill.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete old skill files")
|
||||
return
|
||||
}
|
||||
fileResps = make([]SkillFileResponse, 0, len(req.Files))
|
||||
for _, f := range req.Files {
|
||||
sf, err := qtx.UpsertSkillFile(r.Context(), db.UpsertSkillFileParams{
|
||||
SkillID: skill.ID,
|
||||
Path: f.Path,
|
||||
Content: f.Content,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to upsert skill file: "+err.Error())
|
||||
return
|
||||
}
|
||||
fileResps = append(fileResps, skillFileToResponse(sf))
|
||||
}
|
||||
} else {
|
||||
files, _ := qtx.ListSkillFiles(r.Context(), skill.ID)
|
||||
fileResps = make([]SkillFileResponse, len(files))
|
||||
for i, f := range files {
|
||||
fileResps[i] = skillFileToResponse(f)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to commit")
|
||||
return
|
||||
}
|
||||
|
||||
resp := SkillWithFilesResponse{
|
||||
SkillResponse: skillToResponse(skill),
|
||||
Files: fileResps,
|
||||
}
|
||||
h.broadcast("skill:updated", map[string]any{"skill": resp})
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteSkill(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
skill, ok := h.loadSkillForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := h.requireWorkspaceRole(w, r, uuidToString(skill.WorkspaceID), "skill not found", "owner", "admin"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Queries.DeleteSkill(r.Context(), parseUUID(id)); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete skill")
|
||||
return
|
||||
}
|
||||
h.broadcast("skill:deleted", map[string]any{"skill_id": id})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- Skill File endpoints ---
|
||||
|
||||
func (h *Handler) ListSkillFiles(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
skill, ok := h.loadSkillForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
files, err := h.Queries.ListSkillFiles(r.Context(), skill.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list skill files")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]SkillFileResponse, len(files))
|
||||
for i, f := range files {
|
||||
resp[i] = skillFileToResponse(f)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) UpsertSkillFile(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
skill, ok := h.loadSkillForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := h.requireWorkspaceRole(w, r, uuidToString(skill.WorkspaceID), "skill not found", "owner", "admin"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateSkillFileRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if !validateFilePath(req.Path) {
|
||||
writeError(w, http.StatusBadRequest, "invalid file path")
|
||||
return
|
||||
}
|
||||
|
||||
sf, err := h.Queries.UpsertSkillFile(r.Context(), db.UpsertSkillFileParams{
|
||||
SkillID: skill.ID,
|
||||
Path: req.Path,
|
||||
Content: req.Content,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to upsert skill file: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, skillFileToResponse(sf))
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteSkillFile(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
skill, ok := h.loadSkillForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := h.requireWorkspaceRole(w, r, uuidToString(skill.WorkspaceID), "skill not found", "owner", "admin"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
fileID := chi.URLParam(r, "fileId")
|
||||
if err := h.Queries.DeleteSkillFile(r.Context(), parseUUID(fileID)); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete skill file")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- Agent-Skill junction ---
|
||||
|
||||
func (h *Handler) ListAgentSkills(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
agent, ok := h.loadAgentForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
skills, err := h.Queries.ListAgentSkills(r.Context(), agent.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list agent skills")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]SkillResponse, len(skills))
|
||||
for i, s := range skills {
|
||||
resp[i] = skillToResponse(s)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) SetAgentSkills(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
agent, ok := h.loadAgentForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := h.requireWorkspaceRole(w, r, uuidToString(agent.WorkspaceID), "agent not found", "owner", "admin"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req SetAgentSkillsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := h.TxStarter.Begin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to start transaction")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
|
||||
if err := qtx.RemoveAllAgentSkills(r.Context(), agent.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to clear agent skills")
|
||||
return
|
||||
}
|
||||
|
||||
for _, skillID := range req.SkillIDs {
|
||||
if err := qtx.AddAgentSkill(r.Context(), db.AddAgentSkillParams{
|
||||
AgentID: agent.ID,
|
||||
SkillID: parseUUID(skillID),
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to add agent skill: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to commit")
|
||||
return
|
||||
}
|
||||
|
||||
// Return the updated skills list.
|
||||
skills, err := h.Queries.ListAgentSkills(r.Context(), agent.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list agent skills")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]SkillResponse, len(skills))
|
||||
for i, s := range skills {
|
||||
resp[i] = skillToResponse(s)
|
||||
}
|
||||
h.broadcast("agent:status", map[string]any{"agent_id": uuidToString(agent.ID), "skills": resp})
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
|
@ -48,7 +48,10 @@ func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue) (
|
|||
workspaceContext = ws.Context.String
|
||||
}
|
||||
|
||||
snapshot := buildContextSnapshot(issue, agent, runtime, workspaceContext)
|
||||
// Load agent's structured skills + files.
|
||||
agentSkills := s.loadAgentSkillsForSnapshot(ctx, agent.ID)
|
||||
|
||||
snapshot := buildContextSnapshot(issue, agent, runtime, workspaceContext, agentSkills)
|
||||
contextJSON, _ := json.Marshal(snapshot)
|
||||
|
||||
task, err := s.Queries.CreateAgentTaskWithContext(ctx, db.CreateAgentTaskWithContextParams{
|
||||
|
|
@ -257,7 +260,36 @@ func (s *TaskService) updateAgentStatus(ctx context.Context, agentID pgtype.UUID
|
|||
s.broadcast(protocol.EventAgentStatus, map[string]any{"agent": agentToMap(agent)})
|
||||
}
|
||||
|
||||
func buildContextSnapshot(issue db.Issue, agent db.Agent, runtime db.AgentRuntime, workspaceContext string) map[string]any {
|
||||
type skillSnapshot struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Files []skillFileSnapshot `json:"files,omitempty"`
|
||||
}
|
||||
|
||||
type skillFileSnapshot struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func (s *TaskService) loadAgentSkillsForSnapshot(ctx context.Context, agentID pgtype.UUID) []skillSnapshot {
|
||||
skills, err := s.Queries.ListAgentSkills(ctx, agentID)
|
||||
if err != nil || len(skills) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]skillSnapshot, 0, len(skills))
|
||||
for _, sk := range skills {
|
||||
snap := skillSnapshot{Name: sk.Name, Content: sk.Content}
|
||||
files, _ := s.Queries.ListSkillFiles(ctx, sk.ID)
|
||||
for _, f := range files {
|
||||
snap.Files = append(snap.Files, skillFileSnapshot{Path: f.Path, Content: f.Content})
|
||||
}
|
||||
result = append(result, snap)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func buildContextSnapshot(issue db.Issue, agent db.Agent, runtime db.AgentRuntime, workspaceContext string, skills []skillSnapshot) map[string]any {
|
||||
var ac []string
|
||||
if issue.AcceptanceCriteria != nil {
|
||||
json.Unmarshal(issue.AcceptanceCriteria, &ac)
|
||||
|
|
@ -286,7 +318,7 @@ func buildContextSnapshot(issue db.Issue, agent db.Agent, runtime db.AgentRuntim
|
|||
"agent": map[string]any{
|
||||
"id": util.UUIDToString(agent.ID),
|
||||
"name": agent.Name,
|
||||
"skills": agent.Skills,
|
||||
"skills": skills,
|
||||
"tools": tools,
|
||||
},
|
||||
"runtime": map[string]any{
|
||||
|
|
@ -476,7 +508,7 @@ func agentToMap(a db.Agent) map[string]any {
|
|||
"status": a.Status,
|
||||
"max_concurrent_tasks": a.MaxConcurrentTasks,
|
||||
"owner_id": util.UUIDToPtr(a.OwnerID),
|
||||
"skills": a.Skills,
|
||||
"skills": []any{},
|
||||
"tools": tools,
|
||||
"triggers": triggers,
|
||||
"created_at": util.TimestampToString(a.CreatedAt),
|
||||
|
|
|
|||
4
server/migrations/008_structured_skills.down.sql
Normal file
4
server/migrations/008_structured_skills.down.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
DROP TABLE IF EXISTS agent_skill;
|
||||
DROP TABLE IF EXISTS skill_file;
|
||||
DROP TABLE IF EXISTS skill;
|
||||
ALTER TABLE agent ADD COLUMN IF NOT EXISTS skills TEXT NOT NULL DEFAULT '';
|
||||
41
server/migrations/008_structured_skills.up.sql
Normal file
41
server/migrations/008_structured_skills.up.sql
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
-- Structured Skills: workspace-level skill entities with supporting files
|
||||
-- and many-to-many agent-skill associations.
|
||||
|
||||
CREATE TABLE skill (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
created_by UUID REFERENCES "user"(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(workspace_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE skill_file (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
skill_id UUID NOT NULL REFERENCES skill(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(skill_id, path)
|
||||
);
|
||||
|
||||
CREATE TABLE agent_skill (
|
||||
agent_id UUID NOT NULL REFERENCES agent(id) ON DELETE CASCADE,
|
||||
skill_id UUID NOT NULL REFERENCES skill(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (agent_id, skill_id)
|
||||
);
|
||||
|
||||
-- Remove old text-based skills column from agent
|
||||
ALTER TABLE agent DROP COLUMN IF EXISTS skills;
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_skill_workspace ON skill(workspace_id);
|
||||
CREATE INDEX idx_skill_file_skill ON skill_file(skill_id);
|
||||
CREATE INDEX idx_agent_skill_skill ON agent_skill(skill_id);
|
||||
CREATE INDEX idx_agent_skill_agent ON agent_skill(agent_id);
|
||||
|
|
@ -105,9 +105,9 @@ const createAgent = `-- name: CreateAgent :one
|
|||
INSERT INTO agent (
|
||||
workspace_id, name, description, avatar_url, runtime_mode,
|
||||
runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id,
|
||||
skills, tools, triggers
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, skills, tools, triggers, runtime_id
|
||||
tools, triggers
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id
|
||||
`
|
||||
|
||||
type CreateAgentParams struct {
|
||||
|
|
@ -121,7 +121,6 @@ type CreateAgentParams struct {
|
|||
Visibility string `json:"visibility"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
Skills string `json:"skills"`
|
||||
Tools []byte `json:"tools"`
|
||||
Triggers []byte `json:"triggers"`
|
||||
}
|
||||
|
|
@ -138,7 +137,6 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent
|
|||
arg.Visibility,
|
||||
arg.MaxConcurrentTasks,
|
||||
arg.OwnerID,
|
||||
arg.Skills,
|
||||
arg.Tools,
|
||||
arg.Triggers,
|
||||
)
|
||||
|
|
@ -157,7 +155,6 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent
|
|||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Description,
|
||||
&i.Skills,
|
||||
&i.Tools,
|
||||
&i.Triggers,
|
||||
&i.RuntimeID,
|
||||
|
|
@ -288,7 +285,7 @@ func (q *Queries) FailAgentTask(ctx context.Context, arg FailAgentTaskParams) (A
|
|||
}
|
||||
|
||||
const getAgent = `-- name: GetAgent :one
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, skills, tools, triggers, runtime_id FROM agent
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id FROM agent
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
|
|
@ -309,7 +306,6 @@ func (q *Queries) GetAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
|
|||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Description,
|
||||
&i.Skills,
|
||||
&i.Tools,
|
||||
&i.Triggers,
|
||||
&i.RuntimeID,
|
||||
|
|
@ -384,7 +380,7 @@ func (q *Queries) ListAgentTasks(ctx context.Context, agentID pgtype.UUID) ([]Ag
|
|||
}
|
||||
|
||||
const listAgents = `-- name: ListAgents :many
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, skills, tools, triggers, runtime_id FROM agent
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id FROM agent
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
|
@ -412,7 +408,6 @@ func (q *Queries) ListAgents(ctx context.Context, workspaceID pgtype.UUID) ([]Ag
|
|||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Description,
|
||||
&i.Skills,
|
||||
&i.Tools,
|
||||
&i.Triggers,
|
||||
&i.RuntimeID,
|
||||
|
|
@ -506,12 +501,11 @@ UPDATE agent SET
|
|||
visibility = COALESCE($8, visibility),
|
||||
status = COALESCE($9, status),
|
||||
max_concurrent_tasks = COALESCE($10, max_concurrent_tasks),
|
||||
skills = COALESCE($11, skills),
|
||||
tools = COALESCE($12, tools),
|
||||
triggers = COALESCE($13, triggers),
|
||||
tools = COALESCE($11, tools),
|
||||
triggers = COALESCE($12, triggers),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, skills, tools, triggers, runtime_id
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id
|
||||
`
|
||||
|
||||
type UpdateAgentParams struct {
|
||||
|
|
@ -525,7 +519,6 @@ type UpdateAgentParams struct {
|
|||
Visibility pgtype.Text `json:"visibility"`
|
||||
Status pgtype.Text `json:"status"`
|
||||
MaxConcurrentTasks pgtype.Int4 `json:"max_concurrent_tasks"`
|
||||
Skills pgtype.Text `json:"skills"`
|
||||
Tools []byte `json:"tools"`
|
||||
Triggers []byte `json:"triggers"`
|
||||
}
|
||||
|
|
@ -542,7 +535,6 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
|
|||
arg.Visibility,
|
||||
arg.Status,
|
||||
arg.MaxConcurrentTasks,
|
||||
arg.Skills,
|
||||
arg.Tools,
|
||||
arg.Triggers,
|
||||
)
|
||||
|
|
@ -561,7 +553,6 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
|
|||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Description,
|
||||
&i.Skills,
|
||||
&i.Tools,
|
||||
&i.Triggers,
|
||||
&i.RuntimeID,
|
||||
|
|
@ -572,7 +563,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
|
|||
const updateAgentStatus = `-- name: UpdateAgentStatus :one
|
||||
UPDATE agent SET status = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, skills, tools, triggers, runtime_id
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id
|
||||
`
|
||||
|
||||
type UpdateAgentStatusParams struct {
|
||||
|
|
@ -597,7 +588,6 @@ func (q *Queries) UpdateAgentStatus(ctx context.Context, arg UpdateAgentStatusPa
|
|||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Description,
|
||||
&i.Skills,
|
||||
&i.Tools,
|
||||
&i.Triggers,
|
||||
&i.RuntimeID,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ type Agent struct {
|
|||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
Description string `json:"description"`
|
||||
Skills string `json:"skills"`
|
||||
Tools []byte `json:"tools"`
|
||||
Triggers []byte `json:"triggers"`
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
|
|
@ -54,6 +53,12 @@ type AgentRuntime struct {
|
|||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AgentSkill struct {
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
SkillID pgtype.UUID `json:"skill_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type AgentTaskQueue struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
|
|
@ -172,6 +177,27 @@ type Member struct {
|
|||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Skill struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content"`
|
||||
Config []byte `json:"config"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type SkillFile struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
SkillID pgtype.UUID `json:"skill_id"`
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
|
|
|||
382
server/pkg/db/generated/skill.sql.go
Normal file
382
server/pkg/db/generated/skill.sql.go
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: skill.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const addAgentSkill = `-- name: AddAgentSkill :exec
|
||||
INSERT INTO agent_skill (agent_id, skill_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
`
|
||||
|
||||
type AddAgentSkillParams struct {
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
SkillID pgtype.UUID `json:"skill_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) AddAgentSkill(ctx context.Context, arg AddAgentSkillParams) error {
|
||||
_, err := q.db.Exec(ctx, addAgentSkill, arg.AgentID, arg.SkillID)
|
||||
return err
|
||||
}
|
||||
|
||||
const createSkill = `-- name: CreateSkill :one
|
||||
INSERT INTO skill (workspace_id, name, description, content, config, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, workspace_id, name, description, content, config, created_by, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateSkillParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content"`
|
||||
Config []byte `json:"config"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateSkill(ctx context.Context, arg CreateSkillParams) (Skill, error) {
|
||||
row := q.db.QueryRow(ctx, createSkill,
|
||||
arg.WorkspaceID,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Content,
|
||||
arg.Config,
|
||||
arg.CreatedBy,
|
||||
)
|
||||
var i Skill
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Content,
|
||||
&i.Config,
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteSkill = `-- name: DeleteSkill :exec
|
||||
DELETE FROM skill WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteSkill(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteSkill, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteSkillFile = `-- name: DeleteSkillFile :exec
|
||||
DELETE FROM skill_file WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteSkillFile(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteSkillFile, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteSkillFilesBySkill = `-- name: DeleteSkillFilesBySkill :exec
|
||||
DELETE FROM skill_file WHERE skill_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteSkillFilesBySkill(ctx context.Context, skillID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteSkillFilesBySkill, skillID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getSkill = `-- name: GetSkill :one
|
||||
SELECT id, workspace_id, name, description, content, config, created_by, created_at, updated_at FROM skill
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetSkill(ctx context.Context, id pgtype.UUID) (Skill, error) {
|
||||
row := q.db.QueryRow(ctx, getSkill, id)
|
||||
var i Skill
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Content,
|
||||
&i.Config,
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getSkillFile = `-- name: GetSkillFile :one
|
||||
SELECT id, skill_id, path, content, created_at, updated_at FROM skill_file
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetSkillFile(ctx context.Context, id pgtype.UUID) (SkillFile, error) {
|
||||
row := q.db.QueryRow(ctx, getSkillFile, id)
|
||||
var i SkillFile
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SkillID,
|
||||
&i.Path,
|
||||
&i.Content,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listAgentSkills = `-- name: ListAgentSkills :many
|
||||
|
||||
SELECT s.id, s.workspace_id, s.name, s.description, s.content, s.config, s.created_by, s.created_at, s.updated_at FROM skill s
|
||||
JOIN agent_skill ask ON ask.skill_id = s.id
|
||||
WHERE ask.agent_id = $1
|
||||
ORDER BY s.name ASC
|
||||
`
|
||||
|
||||
// Agent-Skill junction
|
||||
func (q *Queries) ListAgentSkills(ctx context.Context, agentID pgtype.UUID) ([]Skill, error) {
|
||||
rows, err := q.db.Query(ctx, listAgentSkills, agentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Skill{}
|
||||
for rows.Next() {
|
||||
var i Skill
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Content,
|
||||
&i.Config,
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listAgentSkillsByWorkspace = `-- name: ListAgentSkillsByWorkspace :many
|
||||
SELECT ask.agent_id, s.id, s.name, s.description
|
||||
FROM agent_skill ask
|
||||
JOIN skill s ON s.id = ask.skill_id
|
||||
WHERE s.workspace_id = $1
|
||||
ORDER BY s.name ASC
|
||||
`
|
||||
|
||||
type ListAgentSkillsByWorkspaceRow struct {
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListAgentSkillsByWorkspace(ctx context.Context, workspaceID pgtype.UUID) ([]ListAgentSkillsByWorkspaceRow, error) {
|
||||
rows, err := q.db.Query(ctx, listAgentSkillsByWorkspace, workspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListAgentSkillsByWorkspaceRow{}
|
||||
for rows.Next() {
|
||||
var i ListAgentSkillsByWorkspaceRow
|
||||
if err := rows.Scan(
|
||||
&i.AgentID,
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listSkillFiles = `-- name: ListSkillFiles :many
|
||||
|
||||
SELECT id, skill_id, path, content, created_at, updated_at FROM skill_file
|
||||
WHERE skill_id = $1
|
||||
ORDER BY path ASC
|
||||
`
|
||||
|
||||
// Skill File CRUD
|
||||
func (q *Queries) ListSkillFiles(ctx context.Context, skillID pgtype.UUID) ([]SkillFile, error) {
|
||||
rows, err := q.db.Query(ctx, listSkillFiles, skillID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []SkillFile{}
|
||||
for rows.Next() {
|
||||
var i SkillFile
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.SkillID,
|
||||
&i.Path,
|
||||
&i.Content,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listSkillsByWorkspace = `-- name: ListSkillsByWorkspace :many
|
||||
|
||||
SELECT id, workspace_id, name, description, content, config, created_by, created_at, updated_at FROM skill
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY name ASC
|
||||
`
|
||||
|
||||
// Skill CRUD
|
||||
func (q *Queries) ListSkillsByWorkspace(ctx context.Context, workspaceID pgtype.UUID) ([]Skill, error) {
|
||||
rows, err := q.db.Query(ctx, listSkillsByWorkspace, workspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Skill{}
|
||||
for rows.Next() {
|
||||
var i Skill
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Content,
|
||||
&i.Config,
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const removeAgentSkill = `-- name: RemoveAgentSkill :exec
|
||||
DELETE FROM agent_skill
|
||||
WHERE agent_id = $1 AND skill_id = $2
|
||||
`
|
||||
|
||||
type RemoveAgentSkillParams struct {
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
SkillID pgtype.UUID `json:"skill_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) RemoveAgentSkill(ctx context.Context, arg RemoveAgentSkillParams) error {
|
||||
_, err := q.db.Exec(ctx, removeAgentSkill, arg.AgentID, arg.SkillID)
|
||||
return err
|
||||
}
|
||||
|
||||
const removeAllAgentSkills = `-- name: RemoveAllAgentSkills :exec
|
||||
DELETE FROM agent_skill WHERE agent_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) RemoveAllAgentSkills(ctx context.Context, agentID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, removeAllAgentSkills, agentID)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateSkill = `-- name: UpdateSkill :one
|
||||
UPDATE skill SET
|
||||
name = COALESCE($2, name),
|
||||
description = COALESCE($3, description),
|
||||
content = COALESCE($4, content),
|
||||
config = COALESCE($5, config),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, name, description, content, config, created_by, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateSkillParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name pgtype.Text `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Content pgtype.Text `json:"content"`
|
||||
Config []byte `json:"config"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateSkill(ctx context.Context, arg UpdateSkillParams) (Skill, error) {
|
||||
row := q.db.QueryRow(ctx, updateSkill,
|
||||
arg.ID,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Content,
|
||||
arg.Config,
|
||||
)
|
||||
var i Skill
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Content,
|
||||
&i.Config,
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertSkillFile = `-- name: UpsertSkillFile :one
|
||||
INSERT INTO skill_file (skill_id, path, content)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (skill_id, path) DO UPDATE SET
|
||||
content = EXCLUDED.content,
|
||||
updated_at = now()
|
||||
RETURNING id, skill_id, path, content, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertSkillFileParams struct {
|
||||
SkillID pgtype.UUID `json:"skill_id"`
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertSkillFile(ctx context.Context, arg UpsertSkillFileParams) (SkillFile, error) {
|
||||
row := q.db.QueryRow(ctx, upsertSkillFile, arg.SkillID, arg.Path, arg.Content)
|
||||
var i SkillFile
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SkillID,
|
||||
&i.Path,
|
||||
&i.Content,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -11,8 +11,8 @@ WHERE id = $1;
|
|||
INSERT INTO agent (
|
||||
workspace_id, name, description, avatar_url, runtime_mode,
|
||||
runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id,
|
||||
skills, tools, triggers
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
tools, triggers
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateAgent :one
|
||||
|
|
@ -26,7 +26,6 @@ UPDATE agent SET
|
|||
visibility = COALESCE(sqlc.narg('visibility'), visibility),
|
||||
status = COALESCE(sqlc.narg('status'), status),
|
||||
max_concurrent_tasks = COALESCE(sqlc.narg('max_concurrent_tasks'), max_concurrent_tasks),
|
||||
skills = COALESCE(sqlc.narg('skills'), skills),
|
||||
tools = COALESCE(sqlc.narg('tools'), tools),
|
||||
triggers = COALESCE(sqlc.narg('triggers'), triggers),
|
||||
updated_at = now()
|
||||
|
|
|
|||
80
server/pkg/db/queries/skill.sql
Normal file
80
server/pkg/db/queries/skill.sql
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
-- Skill CRUD
|
||||
|
||||
-- name: ListSkillsByWorkspace :many
|
||||
SELECT * FROM skill
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY name ASC;
|
||||
|
||||
-- name: GetSkill :one
|
||||
SELECT * FROM skill
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: CreateSkill :one
|
||||
INSERT INTO skill (workspace_id, name, description, content, config, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateSkill :one
|
||||
UPDATE skill SET
|
||||
name = COALESCE(sqlc.narg('name'), name),
|
||||
description = COALESCE(sqlc.narg('description'), description),
|
||||
content = COALESCE(sqlc.narg('content'), content),
|
||||
config = COALESCE(sqlc.narg('config'), config),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteSkill :exec
|
||||
DELETE FROM skill WHERE id = $1;
|
||||
|
||||
-- Skill File CRUD
|
||||
|
||||
-- name: ListSkillFiles :many
|
||||
SELECT * FROM skill_file
|
||||
WHERE skill_id = $1
|
||||
ORDER BY path ASC;
|
||||
|
||||
-- name: GetSkillFile :one
|
||||
SELECT * FROM skill_file
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: UpsertSkillFile :one
|
||||
INSERT INTO skill_file (skill_id, path, content)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (skill_id, path) DO UPDATE SET
|
||||
content = EXCLUDED.content,
|
||||
updated_at = now()
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteSkillFile :exec
|
||||
DELETE FROM skill_file WHERE id = $1;
|
||||
|
||||
-- name: DeleteSkillFilesBySkill :exec
|
||||
DELETE FROM skill_file WHERE skill_id = $1;
|
||||
|
||||
-- Agent-Skill junction
|
||||
|
||||
-- name: ListAgentSkills :many
|
||||
SELECT s.* FROM skill s
|
||||
JOIN agent_skill ask ON ask.skill_id = s.id
|
||||
WHERE ask.agent_id = $1
|
||||
ORDER BY s.name ASC;
|
||||
|
||||
-- name: AddAgentSkill :exec
|
||||
INSERT INTO agent_skill (agent_id, skill_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- name: RemoveAgentSkill :exec
|
||||
DELETE FROM agent_skill
|
||||
WHERE agent_id = $1 AND skill_id = $2;
|
||||
|
||||
-- name: RemoveAllAgentSkills :exec
|
||||
DELETE FROM agent_skill WHERE agent_id = $1;
|
||||
|
||||
-- name: ListAgentSkillsByWorkspace :many
|
||||
SELECT ask.agent_id, s.id, s.name, s.description
|
||||
FROM agent_skill ask
|
||||
JOIN skill s ON s.id = ask.skill_id
|
||||
WHERE s.workspace_id = $1
|
||||
ORDER BY s.name ASC;
|
||||
Loading…
Add table
Add a link
Reference in a new issue