Merge remote-tracking branch 'origin/main' into feat/tanstack-query-migration

# Conflicts:
#	apps/web/app/(dashboard)/agents/page.tsx
This commit is contained in:
Naiyuan Qing 2026-04-08 10:35:28 +08:00
commit 8cf78b7a47
17 changed files with 36 additions and 803 deletions

View file

@ -8,15 +8,10 @@ import {
Monitor,
Plus,
ListTodo,
Wrench,
FileText,
BookOpenText,
MessageSquare,
Timer,
Trash2,
Save,
Key,
Link2,
Clock,
CheckCircle2,
XCircle,
@ -35,9 +30,6 @@ import type {
Agent,
AgentStatus,
AgentVisibility,
AgentTool,
AgentTrigger,
AgentTriggerType,
AgentTask,
RuntimeDevice,
CreateAgentRequest,
@ -151,10 +143,6 @@ function CreateAgentDialog({
description: description.trim(),
runtime_id: selectedRuntime.id,
visibility,
triggers: [
{ id: generateId(), type: "on_assign", enabled: true, config: {} },
{ id: generateId(), type: "on_comment", enabled: true, config: {} },
],
});
onClose();
} catch (err) {
@ -600,466 +588,6 @@ function SkillsTab({
);
}
// ---------------------------------------------------------------------------
// Tools Tab
// ---------------------------------------------------------------------------
function AddToolDialog({
onClose,
onAdd,
}: {
onClose: () => void;
onAdd: (tool: AgentTool) => void;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [authType, setAuthType] = useState<"oauth" | "api_key" | "none">("api_key");
const handleAdd = () => {
if (!name.trim()) return;
onAdd({
id: generateId(),
name: name.trim(),
description: description.trim(),
auth_type: authType,
connected: false,
config: {},
});
onClose();
};
return (
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-sm">Add Tool</DialogTitle>
<DialogDescription className="text-xs">
Connect an external tool for this agent to use.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs text-muted-foreground">Tool Name</Label>
<Input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Google Search, Slack, GitHub"
className="mt-1"
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Description</Label>
<Input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this tool do?"
className="mt-1"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Authentication</Label>
<div className="mt-1.5 flex gap-2">
{(["api_key", "oauth", "none"] as const).map((type) => (
<Button
key={type}
variant={authType === type ? "outline" : "ghost"}
size="xs"
onClick={() => setAuthType(type)}
className={`flex-1 ${
authType === type
? "border-primary bg-primary/5 font-medium"
: ""
}`}
>
{type === "api_key" ? "API Key" : type === "oauth" ? "OAuth" : "None"}
</Button>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleAdd}
disabled={!name.trim()}
>
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ToolsTab({
agent,
onSave,
}: {
agent: Agent;
onSave: (tools: AgentTool[]) => Promise<void>;
}) {
const [tools, setTools] = useState<AgentTool[]>(agent.tools ?? []);
const [showAdd, setShowAdd] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
setTools(agent.tools ?? []);
}, [agent.id, agent.tools]);
const isDirty = JSON.stringify(tools) !== JSON.stringify(agent.tools ?? []);
const handleSave = async () => {
setSaving(true);
try {
await onSave(tools);
} catch {
// toast handled by parent
} finally {
setSaving(false);
}
};
const toggleConnect = (toolId: string) => {
setTools((prev) =>
prev.map((t) => (t.id === toolId ? { ...t, connected: !t.connected } : t)),
);
};
const removeTool = (toolId: string) => {
setTools((prev) => prev.filter((t) => t.id !== toolId));
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold">Tools</h3>
<p className="text-xs text-muted-foreground mt-0.5">
External tools and APIs this agent can use during task execution.
</p>
</div>
<div className="flex items-center gap-2">
{isDirty && (
<Button
onClick={handleSave}
disabled={saving}
size="xs"
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</Button>
)}
<Button
variant="outline"
size="xs"
onClick={() => setShowAdd(true)}
>
<Plus className="h-3 w-3" />
Add Tool
</Button>
</div>
</div>
{tools.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
<Wrench className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">No tools configured</p>
<Button
onClick={() => setShowAdd(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Add Tool
</Button>
</div>
) : (
<div className="space-y-2">
{tools.map((tool) => (
<div
key={tool.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">
{tool.auth_type === "oauth" ? (
<Link2 className="h-4 w-4 text-muted-foreground" />
) : tool.auth_type === "api_key" ? (
<Key className="h-4 w-4 text-muted-foreground" />
) : (
<Wrench className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{tool.name}</div>
{tool.description && (
<div className="text-xs text-muted-foreground truncate">
{tool.description}
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="xs"
onClick={() => toggleConnect(tool.id)}
className={
tool.connected
? "bg-success/10 text-success"
: "bg-muted text-muted-foreground hover:bg-accent"
}
>
{tool.connected ? "Connected" : "Connect"}
</Button>
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeTool(tool.id)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
{showAdd && (
<AddToolDialog
onClose={() => setShowAdd(false)}
onAdd={(tool) => setTools((prev) => [...prev, tool])}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Triggers Tab
// ---------------------------------------------------------------------------
function TriggersTab({
agent,
onSave,
}: {
agent: Agent;
onSave: (triggers: AgentTrigger[]) => Promise<void>;
}) {
const [triggers, setTriggers] = useState<AgentTrigger[]>(agent.triggers ?? []);
const [saving, setSaving] = useState(false);
useEffect(() => {
setTriggers(agent.triggers ?? []);
}, [agent.id, agent.triggers]);
const isDirty = JSON.stringify(triggers) !== JSON.stringify(agent.triggers ?? []);
const handleSave = async () => {
setSaving(true);
try {
await onSave(triggers);
} catch {
// toast handled by parent
} finally {
setSaving(false);
}
};
const toggleTrigger = (triggerId: string) => {
setTriggers((prev) =>
prev.map((t) => (t.id === triggerId ? { ...t, enabled: !t.enabled } : t)),
);
};
const removeTrigger = (triggerId: string) => {
setTriggers((prev) => prev.filter((t) => t.id !== triggerId));
};
const addTrigger = (type: AgentTriggerType) => {
const newTrigger: AgentTrigger = {
id: generateId(),
type,
enabled: true,
config: type === "scheduled" ? { cron: "0 9 * * 1-5", timezone: "UTC" } : {},
};
setTriggers((prev) => [...prev, newTrigger]);
};
const updateTriggerConfig = (triggerId: string, config: Record<string, unknown>) => {
setTriggers((prev) =>
prev.map((t) => (t.id === triggerId ? { ...t, config } : t)),
);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold">Triggers</h3>
<p className="text-xs text-muted-foreground mt-0.5">
Configure when this agent should start working.
</p>
</div>
<div className="flex items-center gap-2">
{isDirty && (
<Button
onClick={handleSave}
disabled={saving}
size="xs"
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</Button>
)}
</div>
</div>
<div className="space-y-2">
{triggers.map((trigger) => {
const scheduledConfig = (trigger.config ?? {}) as {
cron?: string;
timezone?: string;
};
return (
<div
key={trigger.id}
className="rounded-lg border px-4 py-3"
>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
{trigger.type === "on_assign" ? (
<Bot className="h-4 w-4 text-muted-foreground" />
) : trigger.type === "on_comment" ? (
<MessageSquare className="h-4 w-4 text-muted-foreground" />
) : (
<Timer className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
{trigger.type === "on_assign"
? "On Issue Assign"
: trigger.type === "on_comment"
? "On Comment"
: "Scheduled"}
</div>
<div className="text-xs text-muted-foreground">
{trigger.type === "on_assign"
? "Runs when an issue is assigned to this agent"
: trigger.type === "on_comment"
? "Runs when a member comments on the agent's issue"
: `Cron: ${scheduledConfig.cron ?? "Not set"}`}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => toggleTrigger(trigger.id)}
className={`relative h-5 w-9 rounded-full transition-colors ${
trigger.enabled ? "bg-primary" : "bg-muted"
}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
trigger.enabled ? "left-4.5" : "left-0.5"
}`}
/>
</button>
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeTrigger(trigger.id)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{trigger.type === "scheduled" && (
<div className="mt-3 grid grid-cols-2 gap-3 pl-12">
<div>
<Label className="text-xs text-muted-foreground">
Cron Expression
</Label>
<Input
type="text"
value={scheduledConfig.cron ?? ""}
onChange={(e) =>
updateTriggerConfig(trigger.id, {
...(trigger.config ?? {}),
cron: e.target.value,
})
}
placeholder="0 9 * * 1-5"
className="mt-1 text-xs font-mono"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">
Timezone
</Label>
<Input
type="text"
value={scheduledConfig.timezone ?? ""}
onChange={(e) =>
updateTriggerConfig(trigger.id, {
...(trigger.config ?? {}),
timezone: e.target.value,
})
}
placeholder="UTC"
className="mt-1 text-xs"
/>
</div>
</div>
)}
</div>
);
})}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="xs"
onClick={() => addTrigger("on_assign")}
className="border-dashed text-muted-foreground hover:text-foreground"
>
<Bot className="h-3 w-3" />
Add On Assign
</Button>
<Button
variant="outline"
size="xs"
onClick={() => addTrigger("on_comment")}
className="border-dashed text-muted-foreground hover:text-foreground"
>
<MessageSquare className="h-3 w-3" />
Add On Comment
</Button>
<Button
variant="outline"
size="xs"
onClick={() => addTrigger("scheduled")}
className="border-dashed text-muted-foreground hover:text-foreground"
>
<Timer className="h-3 w-3" />
Add Scheduled
</Button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Tasks Tab
// ---------------------------------------------------------------------------
@ -1371,13 +899,11 @@ function SettingsTab({
// Agent Detail
// ---------------------------------------------------------------------------
type DetailTab = "instructions" | "skills" | "tools" | "triggers" | "tasks" | "settings";
type DetailTab = "instructions" | "skills" | "tasks" | "settings";
const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
{ id: "instructions", label: "Instructions", icon: FileText },
{ id: "skills", label: "Skills", icon: BookOpenText },
{ id: "tools", label: "Tools", icon: Wrench },
{ id: "triggers", label: "Triggers", icon: Timer },
{ id: "tasks", label: "Tasks", icon: ListTodo },
{ id: "settings", label: "Settings", icon: Settings },
];
@ -1491,18 +1017,6 @@ function AgentDetail({
{activeTab === "skills" && (
<SkillsTab agent={agent} />
)}
{activeTab === "tools" && (
<ToolsTab
agent={agent}
onSave={(tools) => onUpdate(agent.id, { tools })}
/>
)}
{activeTab === "triggers" && (
<TriggersTab
agent={agent}
onSave={(triggers) => onUpdate(agent.id, { triggers })}
/>
)}
{activeTab === "tasks" && <TasksTab agent={agent} />}
{activeTab === "settings" && (
<SettingsTab

View file

@ -131,7 +131,7 @@ export const en: LandingDict = {
{
title: "Create your first agent",
description:
"Give it a name, write instructions, attach skills, and set triggers. Choose when it activates: on assignment, on comment, or on mention.",
"Give it a name, write instructions, and attach skills. Agents automatically activate on assignment, on comment, or on mention.",
},
{
title: "Assign an issue and watch it work",
@ -385,7 +385,7 @@ export const en: LandingDict = {
title: "Core Platform",
changes: [
"Multi-workspace switching and creation",
"Agent management UI with skills, tools, and triggers",
"Agent management UI with skills",
"Unified agent SDK supporting Claude Code and Codex backends",
"Comment CRUD with real-time WebSocket updates",
"Task service layer and daemon REST protocol",

View file

@ -4,8 +4,6 @@ export type AgentRuntimeMode = "local" | "cloud";
export type AgentVisibility = "workspace" | "private";
export type AgentTriggerType = "on_assign" | "on_comment" | "scheduled";
export interface RuntimeDevice {
id: string;
workspace_id: string;
@ -23,22 +21,6 @@ export interface RuntimeDevice {
export type AgentRuntime = RuntimeDevice;
export interface AgentTool {
id: string;
name: string;
description: string;
auth_type: "oauth" | "api_key" | "none";
connected: boolean;
config: Record<string, unknown>;
}
export interface AgentTrigger {
id: string;
type: AgentTriggerType;
enabled: boolean;
config: Record<string, unknown> | null;
}
export interface AgentTask {
id: string;
agent_id: string;
@ -69,8 +51,6 @@ export interface Agent {
max_concurrent_tasks: number;
owner_id: string | null;
skills: Skill[];
tools: AgentTool[];
triggers: AgentTrigger[];
created_at: string;
updated_at: string;
archived_at: string | null;
@ -86,8 +66,6 @@ export interface CreateAgentRequest {
runtime_config?: Record<string, unknown>;
visibility?: AgentVisibility;
max_concurrent_tasks?: number;
tools?: AgentTool[];
triggers?: AgentTrigger[];
}
export interface UpdateAgentRequest {
@ -100,8 +78,6 @@ export interface UpdateAgentRequest {
visibility?: AgentVisibility;
status?: AgentStatus;
max_concurrent_tasks?: number;
tools?: AgentTool[];
triggers?: AgentTrigger[];
}
// Skills

View file

@ -4,9 +4,6 @@ export type {
AgentStatus,
AgentRuntimeMode,
AgentVisibility,
AgentTriggerType,
AgentTool,
AgentTrigger,
AgentTask,
AgentRuntime,
RuntimeDevice,

View file

@ -58,8 +58,6 @@ export const mockAgents: Agent[] = [
max_concurrent_tasks: 3,
owner_id: null,
skills: [],
tools: [],
triggers: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
archived_at: null,

View file

@ -139,9 +139,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, tools, triggers
runtime_id, visibility, max_concurrent_tasks, owner_id
)
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4, '[]'::jsonb, '[]'::jsonb)
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4)
`, workspaceID, "Integration Test Agent", runtimeID, userID); err != nil {
return "", "", err
}

View file

@ -28,8 +28,6 @@ type AgentResponse struct {
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"`
ArchivedAt *string `json:"archived_at"`
@ -45,22 +43,6 @@ func agentToResponse(a db.Agent) AgentResponse {
rc = map[string]any{}
}
var tools any
if a.Tools != nil {
json.Unmarshal(a.Tools, &tools)
}
if tools == nil {
tools = []any{}
}
var triggers any
if a.Triggers != nil {
json.Unmarshal(a.Triggers, &triggers)
}
if triggers == nil {
triggers = []any{}
}
return AgentResponse{
ID: uuidToString(a.ID),
WorkspaceID: uuidToString(a.WorkspaceID),
@ -76,8 +58,6 @@ func agentToResponse(a db.Agent) AgentResponse {
MaxConcurrentTasks: a.MaxConcurrentTasks,
OwnerID: uuidToPtr(a.OwnerID),
Skills: []SkillResponse{},
Tools: tools,
Triggers: triggers,
CreatedAt: timestampToString(a.CreatedAt),
UpdatedAt: timestampToString(a.UpdatedAt),
ArchivedAt: timestampToPtr(a.ArchivedAt),
@ -221,8 +201,6 @@ type CreateAgentRequest struct {
RuntimeConfig any `json:"runtime_config"`
Visibility string `json:"visibility"`
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
Tools any `json:"tools"`
Triggers any `json:"triggers"`
}
func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
@ -268,16 +246,6 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
rc = []byte("{}")
}
tools, _ := json.Marshal(req.Tools)
if req.Tools == nil {
tools = []byte("[]")
}
triggers, _ := json.Marshal(req.Triggers)
if req.Triggers == nil {
triggers = defaultAgentTriggers()
}
agent, err := h.Queries.CreateAgent(r.Context(), db.CreateAgentParams{
WorkspaceID: parseUUID(workspaceID),
Name: req.Name,
@ -290,8 +258,6 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
Visibility: req.Visibility,
MaxConcurrentTasks: req.MaxConcurrentTasks,
OwnerID: parseUUID(ownerID),
Tools: tools,
Triggers: triggers,
})
if err != nil {
slog.Warn("create agent failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
@ -323,8 +289,6 @@ type UpdateAgentRequest struct {
Visibility *string `json:"visibility"`
Status *string `json:"status"`
MaxConcurrentTasks *int32 `json:"max_concurrent_tasks"`
Tools any `json:"tools"`
Triggers any `json:"triggers"`
}
// canManageAgent checks whether the current user can update or archive an agent.
@ -401,14 +365,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.Tools != nil {
tools, _ := json.Marshal(req.Tools)
params.Tools = tools
}
if req.Triggers != nil {
triggers, _ := json.Marshal(req.Triggers)
params.Triggers = triggers
}
agent, err := h.Queries.UpdateAgent(r.Context(), params)
if err != nil {

View file

@ -418,10 +418,6 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue
}
}
}
// Check if the agent has on_mention trigger enabled.
if !agentHasTriggerEnabled(agent.Triggers, "on_mention") {
continue
}
// Dedup: skip if this agent already has a pending task for this issue.
hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{
IssueID: issue.ID,

View file

@ -118,9 +118,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, tools, triggers
runtime_id, visibility, max_concurrent_tasks, owner_id
)
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4, '[]'::jsonb, '[]'::jsonb)
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4)
`, workspaceID, "Handler Test Agent", runtimeID, userID); err != nil {
return "", "", err
}

View file

@ -39,23 +39,6 @@ type IssueResponse struct {
Attachments []AttachmentResponse `json:"attachments,omitempty"`
}
type agentTriggerSnapshot struct {
Type string `json:"type"`
Enabled bool `json:"enabled"`
Config map[string]any `json:"config"`
}
// defaultAgentTriggers returns the default trigger config for new agents:
// all three triggers explicitly enabled.
func defaultAgentTriggers() []byte {
b, _ := json.Marshal([]agentTriggerSnapshot{
{Type: "on_assign", Enabled: true},
{Type: "on_comment", Enabled: true},
{Type: "on_mention", Enabled: true},
})
return b
}
func issueToResponse(i db.Issue, issuePrefix string) IssueResponse {
identifier := issuePrefix + "-" + strconv.Itoa(int(i.Number))
return IssueResponse{
@ -549,8 +532,9 @@ func (h *Handler) canAssignAgent(ctx context.Context, r *http.Request, agentID,
// the assigned agent. No status gate — assignment is an explicit human action,
// so it should trigger regardless of issue status (e.g. assigning an agent to
// a done issue to fix a discovered problem).
// All trigger types (on_assign, on_comment, on_mention) are always enabled.
func (h *Handler) shouldEnqueueAgentTask(ctx context.Context, issue db.Issue) bool {
return h.isAgentTriggerEnabled(ctx, issue, "on_assign")
return h.isAgentAssigneeReady(ctx, issue)
}
// shouldEnqueueOnComment returns true if a member comment on this issue should
@ -561,7 +545,7 @@ func (h *Handler) shouldEnqueueOnComment(ctx context.Context, issue db.Issue) bo
if issue.Status == "done" || issue.Status == "cancelled" {
return false
}
if !h.isAgentTriggerEnabled(ctx, issue, "on_comment") {
if !h.isAgentAssigneeReady(ctx, issue) {
return false
}
// Coalescing queue: allow enqueue when a task is running (so the agent
@ -574,10 +558,9 @@ func (h *Handler) shouldEnqueueOnComment(ctx context.Context, issue db.Issue) bo
return true
}
// isAgentTriggerEnabled checks if an issue is assigned to an agent with a
// specific trigger type enabled. Returns true if the agent has no triggers
// configured (default-enabled behavior for backwards compatibility).
func (h *Handler) isAgentTriggerEnabled(ctx context.Context, issue db.Issue, triggerType string) bool {
// isAgentAssigneeReady checks if an issue is assigned to an active agent
// with a valid runtime.
func (h *Handler) isAgentAssigneeReady(ctx context.Context, issue db.Issue) bool {
if !issue.AssigneeType.Valid || issue.AssigneeType.String != "agent" || !issue.AssigneeID.Valid {
return false
}
@ -587,43 +570,7 @@ func (h *Handler) isAgentTriggerEnabled(ctx context.Context, issue db.Issue, tri
return false
}
return agentHasTriggerEnabled(agent.Triggers, triggerType)
}
// isAgentMentionTriggerEnabled checks if a specific agent has the on_mention
// trigger enabled. Unlike isAgentTriggerEnabled, this takes an explicit agent
// ID rather than deriving it from the issue assignee.
func (h *Handler) isAgentMentionTriggerEnabled(ctx context.Context, agentID pgtype.UUID) bool {
agent, err := h.Queries.GetAgent(ctx, agentID)
if err != nil || !agent.RuntimeID.Valid {
return false
}
return agentHasTriggerEnabled(agent.Triggers, "on_mention")
}
// agentHasTriggerEnabled checks if a trigger type is enabled in the agent's
// trigger config. Returns true (default-enabled) when the triggers list is
// empty or does not contain the requested type — for backwards compatibility
// with agents created before explicit trigger config was introduced.
func agentHasTriggerEnabled(raw []byte, triggerType string) bool {
if raw == nil || len(raw) == 0 {
return true
}
var triggers []agentTriggerSnapshot
if err := json.Unmarshal(raw, &triggers); err != nil {
return false
}
if len(triggers) == 0 {
return true // Empty array = default-enabled (backwards compat)
}
for _, trigger := range triggers {
if trigger.Type == triggerType {
return trigger.Enabled
}
}
return true // Trigger type not configured = enabled by default
return true
}
func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) {

View file

@ -1,7 +1,6 @@
package handler
import (
"encoding/json"
"fmt"
"testing"
@ -268,119 +267,3 @@ func TestOnCommentTriggerDecision(t *testing.T) {
}
}
// -------------------------------------------------------------------
// agentHasTriggerEnabled
// -------------------------------------------------------------------
func TestAgentHasTriggerEnabled(t *testing.T) {
tests := []struct {
name string
raw []byte
triggerType string
want bool
}{
{
name: "nil triggers → enabled (backwards compat)",
raw: nil,
triggerType: "on_comment",
want: true,
},
{
name: "empty byte slice → enabled",
raw: []byte{},
triggerType: "on_comment",
want: true,
},
{
name: "empty JSON array → enabled (backwards compat)",
raw: []byte("[]"),
triggerType: "on_comment",
want: true,
},
{
name: "on_comment explicitly enabled",
raw: mustJSON([]agentTriggerSnapshot{{Type: "on_comment", Enabled: true}}),
triggerType: "on_comment",
want: true,
},
{
name: "on_comment explicitly disabled",
raw: mustJSON([]agentTriggerSnapshot{{Type: "on_comment", Enabled: false}}),
triggerType: "on_comment",
want: false,
},
{
name: "on_mention not configured but others are → enabled by default",
raw: mustJSON([]agentTriggerSnapshot{{Type: "on_comment", Enabled: true}}),
triggerType: "on_mention",
want: true,
},
{
name: "invalid JSON → disabled (fail safe)",
raw: []byte("{bad json"),
triggerType: "on_comment",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := agentHasTriggerEnabled(tt.raw, tt.triggerType)
if got != tt.want {
t.Errorf("agentHasTriggerEnabled() = %v, want %v", got, tt.want)
}
})
}
}
// -------------------------------------------------------------------
// defaultAgentTriggers
// -------------------------------------------------------------------
func TestDefaultAgentTriggers(t *testing.T) {
raw := defaultAgentTriggers()
var triggers []agentTriggerSnapshot
if err := json.Unmarshal(raw, &triggers); err != nil {
t.Fatalf("failed to unmarshal default triggers: %v", err)
}
if len(triggers) != 3 {
t.Fatalf("expected 3 default triggers, got %d", len(triggers))
}
expected := map[string]bool{
"on_assign": true,
"on_comment": true,
"on_mention": true,
}
for _, tr := range triggers {
want, ok := expected[tr.Type]
if !ok {
t.Errorf("unexpected trigger type: %s", tr.Type)
continue
}
if tr.Enabled != want {
t.Errorf("trigger %s: enabled = %v, want %v", tr.Type, tr.Enabled, want)
}
delete(expected, tr.Type)
}
for typ := range expected {
t.Errorf("missing trigger type: %s", typ)
}
// Verify all triggers are enabled via agentHasTriggerEnabled
for _, typ := range []string{"on_assign", "on_comment", "on_mention"} {
if !agentHasTriggerEnabled(raw, typ) {
t.Errorf("agentHasTriggerEnabled(default, %q) = false, want true", typ)
}
}
}
func mustJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return b
}

View file

@ -531,14 +531,6 @@ func agentToMap(a db.Agent) map[string]any {
if a.RuntimeConfig != nil {
json.Unmarshal(a.RuntimeConfig, &rc)
}
var tools any
if a.Tools != nil {
json.Unmarshal(a.Tools, &tools)
}
var triggers any
if a.Triggers != nil {
json.Unmarshal(a.Triggers, &triggers)
}
return map[string]any{
"id": util.UUIDToString(a.ID),
"workspace_id": util.UUIDToString(a.WorkspaceID),
@ -553,8 +545,6 @@ func agentToMap(a db.Agent) map[string]any {
"max_concurrent_tasks": a.MaxConcurrentTasks,
"owner_id": util.UUIDToPtr(a.OwnerID),
"skills": []any{},
"tools": tools,
"triggers": triggers,
"created_at": util.TimestampToString(a.CreatedAt),
"updated_at": util.TimestampToString(a.UpdatedAt),
"archived_at": util.TimestampToPtr(a.ArchivedAt),

View file

@ -0,0 +1,3 @@
-- Re-add the triggers and tools columns to agent table.
ALTER TABLE agent ADD COLUMN triggers JSONB NOT NULL DEFAULT '[]';
ALTER TABLE agent ADD COLUMN tools JSONB NOT NULL DEFAULT '[]';

View file

@ -0,0 +1,5 @@
-- Remove the triggers and tools columns from agent table.
-- Trigger behavior (on_assign, on_comment, on_mention) is now always enabled (hardcoded).
-- Tools was a placeholder field never used at runtime.
ALTER TABLE agent DROP COLUMN IF EXISTS triggers;
ALTER TABLE agent DROP COLUMN IF EXISTS tools;

View file

@ -14,7 +14,7 @@ import (
const archiveAgent = `-- name: ArchiveAgent :one
UPDATE agent SET archived_at = now(), archived_by = $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, tools, triggers, runtime_id, instructions, archived_at, archived_by
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by
`
type ArchiveAgentParams struct {
@ -39,8 +39,6 @@ func (q *Queries) ArchiveAgent(ctx context.Context, arg ArchiveAgentParams) (Age
&i.CreatedAt,
&i.UpdatedAt,
&i.Description,
&i.Tools,
&i.Triggers,
&i.RuntimeID,
&i.Instructions,
&i.ArchivedAt,
@ -207,9 +205,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,
tools, triggers, instructions
) 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, tools, triggers, runtime_id, instructions, archived_at, archived_by
instructions
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by
`
type CreateAgentParams struct {
@ -223,8 +221,6 @@ type CreateAgentParams struct {
Visibility string `json:"visibility"`
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
OwnerID pgtype.UUID `json:"owner_id"`
Tools []byte `json:"tools"`
Triggers []byte `json:"triggers"`
Instructions string `json:"instructions"`
}
@ -240,8 +236,6 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent
arg.Visibility,
arg.MaxConcurrentTasks,
arg.OwnerID,
arg.Tools,
arg.Triggers,
arg.Instructions,
)
var i Agent
@ -259,8 +253,6 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent
&i.CreatedAt,
&i.UpdatedAt,
&i.Description,
&i.Tools,
&i.Triggers,
&i.RuntimeID,
&i.Instructions,
&i.ArchivedAt,
@ -392,7 +384,7 @@ func (q *Queries) FailStaleTasks(ctx context.Context, arg FailStaleTasksParams)
}
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, tools, triggers, runtime_id, instructions, archived_at, archived_by 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, runtime_id, instructions, archived_at, archived_by FROM agent
WHERE id = $1
`
@ -413,8 +405,6 @@ func (q *Queries) GetAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
&i.CreatedAt,
&i.UpdatedAt,
&i.Description,
&i.Tools,
&i.Triggers,
&i.RuntimeID,
&i.Instructions,
&i.ArchivedAt,
@ -424,7 +414,7 @@ func (q *Queries) GetAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
}
const getAgentInWorkspace = `-- name: GetAgentInWorkspace :one
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, instructions, archived_at, archived_by 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, runtime_id, instructions, archived_at, archived_by FROM agent
WHERE id = $1 AND workspace_id = $2
`
@ -450,8 +440,6 @@ func (q *Queries) GetAgentInWorkspace(ctx context.Context, arg GetAgentInWorkspa
&i.CreatedAt,
&i.UpdatedAt,
&i.Description,
&i.Tools,
&i.Triggers,
&i.RuntimeID,
&i.Instructions,
&i.ArchivedAt,
@ -650,7 +638,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, tools, triggers, runtime_id, instructions, archived_at, archived_by 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, runtime_id, instructions, archived_at, archived_by FROM agent
WHERE workspace_id = $1 AND archived_at IS NULL
ORDER BY created_at ASC
`
@ -678,8 +666,6 @@ func (q *Queries) ListAgents(ctx context.Context, workspaceID pgtype.UUID) ([]Ag
&i.CreatedAt,
&i.UpdatedAt,
&i.Description,
&i.Tools,
&i.Triggers,
&i.RuntimeID,
&i.Instructions,
&i.ArchivedAt,
@ -696,7 +682,7 @@ func (q *Queries) ListAgents(ctx context.Context, workspaceID pgtype.UUID) ([]Ag
}
const listAllAgents = `-- name: ListAllAgents :many
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, instructions, archived_at, archived_by 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, runtime_id, instructions, archived_at, archived_by FROM agent
WHERE workspace_id = $1
ORDER BY created_at ASC
`
@ -724,8 +710,6 @@ func (q *Queries) ListAllAgents(ctx context.Context, workspaceID pgtype.UUID) ([
&i.CreatedAt,
&i.UpdatedAt,
&i.Description,
&i.Tools,
&i.Triggers,
&i.RuntimeID,
&i.Instructions,
&i.ArchivedAt,
@ -830,7 +814,7 @@ func (q *Queries) ListTasksByIssue(ctx context.Context, issueID pgtype.UUID) ([]
const restoreAgent = `-- name: RestoreAgent :one
UPDATE agent SET archived_at = NULL, archived_by = NULL, 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, tools, triggers, runtime_id, instructions, archived_at, archived_by
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by
`
func (q *Queries) RestoreAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
@ -850,8 +834,6 @@ func (q *Queries) RestoreAgent(ctx context.Context, id pgtype.UUID) (Agent, erro
&i.CreatedAt,
&i.UpdatedAt,
&i.Description,
&i.Tools,
&i.Triggers,
&i.RuntimeID,
&i.Instructions,
&i.ArchivedAt,
@ -902,12 +884,10 @@ UPDATE agent SET
visibility = COALESCE($8, visibility),
status = COALESCE($9, status),
max_concurrent_tasks = COALESCE($10, max_concurrent_tasks),
tools = COALESCE($11, tools),
triggers = COALESCE($12, triggers),
instructions = COALESCE($13, instructions),
instructions = COALESCE($11, instructions),
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, tools, triggers, runtime_id, instructions, archived_at, archived_by
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by
`
type UpdateAgentParams struct {
@ -921,8 +901,6 @@ type UpdateAgentParams struct {
Visibility pgtype.Text `json:"visibility"`
Status pgtype.Text `json:"status"`
MaxConcurrentTasks pgtype.Int4 `json:"max_concurrent_tasks"`
Tools []byte `json:"tools"`
Triggers []byte `json:"triggers"`
Instructions pgtype.Text `json:"instructions"`
}
@ -938,8 +916,6 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
arg.Visibility,
arg.Status,
arg.MaxConcurrentTasks,
arg.Tools,
arg.Triggers,
arg.Instructions,
)
var i Agent
@ -957,8 +933,6 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
&i.CreatedAt,
&i.UpdatedAt,
&i.Description,
&i.Tools,
&i.Triggers,
&i.RuntimeID,
&i.Instructions,
&i.ArchivedAt,
@ -970,7 +944,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, tools, triggers, runtime_id, instructions, archived_at, archived_by
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by
`
type UpdateAgentStatusParams struct {
@ -995,8 +969,6 @@ func (q *Queries) UpdateAgentStatus(ctx context.Context, arg UpdateAgentStatusPa
&i.CreatedAt,
&i.UpdatedAt,
&i.Description,
&i.Tools,
&i.Triggers,
&i.RuntimeID,
&i.Instructions,
&i.ArchivedAt,

View file

@ -33,8 +33,6 @@ type Agent struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Description string `json:"description"`
Tools []byte `json:"tools"`
Triggers []byte `json:"triggers"`
RuntimeID pgtype.UUID `json:"runtime_id"`
Instructions string `json:"instructions"`
ArchivedAt pgtype.Timestamptz `json:"archived_at"`

View file

@ -20,8 +20,8 @@ WHERE id = $1 AND workspace_id = $2;
INSERT INTO agent (
workspace_id, name, description, avatar_url, runtime_mode,
runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id,
tools, triggers, instructions
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
instructions
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *;
-- name: UpdateAgent :one
@ -35,8 +35,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),
tools = COALESCE(sqlc.narg('tools'), tools),
triggers = COALESCE(sqlc.narg('triggers'), triggers),
instructions = COALESCE(sqlc.narg('instructions'), instructions),
updated_at = now()
WHERE id = $1