init
This commit is contained in:
1 parent
9793b522d9
commit
b7e0b93023
46 files changed
+3632
-899
No files matched your search
+5
-1
@@ -137,7 +137,11 @@ func wireAgentAugment(pg *db.DB, skillDir string, hostTools func() ([]actool.Cor
|
||||
}
|
||||
return actool.RenderDeferredToolsBlock(reveal)
|
||||
}
|
||||
extra = append(extra, reg.Tool())
|
||||
// Attribution for the usage ledger: ToolAugment only gets (ctx, agentKey),
|
||||
// so the run's task/session ids ride in on the ctx (agent.RunInfo). Read it
|
||||
// once here — this closure is rebuilt per run, so the captured value always
|
||||
// belongs to this run.
|
||||
extra = append(extra, meterSkillTool(reg.Tool(), pg, reg, a.Key, agent.RunInfoFrom(ctx)))
|
||||
}
|
||||
|
||||
// host tools (traffic / orchestration / custom) — added to every agent's base;
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// pgListCommands returns tool executions (any tool) from the activity table.
|
||||
@@ -45,6 +47,53 @@ func (s *Server) pgListLLMRecords(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"records": records, "total": total})
|
||||
}
|
||||
|
||||
// pgTokenByModel returns a task's LLM token usage grouped by model, from the
|
||||
// always-on llm_usage metering ledger. Accurate even with per-agent model bindings,
|
||||
// pool rotation/failover, and interrupted runs (every call is metered on success or
|
||||
// error). No recording toggle required.
|
||||
func (s *Server) pgTokenByModel(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(r.URL.Query().Get("task"))
|
||||
if id == "" {
|
||||
writeErr(w, 400, "missing task")
|
||||
return
|
||||
}
|
||||
pg := s.m.PG()
|
||||
if pg == nil {
|
||||
writeJSON(w, 200, map[string]any{"models": []db.ModelTokenStat{}})
|
||||
return
|
||||
}
|
||||
models, err := pg.TokenByModel(id)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"models": models})
|
||||
}
|
||||
|
||||
// pgUsageStats returns global llm_usage aggregates for the dashboard's "new" token
|
||||
// view: per-profile totals (all-time) + per-(profile, day) buckets for the daily
|
||||
// chart. Sourced from the always-on metering ledger, so it is accurate across
|
||||
// per-agent bindings, pool rotation, and interrupted runs.
|
||||
func (s *Server) pgUsageStats(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.m.PG()
|
||||
if pg == nil {
|
||||
writeJSON(w, 200, map[string]any{"by_profile": []db.ProfileUsage{}, "daily": []db.ProfileDayUsage{}})
|
||||
return
|
||||
}
|
||||
days := atoiDefault(r.URL.Query().Get("days"), 365)
|
||||
byProfile, err := pg.UsageByProfile()
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
daily, err := pg.UsageDaily(days)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"by_profile": byProfile, "daily": daily})
|
||||
}
|
||||
|
||||
// pgLLMTasks returns distinct recorded tasks with counts, for the page's task
|
||||
// picker (pick a task → filter the list, then delete its conversations).
|
||||
func (s *Server) pgLLMTasks(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -500,6 +500,7 @@ type LLMProfileDTO struct {
|
||||
RatePerSecond float64 `json:"rate_per_second"`
|
||||
RatePerMinute float64 `json:"rate_per_minute"`
|
||||
ContextWindowK int `json:"context_window_k"`
|
||||
ThinkingType string `json:"thinking_type"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
// 轮询(故障转移)参数:priority 越大越先被选中(激活配置恒为链首);
|
||||
@@ -520,6 +521,7 @@ func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO {
|
||||
RatePerSecond: p.RatePerSecond,
|
||||
RatePerMinute: p.RatePerMinute,
|
||||
ContextWindowK: p.ContextWindowK,
|
||||
ThinkingType: p.ThinkingType,
|
||||
ReasoningEffort: p.ReasoningEffort,
|
||||
IsDefault: p.IsDefault,
|
||||
Priority: p.Priority,
|
||||
|
||||
+2
-1
@@ -926,7 +926,8 @@ func (e *Engine) runWorkerStep(ctx context.Context, t *Task, name string, worker
|
||||
return true
|
||||
}
|
||||
// if a pause cancelled this run mid-flight, return the intent to the frontier
|
||||
// so it is re-claimed (and re-run from scratch) on resume — not marked done.
|
||||
// so it is re-claimed on resume — the worker will resume the prior LLM
|
||||
// conversation from its transcript instead of restarting from scratch.
|
||||
if ectx.Err() != nil && e.IsPaused(t.ID) {
|
||||
_ = t.Store.SetIntentState(intent.ID, "open")
|
||||
return true
|
||||
|
||||
@@ -250,6 +250,9 @@ func NewManager(dir, proxyAddr string) (*Manager, error) {
|
||||
if err := pg.EnsureLLMRecordsTable(); err != nil {
|
||||
log.Printf("[llmrec] create table: %v", err)
|
||||
}
|
||||
if err := pg.EnsureLLMUsageTable(); err != nil {
|
||||
log.Printf("[llmusage] create table: %v", err)
|
||||
}
|
||||
m := &Manager{dir: dir, pg: pg, assets: pg.Assets(), tasks: map[string]*Task{}, interceptor: intercept.New(pg)}
|
||||
if proxyAddr != "" {
|
||||
tr, err := traffic.Open(filepath.Join(dir, "traffic"), proxyAddr)
|
||||
|
||||
+190
-13
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
@@ -274,14 +275,14 @@ func (s *Server) agentMaxTurns(key string) int {
|
||||
}
|
||||
|
||||
// agentRunSeconds returns the configured wall-clock run budget (seconds) for an
|
||||
// agent key (0 = unlimited; 600 fallback when no DB or no row, matching schema).
|
||||
// agent key (0 = unlimited; 1200 fallback when no DB or no row, matching schema).
|
||||
func (s *Server) agentRunSeconds(key string) int {
|
||||
if s.m.pg == nil {
|
||||
return 600
|
||||
return 1200
|
||||
}
|
||||
a, err := s.m.pg.GetAgentByKey(key)
|
||||
if err != nil || a == nil {
|
||||
return 600
|
||||
return 1200
|
||||
}
|
||||
return a.RunSecs
|
||||
}
|
||||
@@ -295,6 +296,7 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) {
|
||||
cfg := agent.ConfigFrom(p.Format, p.Model, p.BaseURL, p.APIKey, p.Proxy)
|
||||
cfg.RatePerSecond, cfg.RatePerMinute = p.RatePerSecond, p.RatePerMinute
|
||||
cfg.ContextWindowK = p.ContextWindowK
|
||||
cfg.ThinkingType = p.ThinkingType
|
||||
cfg.ReasoningEffort = p.ReasoningEffort
|
||||
if cfg.APIKey == "" {
|
||||
return cfg, false
|
||||
@@ -328,7 +330,7 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error {
|
||||
newID, err := s.m.pg.SaveProfile(&db.LLMProfile{
|
||||
ID: id, Name: "default", Format: format, Model: cfg.Model, BaseURL: cfg.BaseURL, Proxy: cfg.Proxy,
|
||||
APIKey: cfg.APIKey, RatePerSecond: cfg.RatePerSecond, RatePerMinute: cfg.RatePerMinute,
|
||||
ContextWindowK: cfg.ContextWindowK, ReasoningEffort: cfg.ReasoningEffort, IsDefault: true,
|
||||
ContextWindowK: cfg.ContextWindowK, ThinkingType: cfg.ThinkingType, ReasoningEffort: cfg.ReasoningEffort, IsDefault: true,
|
||||
Priority: priority, PoolExclude: poolExclude,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -408,7 +410,7 @@ func (s *Server) applyLLM(cfg agent.Config) error {
|
||||
s.cfgMu.Lock()
|
||||
profName := s.llmProf
|
||||
s.cfgMu.Unlock()
|
||||
prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, profName, s.m.LLMRecordEnabled)
|
||||
prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, profName, cfg.ThinkingType, cfg.ReasoningEffort, s.m.LLMRecordEnabled)
|
||||
}
|
||||
// LLM 轮询(默认关):把激活配置包进故障转移链,当前配置不可用时自动切下一个。
|
||||
// 只影响「走全局激活配置」的这条路径——agent 绑定 / 任务 pin 的走 providerForProfile,
|
||||
@@ -458,6 +460,7 @@ func (s *Server) loadProfileConfig(id int64) (agent.Config, bool) {
|
||||
cfg := agent.ConfigFrom(p.Format, p.Model, p.BaseURL, p.APIKey, p.Proxy)
|
||||
cfg.RatePerSecond, cfg.RatePerMinute = p.RatePerSecond, p.RatePerMinute
|
||||
cfg.ContextWindowK = p.ContextWindowK
|
||||
cfg.ThinkingType = p.ThinkingType
|
||||
cfg.ReasoningEffort = p.ReasoningEffort
|
||||
if cfg.APIKey == "" {
|
||||
return cfg, false
|
||||
@@ -501,7 +504,7 @@ func (s *Server) providerForProfile(id int64) (llm.Provider, agent.Config, bool)
|
||||
}
|
||||
// Wrap with recorder, tagged with this profile's name.
|
||||
if p, _ := s.m.pg.ProfileByID(id); p != nil {
|
||||
prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, p.Name, s.m.LLMRecordEnabled)
|
||||
prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, p.Name, cfg.ThinkingType, cfg.ReasoningEffort, s.m.LLMRecordEnabled)
|
||||
}
|
||||
s.provCacheMu.Lock()
|
||||
if generation != s.provCacheGen {
|
||||
@@ -647,6 +650,8 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/workspace/download", s.wsDownload)
|
||||
mux.HandleFunc("POST /api/workspace/upload", s.wsUpload)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/scope", s.taskScopeAdd)
|
||||
mux.HandleFunc("DELETE /api/tasks/{id}/scope/{sid}", s.taskScopeDelete)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/control", s.control)
|
||||
mux.HandleFunc("PUT /api/tasks/{id}/llm", s.updateTaskLLMProfiles)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/control", s.controlIntent)
|
||||
@@ -675,6 +680,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/exploration/frontier", s.frontier)
|
||||
mux.HandleFunc("GET /api/exploration/findings", s.findings)
|
||||
mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats)
|
||||
mux.HandleFunc("GET /api/exploration/findings/export", s.findingsExport)
|
||||
mux.HandleFunc("GET /api/exploration/findings/{id}", s.getFinding)
|
||||
mux.HandleFunc("GET /api/exploration/findings/{id}/lineage", s.findingLineage)
|
||||
mux.HandleFunc("PATCH /api/exploration/findings/{id}", s.patchFinding)
|
||||
@@ -688,6 +694,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/exploration/tokens", s.tokenStats)
|
||||
mux.HandleFunc("GET /api/tokens/daily", s.tokenDailyStats)
|
||||
mux.HandleFunc("GET /api/tokens/conversations", s.conversationTokens)
|
||||
mux.HandleFunc("GET /api/tokens/usage", s.pgUsageStats) // 全局 llm_usage 聚合(仪表盘新版视图)
|
||||
|
||||
mux.HandleFunc("GET /api/audit", s.getAudit)
|
||||
mux.HandleFunc("POST /api/gc", s.gc)
|
||||
@@ -700,6 +707,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/llm/records", s.pgListLLMRecords)
|
||||
mux.HandleFunc("DELETE /api/llm/records", s.pgDeleteLLMRecords)
|
||||
mux.HandleFunc("GET /api/llm/records/tasks", s.pgLLMTasks)
|
||||
mux.HandleFunc("GET /api/llm/records/by-model", s.pgTokenByModel) // 按模型聚合本任务 token 用量
|
||||
mux.HandleFunc("GET /api/llm/records/{id}", s.pgGetLLMRecord)
|
||||
mux.HandleFunc("GET /api/settings", s.getSettings)
|
||||
mux.HandleFunc("PUT /api/settings", s.putSettings)
|
||||
@@ -770,6 +778,8 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/skills", s.fsCreateSkill)
|
||||
mux.HandleFunc("POST /api/skills/upload", s.fsUploadSkill)
|
||||
mux.HandleFunc("DELETE /api/skills/{name}", s.fsDeleteSkill)
|
||||
mux.HandleFunc("GET /api/skills/missing", s.fsMissingSkills) // 未命中(想调但不存在)的 skill 名
|
||||
mux.HandleFunc("GET /api/skills/{name}/usage", s.fsSkillUsage) // 单个 skill 的最近调用
|
||||
mux.HandleFunc("PUT /api/skills/{name}/meta", s.fsUpdateSkillMeta)
|
||||
mux.HandleFunc("POST /api/skills/{name}/dirs", s.fsCreateDir)
|
||||
mux.HandleFunc("GET /api/skills/{name}/files", s.fsListFiles)
|
||||
@@ -994,7 +1004,9 @@ func (s *Server) control(w http.ResponseWriter, r *http.Request) {
|
||||
case "pause":
|
||||
s.engine.Pause(t.ID, agent.AbortPausedByUser)
|
||||
// Main Agent turns run outside Engine's planner/worker context so they need
|
||||
// their own cancellation. New turns are rejected while the task is paused.
|
||||
// their own cancellation: pausing terminates the CURRENTLY running turn. New
|
||||
// turns are still allowed while paused — the main-agent console is independent
|
||||
// of task pause (the chat handler no longer rejects on IsPaused).
|
||||
s.cancelTaskChat(t.ID, agent.AbortChatPausedWithTask)
|
||||
case "resume":
|
||||
s.engine.Run(s.ctx, t) // ensure loops are alive, then un-pause + nudge
|
||||
@@ -1167,6 +1179,7 @@ func (s *Server) getLLM(w http.ResponseWriter, r *http.Request) {
|
||||
"rate_per_second": s.llmCfg.RatePerSecond,
|
||||
"rate_per_minute": s.llmCfg.RatePerMinute,
|
||||
"context_window_k": s.llmCfg.ContextWindowK,
|
||||
"thinking_type": s.llmCfg.ThinkingType,
|
||||
"reasoning_effort": s.llmCfg.ReasoningEffort,
|
||||
})
|
||||
}
|
||||
@@ -1182,6 +1195,7 @@ func (s *Server) setLLM(w http.ResponseWriter, r *http.Request) {
|
||||
RatePerSecond float64 `json:"rate_per_second"`
|
||||
RatePerMinute float64 `json:"rate_per_minute"`
|
||||
ContextWindowK int `json:"context_window_k"`
|
||||
ThinkingType string `json:"thinking_type"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -1190,6 +1204,7 @@ func (s *Server) setLLM(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
cfg := agent.ConfigFrom(req.Provider, req.Model, req.BaseURL, req.APIKey, req.Proxy)
|
||||
cfg.RatePerSecond, cfg.RatePerMinute = req.RatePerSecond, req.RatePerMinute
|
||||
cfg.ThinkingType = req.ThinkingType
|
||||
cfg.ReasoningEffort = req.ReasoningEffort
|
||||
if k := req.ContextWindowK; k > 0 { // 0 = keep default (200K); cap at 1M
|
||||
if k > 1000 {
|
||||
@@ -1236,6 +1251,7 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
|
||||
BaseURL string `json:"base_url"`
|
||||
Proxy string `json:"proxy"`
|
||||
APIKey string `json:"api_key"`
|
||||
ThinkingType string `json:"thinking_type"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
ProfileID *int64 `json:"profile_id"` // 测已存 profile 时传入:api_key 为空则用它存的 key
|
||||
}
|
||||
@@ -1246,6 +1262,7 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := agent.ConfigFrom(req.Provider, req.Model, req.BaseURL, req.APIKey, req.Proxy)
|
||||
// mirror production: send the SAME thinking params so a provider that rejects the
|
||||
// reasoning_effort/thinking field fails the test too (no false "test ok, run 400").
|
||||
cfg.ThinkingType = req.ThinkingType
|
||||
cfg.ReasoningEffort = req.ReasoningEffort
|
||||
// API Key 解析优先级:表单输入 > 指定 profile 存的 key > 全局配置的 key。
|
||||
// 已存 profile 的 key 不回传浏览器,所以测试已存配置时表单为空,需从 DB 取。
|
||||
@@ -1634,6 +1651,64 @@ func (s *Server) taskScopeList(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"scope": rows})
|
||||
}
|
||||
|
||||
func (s *Server) taskScopeAdd(w http.ResponseWriter, r *http.Request) {
|
||||
t, ok := s.m.Task(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeErr(w, 404, "task not found")
|
||||
return
|
||||
}
|
||||
as := s.m.Assets()
|
||||
if as == nil {
|
||||
writeErr(w, 503, "asset store 未启用")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Kind string `json:"kind"`
|
||||
Value string `json:"value"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, 400, "invalid JSON")
|
||||
return
|
||||
}
|
||||
taskID, _ := strconv.ParseInt(t.ID, 10, 64)
|
||||
ts, err := as.AddAgentScope(taskID, body.Kind, body.Value, body.Reason, "manual")
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, ts)
|
||||
}
|
||||
|
||||
func (s *Server) taskScopeDelete(w http.ResponseWriter, r *http.Request) {
|
||||
t, ok := s.m.Task(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeErr(w, 404, "task not found")
|
||||
return
|
||||
}
|
||||
as := s.m.Assets()
|
||||
if as == nil {
|
||||
writeErr(w, 503, "asset store 未启用")
|
||||
return
|
||||
}
|
||||
taskID, _ := strconv.ParseInt(t.ID, 10, 64)
|
||||
scopeID, err := strconv.ParseInt(r.PathValue("sid"), 10, 64)
|
||||
if err != nil {
|
||||
writeErr(w, 400, "invalid scope id")
|
||||
return
|
||||
}
|
||||
deleted, err := as.DeleteTaskScope(taskID, scopeID)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if !deleted {
|
||||
writeErr(w, 404, "scope row not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) frontier(w http.ResponseWriter, r *http.Request) {
|
||||
t := s.m.ResolveTask(r.URL.Query().Get("task"))
|
||||
if t == nil {
|
||||
@@ -1833,6 +1908,110 @@ func (s *Server) getFinding(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, dto)
|
||||
}
|
||||
|
||||
// findingsExport 导出发现页的漏洞。
|
||||
//
|
||||
// scope = filtered(沿用页面筛选)| all(全部)| selected(勾选的 ids)
|
||||
// format = md-single(整合一份 .md)| md-zip(一漏洞一 .md,打包 zip)
|
||||
// | csv | json
|
||||
// ids = 逗号分隔的 finding id(scope=selected 时必填)
|
||||
// 筛选参数 severity/status/vulnclass/task_id/sort 与列表接口一致(scope=filtered 用)。
|
||||
func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
scope := q.Get("scope")
|
||||
format := q.Get("format")
|
||||
|
||||
var ids []int64
|
||||
var filter db.FindingFilter
|
||||
switch scope {
|
||||
case "selected":
|
||||
for _, part := range strings.Split(q.Get("ids"), ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
id, err := strconv.ParseInt(part, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
writeErr(w, 400, "bad finding id: "+part)
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
writeErr(w, 400, "no findings selected")
|
||||
return
|
||||
}
|
||||
case "all":
|
||||
// 空 filter = 不加任何条件。
|
||||
case "filtered", "":
|
||||
filter = db.FindingFilter{
|
||||
Severity: normFilter(q.Get("severity")),
|
||||
Status: normFilter(q.Get("status")),
|
||||
VulnClass: normFilter(q.Get("vulnclass")),
|
||||
TaskID: normFilter(q.Get("task_id")),
|
||||
Sort: q.Get("sort"),
|
||||
}
|
||||
default:
|
||||
writeErr(w, 400, "bad scope: "+scope)
|
||||
return
|
||||
}
|
||||
|
||||
fs, err := s.m.pg.ListFindingsForExport(filter, ids)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
stamp := now.Format("20060102-150405")
|
||||
setDownload := func(contentType, filename string) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "md-single":
|
||||
setDownload("text/markdown; charset=utf-8", "findings-"+stamp+".md")
|
||||
_, _ = w.Write([]byte(report.FindingsMarkdown(fs, now)))
|
||||
case "md-zip":
|
||||
setDownload("application/zip", "findings-"+stamp+".zip")
|
||||
zw := zip.NewWriter(w)
|
||||
used := map[string]int{}
|
||||
for _, f := range fs {
|
||||
base := report.FindingFilename(f)
|
||||
name := base
|
||||
// 去重:同名文件追加 -2、-3……
|
||||
if n := used[base]; n > 0 {
|
||||
name = fmt.Sprintf("%s-%d.md", strings.TrimSuffix(base, ".md"), n+1)
|
||||
}
|
||||
used[base]++
|
||||
fw, werr := zw.Create(name)
|
||||
if werr != nil {
|
||||
log.Printf("[findings-export] zip create %s: %v", name, werr)
|
||||
continue
|
||||
}
|
||||
_, _ = fw.Write([]byte(report.SingleFindingMarkdown(f, now)))
|
||||
}
|
||||
if cerr := zw.Close(); cerr != nil {
|
||||
log.Printf("[findings-export] zip close: %v", cerr)
|
||||
}
|
||||
case "csv":
|
||||
setDownload("text/csv; charset=utf-8", "findings-"+stamp+".csv")
|
||||
_, _ = w.Write(report.FindingsCSV(fs))
|
||||
case "json":
|
||||
assets := s.resolveFindingAssets(fs)
|
||||
out := make([]FindingDTO, 0, len(fs))
|
||||
for _, f := range fs {
|
||||
out = append(out, findingFromDB(f, assets))
|
||||
}
|
||||
setDownload("application/json; charset=utf-8", "findings-"+stamp+".json")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(out)
|
||||
default:
|
||||
writeErr(w, 400, "bad format: "+format)
|
||||
}
|
||||
}
|
||||
|
||||
func findingProvenanceInTask(contextTask *Task, findingTaskID *int64) (sourceTaskID string, inherited, allowed bool) {
|
||||
if contextTask == nil || findingTaskID == nil {
|
||||
return "", false, false
|
||||
@@ -2940,10 +3119,8 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 409, "任务正在删除,无法发送新消息")
|
||||
return
|
||||
}
|
||||
if s.engine.IsPaused(t.ID) {
|
||||
writeErr(w, 409, "任务已暂停,无法发送新消息")
|
||||
return
|
||||
}
|
||||
// 注意:任务暂停(paused)不拦截主 Agent 对话。主 Agent 编排会话独立于 planner/
|
||||
// worker 的暂停,暂停中仍可继续对话(暂停只终止其正在进行的那一轮,见 control())。
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
Attachments []chatAttachment `json:"attachments,omitempty"` // 方式1 上传的文件(路径相对任务工作目录)
|
||||
@@ -2956,9 +3133,9 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
|
||||
// turn busy before its first activity/file write, including rule-mode turns.
|
||||
// If deletion wins the race, the second barrier check rejects this request.
|
||||
s.chatMu.Lock()
|
||||
if s.engine.IsDeleting(t.ID) || s.engine.IsPaused(t.ID) {
|
||||
if s.engine.IsDeleting(t.ID) {
|
||||
s.chatMu.Unlock()
|
||||
writeErr(w, 409, "任务正在删除或已暂停,无法发送新消息")
|
||||
writeErr(w, 409, "任务正在删除,无法发送新消息")
|
||||
return
|
||||
}
|
||||
if s.chatBusy[t.ID] {
|
||||
|
||||
+58
-1
@@ -897,6 +897,12 @@ type skillFileNode struct {
|
||||
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) {
|
||||
@@ -915,13 +921,28 @@ func (s *Server) fsListSkills(w http.ResponseWriter, r *http.Request) {
|
||||
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}
|
||||
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
|
||||
@@ -937,6 +958,42 @@ func (s *Server) fsListSkills(w http.ResponseWriter, r *http.Request) {
|
||||
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))
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Autumn-27/norma/skill"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// maxLedgerSkillName caps the skill name stored for a MISS — that string comes
|
||||
// straight from the model and is otherwise unbounded.
|
||||
const maxLedgerSkillName = 128
|
||||
|
||||
// meteredSkill wraps the Skill meta-tool to append one skill_usage row per call.
|
||||
// The metering lives here rather than in Registry.OnInvoke because OnInvoke only
|
||||
// receives the resolved Skill: it never fires for an unknown skill name (norma
|
||||
// returns a tool error before the hook) and it cannot see the caller's args. Both
|
||||
// matter — a miss tells you which procedure the agents wished existed.
|
||||
type meteredSkill struct {
|
||||
actool.CoreTool
|
||||
pg *db.DB
|
||||
reg *skill.Registry
|
||||
agentKey string
|
||||
ri agent.RunInfo
|
||||
}
|
||||
|
||||
// meterSkillTool returns t unchanged when there is no DB to meter into.
|
||||
func meterSkillTool(t actool.CoreTool, pg *db.DB, reg *skill.Registry, agentKey string, ri agent.RunInfo) actool.CoreTool {
|
||||
if pg == nil {
|
||||
return t
|
||||
}
|
||||
return &meteredSkill{CoreTool: t, pg: pg, reg: reg, agentKey: agentKey, ri: ri}
|
||||
}
|
||||
|
||||
func (m *meteredSkill) Call(ctx context.Context, input json.RawMessage, tc *actool.ToolContext) (actool.Result, error) {
|
||||
m.record(input)
|
||||
return m.CoreTool.Call(ctx, input, tc)
|
||||
}
|
||||
|
||||
// record appends the ledger row. Best-effort by design: a metering failure must
|
||||
// never turn into a failed skill invocation, so every error path just logs.
|
||||
func (m *meteredSkill) record(input json.RawMessage) {
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
Args string `json:"args"`
|
||||
}
|
||||
if err := json.Unmarshal(input, &in); err != nil {
|
||||
return // malformed call; norma will reject it too
|
||||
}
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
// Resolve to the directory name so the ledger keys match agent_skill_visibility
|
||||
// and the skills page (a skill's display Name can differ from its directory).
|
||||
found := false
|
||||
if m.reg != nil {
|
||||
if s, ok := m.reg.Get(name); ok {
|
||||
found = true
|
||||
if s.Dir != "" {
|
||||
name = filepath.Base(s.Dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(name) > maxLedgerSkillName {
|
||||
name = name[:maxLedgerSkillName]
|
||||
}
|
||||
err := m.pg.InsertSkillUsage(&db.SkillUsage{
|
||||
Skill: name,
|
||||
AgentKey: m.agentKey,
|
||||
TaskID: m.ri.TaskID,
|
||||
ExplorationID: m.ri.ExplorationID,
|
||||
IntentID: m.ri.IntentID,
|
||||
SessionID: m.ri.SessionID,
|
||||
ArgsLen: len(in.Args),
|
||||
Found: found,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[skillusage] insert: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user