Files
artex/server/server_mgmt.go
T
dela 97668dab7e
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
docs / links (push) Waiting to run
web / web (push) Waiting to run
Localize user-facing UI, agent output, and backend messages to Simplified Chinese
2026-10-09 14:08:31 +08:00

2299 lines
70 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"text/template"
"text/template/parse"
"time"
"unicode"
"unicode/utf8"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/llm"
"github.com/Autumn-27/norma/skill"
)
// validSkillName checks the agentskills.io name constraints, widened so a skill can
// also be named in Chinese (or any other script): ASCII must stay lowercase
// alphanumeric + hyphens, while non-ASCII letters/digits are accepted as-is.
// 1-64 runes, must start with a letter, no leading/trailing/consecutive hyphens.
// The name doubles as a directory name under skillDir, so nothing that could carry a
// path (separators, dots, spaces, control characters) is allowed through.
func validSkillName(name string) bool {
if name == "" || !utf8.ValidString(name) {
return false
}
rs := []rune(name)
if len(rs) > 64 {
return false
}
isLetter := func(r rune) bool {
return (r >= 'a' && r <= 'z') || (r > unicode.MaxASCII && unicode.IsLetter(r))
}
if !isLetter(rs[0]) || rs[len(rs)-1] == '-' {
return false
}
for _, r := range rs {
switch {
case isLetter(r), r >= '0' && r <= '9', r == '-':
case r > unicode.MaxASCII && unicode.IsDigit(r):
default:
return false
}
}
return !strings.Contains(name, "--")
}
// reAgentKey mirrors the agents.key DB check: lowercase letter start, then
// lowercase letters / digits / underscores.
var reAgentKey = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
// F3b: 사용자(관리 콘솔 UI)에게 writeErr/writeJSON 으로 노출되는 응답 문구만 한국어로 모은다.
// 두뇌 段[A]·에이전트 도구 설명·로그(log.Printf)·코드 주석은 BRIEF 방침대로 원문을 보존하고,
// 식별자·필드명(key·llm_profile_id·file·SKILL.md·name)·enum·%s/%d·err.Error() 도 원문 그대로 둔다.
const (
// 모든 관리 핸들러가 거치는 공유 DB 게이트(pg).
errMgmtPGUnavailable = "未连接到管理控制台数据源(PostgreSQL)"
// 작업 삭제
errMgmtTaskIDInvalid = "任务 id 无效"
errMgmtTaskDeleting = "正在删除任务"
errMgmtTaskHasAgents = "仍有运行中的 Agent,已取消任务删除"
// 에이전트 CRUD
errMgmtAgentKeyFormat = "key 必须以小写字母开头,且只能使用小写字母、数字和下划线"
errMgmtNameEmpty = "名称不能为空"
errMgmtAgentKeyExists = "key 已存在"
errMgmtBuiltinNoEditMeta = "内置 Agent 无法修改名称/描述"
errMgmtBuiltinNoDelete = "内置 Agent 无法删除"
errMgmtLLMProfileIDFormat = "llm_profile_id 格式无效"
errMgmtLLMProfileInvalid = "指定的 LLM 配置不存在或无效"
errMgmtNoBuiltinPrompt = "该 Agent 没有内置默认提示词,无法还原"
// 도구 (뒤에 key 를 이어 붙임)
errMgmtToolNotFound = "找不到工具:"
errMgmtNotBuiltinTool = "不是内置工具或不存在:"
// MCP
errMgmtMCPNotFound = "找不到 MCP"
errMgmtToolDiscoverFail = "工具发现失败:" // 뒤에 err.Error()
// 스킬 업로드
errMgmtSkillNameInvalid = "技能名称无效"
errMgmtSkillNoFile = "没有上传文件(表单字段 file)或超出大小限制"
errMgmtSkillNoMDInZip = "压缩包中找不到 SKILL.md"
errMgmtSkillReadMDFail = "无法读取 SKILL.md:" // 뒤에 err.Error()
errMgmtSkillNamePre = "技能名称无效(取自 SKILL.md 的 name 字段):"
errMgmtSkillNamePost = " (64 个字符以内,以字母开头,只能使用小写字母、数字、连字符或韩文等非 ASCII 字符,不能使用空格、点和路径分隔符)"
// "이미 존재" 는 web system/skills/page.tsx 가 includes("이미 존재") 로 덮어쓰기 흐름을
// 띄우는 교차 스택 센티넬이다. 이 접두어를 바꾸면 프론트 미러도 함께 고쳐야 한다.
errMgmtSkillExistsPre = "技能已存在:"
errMgmtSkillExistsPost = " (如需覆盖,请确认后重试)"
errMgmtSkillZipBadPath = "压缩包中存在无效路径 " // 뒤에 항목 이름 + ": " + 사유
errMgmtSkillZipTooMany = "压缩包中文件过多"
errMgmtSkillFileTooLarge = "文件过大:" // 뒤에 경로
errMgmtSkillZipTooLarge = "解压后体积过大"
errMgmtSkillNoMDAfter = "解压后没有 SKILL.md"
errMgmtSkillInstallFail = "安装失败:" // 뒤에 err.Error()
// LLM 설정 삭제
errMgmtLLMActiveDelete = "无法删除当前已激活的 LLM 配置。请先启用其他配置"
errMgmtLLMRefChanged = "LLM 配置正在被任务或对话修改,请重试"
errMgmtLLMRefTimeout = "等待 LLM 配置引用释放超时,请重试"
// LLM 연결 테스트·모델 목록 조회 (pgListModels)
errMgmtNoAPIKey = "未提供 API Key"
errMgmtBuildReqFail = "无法构造请求:" // 뒤에 err.Error()
errMgmtReqFail = "请求失败:" // 뒤에 err.Error()
errMgmtAPIReturned = "API 返回了 %d:%s" // fmt.Sprintf
errMgmtParseRespFail = "无法解析响应:" // 뒤에 err.Error()
errMgmtNoModelList = "无法获取模型列表"
// 프롬프트 템플릿 편집기 (validateTemplate)
errMgmtTmplSyntax = "模板语法错误:" // 뒤에 err.Error()
errMgmtTmplVarPre = "变量 {{."
errMgmtTmplVarPost = "}} 不在该 Agent 的允许列表中"
)
// pgReady returns the PG handle, or writes 503 and returns nil if unavailable.
func (s *Server) pg(w http.ResponseWriter) *db.DB {
if s.m.pg == nil {
writeErr(w, 503, errMgmtPGUnavailable)
return nil
}
return s.m.pg
}
func pathInt(r *http.Request, name string) (int64, bool) {
n, err := strconv.ParseInt(r.PathValue(name), 10, 64)
return n, err == nil
}
func decode(r *http.Request, v any) error { return json.NewDecoder(r.Body).Decode(v) }
// ---------- tasks (delete) ----------
const taskDeleteDrainTimeout = 10 * time.Second
func canonicalTaskID(raw string) (string, bool) {
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n <= 0 {
return "", false
}
return strconv.FormatInt(n, 10), true
}
// beginTaskDelete serializes the delete barrier with pause, resume, admission,
// and FIFO reconciliation. Every lifecycle path rechecks deleting after taking
// concMu, so none can commit a contradictory state once this returns.
func (s *Server) beginTaskDelete(taskID string) bool {
s.concMu.Lock()
defer s.concMu.Unlock()
if s.engine.IsDeleting(taskID) {
return false
}
return s.engine.BeginDelete(taskID)
}
// abortTaskDelete restores the execution barrier from the committed task row,
// not from the in-memory state observed before deletion began. Keeping the task
// paused on an unreadable row is conservative: a later explicit resume can
// safely recover it without allowing work to escape an uncertain delete.
func (s *Server) abortTaskDelete(taskID string) {
s.concMu.Lock()
defer s.concMu.Unlock()
keepPaused := true
if id, err := strconv.ParseInt(taskID, 10, 64); err == nil && s.m != nil && s.m.pg != nil {
if persisted, getErr := s.m.pg.GetTask(id); getErr == nil && persisted != nil {
keepPaused = persisted.Paused || persisted.Queued
} else if task, ok := s.m.Task(taskID); ok {
state := task.lifecycleSnapshot()
keepPaused = state.Paused || state.Queued
if getErr != nil {
log.Printf("[task-delete] task %s 读取持久状态失败,使用内存状态恢复屏障: %v", taskID, getErr)
}
} else if getErr == nil {
// The request targeted a task that does not exist. Do not retain a
// synthetic pause entry after releasing its temporary delete barrier.
keepPaused = false
}
}
s.engine.AbortDelete(taskID, keepPaused)
}
func (s *Server) pgDeleteTask(w http.ResponseWriter, r *http.Request) {
id, ok := canonicalTaskID(r.PathValue("id"))
if !ok {
writeErr(w, http.StatusBadRequest, errMgmtTaskIDInvalid)
return
}
var opts DeleteTaskOptions
if err := decode(r, &opts); err != nil && err != io.EOF {
writeErr(w, 400, "invalid JSON: "+err.Error())
return
}
if !s.beginTaskDelete(id) {
writeErr(w, http.StatusConflict, errMgmtTaskDeleting)
return
}
deleted := false
defer func() {
if !deleted {
s.abortTaskDelete(id)
}
}()
// Main-agent runs use a separate context from planner/workers. Cancel it, then
// wait until both execution domains have returned before removing transcripts.
s.cancelTaskChat(id, agent.AbortTaskDeleted)
drainCtx, cancelDrain := context.WithTimeout(r.Context(), taskDeleteDrainTimeout)
defer cancelDrain()
if err := s.waitTaskQuiescent(drainCtx, id); err != nil {
writeErr(w, http.StatusConflict, errMgmtTaskHasAgents)
return
}
if err := s.drainTaskSideQuestions(drainCtx, id); err != nil {
writeErr(w, http.StatusConflict, err.Error())
return
}
result, err := s.m.DeleteTask(id, opts)
if err != nil {
var committed *taskDeleteCommittedError
if errors.As(err, &committed) {
// PostgreSQL is already gone. Complete runtime teardown and return the
// auditable counts together with the post-commit cleanup warning.
s.engine.StopTask(id)
s.taskAgentMu.Lock()
delete(s.taskAgents, id)
s.taskAgentMu.Unlock()
deleted = true
writeCommittedTaskDelete(w, result, err)
return
}
writeErr(w, 500, err.Error())
return
}
// Manager has removed the task from the registry, so no new API operation can
// resolve it. Now stop and join every task-owned Engine goroutine and clear its
// lifecycle maps before releasing the delete barrier.
s.engine.StopTask(id)
s.taskAgentMu.Lock()
delete(s.taskAgents, id)
s.taskAgentMu.Unlock()
deleted = true
writeJSON(w, 200, result)
}
func writeCommittedTaskDelete(w http.ResponseWriter, result DeleteTaskResult, err error) {
result.CleanupWarning = err.Error()
writeJSON(w, http.StatusOK, result)
}
func (s *Server) waitTaskQuiescent(ctx context.Context, taskID string) error {
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
s.chatMu.Lock()
chatBusy := s.chatBusy[taskID]
s.chatMu.Unlock()
if s.engine.inflightCount(taskID) == 0 && !chatBusy {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}
// ---------- agents ----------
func (s *Server) pgListAgents(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
ags, err := pg.ListAgents()
if err != nil {
writeErr(w, 500, err.Error())
return
}
dtos := agentDTOs(ags)
// overlay per-agent binding counts (mcp/skill by id, tools by key) — best-effort.
if mcp, skill, tools, err := pg.AgentBindingCounts(); err == nil {
for i := range dtos {
dtos[i].McpCount = mcp[ags[i].ID]
dtos[i].SkillCount = skill[ags[i].ID]
dtos[i].ToolCount = tools[ags[i].Key]
}
}
writeJSON(w, 200, map[string]any{"agents": dtos})
}
// pgCreateAgent creates a CUSTOM conversational agent (builtin=false, role
// 'assistant') and seeds it a starter prompt so its editor isn't blank.
func (s *Server) pgCreateAgent(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
var req struct{ Key, Name, Description string }
if err := decode(r, &req); err != nil {
writeErr(w, 400, err.Error())
return
}
req.Key, req.Name = strings.TrimSpace(req.Key), strings.TrimSpace(req.Name)
if !reAgentKey.MatchString(req.Key) {
writeErr(w, 400, errMgmtAgentKeyFormat)
return
}
if req.Name == "" {
writeErr(w, 400, errMgmtNameEmpty)
return
}
if exist, _ := pg.GetAgentByKey(req.Key); exist != nil {
writeErr(w, 409, errMgmtAgentKeyExists)
return
}
a, err := pg.CreateAgent(req.Key, req.Name, req.Description)
if err != nil {
writeErr(w, 500, err.Error())
return
}
// starter prompt so the editor shows something editable from the start.
if err := pg.SeedPromptIfEmpty(a.ID, agent.DefaultAssistantPrompt); err != nil {
log.Printf("[agents] seed starter prompt for %s 失败: %v", a.Key, err)
}
writeJSON(w, 200, agentDTO(a))
}
// pgUpdateAgent updates a custom agent's name/description (built-in agents rejected).
func (s *Server) pgUpdateAgent(w http.ResponseWriter, r *http.Request) {
pg, a, ok := s.agentByKey(w, r)
if !ok {
return
}
if a.Builtin {
writeErr(w, 400, errMgmtBuiltinNoEditMeta)
return
}
var req struct{ Name, Description string }
if err := decode(r, &req); err != nil {
writeErr(w, 400, err.Error())
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
writeErr(w, 400, errMgmtNameEmpty)
return
}
if err := pg.UpdateAgentMeta(a.Key, req.Name, req.Description); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
// pgDeleteAgent removes a custom agent (built-in rejected). Prompts/vars/visibility
// cascade via FK; tool-binding cleanup is best-effort.
func (s *Server) pgDeleteAgent(w http.ResponseWriter, r *http.Request) {
pg, a, ok := s.agentByKey(w, r)
if !ok {
return
}
if a.Builtin {
writeErr(w, 400, errMgmtBuiltinNoDelete)
return
}
if err := pg.DeleteAgent(a.Key); err != nil {
writeErr(w, 500, err.Error())
return
}
if err := pg.RemoveAgentFromToolBindings(a.Key); err != nil {
log.Printf("[agents] 清理 %s 工具绑定失败: %v", a.Key, err)
}
if err := pg.DeleteTriggersForAgent(a.Key); err != nil {
log.Printf("[agents] 清理 %s 触发器失败: %v", a.Key, err)
}
writeJSON(w, 200, map[string]any{"deleted": a.Key})
}
func (s *Server) agentByKey(w http.ResponseWriter, r *http.Request) (*db.DB, *db.Agent, bool) {
pg := s.pg(w)
if pg == nil {
return nil, nil, false
}
a, err := pg.GetAgentByKey(r.PathValue("key"))
if err != nil {
writeErr(w, 500, err.Error())
return nil, nil, false
}
if a == nil {
writeErr(w, 404, "agent not found")
return nil, nil, false
}
return pg, a, true
}
// pgSaveAgentConfig updates an agent's runtime config (currently max_turns) and
// re-applies the live LLM so the change takes effect immediately.
func (s *Server) pgSaveAgentConfig(w http.ResponseWriter, r *http.Request) {
pg, a, ok := s.agentByKey(w, r)
if !ok {
return
}
// All fields optional (pointers) so a partial patch (e.g. the triggers tab sending
// only trigger_* fields) leaves the untouched settings alone instead of resetting
// max_turns/run_seconds to 0.
var req struct {
MaxTurns *int `json:"max_turns"`
RunSeconds *int `json:"run_seconds"`
WebSearch *bool `json:"web_search"`
InteractiveShell *bool `json:"interactive_shell"`
// llm_profile_id 三态:字段缺省=不动;显式 null=解绑(跟随任务/全局);数字=绑定该 profile。
LLMProfileID json.RawMessage `json:"llm_profile_id"`
// P3 触发后处理策略(三者一起可选,提供任一即整体写入;未提供则不动)。
TriggerRunMode *string `json:"trigger_run_mode"`
TriggerMergeMode *string `json:"trigger_merge_mode"`
TriggerMaxParallel *int `json:"trigger_max_parallel"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, err.Error())
return
}
profileChanged := false
if req.LLMProfileID != nil { // key present (数字 或 null)
var id *int64
if err := json.Unmarshal(req.LLMProfileID, &id); err != nil {
writeErr(w, 400, errMgmtLLMProfileIDFormat)
return
}
if id != nil { // 绑定:校验目标 profile 有效
if _, ok := s.loadProfileConfig(*id); !ok {
writeErr(w, 400, errMgmtLLMProfileInvalid)
return
}
}
if err := pg.SetAgentLLMProfile(a.Key, id); err != nil {
writeErr(w, 500, err.Error())
return
}
profileChanged = true
}
if req.MaxTurns != nil {
mt := *req.MaxTurns
if mt < 0 {
mt = 0
}
if err := pg.SetAgentMaxTurns(a.Key, mt); err != nil {
writeErr(w, 500, err.Error())
return
}
}
if req.RunSeconds != nil {
rs := *req.RunSeconds
if rs < 0 {
rs = 0
}
if err := pg.SetAgentRunSeconds(a.Key, rs); err != nil {
writeErr(w, 500, err.Error())
return
}
}
if req.WebSearch != nil {
if err := pg.SetAgentWebSearch(a.Key, *req.WebSearch); err != nil {
writeErr(w, 500, err.Error())
return
}
}
if req.InteractiveShell != nil {
if err := pg.SetAgentInteractiveShell(a.Key, *req.InteractiveShell); err != nil {
writeErr(w, 500, err.Error())
return
}
}
// P3 触发策略:三者作为一组写入(SetAgentTriggerBehavior 一次写三列),缺的字段用
// 当前存量值回填,避免只传一个把另两个覆盖成默认。
if req.TriggerRunMode != nil || req.TriggerMergeMode != nil || req.TriggerMaxParallel != nil {
runMode, mergeMode, maxPar := a.TriggerRunMode, a.TriggerMergeMode, a.TriggerMaxParallel
if req.TriggerRunMode != nil {
runMode = *req.TriggerRunMode
}
if req.TriggerMergeMode != nil {
mergeMode = *req.TriggerMergeMode
}
if req.TriggerMaxParallel != nil {
maxPar = *req.TriggerMaxParallel
}
if err := pg.SetAgentTriggerBehavior(a.Key, runMode, mergeMode, maxPar); err != nil {
writeErr(w, 500, err.Error())
return
}
}
// an agent's LLM binding change also invalidates pinned-task / per-profile caches
// so their next round re-resolves each agent's bound model.
if profileChanged {
s.invalidateProfileAgents()
}
// Task bundles capture operational Agent settings (turn/time budgets, tools,
// web search). Rebuild them even when no global profile is configured.
s.invalidateTaskAgents()
// rebuild the live agents so the new max_turns/run_seconds/binding apply without a restart.
s.cfgMu.Lock()
cfg, on := s.llmCfg, s.llmOn
s.cfgMu.Unlock()
if on {
_ = s.applyLLM(cfg)
}
resp := map[string]any{"ok": true}
if req.MaxTurns != nil {
resp["max_turns"] = *req.MaxTurns
}
if req.RunSeconds != nil {
resp["run_seconds"] = *req.RunSeconds
}
writeJSON(w, 200, resp)
}
func (s *Server) pgGetAgent(w http.ResponseWriter, r *http.Request) {
pg, a, ok := s.agentByKey(w, r)
if !ok {
return
}
cur, _ := pg.CurrentPrompt(a.ID)
vars, _ := pg.PromptVars(a.ID)
vars = withGlobalVars(vars)
vers, _ := pg.ListPromptVersions(a.ID)
if vers == nil {
vers = []db.PromptVersion{}
}
mcp, _ := pg.AgentVisible(a.ID, "mcp")
sk, _ := pg.AgentSkillNames(a.ID)
if sk == nil {
sk = []string{}
}
// 可选 LLM 配置列表(id/name/model/是否默认),供前端渲染 "默认模型" 下拉;当前绑定见 agent.llm_profile_id。
profs, _ := pg.ListProfiles()
llmProfiles := make([]map[string]any, 0, len(profs))
for _, p := range profs {
llmProfiles = append(llmProfiles, map[string]any{
"id": p.ID, "name": p.Name, "model": p.Model, "is_default": p.IsDefault,
})
}
writeJSON(w, 200, map[string]any{
"agent": agentDTO(a), "prompt": cur, "variables": vars, "versions": vers,
"visibility": map[string]any{"mcp": mcp, "skill": sk},
"llm_profiles": llmProfiles, // 可绑定的 LLM 配置候选
"wrapup_prompt": a.WrapupPrompt, // 已保存的收尾提示词(空=用内置默认)
"wrapup_default": agent.WrapupDefault(a.Key), // 内置默认(供占位/恢复默认)
"wrapup_max_turns": a.WrapupMaxTurns, // 已保存的收尾轮数(0=用内置默认)
"wrapup_max_turns_default": agent.WrapupTurnsDefault(a.Key), // 内置默认轮数(供 "0=默认N" 提示)
// 任务级超时收尾词(仅 worker/planner 有内置默认;task_timeout_supported 供前端决定是否显示该分区)
"task_timeout_wrapup_supported": agent.TaskTimeoutWrapupDefault(a.Key) != "",
"task_timeout_wrapup_prompt": a.TaskTimeoutWrapupPrompt,
"task_timeout_wrapup_default": agent.TaskTimeoutWrapupDefault(a.Key),
"task_timeout_wrapup_max_turns": a.TaskTimeoutWrapupMaxTurns,
"task_timeout_wrapup_max_turns_default": agent.WrapupTurnsDefault(a.Key),
})
}
func (s *Server) pgSavePrompt(w http.ResponseWriter, r *http.Request) {
pg, a, ok := s.agentByKey(w, r)
if !ok {
return
}
var body struct{ Template, Note string }
if err := decode(r, &body); err != nil {
writeErr(w, 400, err.Error())
return
}
vars, _ := pg.PromptVars(a.ID)
if bad := validateTemplate(body.Template, withGlobalVars(vars)); bad != "" {
writeErr(w, 400, bad)
return
}
ver, err := pg.SavePrompt(a.ID, body.Template, body.Note, "ui")
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"version": ver})
}
// pgResetPrompt restores an agent's prompt body to the in-code built-in default
// (段 [A]). Only built-in agents have a code default; custom agents have none.
func (s *Server) pgResetPrompt(w http.ResponseWriter, r *http.Request) {
pg, a, ok := s.agentByKey(w, r)
if !ok {
return
}
tmpl, has := agent.BuiltinPromptSeeds()[a.Key]
if !has {
writeErr(w, 400, errMgmtNoBuiltinPrompt)
return
}
ver, err := pg.ResetPromptToDefault(a.ID, tmpl)
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"version": ver})
}
// pgSaveWrapup stores an agent's wrap-up (settlement) prompt — the text injected on
// timeout / step-exhaustion. Empty body clears the override → built-in default.
func (s *Server) pgSaveWrapup(w http.ResponseWriter, r *http.Request) {
pg, a, ok := s.agentByKey(w, r)
if !ok {
return
}
// MaxTurns is optional (pointer): omit to leave the stored turn budget untouched.
var body struct {
Prompt string `json:"prompt"`
MaxTurns *int `json:"max_turns"`
}
if err := decode(r, &body); err != nil {
writeErr(w, 400, err.Error())
return
}
if err := pg.SetAgentWrapupPrompt(a.Key, body.Prompt); err != nil {
writeErr(w, 500, err.Error())
return
}
if body.MaxTurns != nil {
n := *body.MaxTurns
if n < 0 {
n = 0
}
if err := pg.SetAgentWrapupMaxTurns(a.Key, n); err != nil {
writeErr(w, 500, err.Error())
return
}
}
writeJSON(w, 200, map[string]any{"ok": true})
}
// pgResetWrapup clears an agent's wrap-up prompt override so the code built-in
// default is used again.
func (s *Server) pgResetWrapup(w http.ResponseWriter, r *http.Request) {
pg, a, ok := s.agentByKey(w, r)
if !ok {
return
}
if err := pg.SetAgentWrapupPrompt(a.Key, ""); err != nil {
writeErr(w, 500, err.Error())
return
}
if err := pg.SetAgentWrapupMaxTurns(a.Key, 0); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{
"ok": true,
"wrapup_default": agent.WrapupDefault(a.Key),
"wrapup_max_turns_default": agent.WrapupTurnsDefault(a.Key),
})
}
// pgSaveTaskTimeoutWrapup stores an agent's TASK-TIMEOUT wrap-up prompt + turn budget
// (worker/planner only). Empty prompt / 0 turns clear the override → built-in default.
func (s *Server) pgSaveTaskTimeoutWrapup(w http.ResponseWriter, r *http.Request) {
pg, a, ok := s.agentByKey(w, r)
if !ok {
return
}
var body struct {
Prompt string `json:"prompt"`
MaxTurns *int `json:"max_turns"`
}
if err := decode(r, &body); err != nil {
writeErr(w, 400, err.Error())
return
}
turns := a.TaskTimeoutWrapupMaxTurns // 未传则保留原值
if body.MaxTurns != nil {
turns = *body.MaxTurns
if turns < 0 {
turns = 0
}
}
if err := pg.SetAgentTaskTimeoutWrapup(a.Key, body.Prompt, turns); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
// pgResetTaskTimeoutWrapup clears the task-timeout wrap-up override → built-in default.
func (s *Server) pgResetTaskTimeoutWrapup(w http.ResponseWriter, r *http.Request) {
pg, a, ok := s.agentByKey(w, r)
if !ok {
return
}
if err := pg.SetAgentTaskTimeoutWrapup(a.Key, "", 0); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{
"ok": true,
"task_timeout_wrapup_default": agent.TaskTimeoutWrapupDefault(a.Key),
"task_timeout_wrapup_max_turns_default": agent.WrapupTurnsDefault(a.Key),
})
}
func (s *Server) pgListPromptVersions(w http.ResponseWriter, r *http.Request) {
pg, a, ok := s.agentByKey(w, r)
if !ok {
return
}
vers, err := pg.ListPromptVersions(a.ID)
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"versions": vers})
}
func (s *Server) pgPromptVars(w http.ResponseWriter, r *http.Request) {
pg, a, ok := s.agentByKey(w, r)
if !ok {
return
}
vars, err := pg.PromptVars(a.ID)
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"variables": withGlobalVars(vars)})
}
func (s *Server) pgPreviewPrompt(w http.ResponseWriter, r *http.Request) {
pg, a, ok := s.agentByKey(w, r)
if !ok {
return
}
var body struct {
Template string `json:"template"`
Sample map[string]string `json:"sample"`
}
_ = decode(r, &body)
vars, _ := pg.PromptVars(a.ID)
if body.Template == "" {
body.Template, _ = pg.CurrentPrompt(a.ID)
}
rendered, err := renderPrompt(body.Template, withGlobalVars(vars), body.Sample)
if err != nil {
writeJSON(w, 200, map[string]any{"rendered": "", "error": err.Error()})
return
}
writeJSON(w, 200, map[string]any{"rendered": rendered})
}
func (s *Server) pgGetAgentVisibility(w http.ResponseWriter, r *http.Request) {
pg, a, ok := s.agentByKey(w, r)
if !ok {
return
}
mcp, _ := pg.AgentVisible(a.ID, "mcp")
sk, _ := pg.AgentSkillNames(a.ID)
if sk == nil {
sk = []string{}
}
writeJSON(w, 200, map[string]any{"mcp": mcp, "skill": sk})
}
func (s *Server) pgSetAgentVisibility(w http.ResponseWriter, r *http.Request) {
pg, a, ok := s.agentByKey(w, r)
if !ok {
return
}
var body struct {
MCP []int64 `json:"mcp"`
Skill []string `json:"skill"`
}
if err := decode(r, &body); err != nil {
writeErr(w, 400, err.Error())
return
}
if err := pg.SetAgentVisibilityKind(a.ID, "mcp", body.MCP); err != nil {
writeErr(w, 500, err.Error())
return
}
if err := pg.SetAgentSkillVisibility(a.ID, body.Skill); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
// ---------- tools (内置工具目录) ----------
func (s *Server) pgListTools(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
ts, err := pg.ListTools()
if err != nil {
writeErr(w, 500, err.Error())
return
}
if ts == nil {
ts = []*db.Tool{}
}
// Usage is best-effort decoration. Runtime tool resolution keeps using the plain
// catalog query, so agent assembly never pays for this aggregate.
counts, countErr := pg.ToolUsageCounts()
if countErr != nil {
log.Printf("[tools] 读取调用统计失败: %v", countErr)
} else {
for _, tool := range ts {
tool.Calls = counts[tool.Key]
}
}
writeJSON(w, 200, map[string]any{"tools": ts})
}
// pgUpdateTool saves the page-editable fields of a built-in tool: description,
// parameter schema (structure is expected unchanged — only per-param description/
// default move), agent binding, and enabled. key is taken from the path and never
// changes (it is welded to the Go handler).
func (s *Server) pgUpdateTool(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
key := r.PathValue("key")
cur, err := pg.GetTool(key)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if cur == nil {
writeErr(w, 404, errMgmtToolNotFound+key)
return
}
var body struct {
Description string `json:"description"`
Schema json.RawMessage `json:"schema"`
Agents []string `json:"agents"`
Enabled bool `json:"enabled"`
}
if err := decode(r, &body); err != nil {
writeErr(w, 400, err.Error())
return
}
agents, _ := json.Marshal(body.Agents)
if err := pg.UpdateTool(key, body.Description, body.Schema, agents, body.Enabled); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
// pgResetTool overwrites a tool row with its code-defined defaults (description,
// schema, agent binding) and re-enables it — the explicit "恢复默认" action, since
// startup seeding is first-insert-only and never overwrites edits.
func (s *Server) pgResetTool(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
key := r.PathValue("key")
for _, sd := range agent.BuiltinToolSeeds() {
if sd.Key != key {
continue
}
schema, _ := json.Marshal(sd.Schema)
agents, _ := json.Marshal(sd.Agents)
if err := pg.UpsertToolForce(sd.Key, sd.Desc, schema, agents); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true})
return
}
// orchestration/platform tools (auto agent) — default-bind to "auto".
autoAgents, _ := json.Marshal([]string{"auto"})
for _, t := range append(s.orchestrationTools(), s.platformTools()...) {
if t.Name() != key {
continue
}
schema, _ := json.Marshal(t.InputSchema())
if err := pg.UpsertToolForce(t.Name(), t.Description(), schema, autoAgents); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true})
return
}
writeErr(w, 404, errMgmtNotBuiltinTool+key)
}
// ---------- mcp ----------
func (s *Server) pgListMCP(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
ms, err := pg.ListMCP()
if err != nil {
writeErr(w, 500, err.Error())
return
}
if ms == nil {
ms = []*db.MCPServer{}
}
writeJSON(w, 200, map[string]any{"servers": ms})
}
func (s *Server) pgSaveMCP(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
var m db.MCPServer
if err := decode(r, &m); err != nil {
writeErr(w, 400, err.Error())
return
}
isNew := m.ID == 0
id, err := pg.SaveMCP(&m)
if err != nil {
writeErr(w, 500, err.Error())
return
}
// On initial add, auto-discover + cache the tool list so the UI shows it right
// away (bounded so a slow/broken server can't hang the request). Skipped on
// plain updates (e.g. enable toggles) to avoid re-spawning the server each time.
if isNew && m.Enabled {
m.ID = id
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
if derr := s.discoverAndCacheMCP(ctx, &m); derr != nil {
log.Printf("[mcp] %s 添加后工具发现失败: %v", m.Name, derr)
}
cancel()
}
writeJSON(w, 200, map[string]any{"id": id})
}
func (s *Server) pgDeleteMCP(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
id, _ := pathInt(r, "id")
if err := pg.DeleteMCP(id); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"deleted": id})
}
// pgRefreshMCP re-discovers one MCP's tools on demand and re-caches them, so a
// config change or an earlier discovery failure can be fixed without a restart.
func (s *Server) pgRefreshMCP(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
id, _ := pathInt(r, "id")
all, err := pg.ListMCP()
if err != nil {
writeErr(w, 500, err.Error())
return
}
var target *db.MCPServer
for _, m := range all {
if m.ID == id {
target = m
break
}
}
if target == nil {
writeErr(w, 404, errMgmtMCPNotFound)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
defer cancel()
if err := s.discoverAndCacheMCP(ctx, target); err != nil {
writeErr(w, 502, errMgmtToolDiscoverFail+err.Error())
return
}
tools, _ := pg.MCPToolsDetailed(id)
writeJSON(w, 200, map[string]any{"tools": tools})
}
// pgMCPTools returns one MCP's cached tools (name + description) for the detail UI.
func (s *Server) pgMCPTools(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
id, _ := pathInt(r, "id")
tools, err := pg.MCPToolsDetailed(id)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if tools == nil {
tools = []db.MCPTool{}
}
writeJSON(w, 200, map[string]any{"tools": tools})
}
// ---------- skills (文件系统) ----------
type skillFileNode struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
License string `json:"license,omitempty"`
Compatibility string `json:"compatibility,omitempty"`
MCPs []string `json:"mcps,omitempty"`
Files []string `json:"files"`
// Usage ledger (db/skill_usage.go). Calls is 0 and LastUsed nil for a skill that
// was never invoked — the ledger only has rows for skills agents actually loaded.
Calls int `json:"calls"`
Tasks int `json:"tasks"`
Agents []string `json:"usage_agents"`
LastUsed *time.Time `json:"last_used,omitempty"`
}
func (s *Server) fsListSkills(w http.ResponseWriter, r *http.Request) {
_ = os.MkdirAll(s.skillDir, 0o755)
allReg, _ := skill.LoadDir(s.skillDir)
metaByDir := map[string]skill.Skill{}
if allReg != nil {
for _, sk := range allReg.List() {
if sk.Dir != "" {
metaByDir[filepath.Base(sk.Dir)] = sk
}
}
}
entries, err := os.ReadDir(s.skillDir)
if err != nil {
writeErr(w, 500, err.Error())
return
}
// Usage is best-effort decoration: a ledger read failure leaves the counts at
// zero rather than failing the skill list itself.
statBySkill := map[string]db.SkillStat{}
if s.m.pg != nil {
stats, err := s.m.pg.SkillStats()
if err != nil {
log.Printf("[skills] 读取调用统计失败: %v", err)
}
for _, st := range stats {
statBySkill[st.Skill] = st
}
}
nodes := []skillFileNode{}
for _, e := range entries {
if !e.IsDir() {
continue
}
dirName := e.Name()
node := skillFileNode{Name: dirName, Agents: []string{}}
if st, ok := statBySkill[dirName]; ok {
node.Calls, node.Tasks, node.Agents, node.LastUsed = st.Calls, st.Tasks, st.Agents, st.LastUsed
}
if meta, ok := metaByDir[dirName]; ok {
node.Description = meta.Description
node.License = meta.License
node.Compatibility = meta.Compatibility
node.MCPs = meta.MCPs
}
node.Files, _ = walkSkillFiles(filepath.Join(s.skillDir, dirName))
if node.Files == nil {
node.Files = []string{}
}
nodes = append(nodes, node)
}
writeJSON(w, 200, map[string]any{"skills": nodes})
}
// fsSkillUsage returns one skill's recent invocations, newest first (detail panel).
func (s *Server) fsSkillUsage(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
name := r.PathValue("name")
if !validSkillName(name) {
writeErr(w, 400, errMgmtSkillNameInvalid)
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
calls, err := pg.RecentSkillCalls(name, limit)
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"calls": calls})
}
// fsMissingSkills lists skill names agents asked for that do not exist — the gap
// list, i.e. which procedures are worth writing next.
func (s *Server) fsMissingSkills(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
missing, err := pg.MissingSkillStats(limit)
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"missing": missing})
}
// cleanStrs trims each string and drops empties.
func cleanStrs(in []string) []string {
out := make([]string, 0, len(in))
for _, s := range in {
if s = strings.TrimSpace(s); s != "" {
out = append(out, s)
}
}
return out
}
func (s *Server) fsCreateSkill(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
Description string `json:"description"` // required per agentskills.io spec
License string `json:"license"` // optional
Compatibility string `json:"compatibility"` // optional
MCPs []string `json:"mcps"` // optional; MCP servers this skill unlocks
Instructions string `json:"instructions"` // optional; scaffolded if empty
}
if err := decode(r, &body); err != nil {
writeErr(w, 400, err.Error())
return
}
if !validSkillName(body.Name) {
writeErr(w, 400, "skill name must be 1-64 lowercase alphanumeric/hyphen characters, not starting/ending/doubling hyphens")
return
}
if strings.TrimSpace(body.Description) == "" {
writeErr(w, 400, "description is required")
return
}
skillPath := filepath.Join(s.skillDir, body.Name)
if _, err := os.Stat(skillPath); err == nil {
writeErr(w, 409, "skill already exists")
return
}
if err := os.MkdirAll(skillPath, 0o755); err != nil {
writeErr(w, 500, err.Error())
return
}
// Build a spec-compliant SKILL.md (agentskills.io format):
// YAML frontmatter: name (required), description (required),
// license, compatibility (optional)
// Markdown body: step-by-step instructions
var sb strings.Builder
sb.WriteString("---\n")
fmt.Fprintf(&sb, "name: %s\n", body.Name)
fmt.Fprintf(&sb, "description: %s\n", body.Description)
if body.License != "" {
fmt.Fprintf(&sb, "license: %s\n", body.License)
}
if body.Compatibility != "" {
fmt.Fprintf(&sb, "compatibility: %s\n", body.Compatibility)
}
// mcps: MCP servers this skill unlocks on load (skill-gated deferred tools).
if mcps := cleanStrs(body.MCPs); len(mcps) > 0 {
fmt.Fprintf(&sb, "mcps: %s\n", strings.Join(mcps, ", "))
}
sb.WriteString("---\n")
if strings.TrimSpace(body.Instructions) != "" {
sb.WriteString(body.Instructions)
} else {
// scaffold a minimal Markdown body so the file is immediately useful
fmt.Fprintf(&sb, "## %s\n\n", body.Name)
sb.WriteString("<!-- Describe step-by-step instructions in Markdown. -->\n\n")
sb.WriteString("1. \n2. \n3. \n")
}
if err := os.WriteFile(filepath.Join(skillPath, "SKILL.md"), []byte(sb.String()), 0o644); err != nil {
_ = os.RemoveAll(skillPath)
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 201, map[string]any{"name": body.Name})
}
// fsUpdateSkillMeta rewrites the SKILL.md frontmatter fields that are safe to
// change without touching the instruction body: mcps, license, compatibility,
// description. Only fields present in the request body are updated; omitted
// fields are left as-is (the whole frontmatter is reconstructed from the
// parsed values, so the write is a clean replace, not a line-patch).
func (s *Server) fsUpdateSkillMeta(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if !validSkillName(name) {
writeErr(w, 400, "invalid skill name")
return
}
var body struct {
MCPs *[]string `json:"mcps"`
Description *string `json:"description"`
License *string `json:"license"`
Compatibility *string `json:"compatibility"`
}
if err := decode(r, &body); err != nil {
writeErr(w, 400, err.Error())
return
}
skillMD := filepath.Join(s.skillDir, name, "SKILL.md")
raw, err := os.ReadFile(skillMD)
if err != nil {
writeErr(w, 404, "skill not found")
return
}
updated, err := rewriteSkillFrontmatter(raw, body.MCPs, body.Description, body.License, body.Compatibility)
if err != nil {
writeErr(w, 400, err.Error())
return
}
if err := os.WriteFile(skillMD, updated, 0o644); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
// rewriteSkillFrontmatter parses the SKILL.md YAML frontmatter and replaces the
// fields given as non-nil pointers. The instruction body (after the closing ---) is
// preserved verbatim. Returns an error if the file has no recognisable frontmatter.
func rewriteSkillFrontmatter(content []byte, mcps *[]string, description, license, compatibility *string) ([]byte, error) {
lines := strings.Split(string(content), "\n")
if len(lines) < 2 || strings.TrimSpace(lines[0]) != "---" {
return nil, fmt.Errorf("SKILL.md has no YAML frontmatter")
}
fmEnd := -1
for i := 1; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) == "---" {
fmEnd = i
break
}
}
if fmEnd < 0 {
return nil, fmt.Errorf("SKILL.md frontmatter is not closed")
}
// Collect existing key → value from frontmatter (preserve unknown keys)
type kv struct{ k, v string }
var pairs []kv
for _, l := range lines[1:fmEnd] {
if idx := strings.IndexByte(l, ':'); idx >= 0 {
pairs = append(pairs, kv{strings.TrimSpace(l[:idx]), strings.TrimSpace(l[idx+1:])})
} else if strings.TrimSpace(l) != "" {
pairs = append(pairs, kv{"", l}) // preserve non-key lines verbatim
}
}
// Apply updates (nil pointer = no change)
applyStr := func(key string, val *string) {
if val == nil {
return
}
for i, p := range pairs {
if p.k == key {
pairs[i].v = strings.TrimSpace(*val)
return
}
}
pairs = append(pairs, kv{key, strings.TrimSpace(*val)})
}
applyStr("description", description)
applyStr("license", license)
applyStr("compatibility", compatibility)
if mcps != nil {
cleaned := cleanStrs(*mcps)
// Remove existing mcps line
filtered := pairs[:0]
for _, p := range pairs {
if p.k != "mcps" {
filtered = append(filtered, p)
}
}
pairs = filtered
if len(cleaned) > 0 {
pairs = append(pairs, kv{"mcps", strings.Join(cleaned, ", ")})
}
}
// Reconstruct
var sb strings.Builder
sb.WriteString("---\n")
for _, p := range pairs {
if p.k == "" {
sb.WriteString(p.v)
} else {
fmt.Fprintf(&sb, "%s: %s", p.k, p.v)
}
sb.WriteByte('\n')
}
sb.WriteString("---\n")
// Body (lines after closing ---)
if fmEnd+1 < len(lines) {
sb.WriteString(strings.Join(lines[fmEnd+1:], "\n"))
}
return []byte(sb.String()), nil
}
// zip-upload safety caps (defeat zip bombs / runaway archives).
const (
maxSkillZipBytes = 20 << 20 // 20MB compressed request body
maxSkillTotalBytes = 100 << 20 // 100MB total uncompressed
maxSkillFileBytes = 20 << 20 // 20MB per extracted file
maxSkillEntries = 4000 // max files in the archive
)
// skillNameFromFrontmatter extracts the `name:` value from a SKILL.md's YAML
// frontmatter (the block between the first two `---` lines). "" if absent.
func skillNameFromFrontmatter(md []byte) string {
lines := strings.Split(string(md), "\n")
inFM := false
for _, ln := range lines {
t := strings.TrimSpace(ln)
if t == "---" {
if !inFM {
inFM = true
continue
}
break // end of frontmatter
}
if inFM && strings.HasPrefix(t, "name:") {
v := strings.TrimSpace(strings.TrimPrefix(t, "name:"))
return strings.Trim(v, `"'`) // name: "中文技能" 也认
}
}
return ""
}
// fsUploadSkill installs a skill from an uploaded .zip. The archive must contain a
// SKILL.md (at the root or under a single top-level dir); the skill name is taken
// from that file's `name:` frontmatter (falling back to the top dir / zip name).
// Zip-slip is defeated by validating every entry path with skillRelPath, and a set
// of size/count caps guard against zip bombs. POST ?overwrite=true replaces an
// existing skill of the same name.
func (s *Server) fsUploadSkill(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxSkillZipBytes)
file, hdr, err := r.FormFile("file")
if err != nil {
writeErr(w, 400, errMgmtSkillNoFile)
return
}
defer file.Close()
buf, err := io.ReadAll(file)
if err != nil {
writeErr(w, 400, err.Error())
return
}
zr, err := newSkillZipReader(buf)
if err != nil {
writeErr(w, 400, err.Error())
return
}
// entries carry UTF-8-decoded names (GBK 包也能读) and exclude archiver junk.
entriesAll := skillZipEntries(zr)
if err := checkSkillZipMethods(entriesAll); err != nil {
writeErr(w, 400, err.Error())
return
}
// locate the shallowest SKILL.md → its directory is the skill root inside the zip.
var skillMD *skillZipEntry
for i := range entriesAll {
e := &entriesAll[i]
if path.Base(e.name) != "SKILL.md" {
continue
}
if skillMD == nil || strings.Count(e.name, "/") < strings.Count(skillMD.name, "/") {
skillMD = e
}
}
if skillMD == nil {
writeErr(w, 400, errMgmtSkillNoMDInZip)
return
}
root := path.Dir(skillMD.name) // "." when SKILL.md is at the zip root
prefix := ""
if root != "." {
prefix = root + "/"
}
// derive + validate the skill name from the SKILL.md frontmatter.
md, err := readZipEntry(skillMD.f)
if err != nil {
writeErr(w, 400, errMgmtSkillReadMDFail+err.Error())
return
}
name := skillNameFromFrontmatter(md)
if name == "" && prefix != "" {
name = path.Base(strings.TrimSuffix(prefix, "/"))
}
if name == "" {
base := path.Base(filepath.ToSlash(hdr.Filename))
name = strings.TrimSuffix(base, path.Ext(base))
}
if !validSkillName(name) {
writeErr(w, 400, errMgmtSkillNamePre+name+errMgmtSkillNamePost)
return
}
skillPath := filepath.Join(s.skillDir, name)
overwrite := r.URL.Query().Get("overwrite") == "true"
if _, err := os.Stat(skillPath); err == nil && !overwrite {
writeErr(w, 409, errMgmtSkillExistsPre+name+errMgmtSkillExistsPost)
return
}
// extract into a temp dir first, then atomically swap in — a bad entry aborts
// the whole upload without leaving a half-written skill.
_ = os.MkdirAll(s.skillDir, 0o755)
tmp, err := os.MkdirTemp(s.skillDir, ".upload-*")
if err != nil {
writeErr(w, 500, err.Error())
return
}
defer os.RemoveAll(tmp) // no-op after a successful rename
var total int64
entries := 0
for _, e := range entriesAll {
f := e.f
// only files under the skill root; skip anything outside it.
if prefix != "" && !strings.HasPrefix(e.name, prefix) {
continue
}
rel := strings.TrimPrefix(e.name, prefix)
if rel == "" {
continue
}
clean, msg := skillRelPath(rel)
if msg != "" {
writeErr(w, 400, errMgmtSkillZipBadPath+e.name+": "+msg)
return
}
if entries++; entries > maxSkillEntries {
writeErr(w, 400, errMgmtSkillZipTooMany)
return
}
if f.UncompressedSize64 > maxSkillFileBytes {
writeErr(w, 400, errMgmtSkillFileTooLarge+rel)
return
}
dst := filepath.Join(tmp, clean)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
writeErr(w, 500, err.Error())
return
}
rc, err := f.Open()
if err != nil {
writeErr(w, 500, err.Error())
return
}
out, err := os.Create(dst)
if err != nil {
rc.Close()
writeErr(w, 500, err.Error())
return
}
n, err := io.Copy(out, io.LimitReader(rc, maxSkillFileBytes+1))
out.Close()
rc.Close()
if err != nil {
writeErr(w, 500, err.Error())
return
}
total += n
if total > maxSkillTotalBytes {
writeErr(w, 400, errMgmtSkillZipTooLarge)
return
}
}
if _, err := os.Stat(filepath.Join(tmp, "SKILL.md")); err != nil {
writeErr(w, 400, errMgmtSkillNoMDAfter)
return
}
if overwrite {
_ = os.RemoveAll(skillPath)
}
if err := os.Rename(tmp, skillPath); err != nil {
writeErr(w, 500, errMgmtSkillInstallFail+err.Error())
return
}
writeJSON(w, 201, map[string]any{"name": name, "files": entries})
}
// readZipEntry reads a single entry's bytes (capped). Takes the *zip.File rather than
// a name because zr.Open rejects names that aren't valid UTF-8 (GBK-named archives).
func readZipEntry(f *zip.File) ([]byte, error) {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(io.LimitReader(rc, maxSkillFileBytes))
}
func (s *Server) fsDeleteSkill(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
name := r.PathValue("name")
if !validSkillName(name) {
writeErr(w, 400, "invalid skill name")
return
}
if err := pg.DeleteSkillVisibility(name); err != nil {
writeErr(w, 500, err.Error())
return
}
if err := os.RemoveAll(filepath.Join(s.skillDir, name)); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"deleted": name})
}
// skillPathBlocked lists the ASCII characters a skill-relative path may not contain.
// '\' is a Windows separator; '%' keeps double-URL-encoding tricks from surviving
// (Go's net/http URL-decodes PathValue once — %2F→/ %2e→. — so an attacker sending
// %252e%252e arrives here as the literal "%2e%2e" and is rejected on the '%'); '#'
// and '?' would truncate the path when it travels back through a URL; the rest are
// reserved on Windows filesystems.
const skillPathBlocked = `\%#?*:"<>|`
// skillPathRune reports whether r may appear in a client-supplied skill path.
// It is a blacklist over Unicode rather than an ASCII whitelist so that 中文 (and any
// other script) file names work, while everything that makes path validation hard is
// still refused: control/format characters, look-alike whitespace, separators.
func skillPathRune(r rune) bool {
switch {
case r < 0x20, r == 0x7f, r == utf8.RuneError:
return false // NUL & control characters, invalid UTF-8
case strings.ContainsRune(skillPathBlocked, r):
return false
case unicode.Is(unicode.Cf, r), unicode.Is(unicode.Co, r), unicode.Is(unicode.Cs, r):
return false // zero-width joiners, bidi overrides (RLO 文件名伪装), private use
case r != ' ' && unicode.IsSpace(r):
return false // NBSP / 全角空格 之类:看着是空格,其实不是
}
return true
}
// maxSkillPathLen caps a relative path so a pathological name can't reach the syscall.
const maxSkillPathLen = 512
// skillRelPath validates a relative file path supplied by the client.
// Returns the cleaned path and an empty error string on success.
// Validation order matters: character check first (before Clean) so encoding tricks
// can't survive normalization.
func skillRelPath(file string) (string, string) {
// 1. Character check — before normalization. Defeats null bytes, backslash,
// '%', control characters and unicode look-alikes; CJK names pass through.
if file == "" || len(file) > maxSkillPathLen {
return "", "invalid path: empty or too long"
}
if !utf8.ValidString(file) {
return "", "invalid path: not valid UTF-8"
}
for _, r := range file {
if !skillPathRune(r) {
return "", "invalid path: illegal character " + strconv.QuoteRune(r)
}
}
// 2. Reject ".." explicitly. With the whitelist above, encoding bypass is already
// impossible, but we keep this check so the intent is obvious to reviewers.
if strings.Contains(file, "..") {
return "", "invalid path: '..' not allowed"
}
// 3. Reject leading slash and empty segments (double slash).
// Leading slash would survive filepath.Clean as an absolute path.
if strings.HasPrefix(file, "/") || strings.Contains(file, "//") {
return "", "invalid path: must be relative with no empty segments"
}
// 4. Normalize and final safety re-check after Clean.
// filepath.Clean removes redundant separators and resolves single dots.
clean := filepath.Clean(file)
if clean == "." || filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") {
return "", "invalid path"
}
return clean, ""
}
// walkSkillFiles returns all files under root (relative to root), sorted,
// including files in subdirectories (scripts/, references/, assets/, etc.).
// walkSkillFiles returns all entries under root relative to root.
// Directories are included with a trailing "/" so the frontend can distinguish
// them from files and render empty folders in the tree.
func walkSkillFiles(root string) ([]string, error) {
var entries []string
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil // skip unreadable entries
}
rel, relErr := filepath.Rel(root, path)
if relErr != nil || rel == "." {
return nil
}
if d.IsDir() {
entries = append(entries, rel+"/")
} else {
entries = append(entries, rel)
}
return nil
})
return entries, err
}
func (s *Server) fsListFiles(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if !validSkillName(name) {
writeErr(w, 400, "invalid skill name")
return
}
dirPath := filepath.Join(s.skillDir, name)
if _, err := os.Stat(dirPath); os.IsNotExist(err) {
writeErr(w, 404, "skill not found")
return
}
files, err := walkSkillFiles(dirPath)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if files == nil {
files = []string{}
}
writeJSON(w, 200, map[string]any{"files": files})
}
func (s *Server) fsReadFile(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if !validSkillName(name) {
writeErr(w, 400, "invalid skill name")
return
}
file, errMsg := skillRelPath(r.PathValue("file"))
if errMsg != "" {
writeErr(w, 400, errMsg)
return
}
data, err := os.ReadFile(filepath.Join(s.skillDir, name, file))
if os.IsNotExist(err) {
writeErr(w, 404, "file not found")
return
}
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"content": string(data), "file": file})
}
func (s *Server) fsWriteFile(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if !validSkillName(name) {
writeErr(w, 400, "invalid skill name")
return
}
file, errMsg := skillRelPath(r.PathValue("file"))
if errMsg != "" {
writeErr(w, 400, errMsg)
return
}
var body struct {
Content string `json:"content"`
}
if err := decode(r, &body); err != nil {
writeErr(w, 400, err.Error())
return
}
skillPath := filepath.Join(s.skillDir, name)
if _, err := os.Stat(skillPath); os.IsNotExist(err) {
writeErr(w, 404, "skill not found")
return
}
fullPath := filepath.Join(skillPath, file)
// create parent subdirectory if needed (e.g. scripts/, references/, assets/)
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
writeErr(w, 500, err.Error())
return
}
if err := os.WriteFile(fullPath, []byte(body.Content), 0o644); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
func (s *Server) fsCreateDir(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if !validSkillName(name) {
writeErr(w, 400, "invalid skill name")
return
}
var body struct {
Path string `json:"path"`
}
if err := decode(r, &body); err != nil {
writeErr(w, 400, err.Error())
return
}
dir, errMsg := skillRelPath(body.Path)
if errMsg != "" {
writeErr(w, 400, errMsg)
return
}
skillPath := filepath.Join(s.skillDir, name)
if _, err := os.Stat(skillPath); os.IsNotExist(err) {
writeErr(w, 404, "skill not found")
return
}
if err := os.MkdirAll(filepath.Join(skillPath, dir), 0o755); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 201, map[string]any{"dir": dir})
}
func (s *Server) fsDeletePath(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if !validSkillName(name) {
writeErr(w, 400, "invalid skill name")
return
}
file, errMsg := skillRelPath(r.PathValue("file"))
if errMsg != "" {
writeErr(w, 400, errMsg)
return
}
fullPath := filepath.Join(s.skillDir, name, file)
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
writeErr(w, 404, "not found")
return
}
if err := os.RemoveAll(fullPath); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"deleted": file})
}
// ---------- skill visibility ----------
func (s *Server) pgSkillVisibility(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
agents, err := pg.SkillAgents(r.PathValue("name"))
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"agents": idStrings(agents)})
}
func (s *Server) pgToggleSkillVisibility(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
var body struct {
AgentID int64 `json:"agent_id,string"`
SkillName string `json:"skill_name"`
Visible bool `json:"visible"`
}
if err := decode(r, &body); err != nil {
writeErr(w, 400, err.Error())
return
}
if err := pg.ToggleSkillVisibility(body.AgentID, body.SkillName, body.Visible); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
// ---------- visibility (MCP resource side + toggle) ----------
func (s *Server) pgResourceVisibility(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
id, _ := pathInt(r, "id")
agents, err := pg.ResourceAgents(r.PathValue("kind"), id)
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"agents": idStrings(agents)})
}
func (s *Server) pgToggleVisibility(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
var body struct {
AgentID int64 `json:"agent_id,string"`
Kind string `json:"kind"`
ResourceID int64 `json:"resource_id"`
Visible bool `json:"visible"`
}
if err := decode(r, &body); err != nil {
writeErr(w, 400, err.Error())
return
}
if err := pg.ToggleVisibility(body.AgentID, body.Kind, body.ResourceID, body.Visible); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
// ---------- llm profiles ----------
func (s *Server) pgListProfiles(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
ps, err := pg.ListProfiles()
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"profiles": llmProfileDTOs(ps)})
}
func (s *Server) pgSaveProfile(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
// APIKey has json:"-" on db.LLMProfile so it is never leaked to the UI on
// read; accept it here via a sibling field for create/update. Streaming is a
// *bool shadowing the embedded json:"streaming": an absent field must default
// to streaming (true), which a plain bool zero value (false = non-streaming)
// would get wrong — legacy/partial clients that never send it stay streaming.
var body struct {
db.LLMProfile
APIKey string `json:"api_key"`
Streaming *bool `json:"streaming"`
}
if err := decode(r, &body); err != nil {
writeErr(w, 400, err.Error())
return
}
p := body.LLMProfile
p.APIKey = body.APIKey
p.Streaming = body.Streaming == nil || *body.Streaming
// 输出上限:负数无意义,归零(= 不发送该字段)。字段名开关只有 Chat Completions
// 用得上——anthropic 与 openai-responses 各自定死了字段名,存下来只会误导后续读者,
// 故非 openai 格式一律清空。未知取值同样清空,避免把 DB CHECK 的报错甩给用户。
if p.MaxTokens < 0 {
p.MaxTokens = 0
}
if p.Format != "openai" || p.MaxTokensField != llm.MaxTokensFieldCompletion {
p.MaxTokensField = ""
}
id, err := pg.SaveProfile(&p)
if err != nil {
writeErr(w, 500, err.Error())
return
}
// Editing a profile rebuilds any task pinned to it on its next round. Reapply
// the active profile too: the global fallback and explicit task chains must
// repopulate the same provider-cache entry and therefore share one limiter.
s.invalidateProfileAgents()
// Hot-apply. Not just when the ACTIVE profile changed: with failover on, any
// profile's key/model/priority/pool_exclude edit reshapes the chain, so the
// engine's provider has to be rebuilt either way.
s.reapplyActiveProfile()
writeJSON(w, 200, map[string]any{"id": id})
}
// pgGetLLMRetryPolicy 返回全局重试策略(五层各自的次数+间隔)。未配置过 → 全零,
// 前端把零显示成「默认」。
func (s *Server) pgGetLLMRetryPolicy(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
writeJSON(w, 200, pg.LLMRetryPolicy())
}
// pgSaveLLMRetryPolicy 保存全局重试策略。三个「跟着端点走」的层(建连/空响应/同
// provider 安全窗口)是 provider 的构建参数或调用参数,改完必须让缓存里的 provider
// 重建;熔断参数则直接推给进程级 Registry。
func (s *Server) pgSaveLLMRetryPolicy(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
var pol db.LLMRetryPolicy
if err := decode(r, &pol); err != nil {
writeErr(w, 400, err.Error())
return
}
if err := pg.SetLLMRetryPolicy(pol); err != nil {
writeErr(w, 500, err.Error())
return
}
s.applyRetryPolicy()
s.invalidateProfileAgents()
s.reapplyActiveProfile()
writeJSON(w, 200, pg.LLMRetryPolicy())
}
func (s *Server) pgDeleteProfile(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
id, _ := pathInt(r, "id")
if err := pg.DeleteProfileContext(r.Context(), id); err != nil {
switch {
case errors.Is(err, db.ErrActiveLLMProfileDelete):
writeErr(w, 409, errMgmtLLMActiveDelete)
case errors.Is(err, db.ErrLLMProfileReferencesChanged):
writeErr(w, 409, errMgmtLLMRefChanged)
case errors.Is(err, context.DeadlineExceeded):
writeErr(w, 409, errMgmtLLMRefTimeout)
case errors.Is(err, db.ErrLLMProfileNotFound):
writeErr(w, 404, err.Error())
default:
writeErr(w, 500, err.Error())
}
return
}
s.invalidateProfileAgents() // drop cached agents for the removed profile
s.llmHealth.Reset(id) // its breaker state is meaningless now (row is FK-cascaded away)
s.restoreTasksAfterProfileDelete(pg)
// Cache invalidation also removed the active profile's shared provider entry.
// Reapply after task-state sync so the wake-up observes the post-delete chain.
s.reapplyActiveProfile()
writeJSON(w, 200, map[string]any{"deleted": id})
}
func (s *Server) restoreTasksAfterProfileDelete(pg *db.DB) {
for _, task := range s.m.List() {
if taskID, err := strconv.ParseInt(task.ID, 10, 64); err == nil {
if pt, err := pg.GetTask(taskID); err == nil && pt != nil {
s.syncTaskLLMState(pt)
// Deleting the active entry may advance the task to a ready successor,
// or clear the explicit chain and restore its Agent/global fallback.
// Resume only intents that were blocked by the exhausted chain; a
// paused task remains paused and terminal tasks remain immutable.
if !isTerminalStatus(task.lifecycleSnapshot().Status) && s.taskRuntimeAvailable(task, "planner", "worker") {
if _, reopenErr := task.Store.ReopenIntentsByBlockedReason(db.IntentBlockedLLMQuota); reopenErr != nil {
log.Printf("[llm-profile] task %s reopen quota-blocked intents after profile delete: %v", task.ID, reopenErr)
}
}
task.Notify()
}
}
}
}
func (s *Server) pgActivateProfile(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
var body struct {
ID int64 `json:"id"`
}
if err := decode(r, &body); err != nil {
writeErr(w, 400, err.Error())
return
}
if err := pg.SetActiveProfile(body.ID); err != nil {
writeErr(w, 500, err.Error())
return
}
s.invalidateProfileAgents() // active change may affect pinned-task fallbacks
s.reapplyActiveProfile() // switch the running engine to the newly activated profile
writeJSON(w, 200, map[string]any{"ok": true})
}
// pgLLMPoolStatus reports the failover ("轮询") switches, the resolved chain order
// and every profile's circuit-breaker state — what the LLM page renders as the
// "轮询顺序" strip and the per-card health badges.
func (s *Server) pgLLMPoolStatus(w http.ResponseWriter, r *http.Request) {
if s.pg(w) == nil {
return
}
writeJSON(w, 200, s.llmPoolStatus())
}
// pgLLMPoolReset clears a tripped profile's circuit breaker so the next call
// tries it again immediately ("立即恢复"). id=0 clears every profile.
func (s *Server) pgLLMPoolReset(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
var body struct {
ID int64 `json:"id"` // 0 / omitted = all
}
if err := decode(r, &body); err != nil {
writeErr(w, 400, err.Error())
return
}
if body.ID > 0 {
s.llmHealth.Reset(body.ID)
} else {
for id := range s.llmHealth.Snapshot() {
s.llmHealth.Reset(id)
}
}
writeJSON(w, 200, s.llmPoolStatus())
}
// pgListModels fetches available models from the provider's API endpoint.
// Supports OpenAI-format (GET /models) and Anthropic-format (GET /v1/models); for
// Anthropic-compatible third parties (e.g. DeepSeek) whose model list lives only on
// the OpenAI path, it falls back to the OpenAI endpoint at the stripped root.
func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) {
var req struct {
Provider string `json:"provider"` // "openai" | "anthropic"
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Proxy string `json:"proxy"`
ProfileID *int64 `json:"profile_id"` // fallback: use stored key from this profile
}
if err := decode(r, &req); err != nil {
writeErr(w, 400, err.Error())
return
}
// Resolve API key: form input > profile stored key.
apiKey := strings.TrimSpace(req.APIKey)
if apiKey == "" && req.ProfileID != nil {
if p, err := s.m.pg.ProfileByID(*req.ProfileID); err == nil && p != nil {
apiKey = p.APIKey
}
}
if apiKey == "" {
writeJSON(w, 200, map[string]any{"ok": false, "error": errMgmtNoAPIKey})
return
}
baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/")
provider := strings.TrimSpace(req.Provider)
// Candidate endpoints to try in order. Some Anthropic-compatible providers
// (e.g. DeepSeek) implement /v1/messages under an /anthropic path but expose
// the model list only on their OpenAI-format path — so for anthropic we fall
// back to the OpenAI endpoint at the stripped root.
type candidate struct {
url string
hdr http.Header
}
bearerHdr := func() http.Header {
h := http.Header{}
h.Set("Authorization", "Bearer "+apiKey)
return h
}
anthropicHdr := func() http.Header {
h := http.Header{}
h.Set("x-api-key", apiKey)
h.Set("anthropic-version", "2023-06-01")
return h
}
var candidates []candidate
switch provider {
case "openai", "openai-responses":
// Responses API shares the OpenAI model list at /v1/models; tolerate a full
// endpoint URL of either format.
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
b := strings.TrimRight(strings.TrimSuffix(strings.TrimSuffix(baseURL, "/chat/completions"), "/responses"), "/")
candidates = append(candidates, candidate{b + "/models", bearerHdr()})
default: // anthropic
if baseURL == "" {
baseURL = "https://api.anthropic.com"
}
b := strings.TrimRight(strings.TrimSuffix(baseURL, "/v1/messages"), "/")
candidates = append(candidates, candidate{b + "/v1/models", anthropicHdr()})
// Fallback for Anthropic-compatible third parties whose model list lives on
// the OpenAI path: strip a trailing /anthropic and try the OpenAI endpoint.
if root := strings.TrimRight(strings.TrimSuffix(b, "/anthropic"), "/"); root != b {
candidates = append(candidates,
candidate{root + "/models", bearerHdr()},
candidate{root + "/v1/models", bearerHdr()},
)
}
}
// Build HTTP client with optional proxy.
transport := &http.Transport{}
if p := strings.TrimSpace(req.Proxy); p != "" {
if pu, err := url.Parse(p); err == nil {
transport.Proxy = http.ProxyURL(pu)
}
}
client := &http.Client{Transport: transport, Timeout: 30 * time.Second}
// Try each candidate; return the first that yields a non-empty model list.
var lastErr string
emptyOK := false
for _, c := range candidates {
httpReq, err := http.NewRequestWithContext(r.Context(), http.MethodGet, c.url, nil)
if err != nil {
lastErr = errMgmtBuildReqFail + err.Error()
continue
}
httpReq.Header = c.hdr
resp, err := client.Do(httpReq)
if err != nil {
lastErr = errMgmtReqFail + err.Error()
continue
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
lastErr = fmt.Sprintf(errMgmtAPIReturned, resp.StatusCode, string(body[:min(len(body), 512)]))
continue
}
// Both OpenAI and Anthropic return {"data": [{"id": "..."},...]}.
var parsed struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
lastErr = errMgmtParseRespFail + err.Error()
continue
}
models := make([]string, 0, len(parsed.Data))
for _, m := range parsed.Data {
if m.ID != "" {
models = append(models, m.ID)
}
}
if len(models) > 0 {
writeJSON(w, 200, map[string]any{"ok": true, "models": models})
return
}
emptyOK = true // 200 but no model ids — keep trying other candidates
}
if emptyOK {
writeJSON(w, 200, map[string]any{"ok": true, "models": []string{}})
return
}
if lastErr == "" {
lastErr = errMgmtNoModelList
}
writeJSON(w, 200, map[string]any{"ok": false, "error": lastErr})
}
// --- prompt template helpers (Go text/template + catalog 白名单) ---
// globalPromptVars are runtime variables available to EVERY agent (built-in and
// custom) regardless of its per-agent catalog. Each agent's render path fills them
// (see agent.nowStr, rendered fresh each turn), so a prompt may always reference
// {{.Now}} — e.g. subtract it from a fixed start stamp to reason about elapsed time.
var globalPromptVars = []db.PromptVar{
{Name: "Now", Description: "服务器当前时间(每次执行都会实时刷新,减去固定的起始时间即可计算经过的时长)", Example: "2026-08-11 14:30:00 CST", Source: "runtime"},
{Name: "DataDir", Description: "服务器数据根目录(所有任务/对话产物的根目录,各 Agent 实际写入其下的子目录。例如: <DataDir>/<taskID>)", Example: "/app/data", Source: "runtime"},
}
// withGlobalVars appends the universal runtime vars onto an agent's own catalog,
// so validation / the UI variable list / preview all recognize {{.Now}} etc.
// A stored catalog entry that collides with a global name is dropped: the global
// runtime var is authoritative (it's what the render path actually resolves), and
// this keeps the returned list name-unique so the UI never sees duplicate keys.
func withGlobalVars(vars []db.PromptVar) []db.PromptVar {
globalNames := make(map[string]bool, len(globalPromptVars))
for _, g := range globalPromptVars {
globalNames[g.Name] = true
}
out := make([]db.PromptVar, 0, len(vars)+len(globalPromptVars))
for _, v := range vars {
if globalNames[v.Name] {
continue // shadowed by the authoritative global runtime var
}
out = append(out, v)
}
return append(out, globalPromptVars...)
}
// validateTemplate parses the template and rejects any {{.Var}} not in the catalog.
// Returns "" if valid, otherwise an error message.
func validateTemplate(tmpl string, catalog []db.PromptVar) string {
t, err := template.New("p").Option("missingkey=error").Parse(tmpl)
if err != nil {
return errMgmtTmplSyntax + err.Error()
}
allowed := map[string]bool{}
for _, v := range catalog {
allowed[v.Name] = true
}
for _, name := range templateFields(t) {
if !allowed[name] {
return errMgmtTmplVarPre + name + errMgmtTmplVarPost
}
}
return ""
}
// renderPrompt renders with example values (catalog example overridden by sample).
func renderPrompt(tmpl string, catalog []db.PromptVar, sample map[string]string) (string, error) {
t, err := template.New("p").Option("missingkey=error").Parse(tmpl)
if err != nil {
return "", err
}
data := map[string]any{}
for _, v := range catalog {
data[v.Name] = v.Example
}
for k, val := range sample {
data[k] = val
}
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
// templateFields returns the distinct top-level {{.X}} field names referenced by
// the template (used to validate against the catalog whitelist).
func templateFields(t *template.Template) []string {
seen := map[string]bool{}
var out []string
collect := func(p *parse.PipeNode) {
if p == nil {
return
}
for _, cmd := range p.Cmds {
for _, arg := range cmd.Args {
if f, ok := arg.(*parse.FieldNode); ok && len(f.Ident) > 0 && !seen[f.Ident[0]] {
seen[f.Ident[0]] = true
out = append(out, f.Ident[0])
}
}
}
}
var walk func(n parse.Node)
walk = func(n parse.Node) {
switch x := n.(type) {
case *parse.ListNode:
if x == nil {
return
}
for _, c := range x.Nodes {
walk(c)
}
case *parse.ActionNode:
collect(x.Pipe)
case *parse.IfNode:
collect(x.Pipe)
walk(x.List)
walk(x.ElseList)
case *parse.RangeNode:
collect(x.Pipe)
walk(x.List)
walk(x.ElseList)
case *parse.WithNode:
collect(x.Pipe)
walk(x.List)
walk(x.ElseList)
}
}
if t.Tree != nil {
walk(t.Tree.Root)
}
return out
}