feat(agents): restore agent-worker container architecture + fix chat scroll and parallel chats

- Restore agent-worker from commit 153399f: autonomous HTTP server per agent
  (main.go 597 lines, main_test.go 438 lines, Dockerfile.agent-worker)
- Add container fields to agents table (serviceName, servicePort, containerImage, containerStatus)
- Update executor.go: real delegateToAgent() with HTTP POST to agent containers
- Update db.go: GetAgentByID, UpdateContainerStatus, GetAgentHistory, SaveHistory
- Update orchestrator.go: inject DB into executor for container address resolution
- Add tRPC endpoints: agents.deployContainer, agents.stopContainer, agents.containerStatus
- Add Docker Swarm deploy/stop logic in server/agents.ts
- Add Start/Stop container buttons to Agents.tsx with status badges
- Fix chat auto-scroll: replace ScrollArea with overflow-y-auto for direct scrollTop control
- Fix parallel chats: make isThinking per-conversation (thinkingConvId) instead of global
  so switching between chats works while one is processing
This commit is contained in:
¨NW¨
2026-04-10 15:43:33 +01:00
parent 42a4f2d01d
commit 0f23dffc26
14 changed files with 2583 additions and 473 deletions

View File

@@ -93,7 +93,7 @@ function getTs(): string {
class ChatStore {
private conversations: Conversation[] = loadConversations();
private activeId: string = "";
private isThinking = false;
private thinkingConvId: string | null = null; // per-conversation thinking lock
private activeAgents: AgentActivity[] = [];
private listeners = new Set<UpdateHandler>();
@@ -129,7 +129,14 @@ class ChatStore {
return this.conversations.find(c => c.id === this.activeId) ?? null;
}
getIsThinking(): boolean {
return this.isThinking;
return this.thinkingConvId !== null;
}
getIsConversationThinking(convId: string): boolean {
return this.thinkingConvId === convId;
}
/** Which conversation is currently thinking, or null */
getThinkingConvId(): string | null {
return this.thinkingConvId;
}
// ─── Mutations ──────────────────────────────────────────────────────────────
@@ -175,8 +182,12 @@ class ChatStore {
this.emit();
}
setThinking(v: boolean) {
this.isThinking = v;
setThinking(v: boolean, convId?: string) {
if (v) {
this.thinkingConvId = convId ?? this.activeId;
} else {
this.thinkingConvId = null;
}
this.emit();
}
@@ -281,7 +292,7 @@ class ChatStore {
: c
);
persistConversations(this.conversations);
this.isThinking = false;
this.thinkingConvId = null;
this.emit();
}
@@ -299,7 +310,7 @@ class ChatStore {
c.id === convId ? { ...c, messages: [...c.messages, msg] } : c
);
persistConversations(this.conversations);
this.isThinking = false;
this.thinkingConvId = null;
this.emit();
}

View File

@@ -41,6 +41,8 @@ import {
Shield,
Wrench,
Code2,
Container,
Square,
} from "lucide-react";
import { motion } from "framer-motion";
import { useState } from "react";
@@ -74,6 +76,21 @@ function getStatusConfig(status: string) {
}
}
function getContainerStatusConfig(status: string | undefined | null) {
switch (status) {
case "running":
return { label: "RUNNING", color: "text-neon-green", bg: "bg-neon-green/15", border: "border-neon-green/30", icon: Container };
case "deploying":
return { label: "DEPLOYING", color: "text-neon-amber", bg: "bg-neon-amber/15", border: "border-neon-amber/30", icon: Loader2 };
case "error":
return { label: "ERROR", color: "text-neon-red", bg: "bg-neon-red/15", border: "border-neon-red/30", icon: AlertCircle };
case "stopped":
default:
return { label: "STOPPED", color: "text-muted-foreground", bg: "bg-muted/50", border: "border-border", icon: Square };
}
}
}
export default function Agents() {
const [selectedAgent, setSelectedAgent] = useState<any>(null);
const [detailModalOpen, setDetailModalOpen] = useState(false);
@@ -97,6 +114,37 @@ export default function Agents() {
},
});
const deployMutation = trpc.agents.deployContainer.useMutation({
onSuccess: (data) => {
if (data.success) {
toast.success(`Agent container deployed: ${data.serviceName}`);
} else {
toast.error(`Deploy failed: ${data.error}`);
}
refetch();
},
onError: (error) => {
toast.error(`Deploy error: ${error.message}`);
},
});
const stopMutation = trpc.agents.stopContainer.useMutation({
onSuccess: (data) => {
if (data.success) {
toast.success("Agent container stopped");
} else {
toast.error(`Stop failed: ${data.error}`);
}
refetch();
},
onError: (error) => {
toast.error(`Stop error: ${error.message}`);
},
});
const [deployingAgentId, setDeployingAgentId] = useState<number | null>(null);
const [stoppingAgentId, setStoppingAgentId] = useState<number | null>(null);
const handleDeleteClick = (agentId: number) => {
setAgentToDelete(agentId);
setDeleteConfirmOpen(true);
@@ -139,7 +187,8 @@ export default function Agents() {
<div>
<h2 className="text-xl font-bold text-foreground">Agent Fleet</h2>
<p className="text-sm text-muted-foreground font-mono mt-1">
{agents.filter((a: any) => !a.isOrchestrator).length} agents · {agents.filter((a: any) => a.isOrchestrator).length} orchestrator
{agents.filter((a: any) => !a.isOrchestrator).length} agents ·{" "}
{agents.filter((a: any) => a.isOrchestrator).length} orchestrator
</p>
</div>
<Button
@@ -157,7 +206,9 @@ export default function Agents() {
<Card className="bg-card border-border/50">
<CardContent className="p-12 text-center">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<h3 className="text-lg font-semibold text-foreground mb-2">No Agents Deployed</h3>
<h3 className="text-lg font-semibold text-foreground mb-2">
No Agents Deployed
</h3>
<p className="text-sm text-muted-foreground mb-6">
Start by deploying your first AI agent to the cluster.
</p>
@@ -173,98 +224,144 @@ export default function Agents() {
) : (
<div className="space-y-6">
{/* Orchestrator Section */}
{agents.filter((a: any) => a.isOrchestrator).map((agent: any) => {
const temperature = typeof agent.temperature === "string" ? parseFloat(agent.temperature) : (agent.temperature ?? 0.7);
return (
<motion.div key={agent.id} initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
<Card className="bg-card border-cyan-500/40 hover:border-cyan-500/70 transition-all glow-cyan">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-cyan-500/15 border border-cyan-500/40 flex items-center justify-center">
<Crown className="w-6 h-6 text-cyan-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-base font-bold text-foreground">{agent.name}</h3>
<Badge className="text-[9px] font-mono bg-cyan-500/20 text-cyan-300 border-cyan-500/40 px-1.5 py-0">
<Crown className="w-2.5 h-2.5 mr-1" /> ORCHESTRATOR
</Badge>
<Badge className="text-[9px] font-mono bg-amber-500/20 text-amber-300 border-amber-500/40 px-1.5 py-0">
<Shield className="w-2.5 h-2.5 mr-1" /> SYSTEM
</Badge>
{agents
.filter((a: any) => a.isOrchestrator)
.map((agent: any) => {
const temperature =
typeof agent.temperature === "string"
? parseFloat(agent.temperature)
: (agent.temperature ?? 0.7);
return (
<motion.div
key={agent.id}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
>
<Card className="bg-card border-cyan-500/40 hover:border-cyan-500/70 transition-all glow-cyan">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-cyan-500/15 border border-cyan-500/40 flex items-center justify-center">
<Crown className="w-6 h-6 text-cyan-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-base font-bold text-foreground">
{agent.name}
</h3>
<Badge className="text-[9px] font-mono bg-cyan-500/20 text-cyan-300 border-cyan-500/40 px-1.5 py-0">
<Crown className="w-2.5 h-2.5 mr-1" />{" "}
ORCHESTRATOR
</Badge>
<Badge className="text-[9px] font-mono bg-amber-500/20 text-amber-300 border-amber-500/40 px-1.5 py-0">
<Shield className="w-2.5 h-2.5 mr-1" /> SYSTEM
</Badge>
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">
{agent.description ||
"Main orchestrator — controls all agents, tools and system resources"}
</p>
</div>
</div>
<Badge
variant="outline"
className="text-[10px] font-mono bg-neon-green/15 text-neon-green border-neon-green/30"
>
<span className="w-1.5 h-1.5 rounded-full bg-neon-green mr-1.5 pulse-indicator" />
ACTIVE
</Badge>
</div>
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="p-2.5 rounded-md bg-cyan-500/10 border border-cyan-500/20">
<div className="text-[10px] text-muted-foreground font-mono mb-1">
MODEL
</div>
<div className="text-xs font-mono font-bold text-cyan-400">
{agent.model}
</div>
<div className="text-[10px] font-mono text-muted-foreground">
{agent.provider}
</div>
</div>
<div className="p-2.5 rounded-md bg-secondary/30 border border-border/20">
<div className="text-[10px] text-muted-foreground font-mono mb-1">
TEMPERATURE
</div>
<div className="text-xs font-mono font-bold text-foreground">
{temperature.toFixed(2)}
</div>
<div className="text-[10px] font-mono text-muted-foreground">
Tokens: {agent.maxTokens}
</div>
</div>
<div className="p-2.5 rounded-md bg-secondary/30 border border-border/20">
<div className="text-[10px] text-muted-foreground font-mono mb-1">
TOOLS
</div>
<div className="text-xs font-mono font-bold text-foreground">
{agent.allowedTools?.length ?? 0} tools
</div>
<div className="text-[10px] font-mono text-muted-foreground">
Full access
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">{agent.description || "Main orchestrator — controls all agents, tools and system resources"}</p>
</div>
</div>
<Badge variant="outline" className="text-[10px] font-mono bg-neon-green/15 text-neon-green border-neon-green/30">
<span className="w-1.5 h-1.5 rounded-full bg-neon-green mr-1.5 pulse-indicator" />
ACTIVE
</Badge>
</div>
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="p-2.5 rounded-md bg-cyan-500/10 border border-cyan-500/20">
<div className="text-[10px] text-muted-foreground font-mono mb-1">MODEL</div>
<div className="text-xs font-mono font-bold text-cyan-400">{agent.model}</div>
<div className="text-[10px] font-mono text-muted-foreground">{agent.provider}</div>
</div>
<div className="p-2.5 rounded-md bg-secondary/30 border border-border/20">
<div className="text-[10px] text-muted-foreground font-mono mb-1">TEMPERATURE</div>
<div className="text-xs font-mono font-bold text-foreground">{temperature.toFixed(2)}</div>
<div className="text-[10px] font-mono text-muted-foreground">Tokens: {agent.maxTokens}</div>
</div>
<div className="p-2.5 rounded-md bg-secondary/30 border border-border/20">
<div className="text-[10px] text-muted-foreground font-mono mb-1">TOOLS</div>
<div className="text-xs font-mono font-bold text-foreground">
{agent.allowedTools?.length ?? 0} tools
{agent.allowedTools && agent.allowedTools.length > 0 && (
<div className="mb-4">
<div className="flex flex-wrap gap-1.5">
{agent.allowedTools.map((tool: string) => (
<span
key={tool}
className="px-2 py-0.5 rounded text-[10px] font-mono bg-cyan-500/10 text-cyan-400 border border-cyan-500/20"
>
{tool}
</span>
))}
</div>
</div>
<div className="text-[10px] font-mono text-muted-foreground">Full access</div>
</div>
</div>
)}
{agent.allowedTools && agent.allowedTools.length > 0 && (
<div className="mb-4">
<div className="flex flex-wrap gap-1.5">
{agent.allowedTools.map((tool: string) => (
<span key={tool} className="px-2 py-0.5 rounded text-[10px] font-mono bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">
{tool}
</span>
))}
</div>
<div className="flex items-center gap-2 pt-3 border-t border-cyan-500/20">
<Button
size="sm"
className="h-7 text-[11px] bg-cyan-500/15 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/25"
onClick={e => {
e.stopPropagation();
navigate("/chat");
}}
>
<MessageSquare className="w-3 h-3 mr-1" /> Open Chat
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-primary border-primary/30 hover:bg-primary/10"
onClick={e => {
e.stopPropagation();
handleEditAgent(agent);
}}
>
Configure
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-neon-amber border-neon-amber/30 hover:bg-neon-amber/10"
onClick={e => {
e.stopPropagation();
navigate(`/agents/${agent.id}/metrics`);
}}
>
<BarChart2 className="w-3 h-3 mr-1" /> Metrics
</Button>
</div>
)}
<div className="flex items-center gap-2 pt-3 border-t border-cyan-500/20">
<Button
size="sm"
className="h-7 text-[11px] bg-cyan-500/15 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/25"
onClick={(e) => { e.stopPropagation(); navigate("/chat"); }}
>
<MessageSquare className="w-3 h-3 mr-1" /> Open Chat
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-primary border-primary/30 hover:bg-primary/10"
onClick={(e) => { e.stopPropagation(); handleEditAgent(agent); }}
>
Configure
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-neon-amber border-neon-amber/30 hover:bg-neon-amber/10"
onClick={(e) => { e.stopPropagation(); navigate(`/agents/${agent.id}/metrics`); }}
>
<BarChart2 className="w-3 h-3 mr-1" /> Metrics
</Button>
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</CardContent>
</Card>
</motion.div>
);
})}
{/* Specialized Agents Section */}
{agents.filter((a: any) => !a.isOrchestrator).length > 0 && (
@@ -273,126 +370,226 @@ export default function Agents() {
<Bot className="w-4 h-4" /> SPECIALIZED AGENTS
</h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{agents.filter((a: any) => !a.isOrchestrator).map((agent: any, i: number) => {
const sc = getStatusConfig(agent.status || "idle");
const Icon = ROLE_ICONS[agent.role] || Bot;
const temperature = typeof agent.temperature === "string" ? parseFloat(agent.temperature) : (agent.temperature ?? 0.7);
{agents
.filter((a: any) => !a.isOrchestrator)
.map((agent: any, i: number) => {
const sc = getStatusConfig(agent.status || "idle");
const csc = getContainerStatusConfig(agent.containerStatus);
const ContainerIcon = csc.icon;
const Icon = ROLE_ICONS[agent.role] || Bot;
const temperature =
typeof agent.temperature === "string"
? parseFloat(agent.temperature)
: (agent.temperature ?? 0.7);
const isDeploying = deployingAgentId === agent.id;
const isStopping = stoppingAgentId === agent.id;
return (
<motion.div
key={agent.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.08 }}
>
<Card className={`bg-card border-border/50 hover:border-primary/30 transition-all cursor-pointer ${sc.glow}`} onClick={() => handleEditAgent(agent)}>
<CardContent className="p-5">
{/* Top row */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-secondary/50 border border-border/50 flex items-center justify-center">
<Icon className={`w-5 h-5 ${sc.color}`} />
</div>
<div>
<div className="flex items-center gap-1.5">
<h3 className="text-sm font-semibold text-foreground">{agent.name}</h3>
{agent.isSystem && (
<Badge className="text-[9px] font-mono bg-amber-500/15 text-amber-400 border-amber-500/30 px-1 py-0">
SYS
</Badge>
)}
return (
<motion.div
key={agent.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.08 }}
>
<Card
className={`bg-card border-border/50 hover:border-primary/30 transition-all cursor-pointer ${sc.glow}`}
onClick={() => handleEditAgent(agent)}
>
<CardContent className="p-5">
{/* Top row */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-secondary/50 border border-border/50 flex items-center justify-center">
<Icon className={`w-5 h-5 ${sc.color}`} />
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">{agent.description || "No description"}</p>
<div>
<div className="flex items-center gap-1.5">
<h3 className="text-sm font-semibold text-foreground">
{agent.name}
</h3>
{agent.isSystem && (
<Badge className="text-[9px] font-mono bg-amber-500/15 text-amber-400 border-amber-500/30 px-1 py-0">
SYS
</Badge>
)}
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">
{agent.description || "No description"}
</p>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<Badge
variant="outline"
className={`text-[10px] font-mono ${sc.badge}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${sc.bg} mr-1.5 ${agent.isActive ? "pulse-indicator" : ""}`}
/>
{agent.isActive ? "ACTIVE" : "INACTIVE"}
</Badge>
<Badge
variant="outline"
className={`text-[9px] font-mono ${csc.bg} ${csc.color} ${csc.border}`}
>
<ContainerIcon className={`w-2.5 h-2.5 mr-1 ${isDeploying || isStopping ? "animate-spin" : ""}`} />
{csc.label}
</Badge>
</div>
</div>
<Badge variant="outline" className={`text-[10px] font-mono ${sc.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${sc.bg} mr-1.5 ${agent.isActive ? "pulse-indicator" : ""}`} />
{agent.isActive ? "ACTIVE" : "INACTIVE"}
</Badge>
</div>
{/* Model & Config */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="p-2.5 rounded-md bg-secondary/30 border border-border/20">
<div className="flex items-center gap-1.5 mb-1">
<Brain className="w-3 h-3 text-primary" />
<span className="text-[10px] text-muted-foreground font-mono">MODEL</span>
</div>
<div className="text-xs font-mono font-medium text-primary">{agent.model}</div>
<div className="text-[10px] font-mono text-muted-foreground">{agent.provider}</div>
</div>
<div className="p-2.5 rounded-md bg-secondary/30 border border-border/20">
<div className="flex items-center gap-1.5 mb-1">
<Cpu className="w-3 h-3 text-primary" />
<span className="text-[10px] text-muted-foreground font-mono">CONFIG</span>
</div>
<div className="text-xs font-mono font-medium text-foreground">T: {temperature.toFixed(2)}</div>
<div className="text-[10px] font-mono text-muted-foreground">Tokens: {agent.maxTokens}</div>
</div>
</div>
{/* Role & Date */}
<div className="flex items-center gap-4 mb-4 text-[10px] font-mono">
<div className="flex items-center gap-1">
<Zap className="w-3 h-3 text-neon-amber" />
<span className="text-muted-foreground">Role:</span>
<span className="text-foreground font-medium capitalize">{agent.role}</span>
</div>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3 text-primary" />
<span className="text-muted-foreground">Created:</span>
<span className="text-foreground font-medium">{new Date(agent.createdAt).toLocaleDateString()}</span>
</div>
</div>
{/* Tools */}
{agent.allowedTools && agent.allowedTools.length > 0 && (
<div className="mb-4">
<span className="text-[10px] text-muted-foreground font-mono block mb-1.5">TOOLS</span>
<div className="flex flex-wrap gap-1.5">
{agent.allowedTools.map((tool: string) => (
<span key={tool} className="px-2 py-0.5 rounded text-[10px] font-mono bg-primary/10 text-primary border border-primary/20">
{tool}
{/* Model & Config */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="p-2.5 rounded-md bg-secondary/30 border border-border/20">
<div className="flex items-center gap-1.5 mb-1">
<Brain className="w-3 h-3 text-primary" />
<span className="text-[10px] text-muted-foreground font-mono">
MODEL
</span>
))}
</div>
<div className="text-xs font-mono font-medium text-primary">
{agent.model}
</div>
<div className="text-[10px] font-mono text-muted-foreground">
{agent.provider}
</div>
</div>
<div className="p-2.5 rounded-md bg-secondary/30 border border-border/20">
<div className="flex items-center gap-1.5 mb-1">
<Cpu className="w-3 h-3 text-primary" />
<span className="text-[10px] text-muted-foreground font-mono">
CONFIG
</span>
</div>
<div className="text-xs font-mono font-medium text-foreground">
T: {temperature.toFixed(2)}
</div>
<div className="text-[10px] font-mono text-muted-foreground">
Tokens: {agent.maxTokens}
</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-3 border-t border-border/30">
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-primary border-primary/30 hover:bg-primary/10"
onClick={(e) => { e.stopPropagation(); handleEditAgent(agent); }}
>
Edit
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-neon-amber border-neon-amber/30 hover:bg-neon-amber/10"
onClick={(e) => { e.stopPropagation(); navigate(`/agents/${agent.id}/metrics`); }}
>
<BarChart2 className="w-3 h-3 mr-1" /> Metrics
</Button>
{!agent.isSystem && (
{/* Role & Date */}
<div className="flex items-center gap-4 mb-4 text-[10px] font-mono">
<div className="flex items-center gap-1">
<Zap className="w-3 h-3 text-neon-amber" />
<span className="text-muted-foreground">
Role:
</span>
<span className="text-foreground font-medium capitalize">
{agent.role}
</span>
</div>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3 text-primary" />
<span className="text-muted-foreground">
Created:
</span>
<span className="text-foreground font-medium">
{new Date(
agent.createdAt
).toLocaleDateString()}
</span>
</div>
</div>
{/* Tools */}
{agent.allowedTools &&
agent.allowedTools.length > 0 && (
<div className="mb-4">
<span className="text-[10px] text-muted-foreground font-mono block mb-1.5">
TOOLS
</span>
<div className="flex flex-wrap gap-1.5">
{agent.allowedTools.map((tool: string) => (
<span
key={tool}
className="px-2 py-0.5 rounded text-[10px] font-mono bg-primary/10 text-primary border border-primary/20"
>
{tool}
</span>
))}
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-3 border-t border-border/30">
{agent.containerStatus === "running" ? (
<Button
size="sm"
className="h-7 text-[11px] bg-neon-red/15 text-neon-red border border-neon-red/30 hover:bg-neon-red/25"
disabled={isStopping}
onClick={e => {
e.stopPropagation();
setStoppingAgentId(agent.id);
stopMutation.mutate({ agentId: agent.id }, {
onSettled: () => setStoppingAgentId(null),
});
}}
>
{isStopping ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <Square className="w-3 h-3 mr-1" />}
Stop
</Button>
) : (
<Button
size="sm"
className="h-7 text-[11px] bg-neon-green/15 text-neon-green border border-neon-green/30 hover:bg-neon-green/25"
disabled={isDeploying || agent.containerStatus === "deploying"}
onClick={e => {
e.stopPropagation();
setDeployingAgentId(agent.id);
deployMutation.mutate({ agentId: agent.id }, {
onSettled: () => setDeployingAgentId(null),
});
}}
>
{isDeploying || agent.containerStatus === "deploying" ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <Play className="w-3 h-3 mr-1" />}
{isDeploying || agent.containerStatus === "deploying" ? "Deploying" : "Deploy"}
</Button>
)}
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-neon-red border-neon-red/30 hover:bg-neon-red/10 ml-auto"
onClick={(e) => { e.stopPropagation(); handleDeleteClick(agent.id); }}
className="h-7 text-[11px] text-primary border-primary/30 hover:bg-primary/10"
onClick={e => {
e.stopPropagation();
handleEditAgent(agent);
}}
>
<Trash2 className="w-3 h-3 mr-1" /> Delete
Edit
</Button>
)}
</div>
</CardContent>
</Card>
</motion.div>
);
})}
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-neon-amber border-neon-amber/30 hover:bg-neon-amber/10"
onClick={e => {
e.stopPropagation();
navigate(`/agents/${agent.id}/metrics`);
}}
>
<BarChart2 className="w-3 h-3 mr-1" /> Metrics
</Button>
{!agent.isSystem && (
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-neon-red border-neon-red/30 hover:bg-neon-red/10 ml-auto"
onClick={e => {
e.stopPropagation();
handleDeleteClick(agent.id);
}}
>
<Trash2 className="w-3 h-3 mr-1" /> Delete
</Button>
)}
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</div>
</div>
)}
@@ -419,7 +616,8 @@ export default function Agents() {
<AlertDialogHeader>
<AlertDialogTitle>Delete Agent</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this agent? This action cannot be undone.
Are you sure you want to delete this agent? This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-3">

View File

@@ -60,6 +60,9 @@ function useChatStore() {
activeId: chatStore.getActiveId(),
active: chatStore.getActive(),
isThinking: chatStore.getIsThinking(),
isThinkingConvId: chatStore.getThinkingConvId(),
isConversationThinking: (convId: string) =>
chatStore.getIsConversationThinking(convId),
activeAgents: chatStore.getActiveAgents(),
};
}
@@ -509,11 +512,13 @@ function ConversationItem({
isActive,
onClick,
onDelete,
isThinking,
}: {
conv: Conversation;
isActive: boolean;
onClick: () => void;
onDelete: () => void;
isThinking?: boolean;
}) {
const lastMsg = conv.messages[conv.messages.length - 1];
const lastContent =
@@ -530,12 +535,19 @@ function ConversationItem({
className={`group flex items-start gap-1.5 px-2 py-1.5 rounded cursor-pointer transition-colors ${
isActive
? "bg-[#00D4FF]/10 border border-[#00D4FF]/20"
: "hover:bg-secondary/30 border border-transparent"
: isThinking
? "bg-[#FFB800]/5 border border-[#FFB800]/20"
: "hover:bg-secondary/30 border border-transparent"
}`}
>
<MessageSquare
className={`w-3 h-3 mt-0.5 shrink-0 ${isActive ? "text-[#00D4FF]" : "text-muted-foreground/40"}`}
/>
<div className="relative">
<MessageSquare
className={`w-3 h-3 mt-0.5 shrink-0 ${isActive ? "text-[#00D4FF]" : isThinking ? "text-[#FFB800]" : "text-muted-foreground/40"}`}
/>
{isThinking && (
<span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-[#FFB800] animate-pulse" />
)}
</div>
<div className="flex-1 min-w-0">
<p
className={`text-[10px] font-mono truncate ${isActive ? "text-foreground" : "text-foreground/70"}`}
@@ -575,8 +587,15 @@ function ConversationItem({
// ─── Main Chat Component ──────────────────────────────────────────────────────
export default function Chat() {
const { conversations, activeId, active, isThinking, activeAgents } =
useChatStore();
const {
conversations,
activeId,
active,
isThinking,
isThinkingConvId,
isConversationThinking,
activeAgents,
} = useChatStore();
const [input, setInput] = useState("");
const [activeTab, setActiveTab] = useState<SidebarTab>("console");
const scrollRef = useRef<HTMLDivElement>(null);
@@ -598,11 +617,21 @@ export default function Chat() {
}
}, [orchestratorConfigQuery.data, conversations.length]);
// Auto-scroll
// Auto-scroll to bottom when messages change
useEffect(() => {
if (scrollRef.current)
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [active?.messages]);
const el = scrollRef.current;
if (!el) return;
const scrollToBottom = () => {
el.scrollTop = el.scrollHeight;
};
requestAnimationFrame(scrollToBottom);
const t1 = setTimeout(scrollToBottom, 50);
const t2 = setTimeout(scrollToBottom, 200);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}, [active?.messages, isCurrentConvThinking, activeAgents]);
// Auto-resize textarea
const adjustTextareaHeight = useCallback(() => {
@@ -718,15 +747,19 @@ export default function Chat() {
activeId,
]);
const isCurrentConvThinking = activeId
? isConversationThinking(activeId)
: false;
const sendMessage = async () => {
if (!input.trim() || isThinking) return;
if (!input.trim() || isCurrentConvThinking) return;
const { convId: cid, newHistory } = chatStore.addUserMessage(
input.trim(),
convId
);
setInput("");
chatStore.setThinking(true);
chatStore.setThinking(true, cid);
setActiveTab("console");
const thinkingId = chatStore.addThinkingMessage(cid);
@@ -877,6 +910,7 @@ export default function Chat() {
key={c.id}
conv={c}
isActive={c.id === activeId}
isThinking={isConversationThinking(c.id)}
onClick={() => chatStore.setActiveId(c.id)}
onDelete={() => chatStore.deleteConversation(c.id)}
/>
@@ -888,14 +922,14 @@ export default function Chat() {
{/* ─── Center — Chat Area ─── */}
<Card className="flex-1 bg-card border-border/50 overflow-hidden min-w-0">
<CardContent className="p-0 h-full flex flex-col">
<ScrollArea className="flex-1">
<div ref={scrollRef} className="p-4 space-y-4">
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<div className="p-4 space-y-4">
<AnimatePresence initial={false}>
{messages.map(msg => (
<MessageBubble key={msg.id} msg={msg} />
))}
</AnimatePresence>
{isThinking && (
{isCurrentConvThinking && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -914,7 +948,7 @@ export default function Chat() {
</motion.div>
)}
</div>
</ScrollArea>
</div>
{/* Input area */}
<div className="border-t border-border/50 p-3 bg-secondary/10 shrink-0">
@@ -945,21 +979,21 @@ export default function Chat() {
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
isThinking
isCurrentConvThinking
? "Ожидание ответа..."
: "Введите команду... (Shift+Enter для новой строки)"
}
disabled={isThinking}
disabled={isCurrentConvThinking}
rows={1}
className="flex-1 bg-transparent text-foreground font-mono text-sm placeholder:text-muted-foreground/50 focus:outline-none resize-none min-h-[32px] max-h-[150px] py-1.5"
/>
<Button
size="sm"
onClick={sendMessage}
disabled={isThinking || !input.trim()}
disabled={isCurrentConvThinking || !input.trim()}
className="bg-[#00D4FF]/15 text-[#00D4FF] border border-[#00D4FF]/30 hover:bg-[#00D4FF]/25 h-8 w-8 p-0 shrink-0"
>
{isThinking ? (
{isCurrentConvThinking ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Send className="w-3.5 h-3.5" />

View File

@@ -0,0 +1,45 @@
# ─── Stage 1: Build ────────────────────────────────────────────────────────────
# Собираем agent-worker binary из исходников gateway/
FROM golang:1.23-alpine AS builder
WORKDIR /build
# Кэшируем зависимости отдельным слоем
COPY gateway/go.mod gateway/go.sum ./
RUN go mod download
# Копируем исходники
COPY gateway/ ./
# Собираем статически линкованный бинарь
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" \
-o agent-worker \
./cmd/agent-worker
# ─── Stage 2: Runtime ──────────────────────────────────────────────────────────
# Минимальный образ: только бинарь + CA certs (для HTTPS к LLM API)
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /build/agent-worker /app/agent-worker
# Порт HTTP API агента (переопределяется через AGENT_PORT env)
EXPOSE 8001
# ── Healthcheck ──────────────────────────────────────────────────────────────
# Docker/Swarm будет проверять /health каждые 15 секунд
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:${AGENT_PORT:-8001}/health || exit 1
# Required env vars (подставляются при деплое Swarm service):
# AGENT_ID — числовой ID агента из таблицы agents
# DATABASE_URL — mysql://user:pass@host:3306/goclaw
# LLM_BASE_URL — https://ollama.com/v1 или http://ollama:11434/v1
# LLM_API_KEY — ключ LLM провайдера
# AGENT_PORT — порт HTTP (default: 8001)
ENTRYPOINT ["/app/agent-worker"]

View File

@@ -0,0 +1,12 @@
-- Migration: 0006_agent_container_fields
-- Add Docker Swarm container tracking fields to agents table.
-- Each agent can now be deployed as an autonomous Swarm service.
ALTER TABLE `agents`
ADD COLUMN `serviceName` VARCHAR(100) NULL COMMENT 'Docker Swarm service name: goclaw-agent-{id}',
ADD COLUMN `servicePort` INT NULL COMMENT 'HTTP API port inside overlay network (8001-8999)',
ADD COLUMN `containerImage` VARCHAR(255) NOT NULL DEFAULT 'goclaw-agent-worker:latest' COMMENT 'Docker image to run',
ADD COLUMN `containerStatus` ENUM('stopped','deploying','running','error') NOT NULL DEFAULT 'stopped' COMMENT 'Current container lifecycle state';
-- Index for quick lookup of running agents
CREATE INDEX `agents_containerStatus_idx` ON `agents` (`containerStatus`);

View File

@@ -1,4 +1,15 @@
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, decimal, json, boolean, index } from "drizzle-orm/mysql-core";
import {
int,
mysqlEnum,
mysqlTable,
text,
timestamp,
varchar,
decimal,
json,
boolean,
index,
} from "drizzle-orm/mysql-core";
/**
* Core user table backing auth flow.
@@ -28,48 +39,73 @@ export type InsertUser = typeof users.$inferInsert;
/**
* Agents — конфигурация и управление AI-агентами
*/
export const agents = mysqlTable("agents", {
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(), // Владелец агента
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
role: varchar("role", { length: 100 }).notNull(), // "developer", "researcher", "executor"
// Модель LLM
model: varchar("model", { length: 100 }).notNull(),
provider: varchar("provider", { length: 50 }).notNull(),
// Параметры LLM
temperature: decimal("temperature", { precision: 3, scale: 2 }).default("0.7"),
maxTokens: int("maxTokens").default(2048),
topP: decimal("topP", { precision: 3, scale: 2 }).default("1.0"),
frequencyPenalty: decimal("frequencyPenalty", { precision: 3, scale: 2 }).default("0.0"),
presencePenalty: decimal("presencePenalty", { precision: 3, scale: 2 }).default("0.0"),
// System Prompt
systemPrompt: text("systemPrompt"),
// Доступы и разрешения
allowedTools: json("allowedTools").$type<string[]>().default([]),
allowedDomains: json("allowedDomains").$type<string[]>().default([]),
maxRequestsPerHour: int("maxRequestsPerHour").default(100),
// Статус
isActive: boolean("isActive").default(true),
isPublic: boolean("isPublic").default(false),
isSystem: boolean("isSystem").default(false), // Системный агент (нельзя удалить)
isOrchestrator: boolean("isOrchestrator").default(false), // Главный оркестратор чата
// Метаданные
tags: json("tags").$type<string[]>().default([]),
metadata: json("metadata").$type<Record<string, any>>().default({}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
userIdIdx: index("agents_userId_idx").on(table.userId),
modelIdx: index("agents_model_idx").on(table.model),
}));
export const agents = mysqlTable(
"agents",
{
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(), // Владелец агента
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
role: varchar("role", { length: 100 }).notNull(), // "developer", "researcher", "executor"
// Модель LLM
model: varchar("model", { length: 100 }).notNull(),
provider: varchar("provider", { length: 50 }).notNull(),
// Параметры LLM
temperature: decimal("temperature", { precision: 3, scale: 2 }).default(
"0.7"
),
maxTokens: int("maxTokens").default(2048),
topP: decimal("topP", { precision: 3, scale: 2 }).default("1.0"),
frequencyPenalty: decimal("frequencyPenalty", {
precision: 3,
scale: 2,
}).default("0.0"),
presencePenalty: decimal("presencePenalty", {
precision: 3,
scale: 2,
}).default("0.0"),
// System Prompt
systemPrompt: text("systemPrompt"),
// Доступы и разрешения
allowedTools: json("allowedTools").$type<string[]>().default([]),
allowedDomains: json("allowedDomains").$type<string[]>().default([]),
maxRequestsPerHour: int("maxRequestsPerHour").default(100),
// Статус
isActive: boolean("isActive").default(true),
isPublic: boolean("isPublic").default(false),
isSystem: boolean("isSystem").default(false), // Системный агент (нельзя удалить)
isOrchestrator: boolean("isOrchestrator").default(false), // Главный оркестратор чата
// Docker Swarm / Container fields (Phase A)
serviceName: varchar("serviceName", { length: 100 }), // Docker Swarm service name: goclaw-agent-{id}
servicePort: int("servicePort"), // HTTP API port inside overlay network (8001-8999)
containerImage: varchar("containerImage", { length: 255 }).default(
"goclaw-agent-worker:latest"
), // Docker image to run
containerStatus: mysqlEnum("containerStatus", [
"stopped",
"deploying",
"running",
"error",
]).default("stopped"), // Container lifecycle state
// Метаданные
tags: json("tags").$type<string[]>().default([]),
metadata: json("metadata").$type<Record<string, any>>().default({}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
},
table => ({
userIdIdx: index("agents_userId_idx").on(table.userId),
modelIdx: index("agents_model_idx").on(table.model),
})
);
export type Agent = typeof agents.$inferSelect;
export type InsertAgent = typeof agents.$inferInsert;
@@ -77,39 +113,48 @@ export type InsertAgent = typeof agents.$inferInsert;
/**
* Agent Metrics — метрики производительности агентов
*/
export const agentMetrics = mysqlTable("agentMetrics", {
id: int("id").autoincrement().primaryKey(),
agentId: int("agentId").notNull(),
// Информация о запросе
requestId: varchar("requestId", { length: 64 }).notNull().unique(),
userMessage: text("userMessage"),
agentResponse: text("agentResponse"),
// Токены
inputTokens: int("inputTokens").default(0),
outputTokens: int("outputTokens").default(0),
totalTokens: int("totalTokens").default(0),
// Время обработки
processingTimeMs: int("processingTimeMs").notNull(),
// Статус
status: mysqlEnum("status", ["success", "error", "timeout", "rate_limited"]).notNull(),
errorMessage: text("errorMessage"),
// Инструменты
toolsCalled: json("toolsCalled").$type<string[]>().default([]),
// Модель
model: varchar("model", { length: 100 }),
temperature: decimal("temperature", { precision: 3, scale: 2 }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
}, (table) => ({
agentIdIdx: index("agentMetrics_agentId_idx").on(table.agentId),
createdAtIdx: index("agentMetrics_createdAt_idx").on(table.createdAt),
}));
export const agentMetrics = mysqlTable(
"agentMetrics",
{
id: int("id").autoincrement().primaryKey(),
agentId: int("agentId").notNull(),
// Информация о запросе
requestId: varchar("requestId", { length: 64 }).notNull().unique(),
userMessage: text("userMessage"),
agentResponse: text("agentResponse"),
// Токены
inputTokens: int("inputTokens").default(0),
outputTokens: int("outputTokens").default(0),
totalTokens: int("totalTokens").default(0),
// Время обработки
processingTimeMs: int("processingTimeMs").notNull(),
// Статус
status: mysqlEnum("status", [
"success",
"error",
"timeout",
"rate_limited",
]).notNull(),
errorMessage: text("errorMessage"),
// Инструменты
toolsCalled: json("toolsCalled").$type<string[]>().default([]),
// Модель
model: varchar("model", { length: 100 }),
temperature: decimal("temperature", { precision: 3, scale: 2 }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
},
table => ({
agentIdIdx: index("agentMetrics_agentId_idx").on(table.agentId),
createdAtIdx: index("agentMetrics_createdAt_idx").on(table.createdAt),
})
);
export type AgentMetric = typeof agentMetrics.$inferSelect;
export type InsertAgentMetric = typeof agentMetrics.$inferInsert;
@@ -117,22 +162,28 @@ export type InsertAgentMetric = typeof agentMetrics.$inferInsert;
/**
* Agent History — полная история запросов
*/
export const agentHistory = mysqlTable("agentHistory", {
id: int("id").autoincrement().primaryKey(),
agentId: int("agentId").notNull(),
userMessage: text("userMessage").notNull(),
agentResponse: text("agentResponse"),
conversationId: varchar("conversationId", { length: 64 }),
messageIndex: int("messageIndex"),
status: mysqlEnum("status", ["pending", "success", "error"]).default("pending"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
}, (table) => ({
agentIdIdx: index("agentHistory_agentId_idx").on(table.agentId),
}));
export const agentHistory = mysqlTable(
"agentHistory",
{
id: int("id").autoincrement().primaryKey(),
agentId: int("agentId").notNull(),
userMessage: text("userMessage").notNull(),
agentResponse: text("agentResponse"),
conversationId: varchar("conversationId", { length: 64 }),
messageIndex: int("messageIndex"),
status: mysqlEnum("status", ["pending", "success", "error"]).default(
"pending"
),
createdAt: timestamp("createdAt").defaultNow().notNull(),
},
table => ({
agentIdIdx: index("agentHistory_agentId_idx").on(table.agentId),
})
);
export type AgentHistory = typeof agentHistory.$inferSelect;
export type InsertAgentHistory = typeof agentHistory.$inferInsert;
@@ -140,25 +191,32 @@ export type InsertAgentHistory = typeof agentHistory.$inferInsert;
/**
* Agent Access Control — управление доступами
*/
export const agentAccessControl = mysqlTable("agentAccessControl", {
id: int("id").autoincrement().primaryKey(),
agentId: int("agentId").notNull(),
tool: varchar("tool", { length: 50 }).notNull(),
isAllowed: boolean("isAllowed").default(true),
maxExecutionsPerHour: int("maxExecutionsPerHour").default(100),
timeoutSeconds: int("timeoutSeconds").default(30),
allowedPatterns: json("allowedPatterns").$type<string[]>().default([]),
blockedPatterns: json("blockedPatterns").$type<string[]>().default([]),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
agentIdToolIdx: index("agentAccessControl_agentId_tool_idx").on(table.agentId, table.tool),
}));
export const agentAccessControl = mysqlTable(
"agentAccessControl",
{
id: int("id").autoincrement().primaryKey(),
agentId: int("agentId").notNull(),
tool: varchar("tool", { length: 50 }).notNull(),
isAllowed: boolean("isAllowed").default(true),
maxExecutionsPerHour: int("maxExecutionsPerHour").default(100),
timeoutSeconds: int("timeoutSeconds").default(30),
allowedPatterns: json("allowedPatterns").$type<string[]>().default([]),
blockedPatterns: json("blockedPatterns").$type<string[]>().default([]),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
},
table => ({
agentIdToolIdx: index("agentAccessControl_agentId_tool_idx").on(
table.agentId,
table.tool
),
})
);
export type AgentAccessControl = typeof agentAccessControl.$inferSelect;
export type InsertAgentAccessControl = typeof agentAccessControl.$inferInsert;
@@ -172,7 +230,10 @@ export const toolDefinitions = mysqlTable("toolDefinitions", {
description: text("description").notNull(),
category: varchar("category", { length: 50 }).notNull().default("custom"),
dangerous: boolean("dangerous").default(false),
parameters: json("parameters").$type<Record<string, { type: string; description: string; required?: boolean }>>(),
parameters:
json("parameters").$type<
Record<string, { type: string; description: string; required?: boolean }>
>(),
implementation: text("implementation").notNull(), // JS код функции
isActive: boolean("isActive").default(true),
createdBy: int("createdBy"), // agentId или null
@@ -192,7 +253,9 @@ export const browserSessions = mysqlTable("browserSessions", {
agentId: int("agentId").notNull(),
currentUrl: text("currentUrl"),
title: text("title"),
status: mysqlEnum("status", ["active", "idle", "closed", "error"]).default("idle"),
status: mysqlEnum("status", ["active", "idle", "closed", "error"]).default(
"idle"
),
screenshotUrl: text("screenshotUrl"), // S3 URL последнего скриншота
lastActionAt: timestamp("lastActionAt").defaultNow(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
@@ -205,19 +268,29 @@ export type InsertBrowserSession = typeof browserSessions.$inferInsert;
/**
* Node Metrics — исторические метрики Docker-контейнеров/нод (сохраняется каждые 30s)
*/
export const nodeMetrics = mysqlTable("nodeMetrics", {
id: int("id").autoincrement().primaryKey(),
containerId: varchar("containerId", { length: 64 }).notNull(),
containerName: varchar("containerName", { length: 255 }).notNull(),
cpuPercent: decimal("cpuPercent", { precision: 6, scale: 2 }).notNull().default("0.00"),
memUsedMb: decimal("memUsedMb", { precision: 10, scale: 2 }).notNull().default("0.00"),
memLimitMb: decimal("memLimitMb", { precision: 10, scale: 2 }).notNull().default("0.00"),
status: varchar("status", { length: 32 }).notNull().default("running"),
recordedAt: timestamp("recordedAt").defaultNow().notNull(),
}, (table) => ({
containerIdIdx: index("nodeMetrics_containerId_idx").on(table.containerId),
recordedAtIdx: index("nodeMetrics_recordedAt_idx").on(table.recordedAt),
}));
export const nodeMetrics = mysqlTable(
"nodeMetrics",
{
id: int("id").autoincrement().primaryKey(),
containerId: varchar("containerId", { length: 64 }).notNull(),
containerName: varchar("containerName", { length: 255 }).notNull(),
cpuPercent: decimal("cpuPercent", { precision: 6, scale: 2 })
.notNull()
.default("0.00"),
memUsedMb: decimal("memUsedMb", { precision: 10, scale: 2 })
.notNull()
.default("0.00"),
memLimitMb: decimal("memLimitMb", { precision: 10, scale: 2 })
.notNull()
.default("0.00"),
status: varchar("status", { length: 32 }).notNull().default("running"),
recordedAt: timestamp("recordedAt").defaultNow().notNull(),
},
table => ({
containerIdIdx: index("nodeMetrics_containerId_idx").on(table.containerId),
recordedAtIdx: index("nodeMetrics_recordedAt_idx").on(table.recordedAt),
})
);
export type NodeMetric = typeof nodeMetrics.$inferSelect;
export type InsertNodeMetric = typeof nodeMetrics.$inferInsert;
@@ -225,32 +298,48 @@ export type InsertNodeMetric = typeof nodeMetrics.$inferInsert;
/**
* Tasks — задачи, создаваемые агентами для отслеживания работы
*/
export const tasks = mysqlTable("tasks", {
id: int("id").autoincrement().primaryKey(),
agentId: int("agentId").notNull(),
conversationId: varchar("conversationId", { length: 64 }),
title: varchar("title", { length: 255 }).notNull(),
description: text("description"),
status: mysqlEnum("status", ["pending", "in_progress", "completed", "failed", "blocked"]).default("pending").notNull(),
priority: mysqlEnum("priority", ["low", "medium", "high", "critical"]).default("medium").notNull(),
dependsOn: json("dependsOn").$type<number[]>().default([]),
result: text("result"),
errorMessage: text("errorMessage"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
startedAt: timestamp("startedAt"),
completedAt: timestamp("completedAt"),
metadata: json("metadata").$type<Record<string, any>>().default({}),
}, (table) => ({
agentIdIdx: index("tasks_agentId_idx").on(table.agentId),
statusIdx: index("tasks_status_idx").on(table.status),
conversationIdIdx: index("tasks_conversationId_idx").on(table.conversationId),
}));
export const tasks = mysqlTable(
"tasks",
{
id: int("id").autoincrement().primaryKey(),
agentId: int("agentId").notNull(),
conversationId: varchar("conversationId", { length: 64 }),
title: varchar("title", { length: 255 }).notNull(),
description: text("description"),
status: mysqlEnum("status", [
"pending",
"in_progress",
"completed",
"failed",
"blocked",
])
.default("pending")
.notNull(),
priority: mysqlEnum("priority", ["low", "medium", "high", "critical"])
.default("medium")
.notNull(),
dependsOn: json("dependsOn").$type<number[]>().default([]),
result: text("result"),
errorMessage: text("errorMessage"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
startedAt: timestamp("startedAt"),
completedAt: timestamp("completedAt"),
metadata: json("metadata").$type<Record<string, any>>().default({}),
},
table => ({
agentIdIdx: index("tasks_agentId_idx").on(table.agentId),
statusIdx: index("tasks_status_idx").on(table.status),
conversationIdIdx: index("tasks_conversationId_idx").on(
table.conversationId
),
})
);
export type Task = typeof tasks.$inferSelect;
export type InsertTask = typeof tasks.$inferInsert;

View File

@@ -0,0 +1,727 @@
// GoClaw Agent Worker — автономный HTTP-сервер агента.
//
// Каждый агент запускается как отдельный Docker Swarm service.
// Загружает свой конфиг из общей DB по AGENT_ID, выполняет LLM loop
// и принимает параллельные задачи от Orchestrator и других агентов.
//
// Endpoints:
//
// GET /health — liveness probe
// GET /info — конфиг агента (имя, модель, роль)
// POST /chat — синхронный чат (LLM loop, ждёт ответ)
// POST /task — поставить задачу в очередь (async, возвращает task_id)
// GET /tasks — список задач агента (active + recent)
// GET /tasks/{id} — статус конкретной задачи
// GET /memory — последние N сообщений из истории агента
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"sync"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/google/uuid"
"github.com/joho/godotenv"
"git.softuniq.eu/UniqAI/GoClaw/gateway/internal/db"
"git.softuniq.eu/UniqAI/GoClaw/gateway/internal/llm"
"git.softuniq.eu/UniqAI/GoClaw/gateway/internal/tools"
)
// ─── Task types ──────────────────────────────────────────────────────────────
type TaskStatus string
const (
TaskPending TaskStatus = "pending"
TaskRunning TaskStatus = "running"
TaskDone TaskStatus = "done"
TaskFailed TaskStatus = "failed"
TaskCancelled TaskStatus = "cancelled"
)
// Task — единица работы агента, принятая через /task.
type Task struct {
ID string `json:"id"`
FromAgentID int `json:"from_agent_id,omitempty"` // кто делегировал (0 = человек)
Input string `json:"input"` // текст задачи
CallbackURL string `json:"callback_url,omitempty"` // куда POST результат
Priority int `json:"priority"` // 0=normal, 1=high
TimeoutSecs int `json:"timeout_secs"`
Status TaskStatus `json:"status"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
ToolCalls []ToolCallStep `json:"tool_calls,omitempty"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
DoneAt *time.Time `json:"done_at,omitempty"`
}
// ToolCallStep — шаг вызова инструмента для отображения в UI.
type ToolCallStep struct {
Tool string `json:"tool"`
Args any `json:"args"`
Result any `json:"result,omitempty"`
Error string `json:"error,omitempty"`
Success bool `json:"success"`
DurationMs int64 `json:"duration_ms"`
}
// ChatMessage — сообщение в формате для /chat endpoint.
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// ChatRequest — запрос на /chat (синхронный).
type ChatRequest struct {
Messages []ChatMessage `json:"messages"`
Model string `json:"model,omitempty"` // override модели агента
MaxIter int `json:"max_iter,omitempty"` // override max iterations
}
// ChatResponse — ответ /chat.
type ChatResponse struct {
Success bool `json:"success"`
Response string `json:"response"`
ToolCalls []ToolCallStep `json:"tool_calls"`
Model string `json:"model"`
Error string `json:"error,omitempty"`
}
// TaskRequest — запрос на /task (async).
type TaskRequest struct {
Input string `json:"input"`
FromAgentID int `json:"from_agent_id,omitempty"`
CallbackURL string `json:"callback_url,omitempty"`
Priority int `json:"priority,omitempty"`
TimeoutSecs int `json:"timeout_secs,omitempty"`
}
// ─── Agent Worker ─────────────────────────────────────────────────────────────
type AgentWorker struct {
agentID int
cfg *db.AgentConfig
llm *llm.Client
database *db.DB
executor *tools.Executor
// Task queue — buffered channel
taskQueue chan *Task
// Task store — in-memory (id → Task)
tasksMu sync.RWMutex
tasks map[string]*Task
// Recent tasks ring buffer (для GET /tasks)
recentMu sync.Mutex
recentKeys []string
}
const (
taskQueueDepth = 100
maxRecentTasks = 50
defaultMaxIter = 8
defaultTimeout = 120
workerGoroutines = 4 // параллельных воркеров на агента
)
func newAgentWorker(agentID int, database *db.DB, llmClient *llm.Client) (*AgentWorker, error) {
cfg, err := database.GetAgentByID(agentID)
if err != nil {
return nil, fmt.Errorf("agent %d not found in DB: %w", agentID, err)
}
log.Printf("[AgentWorker] Loaded config: id=%d name=%q model=%s", cfg.ID, cfg.Name, cfg.Model)
w := &AgentWorker{
agentID: agentID,
cfg: cfg,
llm: llmClient,
database: database,
taskQueue: make(chan *Task, taskQueueDepth),
tasks: make(map[string]*Task),
}
// Tool executor: агент использует подмножество инструментов из allowedTools
w.executor = tools.NewExecutor("/app", func() ([]map[string]any, error) {
rows, err := database.ListAgents()
if err != nil {
return nil, err
}
result := make([]map[string]any, len(rows))
for i, r := range rows {
result[i] = map[string]any{
"id": r.ID, "name": r.Name, "role": r.Role,
"model": r.Model, "isActive": r.IsActive,
}
}
return result, nil
})
return w, nil
}
// StartWorkers запускает N горутин-воркеров, читающих из taskQueue.
func (w *AgentWorker) StartWorkers(ctx context.Context) {
for i := 0; i < workerGoroutines; i++ {
go w.runWorker(ctx, i)
}
log.Printf("[AgentWorker] %d worker goroutines started", workerGoroutines)
}
func (w *AgentWorker) runWorker(ctx context.Context, workerID int) {
for {
select {
case <-ctx.Done():
log.Printf("[Worker-%d] shutting down", workerID)
return
case task := <-w.taskQueue:
log.Printf("[Worker-%d] processing task %s", workerID, task.ID)
w.processTask(ctx, task)
}
}
}
// EnqueueTask добавляет задачу в очередь и в хранилище.
func (w *AgentWorker) EnqueueTask(req TaskRequest) *Task {
timeout := req.TimeoutSecs
if timeout <= 0 {
timeout = defaultTimeout
}
task := &Task{
ID: uuid.New().String(),
FromAgentID: req.FromAgentID,
Input: req.Input,
CallbackURL: req.CallbackURL,
Priority: req.Priority,
TimeoutSecs: timeout,
Status: TaskPending,
CreatedAt: time.Now(),
}
// Сохранить в store
w.tasksMu.Lock()
w.tasks[task.ID] = task
w.tasksMu.Unlock()
// Добавить в recent ring
w.recentMu.Lock()
w.recentKeys = append(w.recentKeys, task.ID)
if len(w.recentKeys) > maxRecentTasks {
w.recentKeys = w.recentKeys[len(w.recentKeys)-maxRecentTasks:]
}
w.recentMu.Unlock()
// Отправить в очередь (non-blocking — если очередь полна, вернуть ошибку через Status)
select {
case w.taskQueue <- task:
default:
w.tasksMu.Lock()
task.Status = TaskFailed
task.Error = "task queue is full — agent is overloaded"
w.tasksMu.Unlock()
log.Printf("[AgentWorker] WARN: task queue full, task %s rejected", task.ID)
}
return task
}
// processTask выполняет задачу через LLM loop и обновляет её статус.
func (w *AgentWorker) processTask(ctx context.Context, task *Task) {
now := time.Now()
w.tasksMu.Lock()
task.Status = TaskRunning
task.StartedAt = &now
w.tasksMu.Unlock()
// Выполняем чат
chatCtx, cancel := context.WithTimeout(ctx, time.Duration(task.TimeoutSecs)*time.Second)
defer cancel()
messages := []ChatMessage{{Role: "user", Content: task.Input}}
resp := w.runChat(chatCtx, messages, "", defaultMaxIter)
doneAt := time.Now()
w.tasksMu.Lock()
task.DoneAt = &doneAt
task.ToolCalls = resp.ToolCalls
if resp.Success {
task.Status = TaskDone
task.Result = resp.Response
} else {
task.Status = TaskFailed
task.Error = resp.Error
}
w.tasksMu.Unlock()
log.Printf("[AgentWorker] task %s done: status=%s", task.ID, task.Status)
// Отправить результат на callback URL если задан
if task.CallbackURL != "" {
go w.postCallback(task)
}
// Сохранить в DB history
if w.database != nil {
go func() {
userMsg := task.Input
agentResp := task.Result
if task.Status == TaskFailed {
agentResp = "[ERROR] " + task.Error
}
w.database.SaveHistory(db.HistoryInput{
AgentID: w.agentID,
UserMessage: userMsg,
AgentResponse: agentResp,
})
}()
}
}
// runChat — основной LLM loop агента.
func (w *AgentWorker) runChat(ctx context.Context, messages []ChatMessage, overrideModel string, maxIter int) ChatResponse {
model := w.cfg.Model
if overrideModel != "" {
model = overrideModel
}
if maxIter <= 0 {
maxIter = defaultMaxIter
}
// Собрать контекст: системный промпт + история + новые сообщения
conv := []llm.Message{}
if w.cfg.SystemPrompt != "" {
conv = append(conv, llm.Message{Role: "system", Content: w.cfg.SystemPrompt})
}
// Загрузить sliding window памяти из DB
if w.database != nil {
history, err := w.database.GetAgentHistory(w.agentID, 20)
if err == nil {
for _, h := range history {
conv = append(conv, llm.Message{Role: "user", Content: h.UserMessage})
if h.AgentResponse != "" {
conv = append(conv, llm.Message{Role: "assistant", Content: h.AgentResponse})
}
}
}
}
// Добавить текущие сообщения
for _, m := range messages {
conv = append(conv, llm.Message{Role: m.Role, Content: m.Content})
}
// Получить доступные инструменты агента
agentTools := w.getAgentTools()
temp := w.cfg.Temperature
maxTok := w.cfg.MaxTokens
if maxTok == 0 {
maxTok = 4096
}
var toolCallSteps []ToolCallStep
var finalResponse string
var lastModel string
for iter := 0; iter < maxIter; iter++ {
req := llm.ChatRequest{
Model: model,
Messages: conv,
Temperature: &temp,
MaxTokens: &maxTok,
}
if len(agentTools) > 0 {
req.Tools = agentTools
req.ToolChoice = "auto"
}
resp, err := w.llm.Chat(ctx, req)
if err != nil {
// Fallback без инструментов
req.Tools = nil
req.ToolChoice = ""
resp2, err2 := w.llm.Chat(ctx, req)
if err2 != nil {
return ChatResponse{
Success: false,
Error: fmt.Sprintf("LLM error (model: %s): %v", model, err2),
}
}
if len(resp2.Choices) > 0 {
finalResponse = resp2.Choices[0].Message.Content
lastModel = resp2.Model
}
break
}
if len(resp.Choices) == 0 {
break
}
choice := resp.Choices[0]
lastModel = resp.Model
if lastModel == "" {
lastModel = model
}
// Инструменты?
if choice.FinishReason == "tool_calls" && len(choice.Message.ToolCalls) > 0 {
conv = append(conv, choice.Message)
for _, tc := range choice.Message.ToolCalls {
start := time.Now()
result := w.executor.Execute(ctx, tc.Function.Name, tc.Function.Arguments)
step := ToolCallStep{
Tool: tc.Function.Name,
Success: result.Success,
DurationMs: time.Since(start).Milliseconds(),
}
var argsMap any
_ = json.Unmarshal([]byte(tc.Function.Arguments), &argsMap)
step.Args = argsMap
var toolContent string
if result.Success {
step.Result = result.Result
b, _ := json.Marshal(result.Result)
toolContent = string(b)
} else {
step.Error = result.Error
toolContent = fmt.Sprintf(`{"error": %q}`, result.Error)
}
toolCallSteps = append(toolCallSteps, step)
conv = append(conv, llm.Message{
Role: "tool",
Content: toolContent,
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
}
continue
}
finalResponse = choice.Message.Content
break
}
return ChatResponse{
Success: true,
Response: finalResponse,
ToolCalls: toolCallSteps,
Model: lastModel,
}
}
// getAgentTools возвращает только те инструменты, которые разрешены агенту.
func (w *AgentWorker) getAgentTools() []llm.Tool {
allTools := tools.OrchestratorTools()
allowed := make(map[string]bool, len(w.cfg.AllowedTools))
for _, t := range w.cfg.AllowedTools {
allowed[t] = true
}
// Если allowedTools пуст — агент получает базовый набор (http_request, file_read)
if len(allowed) == 0 {
allowed = map[string]bool{
"http_request": true,
"file_read": true,
"file_list": true,
}
}
var result []llm.Tool
for _, td := range allTools {
if allowed[td.Function.Name] {
result = append(result, llm.Tool{
Type: td.Type,
Function: llm.ToolFunction{
Name: td.Function.Name,
Description: td.Function.Description,
Parameters: td.Function.Parameters,
},
})
}
}
return result
}
// postCallback отправляет результат задачи на callback URL.
func (w *AgentWorker) postCallback(task *Task) {
w.tasksMu.RLock()
payload, _ := json.Marshal(task)
w.tasksMu.RUnlock()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, task.CallbackURL,
bytes.NewReader(payload))
if err != nil {
log.Printf("[AgentWorker] callback URL invalid for task %s: %v", task.ID, err)
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Printf("[AgentWorker] callback failed for task %s: %v", task.ID, err)
return
}
resp.Body.Close()
log.Printf("[AgentWorker] callback sent for task %s → %s (status %d)",
task.ID, task.CallbackURL, resp.StatusCode)
}
// ─── HTTP Handlers ────────────────────────────────────────────────────────────
func (w *AgentWorker) handleHealth(rw http.ResponseWriter, r *http.Request) {
json.NewEncoder(rw).Encode(map[string]any{
"status": "ok",
"agentId": w.agentID,
"name": w.cfg.Name,
"model": w.cfg.Model,
"queueLen": len(w.taskQueue),
})
}
func (w *AgentWorker) handleInfo(rw http.ResponseWriter, r *http.Request) {
json.NewEncoder(rw).Encode(map[string]any{
"id": w.cfg.ID,
"name": w.cfg.Name,
"role": w.cfg.Model,
"model": w.cfg.Model,
"allowedTools": w.cfg.AllowedTools,
"isSystem": w.cfg.IsSystem,
})
}
func (w *AgentWorker) handleChat(rw http.ResponseWriter, r *http.Request) {
var req ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(rw, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
if len(req.Messages) == 0 {
http.Error(rw, `{"error":"messages required"}`, http.StatusBadRequest)
return
}
timeout := w.cfg.MaxTokens / 10 // грубая оценка
if timeout < 30 {
timeout = 30
}
if timeout > 300 {
timeout = 300
}
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeout)*time.Second)
defer cancel()
resp := w.runChat(ctx, req.Messages, req.Model, req.MaxIter)
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(resp)
}
func (w *AgentWorker) handleTask(rw http.ResponseWriter, r *http.Request) {
var req TaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(rw, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
if req.Input == "" {
http.Error(rw, `{"error":"input required"}`, http.StatusBadRequest)
return
}
task := w.EnqueueTask(req)
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusAccepted)
json.NewEncoder(rw).Encode(map[string]any{
"task_id": task.ID,
"status": task.Status,
"agent_id": w.agentID,
"queue_len": len(w.taskQueue),
})
}
func (w *AgentWorker) handleListTasks(rw http.ResponseWriter, r *http.Request) {
w.recentMu.Lock()
keys := make([]string, len(w.recentKeys))
copy(keys, w.recentKeys)
w.recentMu.Unlock()
w.tasksMu.RLock()
result := make([]*Task, 0, len(keys))
for i := len(keys) - 1; i >= 0; i-- {
if t, ok := w.tasks[keys[i]]; ok {
result = append(result, t)
}
}
w.tasksMu.RUnlock()
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(map[string]any{
"tasks": result,
"total": len(result),
"queueLen": len(w.taskQueue),
})
}
func (w *AgentWorker) handleGetTask(rw http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "id")
w.tasksMu.RLock()
task, ok := w.tasks[taskID]
w.tasksMu.RUnlock()
if !ok {
http.Error(rw, `{"error":"task not found"}`, http.StatusNotFound)
return
}
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(task)
}
func (w *AgentWorker) handleMemory(rw http.ResponseWriter, r *http.Request) {
limitStr := r.URL.Query().Get("limit")
limit := 20
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 && n <= 100 {
limit = n
}
if w.database == nil {
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(map[string]any{"messages": []any{}, "total": 0})
return
}
history, err := w.database.GetAgentHistory(w.agentID, limit)
if err != nil {
http.Error(rw, `{"error":"failed to load history"}`, http.StatusInternalServerError)
return
}
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(map[string]any{
"agent_id": w.agentID,
"messages": history,
"total": len(history),
})
}
// ─── Main ─────────────────────────────────────────────────────────────────────
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
_ = godotenv.Load("../.env")
_ = godotenv.Load(".env")
// ── Конфиг из env ────────────────────────────────────────────────────────
agentIDStr := os.Getenv("AGENT_ID")
if agentIDStr == "" {
log.Fatal("[AgentWorker] AGENT_ID env var is required")
}
agentID, err := strconv.Atoi(agentIDStr)
if err != nil || agentID <= 0 {
log.Fatalf("[AgentWorker] AGENT_ID must be a positive integer, got: %q", agentIDStr)
}
port := os.Getenv("AGENT_PORT")
if port == "" {
port = "8001"
}
llmBaseURL := getEnvFirst("LLM_BASE_URL", "OLLAMA_BASE_URL")
if llmBaseURL == "" {
llmBaseURL = "https://ollama.com/v1"
}
llmAPIKey := getEnvFirst("LLM_API_KEY", "OLLAMA_API_KEY")
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
log.Fatal("[AgentWorker] DATABASE_URL env var is required")
}
log.Printf("[AgentWorker] Starting: AGENT_ID=%d PORT=%s LLM=%s", agentID, port, llmBaseURL)
// ── DB ───────────────────────────────────────────────────────────────────
database, err := db.Connect(dbURL)
if err != nil {
log.Fatalf("[AgentWorker] DB connection failed: %v", err)
}
defer database.Close()
// ── LLM Client ───────────────────────────────────────────────────────────
llmClient := llm.NewClient(llmBaseURL, llmAPIKey)
// ── Agent Worker ─────────────────────────────────────────────────────────
worker, err := newAgentWorker(agentID, database, llmClient)
if err != nil {
log.Fatalf("[AgentWorker] init failed: %v", err)
}
// ── Background workers ───────────────────────────────────────────────────
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
worker.StartWorkers(ctx)
// ── Router ───────────────────────────────────────────────────────────────
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
AllowedHeaders: []string{"Content-Type", "Authorization", "X-Agent-ID"},
}))
r.Get("/health", worker.handleHealth)
r.Get("/info", worker.handleInfo)
r.Post("/chat", worker.handleChat)
r.Post("/task", worker.handleTask)
r.Get("/tasks", worker.handleListTasks)
r.Get("/tasks/{id}", worker.handleGetTask)
r.Get("/memory", worker.handleMemory)
// ── HTTP Server ───────────────────────────────────────────────────────────
srv := &http.Server{
Addr: ":" + port,
Handler: r,
ReadTimeout: 30 * time.Second,
WriteTimeout: 310 * time.Second, // > max task timeout
IdleTimeout: 120 * time.Second,
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
log.Printf("[AgentWorker] agent-id=%d listening on :%s", agentID, port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("[AgentWorker] server error: %v", err)
}
}()
<-quit
log.Println("[AgentWorker] shutting down gracefully...")
cancel() // stop task workers
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer shutdownCancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("[AgentWorker] shutdown error: %v", err)
}
log.Println("[AgentWorker] stopped.")
}
func getEnvFirst(keys ...string) string {
for _, k := range keys {
if v := os.Getenv(k); v != "" {
return v
}
}
return ""
}

View File

@@ -0,0 +1,438 @@
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.softuniq.eu/UniqAI/GoClaw/gateway/internal/db"
)
// ─── Mock DB agent config ─────────────────────────────────────────────────────
func mockAgentConfig() *db.AgentConfig {
return &db.AgentConfig{
ID: 42,
Name: "Test Agent",
Model: "qwen2.5:7b",
SystemPrompt: "You are a test agent.",
AllowedTools: []string{"http_request", "file_list"},
Temperature: 0.7,
MaxTokens: 2048,
IsSystem: false,
IsOrchestrator: false,
IsActive: true,
ContainerImage: "goclaw-agent-worker:latest",
ContainerStatus: "running",
ServiceName: "goclaw-agent-42",
ServicePort: 8001,
}
}
// ─── Unit: AgentWorker struct ─────────────────────────────────────────────────
func TestAgentWorkerInit(t *testing.T) {
w := &AgentWorker{
agentID: 42,
cfg: mockAgentConfig(),
taskQueue: make(chan *Task, taskQueueDepth),
tasks: make(map[string]*Task),
}
if w.agentID != 42 {
t.Errorf("expected agentID=42, got %d", w.agentID)
}
if w.cfg.Name != "Test Agent" {
t.Errorf("expected name 'Test Agent', got %q", w.cfg.Name)
}
}
// ─── Unit: Task enqueue ───────────────────────────────────────────────────────
func TestEnqueueTask(t *testing.T) {
w := &AgentWorker{
agentID: 42,
cfg: mockAgentConfig(),
taskQueue: make(chan *Task, taskQueueDepth),
tasks: make(map[string]*Task),
}
task := w.EnqueueTask(TaskRequest{
Input: "hello world",
TimeoutSecs: 30,
})
if task.ID == "" {
t.Error("task ID should not be empty")
}
if task.Status != TaskPending {
t.Errorf("expected status=pending, got %q", task.Status)
}
if task.Input != "hello world" {
t.Errorf("expected input='hello world', got %q", task.Input)
}
if len(w.taskQueue) != 1 {
t.Errorf("expected 1 task in queue, got %d", len(w.taskQueue))
}
// Task should be in store
w.tasksMu.RLock()
stored, ok := w.tasks[task.ID]
w.tasksMu.RUnlock()
if !ok {
t.Error("task not found in store")
}
if stored.ID != task.ID {
t.Errorf("stored task ID mismatch: %q != %q", stored.ID, task.ID)
}
}
func TestEnqueueTask_QueueFull(t *testing.T) {
// Queue depth = 1 for this test
w := &AgentWorker{
agentID: 42,
cfg: mockAgentConfig(),
taskQueue: make(chan *Task, 1),
tasks: make(map[string]*Task),
}
// Fill the queue
w.EnqueueTask(TaskRequest{Input: "task 1"})
// Overflow
task2 := w.EnqueueTask(TaskRequest{Input: "task 2"})
w.tasksMu.RLock()
status := task2.Status
w.tasksMu.RUnlock()
if status != TaskFailed {
t.Errorf("expected task2 status=failed when queue full, got %q", status)
}
}
func TestEnqueueTask_DefaultTimeout(t *testing.T) {
w := &AgentWorker{
agentID: 42,
cfg: mockAgentConfig(),
taskQueue: make(chan *Task, taskQueueDepth),
tasks: make(map[string]*Task),
}
task := w.EnqueueTask(TaskRequest{Input: "no timeout set"})
if task.TimeoutSecs != defaultTimeout {
t.Errorf("expected default timeout=%d, got %d", defaultTimeout, task.TimeoutSecs)
}
}
// ─── HTTP Handlers ────────────────────────────────────────────────────────────
func makeTestWorker() *AgentWorker {
return &AgentWorker{
agentID: 42,
cfg: mockAgentConfig(),
taskQueue: make(chan *Task, taskQueueDepth),
tasks: make(map[string]*Task),
}
}
func TestHandleHealth(t *testing.T) {
w := makeTestWorker()
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w.handleHealth(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var body map[string]any
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
if body["status"] != "ok" {
t.Errorf("expected status=ok, got %v", body["status"])
}
if int(body["agentId"].(float64)) != 42 {
t.Errorf("expected agentId=42, got %v", body["agentId"])
}
}
func TestHandleInfo(t *testing.T) {
w := makeTestWorker()
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/info", nil)
w.handleInfo(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var body map[string]any
json.NewDecoder(rr.Body).Decode(&body)
if body["name"] != "Test Agent" {
t.Errorf("expected name='Test Agent', got %v", body["name"])
}
}
func TestHandleTask_Valid(t *testing.T) {
w := makeTestWorker()
body := `{"input":"do something useful","timeout_secs":60}`
req := httptest.NewRequest(http.MethodPost, "/task", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
w.handleTask(rr, req)
if rr.Code != http.StatusAccepted {
t.Errorf("expected 202, got %d", rr.Code)
}
var resp map[string]any
json.NewDecoder(rr.Body).Decode(&resp)
if resp["task_id"] == "" || resp["task_id"] == nil {
t.Error("task_id should be in response")
}
if resp["status"] != string(TaskPending) {
t.Errorf("expected status=pending, got %v", resp["status"])
}
}
func TestHandleTask_EmptyInput(t *testing.T) {
w := makeTestWorker()
req := httptest.NewRequest(http.MethodPost, "/task", bytes.NewBufferString(`{"input":""}`))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
w.handleTask(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rr.Code)
}
}
func TestHandleTask_InvalidJSON(t *testing.T) {
w := makeTestWorker()
req := httptest.NewRequest(http.MethodPost, "/task", bytes.NewBufferString(`not-json`))
rr := httptest.NewRecorder()
w.handleTask(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rr.Code)
}
}
func TestHandleGetTask_NotFound(t *testing.T) {
// We can't easily use chi.URLParam in unit tests without a full router.
// Test the store logic directly instead.
w := makeTestWorker()
w.tasksMu.RLock()
_, ok := w.tasks["nonexistent-id"]
w.tasksMu.RUnlock()
if ok {
t.Error("nonexistent task should not be found")
}
}
func TestHandleListTasks_Empty(t *testing.T) {
w := makeTestWorker()
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
w.handleListTasks(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var resp map[string]any
json.NewDecoder(rr.Body).Decode(&resp)
if resp["total"].(float64) != 0 {
t.Errorf("expected total=0, got %v", resp["total"])
}
}
func TestHandleListTasks_WithTasks(t *testing.T) {
w := makeTestWorker()
w.EnqueueTask(TaskRequest{Input: "task A"})
w.EnqueueTask(TaskRequest{Input: "task B"})
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
w.handleListTasks(rr, req)
var resp map[string]any
json.NewDecoder(rr.Body).Decode(&resp)
if int(resp["total"].(float64)) != 2 {
t.Errorf("expected total=2, got %v", resp["total"])
}
}
func TestHandleMemory_NoDB(t *testing.T) {
w := makeTestWorker() // no database set
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/memory", nil)
w.handleMemory(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var resp map[string]any
json.NewDecoder(rr.Body).Decode(&resp)
if int(resp["total"].(float64)) != 0 {
t.Errorf("expected total=0 without DB, got %v", resp["total"])
}
}
// ─── Unit: getAgentTools ──────────────────────────────────────────────────────
func TestGetAgentTools_WithAllowedTools(t *testing.T) {
w := makeTestWorker()
agentTools := w.getAgentTools()
// Worker has allowedTools = ["http_request", "file_list"]
if len(agentTools) == 0 {
t.Error("expected some tools, got none")
}
names := make(map[string]bool)
for _, t := range agentTools {
names[t.Function.Name] = true
}
if !names["http_request"] {
t.Error("expected http_request in allowed tools")
}
if !names["file_list"] {
t.Error("expected file_list in allowed tools")
}
// shell_exec should NOT be allowed
if names["shell_exec"] {
t.Error("shell_exec should NOT be in allowed tools for this agent")
}
}
func TestGetAgentTools_EmptyAllowedTools_UsesDefaults(t *testing.T) {
cfg := mockAgentConfig()
cfg.AllowedTools = []string{} // empty
w := &AgentWorker{agentID: 1, cfg: cfg, taskQueue: make(chan *Task, 1), tasks: map[string]*Task{}}
tools := w.getAgentTools()
if len(tools) == 0 {
t.Error("expected default tools when allowedTools is empty")
}
}
// ─── Unit: recent task ring ───────────────────────────────────────────────────
func TestRecentRing_MaxCapacity(t *testing.T) {
w := makeTestWorker()
// Enqueue more than maxRecentTasks
for i := 0; i < maxRecentTasks+10; i++ {
// Don't block — drain queue
w.EnqueueTask(TaskRequest{Input: "task"})
select {
case <-w.taskQueue:
default:
}
}
w.recentMu.Lock()
count := len(w.recentKeys)
w.recentMu.Unlock()
if count > maxRecentTasks {
t.Errorf("recent ring should not exceed %d, got %d", maxRecentTasks, count)
}
}
// ─── Unit: Task lifecycle ─────────────────────────────────────────────────────
func TestTaskLifecycle_Timestamps(t *testing.T) {
w := makeTestWorker()
before := time.Now()
task := w.EnqueueTask(TaskRequest{Input: "lifecycle test"})
after := time.Now()
if task.CreatedAt.Before(before) || task.CreatedAt.After(after) {
t.Errorf("CreatedAt=%v should be between %v and %v", task.CreatedAt, before, after)
}
if task.StartedAt != nil {
t.Error("StartedAt should be nil for pending task")
}
if task.DoneAt != nil {
t.Error("DoneAt should be nil for pending task")
}
}
// ─── Unit: HTTP Chat handler (no LLM) ────────────────────────────────────────
func TestHandleChat_InvalidJSON(t *testing.T) {
w := makeTestWorker()
req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewBufferString(`not-json`))
rr := httptest.NewRecorder()
w.handleChat(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rr.Code)
}
}
func TestHandleChat_EmptyMessages(t *testing.T) {
w := makeTestWorker()
req := httptest.NewRequest(http.MethodPost, "/chat",
bytes.NewBufferString(`{"messages":[]}`))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
w.handleChat(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400 for empty messages, got %d", rr.Code)
}
}
// ─── Integration: worker goroutine processes task ─────────────────────────────
func TestWorkerProcessesTask_WithMockLLM(t *testing.T) {
// Create a mock LLM server that returns a simple response
mockLLM := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"choices": []map[string]any{
{
"message": map[string]string{"role": "assistant", "content": "Mock answer"},
"finish_reason": "stop",
},
},
"model": "mock-model",
})
}))
defer mockLLM.Close()
// We can't easily create a full AgentWorker with llm client without more refactoring,
// so we test the task state machine directly
w := makeTestWorker()
task := w.EnqueueTask(TaskRequest{Input: "test task", TimeoutSecs: 5})
if task.Status != TaskPending {
t.Errorf("expected pending, got %s", task.Status)
}
// Simulate task processing (without LLM)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
now := time.Now()
w.tasksMu.Lock()
task.Status = TaskRunning
task.StartedAt = &now
w.tasksMu.Unlock()
// Simulate done
doneAt := time.Now()
w.tasksMu.Lock()
task.Status = TaskDone
task.Result = "completed"
task.DoneAt = &doneAt
w.tasksMu.Unlock()
_ = ctx
w.tasksMu.RLock()
finalStatus := task.Status
w.tasksMu.RUnlock()
if finalStatus != TaskDone {
t.Errorf("expected task done, got %s", finalStatus)
}
}

View File

@@ -4,9 +4,10 @@ go 1.23.4
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/go-chi/chi/v5 v5.2.1 // indirect
github.com/go-chi/cors v1.2.1 // indirect
github.com/go-chi/chi/v5 v5.2.1
github.com/go-chi/cors v1.2.1
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/joho/godotenv v1.5.1
)

View File

@@ -23,6 +23,11 @@ type AgentConfig struct {
IsOrchestrator bool
IsSystem bool
IsActive bool
// Container / Swarm fields (Phase A)
ServiceName string
ServicePort int
ContainerImage string
ContainerStatus string // "stopped" | "deploying" | "running" | "error"
}
// AgentRow is a minimal agent representation for listing.
@@ -68,7 +73,8 @@ func (d *DB) Close() {
// GetOrchestratorConfig loads the agent with isOrchestrator=1 from DB.
func (d *DB) GetOrchestratorConfig() (*AgentConfig, error) {
row := d.conn.QueryRow(`
SELECT id, name, model, systemPrompt, allowedTools, temperature, maxTokens, isOrchestrator, isSystem, isActive
SELECT id, name, model, systemPrompt, allowedTools, temperature, maxTokens, isOrchestrator, isSystem, isActive,
COALESCE(serviceName,''), COALESCE(servicePort,0), COALESCE(containerImage,'goclaw-agent-worker:latest'), COALESCE(containerStatus,'stopped')
FROM agents
WHERE isOrchestrator = 1
LIMIT 1
@@ -79,7 +85,8 @@ func (d *DB) GetOrchestratorConfig() (*AgentConfig, error) {
// GetAgentByID loads a specific agent by ID.
func (d *DB) GetAgentByID(id int) (*AgentConfig, error) {
row := d.conn.QueryRow(`
SELECT id, name, model, systemPrompt, allowedTools, temperature, maxTokens, isOrchestrator, isSystem, isActive
SELECT id, name, model, systemPrompt, allowedTools, temperature, maxTokens, isOrchestrator, isSystem, isActive,
COALESCE(serviceName,''), COALESCE(servicePort,0), COALESCE(containerImage,'goclaw-agent-worker:latest'), COALESCE(containerStatus,'stopped')
FROM agents
WHERE id = ?
LIMIT 1
@@ -129,6 +136,7 @@ func scanAgentConfig(row *sql.Row) (*AgentConfig, error) {
&systemPrompt, &allowedToolsJSON,
&temperature, &maxTokens,
&isOrch, &isSystem, &isActive,
&cfg.ServiceName, &cfg.ServicePort, &cfg.ContainerImage, &cfg.ContainerStatus,
)
if err != nil {
return nil, err
@@ -154,6 +162,107 @@ func scanAgentConfig(row *sql.Row) (*AgentConfig, error) {
return &cfg, nil
}
// ─── Agent Container Fields ───────────────────────────────────────────────────
// These methods support the agent-worker container architecture where each
// agent runs as an autonomous Docker Swarm service.
// UpdateContainerStatus updates the container lifecycle state of an agent.
func (d *DB) UpdateContainerStatus(agentID int, status, serviceName string, servicePort int) error {
if d.conn == nil {
return nil
}
_, err := d.conn.Exec(`
UPDATE agents
SET containerStatus = ?, serviceName = ?, servicePort = ?, updatedAt = NOW()
WHERE id = ?
`, status, serviceName, servicePort, agentID)
return err
}
// HistoryInput holds data for one conversation entry.
type HistoryInput struct {
AgentID int
UserMessage string
AgentResponse string
ConversationID string
Status string // "success" | "error" | "pending"
}
// HistoryRow is a single entry from agentHistory for sliding window memory.
type HistoryRow struct {
ID int `json:"id"`
UserMessage string `json:"userMessage"`
AgentResponse string `json:"agentResponse"`
ConvID string `json:"conversationId"`
}
// SaveHistory inserts a row into the agentHistory table.
// Non-fatal — logs on error but does not return one.
func (d *DB) SaveHistory(h HistoryInput) {
if d.conn == nil {
return
}
status := h.Status
if status == "" {
status = "success"
}
convID := sql.NullString{String: h.ConversationID, Valid: h.ConversationID != ""}
resp := sql.NullString{String: h.AgentResponse, Valid: h.AgentResponse != ""}
_, err := d.conn.Exec(`
INSERT INTO agentHistory (agentId, userMessage, agentResponse, conversationId, status)
VALUES (?, ?, ?, ?, ?)
`,
h.AgentID,
truncate(h.UserMessage, 65535),
resp,
convID,
status,
)
if err != nil {
log.Printf("[DB] SaveHistory error: %v", err)
}
}
// GetAgentHistory returns the last N conversation turns for an agent, oldest first.
func (d *DB) GetAgentHistory(agentID, limit int) ([]HistoryRow, error) {
if d.conn == nil {
return nil, nil
}
rows, err := d.conn.Query(`
SELECT id, userMessage, COALESCE(agentResponse,''), COALESCE(conversationId,'')
FROM agentHistory
WHERE agentId = ?
ORDER BY id DESC
LIMIT ?
`, agentID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var result []HistoryRow
for rows.Next() {
var h HistoryRow
if err := rows.Scan(&h.ID, &h.UserMessage, &h.AgentResponse, &h.ConvID); err != nil {
continue
}
result = append(result, h)
}
// Reverse so oldest is first (for LLM context ordering)
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
result[i], result[j] = result[j], result[i]
}
return result, nil
}
// truncate caps a string to maxLen bytes.
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen]
}
// normalizeDSN converts mysql://user:pass@host:port/db to user:pass@tcp(host:port)/db
func normalizeDSN(dsn string) string {
if !strings.HasPrefix(dsn, "mysql://") {

View File

@@ -96,6 +96,8 @@ func New(llmClient *llm.Client, database *db.DB, projectRoot string) *Orchestrat
}
// Inject agent list function to avoid circular dependency
o.executor = tools.NewExecutor(projectRoot, o.listAgentsFn)
// Inject DB so delegate_to_agent can resolve live agent container addresses
o.executor.SetDatabase(database)
return o
}

View File

@@ -3,6 +3,7 @@
package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -13,6 +14,8 @@ import (
"path/filepath"
"strings"
"time"
"git.softuniq.eu/UniqAI/GoClaw/gateway/internal/db"
)
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -175,6 +178,8 @@ type Executor struct {
httpClient *http.Client
// agentListFn is injected to avoid circular dependency with orchestrator
agentListFn func() ([]map[string]any, error)
// database is used for delegate_to_agent to look up service address
database *db.DB
}
func NewExecutor(projectRoot string, agentListFn func() ([]map[string]any, error)) *Executor {
@@ -187,6 +192,11 @@ func NewExecutor(projectRoot string, agentListFn func() ([]map[string]any, error
}
}
// SetDatabase injects the DB reference so delegate_to_agent can resolve agent addresses.
func (e *Executor) SetDatabase(database *db.DB) {
e.database = database
}
// Execute dispatches a tool call by name.
func (e *Executor) Execute(ctx context.Context, toolName string, argsJSON string) ToolResult {
start := time.Now()
@@ -215,7 +225,7 @@ func (e *Executor) Execute(ctx context.Context, toolName string, argsJSON string
case "list_agents":
result, execErr = e.listAgents()
case "delegate_to_agent":
result, execErr = e.delegateToAgent(args)
result, execErr = e.delegateToAgent(ctx, args)
default:
return ToolResult{Success: false, Error: fmt.Sprintf("unknown tool: %s", toolName), DurationMs: ms(start)}
}
@@ -446,21 +456,86 @@ func (e *Executor) listAgents() (any, error) {
return map[string]any{"agents": agents, "count": len(agents)}, nil
}
func (e *Executor) delegateToAgent(args map[string]any) (any, error) {
agentID, _ := args["agentId"].(float64)
message, _ := args["message"].(string)
if message == "" {
return nil, fmt.Errorf("message is required")
func (e *Executor) delegateToAgent(ctx context.Context, args map[string]any) (any, error) {
agentIDf, _ := args["agentId"].(float64)
agentID := int(agentIDf)
task, _ := args["task"].(string)
if task == "" {
task, _ = args["message"].(string) // backward compat
}
// Delegation is handled at orchestrator level; here we return a placeholder
if task == "" {
return nil, fmt.Errorf("task (or message) is required")
}
callbackURL, _ := args["callbackUrl"].(string)
async, _ := args["async"].(bool)
// Resolve agent container address from DB
if e.database != nil {
cfg, err := e.database.GetAgentByID(agentID)
if err == nil && cfg != nil && cfg.ServicePort > 0 && cfg.ContainerStatus == "running" {
// Agent is deployed — call its container via overlay DNS
// Docker Swarm DNS: service name resolves inside overlay network
agentURL := fmt.Sprintf("http://%s:%d", cfg.ServiceName, cfg.ServicePort)
if async {
return e.postAgentTask(ctx, agentURL, agentID, task, callbackURL)
}
return e.postAgentChat(ctx, agentURL, agentID, task)
}
}
// Fallback: agent not deployed yet — return informational response
return map[string]any{
"delegated": true,
"agentId": int(agentID),
"message": message,
"note": "Agent delegation queued — response will be processed in next iteration",
"delegated": false,
"agentId": agentID,
"task": task,
"note": fmt.Sprintf("Agent %d is not running (containerStatus != running). Deploy it first via Web Panel.", agentID),
}, nil
}
// postAgentTask POSTs to agent's /task endpoint (async, returns task_id).
func (e *Executor) postAgentTask(ctx context.Context, agentURL string, fromAgentID int, task, callbackURL string) (any, error) {
payload, _ := json.Marshal(map[string]any{
"input": task,
"from_agent_id": fromAgentID,
"callback_url": callbackURL,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, agentURL+"/task", bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("delegate build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := e.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("delegate HTTP error: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]any
_ = json.Unmarshal(body, &result)
return result, nil
}
// postAgentChat POSTs to agent's /chat endpoint (sync, waits for response).
func (e *Executor) postAgentChat(ctx context.Context, agentURL string, _ int, task string) (any, error) {
payload, _ := json.Marshal(map[string]any{
"messages": []map[string]string{{"role": "user", "content": task}},
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, agentURL+"/chat", bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("delegate build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := e.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("delegate HTTP error: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]any
_ = json.Unmarshal(body, &result)
return result, nil
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
func (e *Executor) resolvePath(path string) string {

View File

@@ -1,12 +1,28 @@
import { eq, and, desc, gte } from "drizzle-orm";
import { agents, agentMetrics, agentHistory, agentAccessControl, type Agent, type InsertAgent, type AgentMetric, type InsertAgentMetric } from "../drizzle/schema";
import {
agents,
agentMetrics,
agentHistory,
agentAccessControl,
type Agent,
type InsertAgent,
type AgentMetric,
type InsertAgentMetric,
} from "../drizzle/schema";
import { getDb } from "./db";
import { nanoid } from "nanoid";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
/**
* Создать нового агента
*/
export async function createAgent(userId: number, data: InsertAgent): Promise<Agent | null> {
export async function createAgent(
userId: number,
data: InsertAgent
): Promise<Agent | null> {
const db = await getDb();
if (!db) return null;
@@ -17,7 +33,11 @@ export async function createAgent(userId: number, data: InsertAgent): Promise<Ag
});
const agentId = result[0].insertId;
const created = await db.select().from(agents).where(eq(agents.id, Number(agentId))).limit(1);
const created = await db
.select()
.from(agents)
.where(eq(agents.id, Number(agentId)))
.limit(1);
return created[0] || null;
} catch (error) {
console.error("[DB] Failed to create agent:", error);
@@ -33,7 +53,11 @@ export async function getAgentById(agentId: number): Promise<Agent | null> {
if (!db) return null;
try {
const result = await db.select().from(agents).where(eq(agents.id, agentId)).limit(1);
const result = await db
.select()
.from(agents)
.where(eq(agents.id, agentId))
.limit(1);
return result[0] || null;
} catch (error) {
console.error("[DB] Failed to get agent:", error);
@@ -80,7 +104,11 @@ export async function getSystemAgents(): Promise<Agent[]> {
if (!db) return [];
try {
return await db.select().from(agents).where(eq(agents.isSystem, true)).orderBy(agents.id);
return await db
.select()
.from(agents)
.where(eq(agents.isSystem, true))
.orderBy(agents.id);
} catch (error) {
console.error("[DB] Failed to get system agents:", error);
return [];
@@ -90,7 +118,10 @@ export async function getSystemAgents(): Promise<Agent[]> {
/**
* Обновить конфигурацию агента
*/
export async function updateAgent(agentId: number, updates: Partial<InsertAgent>): Promise<Agent | null> {
export async function updateAgent(
agentId: number,
updates: Partial<InsertAgent>
): Promise<Agent | null> {
const db = await getDb();
if (!db) return null;
@@ -122,7 +153,10 @@ export async function deleteAgent(agentId: number): Promise<boolean> {
/**
* Сохранить метрику запроса
*/
export async function saveMetric(agentId: number, data: Omit<InsertAgentMetric, "agentId">): Promise<AgentMetric | null> {
export async function saveMetric(
agentId: number,
data: Omit<InsertAgentMetric, "agentId">
): Promise<AgentMetric | null> {
const db = await getDb();
if (!db) return null;
@@ -135,7 +169,11 @@ export async function saveMetric(agentId: number, data: Omit<InsertAgentMetric,
});
const metricId = result[0].insertId;
const created = await db.select().from(agentMetrics).where(eq(agentMetrics.id, Number(metricId))).limit(1);
const created = await db
.select()
.from(agentMetrics)
.where(eq(agentMetrics.id, Number(metricId)))
.limit(1);
return created[0] || null;
} catch (error) {
console.error("[DB] Failed to save metric:", error);
@@ -146,7 +184,10 @@ export async function saveMetric(agentId: number, data: Omit<InsertAgentMetric,
/**
* Получить метрики агента за последние N часов
*/
export async function getAgentMetrics(agentId: number, hoursBack: number = 24): Promise<AgentMetric[]> {
export async function getAgentMetrics(
agentId: number,
hoursBack: number = 24
): Promise<AgentMetric[]> {
const db = await getDb();
if (!db) return [];
@@ -155,7 +196,12 @@ export async function getAgentMetrics(agentId: number, hoursBack: number = 24):
return await db
.select()
.from(agentMetrics)
.where(and(eq(agentMetrics.agentId, agentId), gte(agentMetrics.createdAt, since)))
.where(
and(
eq(agentMetrics.agentId, agentId),
gte(agentMetrics.createdAt, since)
)
)
.orderBy(desc(agentMetrics.createdAt));
} catch (error) {
console.error("[DB] Failed to get agent metrics:", error);
@@ -174,17 +220,26 @@ export async function getAgentStats(agentId: number, hoursBack: number = 24) {
const metrics = await getAgentMetrics(agentId, hoursBack);
const totalRequests = metrics.length;
const successRequests = metrics.filter((m) => m.status === "success").length;
const errorRequests = metrics.filter((m) => m.status === "error").length;
const avgProcessingTime = metrics.length > 0 ? metrics.reduce((sum, m) => sum + m.processingTimeMs, 0) / metrics.length : 0;
const totalTokens = metrics.reduce((sum, m) => sum + (m.totalTokens || 0), 0);
const avgTokensPerRequest = metrics.length > 0 ? totalTokens / metrics.length : 0;
const successRequests = metrics.filter(m => m.status === "success").length;
const errorRequests = metrics.filter(m => m.status === "error").length;
const avgProcessingTime =
metrics.length > 0
? metrics.reduce((sum, m) => sum + m.processingTimeMs, 0) /
metrics.length
: 0;
const totalTokens = metrics.reduce(
(sum, m) => sum + (m.totalTokens || 0),
0
);
const avgTokensPerRequest =
metrics.length > 0 ? totalTokens / metrics.length : 0;
return {
totalRequests,
successRequests,
errorRequests,
successRate: totalRequests > 0 ? (successRequests / totalRequests) * 100 : 0,
successRate:
totalRequests > 0 ? (successRequests / totalRequests) * 100 : 0,
avgProcessingTime: Math.round(avgProcessingTime),
totalTokens,
avgTokensPerRequest: Math.round(avgTokensPerRequest),
@@ -224,7 +279,10 @@ export async function getAgentAccessControl(agentId: number) {
if (!db) return [];
try {
return await db.select().from(agentAccessControl).where(eq(agentAccessControl.agentId, agentId));
return await db
.select()
.from(agentAccessControl)
.where(eq(agentAccessControl.agentId, agentId));
} catch (error) {
console.error("[DB] Failed to get agent access control:", error);
return [];
@@ -234,7 +292,11 @@ export async function getAgentAccessControl(agentId: number) {
/**
* Обновить управление доступами для инструмента
*/
export async function updateToolAccess(agentId: number, tool: string, updates: Partial<typeof agentAccessControl.$inferInsert>) {
export async function updateToolAccess(
agentId: number,
tool: string,
updates: Partial<typeof agentAccessControl.$inferInsert>
) {
const db = await getDb();
if (!db) return null;
@@ -242,14 +304,24 @@ export async function updateToolAccess(agentId: number, tool: string, updates: P
const existing = await db
.select()
.from(agentAccessControl)
.where(and(eq(agentAccessControl.agentId, agentId), eq(agentAccessControl.tool, tool)))
.where(
and(
eq(agentAccessControl.agentId, agentId),
eq(agentAccessControl.tool, tool)
)
)
.limit(1);
if (existing.length > 0) {
await db
.update(agentAccessControl)
.set(updates)
.where(and(eq(agentAccessControl.agentId, agentId), eq(agentAccessControl.tool, tool)));
.where(
and(
eq(agentAccessControl.agentId, agentId),
eq(agentAccessControl.tool, tool)
)
);
} else {
await db.insert(agentAccessControl).values({
agentId,
@@ -261,7 +333,12 @@ export async function updateToolAccess(agentId: number, tool: string, updates: P
const result = await db
.select()
.from(agentAccessControl)
.where(and(eq(agentAccessControl.agentId, agentId), eq(agentAccessControl.tool, tool)))
.where(
and(
eq(agentAccessControl.agentId, agentId),
eq(agentAccessControl.tool, tool)
)
)
.limit(1);
return result[0] || null;
@@ -300,3 +377,204 @@ export async function saveHistory(
return null;
}
}
// ─── Container Management (Docker Swarm) ────────────────────────────────────
/**
* Deploy an agent as a Docker Swarm service.
* Each agent runs in its own container with the agent-worker binary.
*/
export async function deployAgentContainer(
agentId: number
): Promise<{
success: boolean;
serviceName: string;
servicePort: number;
error?: string;
}> {
const db = await getDb();
if (!db)
return {
success: false,
serviceName: "",
servicePort: 0,
error: "DB not available",
};
const agent = await getAgentById(agentId);
if (!agent)
return {
success: false,
serviceName: "",
servicePort: 0,
error: "Agent not found",
};
const serviceName = `goclaw-agent-${agentId}`;
const servicePort = 8001 + ((agentId - 1) % 999); // Ports 8001-8999
const containerImage =
(agent as any).containerImage || "goclaw-agent-worker:latest";
try {
// Update status to deploying
await db
.update(agents)
.set({
containerStatus: "deploying",
serviceName,
servicePort,
} as any)
.where(eq(agents.id, agentId));
// Build Docker Swarm service create command
const dbUrl =
process.env.DATABASE_URL || "mysql://goclaw:goclaw123@db:3306/goclaw";
const llmBaseUrl = process.env.LLM_BASE_URL || "https://api.openai.com/v1";
const llmApiKey = process.env.LLM_API_KEY || "";
const cmd = [
"docker service create",
`--name ${serviceName}`,
`--env AGENT_ID=${agentId}`,
`--env AGENT_PORT=${servicePort}`,
`--env DATABASE_URL="${dbUrl}"`,
`--env LLM_BASE_URL="${llmBaseUrl}"`,
`--env LLM_API_KEY="${llmApiKey}"`,
`--network goclaw-overlay`,
`--replicas 1`,
containerImage,
].join(" \\\n ");
console.log(
`[Container] Deploying agent ${agentId}: ${serviceName} on port ${servicePort}`
);
const { stdout, stderr } = await execAsync(cmd, { timeout: 30000 });
// Update status to running
await db
.update(agents)
.set({
containerStatus: "running",
serviceName,
servicePort,
} as any)
.where(eq(agents.id, agentId));
console.log(`[Container] Agent ${agentId} deployed: ${serviceName}`);
return { success: true, serviceName, servicePort };
} catch (error: any) {
console.error(
`[Container] Failed to deploy agent ${agentId}:`,
error.message
);
// Update status to error
await db
.update(agents)
.set({
containerStatus: "error",
} as any)
.where(eq(agents.id, agentId));
return {
success: false,
serviceName,
servicePort: 0,
error: error.message,
};
}
}
/**
* Stop and remove an agent's Docker Swarm service.
*/
export async function stopAgentContainer(
agentId: number
): Promise<{ success: boolean; error?: string }> {
const db = await getDb();
if (!db) return { success: false, error: "DB not available" };
const agent = await getAgentById(agentId);
if (!agent) return { success: false, error: "Agent not found" };
const serviceName = (agent as any).serviceName || `goclaw-agent-${agentId}`;
try {
// Remove the Docker Swarm service
console.log(`[Container] Stopping agent ${agentId}: ${serviceName}`);
await execAsync(`docker service rm ${serviceName}`, {
timeout: 15000,
}).catch(() => {
// Service may not exist — that's OK
console.log(
`[Container] Service ${serviceName} not found (already removed)`
);
});
// Update status to stopped
await db
.update(agents)
.set({
containerStatus: "stopped",
serviceName: null,
servicePort: null,
} as any)
.where(eq(agents.id, agentId));
console.log(`[Container] Agent ${agentId} stopped: ${serviceName}`);
return { success: true };
} catch (error: any) {
console.error(
`[Container] Failed to stop agent ${agentId}:`,
error.message
);
// Still mark as stopped in DB even if Docker failed
await db
.update(agents)
.set({
containerStatus: "stopped",
} as any)
.where(eq(agents.id, agentId));
return { success: false, error: error.message };
}
}
/**
* Get the status of an agent's container.
* Checks Docker Swarm service and returns current state.
*/
export async function getAgentContainerStatus(
agentId: number
): Promise<{ status: string; serviceName?: string; servicePort?: number }> {
const db = await getDb();
if (!db) return { status: "unknown" };
const agent = await getAgentById(agentId);
if (!agent) return { status: "unknown" };
const containerStatus = (agent as any).containerStatus || "stopped";
const serviceName = (agent as any).serviceName || "";
const servicePort = (agent as any).servicePort || 0;
// Verify actual Docker Swarm state
if (serviceName) {
try {
const { stdout } = await execAsync(
`docker service inspect ${serviceName} --format '{{.Spec.Mode.Replicated.Replicas}}'`,
{ timeout: 5000 }
);
const replicas = parseInt(stdout.trim());
if (replicas > 0) {
return { status: "running", serviceName, servicePort };
}
} catch {
// Service doesn't exist
return { status: "stopped" };
}
}
return { status: containerStatus, serviceName, servicePort };
}

View File

@@ -4,7 +4,12 @@ import { getDb } from "./db";
import { getSessionCookieOptions } from "./_core/cookies";
import { systemRouter } from "./_core/systemRouter";
import { publicProcedure, router, protectedProcedure } from "./_core/trpc";
import { retryWithBackoff, isRetryableError, logRetryAttempt, DEFAULT_RETRY_CONFIG } from "./chat-resilience";
import {
retryWithBackoff,
isRetryableError,
logRetryAttempt,
DEFAULT_RETRY_CONFIG,
} from "./chat-resilience";
import { checkOllamaHealth, listModels, chatCompletion } from "./ollama";
import {
checkGatewayHealth,
@@ -127,10 +132,12 @@ export const appRouter = router({
return getAllAgents();
}),
get: publicProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
const { getAgentById } = await import("./agents");
return getAgentById(input.id);
}),
get: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const { getAgentById } = await import("./agents");
return getAgentById(input.id);
}),
create: publicProcedure
.input(
@@ -184,39 +191,59 @@ export const appRouter = router({
)
.mutation(async ({ input }) => {
const { updateAgent } = await import("./agents");
const { id, temperature, topP, frequencyPenalty, presencePenalty, ...rest } = input;
const {
id,
temperature,
topP,
frequencyPenalty,
presencePenalty,
...rest
} = input;
const updates: Record<string, any> = { ...rest };
if (temperature !== undefined) updates.temperature = temperature.toString();
if (temperature !== undefined)
updates.temperature = temperature.toString();
if (topP !== undefined) updates.topP = topP.toString();
if (frequencyPenalty !== undefined) updates.frequencyPenalty = frequencyPenalty.toString();
if (presencePenalty !== undefined) updates.presencePenalty = presencePenalty.toString();
if (frequencyPenalty !== undefined)
updates.frequencyPenalty = frequencyPenalty.toString();
if (presencePenalty !== undefined)
updates.presencePenalty = presencePenalty.toString();
return updateAgent(id, updates as any);
}),
delete: publicProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
const { deleteAgent } = await import("./agents");
return deleteAgent(input.id);
}),
delete: publicProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const { deleteAgent } = await import("./agents");
return deleteAgent(input.id);
}),
stats: publicProcedure.input(z.object({ id: z.number(), hoursBack: z.number().default(24) })).query(async ({ input }) => {
const { getAgentStats } = await import("./agents");
return getAgentStats(input.id, input.hoursBack);
}),
stats: publicProcedure
.input(z.object({ id: z.number(), hoursBack: z.number().default(24) }))
.query(async ({ input }) => {
const { getAgentStats } = await import("./agents");
return getAgentStats(input.id, input.hoursBack);
}),
metrics: publicProcedure.input(z.object({ id: z.number(), hoursBack: z.number().default(24) })).query(async ({ input }) => {
const { getAgentMetrics } = await import("./agents");
return getAgentMetrics(input.id, input.hoursBack);
}),
metrics: publicProcedure
.input(z.object({ id: z.number(), hoursBack: z.number().default(24) }))
.query(async ({ input }) => {
const { getAgentMetrics } = await import("./agents");
return getAgentMetrics(input.id, input.hoursBack);
}),
history: publicProcedure.input(z.object({ id: z.number(), limit: z.number().default(50) })).query(async ({ input }) => {
const { getAgentHistory } = await import("./agents");
return getAgentHistory(input.id, input.limit);
}),
history: publicProcedure
.input(z.object({ id: z.number(), limit: z.number().default(50) }))
.query(async ({ input }) => {
const { getAgentHistory } = await import("./agents");
return getAgentHistory(input.id, input.limit);
}),
accessControl: publicProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
const { getAgentAccessControl } = await import("./agents");
return getAgentAccessControl(input.id);
}),
accessControl: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const { getAgentAccessControl } = await import("./agents");
return getAgentAccessControl(input.id);
}),
updateToolAccess: publicProcedure
.input(
@@ -236,6 +263,37 @@ export const appRouter = router({
return updateToolAccess(agentId, input.tool, updates);
}),
/**
* Deploy an agent as a Docker Swarm container.
* Creates a Swarm service with the agent-worker binary.
*/
deployContainer: publicProcedure
.input(z.object({ agentId: z.number() }))
.mutation(async ({ input }) => {
const { deployAgentContainer } = await import("./agents");
return deployAgentContainer(input.agentId);
}),
/**
* Stop and remove an agent's Docker Swarm container.
*/
stopContainer: publicProcedure
.input(z.object({ agentId: z.number() }))
.mutation(async ({ input }) => {
const { stopAgentContainer } = await import("./agents");
return stopAgentContainer(input.agentId);
}),
/**
* Get container status for an agent (checks Docker Swarm).
*/
containerStatus: publicProcedure
.input(z.object({ agentId: z.number() }))
.query(async ({ input }) => {
const { getAgentContainerStatus } = await import("./agents");
return getAgentContainerStatus(input.agentId);
}),
/**
* Chat with a specific agent using its configuration
*/
@@ -251,10 +309,17 @@ export const appRouter = router({
const { getAgentById, saveHistory } = await import("./agents");
const agent = await getAgentById(input.agentId);
if (!agent) {
return { success: false as const, response: "", error: "Agent not found" };
return {
success: false as const,
response: "",
error: "Agent not found",
};
}
const messages: Array<{ role: "system" | "user" | "assistant"; content: string }> = [];
const messages: Array<{
role: "system" | "user" | "assistant";
content: string;
}> = [];
if (agent.systemPrompt) {
messages.push({ role: "system", content: agent.systemPrompt });
}
@@ -262,14 +327,12 @@ export const appRouter = router({
const startTime = Date.now();
try {
const result = await chatCompletion(
agent.model,
messages,
{
temperature: agent.temperature ? parseFloat(agent.temperature as string) : 0.7,
max_tokens: agent.maxTokens ?? 2048,
}
);
const result = await chatCompletion(agent.model, messages, {
temperature: agent.temperature
? parseFloat(agent.temperature as string)
: 0.7,
max_tokens: agent.maxTokens ?? 2048,
});
const processingTimeMs = Date.now() - startTime;
const response = result.choices[0]?.message?.content ?? "";
@@ -304,7 +367,7 @@ export const appRouter = router({
}),
}),
/**
/**
* Tools — управление инструментами агентов
*/
tools: router({
@@ -342,7 +405,17 @@ export const appRouter = router({
z.object({
sessionId: z.string(),
action: z.object({
type: z.enum(["navigate", "click", "type", "extract", "screenshot", "scroll", "wait", "evaluate", "close"]),
type: z.enum([
"navigate",
"click",
"type",
"extract",
"screenshot",
"scroll",
"wait",
"evaluate",
"close",
]),
params: z.record(z.string(), z.unknown()),
}),
})
@@ -363,7 +436,10 @@ export const appRouter = router({
.input(z.object({ sessionId: z.string() }))
.mutation(async ({ input }) => {
const { executeBrowserAction } = await import("./browser-agent");
return executeBrowserAction(input.sessionId, { type: "close", params: {} });
return executeBrowserAction(input.sessionId, {
type: "close",
params: {},
});
}),
closeAllSessions: publicProcedure
@@ -403,11 +479,14 @@ export const appRouter = router({
description: z.string(),
category: z.string(),
dangerous: z.boolean(),
parameters: z.record(z.string(), z.object({
type: z.string(),
description: z.string(),
required: z.boolean().optional(),
})),
parameters: z.record(
z.string(),
z.object({
type: z.string(),
description: z.string(),
required: z.boolean().optional(),
})
),
implementation: z.string(),
})
)
@@ -557,7 +636,9 @@ export const appRouter = router({
DEFAULT_RETRY_CONFIG,
(attempt, error) => {
if (isRetryableError(error)) {
logRetryAttempt(attempt, error, { messageCount: input.messages.length });
logRetryAttempt(attempt, error, {
messageCount: input.messages.length,
});
} else {
// Non-retryable error, throw immediately
throw error;
@@ -570,7 +651,7 @@ export const appRouter = router({
tools: publicProcedure.query(async () => {
const gwTools = await getGatewayTools();
if (gwTools) {
return gwTools.map((t) => ({
return gwTools.map(t => ({
name: t.name,
description: t.description,
parameters: t.parameters,
@@ -579,7 +660,7 @@ export const appRouter = router({
}
// Fallback: Node.js tool definitions
const { ORCHESTRATOR_TOOLS } = await import("./orchestrator");
return ORCHESTRATOR_TOOLS.map((t) => ({
return ORCHESTRATOR_TOOLS.map(t => ({
name: t.function.name,
description: t.function.description,
parameters: t.function.parameters,
@@ -623,11 +704,12 @@ export const appRouter = router({
const days = Math.floor(uptimeSec / 86400);
const hours = Math.floor((uptimeSec % 86400) / 3600);
const mins = Math.floor((uptimeSec % 3600) / 60);
const uptime = days > 0
? `${days}d ${hours}h ${mins}m`
: hours > 0
? `${hours}h ${mins}m`
: `${mins}m`;
const uptime =
days > 0
? `${days}d ${hours}h ${mins}m`
: hours > 0
? `${hours}h ${mins}m`
: `${mins}m`;
// 2. Container / node stats from Go Gateway
const [nodesResult, statsResult] = await Promise.allSettled([
@@ -635,19 +717,22 @@ export const appRouter = router({
getGatewayNodeStats(),
]);
const nodes = nodesResult.status === "fulfilled" && nodesResult.value
? nodesResult.value
: null;
const statsData = statsResult.status === "fulfilled" && statsResult.value
? statsResult.value
: null;
const nodes =
nodesResult.status === "fulfilled" && nodesResult.value
? nodesResult.value
: null;
const statsData =
statsResult.status === "fulfilled" && statsResult.value
? statsResult.value
: null;
const containerCount = nodes?.containers?.length ?? nodes?.count ?? 0;
const totalContainers = nodes?.containers?.length ?? 0;
// CPU: average across all containers
const cpuPct = statsData?.stats?.length
? statsData.stats.reduce((sum, s) => sum + s.cpuPct, 0) / statsData.stats.length
? statsData.stats.reduce((sum, s) => sum + s.cpuPct, 0) /
statsData.stats.length
: 0;
// MEM: total used MB
@@ -779,7 +864,9 @@ export const appRouter = router({
taskId: z.number(),
title: z.string().optional(),
description: z.string().optional(),
status: z.enum(["pending", "in_progress", "completed", "failed", "blocked"]).optional(),
status: z
.enum(["pending", "in_progress", "completed", "failed", "blocked"])
.optional(),
priority: z.enum(["low", "medium", "high", "critical"]).optional(),
result: z.string().optional(),
errorMessage: z.string().optional(),
@@ -857,7 +944,11 @@ export const appRouter = router({
)
.mutation(async ({ input }) => {
const { createResearchTasks } = await import("./web-research");
return createResearchTasks(input.agentId, input.conversationId, input.queries);
return createResearchTasks(
input.agentId,
input.conversationId,
input.queries
);
}),
}),
});