feat(agnet): canonical six-role catalog + role-templates API
Sprint 2. Materialises the six platform-recommended Agnet roles
documented in docs/product-package/13-platform-description.md §3.
Backend:
- controller/agnet_role_template.go (new): AgnetRoleTemplate type
+ canonical catalog returned by GET /api/agnet/role-templates.
Six roles: product / architect / frontend / backend / reviewer / ops.
Stored as constants (not DB rows) because they are platform
contracts, not user-editable data. Each entry carries:
- stable key (frontend dispatches on this — never rename)
- display name + summary (translatable)
- default model recommendation
- default permission scope hints
- risk classification (low/medium/high) — Ops alone is high,
matching the production-deploy-needs-approval rule
- router/api-router.go: mount GET /api/agnet/role-templates inside
the existing /api/agnet group (same auth as the other endpoints)
- controller/agnet_role_template_test.go (new): 4 tests pin the
six-role set, risk-level matrix, HTTP envelope shape, and the
closed-set helper that will gate validation later
Frontend:
- features/agnet-console/api.ts: new AgnetRoleTemplate type + a
module-level cached listAgnetRoleTemplates() helper. Caching
means the picker doesn't refetch every time the deployment sheet
opens.
- features/agnet-console/create-agnet-deployment-sheet.tsx:
- Replace free-text role_template Input with a Select bound to
the catalog; falls back to Input if the catalog is empty so
the form stays usable when the endpoint is down.
- Fix two informal role names in built-in presets (debugger →
reviewer, executor → backend) so presets reference only
canonical keys.
Verification:
- go test ./controller/... ./middleware/... ./model/... all green
(4 new role-template tests + existing suite)
- frontend tsc --noEmit clean
- zero touch on the token / device-signature hot paths
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/heicode/manager/common"
|
||||
)
|
||||
|
||||
// AgnetRoleTemplate is the platform-recommended role catalog Manager
|
||||
// surfaces to users when they assemble an AI development team. The
|
||||
// six canonical roles come from docs/product-package/13-platform-
|
||||
// description.md §3 and 04-platform-usage-guide.md §第五步.
|
||||
//
|
||||
// Why constants, not a DB table:
|
||||
// - Roles are platform-defined contracts, not user-editable data.
|
||||
// Treating them like rows would invite drift between deployments.
|
||||
// - Permission hints below are *recommendations* the UI uses to
|
||||
// pre-fill the "what can this Agnet do" confirmation card —
|
||||
// the actual permission grant still goes through ResourceGrant.
|
||||
// - If we ever need per-tenant role customization, we add an
|
||||
// overlay table; the canonical set still lives here as the
|
||||
// baseline.
|
||||
//
|
||||
// Wire-format note: keys (key column) are stable identifiers used
|
||||
// across persistence and the client picker. Display strings can
|
||||
// be translated, but the key must NEVER change without a coordinated
|
||||
// frontend rollout.
|
||||
type AgnetRoleTemplate struct {
|
||||
Key string `json:"key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Summary string `json:"summary"`
|
||||
DefaultModel string `json:"default_model"`
|
||||
DefaultPermissions []string `json:"default_permissions"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
}
|
||||
|
||||
// agnetRoleTemplates returns the canonical six-role catalog. Order
|
||||
// matches the typical lifecycle a user walks through when assembling
|
||||
// a team: discover -> design -> build -> review -> operate.
|
||||
//
|
||||
// Permission hints use the verbs from docs §13.3.4 (Resource Grant)
|
||||
// and stay deliberately broad — concrete grants come from the user
|
||||
// resource-binding flow.
|
||||
func agnetRoleTemplates() []AgnetRoleTemplate {
|
||||
return []AgnetRoleTemplate{
|
||||
{
|
||||
Key: "product",
|
||||
DisplayName: "Product Agnet",
|
||||
Summary: "Refines the user idea into product scope, requirements and acceptance criteria.",
|
||||
DefaultModel: "claude-sonnet-4-6",
|
||||
DefaultPermissions: []string{
|
||||
"read:project_docs",
|
||||
"write:product_spec",
|
||||
},
|
||||
RiskLevel: agnetRiskLow,
|
||||
},
|
||||
{
|
||||
Key: "architect",
|
||||
DisplayName: "Architect Agnet",
|
||||
Summary: "Designs the technical approach, picks frameworks, and breaks work into sub-tasks.",
|
||||
DefaultModel: "claude-opus-4-7",
|
||||
DefaultPermissions: []string{
|
||||
"read:repo",
|
||||
"write:architecture_doc",
|
||||
},
|
||||
RiskLevel: agnetRiskLow,
|
||||
},
|
||||
{
|
||||
Key: "frontend",
|
||||
DisplayName: "Frontend Agnet",
|
||||
Summary: "Implements UI, components and client-side state per the architect's plan.",
|
||||
DefaultModel: "claude-sonnet-4-6",
|
||||
DefaultPermissions: []string{
|
||||
"read:repo",
|
||||
"write:repo:frontend",
|
||||
},
|
||||
RiskLevel: agnetRiskMedium,
|
||||
},
|
||||
{
|
||||
Key: "backend",
|
||||
DisplayName: "Backend Agnet",
|
||||
Summary: "Implements server-side APIs, data models and integrations.",
|
||||
DefaultModel: "claude-sonnet-4-6",
|
||||
DefaultPermissions: []string{
|
||||
"read:repo",
|
||||
"write:repo:backend",
|
||||
"read:dev_database",
|
||||
},
|
||||
RiskLevel: agnetRiskMedium,
|
||||
},
|
||||
{
|
||||
Key: "reviewer",
|
||||
DisplayName: "Reviewer Agnet",
|
||||
Summary: "Performs code review, security checks and runs the test suite.",
|
||||
DefaultModel: "claude-opus-4-7",
|
||||
DefaultPermissions: []string{
|
||||
"read:repo",
|
||||
"run:tests",
|
||||
"comment:pull_request",
|
||||
},
|
||||
RiskLevel: agnetRiskLow,
|
||||
},
|
||||
{
|
||||
Key: "ops",
|
||||
DisplayName: "Ops Agnet",
|
||||
Summary: "Deploys to test environments, watches logs and prepares production rollouts (production requires approval).",
|
||||
DefaultModel: "claude-sonnet-4-6",
|
||||
DefaultPermissions: []string{
|
||||
"read:repo",
|
||||
"deploy:test_env",
|
||||
"read:metrics",
|
||||
"approval_required:deploy_prod",
|
||||
},
|
||||
RiskLevel: agnetRiskHigh,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AgnetListRoleTemplates is the GET /api/agnet/role-templates handler.
|
||||
// Returns the canonical six-role catalog so the deployment-creation
|
||||
// UI can pre-populate role pickers and the documentation page can
|
||||
// render the role overview.
|
||||
//
|
||||
// Auth: requires UserAuth (mounted by router). Anyone logged in to
|
||||
// Manager can read the catalog; there are no secrets in the payload.
|
||||
func AgnetListRoleTemplates(c *gin.Context) {
|
||||
tpls := agnetRoleTemplates()
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"items": tpls,
|
||||
"total": len(tpls),
|
||||
})
|
||||
}
|
||||
|
||||
// agnetRoleTemplateKeys is a helper for validation in deployment
|
||||
// creation — checks whether a user-provided role_template string is
|
||||
// one of the canonical six. Returns true for any of the canonical
|
||||
// keys; returns true for unknown keys too (deployment flow today
|
||||
// accepts free-form role_template strings, see agnet_control_plane.
|
||||
// go:552), so this helper is currently advisory. When we tighten
|
||||
// validation (after frontend ships the new picker), flip the
|
||||
// fallback to false and add a unit test.
|
||||
func agnetRoleTemplateKeys() map[string]bool {
|
||||
keys := make(map[string]bool)
|
||||
for _, t := range agnetRoleTemplates() {
|
||||
keys[t.Key] = true
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// agnetIsCanonicalRoleKey reports whether `key` matches one of the
|
||||
// six platform-defined roles. Today the deployment endpoint accepts
|
||||
// any non-empty string; this helper is reserved for the next step
|
||||
// when we move to a closed set.
|
||||
func agnetIsCanonicalRoleKey(key string) bool {
|
||||
return agnetRoleTemplateKeys()[key]
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/heicode/manager/common"
|
||||
)
|
||||
|
||||
func TestAgnetRoleTemplates_CanonicalSetCovered(t *testing.T) {
|
||||
// Pins the six canonical role keys from docs §13.3.3. Any code
|
||||
// change that adds, removes or renames a key MUST update this
|
||||
// list — guards against accidental drift between Manager and
|
||||
// the product spec.
|
||||
want := []string{"product", "architect", "frontend", "backend", "reviewer", "ops"}
|
||||
got := agnetRoleTemplates()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected %d roles, got %d", len(want), len(got))
|
||||
}
|
||||
for i, key := range want {
|
||||
if got[i].Key != key {
|
||||
t.Errorf("position %d: want key %q, got %q", i, key, got[i].Key)
|
||||
}
|
||||
// Display + summary populated; never returning a blank-row
|
||||
// in the picker.
|
||||
if strings.TrimSpace(got[i].DisplayName) == "" {
|
||||
t.Errorf("role %q has empty DisplayName", key)
|
||||
}
|
||||
if strings.TrimSpace(got[i].Summary) == "" {
|
||||
t.Errorf("role %q has empty Summary", key)
|
||||
}
|
||||
if len(got[i].DefaultPermissions) == 0 {
|
||||
t.Errorf("role %q has empty DefaultPermissions", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgnetRoleTemplates_RiskLevels(t *testing.T) {
|
||||
// Ops is the only canonical role with high risk (production
|
||||
// deployment intent). Reviewer + Product + Architect stay low
|
||||
// (read-mostly). Frontend + Backend land at medium. Pins the
|
||||
// product-doc default risk classification so we don't silently
|
||||
// flip an ops role to "low" and skip the high-risk approval
|
||||
// gating downstream.
|
||||
wantRisk := map[string]string{
|
||||
"product": agnetRiskLow,
|
||||
"architect": agnetRiskLow,
|
||||
"reviewer": agnetRiskLow,
|
||||
"frontend": agnetRiskMedium,
|
||||
"backend": agnetRiskMedium,
|
||||
"ops": agnetRiskHigh,
|
||||
}
|
||||
for _, tpl := range agnetRoleTemplates() {
|
||||
if want, ok := wantRisk[tpl.Key]; ok && tpl.RiskLevel != want {
|
||||
t.Errorf("role %q: want risk %q, got %q", tpl.Key, want, tpl.RiskLevel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgnetListRoleTemplates_HTTPShape(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest("GET", "/api/agnet/role-templates", nil)
|
||||
c.Set("id", 1)
|
||||
c.Set("role", common.RoleCommonUser)
|
||||
|
||||
AgnetListRoleTemplates(c)
|
||||
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
// Stable keys the frontend depends on — checked via substring
|
||||
// rather than parsing so we don't have to spin up a typed
|
||||
// envelope just for this guard.
|
||||
for _, want := range []string{`"items"`, `"total"`, `"product"`, `"ops"`, `"display_name"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("response missing %q. body=%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgnetIsCanonicalRoleKey(t *testing.T) {
|
||||
// Defensive helper currently used as advisory — pins the closed
|
||||
// set so the future tighten-up to closed-set validation is one
|
||||
// flip instead of an open-ended audit.
|
||||
for _, ok := range []string{"product", "architect", "frontend", "backend", "reviewer", "ops"} {
|
||||
if !agnetIsCanonicalRoleKey(ok) {
|
||||
t.Errorf("%q should be canonical", ok)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "debugger", "executor", "random_string"} {
|
||||
if agnetIsCanonicalRoleKey(bad) {
|
||||
t.Errorf("%q should NOT be canonical", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -497,6 +497,12 @@ func SetApiRouter(router *gin.Engine) {
|
||||
agnetRoute.POST("/sk-snapshots/resolve", controller.AgnetResolveSKSnapshots)
|
||||
agnetRoute.GET("/projects/:project_id/dashboard-snapshot", controller.AgnetProjectDashboardSnapshot)
|
||||
agnetRoute.GET("/audit-logs", controller.AgnetListAuditLogs)
|
||||
// Platform-recommended role catalog. Six canonical Agnet
|
||||
// roles per docs/product-package/13-platform-description.md
|
||||
// §3 (Product / Architect / Frontend / Backend / Reviewer /
|
||||
// Ops). UI uses this to populate the deployment-creation
|
||||
// role picker. Read-only, no secrets in payload.
|
||||
agnetRoute.GET("/role-templates", controller.AgnetListRoleTemplates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,36 @@ export type GitSourcePayload = {
|
||||
usage: GitSourceUsage
|
||||
}
|
||||
|
||||
// Platform-recommended role catalog shape. Mirrors backend
|
||||
// `AgnetRoleTemplate` in controller/agnet_role_template.go. The
|
||||
// six canonical roles come from docs/product-package §13.3.3 —
|
||||
// keys are stable identifiers, display strings can be translated.
|
||||
export type AgnetRoleTemplate = {
|
||||
key: string
|
||||
display_name: string
|
||||
summary: string
|
||||
default_model: string
|
||||
default_permissions: string[]
|
||||
risk_level: 'low' | 'medium' | 'high'
|
||||
}
|
||||
|
||||
// Cached at module level — the canonical six-role catalog doesn't
|
||||
// change between page loads, so we avoid an extra request every
|
||||
// time the create-deployment sheet opens.
|
||||
let _roleTemplateCache: AgnetRoleTemplate[] | null = null
|
||||
|
||||
export async function listAgnetRoleTemplates(): Promise<AgnetRoleTemplate[]> {
|
||||
if (_roleTemplateCache) return _roleTemplateCache
|
||||
const res = await api.get<ApiEnvelope<{ items?: AgnetRoleTemplate[] }>>(
|
||||
'/api/agnet/role-templates'
|
||||
)
|
||||
const items = res.data?.data?.items ?? []
|
||||
if (items.length > 0) {
|
||||
_roleTemplateCache = items
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export async function listAgnetDeployments(): Promise<AgnetDeployment[]> {
|
||||
const res = await api.get<ApiEnvelope<{ items?: AgnetDeployment[] }>>(
|
||||
'/api/agnet/deployments'
|
||||
|
||||
+70
-13
@@ -41,8 +41,10 @@ import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import {
|
||||
createAgnetDeployment,
|
||||
listAgnetRoleTemplates,
|
||||
type AgnetAgentPlan,
|
||||
type AgnetOrchestrationPlan,
|
||||
type AgnetRoleTemplate,
|
||||
type AgnetSKSource,
|
||||
} from './api'
|
||||
|
||||
@@ -127,14 +129,17 @@ const TEMPLATE_PRESETS: TemplatePreset[] = [
|
||||
label: 'Fix or improve',
|
||||
objective: 'Investigate a focused issue and ship the smallest safe fix.',
|
||||
risk: 'low',
|
||||
// Role keys aligned to the canonical six-role catalog from
|
||||
// docs/product-package §13.3.3. Previous presets used informal
|
||||
// "debugger" / "executor" labels that don't exist in the API.
|
||||
roles: [
|
||||
{
|
||||
role_template: 'debugger',
|
||||
role_template: 'reviewer',
|
||||
goal: 'Find the root cause and define the minimum repair.',
|
||||
default_model_id: 'agnet-model-debugger',
|
||||
},
|
||||
{
|
||||
role_template: 'executor',
|
||||
role_template: 'backend',
|
||||
goal: 'Implement and verify the scoped change.',
|
||||
default_model_id: 'agnet-model-builder',
|
||||
},
|
||||
@@ -145,9 +150,13 @@ const TEMPLATE_PRESETS: TemplatePreset[] = [
|
||||
label: 'Release readiness',
|
||||
objective: 'Prepare a release candidate with verification and audit trail.',
|
||||
risk: 'high',
|
||||
// Canonical six-role catalog (§13.3.3) — "executor" was an
|
||||
// informal label; the closest canonical mapping is "backend"
|
||||
// (build the release artifact) followed by "ops" (validate
|
||||
// rollout) and "reviewer" (confirm evidence).
|
||||
roles: [
|
||||
{
|
||||
role_template: 'executor',
|
||||
role_template: 'backend',
|
||||
goal: 'Prepare the release branch and required artifacts.',
|
||||
default_model_id: 'agnet-model-builder',
|
||||
},
|
||||
@@ -370,6 +379,27 @@ export function CreateAgnetDeploymentSheet({
|
||||
|
||||
const userId = String(currentUser?.id || userScopeRef.trim() || 'user_local')
|
||||
|
||||
// Platform-recommended role catalog. Loaded once when the sheet
|
||||
// opens; the API client caches the response at module level so
|
||||
// subsequent sheet opens don't re-fetch. Empty array fallback
|
||||
// means the sheet stays usable if the catalog endpoint is down —
|
||||
// the role picker falls back to free-text input.
|
||||
const [roleTemplates, setRoleTemplates] = useState<AgnetRoleTemplate[]>([])
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
listAgnetRoleTemplates()
|
||||
.then((items) => {
|
||||
if (!cancelled) setRoleTemplates(items)
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort — sheet still works without the catalog.
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !correlationId) {
|
||||
setCorrelationId(crypto.randomUUID())
|
||||
@@ -882,16 +912,43 @@ export function CreateAgnetDeploymentSheet({
|
||||
|
||||
<div className='grid gap-3'>
|
||||
<div className='grid gap-2 sm:grid-cols-3'>
|
||||
<Input
|
||||
placeholder={t('Role template')}
|
||||
value={agent.role_template}
|
||||
onChange={(e) =>
|
||||
updateAgent(index, {
|
||||
role_template: e.target.value,
|
||||
})
|
||||
}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
{/* Role picker — bound to the canonical six-role
|
||||
catalog from /api/agnet/role-templates. Falls
|
||||
back to a free-text input if the catalog
|
||||
failed to load. */}
|
||||
{roleTemplates.length > 0 ? (
|
||||
<Select
|
||||
value={agent.role_template}
|
||||
onValueChange={(value) =>
|
||||
updateAgent(index, { role_template: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='h-9 font-mono text-xs'>
|
||||
<SelectValue placeholder={t('Role template')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roleTemplates.map((tpl) => (
|
||||
<SelectItem key={tpl.key} value={tpl.key}>
|
||||
<span className='font-mono'>{tpl.key}</span>
|
||||
<span className='text-muted-foreground ml-2'>
|
||||
· {tpl.display_name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={t('Role template')}
|
||||
value={agent.role_template}
|
||||
onChange={(e) =>
|
||||
updateAgent(index, {
|
||||
role_template: e.target.value,
|
||||
})
|
||||
}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
placeholder={t('Agnet runtime model id')}
|
||||
value={agent.default_model_id}
|
||||
|
||||
Reference in New Issue
Block a user