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..870c89e9 100644 --- a/internal/service/agent_coverage_test.go +++ b/internal/service/agent_coverage_test.go @@ -9,6 +9,7 @@ import ( "context" "errors" "fmt" + "strconv" "strings" "testing" "time" @@ -83,6 +84,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 +673,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 +699,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 +953,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 +1127,193 @@ 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 != "" { + n, err := strconv.Atoi(cursor) + if err != nil { + return nil, "", fmt.Errorf("roster fake: bad cursor %q: %w", cursor, err) + } + start = n + } + 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..1e8162a6 --- /dev/null +++ b/src/components/admin/AgentsAdminPanel.tsx @@ -0,0 +1,245 @@ +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() { + // Normalise the fields to what is actually being sent. Without this the + // form keeps the untrimmed text, stays permanently "dirty" against the + // saved template, and never settles back to showing it as saved. + const trimmedModel = model.trim(); + const trimmedPersona = persona.trim(); + setModel(trimmedModel); + setPersona(trimmedPersona); + 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: trimmedModel, + executionMode: isBedrock ? 'server' : '', + persona: trimmedPersona, + }, + }); + } + + 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. +

+ )} + +
+ +