Files
dela 0335d572de
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
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
First Commit
2026-10-09 08:38:16 +08:00

2299 lines
71 KiB
Go

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 = "실행 중인 에이전트가 남아 있어 작업 삭제를 취소했습니다"
// 에이전트 CRUD
errMgmtAgentKeyFormat = "key 는 소문자로 시작하고, 소문자·숫자·밑줄만 사용할 수 있습니다"
errMgmtNameEmpty = "이름은 비워 둘 수 없습니다"
errMgmtAgentKeyExists = "이미 존재하는 key 입니다"
errMgmtBuiltinNoEditMeta = "내장 에이전트는 이름/설명을 수정할 수 없습니다"
errMgmtBuiltinNoDelete = "내장 에이전트는 삭제할 수 없습니다"
errMgmtLLMProfileIDFormat = "llm_profile_id 형식이 올바르지 않습니다"
errMgmtLLMProfileInvalid = "지정한 LLM 설정이 존재하지 않거나 유효하지 않습니다"
errMgmtNoBuiltinPrompt = "이 에이전트에는 내장 기본 프롬프트가 없어 복원할 수 없습니다"
// 도구 (뒤에 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 = "}} 는 이 에이전트의 허용 목록에 없습니다"
)
// 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: "서버 데이터 루트 디렉터리(모든 작업/대화 산출물의 루트이며, 각 에이전트는 그 아래 하위 디렉터리에 실제로 기록합니다. 예: <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
}