按桌面客户端统一方案 v0.1 + agent_management Sub Mode Runtime 对接,强制全量统一,不留兼容。
命名统一(强制,无兼容):
- 全仓 agnet/Agnet/AGNET → agent/Agent/AGENT:后端 Go(路由 /api/agent/*、env AGENT_*、
结构体/函数、19 个文件改名)、前端(agent-console/agent-hub、/api/agent 调用、i18n)、
DB(表 agent_*、列 agent_id)、compose/.env、文档、脚本。
- DB 加幂等迁移 renameAgnetTablesToAgent():启动时 rename 老 agnet_* 表/列,保住生产数据。
统一方案核心(10 项):
- callback 统一 /api/agent/callbacks/runtime-events(路由/广播URL/函数名)。
- artifact 兜底判定改用 Runtime 权威信号 metadata.synthesized(§7.2)+ 结构化 artifact_type。
- Manager→Runtime 路径对齐 /api/agent/sub-agile/deployments(§2.2),{deployment_id} 回退 swarm_id。
- 状态裁决 display_status:Manager 唯一裁判,completed 无有效产物→needs_codegen/
completed_without_deliverable(§10.6),接入 detail/timeline/workflow。
- GET /api/heicode/capabilities 能力发现(§6)。
- 模型策略 per_role(role_models)+ 收集 allowed_model_ids(§9)。
- resource_binding_id→secret_ref 服务端解析,客户端不再 inline secret_ref(§17.6)。
- 客户端统一路由层 /api/heicode/sub-agile|swarm/*(task≡deployment,复用控制面)+ workflow 投影。
- 日志分层 user_logs/debug_logs(§13)。
验证:go build ./... + go test(controller/router/model/middleware)全绿;前端 tsc -b + rsbuild build 通过。
待部署:VM .env 的 AGNET_*→AGENT_*;启动迁移自动 rename 表;其他三仓库需同步切到 /api/agent。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
300 lines
9.2 KiB
Go
300 lines
9.2 KiB
Go
package controller
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/heicode/manager/common"
|
|
"github.com/heicode/manager/model"
|
|
)
|
|
|
|
type agentTaskDeploymentDraftRequest struct {
|
|
Task agentTaskSnapshot `json:"task"`
|
|
SubMode string `json:"sub_mode"`
|
|
RiskLevel string `json:"risk_level"`
|
|
Budget agentBudget `json:"budget"`
|
|
BindingScope string `json:"binding_scope"`
|
|
RoleTemplates []string `json:"role_templates"`
|
|
DefaultModelID string `json:"default_model_id"`
|
|
RoleModels map[string]string `json:"role_models"`
|
|
ResourceGrants []agentResourceGrant `json:"resource_grants"`
|
|
}
|
|
|
|
type agentTaskSnapshot struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Intent string `json:"intent"`
|
|
Status string `json:"status"`
|
|
Card map[string]any `json:"card"`
|
|
}
|
|
|
|
func stringFromTaskCard(card map[string]any, key string) string {
|
|
if card == nil {
|
|
return ""
|
|
}
|
|
if value, ok := card[key].(string); ok {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func objectiveFromTaskSnapshot(task agentTaskSnapshot) string {
|
|
for _, value := range []string{
|
|
stringFromTaskCard(task.Card, "goal"),
|
|
task.Name,
|
|
task.Intent,
|
|
} {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizeAgentDraftBudget(budget agentBudget) agentBudget {
|
|
if budget.MaxTokens <= 0 {
|
|
budget.MaxTokens = 120000
|
|
}
|
|
if budget.MaxCostUSD <= 0 {
|
|
budget.MaxCostUSD = 8
|
|
}
|
|
if budget.MaxDurationSec <= 0 {
|
|
budget.MaxDurationSec = 3600
|
|
}
|
|
return budget
|
|
}
|
|
|
|
func normalizeAgentDraftRoleTemplates(values []string) []string {
|
|
roles := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
role := strings.TrimSpace(value)
|
|
if role == "" {
|
|
continue
|
|
}
|
|
roles = append(roles, role)
|
|
}
|
|
if len(roles) == 0 {
|
|
return []string{"backend"}
|
|
}
|
|
return roles
|
|
}
|
|
|
|
func defaultAgentTaskBindingScope(taskID string) string {
|
|
bindingScope := "task-" + sanitizeAgentRef(taskID)
|
|
if bindingScope == "task-" {
|
|
return "task-local"
|
|
}
|
|
return bindingScope
|
|
}
|
|
|
|
func defaultTaskDraftResourceGrant(userID string, bindingScope string, role string, taskID string) agentResourceGrant {
|
|
return agentResourceGrant{
|
|
GrantID: "grant-" + sanitizeAgentRef(taskID) + "-" + sanitizeAgentRef(role),
|
|
ResourceID: "task-" + sanitizeAgentRef(taskID) + "-context",
|
|
ResourceType: agentResourceProjectDoc,
|
|
UserID: userID,
|
|
BindingScope: bindingScope,
|
|
TargetRole: role,
|
|
TargetAgentRef: "agent-" + sanitizeAgentRef(role) + "-1",
|
|
PermissionScope: []string{"doc:read"},
|
|
Constraints: map[string]string{"ref": "task-card"},
|
|
Metadata: map[string]string{"provider": "heicode-task", "resource_ref": taskID},
|
|
Status: agentGrantStatusActive,
|
|
Audit: map[string]string{"source": "heicode-task-draft"},
|
|
}
|
|
}
|
|
|
|
func sanitizeAgentRef(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
var b strings.Builder
|
|
for _, r := range value {
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
b.WriteRune(r)
|
|
case r >= '0' && r <= '9':
|
|
b.WriteRune(r)
|
|
case r == '-' || r == '_':
|
|
b.WriteRune(r)
|
|
default:
|
|
b.WriteRune('-')
|
|
}
|
|
}
|
|
return strings.Trim(b.String(), "-")
|
|
}
|
|
|
|
func buildAgentDraftAgentPlan(role string, defaultModelID string, grants []agentResourceGrant) agentAgentPlan {
|
|
if defaultModelID == "" {
|
|
defaultModelID = defaultAgentModelID()
|
|
}
|
|
return agentAgentPlan{
|
|
RoleTemplate: role,
|
|
Goal: fmt.Sprintf("Execute the Heicode task as %s within the approved resource scope.", role),
|
|
DefaultModelID: defaultModelID,
|
|
ResourceGrants: grants,
|
|
}
|
|
}
|
|
|
|
func normalizeTaskDraftResourceGrants(userID string, bindingScope string, role string, taskID string, grants []agentResourceGrant) []agentResourceGrant {
|
|
if len(grants) == 0 {
|
|
return []agentResourceGrant{defaultTaskDraftResourceGrant(userID, bindingScope, role, taskID)}
|
|
}
|
|
normalized := make([]agentResourceGrant, 0, len(grants))
|
|
for idx, grant := range grants {
|
|
grant.UserID = userID
|
|
if strings.TrimSpace(grant.GrantID) == "" {
|
|
grant.GrantID = fmt.Sprintf("grant-%s-%s-%d", sanitizeAgentRef(taskID), sanitizeAgentRef(role), idx+1)
|
|
}
|
|
if strings.TrimSpace(grant.BindingScope) == "" {
|
|
grant.BindingScope = bindingScope
|
|
}
|
|
if strings.TrimSpace(grant.TargetRole) == "" {
|
|
grant.TargetRole = role
|
|
}
|
|
if strings.TrimSpace(grant.TargetAgentRef) == "" {
|
|
grant.TargetAgentRef = "agent-" + sanitizeAgentRef(role) + "-1"
|
|
}
|
|
if strings.TrimSpace(grant.Status) == "" {
|
|
grant.Status = agentGrantStatusActive
|
|
}
|
|
grant = resolveResourceBindingIntoGrant(userID, grant)
|
|
normalized = append(normalized, grant)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
// resolveResourceBindingIntoGrant injects the stored ResourceBinding's real
|
|
// secret_ref and resource metadata when the client referenced a binding by id
|
|
// instead of inlining a secret_ref (unified spec §17.6). The binding must be
|
|
// owned by the requesting user; unknown/unowned ids are left untouched so the
|
|
// existing plan validation surfaces a clear error.
|
|
func resolveResourceBindingIntoGrant(userID string, grant agentResourceGrant) agentResourceGrant {
|
|
if grant.ResourceBindingID <= 0 || model.DB == nil {
|
|
return grant
|
|
}
|
|
uid, _ := strconv.Atoi(strings.TrimSpace(userID))
|
|
if uid <= 0 {
|
|
return grant
|
|
}
|
|
var binding model.ResourceBinding
|
|
if err := model.DB.Where("id = ? AND user_id = ?", grant.ResourceBindingID, uid).First(&binding).Error; err != nil {
|
|
return grant
|
|
}
|
|
if strings.TrimSpace(grant.SecretRef) == "" {
|
|
grant.SecretRef = strings.TrimSpace(binding.SecretRef)
|
|
}
|
|
if strings.TrimSpace(grant.ResourceID) == "" {
|
|
grant.ResourceID = fmt.Sprintf("rb_%d", binding.Id)
|
|
}
|
|
if strings.TrimSpace(grant.ResourceType) == "" {
|
|
grant.ResourceType = strings.TrimSpace(binding.ResourceType)
|
|
}
|
|
if strings.TrimSpace(grant.BindingScope) == "" {
|
|
grant.BindingScope = strings.TrimSpace(binding.BindingScope)
|
|
}
|
|
return grant
|
|
}
|
|
|
|
func AgentCreateTaskDeploymentDraft(c *gin.Context) {
|
|
taskID := strings.TrimSpace(c.Param("task_id"))
|
|
if taskID == "" {
|
|
agentError(c, "TASK_NOT_FOUND", "task_id is required")
|
|
return
|
|
}
|
|
|
|
var req agentTaskDeploymentDraftRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
agentError(c, "POLICY_REJECTED", err.Error())
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Task.ID) == "" {
|
|
agentError(c, "TASK_NOT_FOUND", "task snapshot is required")
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Task.ID) != taskID {
|
|
agentError(c, "TASK_CONFLICT", "task snapshot id must match route task_id")
|
|
return
|
|
}
|
|
if !isValidAgentSubMode(req.SubMode) {
|
|
agentError(c, "POLICY_REJECTED", "sub_mode must be agile or waterfall")
|
|
return
|
|
}
|
|
|
|
userID := strconv.Itoa(c.GetInt("id"))
|
|
if userID == "0" {
|
|
agentError(c, "POLICY_REJECTED", "authenticated user is required")
|
|
return
|
|
}
|
|
objective := objectiveFromTaskSnapshot(req.Task)
|
|
if objective == "" {
|
|
agentError(c, "POLICY_REJECTED", "task objective is required")
|
|
return
|
|
}
|
|
|
|
bindingScope := strings.TrimSpace(req.BindingScope)
|
|
if bindingScope == "" {
|
|
bindingScope = defaultAgentTaskBindingScope(taskID)
|
|
}
|
|
roles := normalizeAgentDraftRoleTemplates(req.RoleTemplates)
|
|
riskLevel := strings.TrimSpace(req.RiskLevel)
|
|
if riskLevel == "" {
|
|
riskLevel = agentRiskLow
|
|
}
|
|
defaultModelID := strings.TrimSpace(req.DefaultModelID)
|
|
group := strings.TrimSpace(c.GetString("group"))
|
|
agents := make([]agentAgentPlan, 0, len(roles))
|
|
runtimeAgents := make([]agentRuntimeAgent, 0, len(roles))
|
|
// per_role model selection (unified spec §9): role_models[role] wins, then
|
|
// the request default, then the platform default. Every resolved model is
|
|
// collected into allowed_model_ids so create-time validation accepts them.
|
|
allowedSeen := map[string]bool{}
|
|
allowedModels := []string{}
|
|
addAllowedModel := func(m string) {
|
|
m = strings.TrimSpace(m)
|
|
if m == "" || allowedSeen[m] {
|
|
return
|
|
}
|
|
allowedSeen[m] = true
|
|
allowedModels = append(allowedModels, m)
|
|
}
|
|
for _, role := range roles {
|
|
grants := normalizeTaskDraftResourceGrants(userID, bindingScope, role, taskID, req.ResourceGrants)
|
|
modelRef := firstNonEmpty(req.RoleModels[role], defaultModelID, defaultAgentModelID())
|
|
agents = append(agents, buildAgentDraftAgentPlan(role, modelRef, grants))
|
|
runtimeAgents = append(runtimeAgents, agentRuntimeAgent{Role: role, ModelRef: modelRef, InstanceCount: 1})
|
|
addAllowedModel(modelRef)
|
|
}
|
|
|
|
plan := agentOrchestrationPlan{
|
|
IntentID: taskID,
|
|
TemplateHint: "heicode-task",
|
|
Objective: objective,
|
|
SubMode: normalizeAgentSubMode(req.SubMode),
|
|
RiskLevel: riskLevel,
|
|
Budget: normalizeAgentDraftBudget(req.Budget),
|
|
UserContext: agentUserContext{
|
|
UserID: userID,
|
|
Role: "user",
|
|
ChannelID: group,
|
|
},
|
|
AgentRuntime: agentAgentRuntime{Platform: "agent", Agents: runtimeAgents},
|
|
Agents: agents,
|
|
Constraints: agentConstraints{AllowedModelIDs: allowedModels},
|
|
Metadata: agentMetadata{
|
|
CorrelationID: "task-" + sanitizeAgentRef(taskID) + "-" + common.GetUUID()[:8],
|
|
},
|
|
}
|
|
if group != "" {
|
|
plan.BillingContext = agentBillingContext{Provider: "newapi", NewAPIGroup: group}
|
|
}
|
|
if !validateOrchestrationPlan(c, plan) {
|
|
return
|
|
}
|
|
|
|
common.ApiSuccess(c, gin.H{
|
|
"task_id": taskID,
|
|
"orchestration_plan": plan,
|
|
})
|
|
}
|