From 7174fe581538cf0c2c6c22c5015f1c234c25ae0f Mon Sep 17 00:00:00 2001 From: Shivesh Tripathi Date: Tue, 29 Sep 2026 09:26:30 +0530 Subject: [PATCH 1/3] Admin: manage agent defaults and team skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an Agents tab to the admin area for each shared agent's workspace default (prompt, backend, model), how many people have their own settings, and a reset for them. The Agents page is unchanged — everything there still writes the caller's own prefs, admin or not. Also: admins can edit or delete any skill, and the agent typing indicator now stops when the agent posts rather than when the run ends. Frontend components are not tested yet, so that coverage gate will fail. --- cmd/server/main.go | 3 + internal/handler/agent.go | 94 ++++++- internal/handler/agent_coverage_test.go | 90 ++++++- internal/handler/agentrunner_coverage_test.go | 1 + .../handler/agentworkspace_coverage_test.go | 1 + .../codingtask_context_coverage_test.go | 3 + internal/handler/router.go | 2 + internal/service/agent.go | 126 +++++++++- internal/service/agent_coverage_test.go | 213 +++++++++++++++- internal/service/audit_fixes_coverage_test.go | 39 +++ internal/service/orchestrator.go | 12 + internal/service/orchestrator_test.go | 7 + internal/store/agent.go | 14 ++ internal/store/agent_test.go | 13 + src/App.tsx | 11 +- src/components/admin/AgentsAdminPanel.tsx | 238 ++++++++++++++++++ src/hooks/useAgents.ts | 79 ++++++ src/lib/admin-tabs.ts | 21 ++ src/pages/AdminAgentsPage.tsx | 20 ++ src/pages/AdminLayout.tsx | 52 ++++ src/pages/SkillsPage.tsx | 29 ++- 21 files changed, 1031 insertions(+), 37 deletions(-) create mode 100644 src/components/admin/AgentsAdminPanel.tsx create mode 100644 src/lib/admin-tabs.ts create mode 100644 src/pages/AdminAgentsPage.tsx create mode 100644 src/pages/AdminLayout.tsx diff --git a/cmd/server/main.go b/cmd/server/main.go index 1be56562..41031fb1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -265,6 +265,9 @@ func main() { agentStore := store.NewAgentStore(db) runStore := store.NewRunStore(db) agentSvc := service.NewAgentService(agentStore, userStore) + // Agent prefs are partitioned by user, so the admin panel's "how many + // people have their own settings" and its reset both walk the roster. + agentSvc.SetUserLister(userStore) orchestrator := service.NewOrchestrator(runStore, agentSvc, userStore, messageSvc, redisPubSub, jwtMgr) messageSvc.SetAgentDispatcher(orchestrator) // Deleting a chat sweeps its agent-run activity logs (a thread root sweeps diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 440e650d..77f3958b 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -60,6 +60,14 @@ type agentView struct { // page pre-fills the editor with the effective prompt and needs this to // know when an edit lands back ON the default (→ store "inherit"). DefaultPersona string `json:"defaultPersona"` + // DefaultHarness/DefaultModel/DefaultExecutionMode are the template's + // engine, for the same reason: the admin panel edits the WORKSPACE + // DEFAULT, and `resolved` is the caller's own once they have prefs — an + // admin who had customised an agent would otherwise be shown their own + // values labelled as everyone's. + DefaultHarness string `json:"defaultHarness"` + DefaultModel string `json:"defaultModel"` + DefaultExecutionMode string `json:"defaultExecutionMode"` } func (h *AgentHandler) view(r *http.Request, agent *model.User, callerID string) (agentView, error) { @@ -90,13 +98,16 @@ func (h *AgentHandler) view(r *http.Request, agent *model.User, callerID string) } } return agentView{ - ID: agent.ID, - DisplayName: agent.DisplayName, - Slug: slug, - Status: status, - Prefs: prefs, - Resolved: resolved, - DefaultPersona: tpl.Persona, + ID: agent.ID, + DisplayName: agent.DisplayName, + Slug: slug, + Status: status, + Prefs: prefs, + Resolved: resolved, + DefaultPersona: tpl.Persona, + DefaultHarness: tpl.Harness, + DefaultModel: tpl.Model, + DefaultExecutionMode: tpl.ExecutionMode, }, nil } @@ -149,13 +160,16 @@ func (h *AgentHandler) RenameAgent(w http.ResponseWriter, r *http.Request) { Harness string `json:"harness"` Model string `json:"model"` ExecutionMode string `json:"executionMode"` + // Template prompt. Blank = unchanged; the service refuses a blank + // prompt outright, so there is no "clear it" here by design. + Persona string `json:"persona"` } if err := readAgentJSON(r, &body, maxAgentBodyBytes); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "invalid body") return } - if body.DisplayName == "" && body.SkillIDs == nil && body.Harness == "" { - writeError(w, http.StatusBadRequest, "bad_request", "nothing to update — set displayName, skillIDs and/or harness") + if body.DisplayName == "" && body.SkillIDs == nil && body.Harness == "" && body.Persona == "" { + writeError(w, http.StatusBadRequest, "bad_request", "nothing to update — set displayName, skillIDs, harness and/or persona") return } if body.Harness == "" && (body.Model != "" || body.ExecutionMode != "") { @@ -194,6 +208,12 @@ func (h *AgentHandler) RenameAgent(w http.ResponseWriter, r *http.Request) { return } } + if body.Persona != "" { + if tpl, err = h.agents.SetAgentPersona(r.Context(), r.PathValue("slug"), body.Persona); err != nil { + fail(err, "set agent prompt") + return + } + } writeJSON(w, http.StatusOK, JSON{"agent": tpl}) } @@ -638,7 +658,15 @@ func (h *AgentHandler) CreateSkill(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, JSON{"skill": sk}) } -// UpdateSkill applies the author's edits. +// callerIsAdmin reports whether the request's signed claims carry the admin +// system role. Used where authority WIDENS a caller's reach inside a handler +// the middleware has already let through, rather than gating the route. +func callerIsAdmin(r *http.Request) bool { + claims := middleware.ClaimsFromContext(r.Context()) + return claims != nil && claims.SystemRole == model.SystemRoleAdmin +} + +// UpdateSkill applies the author's edits — or an admin's. // PATCH /api/v1/skills/{id} func (h *AgentHandler) UpdateSkill(w http.ResponseWriter, r *http.Request) { callerID := middleware.UserIDFromContext(r.Context()) @@ -647,7 +675,7 @@ func (h *AgentHandler) UpdateSkill(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "bad_request", "invalid body") return } - sk, err := h.agents.UpdateSkill(r.Context(), callerID, r.PathValue("id"), patch) + sk, err := h.agents.UpdateSkill(r.Context(), callerID, callerIsAdmin(r), r.PathValue("id"), patch) if err != nil { h.writeSkillError(w, err) return @@ -655,17 +683,57 @@ func (h *AgentHandler) UpdateSkill(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, JSON{"skill": sk}) } -// DeleteSkill removes a skill (author-only). +// DeleteSkill removes a skill (author, or any admin). // DELETE /api/v1/skills/{id} func (h *AgentHandler) DeleteSkill(w http.ResponseWriter, r *http.Request) { callerID := middleware.UserIDFromContext(r.Context()) - if err := h.agents.DeleteSkill(r.Context(), callerID, r.PathValue("id")); err != nil { + if err := h.agents.DeleteSkill(r.Context(), callerID, callerIsAdmin(r), r.PathValue("id")); err != nil { h.writeSkillError(w, err) return } writeJSON(w, http.StatusOK, JSON{"ok": true}) } +// CountOverrides reports how many people have their own settings for an +// agent, so an admin editing the workspace default can see how far it reaches. +// GET /api/v1/agents/{slug}/overrides +func (h *AgentHandler) CountOverrides(w http.ResponseWriter, r *http.Request) { + n, err := h.agents.CountAgentOverrides(r.Context(), r.PathValue("slug")) + if err != nil { + h.writeOverrideError(w, err, "count overrides") + return + } + writeJSON(w, http.StatusOK, JSON{"count": n}) +} + +// ResetOverrides clears everyone's personal settings for an agent so the +// template governs the whole workspace. +// +// Destructive and not undoable: a prefs row is one document, so this also +// clears each person's persona, limits, follow-up settings and pre-approved +// tool classes for this agent — not only the harness and model. The count +// comes back so the caller can report what it actually removed. +// DELETE /api/v1/agents/{slug}/overrides +func (h *AgentHandler) ResetOverrides(w http.ResponseWriter, r *http.Request) { + n, err := h.agents.ResetAgentOverrides(r.Context(), r.PathValue("slug")) + if err != nil { + h.writeOverrideError(w, err, "reset overrides") + return + } + writeJSON(w, http.StatusOK, JSON{"cleared": n}) +} + +func (h *AgentHandler) writeOverrideError(w http.ResponseWriter, err error, what string) { + switch { + case errors.Is(err, store.ErrNotFound): + writeError(w, http.StatusNotFound, "not_found", "unknown agent") + case errors.Is(err, service.ErrValidation): + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + default: + writeError(w, http.StatusInternalServerError, "internal", "failed to "+what) + } +} + func (h *AgentHandler) writeSkillError(w http.ResponseWriter, err error) { switch { case errors.Is(err, store.ErrNotFound): diff --git a/internal/handler/agent_coverage_test.go b/internal/handler/agent_coverage_test.go index 9cc97b79..a17939de 100644 --- a/internal/handler/agent_coverage_test.go +++ b/internal/handler/agent_coverage_test.go @@ -140,6 +140,14 @@ func (d *hagentCovDir) PutAgentPrefs(_ context.Context, prefs *model.UserAgentPr return nil } +func (d *hagentCovDir) DeleteAgentPrefs(_ context.Context, userID, slug string) error { + if err := d.trip("DeleteAgentPrefs"); err != nil { + return err + } + delete(d.prefs, userID+"|"+slug) + return nil +} + func (d *hagentCovDir) GetAgentPrefs(_ context.Context, userID, slug string) (*model.UserAgentPrefs, error) { if err := d.trip("GetAgentPrefs"); err != nil { return nil, err @@ -591,6 +599,7 @@ type hagentCovEnv struct { msgs *hagentCovMessages agentID string h *AgentHandler + svc *service.AgentService } // hagentCovNewEnv builds a handler over real services with one seeded shared @@ -616,7 +625,7 @@ func hagentCovNewEnv() *hagentCovEnv { jwtMgr := auth.NewJWTManager("hagent-cov-secret", 15*time.Minute, 720*time.Hour) orch := service.NewOrchestrator(runs, agentSvc, &hagentCovOrchUsers{users: users}, msgs, hagentCovPub{}, jwtMgr) h := NewAgentHandler(agentSvc, orch, userSvc, jwtMgr) - return &hagentCovEnv{dir: dir, runs: runs, users: users, msgs: msgs, agentID: agentID, h: h} + return &hagentCovEnv{dir: dir, runs: runs, users: users, msgs: msgs, agentID: agentID, h: h, svc: agentSvc} } // seedRun stores a run both by ID and in its parent's listing. @@ -1346,3 +1355,82 @@ func TestHagentCovDeleteSkill(t *testing.T) { rec = hagentCovDo(env.h.DeleteSkill, hagentCovReq(http.MethodDelete, "/api/v1/skills/sk1", "", "u1", map[string]string{"id": "sk1"})) hagentCovWant(t, rec, http.StatusOK) } + +// hagentCovRoster is a one-page user lister for the override walk. +type hagentCovRoster struct { + users []*model.User + err error +} + +func (r *hagentCovRoster) ListUsers(_ context.Context, _ int, _ string) ([]*model.User, string, error) { + return r.users, "", r.err +} + +func TestHagentCovOverrides(t *testing.T) { + env := hagentCovNewEnv() + slug := map[string]string{"slug": "gg"} + + // No roster wired: a validation error, not a 500 — the deployment is + // misconfigured, which is a different thing from the request being wrong. + rec := hagentCovDo(env.h.CountOverrides, hagentCovReq(http.MethodGet, "/api/v1/agents/gg/overrides", "", "u1", slug)) + hagentCovWant(t, rec, http.StatusBadRequest) + + env.svc.SetUserLister(&hagentCovRoster{users: []*model.User{ + {ID: "u1", DisplayName: "u1"}, + {ID: "u2", DisplayName: "u2"}, + }}) + + // An unknown agent is a 404, never a confident zero. + rec = hagentCovDo(env.h.CountOverrides, hagentCovReq(http.MethodGet, "/api/v1/agents/ghost/overrides", "", "u1", map[string]string{"slug": "ghost"})) + hagentCovWant(t, rec, http.StatusNotFound) + + // Nobody has customized gg yet. + rec = hagentCovDo(env.h.CountOverrides, hagentCovReq(http.MethodGet, "/api/v1/agents/gg/overrides", "", "u1", slug)) + hagentCovWant(t, rec, http.StatusOK) + if got := hagentCovJSON(t, rec)["count"]; got != float64(0) { + t.Fatalf("count = %v, want 0", got) + } + + // u2 customizes, so the count moves and the reset clears exactly that row. + if err := env.dir.PutAgentPrefs(context.Background(), &model.UserAgentPrefs{ + UserID: "u2", Slug: "gg", Harness: model.HarnessBedrock, + }); err != nil { + t.Fatalf("seed prefs: %v", err) + } + rec = hagentCovDo(env.h.CountOverrides, hagentCovReq(http.MethodGet, "/api/v1/agents/gg/overrides", "", "u1", slug)) + hagentCovWant(t, rec, http.StatusOK) + if got := hagentCovJSON(t, rec)["count"]; got != float64(1) { + t.Fatalf("count = %v, want 1", got) + } + + rec = hagentCovDo(env.h.ResetOverrides, hagentCovReq(http.MethodDelete, "/api/v1/agents/gg/overrides", "", "u1", slug)) + hagentCovWant(t, rec, http.StatusOK) + if got := hagentCovJSON(t, rec)["cleared"]; got != float64(1) { + t.Fatalf("cleared = %v, want 1", got) + } + + // A store failure is a 500 on both verbs. + env.dir.failFrom["GetAgentPrefs"] = 1 + rec = hagentCovDo(env.h.CountOverrides, hagentCovReq(http.MethodGet, "/api/v1/agents/gg/overrides", "", "u1", slug)) + hagentCovWant(t, rec, http.StatusInternalServerError) + rec = hagentCovDo(env.h.ResetOverrides, hagentCovReq(http.MethodDelete, "/api/v1/agents/gg/overrides", "", "u1", slug)) + hagentCovWant(t, rec, http.StatusInternalServerError) + delete(env.dir.failFrom, "GetAgentPrefs") +} + +// callerIsAdmin reads the SIGNED claims, not a body field — a member must not +// be able to widen their own reach over someone else's skill. +func TestHagentCovCallerIsAdmin(t *testing.T) { + req := httptest.NewRequest(http.MethodPatch, "/api/v1/skills/s1", nil) + if callerIsAdmin(req) { + t.Fatal("no claims must not read as admin") + } + req = req.WithContext(middleware.ContextWithClaims(req.Context(), &model.TokenClaims{UserID: "u1", SystemRole: model.SystemRoleMember})) + if callerIsAdmin(req) { + t.Fatal("member must not read as admin") + } + req = req.WithContext(middleware.ContextWithClaims(req.Context(), &model.TokenClaims{UserID: "u1", SystemRole: model.SystemRoleAdmin})) + if !callerIsAdmin(req) { + t.Fatal("admin claims must read as admin") + } +} diff --git a/internal/handler/agentrunner_coverage_test.go b/internal/handler/agentrunner_coverage_test.go index 13607bfd..26e58273 100644 --- a/internal/handler/agentrunner_coverage_test.go +++ b/internal/handler/agentrunner_coverage_test.go @@ -364,6 +364,7 @@ func (d *hrunnerCovDir) ListTemplates(context.Context) ([]*model.AgentTemplate, func (d *hrunnerCovDir) CreateAgentUser(context.Context, *model.User) error { return nil } func (d *hrunnerCovDir) PutAgentPrefs(context.Context, *model.UserAgentPrefs) error { return nil } +func (d *hrunnerCovDir) DeleteAgentPrefs(context.Context, string, string) error { return nil } func (d *hrunnerCovDir) GetAgentPrefs(context.Context, string, string) (*model.UserAgentPrefs, error) { return nil, store.ErrNotFound } diff --git a/internal/handler/agentworkspace_coverage_test.go b/internal/handler/agentworkspace_coverage_test.go index 809b5c35..286e5871 100644 --- a/internal/handler/agentworkspace_coverage_test.go +++ b/internal/handler/agentworkspace_coverage_test.go @@ -215,6 +215,7 @@ func (hwsCovAgentDir) ListTemplates(context.Context) ([]*model.AgentTemplate, er } func (hwsCovAgentDir) CreateAgentUser(context.Context, *model.User) error { return nil } func (hwsCovAgentDir) PutAgentPrefs(context.Context, *model.UserAgentPrefs) error { return nil } +func (hwsCovAgentDir) DeleteAgentPrefs(context.Context, string, string) error { return nil } func (hwsCovAgentDir) GetAgentPrefs(context.Context, string, string) (*model.UserAgentPrefs, error) { return nil, store.ErrNotFound } diff --git a/internal/handler/codingtask_context_coverage_test.go b/internal/handler/codingtask_context_coverage_test.go index 7600efe6..6a25bb09 100644 --- a/internal/handler/codingtask_context_coverage_test.go +++ b/internal/handler/codingtask_context_coverage_test.go @@ -507,6 +507,9 @@ func (f *htaskCovAgentDir) CreateAgentUser(context.Context, *model.User) error { func (f *htaskCovAgentDir) PutAgentPrefs(context.Context, *model.UserAgentPrefs) error { return nil } +func (f *htaskCovAgentDir) DeleteAgentPrefs(context.Context, string, string) error { + return nil +} func (f *htaskCovAgentDir) GetAgentPrefs(context.Context, string, string) (*model.UserAgentPrefs, error) { return nil, store.ErrNotFound } diff --git a/internal/handler/router.go b/internal/handler/router.go index 436dbf20..32880d90 100644 --- a/internal/handler/router.go +++ b/internal/handler/router.go @@ -293,6 +293,8 @@ func NewRouter(d *Deps) http.Handler { mux.Handle("POST /api/v1/agents", middleware.WrapFunc(d.Agent.CreateAgent, authMW, middleware.RequireSystemRole(model.SystemRoleAdmin), writeLimit)) mux.Handle("PATCH /api/v1/agents/{slug}", middleware.WrapFunc(d.Agent.RenameAgent, authMW, middleware.RequireSystemRole(model.SystemRoleAdmin), writeLimit)) mux.Handle("PATCH /api/v1/agents/{slug}/prefs", middleware.WrapFunc(d.Agent.UpdatePrefs, authMW)) + mux.Handle("GET /api/v1/agents/{slug}/overrides", middleware.WrapFunc(d.Agent.CountOverrides, authMW, middleware.RequireSystemRole(model.SystemRoleAdmin))) + mux.Handle("DELETE /api/v1/agents/{slug}/overrides", middleware.WrapFunc(d.Agent.ResetOverrides, authMW, middleware.RequireSystemRole(model.SystemRoleAdmin), writeLimit)) // Rate-limited like every other write: minting is cheap for the caller // and signs a long-lived credential, so it must not be the one POST a // client can hammer freely. diff --git a/internal/service/agent.go b/internal/service/agent.go index a506999a..279f741c 100644 --- a/internal/service/agent.go +++ b/internal/service/agent.go @@ -36,6 +36,7 @@ type AgentDirectoryStore interface { CreateAgentUser(ctx context.Context, user *model.User) error PutAgentPrefs(ctx context.Context, prefs *model.UserAgentPrefs) error GetAgentPrefs(ctx context.Context, userID, slug string) (*model.UserAgentPrefs, error) + DeleteAgentPrefs(ctx context.Context, userID, slug string) error PutRunner(ctx context.Context, reg *model.RunnerRegistration) error ListRunners(ctx context.Context, ownerID string) ([]*model.RunnerRegistration, error) PutSkill(ctx context.Context, sk *model.Skill) error @@ -63,12 +64,22 @@ type agentUserGetter interface { UpdateUser(ctx context.Context, user *model.User) error } +// agentUserLister enumerates the workspace roster. Per-user agent prefs are +// partitioned BY USER (PK=user#…, SK=agentprefs#), so "everyone who +// customized this agent" has no index that can answer it — it is a walk of +// the roster. Behind its own interface, and only ever driven by +// admin-initiated, on-demand calls; never a hot path. +type agentUserLister interface { + ListUsers(ctx context.Context, limit int, cursor string) ([]*model.User, string, error) +} + // AgentService owns agent templates, the shared agent users, and per-user // preference resolution. Runs are the Orchestrator's business. type AgentService struct { - agents AgentDirectoryStore - users agentUserGetter - indexer UserIndexer + agents AgentDirectoryStore + users agentUserGetter + indexer UserIndexer + userList agentUserLister } // NewAgentService constructs an AgentService. @@ -76,6 +87,9 @@ func NewAgentService(agents AgentDirectoryStore, users agentUserGetter) *AgentSe return &AgentService{agents: agents, users: users} } +// SetUserLister wires the roster walk the override count and reset need. +func (s *AgentService) SetUserLister(l agentUserLister) { s.userList = l } + // SetIndexer wires the search indexer. Optional: deployments without a search // client resolve user search by linear scan instead, and need no index. func (s *AgentService) SetIndexer(i UserIndexer) { s.indexer = i } @@ -450,6 +464,100 @@ func (s *AgentService) SetAgentEngine(ctx context.Context, slug, harness, mdl, e return tpl, nil } +// SetAgentPersona replaces the template's prompt — the instructions every +// member inherits unless they have written their own. Admin-gated at the route. +// +// Blank is refused rather than treated as "clear": Resolve falls back to the +// template persona, so emptying it would leave everyone without an override +// running with no instructions at all. +func (s *AgentService) SetAgentPersona(ctx context.Context, slug, persona string) (*model.AgentTemplate, error) { + persona = strings.TrimSpace(persona) + if persona == "" { + return nil, fmt.Errorf("agent: prompt is required: %w", ErrValidation) + } + tpl, err := s.agents.GetTemplate(ctx, strings.ToLower(strings.TrimSpace(slug))) + if err != nil { + return nil, err + } + tpl.Persona = persona + tpl.UpdatedAt = time.Now() + if err := s.agents.PutTemplate(ctx, tpl); err != nil { + return nil, fmt.Errorf("agent: set persona: %w", err) + } + return tpl, nil +} + +// agentOverridePage bounds one roster page during an override walk. +const agentOverridePage = 200 + +// forEachOverride walks the roster and visits every user holding their own +// prefs row for slug. Agents are skipped: they never invoke, so they never +// have prefs. A missing row is the normal case (inherit everything), not an +// error. +func (s *AgentService) forEachOverride(ctx context.Context, slug string, visit func(userID string) error) (int, error) { + if s.userList == nil { + return 0, fmt.Errorf("agent: roster walk unavailable: %w", ErrValidation) + } + slug = strings.ToLower(strings.TrimSpace(slug)) + // Fail on an unknown slug rather than reporting a confident zero — a typo + // must not read as "nobody has customized this". + if _, err := s.agents.GetTemplate(ctx, slug); err != nil { + return 0, err + } + n, cursor := 0, "" + for { + users, next, err := s.userList.ListUsers(ctx, agentOverridePage, cursor) + if err != nil { + return n, fmt.Errorf("agent: list users: %w", err) + } + for _, u := range users { + if u.IsAgent() { + continue + } + if _, err := s.agents.GetAgentPrefs(ctx, u.ID, slug); err != nil { + if errors.Is(err, store.ErrNotFound) { + continue + } + return n, fmt.Errorf("agent: prefs for %s: %w", u.ID, err) + } + n++ + if visit != nil { + if err := visit(u.ID); err != nil { + return n, err + } + } + } + if next == "" { + return n, nil + } + cursor = next + } +} + +// CountAgentOverrides reports how many people have customized this agent, so +// an admin editing the workspace default can see how far that default +// actually reaches. +func (s *AgentService) CountAgentOverrides(ctx context.Context, slug string) (int, error) { + return s.forEachOverride(ctx, slug, nil) +} + +// ResetAgentOverrides deletes everyone's personal settings for one agent, so +// the template becomes the effective config for the whole workspace. +// +// DESTRUCTIVE and not undoable: a prefs row is one document, so this clears a +// person's persona, limits, follow-up settings and pre-approved tool classes +// for this agent too — not just the harness and model an admin was thinking +// about. The route is admin-gated and the UI confirms; this returns how many +// rows it removed so the caller can say so. +func (s *AgentService) ResetAgentOverrides(ctx context.Context, slug string) (int, error) { + return s.forEachOverride(ctx, slug, func(userID string) error { + if err := s.agents.DeleteAgentPrefs(ctx, userID, slug); err != nil { + return fmt.Errorf("agent: clear prefs for %s: %w", userID, err) + } + return nil + }) +} + // defaultAPIModel is the model id used when an API harness has no explicit // pin. Bedrock ids are inference-profile / model ids in the account's region; // this default is the EU cross-region Claude Opus 5 profile (the Claude 5 @@ -743,13 +851,15 @@ func (s *AgentService) CreateSkill(ctx context.Context, authorID, name, descript return sk, nil } -// UpdateSkill applies the author's edits. -func (s *AgentService) UpdateSkill(ctx context.Context, callerID, id string, patch SkillPatch) (*model.Skill, error) { +// UpdateSkill applies the author's edits. Admins may edit anyone's skill: a +// skill is workspace-visible once published, so a bad one is everyone's +// problem and waiting for its author is not a fix. +func (s *AgentService) UpdateSkill(ctx context.Context, callerID string, asAdmin bool, id string, patch SkillPatch) (*model.Skill, error) { sk, err := s.agents.GetSkill(ctx, id) if err != nil { return nil, err } - if sk.CreatedBy != callerID { + if sk.CreatedBy != callerID && !asAdmin { return nil, fmt.Errorf("agent: not the skill author: %w", ErrForbidden) } if patch.Name != nil { @@ -779,12 +889,12 @@ func (s *AgentService) UpdateSkill(ctx context.Context, callerID, id string, pat } // DeleteSkill removes a skill (author-only). -func (s *AgentService) DeleteSkill(ctx context.Context, callerID, id string) error { +func (s *AgentService) DeleteSkill(ctx context.Context, callerID string, asAdmin bool, id string) error { sk, err := s.agents.GetSkill(ctx, id) if err != nil { return err } - if sk.CreatedBy != callerID { + if sk.CreatedBy != callerID && !asAdmin { return fmt.Errorf("agent: not the skill author: %w", ErrForbidden) } return s.agents.DeleteSkill(ctx, id) diff --git a/internal/service/agent_coverage_test.go b/internal/service/agent_coverage_test.go index 418be2fe..9315c69b 100644 --- a/internal/service/agent_coverage_test.go +++ b/internal/service/agent_coverage_test.go @@ -83,6 +83,13 @@ func (d *agentCovDir) PutAgentPrefs(ctx context.Context, prefs *model.UserAgentP return d.fakeAgentDir.PutAgentPrefs(ctx, prefs) } +func (d *agentCovDir) DeleteAgentPrefs(ctx context.Context, userID, slug string) error { + if err := d.errs["DeleteAgentPrefs"]; err != nil { + return err + } + return d.fakeAgentDir.DeleteAgentPrefs(ctx, userID, slug) +} + func (d *agentCovDir) PutSkill(ctx context.Context, sk *model.Skill) error { if err := d.errs["PutSkill"]; err != nil { return err @@ -665,21 +672,21 @@ func TestAgentCovSkillsCRUD(t *testing.T) { t.Fatalf("create skill: %v %+v", err, sk) } - if _, err := svc.UpdateSkill(ctx, "u1", "ghost", SkillPatch{}); !errors.Is(err, store.ErrNotFound) { + if _, err := svc.UpdateSkill(ctx, "u1", false, "ghost", SkillPatch{}); !errors.Is(err, store.ErrNotFound) { t.Fatalf("update: want not found, got %v", err) } - if _, err := svc.UpdateSkill(ctx, "intruder", sk.ID, SkillPatch{}); !errors.Is(err, ErrForbidden) { + if _, err := svc.UpdateSkill(ctx, "intruder", false, sk.ID, SkillPatch{}); !errors.Is(err, ErrForbidden) { t.Fatalf("update: want forbidden, got %v", err) } - if _, err := svc.UpdateSkill(ctx, "u1", sk.ID, SkillPatch{Name: sp(" ")}); !errors.Is(err, ErrValidation) { + if _, err := svc.UpdateSkill(ctx, "u1", false, sk.ID, SkillPatch{Name: sp(" ")}); !errors.Is(err, ErrValidation) { t.Fatalf("update: want validation, got %v", err) } dir.errs["PutSkill"] = errAgentCov - if _, err := svc.UpdateSkill(ctx, "u1", sk.ID, SkillPatch{Name: sp("New")}); !errors.Is(err, errAgentCov) { + if _, err := svc.UpdateSkill(ctx, "u1", false, sk.ID, SkillPatch{Name: sp("New")}); !errors.Is(err, errAgentCov) { t.Fatalf("update: want put error, got %v", err) } delete(dir.errs, "PutSkill") - upd, err := svc.UpdateSkill(ctx, "u1", sk.ID, SkillPatch{Name: sp(" New "), Description: sp(" nd "), Instructions: sp("ni")}) + upd, err := svc.UpdateSkill(ctx, "u1", false, sk.ID, SkillPatch{Name: sp(" New "), Description: sp(" nd "), Instructions: sp("ni")}) if err != nil || upd.Name != "New" || upd.Description != "nd" || upd.Instructions != "ni" { t.Fatalf("update skill: %v %+v", err, upd) } @@ -691,13 +698,13 @@ func TestAgentCovSkillsCRUD(t *testing.T) { t.Fatalf("list skills: %v (%d)", err, len(all)) } - if err := svc.DeleteSkill(ctx, "u1", "ghost"); !errors.Is(err, store.ErrNotFound) { + if err := svc.DeleteSkill(ctx, "u1", false, "ghost"); !errors.Is(err, store.ErrNotFound) { t.Fatalf("delete: want not found, got %v", err) } - if err := svc.DeleteSkill(ctx, "intruder", sk.ID); !errors.Is(err, ErrForbidden) { + if err := svc.DeleteSkill(ctx, "intruder", false, sk.ID); !errors.Is(err, ErrForbidden) { t.Fatalf("delete: want forbidden, got %v", err) } - if err := svc.DeleteSkill(ctx, "u1", sk.ID); err != nil { + if err := svc.DeleteSkill(ctx, "u1", false, sk.ID); err != nil { t.Fatalf("delete skill: %v", err) } if _, err := svc.GetSkill(ctx, sk.ID); !errors.Is(err, store.ErrNotFound) { @@ -945,10 +952,10 @@ func TestAgentCovSkillVisibility(t *testing.T) { } // UpdateSkill visibility patch: publish, then reject a bogus value. - if upd, err := svc.UpdateSkill(ctx, "owner", priv.ID, SkillPatch{Visibility: sp(model.SkillVisibilityPublished)}); err != nil || upd.Visibility != model.SkillVisibilityPublished { + if upd, err := svc.UpdateSkill(ctx, "owner", false, priv.ID, SkillPatch{Visibility: sp(model.SkillVisibilityPublished)}); err != nil || upd.Visibility != model.SkillVisibilityPublished { t.Fatalf("publish via patch: %v %+v", err, upd) } - if _, err := svc.UpdateSkill(ctx, "owner", priv.ID, SkillPatch{Visibility: sp("bogus")}); !errors.Is(err, ErrValidation) { + if _, err := svc.UpdateSkill(ctx, "owner", false, priv.ID, SkillPatch{Visibility: sp("bogus")}); !errors.Is(err, ErrValidation) { t.Fatalf("bogus visibility patch must be rejected, got %v", err) } @@ -1119,3 +1126,189 @@ func TestAgentService_CreateAgent_IndexesAgentUser(t *testing.T) { t.Fatal("a newly created agent must be findable in user search") } } + +// agentCovRoster is a paging user lister; pageSize forces the walk to cross +// pages so the cursor loop is exercised rather than assumed. +type agentCovRoster struct { + users []*model.User + pageSize int + err error +} + +func (r *agentCovRoster) ListUsers(_ context.Context, _ int, cursor string) ([]*model.User, string, error) { + if r.err != nil { + return nil, "", r.err + } + start := 0 + if cursor != "" { + fmt.Sscanf(cursor, "%d", &start) + } + end := start + r.pageSize + if end > len(r.users) { + end = len(r.users) + } + next := "" + if end < len(r.users) { + next = fmt.Sprintf("%d", end) + } + return r.users[start:end], next, nil +} + +func agentCovOverrideFixture(t *testing.T) (*AgentService, *agentCovDir, *agentCovUsers, *agentCovRoster) { + t.Helper() + svc, dir, users := agentCovNewSvc() + agentCovSeedAgent(t, dir, users, AgentSlugGG) + roster := &agentCovRoster{pageSize: 2} + for _, id := range []string{"u1", "u2", "u3", "u4"} { + roster.users = append(roster.users, &model.User{ID: id, DisplayName: id}) + } + // An agent user in the roster must be skipped: agents never invoke, so + // they never hold prefs, and counting one would inflate the figure an + // admin is shown. + roster.users = append(roster.users, &model.User{ + ID: AgentUserID(AgentSlugGG), Kind: model.UserKindAgent, + AgentConfig: &model.AgentConfig{TemplateSlug: AgentSlugGG}, + }) + svc.SetUserLister(roster) + return svc, dir, users, roster +} + +func TestAgentService_CountAndResetOverrides(t *testing.T) { + ctx := context.Background() + svc, dir, _, _ := agentCovOverrideFixture(t) + + // Nobody has customized anything yet. + if n, err := svc.CountAgentOverrides(ctx, AgentSlugGG); err != nil || n != 0 { + t.Fatalf("empty count: %d (%v)", n, err) + } + + for _, id := range []string{"u1", "u3"} { + if err := dir.PutAgentPrefs(ctx, &model.UserAgentPrefs{ + UserID: id, Slug: AgentSlugGG, Harness: model.HarnessBedrock, + }); err != nil { + t.Fatalf("seed prefs: %v", err) + } + } + // Slug casing/padding must not change the answer — it comes off a URL. + if n, err := svc.CountAgentOverrides(ctx, " GG "); err != nil || n != 2 { + t.Fatalf("count: %d (%v)", n, err) + } + + cleared, err := svc.ResetAgentOverrides(ctx, AgentSlugGG) + if err != nil || cleared != 2 { + t.Fatalf("reset: %d (%v)", cleared, err) + } + if n, err := svc.CountAgentOverrides(ctx, AgentSlugGG); err != nil || n != 0 { + t.Fatalf("after reset: %d (%v)", n, err) + } + // Resetting again is a no-op, not an error: an admin double-clicking must + // not see a failure. + if n, err := svc.ResetAgentOverrides(ctx, AgentSlugGG); err != nil || n != 0 { + t.Fatalf("second reset: %d (%v)", n, err) + } +} + +func TestAgentService_Overrides_Failures(t *testing.T) { + ctx := context.Background() + + // No lister wired (a deployment that never called SetUserLister). + bare, dirB, usersB := agentCovNewSvc() + agentCovSeedAgent(t, dirB, usersB, AgentSlugGG) + if _, err := bare.CountAgentOverrides(ctx, AgentSlugGG); !errors.Is(err, ErrValidation) { + t.Fatalf("no lister: %v", err) + } + + svc, dir, _, roster := agentCovOverrideFixture(t) + + // An unknown slug must fail, never report a confident zero. + if _, err := svc.CountAgentOverrides(ctx, "nosuchagent"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("unknown slug: %v", err) + } + + // Roster read failure. + roster.err = errAgentCov + if _, err := svc.CountAgentOverrides(ctx, AgentSlugGG); !errors.Is(err, errAgentCov) { + t.Fatalf("roster error: %v", err) + } + roster.err = nil + + // A prefs read that fails for a reason other than "absent" must surface. + dir.errs["GetAgentPrefs"] = errAgentCov + if _, err := svc.CountAgentOverrides(ctx, AgentSlugGG); !errors.Is(err, errAgentCov) { + t.Fatalf("prefs error: %v", err) + } + delete(dir.errs, "GetAgentPrefs") + + // A delete that fails stops the walk and reports. + if err := dir.PutAgentPrefs(ctx, &model.UserAgentPrefs{UserID: "u1", Slug: AgentSlugGG}); err != nil { + t.Fatalf("seed prefs: %v", err) + } + dir.errs["DeleteAgentPrefs"] = errAgentCov + if _, err := svc.ResetAgentOverrides(ctx, AgentSlugGG); !errors.Is(err, errAgentCov) { + t.Fatalf("delete error: %v", err) + } + delete(dir.errs, "DeleteAgentPrefs") +} + +// An admin may repair or remove a skill someone else published — a bad skill +// is workspace-visible, so waiting for its author is not a remedy. +func TestAgentService_SkillAdminOverride(t *testing.T) { + ctx := context.Background() + svc, _, _ := agentCovNewSvc() + + sp := func(s string) *string { return &s } + sk, err := svc.CreateSkill(ctx, "author", "s", "d", "i", model.SkillVisibilityPublished) + if err != nil { + t.Fatalf("create: %v", err) + } + // Still refused for an ordinary non-author. + if _, err := svc.UpdateSkill(ctx, "stranger", false, sk.ID, SkillPatch{Name: sp("x")}); !errors.Is(err, ErrForbidden) { + t.Fatalf("non-author update: %v", err) + } + upd, err := svc.UpdateSkill(ctx, "admin", true, sk.ID, SkillPatch{Name: sp("fixed")}) + if err != nil || upd.Name != "fixed" { + t.Fatalf("admin update: %+v (%v)", upd, err) + } + if err := svc.DeleteSkill(ctx, "stranger", false, sk.ID); !errors.Is(err, ErrForbidden) { + t.Fatalf("non-author delete: %v", err) + } + if err := svc.DeleteSkill(ctx, "admin", true, sk.ID); err != nil { + t.Fatalf("admin delete: %v", err) + } +} + +func TestAgentService_SetAgentPersona(t *testing.T) { + ctx := context.Background() + svc, dir, users := agentCovNewSvc() + agentCovSeedAgent(t, dir, users, AgentSlugGG) + + // Blank is refused rather than clearing: Resolve falls back to the + // template persona, so emptying it leaves everyone without an override + // running with no instructions at all. + for _, blank := range []string{"", " ", "\n\t "} { + if _, err := svc.SetAgentPersona(ctx, AgentSlugGG, blank); !errors.Is(err, ErrValidation) { + t.Fatalf("blank %q: %v", blank, err) + } + } + + if _, err := svc.SetAgentPersona(ctx, "ghost", "hello"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("unknown slug: %v", err) + } + + // Trimmed, stored, and readable back through the same path the prompt + // bundle uses. + tpl, err := svc.SetAgentPersona(ctx, " GG ", " Be brief. ") + if err != nil || tpl.Persona != "Be brief." { + t.Fatalf("set: %+v (%v)", tpl, err) + } + got, err := svc.Template(ctx, AgentSlugGG) + if err != nil || got.Persona != "Be brief." { + t.Fatalf("read back: %+v (%v)", got, err) + } + + dir.errs["PutTemplate"] = errAgentCov + if _, err := svc.SetAgentPersona(ctx, AgentSlugGG, "x"); !errors.Is(err, errAgentCov) { + t.Fatalf("store error: %v", err) + } + delete(dir.errs, "PutTemplate") +} diff --git a/internal/service/audit_fixes_coverage_test.go b/internal/service/audit_fixes_coverage_test.go index 726c9aa5..03be1916 100644 --- a/internal/service/audit_fixes_coverage_test.go +++ b/internal/service/audit_fixes_coverage_test.go @@ -978,3 +978,42 @@ func TestConnCov_InstalledIndexBatchedRegistryRead(t *testing.T) { t.Fatalf("empty policy should default to ask: %+v", idx[0]) } } + +// The typing ticker must stop when the agent POSTS, not when the run finally +// goes terminal. It used to run until terminal, so a run that answered and +// then spent its remaining turns winding down kept re-publishing "gg is +// typing…" the whole time — and every frame bought another 6s of client-side +// expiry after the last one, which is why the indicator outlived the reply. +func TestOrchCov_TypingStopsWhenAgentPosts(t *testing.T) { + fx := newOrchCovFixture(t) + ctx := context.Background() + run := fx.start(t, "m1", "") + + if _, err := fx.orch.Claim(ctx, "u-alice", "r1", []string{model.HarnessClaude}, 1, 0); err != nil { + t.Fatalf("claim: %v", err) + } + if _, armed := fx.orch.typing.Load(run.ID); !armed { + t.Fatal("claiming a run must arm the typing ticker") + } + + if _, err := fx.orch.RecordAgentPost(ctx, run.ID); err != nil { + t.Fatalf("record post: %v", err) + } + if _, armed := fx.orch.typing.Load(run.ID); armed { + t.Fatal("typing ticker still armed after the agent posted") + } + + // The run is still live — stopping the animation must not have closed it. + if got, err := fx.runs.GetRun(ctx, run.ID); err != nil || got.State.Terminal() { + t.Fatalf("post must not terminate the run: state=%v err=%v", got.State, err) + } + + // A second post is harmless: once it has spoken, "still working" belongs + // to the activity chip, so the ticker stays off rather than re-arming. + if _, err := fx.orch.RecordAgentPost(ctx, run.ID); err != nil { + t.Fatalf("second post: %v", err) + } + if _, armed := fx.orch.typing.Load(run.ID); armed { + t.Fatal("typing ticker re-armed by a second post") + } +} diff --git a/internal/service/orchestrator.go b/internal/service/orchestrator.go index e5692ce1..e99d0858 100644 --- a/internal/service/orchestrator.go +++ b/internal/service/orchestrator.go @@ -1988,6 +1988,18 @@ func (o *Orchestrator) RecordAgentPost(ctx context.Context, runID string) (remai } return 0, err } + // It has answered, so stop claiming it is typing. The ticker otherwise + // runs until the run goes TERMINAL, which is later — a run that posts and + // then spends its remaining turns on cleanup kept re-publishing typing the + // whole time, and each frame bought another 6s of client-side expiry after + // the last one. The SPA already drops the entry when the message lands; + // this is what stops it coming straight back. + // + // Not restarted for a second post in the same run: once an agent has + // spoken, "still working" belongs to the activity chip, not to a typing + // line under the reply it already sent. + o.stopTypingTicker(runID) + // The agent just spoke in this thread: refresh its follow marker so the // invoker's later un-tagged replies can re-invoke it (per their prefs). // Heartbeats have no thread (MessageID "") and are skipped. diff --git a/internal/service/orchestrator_test.go b/internal/service/orchestrator_test.go index 723a8c38..7debc000 100644 --- a/internal/service/orchestrator_test.go +++ b/internal/service/orchestrator_test.go @@ -469,6 +469,13 @@ func (f *fakeAgentDir) PutAgentPrefs(_ context.Context, prefs *model.UserAgentPr return nil } +func (f *fakeAgentDir) DeleteAgentPrefs(_ context.Context, userID, slug string) error { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.prefs, userID+"#"+slug) + return nil +} + func (f *fakeAgentDir) GetAgentPrefs(_ context.Context, userID, slug string) (*model.UserAgentPrefs, error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/store/agent.go b/internal/store/agent.go index fe6379e8..020eaace 100644 --- a/internal/store/agent.go +++ b/internal/store/agent.go @@ -196,6 +196,20 @@ func (s *AgentStore) PutAgentPrefs(ctx context.Context, prefs *model.UserAgentPr return nil } +// DeleteAgentPrefs removes one user's customization of a slug, so the run +// falls back to the template again. Idempotent: DynamoDB's DeleteItem is a +// no-op on a key that isn't there, and "they had nothing to clear" is the +// same outcome as "cleared it". +func (s *AgentStore) DeleteAgentPrefs(ctx context.Context, userID, slug string) error { + if _, err := s.Client.DeleteItem(ctx, &dynamodb.DeleteItemInput{ + TableName: aws.String(s.Table), + Key: compositeKey(userPK(userID), agentPrefsSK(slug)), + }); err != nil { + return fmt.Errorf("store: delete agent prefs: %w", err) + } + return nil +} + // GetAgentPrefs fetches one user's preferences for a slug. ErrNotFound when // they never customized it — callers treat that as "inherit everything". func (s *AgentStore) GetAgentPrefs(ctx context.Context, userID, slug string) (*model.UserAgentPrefs, error) { diff --git a/internal/store/agent_test.go b/internal/store/agent_test.go index b227b224..7b8568b3 100644 --- a/internal/store/agent_test.go +++ b/internal/store/agent_test.go @@ -135,6 +135,19 @@ func TestAgentPrefsStore_RoundTrip(t *testing.T) { if _, err := s.GetAgentPrefs(ctx, "u-1", "qib"); !errors.Is(err, ErrNotFound) { t.Fatalf("get absent: want ErrNotFound, got %v", err) } + + // Delete drops the row, so the next resolve falls back to the template. + if err := s.DeleteAgentPrefs(ctx, "u-1", "gg"); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := s.GetAgentPrefs(ctx, "u-1", "gg"); !errors.Is(err, ErrNotFound) { + t.Fatalf("get after delete: want ErrNotFound, got %v", err) + } + // Idempotent: clearing a row that is not there is the same outcome as + // clearing one that was, which is what lets an admin retry a reset. + if err := s.DeleteAgentPrefs(ctx, "u-1", "gg"); err != nil { + t.Fatalf("delete absent: %v", err) + } } func TestRunnerStore_CRUD(t *testing.T) { diff --git a/src/App.tsx b/src/App.tsx index 31b717cc..4fdb8cc0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,9 +39,13 @@ const DraftsPage = lazy(() => import('@/pages/DraftsPage')); const AgentsPage = lazy(() => import('@/pages/AgentsPage')); const SkillsPage = lazy(() => import('@/pages/SkillsPage')); const ConnectorsPage = lazy(() => import('@/pages/ConnectorsPage')); +const AdminAgentsPage = lazy(() => import('@/pages/AdminAgentsPage')); // Tiny layout shell — not worth its own chunk, and it must be there before // any of its lazy children resolve so the tab strip never flashes in late. import AiHubLayout from '@/pages/AiHubLayout'; +// Same reasoning as AiHubLayout: the shell must be present before its lazy +// children resolve, so the tab strip never flashes in late. +import AdminLayout from '@/pages/AdminLayout'; const ActivityPage = lazy(() => import('@/pages/ActivityPage')); const SearchResultsPage = lazy(() => import('@/pages/SearchResultsPage')); // Password recovery is a cold path (guest accounts only, rarely hit) — keep @@ -157,7 +161,12 @@ function AppRoutes() { } /> } /> - } /> + {/* The admin area: one user-menu entry, tabs over its pages. Each + keeps its own URL, so /admin and /admin/agents deep-link. */} + }> + } /> + } /> + } /> } /> } /> diff --git a/src/components/admin/AgentsAdminPanel.tsx b/src/components/admin/AgentsAdminPanel.tsx new file mode 100644 index 00000000..d94f8d0b --- /dev/null +++ b/src/components/admin/AgentsAdminPanel.tsx @@ -0,0 +1,238 @@ +import { useState } from 'react'; +import { Bot, ChevronDown, ChevronRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + useAgentOverrides, + useAgents, + useResetAgentOverrides, + useUpdateAgentTemplate, + type AgentView, +} from '@/hooks/useAgents'; + +// AgentsAdminPanel edits the WORKSPACE DEFAULT for every shared agent — the +// config a member inherits unless they have set their own on the Agents page. +// +// It lives here rather than on the agent cards because the two operations look +// identical and do opposite things: the card's Advanced section writes the +// caller's personal prefs, this writes the template. On the card, an admin +// could reasonably save Advanced and believe they had changed it for the team. +// +// It reads `default*` rather than `resolved`: resolved is the CALLER's +// effective config, so an admin who has customised an agent would otherwise be +// shown their own values labelled as everyone's. +export function AgentsAdminPanel() { + const { data: agents, isLoading } = useAgents(); + + return ( +
+
+

+

+

+ The prompt, backend and model each shared agent uses by default. Anyone who has set + their own on the Agents page keeps theirs — the count under each agent says how many + that is. +

+
+ + {isLoading && } + + {!isLoading && !agents?.length && ( +

No shared agents yet.

+ )} + +
+ {(agents ?? []).map((agent) => ( + + ))} +
+
+ ); +} + +// One agent, collapsed to a summary line until opened — a workspace with a +// dozen agents should read as a roster, not a wall of forms. +function AgentTemplateRow({ agent }: { agent: AgentView }) { + const [open, setOpen] = useState(false); + const update = useUpdateAgentTemplate(); + const reset = useResetAgentOverrides(); + // Only counted while the row is open: server-side this walks the roster, + // because agent prefs are partitioned by user and have no index by agent. + const { data: overrides, isLoading: counting } = useAgentOverrides(agent.slug, open); + + const [harness, setHarness] = useState(agent.defaultHarness || 'claude'); + const [model, setModel] = useState(agent.defaultModel ?? ''); + const [persona, setPersona] = useState(agent.defaultPersona ?? ''); + const [confirming, setConfirming] = useState(false); + + const isBedrock = harness === 'bedrock'; + const blankPersona = persona.trim() === ''; + const dirty = + harness !== (agent.defaultHarness || 'claude') || + model !== (agent.defaultModel ?? '') || + persona.trim() !== (agent.defaultPersona ?? '').trim(); + + function save() { + update.mutate({ + slug: agent.slug, + patch: { + // harness must ride along: the handler skips the engine block without + // it, so a model sent alone is accepted and silently ignored. + harness, + model: model.trim(), + executionMode: isBedrock ? 'server' : '', + persona: persona.trim(), + }, + }); + } + + return ( +
+ + + {open && ( +
+
+
+ + +
+
+ + setModel(e.target.value)} + placeholder={isBedrock ? 'eu.anthropic.claude-opus-5' : 'claude-opus-5'} + className="mt-1" + /> +
+
+ + {isBedrock && ( +

+ Bedrock ids are not validated here — a wrong one fails at run time as + “bedrock_error”. Paste the inference-profile id exactly as Bedrock lists it for the + backend’s region. Bedrock agents always run on the server. +

+ )} + +
+ +