feat: Implement agent resummoning with UI retry, add provider verification, and introduce created_at timestamps to various tables.

This commit is contained in:
viettranx committed 2026-02-24 15:18:25 +07:00
1 parent 48878848df
commit 16022d77be
17 files changed
+466 -51

No files matched your search

+1 -1
View File
@@ -314,7 +314,7 @@ func wireManagedHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus,
}
if stores != nil && stores.Providers != nil {
providersH = httpapi.NewProvidersHandler(stores.Providers, token)
providersH = httpapi.NewProvidersHandler(stores.Providers, token, providerReg)
}
return agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH
+9
View File
@@ -21,6 +21,15 @@ var templateFiles = []string{
HeartbeatFile,
}
// ReadTemplate returns the content of an embedded template file.
func ReadTemplate(name string) (string, error) {
content, err := templateFS.ReadFile(filepath.Join("templates", name))
if err != nil {
return "", err
}
return string(content), nil
}
// EnsureWorkspaceFiles seeds template files into a workspace directory.
// Only writes files that don't already exist (will not overwrite).
// BOOTSTRAP.md is only seeded if the workspace is brand new (no AGENTS.md exists).
+45
View File
@@ -49,6 +49,7 @@ func (h *AgentsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /v1/agents/{id}/shares", h.authMiddleware(h.handleShare))
mux.HandleFunc("DELETE /v1/agents/{id}/shares/{userID}", h.authMiddleware(h.handleRevokeShare))
mux.HandleFunc("POST /v1/agents/{id}/regenerate", h.authMiddleware(h.handleRegenerate))
mux.HandleFunc("POST /v1/agents/{id}/resummon", h.authMiddleware(h.handleResummon))
}
func (h *AgentsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
@@ -422,6 +423,50 @@ func (h *AgentsHandler) handleRegenerate(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusAccepted, map[string]string{"ok": "true", "status": "summoning"})
}
// handleResummon re-runs SummonAgent from scratch using the original description.
// Used when initial summoning failed (e.g. wrong model) and user wants to retry.
func (h *AgentsHandler) handleResummon(w http.ResponseWriter, r *http.Request) {
userID := store.UserIDFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
return
}
ag, err := h.agents.GetByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
return
}
if userID != "" && ag.OwnerID != userID {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can resummon agent"})
return
}
if ag.Status == "summoning" {
writeJSON(w, http.StatusConflict, map[string]string{"error": "agent is already being summoned"})
return
}
if h.summoner == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "summoning not available"})
return
}
description := extractDescription(ag.OtherConfig)
if description == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent has no description to resummon from"})
return
}
if err := h.agents.Update(r.Context(), id, map[string]any{"status": "summoning"}); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
go h.summoner.SummonAgent(id, ag.Provider, ag.Model, description)
writeJSON(w, http.StatusAccepted, map[string]string{"ok": "true", "status": "summoning"})
}
// extractDescription pulls the description string from other_config JSONB.
func extractDescription(raw json.RawMessage) string {
if len(raw) == 0 {
+112
View File
@@ -0,0 +1,112 @@
package http
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// handleVerifyProvider tests a provider+model combination with a minimal LLM call.
//
// POST /v1/providers/{id}/verify
// Body: {"model": "anthropic/claude-sonnet-4"}
// Response: {"valid": true} or {"valid": false, "error": "..."}
func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid provider ID"})
return
}
var req struct {
Model string `json:"model"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if req.Model == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "model is required"})
return
}
// Look up provider record from DB to get the provider name
p, err := h.store.GetProvider(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider not found"})
return
}
if h.providerReg == nil {
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": "no provider registry available"})
return
}
provider, err := h.providerReg.Get(p.Name)
if err != nil {
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": "provider not registered: " + p.Name})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
_, err = provider.Chat(ctx, providers.ChatRequest{
Messages: []providers.Message{
{Role: "user", Content: "hi"},
},
Model: req.Model,
Options: map[string]interface{}{
"max_tokens": 1,
},
})
if err != nil {
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": friendlyVerifyError(err)})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": true})
}
// friendlyVerifyError extracts a human-readable message from provider errors.
// Raw errors often contain JSON blobs like: `HTTP 400: minimax: {"type":"error","error":{"type":"bad_request_error","message":"unknown model ..."}}`
func friendlyVerifyError(err error) string {
msg := err.Error()
// Try to extract "message" field from embedded JSON
if idx := strings.Index(msg, `"message"`); idx >= 0 {
// Find the value after "message":
rest := msg[idx:]
// Look for :"<value>"
start := strings.Index(rest, `:`)
if start >= 0 {
rest = strings.TrimLeft(rest[start+1:], " ")
if len(rest) > 0 && rest[0] == '"' {
rest = rest[1:]
if end := strings.Index(rest, `"`); end >= 0 {
extracted := rest[:end]
if extracted != "" {
return extracted
}
}
}
}
}
// Fallback: strip "HTTP NNN: provider: " prefix for cleaner display
if idx := strings.LastIndex(msg, ": "); idx >= 0 && idx < len(msg)-2 {
suffix := msg[idx+2:]
// If the remainder still looks like JSON, just say "invalid model"
if strings.HasPrefix(suffix, "{") {
return "Model not recognized by provider"
}
return suffix
}
return msg
}
+9 -4
View File
@@ -7,18 +7,20 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// ProvidersHandler handles LLM provider CRUD endpoints (managed mode).
type ProvidersHandler struct {
store store.ProviderStore
token string
store store.ProviderStore
token string
providerReg *providers.Registry
}
// NewProvidersHandler creates a handler for provider management endpoints.
func NewProvidersHandler(s store.ProviderStore, token string) *ProvidersHandler {
return &ProvidersHandler{store: s, token: token}
func NewProvidersHandler(s store.ProviderStore, token string, providerReg *providers.Registry) *ProvidersHandler {
return &ProvidersHandler{store: s, token: token, providerReg: providerReg}
}
// RegisterRoutes registers all provider management routes on the given mux.
@@ -32,6 +34,9 @@ func (h *ProvidersHandler) RegisterRoutes(mux *http.ServeMux) {
// Model listing (proxied to upstream provider API)
mux.HandleFunc("GET /v1/providers/{id}/models", h.auth(h.handleListProviderModels))
// Provider + model verification (pre-flight check)
mux.HandleFunc("POST /v1/providers/{id}/verify", h.auth(h.handleVerifyProvider))
}
func (h *ProvidersHandler) auth(next http.HandlerFunc) http.HandlerFunc {
+48 -17
View File
@@ -10,6 +10,7 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -48,9 +49,9 @@ func NewAgentSummoner(agents store.AgentStore, providerReg *providers.Registry,
// SummonAgent generates context files from a natural language description.
// Meant to be called as a goroutine: go summoner.SummonAgent(...)
// On success: stores generated files and sets agent status to "active".
// On failure: keeps template files (already seeded) and sets status to "active".
// On failure: keeps template files (already seeded) and sets status to "summon_failed".
func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, description string) {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
defer cancel()
s.emitEvent(agentID, "started", "", "")
@@ -60,7 +61,8 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc
slog.Warn("summoning: LLM generation failed, falling back to templates",
"agent", agentID, "error", err)
s.emitEvent(agentID, "failed", "", err.Error())
s.setAgentStatus(ctx, agentID, "active")
// Use fresh context — the original may have timed out, but we still need to update status.
s.setAgentStatus(context.Background(), agentID, "summon_failed")
return
}
@@ -75,7 +77,7 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc
// Reads existing files, sends them + edit instructions to LLM, stores results.
// Synchronous — caller should run in goroutine if needed.
func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model, editPrompt string) {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
defer cancel()
s.emitEvent(agentID, "started", "", "")
@@ -85,7 +87,7 @@ func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model,
if err != nil {
slog.Warn("summoning: failed to read existing files", "agent", agentID, "error", err)
s.emitEvent(agentID, "failed", "", err.Error())
s.setAgentStatus(ctx, agentID, "active")
s.setAgentStatus(context.Background(), agentID, "summon_failed")
return
}
@@ -95,7 +97,8 @@ func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model,
if err != nil {
slog.Warn("summoning: regeneration failed", "agent", agentID, "error", err)
s.emitEvent(agentID, "failed", "", err.Error())
s.setAgentStatus(ctx, agentID, "active")
// Use fresh context — the original may have timed out, but we still need to update status.
s.setAgentStatus(context.Background(), agentID, "summon_failed")
return
}
@@ -198,20 +201,45 @@ func (s *AgentSummoner) emitEvent(agentID uuid.UUID, eventType, fileName, errMsg
}
// buildCreatePrompt constructs the system + user prompt for initial file generation.
// Includes the full template files as reference so the LLM preserves core operational structure.
func (s *AgentSummoner) buildCreatePrompt(description string) string {
return fmt.Sprintf(`You are setting up a new AI assistant. Based on the description below, generate the content for these context files. Write in the same language as the description.
// Load templates as reference material
templates := make(map[string]string)
for _, name := range summoningFiles {
content, err := bootstrap.ReadTemplate(name)
if err != nil {
slog.Warn("summoning: failed to read template for prompt", "file", name, "error", err)
continue
}
templates[name] = content
}
<description>
%s
</description>
var sb strings.Builder
sb.WriteString("You are setting up a new AI assistant. Based on the description below, generate customized content for each context file.\n\n")
Generate each file inside XML tags. Follow the format and spirit of each file type:
fmt.Fprintf(&sb, "<description>\n%s\n</description>\n\n", description)
- **SOUL.md**: Who the agent IS. Core personality traits, communication style, boundaries, and values. Written in first person, like a character sheet. Not a job description — a soul.
- **IDENTITY.md**: Quick identity card. Name, Creature (what kind of entity?), Vibe (how they come across), Emoji (signature), Avatar (leave blank).
- **AGENTS.md**: Operating instructions. How the agent should behave each session, handle memory, manage safety, work in groups. Keep the core structure from the template but personalize for this agent's purpose.
- **TOOLS.md**: Notes about tools relevant to this agent's role. What environment-specific details matter.
- **HEARTBEAT.md**: Periodic tasks (if the agent should check things proactively). Leave minimal/empty if not applicable.
sb.WriteString("Below are the DEFAULT TEMPLATES for each file. Use them as the foundation — preserve the core structure and operational rules, but customize the content to match the agent's purpose and personality.\n\n")
sb.WriteString("<templates>\n")
for _, name := range summoningFiles {
if content, ok := templates[name]; ok {
fmt.Fprintf(&sb, "<file name=%q>\n%s\n</file>\n", name, content)
}
}
sb.WriteString("</templates>\n\n")
sb.WriteString(`IMPORTANT — Language rule: You MUST write ALL file content in the SAME LANGUAGE as the <description> above. If the description is in Vietnamese, write in Vietnamese. If in English, write in English. The templates below are in English — translate and adapt them to match the description's language. Only keep technical terms (file names, code, commands) in English.
Instructions for each file:
- **SOUL.md**: Rewrite to reflect this agent's unique personality, values, communication style, and boundaries. Keep the spirit of the template (genuine helpfulness, opinions, resourcefulness) but make it specific to this agent's role.
- **IDENTITY.md**: Fill in the identity card fields (Name, Creature, Vibe, Emoji) based on the description. Leave Avatar blank.
- **AGENTS.md**: This is CRITICAL — you MUST preserve the core operational sections (First Run, Every Session, Memory, Safety, External vs Internal, Group Chats, Heartbeats). Customize the content within each section to fit the agent's purpose, but do NOT remove any section. The operational structure is required for the system to function.
- **TOOLS.md**: Customize with tool notes relevant to this agent's role. Keep the structure.
- **HEARTBEAT.md**: Add periodic tasks if relevant to the agent's role. Leave minimal if not applicable.
Generate each file inside XML tags:
<file name="SOUL.md">
(generate here)
@@ -227,7 +255,9 @@ Generate each file inside XML tags. Follow the format and spirit of each file ty
</file>
<file name="HEARTBEAT.md">
(generate here)
</file>`, description)
</file>`)
return sb.String()
}
// buildEditPrompt constructs the prompt for editing existing files.
@@ -242,6 +272,7 @@ func (s *AgentSummoner) buildEditPrompt(existing []store.AgentContextFileData, e
}
sb.WriteString("</current_files>\n\n")
fmt.Fprintf(&sb, "<edit_instructions>\n%s\n</edit_instructions>\n\n", editPrompt)
sb.WriteString("IMPORTANT — Language rule: Write ALL content in the SAME LANGUAGE as the existing files above. If the current files are in Vietnamese, write in Vietnamese. Only keep technical terms (file names, code, commands) in English.\n\n")
sb.WriteString("Generate updated files. Only include files that need changes. Keep the same XML format:\n\n")
sb.WriteString("<file name=\"SOUL.md\">\n(updated content, or omit if unchanged)\n</file>\n")
sb.WriteString("...\n")
@@ -0,0 +1,9 @@
ALTER TABLE agent_context_files DROP COLUMN IF EXISTS created_at;
ALTER TABLE config_secrets DROP COLUMN IF EXISTS created_at;
ALTER TABLE cron_run_logs DROP COLUMN IF EXISTS created_at;
ALTER TABLE embedding_cache DROP COLUMN IF EXISTS created_at;
ALTER TABLE memory_chunks DROP COLUMN IF EXISTS created_at;
ALTER TABLE memory_documents DROP COLUMN IF EXISTS created_at;
ALTER TABLE user_context_files DROP COLUMN IF EXISTS created_at;
ALTER TABLE user_agent_overrides DROP COLUMN IF EXISTS created_at;
ALTER TABLE user_agent_overrides DROP COLUMN IF EXISTS updated_at;
+12
View File
@@ -0,0 +1,12 @@
-- Add created_at to tables that only have updated_at (or no timestamp at all).
-- Existing rows get NOW() as default (close enough for backfill).
ALTER TABLE agent_context_files ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW();
ALTER TABLE config_secrets ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW();
ALTER TABLE cron_run_logs ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW();
ALTER TABLE embedding_cache ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW();
ALTER TABLE memory_chunks ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW();
ALTER TABLE memory_documents ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW();
ALTER TABLE user_context_files ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW();
ALTER TABLE user_agent_overrides ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW();
ALTER TABLE user_agent_overrides ADD COLUMN updated_at TIMESTAMPTZ DEFAULT NOW();
+1 -1
View File
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
+22 -2
View File
@@ -1,15 +1,17 @@
import { Bot, Star } from "lucide-react";
import { Bot, Star, RotateCcw } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { AgentData } from "@/types/agent";
interface AgentCardProps {
agent: AgentData;
onClick: () => void;
onResummon?: () => void;
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function AgentCard({ agent, onClick }: AgentCardProps) {
export function AgentCard({ agent, onClick, onResummon }: AgentCardProps) {
const displayName = agent.display_name
|| (UUID_RE.test(agent.agent_key) ? "Unnamed Agent" : agent.agent_key);
@@ -42,6 +44,10 @@ export function AgentCard({ agent, onClick }: AgentCardProps) {
<Badge variant="outline" className="shrink-0 animate-pulse border-violet-400 text-violet-600 dark:text-violet-400">
Summoning...
</Badge>
) : agent.status === "summon_failed" ? (
<Badge variant="destructive" className="shrink-0">
Failed
</Badge>
) : (
<Badge variant={agent.status === "active" ? "success" : "secondary"} className="shrink-0">
{agent.status}
@@ -64,6 +70,20 @@ export function AgentCard({ agent, onClick }: AgentCardProps) {
{(agent.context_window / 1000).toFixed(0)}K ctx
</span>
)}
{agent.status === "summon_failed" && onResummon && (
<Button
variant="outline"
size="xs"
className="ml-auto"
onClick={(e) => {
e.stopPropagation();
onResummon();
}}
>
<RotateCcw className="h-3 w-3" />
Resummon
</Button>
)}
</div>
</button>
);
+47 -12
View File
@@ -1,4 +1,4 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import {
Dialog,
DialogContent,
@@ -22,6 +22,7 @@ import type { AgentData } from "@/types/agent";
import { slugify, isValidSlug } from "@/lib/slug";
import { useProviders } from "@/pages/providers/hooks/use-providers";
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify";
import { AGENT_PRESETS } from "./agent-presets";
interface AgentCreateDialogProps {
@@ -49,6 +50,17 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
[enabledProviders, provider],
);
const { models, loading: modelsLoading } = useProviderModels(selectedProviderId);
const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify();
// Reset verification when provider or model changes
useEffect(() => {
resetVerify();
}, [provider, model, resetVerify]);
const handleVerify = async () => {
if (!selectedProviderId || !model.trim()) return;
await verify(selectedProviderId, model.trim());
};
const handleCreate = async () => {
if (!agentKey.trim()) return;
@@ -84,7 +96,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>Create Agent</DialogTitle>
</DialogHeader>
@@ -145,13 +157,32 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
</div>
<div className="space-y-2">
<Label>Model *</Label>
<Combobox
value={model}
onChange={setModel}
options={models.map((m) => ({ value: m.id, label: m.name }))}
placeholder={modelsLoading ? "Loading models..." : "Enter or select model"}
/>
{provider && !modelsLoading && models.length === 0 && (
<div className="flex gap-2">
<div className="flex-1">
<Combobox
value={model}
onChange={setModel}
options={models.map((m) => ({ value: m.id, label: m.name }))}
placeholder={modelsLoading ? "Loading models..." : "Enter or select model"}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-9 px-3"
disabled={!selectedProviderId || !model.trim() || verifying}
onClick={handleVerify}
>
{verifying ? "..." : "Check"}
</Button>
</div>
{verifyResult && (
<p className={`text-xs ${verifyResult.valid ? "text-emerald-400" : "text-red-400"}`}>
{verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}
</p>
)}
{!verifyResult && provider && !modelsLoading && models.length === 0 && (
<p className="text-xs text-muted-foreground">This provider doesn't list models — type the model ID manually.</p>
)}
</div>
@@ -218,9 +249,13 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={!displayName.trim() || !agentKey.trim() || !isValidSlug(agentKey) || !provider.trim() || !model.trim() || loading}>
{loading ? "Creating..." : "Create"}
</Button>
{loading ? (
<Button disabled>Creating...</Button>
) : (
<Button onClick={handleCreate} disabled={!displayName.trim() || !agentKey.trim() || !isValidSlug(agentKey) || !provider.trim() || !model.trim() || !verifyResult?.valid}>
Create
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1,3 +1,4 @@
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -7,6 +8,7 @@ import { AgentGeneralTab } from "./agent-general-tab";
import { AgentConfigTab } from "./agent-config-tab";
import { AgentFilesTab } from "./agent-files-tab";
import { AgentSharesTab } from "./agent-shares-tab";
import { SummoningModal } from "../summoning-modal";
import { DeferredSpinner } from "@/components/shared/loading-skeleton";
interface AgentDetailPageProps {
@@ -31,8 +33,14 @@ function agentSubtitle(agent: { display_name?: string; agent_key: string; id: st
}
export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
const { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent } =
const { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent, refresh } =
useAgentDetail(agentId);
const [summoningOpen, setSummoningOpen] = useState(false);
const handleRegenerate = async (prompt: string) => {
await regenerateAgent(prompt);
setSummoningOpen(true);
};
if (loading || !agent) {
return (
@@ -64,8 +72,8 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
{agent.is_default && (
<Star className="h-4 w-4 shrink-0 fill-amber-400 text-amber-400" />
)}
<Badge variant={agent.status === "active" ? "success" : "secondary"}>
{agent.status}
<Badge variant={agent.status === "active" ? "success" : agent.status === "summon_failed" ? "destructive" : "secondary"}>
{agent.status === "summon_failed" ? "Summon Failed" : agent.status}
</Badge>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
@@ -110,7 +118,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
files={files}
onGetFile={getFile}
onSetFile={setFile}
onRegenerate={regenerateAgent}
onRegenerate={handleRegenerate}
/>
</TabsContent>
@@ -119,6 +127,14 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
</TabsContent>
</Tabs>
</div>
<SummoningModal
open={summoningOpen}
onOpenChange={setSummoningOpen}
agentId={agentId}
agentName={title}
onCompleted={refresh}
/>
</div>
);
}
@@ -49,7 +49,10 @@ export function AgentFilesTab({ agent, files, onGetFile, onSetFile, onRegenerate
const canEdit = !isPredefined || isOwner;
// Hide MEMORY.json — memory is managed by the memory system, not context files
const displayFiles = files.filter((f) => f.name !== "MEMORY.json");
// Hide BOOTSTRAP.md for predefined agents — it's a per-user onboarding file, not agent-level
const displayFiles = files.filter((f) =>
f.name !== "MEMORY.json" && !(isPredefined && f.name === "BOOTSTRAP.md"),
);
const allMissing = displayFiles.length > 0 && displayFiles.every((f) => f.missing);
// USER.md is per-user at runtime — not editable from the agent admin UI for predefined agents
@@ -1,4 +1,4 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import { Save, Copy, Check, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -15,6 +15,7 @@ import {
import { Combobox } from "@/components/ui/combobox";
import { useProviders } from "@/pages/providers/hooks/use-providers";
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify";
import type { AgentData } from "@/types/agent";
interface AgentGeneralTabProps {
@@ -35,6 +36,21 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
[enabledProviders, provider],
);
const { models, loading: modelsLoading } = useProviderModels(selectedProviderId);
const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify();
// Track whether provider/model changed from saved values
const llmChanged = provider !== agent.provider || model !== agent.model;
// Reset verification when provider or model changes
useEffect(() => {
resetVerify();
}, [provider, model, resetVerify]);
const handleVerify = async () => {
if (!selectedProviderId || !model.trim()) return;
await verify(selectedProviderId, model.trim());
};
const [contextWindow, setContextWindow] = useState(agent.context_window || 200000);
const [maxToolIterations, setMaxToolIterations] = useState(agent.max_tool_iterations || 20);
const [restrictToWorkspace, setRestrictToWorkspace] = useState(agent.restrict_to_workspace);
@@ -118,6 +134,9 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
{status === "summon_failed" && (
<SelectItem value="summon_failed" disabled>Summon Failed</SelectItem>
)}
</SelectContent>
</Select>
</div>
@@ -163,12 +182,33 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
</div>
<div className="space-y-2">
<Label htmlFor="model">Model</Label>
<Combobox
value={model}
onChange={setModel}
options={models.map((m) => ({ value: m.id, label: m.name }))}
placeholder={modelsLoading ? "Loading models..." : "Enter or select model"}
/>
<div className="flex gap-2">
<div className="flex-1">
<Combobox
value={model}
onChange={setModel}
options={models.map((m) => ({ value: m.id, label: m.name }))}
placeholder={modelsLoading ? "Loading models..." : "Enter or select model"}
/>
</div>
{llmChanged && (
<Button
type="button"
variant="outline"
size="sm"
className="h-9 px-3"
disabled={!selectedProviderId || !model.trim() || verifying}
onClick={handleVerify}
>
{verifying ? "..." : "Check"}
</Button>
)}
</div>
{verifyResult && (
<p className={`text-xs ${verifyResult.valid ? "text-emerald-400" : "text-red-400"}`}>
{verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
@@ -238,7 +278,7 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
<Check className="h-3.5 w-3.5" /> Saved
</span>
)}
<Button onClick={handleSave} disabled={saving}>
<Button onClick={handleSave} disabled={saving || (llmChanged && !verifyResult?.valid)}>
{!saving && <Save className="h-4 w-4" />}
{saving ? "Saving..." : "Save Changes"}
</Button>
+12
View File
@@ -9,6 +9,7 @@ import { CardSkeleton } from "@/components/shared/loading-skeleton";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { useHttp } from "@/hooks/use-ws";
import { useAgents } from "./hooks/use-agents";
import { AgentCard } from "./agent-card";
import { AgentCreateDialog } from "./agent-create-dialog";
@@ -19,6 +20,7 @@ import { usePagination } from "@/hooks/use-pagination";
export function AgentsPage() {
const { id: detailId } = useParams<{ id: string }>();
const navigate = useNavigate();
const http = useHttp();
const { agents, loading, createAgent, deleteAgent, refresh } = useAgents();
const showSkeleton = useDeferredLoading(loading && agents.length === 0);
@@ -27,6 +29,15 @@ export function AgentsPage() {
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [summoningAgent, setSummoningAgent] = useState<{ id: string; name: string } | null>(null);
const handleResummon = async (agent: { id: string; display_name?: string; agent_key: string }) => {
try {
await http.post(`/v1/agents/${agent.id}/resummon`);
setSummoningAgent({ id: agent.id, name: agent.display_name || agent.agent_key });
} catch {
// error handled by http hook
}
};
// Show detail view if route has :id
if (detailId) {
return (
@@ -104,6 +115,7 @@ export function AgentsPage() {
navigate(`/agents/${agent.id}`);
}
}}
onResummon={() => handleResummon(agent)}
/>
))}
</div>
+25 -1
View File
@@ -6,6 +6,8 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useHttp } from "@/hooks/use-ws";
import { useWsEvent } from "@/hooks/use-ws-event";
interface SummoningModalProps {
@@ -31,9 +33,11 @@ export function SummoningModal({
agentName,
onCompleted,
}: SummoningModalProps) {
const http = useHttp();
const [generatedFiles, setGeneratedFiles] = useState<string[]>([]);
const [status, setStatus] = useState<"summoning" | "completed" | "failed">("summoning");
const [errorMsg, setErrorMsg] = useState("");
const [retrying, setRetrying] = useState(false);
// Reset state when modal opens
useEffect(() => {
@@ -70,6 +74,20 @@ export function SummoningModal({
useWsEvent("agent.summoning", handleSummoningEvent);
const handleRetry = async () => {
setRetrying(true);
try {
await http.post(`/v1/agents/${agentId}/resummon`);
setGeneratedFiles([]);
setStatus("summoning");
setErrorMsg("");
} catch {
// stay in failed state
} finally {
setRetrying(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" overlayTransparent onInteractOutside={(e) => { if (status === "summoning") e.preventDefault(); }}>
@@ -187,9 +205,15 @@ export function SummoningModal({
{status === "summoning" && (
<p className="text-center text-xs text-muted-foreground">
This usually takes 10-30 seconds. Please wait...
This usually takes few minutes. Please wait...
</p>
)}
{status === "failed" && (
<Button variant="outline" size="sm" onClick={handleRetry} disabled={retrying}>
{retrying ? "Retrying..." : "Retry"}
</Button>
)}
</div>
</DialogContent>
</Dialog>
@@ -0,0 +1,42 @@
import { useState, useCallback } from "react";
import { useHttp } from "@/hooks/use-ws";
interface VerifyResult {
valid: boolean;
error?: string;
}
export function useProviderVerify() {
const http = useHttp();
const [verifying, setVerifying] = useState(false);
const [result, setResult] = useState<VerifyResult | null>(null);
const verify = useCallback(
async (providerId: string, model: string) => {
setVerifying(true);
setResult(null);
try {
const res = await http.post<VerifyResult>(
`/v1/providers/${providerId}/verify`,
{ model },
);
setResult(res);
return res;
} catch (err) {
const r: VerifyResult = {
valid: false,
error: err instanceof Error ? err.message : "Verification failed",
};
setResult(r);
return r;
} finally {
setVerifying(false);
}
},
[http],
);
const reset = useCallback(() => setResult(null), []);
return { verify, verifying, result, reset };
}