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:
2026-05-22 17:23:43 +08:00
co-authored by Claude Opus 4.7
parent 8f70115c6a
commit d022518a0a
5 changed files with 361 additions and 13 deletions
+155
View File
@@ -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)
}
}
}
+6
View File
@@ -497,6 +497,12 @@ func SetApiRouter(router *gin.Engine) {
agnetRoute.POST("/sk-snapshots/resolve", controller.AgnetResolveSKSnapshots) agnetRoute.POST("/sk-snapshots/resolve", controller.AgnetResolveSKSnapshots)
agnetRoute.GET("/projects/:project_id/dashboard-snapshot", controller.AgnetProjectDashboardSnapshot) agnetRoute.GET("/projects/:project_id/dashboard-snapshot", controller.AgnetProjectDashboardSnapshot)
agnetRoute.GET("/audit-logs", controller.AgnetListAuditLogs) 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)
} }
} }
} }
+30
View File
@@ -178,6 +178,36 @@ export type GitSourcePayload = {
usage: GitSourceUsage 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[]> { export async function listAgnetDeployments(): Promise<AgnetDeployment[]> {
const res = await api.get<ApiEnvelope<{ items?: AgnetDeployment[] }>>( const res = await api.get<ApiEnvelope<{ items?: AgnetDeployment[] }>>(
'/api/agnet/deployments' '/api/agnet/deployments'
@@ -41,8 +41,10 @@ import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth-store' import { useAuthStore } from '@/stores/auth-store'
import { import {
createAgnetDeployment, createAgnetDeployment,
listAgnetRoleTemplates,
type AgnetAgentPlan, type AgnetAgentPlan,
type AgnetOrchestrationPlan, type AgnetOrchestrationPlan,
type AgnetRoleTemplate,
type AgnetSKSource, type AgnetSKSource,
} from './api' } from './api'
@@ -127,14 +129,17 @@ const TEMPLATE_PRESETS: TemplatePreset[] = [
label: 'Fix or improve', label: 'Fix or improve',
objective: 'Investigate a focused issue and ship the smallest safe fix.', objective: 'Investigate a focused issue and ship the smallest safe fix.',
risk: 'low', 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: [ roles: [
{ {
role_template: 'debugger', role_template: 'reviewer',
goal: 'Find the root cause and define the minimum repair.', goal: 'Find the root cause and define the minimum repair.',
default_model_id: 'agnet-model-debugger', default_model_id: 'agnet-model-debugger',
}, },
{ {
role_template: 'executor', role_template: 'backend',
goal: 'Implement and verify the scoped change.', goal: 'Implement and verify the scoped change.',
default_model_id: 'agnet-model-builder', default_model_id: 'agnet-model-builder',
}, },
@@ -145,9 +150,13 @@ const TEMPLATE_PRESETS: TemplatePreset[] = [
label: 'Release readiness', label: 'Release readiness',
objective: 'Prepare a release candidate with verification and audit trail.', objective: 'Prepare a release candidate with verification and audit trail.',
risk: 'high', 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: [ roles: [
{ {
role_template: 'executor', role_template: 'backend',
goal: 'Prepare the release branch and required artifacts.', goal: 'Prepare the release branch and required artifacts.',
default_model_id: 'agnet-model-builder', default_model_id: 'agnet-model-builder',
}, },
@@ -370,6 +379,27 @@ export function CreateAgnetDeploymentSheet({
const userId = String(currentUser?.id || userScopeRef.trim() || 'user_local') 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(() => { useEffect(() => {
if (open && !correlationId) { if (open && !correlationId) {
setCorrelationId(crypto.randomUUID()) setCorrelationId(crypto.randomUUID())
@@ -882,16 +912,43 @@ export function CreateAgnetDeploymentSheet({
<div className='grid gap-3'> <div className='grid gap-3'>
<div className='grid gap-2 sm:grid-cols-3'> <div className='grid gap-2 sm:grid-cols-3'>
<Input {/* Role picker — bound to the canonical six-role
placeholder={t('Role template')} catalog from /api/agnet/role-templates. Falls
value={agent.role_template} back to a free-text input if the catalog
onChange={(e) => failed to load. */}
updateAgent(index, { {roleTemplates.length > 0 ? (
role_template: e.target.value, <Select
}) value={agent.role_template}
} onValueChange={(value) =>
className='font-mono text-xs' 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 <Input
placeholder={t('Agnet runtime model id')} placeholder={t('Agnet runtime model id')}
value={agent.default_model_id} value={agent.default_model_id}