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:
Jiayuan Zhang 2026-03-25 15:41:27 +08:00 committed by GitHub
commit daaee733bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 2380 additions and 196 deletions

View file

@ -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 },
];

View file

@ -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

View file

@ -0,0 +1 @@
export { SkillsPage as default } from "@/features/skills";

View file

@ -0,0 +1 @@
export { default as SkillsPage } from "./skills-page";

View 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&apos;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 &quot;{skill.name}&quot; 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>
);
}

View file

@ -0,0 +1 @@
export { SkillsPage } from "./components";

View file

@ -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: [] });
},
}));

View file

@ -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",

View file

@ -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),
});
}
}

View file

@ -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[];
}

View file

@ -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;

View file

@ -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
View file

@ -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':

View file

@ -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
}

View file

@ -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)
})
})

View file

@ -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
}

View file

@ -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) {

View file

@ -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")
}

View file

@ -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)
}
}
}
}

View file

@ -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()

View 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()
}

View file

@ -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()
}

View file

@ -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.

View file

@ -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

View file

@ -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
}

View 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)
}

View file

@ -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),

View 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 '';

View 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);

View file

@ -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,

View file

@ -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"`

View 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
}

View file

@ -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()

View 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;