Files
ARTEX/server/server_mgmt.go
T
Autumn-27 a50bf02428 feat(llm): 五层重试的次数与间隔可配
一次模型调用的失败由内到外经过五层重试:建连(SDK)、空响应(SDK)、同 provider
安全窗口、轮询熔断、意图重跑。此前五层的次数与间隔全是写死的常量,遇到脾气各异
的中转站只能改代码。现在都能在 LLM 页配置,不配就是原来的默认值。

配置语义五层统一(db.RetryRule):次数 0=内置默认 / -1=关掉这层 / >0=该值;
间隔 0=原本的指数退避 / >0=改用固定间隔。0 被"未配置"占用,故关闭用 -1。
写库前夹紧到 次数 [-1,20]、间隔 [0,1h],DB 另有 CHECK 兜底。

粒度按层的性质分:前三层跟着端点走,可在每个模型配置里逐字段覆盖全局默认(只钉
间隔的仍继承全局次数);熔断与意图重跑是进程级语义,只有全局一份。

生效路径:①②是 provider 构建参数,保存后重建缓存;③随每次 hooks.current()
解析,故障转移换配置时重试节奏跟着换;④在失败路径上每次都要读,由 applyRetryPolicy
推给 llmpool.Registry;⑤每条意图收尾读一次。全部热生效,无需重启。

连接测试刻意不带这些参数——它有 30s 硬超时,叠上用户配的重试只会把本来能用的
端点测成超时失败。

norma 升 v0.3.5(llm.Config 新增 RetryInterval / EmptyResponseRetries /
EmptyResponseInterval)。旧库升级后行为逐字节不变:新列默认 0,settings 键不
存在即全默认。设计见 docs/LLM重试设计.md。
2026-09-09 13:35:04 -04:00

2229 lines
67 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_]*$`)
// 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, "管理后台数据源(PostgreSQL)未连接")
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, "任务 id 无效")
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, "任务正在删除")
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, "任务仍有运行中的 Agent,删除已取消")
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, "key 需小写字母开头,仅含小写字母/数字/下划线")
return
}
if req.Name == "" {
writeErr(w, 400, "名称不能为空")
return
}
if exist, _ := pg.GetAgentByKey(req.Key); exist != nil {
writeErr(w, 409, "该 key 已存在")
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, "内置 agent 不可修改名称/描述")
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, "名称不能为空")
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, "内置 agent 不可删除")
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, "llm_profile_id 格式错误")
return
}
if id != nil { // 绑定:校验目标 profile 有效
if _, ok := s.loadProfileConfig(*id); !ok {
writeErr(w, 400, "指定的 LLM 配置不存在或无效")
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, "该 agent 无内置默认提示词,无法恢复")
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, "工具不存在: "+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, "非内置工具或不存在: "+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, "MCP 不存在")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
defer cancel()
if err := s.discoverAndCacheMCP(ctx, target); err != nil {
writeErr(w, 502, "工具发现失败:"+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, "非法 skill 名")
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, "缺少上传文件(表单字段 file)或超出大小限制")
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, "压缩包内未找到 SKILL.md")
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, "读取 SKILL.md 失败:"+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, "skill 名称无效(取自 SKILL.md 的 name 字段):"+name+
"(≤64 字符,字母开头,只能用小写字母/数字/连字符或中文等非 ASCII 字母,不能有空格、点、路径分隔符)")
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, "skill 已存在:"+name+"(如需覆盖请确认后重试)")
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, "压缩包含非法路径 "+e.name+":"+msg)
return
}
if entries++; entries > maxSkillEntries {
writeErr(w, 400, "压缩包文件过多")
return
}
if f.UncompressedSize64 > maxSkillFileBytes {
writeErr(w, 400, "文件过大:"+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, "压缩包解压后过大")
return
}
}
if _, err := os.Stat(filepath.Join(tmp, "SKILL.md")); err != nil {
writeErr(w, 400, "解压后缺少 SKILL.md")
return
}
if overwrite {
_ = os.RemoveAll(skillPath)
}
if err := os.Rename(tmp, skillPath); err != nil {
writeErr(w, 500, "安装失败:"+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, "当前激活的 LLM 配置不能删除,请先激活其他配置")
case errors.Is(err, db.ErrLLMProfileReferencesChanged):
writeErr(w, 409, "LLM 配置正在被任务或会话修改,请重试")
case errors.Is(err, context.DeadlineExceeded):
writeErr(w, 409, "等待 LLM 配置引用释放超时,请重试")
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": "未提供 API Key"})
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 = "构建请求失败: " + err.Error()
continue
}
httpReq.Header = c.hdr
resp, err := client.Do(httpReq)
if err != nil {
lastErr = "请求失败: " + err.Error()
continue
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
lastErr = fmt.Sprintf("API 返回 %d: %s", 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 = "解析响应失败: " + 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 = "未获取到模型列表"
}
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 "模板语法错误: " + err.Error()
}
allowed := map[string]bool{}
for _, v := range catalog {
allowed[v.Name] = true
}
for _, name := range templateFields(t) {
if !allowed[name] {
return "变量 {{." + name + "}} 不在该 agent 允许列表"
}
}
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
}