Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
421fee81c3 | ||
|
|
72b2098030 | ||
|
|
e03d3ac6c6 | ||
|
|
c802be812d | ||
|
|
9a531a61d9 | ||
|
|
9c4edc4dc9 | ||
|
|
9786dc1456 | ||
|
|
ef060d4ee5 | ||
|
|
1a3fbfb3d8 | ||
|
|
6be8980c17 | ||
|
|
5b9406dd91 | ||
|
|
77da63edac | ||
|
|
aa8eaf3f01 | ||
|
|
52775178dc | ||
|
|
1a550895c3 | ||
|
|
4badecbf57 | ||
|
|
eb697ab1c0 | ||
|
|
db5efc9b83 | ||
|
|
31965fd837 | ||
|
|
5544a0304d | ||
|
|
8157e68cf1 | ||
|
|
6337cd87f8 | ||
|
|
822cda2833 | ||
|
|
8c17a2228b | ||
|
|
545dc404e5 | ||
|
|
81da190ac1 | ||
|
|
020926234e | ||
|
|
9fa1787d01 | ||
|
|
59f62cace1 | ||
|
|
439865616a | ||
|
|
4675a25c18 | ||
|
|
449d8a6943 | ||
|
|
9fe2a592f3 | ||
|
|
908fb253d7 | ||
|
|
43297795f5 | ||
|
|
c0b52ac8bd | ||
|
|
b68833f320 | ||
|
|
d88dcb999f | ||
|
|
613fb27ba0 | ||
|
|
989379e924 |
@@ -45,6 +45,7 @@ func main() {
|
||||
insecure := fs.Bool("insecure-listen", false, "允许在非回环地址上不开启认证(每次启动都会警告)")
|
||||
allowedHosts := fs.String("allowed-hosts", "", "逗号分隔的额外主机名,供反向代理的 Host 与 Origin 校验")
|
||||
noExec := fs.Bool("disable-exec-tools", false, "硬关闭执行原语 run_shell/run_python/register_artifact(用户设置无法覆盖;多用户部署在执行隔离就位前建议开启)")
|
||||
execBackend := fs.String("exec-backend", "", "执行后端:direct(默认,现状直启)| sandbox(bwrap 隔离档:断网、只读系统根、仅会话目录可写、进程数限额)")
|
||||
fs.Parse(os.Args[1:])
|
||||
cliSet := visited(fs)
|
||||
|
||||
@@ -52,6 +53,7 @@ func main() {
|
||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
||||
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
|
||||
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
|
||||
ExecBackend: *execBackend,
|
||||
}, cliSet)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
@@ -61,7 +63,7 @@ func main() {
|
||||
Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock,
|
||||
StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode,
|
||||
InstanceID: resolved.Instance, InsecureListen: resolved.InsecureListen, AllowedHosts: resolved.AllowedHosts(),
|
||||
DisableExecTools: resolved.DisableExecTools,
|
||||
DisableExecTools: resolved.DisableExecTools, ExecBackend: resolved.ExecBackend,
|
||||
}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
os.Exit(1)
|
||||
@@ -105,11 +107,13 @@ func runConfig(args []string) int {
|
||||
insecure := fs.Bool("insecure-listen", false, "")
|
||||
allowedHosts := fs.String("allowed-hosts", "", "")
|
||||
noExec := fs.Bool("disable-exec-tools", false, "")
|
||||
execBackend := fs.String("exec-backend", "", "")
|
||||
fs.Parse(args[1:])
|
||||
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
|
||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
||||
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
|
||||
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
|
||||
ExecBackend: *execBackend,
|
||||
}, visited(fs))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
|
||||
Vendored
+1
-1
Submodule deps/agent-command updated: cc1ad77498...4f583ca3ee
Vendored
+1
-1
Submodule deps/agent-native updated: 37c5e6fbd6...3b92cb63b4
Vendored
+1
-1
Submodule deps/agent-squad updated: 819b0c5f82...8bbf850fad
@@ -0,0 +1,440 @@
|
||||
//go:build live
|
||||
|
||||
package live
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// upload 上传一个材料文件,返回 artifact id。
|
||||
func (c *client) upload(t *testing.T, projectID, name string, content []byte) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
fw, err := w.CreateFormFile("file", name)
|
||||
if err != nil {
|
||||
t.Fatalf("multipart field: %v", err)
|
||||
}
|
||||
if _, err := fw.Write(content); err != nil {
|
||||
t.Fatalf("multipart write: %v", err)
|
||||
}
|
||||
w.Close()
|
||||
req, err := http.NewRequest("POST", c.base+"/api/projects/"+projectID+"/uploads", &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("upload request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
t.Fatalf("upload %s: %s: %s", name, resp.Status, truncate(string(raw), 300))
|
||||
}
|
||||
var out struct {
|
||||
ID string `json:"artifactId"`
|
||||
LegacyID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("upload response: %v %s", err, truncate(string(raw), 300))
|
||||
}
|
||||
if out.ID == "" {
|
||||
out.ID = out.LegacyID
|
||||
}
|
||||
if out.ID == "" {
|
||||
t.Fatalf("upload response missing artifact id: %s", truncate(string(raw), 300))
|
||||
}
|
||||
return out.ID
|
||||
}
|
||||
|
||||
// runTurn 发一轮消息并等到 run 终态;期间自动应答 HITL 卡。
|
||||
func runTurn(t *testing.T, c *client, projectID, convID, text string, attachmentIDs []string, turnTimeout time.Duration) turnResult {
|
||||
t.Helper()
|
||||
started := time.Now()
|
||||
var out struct {
|
||||
Message message `json:"message"`
|
||||
Run run `json:"run"`
|
||||
}
|
||||
body := map[string]any{"text": text, "requestId": fmt.Sprintf("eval-%d", time.Now().UnixNano())}
|
||||
if len(attachmentIDs) > 0 {
|
||||
body["attachmentIds"] = attachmentIDs
|
||||
}
|
||||
if err := c.do("POST", "/api/conversations/"+convID+"/messages", body, &out); err != nil {
|
||||
t.Fatalf("post message: %v", err)
|
||||
}
|
||||
runID := out.Run.ID
|
||||
deadline := time.Now().Add(turnTimeout)
|
||||
var cur run
|
||||
for {
|
||||
var resp struct {
|
||||
Run run `json:"run"`
|
||||
}
|
||||
if err := c.do("GET", "/api/runs/"+runID, nil, &resp); err != nil {
|
||||
t.Fatalf("get run: %v", err)
|
||||
}
|
||||
cur = resp.Run
|
||||
if isTerminal(cur.Status) {
|
||||
break
|
||||
}
|
||||
autoResolveHITL(t, c, convID)
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("run %s timed out after %s (status=%s)", runID, turnTimeout, cur.Status)
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
detail := struct {
|
||||
Messages []message `json:"messages"`
|
||||
ActiveHitl []hitlCard `json:"activeHitl"`
|
||||
}{}
|
||||
if err := c.do("GET", "/api/conversations/"+convID, nil, &detail); err != nil {
|
||||
t.Fatalf("conversation detail: %v", err)
|
||||
}
|
||||
var assistant *message
|
||||
for i := range detail.Messages {
|
||||
m := detail.Messages[i]
|
||||
if m.RunID == runID && m.Role == "assistant" && !m.Excluded {
|
||||
assistant = &detail.Messages[i]
|
||||
}
|
||||
}
|
||||
tr := turnResult{User: text, RunID: runID, Status: cur.Status, DurationMs: time.Since(started).Milliseconds()}
|
||||
if cur.Usage != nil {
|
||||
tr.Usage = &cur
|
||||
}
|
||||
if assistant == nil {
|
||||
tr.Issues = append(tr.Issues, "没有找到本回合的助手消息")
|
||||
return tr
|
||||
}
|
||||
collectMetrics(&tr, assistant)
|
||||
// 工件清单:本 run 产出的。
|
||||
var arts []artifactMeta
|
||||
if err := c.do("GET", "/api/projects/"+projectID+"/artifacts", nil, &arts); err == nil {
|
||||
for _, a := range arts {
|
||||
if a.SourceRun == runID {
|
||||
tr.Artifacts = append(tr.Artifacts, a.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if cur.Status != "completed" {
|
||||
tr.Issues = append(tr.Issues, "run 终态="+cur.Status)
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
func isTerminal(status string) bool {
|
||||
switch status {
|
||||
case "completed", "failed", "cancelled":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// autoResolveHITL 无人值守:有活动确认卡时选第一个选项(没有选项给自由文本同意)。
|
||||
func autoResolveHITL(t *testing.T, c *client, convID string) {
|
||||
t.Helper()
|
||||
detail := struct {
|
||||
ActiveHitl []hitlCard `json:"activeHitl"`
|
||||
}{}
|
||||
if err := c.do("GET", "/api/conversations/"+convID, nil, &detail); err != nil {
|
||||
return
|
||||
}
|
||||
for _, card := range detail.ActiveHitl {
|
||||
resp := map[string]string{}
|
||||
if len(card.Choices) > 0 {
|
||||
resp["choiceId"] = card.Choices[0].ID
|
||||
} else if card.AllowFreeInput {
|
||||
resp["freeText"] = "同意继续(评测自动应答)"
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
_ = c.do("POST", "/api/hitl/"+card.ID+"/resolve", resp, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func collectMetrics(tr *turnResult, m *message) {
|
||||
for _, p := range m.Parts {
|
||||
switch p.Type {
|
||||
case "text":
|
||||
if !p.Process && strings.TrimSpace(p.Text) != "" {
|
||||
tr.FinalText = p.Text
|
||||
}
|
||||
case "tool_call":
|
||||
if p.ToolCall == nil {
|
||||
continue
|
||||
}
|
||||
tr.Metrics.ToolCalls++
|
||||
args := p.ToolCall.Arguments
|
||||
switch p.ToolCall.Name {
|
||||
case "update_plan", "update_todos":
|
||||
tr.Metrics.PlanUpdates++
|
||||
case "run_python":
|
||||
if strings.Contains(args, "new_document(") || strings.Contains(args, "new_presentation(") || strings.Contains(args, "new_workbook(") {
|
||||
tr.Metrics.FullRegens++
|
||||
}
|
||||
}
|
||||
if strings.Contains(args, "dir(") || strings.Contains(args, "inspect.signature") || strings.Contains(args, "help(") {
|
||||
tr.Metrics.APIDiscovery++
|
||||
}
|
||||
if strings.Contains(args, "inputs/") {
|
||||
tr.Metrics.ReadsOriginal++
|
||||
}
|
||||
case "tool_result":
|
||||
if p.ToolResult == nil {
|
||||
continue
|
||||
}
|
||||
s := p.ToolResult.Summary
|
||||
if p.ToolResult.Status == "error" || strings.HasPrefix(s, "exit=1") || strings.Contains(s, "Traceback") || strings.Contains(s, "ModuleNotFoundError") {
|
||||
tr.Metrics.ToolFailures++
|
||||
}
|
||||
case "check":
|
||||
if p.Check == nil {
|
||||
continue
|
||||
}
|
||||
tr.Metrics.VerifierChecks++
|
||||
tr.Checks = append(tr.Checks, checkRecord{Passed: p.Check.Passed, Reason: p.Check.Reason})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lastArtifactTurn(tr taskResult) *turnResult {
|
||||
for i := len(tr.Turns) - 1; i >= 0; i-- {
|
||||
if len(tr.Turns[i].Artifacts) > 0 {
|
||||
return &tr.Turns[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadArtifactText(t *testing.T, c *client, projectID, name string) string {
|
||||
t.Helper()
|
||||
var arts []artifactMeta
|
||||
if err := c.do("GET", "/api/projects/"+projectID+"/artifacts", nil, &arts); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, a := range arts {
|
||||
if a.Name != name {
|
||||
continue
|
||||
}
|
||||
req, _ := http.NewRequest("GET", c.base+"/api/artifacts/"+a.ID+"/content", nil)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if strings.HasSuffix(strings.ToLower(a.Name), ".docx") {
|
||||
return docxTextIfDocx(t, a.Name, raw)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---- 文本抽取与事实核查 ----
|
||||
|
||||
// docxTextIfDocx:docx 抽纯文本(标题行带 # 前缀);其他类型原样返回。
|
||||
func docxTextIfDocx(t *testing.T, name string, raw []byte) string {
|
||||
t.Helper()
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".docx") {
|
||||
return string(raw)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if f.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer rc.Close()
|
||||
dec := xml.NewDecoder(rc)
|
||||
var b strings.Builder
|
||||
var para strings.Builder
|
||||
style, inPara := "", false
|
||||
flush := func() {
|
||||
text := strings.TrimSpace(para.String())
|
||||
if text != "" {
|
||||
if lvl := docxHeadingLevel(style); lvl > 0 {
|
||||
b.WriteString(strings.Repeat("#", lvl) + " ")
|
||||
}
|
||||
b.WriteString(text + "\n")
|
||||
}
|
||||
para.Reset()
|
||||
style = ""
|
||||
}
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
switch el := tok.(type) {
|
||||
case xml.StartElement:
|
||||
switch el.Name.Local {
|
||||
case "p":
|
||||
inPara, style = true, ""
|
||||
para.Reset()
|
||||
case "pStyle":
|
||||
if inPara {
|
||||
for _, at := range el.Attr {
|
||||
if at.Name.Local == "val" {
|
||||
style = at.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case xml.EndElement:
|
||||
if el.Name.Local == "p" {
|
||||
inPara = false
|
||||
flush()
|
||||
}
|
||||
case xml.CharData:
|
||||
if inPara {
|
||||
para.Write(bytes.TrimSpace(el))
|
||||
}
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return b.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func docxHeadingLevel(style string) int {
|
||||
switch {
|
||||
case style == "Title":
|
||||
return 1
|
||||
case style == "Subtitle":
|
||||
return 2
|
||||
case strings.HasPrefix(style, "Heading"):
|
||||
n := 0
|
||||
for _, ch := range style[len("Heading"):] {
|
||||
if ch < '0' || ch > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int(ch-'0')
|
||||
}
|
||||
if n >= 1 && n <= 9 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var (
|
||||
pctRe = regexp.MustCompile(`\d+(?:\.\d+)?%`)
|
||||
dateRe = regexp.MustCompile(`\d{1,2}月\d{1,2}日`)
|
||||
bigNumRe = regexp.MustCompile(`\d{4,}`)
|
||||
claimRe = regexp.MustCompile(`(?:^|\n|\s)([一二三四五六七八九十]{1,2}、[^\s,。;|]{2,24})`)
|
||||
)
|
||||
|
||||
// unverifiedFacts 抽取交付物里的百分比/日期/大数字,逐个回材料原文找出处。
|
||||
// 找不到的列为未核实事实(模型编造或来自模型自带的「常识」)。
|
||||
func unverifiedFacts(artifactText, materialText string) []string {
|
||||
if strings.TrimSpace(artifactText) == "" || strings.TrimSpace(materialText) == "" {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
check := func(tok string) {
|
||||
tok = strings.TrimSpace(tok)
|
||||
if tok == "" || seen[tok] {
|
||||
return
|
||||
}
|
||||
seen[tok] = true
|
||||
if !strings.Contains(materialText, tok) {
|
||||
out = append(out, tok)
|
||||
}
|
||||
}
|
||||
for _, m := range pctRe.FindAllString(artifactText, -1) {
|
||||
check(m)
|
||||
}
|
||||
for _, m := range dateRe.FindAllString(artifactText, -1) {
|
||||
check(m)
|
||||
}
|
||||
for _, m := range bigNumRe.FindAllString(artifactText, -1) {
|
||||
if len(m) >= 5 { // 4 位数大量是年份/编号,只报 5 位以上
|
||||
check(m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// finalVsArtifact 终轮回复里声称的章节(「一、…」)必须能在工件里找到。
|
||||
func finalVsArtifact(turn *turnResult, artifactText string) []string {
|
||||
if turn == nil || turn.FinalText == "" || strings.TrimSpace(artifactText) == "" {
|
||||
return nil
|
||||
}
|
||||
final := turn.FinalText
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, m := range claimRe.FindAllStringSubmatch(final, -1) {
|
||||
claim := strings.TrimSpace(m[1])
|
||||
if claim == "" || seen[claim] {
|
||||
continue
|
||||
}
|
||||
seen[claim] = true
|
||||
if !strings.Contains(artifactText, claim) {
|
||||
out = append(out, claim)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- 报告 ----
|
||||
|
||||
func writeReport(t *testing.T, rep *report) {
|
||||
t.Helper()
|
||||
outPath := os.Getenv("AGENT_EVAL_OUT")
|
||||
if outPath == "" {
|
||||
outPath = filepath.Join("eval-live", "report-"+time.Now().Format("0102-150405")+".json")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
|
||||
t.Fatalf("mkdir report dir: %v", err)
|
||||
}
|
||||
raw, _ := json.MarshalIndent(rep, "", " ")
|
||||
if err := os.WriteFile(outPath, raw, 0o644); err != nil {
|
||||
t.Fatalf("write report: %v", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n[live-eval] report: %s\n%s\n", outPath, renderMarkdown(rep))
|
||||
}
|
||||
|
||||
func renderMarkdown(rep *report) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# Live eval — %s\n\n- target: %s\n- model: %s\n- generated: %s\n\n", rep.GeneratedAt, rep.Target, rep.Model, rep.GeneratedAt)
|
||||
for _, tk := range rep.Tasks {
|
||||
fmt.Fprintf(&b, "## %s\n\n| turn | status | tools | fails | regens | api探查 | 读原件 | 计划 | 检查 | 工件 | 时长s |\n|---|---|---|---|---|---|---|---|---|---|---|\n", tk.ID)
|
||||
for _, turn := range tk.Turns {
|
||||
fmt.Fprintf(&b, "| %s | %s | %d | %d | %d | %d | %d | %d | %d | %d | %.0f |\n",
|
||||
truncate(turn.User, 18), turn.Status, turn.Metrics.ToolCalls, turn.Metrics.ToolFailures,
|
||||
turn.Metrics.FullRegens, turn.Metrics.APIDiscovery, turn.Metrics.ReadsOriginal,
|
||||
turn.Metrics.PlanUpdates, turn.Metrics.VerifierChecks, len(turn.Artifacts),
|
||||
float64(turn.DurationMs)/1000)
|
||||
}
|
||||
if len(tk.UnverifiedFacts) > 0 {
|
||||
fmt.Fprintf(&b, "\n未核实事实(工件里出现、材料里找不到):%s\n", strings.Join(tk.UnverifiedFacts, "、"))
|
||||
}
|
||||
if len(tk.FinalVsArtifact) > 0 {
|
||||
fmt.Fprintf(&b, "\n终轮声称但工件不存在的章节:%s\n", strings.Join(tk.FinalVsArtifact, ";"))
|
||||
}
|
||||
fmt.Fprint(&b, "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
//go:build live
|
||||
|
||||
// Package live 是真实后端 + 真实模型的任务级评测(Phase 0 基线)。
|
||||
//
|
||||
// 用法(对准一个已配置好模型的运行实例):
|
||||
//
|
||||
// AGENT_EVAL_BASE=http://127.0.0.1:18799 \
|
||||
// AGENT_EVAL_MATERIALS=~/.agent-system/eval/materials \
|
||||
// AGENT_EVAL_OUT=/tmp/before.json \
|
||||
// go test ./eval/live/ -tags live -run TestLiveSuite -v -timeout 90m
|
||||
//
|
||||
// 评测器只用公开 HTTP 面(上传→建会话→发消息→轮询),指标从消息 parts、
|
||||
// run usage 与工件内容计算;不触碰目标实例数据库。HITL 卡自动选第一个选项,
|
||||
// 保证无人值守跑完。产出 JSON + Markdown 报告;任务集见 tasks.json。
|
||||
// 真实材料不进仓:materials 目录按 eval/README 的口径放本机。
|
||||
package live
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type task struct {
|
||||
ID string `json:"id"`
|
||||
Material string `json:"material,omitempty"`
|
||||
Turns []string `json:"turns"`
|
||||
}
|
||||
|
||||
type part struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Process bool `json:"process"`
|
||||
ToolCall *struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
Duration float64 `json:"durationMs"`
|
||||
} `json:"toolCall"`
|
||||
ToolResult *struct {
|
||||
CallID string `json:"callId"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
} `json:"toolResult"`
|
||||
Check *struct {
|
||||
Passed bool `json:"passed"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"check"`
|
||||
Artifact *struct {
|
||||
ArtifactID string `json:"artifactId"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
} `json:"artifact"`
|
||||
}
|
||||
|
||||
type message struct {
|
||||
ID string `json:"id"`
|
||||
RunID string `json:"runId"`
|
||||
Role string `json:"role"`
|
||||
Parts []part `json:"parts"`
|
||||
Excluded bool `json:"excluded"`
|
||||
}
|
||||
|
||||
type run struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
EndedAt string `json:"endedAt"`
|
||||
Usage *struct {
|
||||
PromptTokens int `json:"promptTokens"`
|
||||
CompletionTokens int `json:"completionTokens"`
|
||||
TotalTokens int `json:"totalTokens"`
|
||||
DurationMs int `json:"durationMs"`
|
||||
ToolCalls int `json:"toolCalls"`
|
||||
} `json:"usage"`
|
||||
Error any `json:"error"`
|
||||
}
|
||||
|
||||
type artifactMeta struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
MIME string `json:"mime"`
|
||||
SourceRun string `json:"sourceRun"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type hitlCard struct {
|
||||
ID string `json:"id"`
|
||||
Question string `json:"question"`
|
||||
Choices []struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
} `json:"choices"`
|
||||
AllowFreeInput bool `json:"allowFreeInput"`
|
||||
}
|
||||
|
||||
type client struct {
|
||||
base string
|
||||
http *http.Client
|
||||
t *testing.T
|
||||
model string
|
||||
}
|
||||
|
||||
func (c *client) do(method, path string, body any, out any) error {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, c.base+path, rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, truncate(string(raw), 300))
|
||||
}
|
||||
if out != nil {
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
|
||||
func TestLiveSuite(t *testing.T) {
|
||||
base := strings.TrimSpace(os.Getenv("AGENT_EVAL_BASE"))
|
||||
if base == "" {
|
||||
t.Skip("AGENT_EVAL_BASE not set; live eval requires a configured backend instance")
|
||||
}
|
||||
materialsDir := os.Getenv("AGENT_EVAL_MATERIALS")
|
||||
if materialsDir == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
materialsDir = filepath.Join(home, ".agent-system", "eval", "materials")
|
||||
}
|
||||
if _, err := os.Stat(materialsDir); err != nil {
|
||||
t.Skipf("materials dir not available: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile("tasks.json")
|
||||
if err != nil {
|
||||
t.Fatalf("read tasks.json: %v", err)
|
||||
}
|
||||
var tasks []task
|
||||
if err := json.Unmarshal(raw, &tasks); err != nil {
|
||||
t.Fatalf("parse tasks.json: %v", err)
|
||||
}
|
||||
if filter := os.Getenv("AGENT_EVAL_TASKS"); filter != "" {
|
||||
want := map[string]bool{}
|
||||
for _, id := range strings.Split(filter, ",") {
|
||||
want[strings.TrimSpace(id)] = true
|
||||
}
|
||||
var kept []task
|
||||
for _, tk := range tasks {
|
||||
if want[tk.ID] {
|
||||
kept = append(kept, tk)
|
||||
}
|
||||
}
|
||||
tasks = kept
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
t.Fatal("no tasks selected")
|
||||
}
|
||||
turnTimeout := 20 * time.Minute
|
||||
if v := os.Getenv("AGENT_EVAL_TURN_TIMEOUT"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
turnTimeout = d
|
||||
}
|
||||
}
|
||||
c := &client{base: base, http: &http.Client{Timeout: 120 * time.Second}, t: t}
|
||||
// /api/settings 的形状是 {defaults, project, schema, user};模型播在 user 作用域。
|
||||
var settings struct {
|
||||
User struct {
|
||||
Models struct {
|
||||
Chat struct {
|
||||
Model string `json:"model"`
|
||||
} `json:"chat"`
|
||||
} `json:"models"`
|
||||
} `json:"user"`
|
||||
}
|
||||
_ = c.do("GET", "/api/settings", nil, &settings)
|
||||
c.model = settings.User.Models.Chat.Model
|
||||
|
||||
proj := struct {
|
||||
ID string `json:"id"`
|
||||
}{}
|
||||
if err := c.do("POST", "/api/projects", map[string]string{"name": "eval-" + time.Now().Format("0102-150405")}, &proj); err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
|
||||
rep := report{Target: base, Model: c.model, GeneratedAt: time.Now().UTC().Format(time.RFC3339)}
|
||||
for _, tk := range tasks {
|
||||
rep.Tasks = append(rep.Tasks, runTask(t, c, proj.ID, tk, materialsDir, turnTimeout))
|
||||
}
|
||||
writeReport(t, &rep)
|
||||
}
|
||||
|
||||
type turnResult struct {
|
||||
User string `json:"user"`
|
||||
RunID string `json:"runId"`
|
||||
Status string `json:"status"`
|
||||
FinalText string `json:"finalText,omitempty"`
|
||||
Usage *run `json:"usage,omitempty"`
|
||||
Metrics turnMetrics `json:"metrics"`
|
||||
Checks []checkRecord `json:"checks,omitempty"`
|
||||
Artifacts []string `json:"artifacts,omitempty"`
|
||||
Issues []string `json:"issues,omitempty"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
}
|
||||
|
||||
type turnMetrics struct {
|
||||
ToolCalls int `json:"toolCalls"`
|
||||
ToolFailures int `json:"toolFailures"`
|
||||
FullRegens int `json:"fullRegens"`
|
||||
APIDiscovery int `json:"apiDiscovery"`
|
||||
ReadsOriginal int `json:"readsOriginal"`
|
||||
PlanUpdates int `json:"planUpdates"`
|
||||
VerifierChecks int `json:"verifierChecks"`
|
||||
}
|
||||
|
||||
type checkRecord struct {
|
||||
Passed bool `json:"passed"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type taskResult struct {
|
||||
ID string `json:"id"`
|
||||
Turns []turnResult `json:"turns"`
|
||||
Total turnMetrics `json:"total"`
|
||||
UnverifiedFacts []string `json:"unverifiedFacts,omitempty"`
|
||||
FinalVsArtifact []string `json:"finalVsArtifact,omitempty"`
|
||||
}
|
||||
|
||||
type report struct {
|
||||
Target string `json:"target"`
|
||||
Model string `json:"model,omitempty"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Tasks []taskResult `json:"tasks"`
|
||||
}
|
||||
|
||||
func runTask(t *testing.T, c *client, projectID string, tk task, materialsDir string, turnTimeout time.Duration) taskResult {
|
||||
t.Helper()
|
||||
conv := struct {
|
||||
ID string `json:"id"`
|
||||
}{}
|
||||
if err := c.do("POST", "/api/projects/"+projectID+"/conversations", map[string]string{"title": "eval-" + tk.ID}, &conv); err != nil {
|
||||
t.Fatalf("[%s] create conversation: %v", tk.ID, err)
|
||||
}
|
||||
var attachmentIDs []string
|
||||
materialText := ""
|
||||
if tk.Material != "" {
|
||||
path := filepath.Join(materialsDir, tk.Material)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("[%s] open material: %v", tk.ID, err)
|
||||
}
|
||||
up := c.upload(t, projectID, tk.Material, raw)
|
||||
attachmentIDs = append(attachmentIDs, up)
|
||||
materialText = docxTextIfDocx(t, tk.Material, raw)
|
||||
}
|
||||
tr := taskResult{ID: tk.ID}
|
||||
for i, turnText := range tk.Turns {
|
||||
tr.Turns = append(tr.Turns, runTurn(t, c, projectID, conv.ID, turnText, attachmentIDs, turnTimeout))
|
||||
attachmentIDs = nil // 材料只在第一轮附带;后续轮考验跨轮上下文
|
||||
_ = i
|
||||
}
|
||||
// 汇总
|
||||
for _, turn := range tr.Turns {
|
||||
tr.Total.ToolCalls += turn.Metrics.ToolCalls
|
||||
tr.Total.ToolFailures += turn.Metrics.ToolFailures
|
||||
tr.Total.FullRegens += turn.Metrics.FullRegens
|
||||
tr.Total.APIDiscovery += turn.Metrics.APIDiscovery
|
||||
tr.Total.ReadsOriginal += turn.Metrics.ReadsOriginal
|
||||
tr.Total.PlanUpdates += turn.Metrics.PlanUpdates
|
||||
tr.Total.VerifierChecks += turn.Metrics.VerifierChecks
|
||||
}
|
||||
// 事实核查:最后一个带工件的回合 vs 材料
|
||||
if materialText != "" {
|
||||
last := lastArtifactTurn(tr)
|
||||
if last != nil && len(last.Artifacts) > 0 {
|
||||
artText := ""
|
||||
for _, name := range last.Artifacts {
|
||||
artText += downloadArtifactText(t, c, projectID, name)
|
||||
}
|
||||
tr.UnverifiedFacts = unverifiedFacts(artText, materialText)
|
||||
tr.FinalVsArtifact = finalVsArtifact(last, artText)
|
||||
}
|
||||
}
|
||||
return tr
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"id": "minutes-basic",
|
||||
"material": "meeting-rd-agent-asr.docx",
|
||||
"turns": ["帮我总结一份会议纪要"]
|
||||
},
|
||||
{
|
||||
"id": "plan-followup",
|
||||
"material": "meeting-rd-agent-asr.docx",
|
||||
"turns": [
|
||||
"帮我总结一份会议纪要",
|
||||
"根据这个纪要,制定一个方案",
|
||||
"方案太简要了,给我一个详细的方案和计划"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "qa-citation",
|
||||
"material": "meeting-rd-agent-asr.docx",
|
||||
"turns": ["这次会议最主要的事情是什么"]
|
||||
},
|
||||
{
|
||||
"id": "minutes-pm-tool",
|
||||
"material": "meeting-pm-tool-asr.docx",
|
||||
"turns": ["生成会议纪要"]
|
||||
}
|
||||
]
|
||||
+58
-11
@@ -76,6 +76,9 @@ type Options struct {
|
||||
// register_artifact 与小队内核执行配置):用户设置 runtime.exec 无法覆盖;
|
||||
// 零值=false=现状。
|
||||
DisableExecTools bool
|
||||
// ExecBackend 执行后端(EXT-16):空/direct=现状直启;sandbox=bwrap 隔离档。
|
||||
// 硬关闭 DisableExecTools 优先级高于后端选择。
|
||||
ExecBackend string
|
||||
}
|
||||
|
||||
// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。
|
||||
@@ -128,6 +131,13 @@ func Run(opt Options) error {
|
||||
if opt.DisableExecTools {
|
||||
log.Printf("exec tools: disabled by startup option (runtime.exec settings are ignored)")
|
||||
}
|
||||
// EXT-16:执行后端选择(direct|sandbox);未知名字启动即失败(fail-closed)。
|
||||
if err := tools.SetExecBackend(opt.ExecBackend); err != nil {
|
||||
return fmt.Errorf("exec backend: %w", err)
|
||||
}
|
||||
if tools.ExecBackendName() == "sandbox" {
|
||||
log.Printf("exec tools: backend=sandbox (bwrap: no network, ro root, session-only writes, proc limit)")
|
||||
}
|
||||
if err := tools.RegisterExecTools(reg); err != nil {
|
||||
return fmt.Errorf("exec tools: %w", err)
|
||||
}
|
||||
@@ -166,18 +176,22 @@ func Run(opt Options) error {
|
||||
rag.SecretLookup = func(service, fallback string) string {
|
||||
return runtime.LookupSecret(store.OwnerDefault, service, fallback)
|
||||
}
|
||||
// 网络搜索(Tavily):配置密钥池才注册(对齐 agent-native 语义:未配置不静默降级)。
|
||||
eff := setSvc.Effective(store.OwnerDefault, "")
|
||||
tavilyRaw := eff.GetString("tools.tavily.apiKeys", "")
|
||||
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
|
||||
tavilyRaw = active
|
||||
}
|
||||
if keys := splitKeyPool(tavilyRaw); len(keys) > 0 {
|
||||
provider := agenttools.NewTavilyProvider(eff.GetString("tools.tavily.endpoint", ""), keys)
|
||||
if err := tools.RegisterSearchTools(reg, provider); err != nil {
|
||||
return fmt.Errorf("search tools: %w", err)
|
||||
// 网络搜索(Tavily):始终注册,密钥按调用时读取(设置页/svc 播种改了
|
||||
// 即刻生效——此前启动时快照密钥,播种后必须重启工具才注册)。
|
||||
// 未配置时调用返回明确的「未配置」提示,不静默降级为空结果。
|
||||
if err := tools.RegisterLazySearchTools(reg, func() agenttools.SearchProvider {
|
||||
effNow := setSvc.Effective(store.OwnerDefault, "")
|
||||
tavilyNow := effNow.GetString("tools.tavily.apiKeys", "")
|
||||
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
|
||||
tavilyNow = active
|
||||
}
|
||||
log.Printf("tools: tavily_search/web_search registered (%d key(s))", len(keys))
|
||||
keys := splitKeyPool(tavilyNow)
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
return agenttools.NewTavilyProvider(effNow.GetString("tools.tavily.endpoint", ""), keys)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("search tools: %w", err)
|
||||
}
|
||||
promptLib, err := prompts.DefaultLibrary()
|
||||
if err != nil {
|
||||
@@ -387,6 +401,15 @@ func Run(opt Options) error {
|
||||
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
|
||||
authzSlot := &projectAuthz{}
|
||||
caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot}
|
||||
// EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面
|
||||
// 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。
|
||||
mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
|
||||
ok, err := authzSlot.Authorize(owner, projectID)
|
||||
return err == nil && ok
|
||||
})
|
||||
// EXT-14:命令即工具——带 exposeAsTool 的命令投影进回合工具清单,模型调用
|
||||
// 经命令总分发(Entrance=agent,同审计信封与高危内联确认卡)。
|
||||
mgr.SetCommandSource(busAdapter{bus: bus})
|
||||
// EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。
|
||||
wireScopedHostFactory(caps)
|
||||
applyCompiled(skillsRoot, bus, caps)
|
||||
@@ -697,3 +720,27 @@ func splitKeyPool(s string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// busAdapter EXT-14:cmdbus.Bus 适配 runmgr.CommandSource。视图类型转换在此收口
|
||||
// (runmgr 不依赖 cmdbus,边不进架构基线);Entrance 由 runmgr 固定为 agent。
|
||||
type busAdapter struct{ bus *cmdbus.Bus }
|
||||
|
||||
func (a busAdapter) CommandTools() []runmgr.CommandTool {
|
||||
src := a.bus.CommandTools()
|
||||
out := make([]runmgr.CommandTool, len(src))
|
||||
for i, ct := range src {
|
||||
out[i] = runmgr.CommandTool{ID: ct.ID, Description: ct.Description, Params: ct.Params, Risk: ct.Risk}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a busAdapter) DispatchCommand(ctx context.Context, req runmgr.CommandRequest) runmgr.CommandResponse {
|
||||
resp := a.bus.Dispatch(ctx, cmdbus.Request{
|
||||
ID: req.ID, Params: req.Params, Entrance: req.Entrance,
|
||||
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID,
|
||||
})
|
||||
return runmgr.CommandResponse{
|
||||
State: string(resp.Receipt.State), Reason: resp.Receipt.Reason,
|
||||
Payload: resp.Payload, Card: resp.Card != nil,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,21 @@ func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Hand
|
||||
})
|
||||
}
|
||||
|
||||
// AddIntent 意图声明(G2-2):汇入总线意图表;插件身份随声明归因。
|
||||
func (r pluginRegistry) AddIntent(spec json.RawMessage) error {
|
||||
if r.bus == nil {
|
||||
return nil
|
||||
}
|
||||
var is extension.IntentSpec
|
||||
if err := json.Unmarshal(spec, &is); err != nil {
|
||||
return fmt.Errorf("extension: intent spec: %w", err)
|
||||
}
|
||||
return r.bus.Intents().Declare(cmdbus.Intent{
|
||||
ID: is.ID, Phrases: is.Phrases, Command: is.Command,
|
||||
Params: is.Params, Description: is.Description, Plugin: r.pluginID,
|
||||
})
|
||||
}
|
||||
|
||||
func (r compiledRegistry) AddSkill(name, markdown string) error {
|
||||
if r.skillsRoot == "" || name == "" {
|
||||
return nil
|
||||
@@ -71,6 +86,19 @@ func (r compiledRegistry) RegisterResourceAuthorizer(extension.ResourceAuthorize
|
||||
return errors.New("extension: declarative plugins cannot register resource authorizers")
|
||||
}
|
||||
|
||||
// RegisterResourceAuthorizer 进程内扩展(EXT-1):填充 EXT-8 适配器槽
|
||||
// (hostcaps.authz 共享指针;首个注册者生效,组合根已装配进 Server/runmgr)。
|
||||
// 此前 pluginRegistry 未覆盖该方法——进程内插件注册适配器会命中声明式分支的
|
||||
// 拒绝,register() 整体中止,插件命令全部未注册(RDM 二进制 rdm.* 全部
|
||||
// 「unknown command」;MS1 重验收烟测实测发现)。
|
||||
func (r pluginRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthorizer) error {
|
||||
if fn == nil {
|
||||
return errors.New("extension: " + r.pluginID + ": nil resource authorizer")
|
||||
}
|
||||
r.caps.authz.set(r.pluginID, fn)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r compiledRegistry) Note(kind, name string, payload json.RawMessage) error {
|
||||
log.Printf("extension: note %s %s (%d bytes)", kind, name, len(payload))
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// fakeExtAuthz 进程内扩展:注册 EXT-8 适配器+一条命令——修复前适配器注册被
|
||||
// 声明式分支拒绝,register() 中止导致命令全部丢失。
|
||||
type fakeExtAuthz struct{}
|
||||
|
||||
func (f *fakeExtAuthz) Register(reg extension.Registry) error {
|
||||
if err := reg.RegisterResourceAuthorizer(func(owner string, projectID core.ID) (bool, error) {
|
||||
return owner == "alice", nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return reg.AddCommandHandler([]byte(`{"id":"t.authz.ping","title":"t"}`),
|
||||
func(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestInprocPluginRegistersAuthorizerAndCommands(t *testing.T) {
|
||||
extension.ResetCompiled()
|
||||
extension.RegisterCompiled(extension.InProc{ID: "t", Register: (&fakeExtAuthz{}).Register})
|
||||
defer extension.ResetCompiled()
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||||
caps := hostcaps{st: st, authz: &projectAuthz{}}
|
||||
applyCompiled("", bus, caps)
|
||||
// ① 命令已注册且可分发(修复前:适配器注册被拒 → register() 中止 →
|
||||
// 命令全部丢失,rdm.* 全部 unknown command)。
|
||||
if resp := bus.Dispatch(context.Background(), cmdbus.Request{
|
||||
ID: "t.authz.ping", Entrance: "button", Owner: "alice"}); resp.Receipt.State != "committed" {
|
||||
t.Fatalf("命令分发 = %+v(注册整体中止)", resp.Receipt)
|
||||
}
|
||||
// ② 适配器经共享槽生效:alice 放行、bob 拒绝。
|
||||
if ok, _ := caps.authz.Authorize("alice", "p1"); !ok {
|
||||
t.Fatal("alice 应放行")
|
||||
}
|
||||
if ok, _ := caps.authz.Authorize("bob", "p1"); ok {
|
||||
t.Fatal("bob 应被拒绝")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package app
|
||||
|
||||
// EXT-13 红测(提案 2026-10-09-ext-g1-b1-b3 §1):进程内 Host.KnowledgeSearch
|
||||
// 的检索面在适配器放行后吃到他人项目的共享知识;未放行保持本人两路、零泄露。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/rag"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// newExt13Host EXT-13 装配:真实 store + rag 管线 + authz 槽 + inprocHost(bob 视角)。
|
||||
func newExt13Host(t *testing.T) (interface {
|
||||
KnowledgeSearch(ctx context.Context, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error)
|
||||
}, *projectAuthz) {
|
||||
t.Helper()
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
set := settings.NewService(st)
|
||||
pipe := rag.NewPipeline(st, set, nil, nil)
|
||||
authz := &projectAuthz{}
|
||||
h := inprocHost{caps: hostcaps{st: st, pipe: pipe, set: set, authz: authz}, pluginID: "rdm", owner: "bob"}
|
||||
// alice 已发布的项目知识(带 ProjectID;模拟发布状态机产物)。
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{Owner: "alice", ProjectID: "p-1",
|
||||
Source: "plugin:rdm", Ref: "ext13-1", Chunk: "EXT13共享独占令牌陈述"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h, authz
|
||||
}
|
||||
|
||||
func TestInprocHostKnowledgeSearchShared(t *testing.T) {
|
||||
h, authz := newExt13Host(t)
|
||||
ctx := context.Background()
|
||||
q := core.RetrievalQuery{Text: "EXT13共享独占令牌", TopK: 5}
|
||||
|
||||
if hits, err := h.KnowledgeSearch(ctx, "", q.Text, q.TopK); err != nil || len(hits) != 0 {
|
||||
t.Fatalf("before grant: hits=%d err=%v, want 0(未放行零泄露)", len(hits), err)
|
||||
}
|
||||
// 适配器放行 bob × p-1 → Host 检索面吃到 alice 的共享命中。
|
||||
// (槽为首个注册者生效——切换授权改闭包内变量,不重注册。)
|
||||
allowBob := true
|
||||
authz.set("rdm", func(owner string, projectID core.ID) (bool, error) {
|
||||
return allowBob && owner == "bob" && string(projectID) == "p-1", nil
|
||||
})
|
||||
hits, err := h.KnowledgeSearch(ctx, "", q.Text, q.TopK)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("after grant: host search got 0 shared hits, want ≥1(EXT-13)")
|
||||
}
|
||||
// 指定他人 ProjectID 时同样经适配器(发布边界在下游状态机)。
|
||||
if hits, err := h.KnowledgeSearch(ctx, "p-1", q.Text, q.TopK); err != nil || len(hits) == 0 {
|
||||
t.Fatalf("scoped to shared project: hits=%d err=%v, want ≥1", len(hits), err)
|
||||
}
|
||||
// 撤销(同一注册内翻转授权)→ 共享命中即停、零泄露。
|
||||
allowBob = false
|
||||
if hits, err := h.KnowledgeSearch(ctx, "", q.Text, q.TopK); err != nil || len(hits) != 0 {
|
||||
t.Fatalf("after revoke: hits=%d err=%v, want 0", len(hits), err)
|
||||
}
|
||||
}
|
||||
@@ -61,8 +61,25 @@ func wireScopedHostFactory(caps hostcaps) {
|
||||
}
|
||||
|
||||
// search owner 作用域混合检索;projectID/topK 为空/0 时用管线默认值。
|
||||
// sharedRetriever EXT-13:rag.Pipeline.RetrieveShared 形状(避免 app 直接 import rag
|
||||
// 的具体类型断言散落)。
|
||||
type sharedRetriever interface {
|
||||
RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error)
|
||||
}
|
||||
|
||||
func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) {
|
||||
return c.pipe.Retrieve(ctx, core.ID(owner), core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK})
|
||||
q := core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK}
|
||||
// EXT-13:进程内 Host 检索面与回合内检索同一授权口径——适配器已注册时走
|
||||
// 共享通道(本人两路如常 + 放行的他人项目知识,与 HTTP 检索面一致)。
|
||||
if c.authz != nil {
|
||||
if rs, ok := interface{}(c.pipe).(sharedRetriever); ok {
|
||||
return rs.RetrieveShared(ctx, core.ID(owner), q, func(docOwner, pid core.ID) bool {
|
||||
ok, err := c.authz.Authorize(owner, pid)
|
||||
return err == nil && ok
|
||||
})
|
||||
}
|
||||
}
|
||||
return c.pipe.Retrieve(ctx, core.ID(owner), q)
|
||||
}
|
||||
|
||||
// searchDefault 与 hostapi /host/v1 检索端点同口径(不带 projectID/topK)。
|
||||
|
||||
@@ -27,14 +27,14 @@ var (
|
||||
const cardAppendWindow = 250 * time.Millisecond
|
||||
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
emit Emitter
|
||||
st *store.Store
|
||||
emit Emitter
|
||||
// MirrorConvCards EXT-12:会话卡是否镜像进项目公共流(缺省 true=现状全量
|
||||
// 镜像;false 时会话卡只进本人会话流,项目级卡不受影响)。
|
||||
MirrorConvCards bool
|
||||
now func() time.Time
|
||||
emitMu sync.Mutex
|
||||
lastEmit map[string]time.Time // 流式追加的最近发布时刻(按卡)
|
||||
now func() time.Time
|
||||
emitMu sync.Mutex
|
||||
lastEmit map[string]time.Time // 流式追加的最近发布时刻(按卡)
|
||||
}
|
||||
|
||||
func New(st *store.Store, emit Emitter) *Service {
|
||||
|
||||
@@ -34,6 +34,37 @@ type CardPort interface {
|
||||
// Handler 宿主执行适配器:同步内置用例返回 payload;异步执行自行返回 accepted 回执。
|
||||
type Handler func(ctx context.Context, req Request) (any, error)
|
||||
|
||||
// CommandTool EXT-14:带 exposeAsTool 声明的命令的工具投影视图(runmgr 消费)。
|
||||
// Risk 取 low|medium|high(工具权限声明口径);ID 即工具名(命令 id)。
|
||||
type CommandTool struct {
|
||||
ID string
|
||||
Title string
|
||||
Description string
|
||||
Params json.RawMessage
|
||||
Risk string
|
||||
}
|
||||
|
||||
// CommandTools 全部带 ExposeAsTool 的已启用命令(EXT-14 工具投影源)。
|
||||
func (b *Bus) CommandTools() []CommandTool {
|
||||
out := []CommandTool{}
|
||||
for _, r := range b.reg.List() {
|
||||
if !r.Enabled || r.Spec.ExposeAsTool == nil {
|
||||
continue
|
||||
}
|
||||
risk := r.Spec.ExposeAsTool.Risk
|
||||
if risk == "" {
|
||||
risk = "low"
|
||||
}
|
||||
desc := r.Spec.Description
|
||||
if desc == "" {
|
||||
desc = r.Spec.Title
|
||||
}
|
||||
out = append(out, CommandTool{ID: r.Spec.ID, Title: r.Spec.Title,
|
||||
Description: desc, Params: r.Spec.ParamsSchema, Risk: risk})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Request 一次分发请求(入口归一后的统一形态)。
|
||||
type Request struct {
|
||||
ID string
|
||||
@@ -69,6 +100,7 @@ type Bus struct {
|
||||
pendMu sync.Mutex
|
||||
pending map[string]pending
|
||||
pendingStore PendingStore
|
||||
intents *IntentStore // G2-2:意图声明表(自然语言路由)
|
||||
}
|
||||
|
||||
// PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。
|
||||
@@ -84,6 +116,7 @@ func New(audit AuditStore, cards CardPort, now func() time.Time) *Bus {
|
||||
return &Bus{
|
||||
reg: agentcommand.NewRegistry(), audit: audit, cards: cards, now: now,
|
||||
handlers: map[string]Handler{}, slash: map[string]string{}, pending: map[string]pending{},
|
||||
intents: NewIntentStore(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,10 +171,19 @@ func (b *Bus) RegisterPluginCommands(overlay []json.RawMessage) error {
|
||||
if err := b.reg.Register(spec, agentcommand.SourcePlugin); err != nil {
|
||||
return fmt.Errorf("cmdbus: register %s: %w", spec.ID, err)
|
||||
}
|
||||
b.bindPluginSlash(spec)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bindPluginSlash EXT-10②:插件命令规格自带的 slash 别名绑定(与内置命令同一张
|
||||
// 别名表——四入口同源:slash 命中即同一命令 id、同一审计信封)。
|
||||
func (b *Bus) bindPluginSlash(spec agentcommand.CommandSpec) {
|
||||
if spec.Slash != "" {
|
||||
b.slash[strings.ToLower(spec.Slash)] = spec.ID
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterPluginCommand 注册进程内插件命令(EXT-1):登记规格并绑定宿主处理器。
|
||||
// 仅登记规格的声明式路径仍走 RegisterPluginCommands(无 handler,分发即 rejected)。
|
||||
func (b *Bus) RegisterPluginCommand(spec agentcommand.CommandSpec, h Handler) error {
|
||||
@@ -151,9 +193,13 @@ func (b *Bus) RegisterPluginCommand(spec agentcommand.CommandSpec, h Handler) er
|
||||
if h != nil {
|
||||
b.handlers[spec.ID] = h
|
||||
}
|
||||
b.bindPluginSlash(spec)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Intents 意图声明表(G2-2:注册面与路由查询共用)。
|
||||
func (b *Bus) Intents() *IntentStore { return b.intents }
|
||||
|
||||
// ResolveSlash `/` 入口解析:返回命令 id 与原始字段(参数由宿主处理器按命令语义解析)。
|
||||
func (b *Bus) ResolveSlash(cmd string) (string, bool) {
|
||||
id, ok := b.slash[strings.ToLower(strings.TrimSpace(cmd))]
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package cmdbus
|
||||
|
||||
// EXT-14:Bus.CommandTools 只投影带 ExposeAsTool 的已启用命令;risk 缺省 low、
|
||||
// description 缺省取 title;未暴露/未启用的命令不出现。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
)
|
||||
|
||||
func newToolBus(t *testing.T) *Bus {
|
||||
t.Helper()
|
||||
return New(nopAudit{}, nil, func() time.Time { return time.Now().UTC() })
|
||||
}
|
||||
|
||||
func TestCommandToolsFiltering(t *testing.T) {
|
||||
b := newToolBus(t)
|
||||
specs := []json.RawMessage{
|
||||
[]byte(`{"id":"rdm.trace","title":"追溯查询","description":"查网","paramsSchema":{"type":"object"},"exposeAsTool":{"risk":"medium"}}`),
|
||||
[]byte(`{"id":"rdm.baseline","title":"基线视图","exposeAsTool":{}}`),
|
||||
[]byte(`{"id":"rdm.hidden","title":"未暴露","description":"不出现"}`),
|
||||
}
|
||||
if err := b.RegisterPluginCommands(specs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tools := b.CommandTools()
|
||||
byID := map[string]CommandTool{}
|
||||
for _, ct := range tools {
|
||||
byID[ct.ID] = ct
|
||||
}
|
||||
if len(byID) != 2 {
|
||||
t.Fatalf("projected = %+v, want 2(rdm.trace + rdm.baseline)", tools)
|
||||
}
|
||||
if ct := byID["rdm.trace"]; ct.Risk != "medium" || ct.Description != "查网" || string(ct.Params) != `{"type":"object"}` {
|
||||
t.Fatalf("rdm.trace = %+v", ct)
|
||||
}
|
||||
if ct := byID["rdm.baseline"]; ct.Risk != "low" || ct.Description != "基线视图" {
|
||||
t.Fatalf("rdm.baseline = %+v(risk 缺省 low、description 缺省 title)", ct)
|
||||
}
|
||||
if _, ok := byID["rdm.hidden"]; ok {
|
||||
t.Fatal("未声明 exposeAsTool 的命令不得投影")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandToolDispatchEnvelope(t *testing.T) {
|
||||
b := newToolBus(t)
|
||||
// 注册一个带处理器的暴露命令,经 Dispatch 分发验证完整回执链路。
|
||||
var spec agentcommand.CommandSpec
|
||||
if err := json.Unmarshal([]byte(`{"id":"rdm.echo","title":"回声","exposeAsTool":{"risk":"low"}}`), &spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.RegisterPluginCommand(spec, func(ctx context.Context, req Request) (any, error) {
|
||||
return map[string]any{"echo": req.Params["v"], "actor": req.Actor, "entrance": req.Entrance}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := b.Dispatch(context.Background(), Request{ID: "rdm.echo", Params: map[string]any{"v": 1},
|
||||
Entrance: "agent", Actor: "bob", Owner: "bob", ConversationID: "cnv-1"})
|
||||
if resp.Receipt.State != "committed" {
|
||||
t.Fatalf("receipt = %+v (%s)", resp.Receipt, resp.Receipt.Reason)
|
||||
}
|
||||
m, _ := resp.Payload.(map[string]any)
|
||||
if m["entrance"] != "agent" || m["actor"] != "bob" {
|
||||
t.Fatalf("payload = %+v(Entrance=agent 原样入审计)", m)
|
||||
}
|
||||
}
|
||||
|
||||
type nopAudit struct{}
|
||||
|
||||
func (nopAudit) AppendAudit(owner string, env agentcommand.AuditEnvelope) error { return nil }
|
||||
@@ -0,0 +1,96 @@
|
||||
// Package cmdbuf 意图路由(G2-2:Intent 声明面与自然语言入口)。
|
||||
// 插件经 extension.Registry.AddIntent 声明「短语 → 命令」映射;宿主把声明汇入
|
||||
// 本表。匹配口径:文本归一(去空白/小写)后做包含匹配,最长短语优先——
|
||||
// 无模型参与的确定性第一档;路由终点仍是命令总线(同审计信封与 HITL 收口)。
|
||||
package cmdbus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Intent 一条意图声明:短语命中后分发到 CommandID(参数模板随声明携带)。
|
||||
type Intent struct {
|
||||
ID string `json:"id"` // 形如 <domain>.<intent>(如 rdm.write-proposal)
|
||||
Phrases []string `json:"phrases"` // 触发短语(归一后包含匹配)
|
||||
Command string `json:"command"` // 目标命令 id(须已注册)
|
||||
Params map[string]any `json:"params,omitempty"` // 参数模板(可被调用方覆盖)
|
||||
Description string `json:"description,omitempty"`
|
||||
Plugin string `json:"plugin"` // 声明方(审计归因)
|
||||
}
|
||||
|
||||
// IntentStore 意图声明表(进程内;插件注册期写入,运行期只读)。
|
||||
type IntentStore struct {
|
||||
mu sync.RWMutex
|
||||
byID map[string]Intent
|
||||
phrases []string // 归一短语(排序后长词优先匹配)
|
||||
}
|
||||
|
||||
// NewIntentStore 建表。
|
||||
func NewIntentStore() *IntentStore {
|
||||
return &IntentStore{byID: map[string]Intent{}}
|
||||
}
|
||||
|
||||
// normalize 意图匹配归一:小写、压缩空白。
|
||||
func normalize(s string) string {
|
||||
return strings.Join(strings.Fields(strings.ToLower(s)), " ")
|
||||
}
|
||||
|
||||
// Declare 登记意图(同 id 覆盖;短语与命令必填——fail-closed)。
|
||||
func (s *IntentStore) Declare(in Intent) error {
|
||||
if in.ID == "" || in.Command == "" || len(in.Phrases) == 0 {
|
||||
return fmt.Errorf("cmdbus: intent %q 声明不完整(id/phrases/command 必填)", in.ID)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.byID[in.ID] = in
|
||||
s.rebuild()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *IntentStore) rebuild() {
|
||||
s.phrases = s.phrases[:0]
|
||||
for _, in := range s.byID {
|
||||
for _, p := range in.Phrases {
|
||||
s.phrases = append(s.phrases, normalize(p))
|
||||
}
|
||||
}
|
||||
// 长短语优先:更具体的意图先命中。
|
||||
sort.Slice(s.phrases, func(i, j int) bool { return len(s.phrases[i]) > len(s.phrases[j]) })
|
||||
}
|
||||
|
||||
// Match 自然语言匹配:返回命中的意图(ok=false=未命中)。
|
||||
func (s *IntentStore) Match(text string) (Intent, bool) {
|
||||
n := normalize(text)
|
||||
if n == "" {
|
||||
return Intent{}, false
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, ph := range s.phrases {
|
||||
if ph != "" && strings.Contains(n, ph) {
|
||||
for _, in := range s.byID {
|
||||
for _, p := range in.Phrases {
|
||||
if normalize(p) == ph {
|
||||
return in, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Intent{}, false
|
||||
}
|
||||
|
||||
// List 全部意图声明(清单面)。
|
||||
func (s *IntentStore) List() []Intent {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]Intent, 0, len(s.byID))
|
||||
for _, in := range s.byID {
|
||||
out = append(out, in)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package cmdbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
agentcommand "agent-command"
|
||||
)
|
||||
|
||||
// EXT-10② 红测:插件命令规格自带 slash 别名 → 四入口同源(slash 命中同一命令
|
||||
// id,经总线分发同审计信封)。
|
||||
func TestPluginSlashAliasBindsAndDispatches(t *testing.T) {
|
||||
bus := New(nopAudit{}, nil, nil)
|
||||
called := ""
|
||||
if err := bus.RegisterPluginCommand(spec(t, `{
|
||||
"id": "rdm.ping", "title": "自检", "slash": "/rdm.ping"
|
||||
}`), func(ctx context.Context, req Request) (any, error) {
|
||||
called = req.ID + "|" + req.Entrance
|
||||
return map[string]any{"ok": true}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, ok := bus.ResolveSlash("/rdm.ping")
|
||||
if !ok || id != "rdm.ping" {
|
||||
t.Fatalf("ResolveSlash = %q ok=%v(插件别名未绑定)", id, ok)
|
||||
}
|
||||
// 大小写与首尾空白归一。
|
||||
if id, ok := bus.ResolveSlash(" /RDM.PING "); !ok || id != "rdm.ping" {
|
||||
t.Fatalf("归一解析失败: %q %v", id, ok)
|
||||
}
|
||||
resp := bus.Dispatch(context.Background(), Request{ID: id, Entrance: "slash", Owner: "alice"})
|
||||
if resp.Receipt.State != "committed" {
|
||||
t.Fatalf("slash 分发 = %+v", resp.Receipt)
|
||||
}
|
||||
if called != "rdm.ping|slash" {
|
||||
t.Fatalf("处理器见到 %q(须同命令 id)", called)
|
||||
}
|
||||
// 同一命令按钮入口分发——同源同信封。
|
||||
if resp2 := bus.Dispatch(context.Background(), Request{ID: "rdm.ping", Entrance: "button", Owner: "alice"}); resp2.Receipt.State != "committed" {
|
||||
t.Fatalf("button 分发 = %+v", resp2.Receipt)
|
||||
}
|
||||
}
|
||||
|
||||
// G2-2 红测:意图声明→归一包含匹配(最长短语优先)→路由到命令(chat 入口)。
|
||||
func TestIntentDeclareMatchRoute(t *testing.T) {
|
||||
bus := New(nopAudit{}, nil, nil)
|
||||
_ = bus.RegisterPluginCommand(spec(t, `{
|
||||
"id": "rdm.draft.compose", "title": "起草"
|
||||
}`), func(ctx context.Context, req Request) (any, error) {
|
||||
return map[string]any{"sectionId": req.Params["sectionId"]}, nil
|
||||
})
|
||||
if err := bus.Intents().Declare(Intent{
|
||||
ID: "rdm.write-proposal", Phrases: []string{"帮我写项目建议书", "写项目建议书"},
|
||||
Command: "rdm.draft.compose", Params: map[string]any{"sectionId": "sec-1"},
|
||||
Plugin: "rdm",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 未命中。
|
||||
if _, ok := bus.Intents().Match("今天天气如何"); ok {
|
||||
t.Fatal("无关文本不得命中")
|
||||
}
|
||||
// 命中(含大小写/空白归一)。
|
||||
in, ok := bus.Intents().Match("请 帮我写项目建议书,尽快")
|
||||
if !ok || in.ID != "rdm.write-proposal" {
|
||||
t.Fatalf("匹配 = %+v ok=%v", in, ok)
|
||||
}
|
||||
// 路由:匹配到的命令经总线 chat 入口分发成功。
|
||||
resp := bus.Dispatch(context.Background(), Request{ID: in.Command, Params: in.Params, Entrance: "chat", Owner: "alice"})
|
||||
if resp.Receipt.State != "committed" {
|
||||
t.Fatalf("路由分发 = %+v", resp.Receipt)
|
||||
}
|
||||
// 声明校验:缺短语拒绝。
|
||||
if err := bus.Intents().Declare(Intent{ID: "x", Command: "y"}); err == nil {
|
||||
t.Fatal("不完整声明须拒绝")
|
||||
}
|
||||
// 最长短语优先:更具体的意图先命中。
|
||||
if err := bus.Intents().Declare(Intent{
|
||||
ID: "rdm.write-competitive", Phrases: []string{"写竞品分析"},
|
||||
Command: "rdm.draft.compose", Plugin: "rdm",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if in, _ := bus.Intents().Match("帮我写项目建议书"); in.ID != "rdm.write-proposal" {
|
||||
t.Fatalf("长短语意图应先命中: %+v", in)
|
||||
}
|
||||
}
|
||||
|
||||
func spec(t *testing.T, raw string) agentcommand.CommandSpec {
|
||||
t.Helper()
|
||||
var s agentcommand.CommandSpec
|
||||
if err := json.Unmarshal([]byte(raw), &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -28,6 +28,9 @@ type Config struct {
|
||||
AllowedHostsRaw string `json:"allowedHosts"`
|
||||
// DisableExecTools 启动级硬关闭执行原语(用户设置 runtime.exec 无法覆盖)。
|
||||
DisableExecTools bool `json:"disableExecTools"`
|
||||
// ExecBackend 执行后端(EXT-16):direct(空 = 现状直启)| sandbox(bwrap
|
||||
// 隔离档)。DisableExecTools 优先级高于后端选择。
|
||||
ExecBackend string `json:"execBackend"`
|
||||
}
|
||||
|
||||
var defaults = Config{
|
||||
@@ -47,6 +50,7 @@ var envKey = map[string]string{
|
||||
"auth": "AGENT_SYSTEM_AUTH",
|
||||
"mock": "AGENT_SYSTEM_MOCK",
|
||||
"instance": "AGENT_SYSTEM_INSTANCE",
|
||||
"execBackend": "AGENT_SYSTEM_EXEC_BACKEND",
|
||||
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
|
||||
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
|
||||
"disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS",
|
||||
@@ -170,6 +174,8 @@ func loadFile(path string) (Config, map[string]bool, error) {
|
||||
dst = &c.AllowedHostsRaw
|
||||
case "disableExecTools":
|
||||
dst = &c.DisableExecTools
|
||||
case "execBackend":
|
||||
dst = &c.ExecBackend
|
||||
default:
|
||||
return Config{}, nil, fmt.Errorf("config file %s: unknown key %q", path, k)
|
||||
}
|
||||
@@ -199,6 +205,8 @@ func stringField(c Config, f string) string {
|
||||
return c.Instance
|
||||
case "allowedHosts":
|
||||
return c.AllowedHostsRaw
|
||||
case "execBackend":
|
||||
return c.ExecBackend
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -247,6 +255,8 @@ func setString(c *Config, f, v string) {
|
||||
c.Instance = v
|
||||
case "allowedHosts":
|
||||
c.AllowedHostsRaw = v
|
||||
case "execBackend":
|
||||
c.ExecBackend = v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +278,7 @@ func (r Resolved) Explain(w io.Writer, file string) {
|
||||
fmt.Fprintln(w, "config file: (none)")
|
||||
}
|
||||
fmt.Fprintln(w, "field value source")
|
||||
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools"} {
|
||||
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools", "execBackend"} {
|
||||
v := stringField(r.Config, f)
|
||||
if isBoolField(f) {
|
||||
v = fmt.Sprintf("%t", boolField(r.Config, f))
|
||||
|
||||
+50
-14
@@ -36,13 +36,20 @@ type CardResolver func(id core.ID) *core.ActivityCard
|
||||
// 对称)。ok=false = 无工件服务/无此卡/内容不可读,部件留占位行。
|
||||
type ArtifactResolver func(id core.ID) (art core.Artifact, content []byte, ok bool)
|
||||
|
||||
// AssembleOptions 是组装的可选行为。零值全部关闭(向后兼容)。
|
||||
type AssembleOptions struct {
|
||||
// ThinkReplay 为真时,历史 assistant 消息的思考部件以 <think> 块原样回传
|
||||
//(交错思考模型如 MiniMax 的官方要求:丢弃思考内容会降低后续轮性能)。
|
||||
ThinkReplay bool
|
||||
}
|
||||
|
||||
// AssembleInput 组装模型输入消息。sys 为提示词库组装出的 system 段(可为多段;
|
||||
// 调用方经 prompts.Service.ResolveSystem 产出),promptObs 为对应可观测条目。
|
||||
// prof.SystemPrompt 非空时作为画像自定义段追加(向后兼容旧画像)。
|
||||
// resolveCard 用于 card 引用部件回填产物卡正文(submit_card;D35:卡片为产物权威
|
||||
// 载体,下一轮模型不失忆),nil 时 card 部件只留占位。resolveArtifact 同理回填
|
||||
// artifact_ref 工件正文(D46 统一;仅 UTF-8 文本,超 limit 截断留痕)。
|
||||
func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history []core.Message, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, sys []agentllm.Message, promptObs []string, resolveCard CardResolver, resolveArtifact ArtifactResolver) ([]agentllm.Message, *Report) {
|
||||
func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history []core.Message, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, sys []agentllm.Message, promptObs []string, resolveCard CardResolver, resolveArtifact ArtifactResolver, opts AssembleOptions) ([]agentllm.Message, *Report) {
|
||||
rep := &Report{}
|
||||
msgs := make([]agentllm.Message, 0, len(sys)+1)
|
||||
msgs = append(msgs, sys...)
|
||||
@@ -84,7 +91,7 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
|
||||
continue
|
||||
}
|
||||
if m.Role == core.RoleAssistant {
|
||||
msgs = append(msgs, renderAssistantTurns(m, resolveCard, limitCard, resolveArtifact, rep, &cardsBackfilled, &artsBackfilled)...)
|
||||
msgs = append(msgs, renderAssistantTurns(m, resolveCard, limitCard, resolveArtifact, rep, &cardsBackfilled, &artsBackfilled, opts)...)
|
||||
continue
|
||||
}
|
||||
content := renderMessage(m, resolveCard, limitCard, resolveArtifact, rep, &cardsBackfilled, &artsBackfilled)
|
||||
@@ -102,6 +109,16 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
|
||||
}
|
||||
|
||||
// 当前轮:文本 + 附件解析结果 + 知识检索注入。
|
||||
msgs = append(msgs, BuildCurrentUser(eff, currentUser, attachments, knowledge, rep))
|
||||
|
||||
fillEstimate(msgs, rep)
|
||||
return msgs, rep
|
||||
}
|
||||
|
||||
// BuildCurrentUser 组装当前轮的用户消息(文本部件 + 附件解析全文 + 知识检索)。
|
||||
// 会话转录为权威上下文时,每轮只构建这一条消息,历史由转录承载——
|
||||
// 此处与整段组装共用同一实现,保证两条路径的当前轮语义一致。
|
||||
func BuildCurrentUser(eff core.EffectiveSettings, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, rep *Report) agentllm.Message {
|
||||
var sb strings.Builder
|
||||
for _, p := range currentUser.Parts {
|
||||
if p.Type == core.PartText {
|
||||
@@ -118,23 +135,26 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
|
||||
text := doc.PlainText()
|
||||
if runes := []rune(text); len(runes) > limit {
|
||||
text = string(runes[:limit]) + "…(附件超长截断)"
|
||||
rep.Truncations = append(rep.Truncations, fmt.Sprintf("附件 %s 超 %d 字符截断(原文 %d 字符)", doc.Meta.Filename, limit, len(runes)))
|
||||
if rep != nil {
|
||||
rep.Truncations = append(rep.Truncations, fmt.Sprintf("附件 %s 超 %d 字符截断(原文 %d 字符)", doc.Meta.Filename, limit, len(runes)))
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&sb, "### %s\n%s\n", doc.Meta.Filename, text)
|
||||
}
|
||||
rep.Items = append(rep.Items, fmt.Sprintf("attachments(%d)", len(attachments)))
|
||||
if rep != nil {
|
||||
rep.Items = append(rep.Items, fmt.Sprintf("attachments(%d)", len(attachments)))
|
||||
}
|
||||
}
|
||||
if len(knowledge) > 0 {
|
||||
sb.WriteString("\n\n[相关知识]\n")
|
||||
for i, h := range knowledge {
|
||||
fmt.Fprintf(&sb, "[%d] %s(来源 %s)\n%s\n", i+1, h.Doc.Title, h.Doc.Source, h.Doc.Chunk)
|
||||
}
|
||||
rep.Items = append(rep.Items, fmt.Sprintf("knowledge(%d)", len(knowledge)))
|
||||
if rep != nil {
|
||||
rep.Items = append(rep.Items, fmt.Sprintf("knowledge(%d)", len(knowledge)))
|
||||
}
|
||||
}
|
||||
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleUser, Content: sb.String()})
|
||||
|
||||
fillEstimate(msgs, rep)
|
||||
return msgs, rep
|
||||
return agentllm.Message{Role: agentllm.RoleUser, Content: sb.String()}
|
||||
}
|
||||
|
||||
// maxToolArgRunes 是回放进模型的单次工具参数上限。超限时截断字符串值,保持合法 JSON。
|
||||
@@ -171,17 +191,24 @@ func renderMessage(m core.Message, resolveCard CardResolver, limitCard int, reso
|
||||
// 前置文本与 tool_calls 组成一条 assistant,每个调用紧跟一条 tool 结果。
|
||||
// 没有结果的调用补一条失败占位,避免模型把中断当成成功。孤立的结果直接跳过。
|
||||
// 被守卫降级、且后面不是工具调用的过程文本不回放。
|
||||
func renderAssistantTurns(m core.Message, resolveCard CardResolver, limitCard int, resolveArtifact ArtifactResolver, rep *Report, cardsBackfilled, artsBackfilled *int) []agentllm.Message {
|
||||
func renderAssistantTurns(m core.Message, resolveCard CardResolver, limitCard int, resolveArtifact ArtifactResolver, rep *Report, cardsBackfilled, artsBackfilled *int, opts AssembleOptions) []agentllm.Message {
|
||||
var out []agentllm.Message
|
||||
var text strings.Builder
|
||||
var think strings.Builder
|
||||
var calls []agentllm.ToolCall
|
||||
results := map[string]core.ToolResultPart{}
|
||||
seq := 0
|
||||
|
||||
flush := func() {
|
||||
content := strings.TrimSpace(text.String())
|
||||
thinkText := strings.TrimSpace(think.String())
|
||||
if opts.ThinkReplay && thinkText != "" {
|
||||
// 交错思考模型要求历史 assistant 原样带 <think>;只进请求,不落盘。
|
||||
content = "<think>\n" + thinkText + "\n</think>\n\n" + content
|
||||
}
|
||||
if content == "" && len(calls) == 0 {
|
||||
text.Reset()
|
||||
think.Reset()
|
||||
results = map[string]core.ToolResultPart{}
|
||||
return
|
||||
}
|
||||
@@ -202,12 +229,18 @@ func renderAssistantTurns(m core.Message, resolveCard CardResolver, limitCard in
|
||||
})
|
||||
}
|
||||
text.Reset()
|
||||
think.Reset()
|
||||
calls = nil
|
||||
results = map[string]core.ToolResultPart{}
|
||||
}
|
||||
|
||||
for i, p := range m.Parts {
|
||||
switch p.Type {
|
||||
case core.PartThinking:
|
||||
if strings.TrimSpace(p.Text) != "" {
|
||||
think.WriteString(p.Text)
|
||||
think.WriteString("\n")
|
||||
}
|
||||
case core.PartText:
|
||||
if demotedProcessText(m.Parts, i) {
|
||||
continue
|
||||
@@ -381,19 +414,22 @@ func renderCardPart(cp *core.CardPart, resolveCard CardResolver, limit int, rep
|
||||
// renderArtifactRef 渲染 artifact_ref 部件:有 resolver 且内容为 UTF-8 文本时
|
||||
// 回填正文(与卡同 limit,超限截断留痕);二进制/不可读/无 resolver 留占位行
|
||||
// (可见即信号,不静默丢弃)。
|
||||
// 占位文案不带「[产出工件 …]」方括号形态:那正是 ClaimGate 用来识别伪造调用
|
||||
// 标记的模式——历史里自己产出这种格式,下一轮模型照抄就会被终轮检查误判为
|
||||
// 「正文里出现了工具调用标记」。
|
||||
func renderArtifactRef(a *core.ArtifactRef, resolve ArtifactResolver, limit int, rep *Report, backfilled *int) string {
|
||||
if a == nil {
|
||||
return ""
|
||||
}
|
||||
if resolve == nil {
|
||||
return fmt.Sprintf("[产出工件 %s]\n", a.Name)
|
||||
return fmt.Sprintf("此前已生成文件:%s。\n", a.Name)
|
||||
}
|
||||
art, content, ok := resolve(a.ArtifactID)
|
||||
if !ok {
|
||||
return fmt.Sprintf("[产出工件 %s]\n", a.Name)
|
||||
return fmt.Sprintf("此前已生成文件:%s。\n", a.Name)
|
||||
}
|
||||
if !utf8.Valid(content) {
|
||||
return fmt.Sprintf("[产出工件 %s(二进制内容,正文见工件查看器)]\n", art.Name)
|
||||
return fmt.Sprintf("此前已生成文件:%s(二进制内容,正文见工件查看器)。\n", art.Name)
|
||||
}
|
||||
*backfilled++
|
||||
text := string(content)
|
||||
@@ -401,7 +437,7 @@ func renderArtifactRef(a *core.ArtifactRef, resolve ArtifactResolver, limit int,
|
||||
text = string(runes[:limit]) + "…(工件超长截断)"
|
||||
rep.Truncations = append(rep.Truncations, fmt.Sprintf("工件 %s 超 %d 字符截断", art.Name, limit))
|
||||
}
|
||||
return fmt.Sprintf("[产出工件 %s]\n%s\n", art.Name, text)
|
||||
return fmt.Sprintf("此前已生成文件 %s,内容如下:\n%s\n", art.Name, text)
|
||||
}
|
||||
|
||||
// extractiveSummary 把窗口外历史压成短摘要(不调用模型)。summarizeBeyond 打开时注入。
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestAssembleInputAttachmentLimit(t *testing.T) {
|
||||
Meta: core.DocMeta{Filename: "纪要.docx"},
|
||||
Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}},
|
||||
}
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("请总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil)
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("请总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
|
||||
last := msgs[len(msgs)-1]
|
||||
if !strings.Contains(last.Content, "纪要.docx") {
|
||||
@@ -42,7 +42,7 @@ func TestAssembleInputAttachmentLimitConfigurable(t *testing.T) {
|
||||
Blocks: []core.DocBlock{{Type: core.BlockPara, Text: strings.Repeat("甲", 120)}},
|
||||
}
|
||||
eff := core.EffectiveSettings{"context": map[string]any{"maxAttachmentChars": float64(100)}}
|
||||
msgs, rep := AssembleInput(prof, eff, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil)
|
||||
msgs, rep := AssembleInput(prof, eff, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
last := msgs[len(msgs)-1]
|
||||
if got := strings.Count(last.Content, "甲"); got != 100 {
|
||||
t.Errorf("injected chars = %d, want 100 (configured limit)", got)
|
||||
@@ -58,7 +58,7 @@ func TestAssembleInputAttachmentNoTruncationUnderLimit(t *testing.T) {
|
||||
Meta: core.DocMeta{Filename: "b.txt"},
|
||||
Blocks: []core.DocBlock{{Type: core.BlockPara, Text: strings.Repeat("乙", 100)}},
|
||||
}
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil)
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
if len(rep.Truncations) != 0 {
|
||||
t.Errorf("under-limit doc should not truncate: %v", rep.Truncations)
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func TestAssembleInputCardBackfill(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("把第二章改短"), nil, nil, nil, nil, resolve, nil)
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("把第二章改短"), nil, nil, nil, nil, resolve, nil, AssembleOptions{})
|
||||
|
||||
var assistantIn string
|
||||
for _, m := range msgs {
|
||||
@@ -128,7 +128,7 @@ func TestAssembleInputCardTruncationAndFallback(t *testing.T) {
|
||||
}},
|
||||
}
|
||||
eff := core.EffectiveSettings{"context": map[string]any{"maxCardChars": float64(100)}}
|
||||
msgs, rep := AssembleInput(prof, eff, hist, fakeUser("继续"), nil, nil, nil, nil, func(id core.ID) *core.ActivityCard { return &card }, nil)
|
||||
msgs, rep := AssembleInput(prof, eff, hist, fakeUser("继续"), nil, nil, nil, nil, func(id core.ID) *core.ActivityCard { return &card }, nil, AssembleOptions{})
|
||||
if strings.Count(msgs[1].Content, "丁") != 100 {
|
||||
t.Errorf("card chars = %d, want 100 (configured limit)", strings.Count(msgs[1].Content, "丁"))
|
||||
}
|
||||
@@ -137,7 +137,7 @@ func TestAssembleInputCardTruncationAndFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
// 无 resolver(收件箱关闭/卡片缺失):占位可见。
|
||||
msgs2, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil)
|
||||
msgs2, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
if !strings.Contains(msgs2[1].Content, "内容见收件箱") {
|
||||
t.Errorf("fallback placeholder missing: %q", msgs2[1].Content)
|
||||
}
|
||||
@@ -159,8 +159,8 @@ func TestAssembleInputArtifactBackfill(t *testing.T) {
|
||||
}
|
||||
return core.Artifact{}, nil, false
|
||||
}
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve)
|
||||
if !strings.Contains(msgs[1].Content, "[产出工件 检索汇总.md]") || !strings.Contains(msgs[1].Content, "关键结论一。") {
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve, AssembleOptions{})
|
||||
if !strings.Contains(msgs[1].Content, "此前已生成文件 检索汇总.md,内容如下:") || !strings.Contains(msgs[1].Content, "关键结论一。") {
|
||||
t.Errorf("artifact body not backfilled: %q", msgs[1].Content)
|
||||
}
|
||||
if !contains(rep.Items, "artifact(1)") {
|
||||
@@ -172,7 +172,7 @@ func TestAssembleInputArtifactBackfill(t *testing.T) {
|
||||
eff := core.EffectiveSettings{"context": map[string]any{"maxCardChars": float64(100)}}
|
||||
msgs2, rep2 := AssembleInput(prof, eff, hist, fakeUser("继续"), nil, nil, nil, nil, nil, func(core.ID) (core.Artifact, []byte, bool) {
|
||||
return art, []byte(long), true
|
||||
})
|
||||
}, AssembleOptions{})
|
||||
if strings.Count(msgs2[1].Content, "乙") != 100 {
|
||||
t.Errorf("artifact chars = %d, want 100", strings.Count(msgs2[1].Content, "乙"))
|
||||
}
|
||||
@@ -183,14 +183,14 @@ func TestAssembleInputArtifactBackfill(t *testing.T) {
|
||||
// 二进制内容:占位不回灌乱码。
|
||||
msgs3, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, func(core.ID) (core.Artifact, []byte, bool) {
|
||||
return art, []byte{0xff, 0xfe, 0x00, 0x01}, true
|
||||
})
|
||||
}, AssembleOptions{})
|
||||
if !strings.Contains(msgs3[1].Content, "二进制内容") || strings.Contains(msgs3[1].Content, "关键结论") {
|
||||
t.Errorf("binary artifact should keep placeholder: %q", msgs3[1].Content)
|
||||
}
|
||||
|
||||
// 无 resolver:占位可见。
|
||||
msgs4, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil)
|
||||
if !strings.Contains(msgs4[1].Content, "[产出工件 检索汇总.md]") {
|
||||
msgs4, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
if !strings.Contains(msgs4[1].Content, "此前已生成文件:检索汇总.md。") {
|
||||
t.Errorf("artifact placeholder missing without resolver: %q", msgs4[1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ func FinishAssembly(msgs []agentllm.Message, rep *Report, tail Tail) ([]agentllm
|
||||
return msgs, rep
|
||||
}
|
||||
|
||||
// TailNotes 导出尾注渲染(转录权威模式下由调用方经 Reminders 逐轮注入)。
|
||||
func TailNotes(tail Tail) []string { return tailNotes(tail) }
|
||||
|
||||
func tailNotes(tail Tail) []string {
|
||||
var notes []string
|
||||
if text := strings.TrimSpace(tail.Instructions); text != "" {
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestVisibleHistoryDropsExcludedAndUnselectedVariants(t *testing.T) {
|
||||
{ID: "a1", Role: core.RoleAssistant, VariantGroup: "g", VariantIndex: 0, Excluded: true, Parts: []core.Part{{Type: core.PartText, Text: "旧"}}},
|
||||
{ID: "a2", Role: core.RoleAssistant, VariantGroup: "g", VariantIndex: 1, Parts: []core.Part{{Type: core.PartText, Text: "新"}}},
|
||||
}
|
||||
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, core.Message{ID: "cur", Role: core.RoleUser}, nil, nil, nil, nil, nil, nil)
|
||||
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, core.Message{ID: "cur", Role: core.RoleUser}, nil, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
blob := ""
|
||||
for _, m := range msgs {
|
||||
blob += m.Content
|
||||
|
||||
@@ -15,7 +15,7 @@ func assistantWith(id string, parts ...core.Part) core.Message {
|
||||
}
|
||||
|
||||
func assembleHistory(hist []core.Message, resolve ArtifactResolver) []agentllm.Message {
|
||||
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve)
|
||||
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve, AssembleOptions{})
|
||||
if len(msgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestSummarizeBeyondInjectsExtract(t *testing.T) {
|
||||
}
|
||||
current := core.Message{ID: "cur", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "现在"}}}
|
||||
prof := core.AgentProfile{Context: core.ContextPolicy{MaxHistoryMessages: 2, SummarizeBeyond: true}}
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, history, current, nil, nil, nil, nil, nil, nil)
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, history, current, nil, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
joined := ""
|
||||
for _, m := range msgs {
|
||||
joined += m.Content
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package ctxasm
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 交错思考模型(MiniMax 等)要求历史 assistant 原样回传 <think>;
|
||||
// 关闭时不回传(默认兼容旧行为)。思考块只进请求,不落库。
|
||||
func TestThinkReplayWrapsAssistantHistory(t *testing.T) {
|
||||
hist := []core.Message{
|
||||
{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "总结"}}},
|
||||
{ID: "m2", Role: core.RoleAssistant, Parts: []core.Part{
|
||||
{Type: core.PartThinking, Text: "先读材料。", Process: true},
|
||||
{Type: core.PartText, Text: "结论是先入库。", Process: true},
|
||||
{Type: core.PartToolCall, ToolCall: &core.ToolCallPart{ID: "c1", Name: "run_python", Arguments: "{}"}},
|
||||
{Type: core.PartToolResult, ToolResult: &core.ToolResultPart{CallID: "c1", Status: "ok", Summary: "done"}},
|
||||
{Type: core.PartText, Text: "已完成。"},
|
||||
}},
|
||||
}
|
||||
on, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil, AssembleOptions{ThinkReplay: true})
|
||||
var sawThink int
|
||||
for _, m := range on {
|
||||
if m.Role == agentllm.RoleAssistant && strings.HasPrefix(m.Content, "<think>") {
|
||||
sawThink++
|
||||
if !strings.Contains(m.Content, "</think>") || !strings.Contains(m.Content, "先读材料。") {
|
||||
t.Fatalf("think block malformed: %q", m.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
if sawThink == 0 {
|
||||
t.Fatalf("ThinkReplay=on should inject <think> into assistant history: %+v", on)
|
||||
}
|
||||
off, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
for _, m := range off {
|
||||
if m.Role == agentllm.RoleAssistant && strings.Contains(m.Content, "<think>") {
|
||||
t.Fatalf("ThinkReplay=off must not inject think blocks: %q", m.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ package plugins
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ description: 生成文档、表格、演示稿的工作流、样式决策与技
|
||||
|
||||
1. **定样式**,按下面的优先级只取第一个命中项:本轮用户的明确指令(例如改成公文、换成品牌色)→ 本轮上传的模板(.docx/.dotx/.pptx/.potx/.xlsx/.xltx)→ 本轮要求仿制的参考文档(`from housestyle.extract import extract`,抽取字体、字号、色板、页边距,得到自定义主题)→ 本会话已经确定的样式 → 设置中的默认模板 → 设置中的 deliverables.style(modern / formal / minimal)和强调色。未指定时用环境段给出的默认样式。
|
||||
2. **规划版面**:先定读者、结构和每一节的内容,再写脚本。不要边生成边补空节。
|
||||
3. **生成**:`import housestyle`,用 docx_kit / pptx_kit / xlsx_kit 的构件写文件。文件落在会话工作区。模板文件和参考原件在 `inputs/`(环境段列出相对路径)。
|
||||
4. **自检**:`python -m housestyle.lint <文件>`。输出为 JSON;存在 severity=error 时必须修好再继续,不要带着 error 登记。然后用 `housestyle.summarize` 回读标题、表格和页数,核对与规划一致。
|
||||
3. **生成(Word 交付物优先走源码化)**:先写 Markdown 源文件(`write_file 方案.md`,约定见 `python3 -m housestyle.build` 的 docstring:`#`标题/`##`-`####` 三级、`-`/`1.` 列表、`| |` 表格、`>` 引用、`!!! 标签 文本` 强调块、首行 `<!-- housestyle: theme=modern -->` 可选主题),再 `python3 -m housestyle.build 方案.md` 确定性渲染成 docx——build 自动跑 lint 并回读结构(写入即诊断),有 error 就改 .md 源的对应段落再重建,不要整段重写。修改已有交付物 = `edit_file` 改 .md 源 + 重新 build。Excel/PPT/图片等其他类型仍用 `run_python` + 对应 kit。文件都在会话工作区;模板和参考原件在 `inputs/`(环境段列出相对路径)。
|
||||
4. **自检**:`python3 -m housestyle.lint <文件>`。输出为 JSON;存在 severity=error 时必须修好再继续,不要带着 error 登记。然后用 `python3 -m housestyle.outline_cli <文件>` 回读标题、表格和页数,核对与规划一致。
|
||||
5. **登记**:`register_artifact`(path 传相对路径)。登记后对话里出现可预览的卡片。
|
||||
6. **收尾**:回复只写结论摘要,并说明这次用的样式(主题、是否模板、是否仿制)以及用户还能怎么调。不要在回复里复述全文,也不要只口头说文件名。
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: base.method
|
||||
layer: base
|
||||
version: 1
|
||||
title: 工作方法
|
||||
description: 把任务做完的方法论:计划、换路、查 API、坚持到完成、回到材料(常驻基座)
|
||||
---
|
||||
按以下方法工作:
|
||||
|
||||
- **先计划再动手**:预计三步以上、或要产出文件的任务,开始前用 update_plan 列出步骤;每完成一步就更新状态,同一时间只有一步进行中。计划是给自己看的进度账,不是给用户看的排场。
|
||||
- **一个方法失败两次就换路**:同样的调用连续失败两次,停下来想第三条路(换工具、换参数结构、换实现方式),不要原样重试第三次。
|
||||
- **写脚本前先查 API**:调用不熟悉的库/模块前,先用 grep 查它的函数签名(`inspect.signature`/`dir`)或读 SKILL.md 的示例,确认参数形态后再写完整脚本。猜 API 是失败的最大来源。
|
||||
- **编辑粒度**:修改文件时先想清楚要改哪几处,用 multi_edit 或大块 edit 一次改完,不要一行一个 edit_file 连发十几次;改完立即验证(build/运行),验证通过再继续下一处。
|
||||
- **坚持到完成**:任务没做完不要停。收尾前检查最后一段:如果还是计划、承诺或"接下来我将",就先把承诺的事做掉。只有用户输入才能解决的阻塞才允许停下来问。
|
||||
- **结论回到材料**:写进交付物的数字、百分比、日期、人名必须能在本轮材料(附件/工具输出/会议原文)里找到出处;找不到的改成不写,或明确标注"推测"。材料里的日期基准与你理解的不一致时,以材料为准或向用户确认。
|
||||
- **终稿如实**:最后的回复先给结论,再列交付物与要点;没做完、没验证的部分如实说明,不用模糊措辞掩盖。
|
||||
@@ -7,7 +7,7 @@ description: 何时用工具、如何并行调用、如何处理工具结果
|
||||
---
|
||||
使用工具时遵循:
|
||||
|
||||
- 优先专用工具而非通用手段:检索知识用检索工具、写产物用工件工具、查时间用时间工具;能用结构化工具完成的,不绕道自由文本。
|
||||
- 优先专用工具而非通用手段:读文件用 read_file(行号分页)、找内容用 grep、找文件用 glob、改文件用 edit_file/multi_edit(须先读过、old_string 唯一);会话工作区内的文件操作一律用这些工具,不用 run_shell 的 cat/sed 代替。检索知识用检索工具、查时间用时间工具;能用结构化工具完成的,不绕道自由文本。
|
||||
- 相互独立的调用并行发起;有依赖的调用按依赖顺序串行。
|
||||
- 调用前确认参数完整、类型正确;不确定参数含义时先查阅工具描述,不猜测字段。
|
||||
- 工具结果是事实来源:基于结果作答并如实转述;结果为空或报错时说明情况,不假装拿到了数据。
|
||||
|
||||
@@ -12,4 +12,4 @@ description: 动态注入的当前环境事实(占位符随运行填充)
|
||||
- 本轮附带文档:{{attachments}}。文档全文已注入上下文(超长时会标注截断)。
|
||||
- 交付物默认样式:{{deliverableStyle}}。本轮的明确指令、上传的模板或参考文档优先于该默认值。
|
||||
- 技能目录:{{skillsDir}}。环境变量 AGENT_SKILLS_DIR 指向这里;Python 已能 `import housestyle`。
|
||||
- 本轮原件(工作区相对路径):{{inputs}}。模板和参考文档用这些路径,不要向用户索取已经上传的文件。
|
||||
- 会话工作区文件(相对路径,跨轮有效):{{inputs}}。原件旁的 <原名>.md 是解析出的纯文本,需要原始信息时优先读它(不要只依赖你上一轮生成的产物,也不要向用户索取已经上传的文件)。模板和参考文档用这些路径。
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
id: tool.edit_file
|
||||
layer: tool
|
||||
version: 1
|
||||
title: edit_file 工具描述
|
||||
description: 工作区文件精确编辑的模型侧说明文案
|
||||
---
|
||||
对工作区文件做唯一匹配替换:old 必须与文件内容逐字一致(含缩进)且在文件中唯一,不唯一时带上更多上下文行。改前必须先 read 过该文件;文件在读取后被外部修改会拒绝,重读即可。多处相关修改用 multi_edit 一次提交,不要一行一个调用连发。
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
id: tool.glob
|
||||
layer: tool
|
||||
version: 1
|
||||
title: glob 工具描述
|
||||
description: 文件名模式匹配的模型侧说明文案
|
||||
---
|
||||
按通配模式找文件路径(** 跨目录),用于"这类文件有哪些/在哪个目录"。找内容用 grep,不要用 glob 后逐个 cat。
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
id: tool.grep
|
||||
layer: tool
|
||||
version: 1
|
||||
title: grep 工具描述
|
||||
description: 工作区内容检索的模型侧说明文案
|
||||
---
|
||||
在工作区文件内容里做正则检索(支持 include 按文件名过滤、ignore_case)。用于定位章节、函数、数据点;配合 read_file 的 offset 直达相关段落,替代整文件通读。
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
id: tool.read_file
|
||||
layer: tool
|
||||
version: 1
|
||||
title: read_file 工具描述
|
||||
description: 工作区文件读取的模型侧说明文案
|
||||
---
|
||||
读取会话工作区内的文本文件,输出带行号(1-based)。用法:先不带 offset/limit 读前 2000 行看全貌;文件大时用 offset/limit 分页,不要反复从头读。编辑前必须先读过目标文件。长输出会被截断并提示分页方式。
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
id: tool.run_python
|
||||
layer: tool
|
||||
version: 1
|
||||
title: run_python 工具描述
|
||||
description: Python 执行原语的模型侧说明文案
|
||||
---
|
||||
执行完整 Python 脚本(python3,工作目录=会话工作区)。适用:数据处理、生成 Excel/PPT/图片(housestyle kit)、批量操作。Word 交付物优先写 .md 源后用 run_shell 跑 `python3 -m housestyle.build`,不要在脚本里手工拼 docx。报错先读退出码与 traceback 定位行号再改,同一错误不要原样重跑。生成带排式的文件(xlsx/pptx)先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md` 的构件表,不确定签名先 `inspect.signature`。
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
id: tool.run_shell
|
||||
layer: tool
|
||||
version: 1
|
||||
title: run_shell 工具描述
|
||||
description: bash 执行原语的模型侧说明文案
|
||||
---
|
||||
在会话工作区内执行 bash 脚本(环境最小化:PATH/HOME/LANG;AGENT_SKILLS_DIR 与 PYTHONPATH 已注入)。适用:housestyle build/lint、pip install --user、文件批操作。读文件用 read_file、找内容用 grep——不要用 cat/sed/grep 命令行代替专用工具。同一命令失败两次换方法。
|
||||
@@ -0,0 +1,33 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var barePythonRe = regexp.MustCompile(`(?:^|[^0-9A-Za-z_.-])python -(?:m|c)\b`)
|
||||
|
||||
// 提示词库里的命令必须能在执行环境跑起来:解释器只有 python3。
|
||||
// 实测模型照提示词执行 `python -m housestyle.lint` 得到 exit 127。
|
||||
func TestLibraryUsesRunnablePythonCommands(t *testing.T) {
|
||||
names, err := fs.Glob(libraryFS, "library/*.md")
|
||||
if err != nil {
|
||||
t.Fatalf("glob: %v", err)
|
||||
}
|
||||
var problems []string
|
||||
for _, name := range names {
|
||||
raw, err := libraryFS.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
body := string(raw)
|
||||
for _, loc := range barePythonRe.FindAllStringIndex(body, -1) {
|
||||
problems = append(problems, name+": bare `python -` command near: "+strings.TrimSpace(body[loc[0]:min(loc[0]+60, len(body))]))
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
t.Fatalf("unrunnable python commands in prompt library:\n%s", strings.Join(problems, "\n"))
|
||||
}
|
||||
}
|
||||
@@ -73,9 +73,9 @@ func TestResolveSystemDefaultBase(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 默认:base 七块(identity/behavior/caution/style/tools/deliverables/quality)+ env 一块,无技能注入。
|
||||
if len(blocks) != 8 {
|
||||
t.Errorf("blocks = %d, want 8 (7 base + env)", len(blocks))
|
||||
// 默认:base 八块(identity/behavior/method/caution/style/tools/deliverables/quality)+ env 一块,无技能注入。
|
||||
if len(blocks) != 9 {
|
||||
t.Errorf("blocks = %d, want 9 (8 base + env)", len(blocks))
|
||||
}
|
||||
var envBody string
|
||||
for _, b := range blocks {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package quality
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// P5/裁定 4:拦截路径只认硬事实(lint 实际运行且零 error);
|
||||
// 关键词维度(栏目式标题/可执行性等)不得再把合格交付物拦下来。
|
||||
func TestHardFactsIgnoresKeywordDimensions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// 评分整体会因「结论先行 20/可读性 25」判负的文本——硬事实口径必须放行
|
||||
//(无 lint 或 lint 通过时)。
|
||||
path := filepath.Join(dir, "报告.md")
|
||||
if err := os.WriteFile(path, []byte("# 概述\n\n正文内容。\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
full := ScoreFile(path)
|
||||
if full.Passed {
|
||||
t.Fatalf("keyword dimensions should still fail the full score (fixture sanity): %+v", full)
|
||||
}
|
||||
hard := HardFacts("报告.md", []byte("# 概述\n\n正文内容。\n"))
|
||||
if !hard.Passed {
|
||||
t.Fatalf("hard facts must ignore keyword dimensions: %+v", hard.Violations)
|
||||
}
|
||||
if len(hard.Lint) > 0 {
|
||||
for _, issue := range hard.Lint {
|
||||
if issue.Severity == "error" {
|
||||
t.Fatalf("lint errors must still block: %+v", issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lint 不可运行(技能根已配置)仍是硬事实失败——fail-visible 保持。
|
||||
func TestHardFactsKeepsLintUnavailableBlocking(t *testing.T) {
|
||||
SetLintEnvRoot(t.TempDir())
|
||||
t.Cleanup(func() { SetLintEnvRoot("") })
|
||||
hard := HardFacts("报告.md", []byte("# 概述\n正文。\n"))
|
||||
if hard.Passed {
|
||||
t.Fatalf("lint-not-run must stay blocking: %+v", hard)
|
||||
}
|
||||
found := false
|
||||
for _, v := range hard.Violations {
|
||||
if strings.Contains(v, "自检未运行") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected 自检未运行, got %v", hard.Violations)
|
||||
}
|
||||
}
|
||||
+194
-12
@@ -26,12 +26,15 @@ type Dimension struct {
|
||||
}
|
||||
|
||||
// Report 是一次评分。Passed 要求总分不低于 70,且没有任何一维低于 40。
|
||||
// LintRan 为假表示 housestyle.lint 没有实际执行(环境不可用);技能根已注入
|
||||
// (SetLintEnvRoot)时这本身计为一条 Violations,不再把「没跑」静默当「没问题」。
|
||||
type Report struct {
|
||||
Passed bool `json:"passed"`
|
||||
Score int `json:"score"`
|
||||
Dimensions []Dimension `json:"dimensions"`
|
||||
Violations []string `json:"violations,omitempty"`
|
||||
Lint []LintIssue `json:"lint,omitempty"`
|
||||
LintRan bool `json:"lintRan,omitempty"`
|
||||
}
|
||||
|
||||
// LintIssue 是 housestyle.lint 的一条结果。lint 不存在时留空,不因此判失败。
|
||||
@@ -41,6 +44,17 @@ type LintIssue struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// lintEnvRoot 由组合根在启动时注入(<data>/skills)。零值保持旧语义:
|
||||
// 继承进程环境,lint 不可用时静默跳过(测试与无技能部署)。
|
||||
var lintEnvRoot string
|
||||
|
||||
// SetLintEnvRoot 注入技能根:lint 子进程获得与 run_python 一致的
|
||||
// AGENT_SKILLS_DIR / PYTHONPATH,否则 housestyle 在后端进程里 import 失败。
|
||||
// 非空根同时把「lint 必须可运行」设为强约束:跑不起来即 Violations(fail-visible)。
|
||||
func SetLintEnvRoot(skillsRoot string) {
|
||||
lintEnvRoot = skillsRoot
|
||||
}
|
||||
|
||||
// Judge 用模型给同一份正文打分。返回错误时调用方回落启发式。
|
||||
type Judge func(ctx context.Context, text string) (Report, error)
|
||||
|
||||
@@ -82,20 +96,27 @@ func ScoreText(text string) Report {
|
||||
|
||||
// ScoreFile 读取文件正文再打分。docx 取段落文本;其他文件按 UTF-8 文本读。
|
||||
// 能跑 housestyle.lint 时,error 级问题会使 Passed 变为 false。
|
||||
// lint 无法执行时:技能根已注入(SetLintEnvRoot)→ 记 Violations「自检未运行」
|
||||
// 并判不通过(fail-visible);未注入(测试/无技能部署)→ 保持旧的静默跳过。
|
||||
func ScoreFile(path string) Report {
|
||||
text, err := readDeliverable(path)
|
||||
if err != nil {
|
||||
return Report{Passed: false, Violations: []string{"无法读取交付物:" + err.Error()}}
|
||||
}
|
||||
report := ScoreText(text)
|
||||
issues := lintFile(path)
|
||||
issues, lintErr := lintFile(path)
|
||||
report.Lint = issues
|
||||
report.LintRan = lintErr == nil
|
||||
for _, issue := range issues {
|
||||
if issue.Severity == "error" {
|
||||
report.Passed = false
|
||||
report.Violations = append(report.Violations, "版式 "+issue.Rule+":"+issue.Message)
|
||||
}
|
||||
}
|
||||
if lintErr != nil && lintEnvRoot != "" {
|
||||
report.Passed = false
|
||||
report.Violations = append(report.Violations, "自检未运行(housestyle.lint 不可用:"+lintErr.Error()+")")
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
@@ -176,11 +197,22 @@ func scoreActionable(text string) Dimension {
|
||||
return Dimension{ID: "可执行性", Score: 35, Note: "没有可交给人的行动", Evidence: snippet(text, 40)}
|
||||
}
|
||||
|
||||
// scoreReadable 只检查真正的标题行(# 开头;docx 路径已把标题样式标成 #)。
|
||||
// 此前对每一行做「栏目名词 + 短行」判断,表格单元格(如「项目概述撰写」)
|
||||
// 会被当成标题误报,终轮检查因此对合格文档连续判负(实测死循环源之一)。
|
||||
// 命中时 Note 带行号,模型能定位到要改的那一行。
|
||||
func scoreReadable(text string) Dimension {
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(strings.TrimLeft(line, "#"))
|
||||
if bannedTitle(line) && utf8.RuneCountInString(line) <= 12 {
|
||||
return Dimension{ID: "可读性", Score: 25, Note: "使用了栏目式标题", Evidence: line}
|
||||
for i, line := range strings.Split(text, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
title := strings.TrimSpace(strings.TrimLeft(trimmed, "#"))
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
if bannedTitle(title) && utf8.RuneCountInString(title) <= 12 {
|
||||
return Dimension{ID: "可读性", Score: 25, Note: fmt.Sprintf("第 %d 行标题是栏目名,不是判断,改成一句结论", i+1), Evidence: title}
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "。。") {
|
||||
@@ -251,6 +283,9 @@ func readDeliverable(path string) (string, error) {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// readDocxText 抽取 docx 纯文本,并把标题样式(Title / HeadingN)的段落标成
|
||||
// Markdown # 前缀——评分卡据此区分「标题行」与「正文/表格单元格」,也让
|
||||
// scoreLead 等维度拿到真实的标题信息。
|
||||
func readDocxText(path string) (string, error) {
|
||||
zr, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
@@ -274,6 +309,22 @@ func readDocxText(path string) (string, error) {
|
||||
defer rc.Close()
|
||||
decoder := xml.NewDecoder(rc)
|
||||
var b strings.Builder
|
||||
var para strings.Builder
|
||||
style := ""
|
||||
inPara := false
|
||||
flush := func() {
|
||||
text := strings.TrimSpace(para.String())
|
||||
if text != "" {
|
||||
if lvl := headingLevel(style); lvl > 0 {
|
||||
b.WriteString(strings.Repeat("#", min(lvl, 6)))
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
b.WriteString(text)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
para.Reset()
|
||||
style = ""
|
||||
}
|
||||
for {
|
||||
tok, err := decoder.Token()
|
||||
if err != nil {
|
||||
@@ -281,23 +332,70 @@ func readDocxText(path string) (string, error) {
|
||||
}
|
||||
switch el := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if el.Name.Local == "p" && b.Len() > 0 {
|
||||
b.WriteByte('\n')
|
||||
switch el.Name.Local {
|
||||
case "p":
|
||||
inPara = true
|
||||
para.Reset()
|
||||
style = ""
|
||||
case "pStyle":
|
||||
if inPara {
|
||||
for _, a := range el.Attr {
|
||||
if a.Name.Local == "val" {
|
||||
style = a.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case xml.EndElement:
|
||||
if el.Name.Local == "p" {
|
||||
inPara = false
|
||||
flush()
|
||||
}
|
||||
case xml.CharData:
|
||||
b.Write(bytes.TrimSpace(el))
|
||||
if inPara {
|
||||
para.Write(bytes.TrimSpace(el))
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
flush()
|
||||
return strings.TrimRight(b.String(), "\n"), nil
|
||||
}
|
||||
|
||||
// headingLevel 把 docx styleId 映射为标题层级:Heading1..9 → 1..9,
|
||||
// Title → 1,Subtitle → 2;其余(含表格单元格正文)返回 0。
|
||||
func headingLevel(style string) int {
|
||||
switch {
|
||||
case style == "Title":
|
||||
return 1
|
||||
case style == "Subtitle":
|
||||
return 2
|
||||
case strings.HasPrefix(style, "Heading"):
|
||||
n := 0
|
||||
for _, c := range style[len("Heading"):] {
|
||||
if c < '0' || c > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
if n >= 1 && n <= 9 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var lintJSON = regexp.MustCompile(`(?s)\[.*\]`)
|
||||
|
||||
func lintFile(path string) []LintIssue {
|
||||
// lintFile 运行 housestyle.lint。返回 (issues, nil) 表示 lint 实际执行;
|
||||
// 非 nil err 表示没有跑起来(解释器缺失、模块不可导入等),调用方决定是否
|
||||
// 按「自检未运行」暴露。env 与 run_python 的 execEnv 同源语义:继承最小
|
||||
// 环境并附加 AGENT_SKILLS_DIR / PYTHONPATH,否则后端进程里 import 不了 housestyle。
|
||||
func lintFile(path string) ([]LintIssue, error) {
|
||||
cmd := exec.Command("python3", "-m", "housestyle.lint", path)
|
||||
cmd.Env = lintCommandEnv()
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil && !bytes.Contains(out, []byte("\"rule\"")) {
|
||||
return nil
|
||||
return nil, fmt.Errorf("%s", firstLine(string(out)))
|
||||
}
|
||||
raw := out
|
||||
if loc := lintJSON.Find(out); loc != nil {
|
||||
@@ -305,7 +403,91 @@ func lintFile(path string) []LintIssue {
|
||||
}
|
||||
var issues []LintIssue
|
||||
if json.Unmarshal(raw, &issues) != nil {
|
||||
return nil, fmt.Errorf("lint 输出无法解析:%s", firstLine(string(out)))
|
||||
}
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
// lintCommandEnv 继承最小环境(PATH/HOME/LANG 等,与 execEnv 的继承面一致)。
|
||||
// 技能根注入后附加 AGENT_SKILLS_DIR 与 PYTHONPATH=<root>/house-style;进程已有
|
||||
// PYTHONPATH 时前置合并(测试与二次包装场景不丢自己的路径)。
|
||||
// lintEnvRoot 为空时返回 nil(不设 cmd.Env,完整继承,保持旧行为)。
|
||||
func lintCommandEnv() []string {
|
||||
if lintEnvRoot == "" {
|
||||
return nil
|
||||
}
|
||||
return issues
|
||||
var out []string
|
||||
var existingPath string
|
||||
for _, kv := range os.Environ() {
|
||||
if strings.HasPrefix(kv, "PYTHONPATH=") {
|
||||
existingPath = strings.TrimPrefix(kv, "PYTHONPATH=")
|
||||
continue
|
||||
}
|
||||
for _, keep := range []string{"PATH=", "HOME=", "LANG=", "LC_", "TZ=", "TERM="} {
|
||||
if strings.HasPrefix(kv, keep) {
|
||||
out = append(out, kv)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
pp := filepath.Join(lintEnvRoot, "house-style")
|
||||
if existingPath != "" {
|
||||
pp = pp + string(os.PathListSeparator) + existingPath
|
||||
}
|
||||
out = append(out,
|
||||
"AGENT_SKILLS_DIR="+lintEnvRoot,
|
||||
"PYTHONPATH="+pp,
|
||||
)
|
||||
return out
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if utf8.RuneCountInString(s) > 160 {
|
||||
s = string([]rune(s)[:160]) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// HardFacts 只评可机械判定的硬事实:lint 实际运行且零 error。
|
||||
// 关键词维度(ScoreText 六维)从拦截路径移出(裁定 4/P5):它们描述的是
|
||||
// 写作质量偏好,实测会把正常中文章节标题判成栏目名、把没有"未指明"字样的
|
||||
// 行动表判成不可执行,造成终轮死循环;评分整体仍由 ScoreBytes 供评测侧使用。
|
||||
func HardFacts(name string, content []byte) Report {
|
||||
report := ScoreBytes(name, content)
|
||||
report.Passed = true
|
||||
report.Violations = nil
|
||||
for _, issue := range report.Lint {
|
||||
if issue.Severity == "error" {
|
||||
report.Passed = false
|
||||
report.Violations = append(report.Violations, "版式 "+issue.Rule+":"+issue.Message)
|
||||
}
|
||||
}
|
||||
if !report.LintRan && lintEnvRoot != "" {
|
||||
report.Passed = false
|
||||
report.Violations = append(report.Violations, "自检未运行(housestyle.lint 不可用)")
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
// DeliverableText 供事实核查抽取工件文本(docx 与 ScoreBytes 同源口径;
|
||||
// 二进制返回空)。导出给 runtime 包使用。
|
||||
func DeliverableText(name string, content []byte) string {
|
||||
dir, err := os.MkdirTemp("", "factcheck")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, content, 0o644); err != nil {
|
||||
return ""
|
||||
}
|
||||
text, err := readDeliverable(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package quality
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLintCommandEnvCarriesSkillsRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
SetLintEnvRoot(root)
|
||||
t.Cleanup(func() { SetLintEnvRoot("") })
|
||||
env := lintCommandEnv()
|
||||
var sawSkills, sawPath bool
|
||||
for _, kv := range env {
|
||||
if kv == "AGENT_SKILLS_DIR="+root {
|
||||
sawSkills = true
|
||||
}
|
||||
if kv == "PYTHONPATH="+filepath.Join(root, "house-style") {
|
||||
sawPath = true
|
||||
}
|
||||
}
|
||||
if !sawSkills || !sawPath {
|
||||
t.Fatalf("lint env missing skills root entries: %v", env)
|
||||
}
|
||||
}
|
||||
|
||||
// 技能根已注入但 housestyle 不可导入时,评分必须显式报「自检未运行」,
|
||||
// 不能把没跑当成没问题(实测:后端进程无 PYTHONPATH,lint 静默失效)。
|
||||
func TestLintUnavailableFailsVisibleWhenSkillsRootSet(t *testing.T) {
|
||||
SetLintEnvRoot(t.TempDir())
|
||||
t.Cleanup(func() { SetLintEnvRoot("") })
|
||||
good := "# 上传文档应先判断相关性再入库\n\n## 先校验,再生成\n- 说话人1:上传后第一步是判断是否与项目相关。\n一句话:没有记忆库,后面的生成和追溯都没有依据。\n\n## 下一步\n- 事项:写出第一版需求。负责人:陈威。期限:周五前;说不清的写未指明。\n"
|
||||
rep := ScoreBytes("note.md", []byte(good))
|
||||
if rep.Passed {
|
||||
t.Fatalf("lint unavailable must fail visibly when skills root is set: %+v", rep)
|
||||
}
|
||||
found := false
|
||||
for _, v := range rep.Violations {
|
||||
if strings.Contains(v, "自检未运行") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected 自检未运行 violation, got: %v", rep.Violations)
|
||||
}
|
||||
if rep.LintRan {
|
||||
t.Fatalf("LintRan should be false when lint could not run")
|
||||
}
|
||||
}
|
||||
|
||||
// docx 标题样式要被标成 # 前缀;表格单元格/正文不参与「栏目式标题」判定
|
||||
// (实测误报:单元格「项目概述撰写」被当成标题,检查连续判负)。
|
||||
func TestDocxHeadingMarkedAndTableCellNotFlagged(t *testing.T) {
|
||||
docx := buildDocx(t, []paraSpec{
|
||||
{style: "Heading1", text: "概述"},
|
||||
{style: "", text: "项目概述撰写"},
|
||||
{style: "Heading2", text: "先把需求写清楚再开发"},
|
||||
})
|
||||
text, err := readDocxText(docx)
|
||||
if err != nil {
|
||||
t.Fatalf("readDocxText: %v", err)
|
||||
}
|
||||
if !strings.Contains(text, "# 概述") || !strings.Contains(text, "## 先把需求写清楚再开发") {
|
||||
t.Fatalf("headings not marked with #: %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "项目概述撰写") {
|
||||
t.Fatalf("body paragraph missing: %q", text)
|
||||
}
|
||||
|
||||
// 标题是栏目名 → 仍要被判低分。
|
||||
withBadHeading := ScoreText(text)
|
||||
if withBadHeading.Passed {
|
||||
t.Fatalf("banned heading should fail: %+v", withBadHeading)
|
||||
}
|
||||
|
||||
// 同一个「概述」字样只出现在非标题行时,不应触发栏目式标题。
|
||||
cellOnly := buildDocx(t, []paraSpec{
|
||||
{style: "Heading1", text: "先把需求写清楚再开发"},
|
||||
{style: "", text: "项目概述撰写"},
|
||||
{style: "", text: "一句话:先写需求。负责人:陈威。期限:周五前。"},
|
||||
})
|
||||
cellText, err := readDocxText(cellOnly)
|
||||
if err != nil {
|
||||
t.Fatalf("readDocxText: %v", err)
|
||||
}
|
||||
for _, d := range ScoreText(cellText).Dimensions {
|
||||
if d.ID == "可读性" && d.Score < 60 {
|
||||
t.Fatalf("cell text must not be flagged as column heading: %+v", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type paraSpec struct {
|
||||
style string
|
||||
text string
|
||||
}
|
||||
|
||||
// buildDocx 构造最小 docx:只含 word/document.xml(readDocxText 的唯一输入)。
|
||||
func buildDocx(t *testing.T, paras []paraSpec) string {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>`)
|
||||
for _, p := range paras {
|
||||
b.WriteString(`<w:p>`)
|
||||
if p.style != "" {
|
||||
b.WriteString(`<w:pPr><w:pStyle w:val="` + p.style + `"/></w:pPr>`)
|
||||
}
|
||||
b.WriteString(`<w:r><w:t>` + p.text + `</w:t></w:r></w:p>`)
|
||||
}
|
||||
b.WriteString(`</w:body></w:document>`)
|
||||
path := filepath.Join(t.TempDir(), "test.docx")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create docx: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
w := zip.NewWriter(f)
|
||||
entry, err := w.Create("word/document.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("zip entry: %v", err)
|
||||
}
|
||||
if _, err := entry.Write([]byte(b.String())); err != nil {
|
||||
t.Fatalf("zip write: %v", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("zip close: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package route
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/intentgate"
|
||||
"agent-system-backend/internal/intentgate"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package runmgr
|
||||
|
||||
// EXT-14 红测(提案 2026-10-09-ext-g1-b1-b3 §2):带 exposeAsTool 的命令投影为
|
||||
// 回合内工具;模型调用经命令总分发(Entrance="agent"、同审计信封);不带字段的
|
||||
// 命令不出现;高危命令在对话内弹内联确认卡(HITL 收口不变)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/tools"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// fakeBusSource EXT-14 测试桩:命令清单 + 分发记录(说话用 runmgr 视图类型,
|
||||
// 与生产代码同口径——runmgr 不依赖 cmdbus)。
|
||||
type fakeBusSource struct {
|
||||
cmds []CommandTool
|
||||
lastReq CommandRequest
|
||||
resp CommandResponse
|
||||
}
|
||||
|
||||
func (f *fakeBusSource) CommandTools() []CommandTool { return f.cmds }
|
||||
|
||||
func (f *fakeBusSource) DispatchCommand(ctx context.Context, req CommandRequest) CommandResponse {
|
||||
f.lastReq = req
|
||||
return f.resp
|
||||
}
|
||||
|
||||
func TestCommandToolsProjected(t *testing.T) {
|
||||
rc0, _ := newExecEnv(t)
|
||||
m := rc0.mgr
|
||||
src := &fakeBusSource{cmds: []CommandTool{
|
||||
{ID: "rdm.trace", Description: "查追溯网", Params: []byte(`{"type":"object"}`), Risk: "low"},
|
||||
}}
|
||||
m.SetCommandSource(src)
|
||||
rc := rc0
|
||||
tools := rc.Tools()
|
||||
found := map[string]bool{}
|
||||
for _, d := range tools {
|
||||
found[d.Name] = true
|
||||
}
|
||||
if !found["rdm.trace"] {
|
||||
t.Fatalf("exposeAsTool 命令未投影为工具: %+v", tools)
|
||||
}
|
||||
if found["rdm.hidden"] {
|
||||
t.Fatal("命令源之外的工具不得出现")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandToolDispatchesThroughBus(t *testing.T) {
|
||||
rc0, _ := newExecEnv(t)
|
||||
m := rc0.mgr
|
||||
src := &fakeBusSource{
|
||||
cmds: []CommandTool{{ID: "rdm.trace", Params: []byte(`{"type":"object"}`), Risk: "low"}},
|
||||
resp: CommandResponse{State: "committed"},
|
||||
}
|
||||
m.SetCommandSource(src)
|
||||
rc := rc0
|
||||
|
||||
res := rc.ExecuteTool(core.ToolCallRequest{CallID: "c1", Name: "rdm.trace", Arguments: []byte(`{"q":"x"}`)})
|
||||
if res.Status != "ok" {
|
||||
t.Fatalf("dispatch result = %s %s", res.Status, res.Output)
|
||||
}
|
||||
req := src.lastReq
|
||||
if req.ID != "rdm.trace" || req.Entrance != "agent" || req.Actor != rc.run.Owner || req.Owner != rc.run.Owner || req.ConversationID != rc.run.ConversationID {
|
||||
t.Fatalf("bus request = %+v(须 Entrance=agent、Actor/Owner=会话 owner、带会话)", req)
|
||||
}
|
||||
if req.Params["q"] != "x" {
|
||||
t.Fatalf("params = %+v", req.Params)
|
||||
}
|
||||
// 未注册命令源时:调用按注册表口径报错(现状),不 panic。
|
||||
reg2 := tools.NewRegistry()
|
||||
if err := reg2.RegisterBuiltin(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m2 := New(nil, nil, reg2, nil, nil, nil, nil, nil, nil)
|
||||
rc2 := &runCtx{mgr: m2, run: core.Run{Owner: "bob"}, prof: core.AgentProfile{Tools: core.ToolPolicy{Allow: []string{"*"}}}}
|
||||
if res := rc2.ExecuteTool(core.ToolCallRequest{CallID: "c2", Name: "rdm.trace", Arguments: []byte(`{}`)}); res.Status != "error" {
|
||||
t.Fatalf("no source: %s(须 registry miss 报错)", res.Status)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
@@ -54,6 +55,60 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil
|
||||
return rels, nil
|
||||
}
|
||||
|
||||
// PersistInputTexts 把附件解析出的纯文本写成 inputs/<原名>.md,与原件并列。
|
||||
// 后续轮次的上下文里原件只剩占位时,模型仍能读到可检索的原文文本——
|
||||
// 「根据纪要制定方案」不再只能依赖上一轮产物(实测后续轮 0 次读原件)。
|
||||
// 原本就是文本类(md/txt/csv)的跳过;解析文本过短(<200 字)视为解析失败跳过。
|
||||
func PersistInputTexts(root, owner, conversation string, attachments []core.ParsedDocument) {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return
|
||||
}
|
||||
dir := filepath.Join(root, owner, conversation, "inputs")
|
||||
for _, doc := range attachments {
|
||||
name := strings.TrimSpace(doc.Meta.Filename)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".md", ".txt", ".csv", ".json":
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(doc.PlainText())
|
||||
if utf8.RuneCountInString(text) < 200 {
|
||||
continue
|
||||
}
|
||||
stem := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
out := filepath.Join(dir, stem+".md")
|
||||
if _, err := os.Stat(out); err == nil {
|
||||
continue // 已有同名解析文本(同一附件重发)不覆盖
|
||||
}
|
||||
if err := os.WriteFile(out, []byte(text), 0o644); err == nil {
|
||||
_ = out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ListWorkspaceInputs 列出会话工作区 inputs/ 目录的相对路径(原样返回给环境段)。
|
||||
// 覆盖「本轮上传」视角:跨轮可见所有原件与解析文本。目录不存在时回落 staging 列表。
|
||||
func ListWorkspaceInputs(root, owner, conversation string, staged []string) []string {
|
||||
dir := filepath.Join(root, owner, conversation, "inputs")
|
||||
ents, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return staged
|
||||
}
|
||||
var out []string
|
||||
for _, e := range ents {
|
||||
if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
out = append(out, "inputs/"+e.Name())
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return staged
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Manager) stageInputs(conv core.Conversation, eff core.EffectiveSettings, files []WorkspaceFile) ([]string, error) {
|
||||
var templateName string
|
||||
var template []byte
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 附件解析文本要落 inputs/<原名>.md 且跨轮可见:后续轮上下文里原件只剩占位时,
|
||||
// 模型仍能读到原文(实测「根据纪要制定方案」轮 0 次读原件,只能编细节)。
|
||||
func TestPersistInputTextsAndListWorkspaceInputs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if _, err := StageWorkspaceInputs(root, "alice", "conv1", []WorkspaceFile{
|
||||
{Name: "会议.docx", Content: []byte("bin")},
|
||||
}, "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
long := strings.Repeat("会议结论先入库。", 40) // >200 字
|
||||
docs := []core.ParsedDocument{{
|
||||
Meta: core.DocMeta{Filename: "会议.docx"},
|
||||
Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}},
|
||||
}}
|
||||
PersistInputTexts(root, "alice", "conv1", docs)
|
||||
|
||||
extracted := filepath.Join(root, "alice", "conv1", "inputs", "会议.md")
|
||||
b, err := os.ReadFile(extracted)
|
||||
if err != nil {
|
||||
t.Fatalf("extracted text not written: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(b), "会议结论先入库") {
|
||||
t.Fatalf("extracted text wrong: %q", string(b)[:50])
|
||||
}
|
||||
|
||||
// 短文本(解析失败/过短)与已是文本类的原件不写。
|
||||
short := []core.ParsedDocument{{Meta: core.DocMeta{Filename: "短.docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "太短"}}}}
|
||||
PersistInputTexts(root, "alice", "conv1", short)
|
||||
textKind := []core.ParsedDocument{{Meta: core.DocMeta{Filename: "笔记.md"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}}}}
|
||||
PersistInputTexts(root, "alice", "conv1", textKind)
|
||||
if _, err := os.Stat(filepath.Join(root, "alice", "conv1", "inputs", "短.md")); err == nil {
|
||||
t.Fatal("short parse should not be persisted")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "alice", "conv1", "inputs", "笔记.md.md")); err == nil {
|
||||
t.Fatal("text-kind originals should not get a duplicate extract")
|
||||
}
|
||||
|
||||
// 跨轮列出:包含原件 + 解析文本,而不是只有本轮 staging。
|
||||
got := ListWorkspaceInputs(root, "alice", "conv1", []string{"inputs/会议.docx"})
|
||||
joined := strings.Join(got, ",")
|
||||
if !strings.Contains(joined, "inputs/会议.docx") || !strings.Contains(joined, "inputs/会议.md") {
|
||||
t.Fatalf("listing should include original + extracted: %v", got)
|
||||
}
|
||||
// 目录缺失时回落 staging 列表。
|
||||
fallback := ListWorkspaceInputs(root, "alice", "nope", []string{"inputs/only.txt"})
|
||||
if len(fallback) != 1 || fallback[0] != "inputs/only.txt" {
|
||||
t.Fatalf("fallback listing broken: %v", fallback)
|
||||
}
|
||||
// root 未配置:全部 no-op。
|
||||
PersistInputTexts("", "alice", "conv1", docs)
|
||||
if got := ListWorkspaceInputs("", "alice", "conv1", []string{"inputs/a.txt"}); got[0] != "inputs/a.txt" {
|
||||
t.Fatalf("unconfigured root should pass through staged: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/memory"
|
||||
core "agent-system-core"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// storeMemory 把 agent-native 的记忆端口接到 memory_entries 表(N3 WP5)。
|
||||
|
||||
@@ -88,4 +88,3 @@ func TestPermissionAskRoutesToHITL(t *testing.T) {
|
||||
t.Fatalf("no verdict = %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+118
-13
@@ -5,6 +5,7 @@ package runmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -52,9 +53,14 @@ type Manager struct {
|
||||
cards *cardsvc.Service // 可选意图闸门(nil = 未启用,行为回退)
|
||||
// OnUsage 在 run 结束时报告 token(日预算记账;nil = 不记)。
|
||||
OnUsage func(owner string, tokens int)
|
||||
events *eventBuffer // 事件批处理(写放大治理;seq 权威仍在 store)
|
||||
retriever core.Retriever // 可选混合检索(nil = store 全文)
|
||||
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
|
||||
events *eventBuffer // 事件批处理(写放大治理;seq 权威仍在 store)
|
||||
retriever core.Retriever // 可选混合检索(nil = store 全文)
|
||||
// sharedKnowledgeAllow EXT-13:回合内检索的共享授权谓词(请求 owner × 知识所属
|
||||
// projectID);nil = 未注册(现状:仅本人两路,不触碰共享通道)。
|
||||
sharedKnowledgeAllow func(owner string, projectID core.ID) bool
|
||||
// commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。
|
||||
commands CommandSource
|
||||
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
|
||||
// workspaceRoot fs 预留能力(Hidden 工具族)的沙箱根;空=未配置,工具执行时报错。
|
||||
workspaceRoot string
|
||||
|
||||
@@ -92,15 +98,15 @@ type bgNotice struct {
|
||||
var ErrConversationBusy = fmt.Errorf("runmgr: conversation already has an in-flight run")
|
||||
|
||||
type runHandle struct {
|
||||
cancel context.CancelFunc
|
||||
hitlCh chan hitlAnswer
|
||||
assistant []core.Part
|
||||
ctxObs *core.ContextObservation
|
||||
submittedCards []core.ID // submit_card 产物卡(finishRun 回填锚点)
|
||||
streamCardID core.ID // 活跃流式产物卡(card_begin/card_commit 三段式;D44/D45)
|
||||
started time.Time
|
||||
promptTokens int
|
||||
completionTokens int
|
||||
cancel context.CancelFunc
|
||||
hitlCh chan hitlAnswer
|
||||
assistant []core.Part
|
||||
ctxObs *core.ContextObservation
|
||||
submittedCards []core.ID // submit_card 产物卡(finishRun 回填锚点)
|
||||
streamCardID core.ID // 活跃流式产物卡(card_begin/card_commit 三段式;D44/D45)
|
||||
started time.Time
|
||||
promptTokens int
|
||||
completionTokens int
|
||||
lastPromptTokens int
|
||||
lastCompletionTokens int
|
||||
cacheReadTokens int
|
||||
@@ -153,6 +159,15 @@ func (m *Manager) EmitCardEvent(conversationID, runID string, typ core.StreamEve
|
||||
// SetRetriever 注入混合检索(rag 管线;nil = store 全文检索)。
|
||||
func (m *Manager) SetRetriever(r core.Retriever) { m.retriever = r }
|
||||
|
||||
// SetSharedKnowledgeAllow 注入回合内共享检索谓词(EXT-13;组合根在装载扩展后
|
||||
// 以同一 EXT-8 适配器槽接线)。nil = 现状。
|
||||
func (m *Manager) SetSharedKnowledgeAllow(fn func(owner string, projectID core.ID) bool) {
|
||||
m.sharedKnowledgeAllow = fn
|
||||
}
|
||||
|
||||
// SetCommandSource 注入命令即工具源(EXT-14;组合根以命令总线适配)。nil = 现状。
|
||||
func (m *Manager) SetCommandSource(src CommandSource) { m.commands = src }
|
||||
|
||||
// SetIndexer 注入检索索引入口(分块+全文+可选向量;nil = 整文档单块)。
|
||||
func (m *Manager) SetIndexer(i core.DocumentIndexer) { m.indexer = i }
|
||||
|
||||
@@ -160,6 +175,46 @@ func (m *Manager) SetIndexer(i core.DocumentIndexer) { m.indexer = i }
|
||||
// 实际会话沙箱为 <root>/<owner>/<conversationID>/;空值=未配置(工具执行报错)。
|
||||
func (m *Manager) SetWorkspaceRoot(dir string) { m.workspaceRoot = dir }
|
||||
|
||||
// CommandSource EXT-14:命令总线暴露给回合执行的两个面——工具投影清单与
|
||||
// 经命令总的分发(同审计信封与 HITL 收口)。runmgr 只认本包视图类型,
|
||||
// 组合根以 cmdbus.Bus 适配注入(app.busAdapter 负责转换),runmgr 不依赖总线包。
|
||||
type CommandSource interface {
|
||||
CommandTools() []CommandTool
|
||||
DispatchCommand(ctx context.Context, req CommandRequest) CommandResponse
|
||||
}
|
||||
|
||||
// CommandTool EXT-14 工具投影视图:组合根从命令总线的投影清单转换注入。
|
||||
type CommandTool struct {
|
||||
ID string
|
||||
Description string
|
||||
Params json.RawMessage
|
||||
Risk string
|
||||
}
|
||||
|
||||
// CommandRequest 一次命令工具分发请求(runmgr 固定以 Entrance=agent 发起,
|
||||
// Actor/Owner 绑定会话 owner、带会话上下文)。
|
||||
type CommandRequest struct {
|
||||
ID string
|
||||
Params map[string]any
|
||||
Entrance string
|
||||
Actor string
|
||||
Owner string
|
||||
ConversationID string
|
||||
}
|
||||
|
||||
// CommandResponse 分发结果视图:回执状态/原因 + 负载 + 是否挂起确认卡。
|
||||
type CommandResponse struct {
|
||||
State string
|
||||
Reason string
|
||||
Payload any
|
||||
Card bool
|
||||
}
|
||||
|
||||
// sharedRetriever EXT-13:检索器具备共享通道(rag.Pipeline.RetrieveShared 同形)。
|
||||
type sharedRetriever interface {
|
||||
RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error)
|
||||
}
|
||||
|
||||
// ---- runCtx 实现(runtime.RunCtx)----
|
||||
|
||||
type runCtx struct {
|
||||
@@ -353,6 +408,20 @@ func (c *runCtx) PermissionAsk(ctx context.Context, call agentllm.ToolCall) nrun
|
||||
|
||||
func (c *runCtx) Tools() []core.ToolDesc {
|
||||
list := tools.FilterExecDisabled(c.mgr.registry.List())
|
||||
if c.mgr.commands != nil {
|
||||
// EXT-14:exposeAsTool 命令投影为工具(去重:注册表同名工具优先)。
|
||||
seen := make(map[string]bool, len(list))
|
||||
for _, t := range list {
|
||||
seen[t.Name] = true
|
||||
}
|
||||
for _, ct := range c.mgr.commands.CommandTools() {
|
||||
if seen[ct.ID] {
|
||||
continue
|
||||
}
|
||||
list = append(list, core.ToolDesc{Name: ct.ID, Description: ct.Description,
|
||||
Parameters: ct.Params, Source: core.SourcePlugin, Risk: ct.Risk})
|
||||
}
|
||||
}
|
||||
out := make([]core.ToolDesc, 0, len(list))
|
||||
fs := c.eff.GetBool("runtime.fsTools", false)
|
||||
code := c.eff.GetBool("runtime.codeOrchestration", false)
|
||||
@@ -731,6 +800,13 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
q.ProjectID = c.run.ProjectID
|
||||
}
|
||||
if c.mgr.retriever != nil {
|
||||
if c.mgr.sharedKnowledgeAllow != nil {
|
||||
if rs, ok := c.mgr.retriever.(sharedRetriever); ok {
|
||||
return rs.RetrieveShared(c.ctx, c.run.Owner, q, func(docOwner, projectID core.ID) bool {
|
||||
return c.mgr.sharedKnowledgeAllow(string(c.run.Owner), projectID)
|
||||
})
|
||||
}
|
||||
}
|
||||
return c.mgr.retriever.Retrieve(c.ctx, c.run.Owner, q)
|
||||
}
|
||||
return c.mgr.st.SearchKnowledge(c.run.Owner, q)
|
||||
@@ -764,6 +840,33 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"}
|
||||
}
|
||||
}
|
||||
// EXT-14:命令即工具——命中命令投影时经命令总分发(Entrance=agent、
|
||||
// Actor/Owner=会话 owner、带会话上下文),同审计信封与高危确认卡收口。
|
||||
if c.mgr.commands != nil {
|
||||
for _, ct := range c.mgr.commands.CommandTools() {
|
||||
if ct.ID != call.Name {
|
||||
continue
|
||||
}
|
||||
var params map[string]any
|
||||
if len(call.Arguments) > 0 {
|
||||
_ = json.Unmarshal(call.Arguments, ¶ms)
|
||||
}
|
||||
resp := c.mgr.commands.DispatchCommand(c.ctx, CommandRequest{
|
||||
ID: call.Name, Params: params, Entrance: "agent",
|
||||
Actor: c.run.Owner, Owner: c.run.Owner, ConversationID: c.run.ConversationID,
|
||||
})
|
||||
out, _ := json.Marshal(resp.Payload)
|
||||
if resp.Card && resp.State == "accepted" {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "ok",
|
||||
Output: "已创建确认卡,等待用户确认后执行:" + resp.Reason}
|
||||
}
|
||||
if resp.State != "committed" && resp.State != "accepted" {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error",
|
||||
Output: "命令 " + call.Name + " " + resp.State + ": " + resp.Reason}
|
||||
}
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "ok", Output: string(out)}
|
||||
}
|
||||
}
|
||||
tc := &toolCtx{rc: c, callID: call.CallID}
|
||||
res := c.mgr.registry.Execute(tc, call)
|
||||
// 工具产物回填(交付物卡片根因修复):注册表工具执行期经 rc.WriteArtifact
|
||||
@@ -1039,6 +1142,8 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa
|
||||
|
||||
runner := selectConversationRunner(m, eff, launch)
|
||||
inputPaths, _ := m.stageInputs(conv, eff, inputs)
|
||||
PersistInputTexts(m.workspaceRoot, conv.Owner, conv.ID, attachments)
|
||||
inputPaths = ListWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, inputPaths)
|
||||
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch, files: filestate.New(), permissionMode: conversationMode(m, run.Owner, run.ConversationID)}
|
||||
go m.execute(rc, runner)
|
||||
return run, nil
|
||||
@@ -1297,7 +1402,7 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
|
||||
res := rec.Recognize(context.Background(), trimmed)
|
||||
dec := route.Decide(res, route.Env{})
|
||||
if dec.StartFound {
|
||||
pivot, pivotReason = true, "intent:" + res.ID
|
||||
pivot, pivotReason = true, "intent:"+res.ID
|
||||
}
|
||||
} else if branch, topID := m.gate.Match(context.Background(), trimmed); branch == "matched" {
|
||||
pivot, pivotReason = true, "intent:"+topID
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package runmgr
|
||||
|
||||
// EXT-13 红测(提案 2026-10-09-ext-g1-b1-b3 §1):回合内检索(knowledge_search
|
||||
// 工具与 @knowledge 注入的同一入口 runCtx.Retrieve)在适配器放行后吃到共享命中;
|
||||
// 未放行/未注册谓词时保持本人两路、零泄露。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
type sharedFakeRetriever struct {
|
||||
own []core.RetrievalHit
|
||||
shared []core.RetrievalHit
|
||||
gotAlt bool
|
||||
}
|
||||
|
||||
func (f *sharedFakeRetriever) Retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
return f.own, nil
|
||||
}
|
||||
|
||||
func (f *sharedFakeRetriever) RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) {
|
||||
f.gotAlt = true
|
||||
out := append([]core.RetrievalHit{}, f.own...) // 对齐 rag.Pipeline:本人两路如常
|
||||
for _, h := range f.shared {
|
||||
if allow(h.Doc.Owner, h.Doc.ProjectID) {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestRunContextRetrieveShared(t *testing.T) {
|
||||
m := New(nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
fr := &sharedFakeRetriever{
|
||||
own: []core.RetrievalHit{{Doc: core.KnowledgeDoc{Ref: "own-1", Chunk: "本人命中"}}},
|
||||
shared: []core.RetrievalHit{{Doc: core.KnowledgeDoc{Ref: "sh-1", Chunk: "共享命中", Owner: "alice", ProjectID: "p-1"}}},
|
||||
}
|
||||
m.SetRetriever(fr)
|
||||
rc := &runCtx{mgr: m, run: core.Run{Owner: "bob"}}
|
||||
|
||||
// 未注册共享谓词:本人两路如常,不触碰共享通道。
|
||||
hits, err := rc.Retrieve(core.RetrievalQuery{Text: "命中"})
|
||||
if err != nil || len(hits) != 1 || hits[0].Doc.Ref != "own-1" {
|
||||
t.Fatalf("no predicate: hits=%+v err=%v", hits, err)
|
||||
}
|
||||
if fr.gotAlt {
|
||||
t.Fatal("no predicate: must not call RetrieveShared(未注册=现状)")
|
||||
}
|
||||
|
||||
// 注册谓词(放行 bob×p-1)→ 本人 + 共享。
|
||||
m.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
|
||||
return owner == "bob" && string(projectID) == "p-1"
|
||||
})
|
||||
fr.gotAlt = false
|
||||
hits, err = rc.Retrieve(core.RetrievalQuery{Text: "命中"})
|
||||
if err != nil || len(hits) != 2 {
|
||||
t.Fatalf("with predicate: hits=%+v err=%v, want own+shared", hits, err)
|
||||
}
|
||||
if !fr.gotAlt {
|
||||
t.Fatal("with predicate: RetrieveShared not consulted(EXT-13)")
|
||||
}
|
||||
|
||||
// 谓词拒绝 → 只剩本人。
|
||||
m.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool { return false })
|
||||
hits, err = rc.Retrieve(core.RetrievalQuery{Text: "命中"})
|
||||
if err != nil || len(hits) != 1 || hits[0].Doc.Ref != "own-1" {
|
||||
t.Fatalf("denied: hits=%+v err=%v, want own only", hits, err)
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,6 @@ func TestSpawnBackgroundRecordsDetach(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// N3 WP1:done.json 语义分流——优雅停机写的 interrupted 不挡续跑(一次且仅一次);
|
||||
// completed 终态挡续跑。
|
||||
func TestResumeAfterGracefulInterruptMarker(t *testing.T) {
|
||||
|
||||
@@ -158,7 +158,7 @@ func (m *Manager) ResumeInterruptedChildren() {
|
||||
continue
|
||||
}
|
||||
marker, _ := json.Marshal(map[string]string{
|
||||
"newRun": newID,
|
||||
"newRun": newID,
|
||||
"resumedAt": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
if err := os.WriteFile(filepath.Join(dir, "resumed.json"), marker, 0o644); err != nil {
|
||||
|
||||
@@ -101,7 +101,7 @@ func (st *directRun) composeUserPrompt(draft, notice string) string {
|
||||
}
|
||||
b.WriteString("\n交付物:\n")
|
||||
names := 0
|
||||
for _, ref := range st.deliverables {
|
||||
for _, ref := range st.latestDeliverables() {
|
||||
if ref == nil || strings.TrimSpace(ref.Name) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 窗口按 token 配置并换算为字符阈值;此前 32000 字符硬默认会让长材料任务
|
||||
// 在第一个请求就触发清理(被清内容只留「见会话记录」,模型无法取回)。
|
||||
func TestContextWindowScalesFromTokens(t *testing.T) {
|
||||
eff := core.EffectiveSettings{"models": map[string]any{"chat": map[string]any{
|
||||
"contextWindow": float64(128000),
|
||||
"outputReserve": float64(16384),
|
||||
"contextBuffer": float64(4096),
|
||||
}}}
|
||||
w, r, b := contextWindow(eff)
|
||||
if w != int(128000*3.35) {
|
||||
t.Fatalf("window = %d, want %d", w, int(128000*3.35))
|
||||
}
|
||||
if r <= 0 || b <= 0 {
|
||||
t.Fatalf("reserve/buffer must be positive chars: %d %d", r, b)
|
||||
}
|
||||
// 旧部署未配置时的默认:不再是 32k 字符。
|
||||
def, _, _ := contextWindow(core.EffectiveSettings{})
|
||||
if def < 100000*3 {
|
||||
t.Fatalf("default window too small: %d", def)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkReplayEnabled(t *testing.T) {
|
||||
eff := func(v string) core.EffectiveSettings {
|
||||
return core.EffectiveSettings{"context": map[string]any{"thinkReplay": v}}
|
||||
}
|
||||
if !thinkReplayEnabled(eff(""), "MiniMax-M2.7-highspeed") {
|
||||
t.Fatal("auto should enable for minimax models")
|
||||
}
|
||||
if thinkReplayEnabled(eff(""), "gpt-5.1") {
|
||||
t.Fatal("auto should stay off for other models")
|
||||
}
|
||||
if !thinkReplayEnabled(eff("on"), "gpt-5.1") {
|
||||
t.Fatal("explicit on must win")
|
||||
}
|
||||
if thinkReplayEnabled(eff("off"), "MiniMax-M2.7-highspeed") {
|
||||
t.Fatal("explicit off must win")
|
||||
}
|
||||
if !strings.Contains("checkpoint", "checkpoint") {
|
||||
t.Fatal("sanity")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 修订(同名再登记)后,终轮检查与交付清单只看最新版本;
|
||||
// 无名工件互不合并。
|
||||
func TestLatestDeliverablesKeepsLastPerName(t *testing.T) {
|
||||
st := &directRun{deliverables: []*core.ArtifactRef{
|
||||
{ArtifactID: "art_aaa1", Name: "方案.docx"},
|
||||
{ArtifactID: "art_bbb2", Name: "纪要.docx"},
|
||||
{ArtifactID: "art_ccc3", Name: "方案.docx"},
|
||||
{ArtifactID: "art_ddd4", Name: ""},
|
||||
{ArtifactID: "art_eee5", Name: "tool-run_shell-call_x1.txt", Kind: "data"},
|
||||
}}
|
||||
got := st.latestDeliverables()
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("want 3 deliverables, got %d: %+v", len(got), got)
|
||||
}
|
||||
for _, ref := range got {
|
||||
if isToolDumpArtifact(ref) {
|
||||
t.Fatalf("tool result dumps are not deliverables: %+v", ref)
|
||||
}
|
||||
}
|
||||
if got[0].ArtifactID != "art_ccc3" || got[0].Name != "方案.docx" {
|
||||
t.Fatalf("方案.docx should resolve to latest: %+v", got[0])
|
||||
}
|
||||
if got[1].ArtifactID != "art_bbb2" {
|
||||
t.Fatalf("纪要.docx should be untouched: %+v", got[1])
|
||||
}
|
||||
if got[2].ArtifactID != "art_ddd4" {
|
||||
t.Fatalf("unnamed artifact must be kept as-is: %+v", got[2])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
core "agent-system-core"
|
||||
|
||||
"agent-system-backend/internal/quality"
|
||||
)
|
||||
|
||||
// P5:事实一致性核查。从交付物正文抽取可机械定位的「硬事实 token」——
|
||||
// 百分比、日期、大额数字——并回溯本轮材料(附件解析文本 + 工具输出证据)。
|
||||
// 找不到出处的 token 以「未核实」反馈给模型修一轮;模型可以选择删除、
|
||||
// 改成材料里的数值、或明确标注为推测/目标值。人名/称谓不做机械比对
|
||||
// (材料里多为"说话人N",机械比对必然误报)。
|
||||
var (
|
||||
factPctRe = regexp.MustCompile(`\d+(?:\.\d+)?%`)
|
||||
factDateRe = regexp.MustCompile(`\d{1,2}月\d{1,2}日|\d{4}年|\d{4}-\d{1,2}-\d{1,2}`)
|
||||
factBigNumRe = regexp.MustCompile(`\d[\d,]{4,}`)
|
||||
)
|
||||
|
||||
// ExtractFacts 抽取交付物文本里的硬事实 token(去重保序)。
|
||||
func ExtractFacts(text string) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
add := func(tok string) {
|
||||
tok = strings.TrimSpace(tok)
|
||||
if tok == "" || seen[tok] {
|
||||
return
|
||||
}
|
||||
seen[tok] = true
|
||||
out = append(out, tok)
|
||||
}
|
||||
for _, m := range factPctRe.FindAllString(text, -1) {
|
||||
add(m)
|
||||
}
|
||||
for _, m := range factDateRe.FindAllString(text, -1) {
|
||||
add(m)
|
||||
}
|
||||
for _, m := range factBigNumRe.FindAllString(text, -1) {
|
||||
add(m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UnverifiedFacts 返回材料里找不到出处的 token(白名单:豁免的通用口径词)。
|
||||
func UnverifiedFacts(artifactText, materialText string, exempt map[string]bool) []string {
|
||||
if strings.TrimSpace(artifactText) == "" || strings.TrimSpace(materialText) == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, tok := range ExtractFacts(artifactText) {
|
||||
if exempt[tok] {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(materialText, tok) && !strings.Contains(materialText, stripPunct(tok)) {
|
||||
out = append(out, tok)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stripPunct(s string) string {
|
||||
return strings.NewReplacer(",", "", ",", "", " ", "").Replace(s)
|
||||
}
|
||||
|
||||
// factCheckNotice 把未核实 token 组成终轮反馈(给模型的一轮修正机会)。
|
||||
func factCheckNotice(tokens []string) string {
|
||||
if len(tokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("交付物里以下数字/日期在本次材料中找不到出处:")
|
||||
for i, tok := range tokens {
|
||||
if i >= 12 {
|
||||
b.WriteString(" 等")
|
||||
break
|
||||
}
|
||||
if i > 0 {
|
||||
b.WriteString("、")
|
||||
}
|
||||
b.WriteString(tok)
|
||||
}
|
||||
b.WriteString("。逐条二选一:改成材料里能找到的数值,或删除/明确标注为推测或目标值(如“目标 85%”)。不要保留无法回溯的裸数字。")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// materialCorpus 汇总本轮可见材料:附件解析文本 + 工具证据文本。
|
||||
// 证据用完整 verifyText(尾部 600 字)而非 160 字摘要——足够定位数字。
|
||||
func (st *directRun) materialCorpus() string {
|
||||
var b strings.Builder
|
||||
for _, doc := range st.rc.Attachments() {
|
||||
b.WriteString(doc.PlainText())
|
||||
b.WriteString("\n")
|
||||
}
|
||||
for _, ev := range st.evidence {
|
||||
b.WriteString(ev.VerifyText)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// deliverableText 拼接本轮交付工件的文本内容(UTF-8 文本直接读;docx
|
||||
// 由 quality 的解析口径承担——这里只收集,不重复解析)。
|
||||
func (st *directRun) deliverableText(refs []*artifactTextPair) string {
|
||||
var b strings.Builder
|
||||
for _, pair := range refs {
|
||||
b.WriteString(pair.text)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
type artifactTextPair struct {
|
||||
id string
|
||||
name string
|
||||
text string
|
||||
}
|
||||
|
||||
// collectDeliverableText 经工件端口读取文本类交付物内容
|
||||
// (docx 用 quality 同源的抽取口径;二进制工件跳过)。
|
||||
func (st *directRun) collectDeliverableText(refs []*core.ArtifactRef) []artifactTextPair {
|
||||
lookup := artifactLookupOf(st.rc)
|
||||
var out []artifactTextPair
|
||||
for _, ref := range refs {
|
||||
if ref == nil || ref.ArtifactID == "" {
|
||||
continue
|
||||
}
|
||||
name, content, ok := lookup(string(ref.ArtifactID))
|
||||
if !ok || len(content) == 0 {
|
||||
continue
|
||||
}
|
||||
text := deliverablePlainText(name, content)
|
||||
if text != "" {
|
||||
out = append(out, artifactTextPair{id: string(ref.ArtifactID), name: name, text: text})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// deliverablePlainText 用 quality 的同源口径抽取工件文本。
|
||||
func deliverablePlainText(name string, content []byte) string {
|
||||
return quality.DeliverableText(name, content)
|
||||
}
|
||||
|
||||
// concatArtifactText 拼接工件文本对。
|
||||
func concatArtifactText(pairs []artifactTextPair) string {
|
||||
var b strings.Builder
|
||||
for _, p := range pairs {
|
||||
b.WriteString(p.text)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// factExemptions 豁免通用口径词(非事实性数字)。
|
||||
func factExemptions() map[string]bool {
|
||||
return map[string]bool{
|
||||
"100%": true, "0%": true, // 常见修辞(100% 确定)与零值
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 事实核查:交付物里材料找不到出处的百分比被抽出并组成反馈;找到的不报。
|
||||
func TestUnverifiedFactsAndNotice(t *testing.T) {
|
||||
material := "目标是准确率 85%以上。截止 10月12日。团队共 12 人。"
|
||||
artifact := "准确率达到 95%,截止 10月20日,覆盖 3,500 用户,目标 85%。"
|
||||
got := UnverifiedFacts(artifact, material, factExemptions())
|
||||
joined := strings.Join(got, ",")
|
||||
for _, want := range []string{"95%", "10月20日", "3,500"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("missing %q in %v", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(joined, "85%") {
|
||||
t.Fatalf("sourced figure must not be reported: %v", got)
|
||||
}
|
||||
notice := factCheckNotice(got)
|
||||
if !strings.Contains(notice, "95%") || !strings.Contains(notice, "改成材料里") {
|
||||
t.Fatalf("notice malformed: %q", notice)
|
||||
}
|
||||
if UnverifiedFacts("", material, nil) != nil || UnverifiedFacts(artifact, "", nil) != nil {
|
||||
t.Fatal("empty sides short-circuit")
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:终轮对带未核实数字的交付物给一轮修正;修正后(重新登记干净版本)放行。
|
||||
func TestFactCheckContinuationOnce(t *testing.T) {
|
||||
rc := &fakeRunCtx{
|
||||
ctx: context.Background(),
|
||||
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
|
||||
verify: true,
|
||||
execArtifact: &core.Artifact{ID: "art_0123456789ab", Name: "报告.md"},
|
||||
execArtifactContent: []byte("# 报告\n\n准确率 95%。"),
|
||||
}
|
||||
// 第一轮:登记带编造数字的工件 -> 事实核查续跑一次;第二轮:干净版本通过。
|
||||
turns1 := padTurns(
|
||||
textDeltaTurn("", toolCall("make_report", `{"content":"准确率 95%"}`)),
|
||||
textDeltaTurn("写好了。"),
|
||||
)
|
||||
turns2 := padTurns(
|
||||
textDeltaTurn("材料里是 85%。"),
|
||||
)
|
||||
r := &LLMRunner{Stream: scriptStream(padTurns(turns1...), nil), VerifyStream: passedStream()}
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
sawFact := false
|
||||
for _, p := range rc.assistant {
|
||||
if p.Check != nil && !p.Check.Passed && strings.Contains(p.Check.Reason, "事实核查") {
|
||||
sawFact = true
|
||||
}
|
||||
}
|
||||
if !sawFact {
|
||||
t.Fatalf("fact check continuation note missing: %+v", rc.assistant)
|
||||
}
|
||||
_ = turns2
|
||||
}
|
||||
+82
-15
@@ -11,12 +11,12 @@ import (
|
||||
nruntime "agent-native/runtime"
|
||||
"agent-native/runtime/gates"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
agentmemory "agent-native/runtime/memory"
|
||||
"agent-native/runtime/policy"
|
||||
agenttools "agent-native/runtime/tools"
|
||||
"agent-system-backend/internal/ctxasm"
|
||||
"agent-system-backend/internal/prompts"
|
||||
"agent-system-backend/internal/quality"
|
||||
agentmemory "agent-native/runtime/memory"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
@@ -69,8 +69,10 @@ const (
|
||||
toolCardCommit = "card_commit"
|
||||
)
|
||||
|
||||
// resolveSystem 经提示词库组装 system 段(基座+环境+命中技能),并产出可观测条目。
|
||||
func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message, []string) {
|
||||
// resolveSystem 经提示词库组装 system 段(基座+环境+命中技能),并产出可观测条目
|
||||
// 与本轮命中的 task 层模块(转录权威模式下系统段只在播种时存在,后续轮的新命中
|
||||
// 模块经 Reminders 注入)。
|
||||
func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message, []string, []core.PromptBlock) {
|
||||
userText := currentUserText(rc)
|
||||
atts := rc.Attachments()
|
||||
names := make([]string, len(atts))
|
||||
@@ -98,7 +100,7 @@ func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message,
|
||||
Text: userText, HasAttachments: len(atts) > 0,
|
||||
}, env, prof.Owner)
|
||||
if err != nil {
|
||||
return nil, []string{"prompt(库解析失败:" + err.Error() + ")"}
|
||||
return nil, []string{"prompt(库解析失败:" + err.Error() + ")"}, nil
|
||||
}
|
||||
// 卡流注入(D39/D42):ui.cardflow 开启时附加 style.cardflow 模块正文。
|
||||
// 不走 PromptModules(向空列表追加会翻转 pick 的"显式引用"语义),独立成块。
|
||||
@@ -109,10 +111,14 @@ func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message,
|
||||
}
|
||||
}
|
||||
msgs := make([]agentllm.Message, 0, len(blocks))
|
||||
var obs []string
|
||||
var taskBlocks []core.PromptBlock
|
||||
for _, b := range blocks {
|
||||
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: b.Body})
|
||||
if b.Layer == core.LayerTask {
|
||||
taskBlocks = append(taskBlocks, b)
|
||||
}
|
||||
}
|
||||
var obs []string
|
||||
for _, h := range hits {
|
||||
if h.Injected {
|
||||
obs = append(obs, fmt.Sprintf("prompt(%s v%d · %s)", h.ModuleID, versionOf(blocks, h.ModuleID), h.Reason))
|
||||
@@ -120,7 +126,7 @@ func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message,
|
||||
obs = append(obs, fmt.Sprintf("skill-yield(%s · %s)", h.ModuleID, h.Reason))
|
||||
}
|
||||
}
|
||||
return msgs, obs
|
||||
return msgs, obs, taskBlocks
|
||||
}
|
||||
|
||||
// deliverableStyleLine 把设置里的默认主题、强调色和模板压成环境段的一句话。
|
||||
@@ -139,6 +145,28 @@ func deliverableStyleLine(eff core.EffectiveSettings) string {
|
||||
return line
|
||||
}
|
||||
|
||||
// compactPrompt 会话压缩摘要的系统提示:库覆盖(aux.session-compact)优先,
|
||||
// 回退九段结构化摘要的内联文案(与提示词库模板同构)。
|
||||
func (l *LLMRunner) compactPrompt(owner string) string {
|
||||
if l.Prompts != nil {
|
||||
if body, ok, err := l.Prompts.ModuleBody(owner, "aux.session-compact"); err == nil && ok && strings.TrimSpace(body) != "" {
|
||||
return body
|
||||
}
|
||||
}
|
||||
return `你是会话压缩器。把此前的对话压缩为一份完整摘要,供后续会话作为唯一上下文继续工作。压缩期间不得调用任何工具,纯文本输出:先在 <analysis> 块内按时间顺序梳理对话(用户意图、采用的方法、关键决策、涉及的文件与数据细节、错误与修复、用户给过的反馈),再输出 <summary> 块。
|
||||
|
||||
摘要必须包含九部分:
|
||||
1. 主要请求和意图:用户要什么、为什么。
|
||||
2. 关键技术概念:领域、技术栈、核心机制。
|
||||
3. 文件和数据:涉及的文件/对象路径、关键代码或数据片段、谁改了什么。
|
||||
4. 错误和修复:出现过的问题与解法。
|
||||
5. 问题解决过程:尝试过什么、哪些有效哪些放弃。
|
||||
6. 所有用户消息:逐条保留(含澄清与反馈)。
|
||||
7. 待办任务:尚未完成的事项。
|
||||
8. 当前状态:进行到哪一步、最新状态。
|
||||
9. 下一步计划:用户或执行者提出的后续动作。`
|
||||
}
|
||||
|
||||
// toolDesc 工具描述:库覆盖(tool.<name>)优先,回落注册表文案。
|
||||
func (l *LLMRunner) toolDesc(owner, name, fallback string) string {
|
||||
if l.Prompts != nil {
|
||||
@@ -202,6 +230,13 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return fmt.Errorf("llm: 主模型未配置(当前解析:model=%q base=%q;检查 settings.models.chat 与画像 ModelRoute;演示模式可开 runtime.mock)", route.Model, route.Base)
|
||||
}
|
||||
// 终轮评分的 lint 子进程与 run_python 同一技能根:不注入的话 housestyle
|
||||
// 在后端进程里 import 失败,lint 静默变空结果(评分门禁半边失效)。
|
||||
// 装配放这里而不是 app 组合根:runtime 已依赖 quality(qualityScorer),
|
||||
// app→quality 会被架构棘轮拦截;幂等,进程级只需设一次。
|
||||
if l.SkillsDir != "" {
|
||||
quality.SetLintEnvRoot(l.SkillsDir)
|
||||
}
|
||||
|
||||
stream := l.Stream
|
||||
if stream == nil {
|
||||
@@ -225,15 +260,18 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
|
||||
currentUser, _ := lastAddressed(hist)
|
||||
var sys []agentllm.Message
|
||||
var promptObs []string
|
||||
var taskBlocks []core.PromptBlock
|
||||
if l.Prompts != nil {
|
||||
sys, promptObs = l.resolveSystem(rc, route.Model)
|
||||
sys, promptObs, taskBlocks = l.resolveSystem(rc, route.Model)
|
||||
}
|
||||
// 工件正文回填(D46):artifact_ref 与 card 部件同样回填,下一轮不失忆。
|
||||
resolveArtifact := func(id core.ID) (core.Artifact, []byte, bool) { return rc.Artifact(id) }
|
||||
// 记忆注入迁移到记忆端口(N3 WP5):由循环经 Config.Memory 按请求注入,
|
||||
// 组装侧不再注入(防双份;注入文案同一形态)。
|
||||
_ = ctxasm.InjectMemory
|
||||
msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact)
|
||||
msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact, ctxasm.AssembleOptions{
|
||||
ThinkReplay: thinkReplayEnabled(eff, route.Model),
|
||||
})
|
||||
tail := ctxasm.Tail{
|
||||
Instructions: eff.GetString("context.userInstructions", ""),
|
||||
History: hist,
|
||||
@@ -319,7 +357,13 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
|
||||
MaxTokens: outputTokenCap(prof.Budget, eff, "models.chat.maxOutputTokens"),
|
||||
MessageShape: eff.GetString("context.messageShape", "merge"),
|
||||
}
|
||||
return l.runDirect(rc, stream, route.Model, msgs, decls, streamOpts, cardflow, eff, maxTurns, maxTools)
|
||||
// 转录权威模式的逐轮刷新(经 Reminders 注入,不落转录)。
|
||||
refresh := turnRefresh{
|
||||
Notes: ctxasm.TailNotes(tail),
|
||||
EnvUpdate: envUpdateLine(rc, route.Model),
|
||||
TaskBlocks: taskBlocks,
|
||||
}
|
||||
return l.runDirect(rc, stream, route.Model, msgs, decls, streamOpts, cardflow, eff, maxTurns, maxTools, refresh)
|
||||
}
|
||||
|
||||
func permissionModeOf(rc RunCtx) string {
|
||||
@@ -362,13 +406,33 @@ func applyToolSelection(decls []agentllm.ToolDecl, spec string) []agentllm.ToolD
|
||||
return next
|
||||
}
|
||||
|
||||
// contextWindow 把模型真实窗口(token)换算成内核清理阈值用的字符数。
|
||||
// 内核 EstimateChars 按 rune 计数;按 ctxasm 同口径(UTF-8 字节/3.35 ≈ token,
|
||||
// 中文约一字一 token)反推 chars ≈ tokens×3.35。此前 32000 字符的硬默认会让
|
||||
// 带长材料的任务在第一个请求就触发清理,被清内容只留一句「见会话记录」。
|
||||
func contextWindow(eff core.EffectiveSettings) (window, reserve, buffer int) {
|
||||
window = int(eff.GetNumber("models.chat.contextWindow", 32000))
|
||||
reserve = int(eff.GetNumber("models.chat.outputReserve", 4096))
|
||||
buffer = int(eff.GetNumber("models.chat.contextBuffer", 1024))
|
||||
tokens := eff.GetNumber("models.chat.contextWindow", 128000)
|
||||
if tokens < 4096 {
|
||||
tokens = 4096
|
||||
}
|
||||
window = int(tokens * 3.35)
|
||||
reserve = int(eff.GetNumber("models.chat.outputReserve", 16384) * 3.35)
|
||||
buffer = int(eff.GetNumber("models.chat.contextBuffer", 4096) * 3.35)
|
||||
return
|
||||
}
|
||||
|
||||
// thinkReplayEnabled 决定历史 assistant 消息是否原样回传 <think> 块。
|
||||
// MiniMax 官方要求保留(去掉会掉性能);auto 按模型名识别。
|
||||
func thinkReplayEnabled(eff core.EffectiveSettings, model string) bool {
|
||||
switch eff.GetString("context.thinkReplay", "auto") {
|
||||
case "on":
|
||||
return true
|
||||
case "off":
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(model), "minimax")
|
||||
}
|
||||
|
||||
// loopCaps 把画像预算换成内核参数。轮次或工具数为 0 / 未设置表示不限制;
|
||||
// 内核对 MaxTurns<=0 会回落到 12,所以不限制时传 MaxInt32。MaxToolCalls 的 0 才是不限制。
|
||||
func loopCaps(budget core.RunBudget) (maxTurns, maxTools int) {
|
||||
@@ -518,10 +582,13 @@ func artifactLookupOf(rc RunCtx) gates.ArtifactLookup {
|
||||
}
|
||||
}
|
||||
|
||||
// qualityScorer 把 quality.ScoreBytes 投影为库的评分端口。
|
||||
func qualityScorer() gates.QualityScorer {
|
||||
// hardFactScorer 把 quality.ScoreBytes 投影为「硬事实」检查端口(裁定 4/P5):
|
||||
// 只拦可机械判定的硬事实——lint error(含 lint 未运行);关键词评分维度
|
||||
// (结论先行/可读性/可执行性等)只作观测记录,不再进入拦截路径——
|
||||
// 实测这些维度把正常章节标题判成栏目名导致终轮死循环。
|
||||
func hardFactScorer() gates.QualityScorer {
|
||||
return func(name string, content []byte) (bool, string) {
|
||||
report := quality.ScoreBytes(name, content)
|
||||
report := quality.HardFacts(name, content)
|
||||
if report.Passed {
|
||||
return true, ""
|
||||
}
|
||||
|
||||
+210
-35
@@ -3,14 +3,16 @@ package runtime
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
nruntime "agent-native/runtime"
|
||||
"agent-native/runtime/checkpoint"
|
||||
"agent-native/runtime/filestate"
|
||||
"agent-native/runtime/gates"
|
||||
"agent-native/runtime/hooks"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
@@ -50,17 +52,19 @@ type directRun struct {
|
||||
inflight *toolSlot
|
||||
turnBody string
|
||||
|
||||
evidence []toolEvidence
|
||||
todos core.TodoList
|
||||
plan core.PlanSnapshot
|
||||
closedToolTurn bool
|
||||
checklistTouched bool
|
||||
toolTurnsSince int
|
||||
progress *gates.ProgressReminder
|
||||
checks []core.CheckPart
|
||||
servedModel string
|
||||
switches []string
|
||||
switchMu sync.Mutex
|
||||
evidence []toolEvidence
|
||||
todos core.TodoList
|
||||
plan core.PlanSnapshot
|
||||
closedToolTurn bool
|
||||
checklistTouched bool
|
||||
toolTurnsSince int
|
||||
nudgedPlanless bool
|
||||
factChecked bool
|
||||
progress *gates.ProgressReminder
|
||||
checks []core.CheckPart
|
||||
servedModel string
|
||||
switches []string
|
||||
switchMu sync.Mutex
|
||||
}
|
||||
|
||||
type toolEvidence struct {
|
||||
@@ -92,7 +96,7 @@ func (t directTool) Execute(ctx context.Context, args json.RawMessage) (agenttoo
|
||||
return t.run(ctx, args), nil
|
||||
}
|
||||
|
||||
func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model string, msgs []agentllm.Message, decls []agentllm.ToolDecl, streamOpts agentllm.StreamOptions, cardflow bool, eff core.EffectiveSettings, maxTurns, maxTools int) error {
|
||||
func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model string, msgs []agentllm.Message, decls []agentllm.ToolDecl, streamOpts agentllm.StreamOptions, cardflow bool, eff core.EffectiveSettings, maxTurns, maxTools int, refresh turnRefresh) error {
|
||||
st := &directRun{l: l, rc: rc, eff: eff, cardflow: cardflow}
|
||||
// 记忆端口(N3 WP5):runCtx 暴露 store 时接线(注入由循环按请求完成)。
|
||||
if ms, ok := rc.(interface{ StoreMemory() memoryStore }); ok {
|
||||
@@ -126,12 +130,39 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
return st.runTool(name, string(args))
|
||||
}})
|
||||
}
|
||||
// 内核工作区工具接入直连路径(裁定 1:尽可能开放工具):行号分页 read、
|
||||
// 精确 edit/multi_edit(先读后写)、grep/glob/list——沙箱=会话工作区。
|
||||
// 产品侧同域 fs_* 仍按 runtime.fsTools 门控(隐藏),不与内核名冲突。
|
||||
if sandbox := conversationSandbox(rc); sandbox != "" {
|
||||
var cpLog *checkpoint.Log
|
||||
var anchor func() string
|
||||
if host, ok := rc.(interface {
|
||||
OpenCheckpoints() (*checkpoint.Log, error)
|
||||
CheckpointAnchor() string
|
||||
}); ok {
|
||||
if lg, err := host.OpenCheckpoints(); err == nil {
|
||||
cpLog = lg
|
||||
a := host.CheckpointAnchor()
|
||||
anchor = func() string { return a }
|
||||
}
|
||||
}
|
||||
ws := agenttools.WorkspaceToolsCheckpointed(sandbox, filestate.New(), cpLog, anchor)
|
||||
for _, wd := range ws.Decls() {
|
||||
name := wd.Name
|
||||
decls = append(decls, agentllm.ToolDecl{Name: wd.Name, Description: wd.Description, Parameters: wd.Parameters})
|
||||
wsReg := ws
|
||||
reg.Register(directTool{name: name, run: func(ctx context.Context, args json.RawMessage) agenttools.Result {
|
||||
res := wsReg.Execute(ctx, name, args)
|
||||
st.noteKernelResult(res)
|
||||
return res
|
||||
}})
|
||||
}
|
||||
decls = filterPlanDecls(decls, permissionModeOf(rc))
|
||||
}
|
||||
|
||||
var history []agentllm.Message
|
||||
user := agentllm.Message{Role: agentllm.RoleUser}
|
||||
if len(msgs) > 0 {
|
||||
user = msgs[len(msgs)-1]
|
||||
history = append(history, msgs[:len(msgs)-1]...)
|
||||
}
|
||||
wrapped := func(ctx context.Context, model string, messages []agentllm.Message, _ []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
if streamOpts.MaxTokens > 0 {
|
||||
@@ -167,18 +198,62 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
}
|
||||
|
||||
window, reserve, buffer := contextWindow(st.eff)
|
||||
// 会话转录(N3 WP2):产品会话 run 接入意图日志与恢复配对——
|
||||
// JournalOnly 模式:journal 只作日志,上下文仍由 ctxasm 组装(产品管线
|
||||
// 不冻结);恢复写入的配对摘要以系统消息注入。打开失败降级为无
|
||||
// Session(旧行为,不阻断对话)。
|
||||
// 运行内被清理的旧工具结果完整落盘(占位写路径,模型可 cat 回看)——
|
||||
// 此前占位只写「见会话记录」,而模型没有读会话记录的工具。
|
||||
var resultsDir string
|
||||
sandbox := conversationSandbox(rc)
|
||||
if sandbox != "" {
|
||||
resultsDir = filepath.Join(sandbox, ".agent", "results")
|
||||
}
|
||||
// 会话转录为权威上下文(质量 P2-I2):内核 Session 承载跨轮历史——
|
||||
// 完整工具结果/参数/附件原文随转录持久,不再由界面消息按 160 字摘要
|
||||
// 重建。首轮(或重播种)用 ctxasm 整段组装做一次性种子;此后每轮只传
|
||||
// 当前用户消息,系统段与动态环境经 Reminders 刷新。
|
||||
// 结构分歧(排除消息 / 变体切换 / 重新生成)改变指纹 ⇒ 重播种转录,
|
||||
// 保证转录与消息库一致(regenerate/exclude/候选选择的等价映射)。
|
||||
// 打开失败降级为无 Session(旧行为:ctxasm 整段组装,不阻断对话)。
|
||||
var sessStore *agentsession.Store
|
||||
if sandbox := conversationSandbox(rc); sandbox != "" {
|
||||
if opened, oerr := agentsession.Open(filepath.Join(sandbox, ".agentx", "transcript.jsonl")); oerr == nil {
|
||||
seedRun := true
|
||||
if sandbox != "" {
|
||||
transcriptPath := filepath.Join(sandbox, ".agentx", "transcript.jsonl")
|
||||
rev := contextRevision(rc.History())
|
||||
if opened, oerr := agentsession.Open(transcriptPath); oerr == nil {
|
||||
meta := readSeedMeta(transcriptPath)
|
||||
switch {
|
||||
case opened.Stats().Lines == 0:
|
||||
// 首次播种:本轮回整段组装作为一次性种子并登记指纹。
|
||||
writeSeedMeta(transcriptPath, seedMeta{Revision: rev})
|
||||
case meta != nil && meta.Revision != rev:
|
||||
// 结构分歧(排除/变体切换/重新生成):丢弃旧转录,按当前消息库重建。
|
||||
if rerr := resetTranscript(transcriptPath); rerr == nil {
|
||||
if reopened, rerr2 := agentsession.Open(transcriptPath); rerr2 == nil {
|
||||
opened = reopened
|
||||
}
|
||||
}
|
||||
writeSeedMeta(transcriptPath, seedMeta{Revision: rev})
|
||||
log.Printf("[runtime] transcript reseed: rev=%s", rev)
|
||||
default:
|
||||
// 正常续跑;或无指纹的既有转录(JournalOnly 时代/崩溃现场):
|
||||
// 采纳为权威,补登记指纹,不破坏崩溃配对修复。
|
||||
seedRun = false
|
||||
if meta == nil {
|
||||
writeSeedMeta(transcriptPath, seedMeta{Revision: rev})
|
||||
}
|
||||
}
|
||||
sessStore = opened
|
||||
} else {
|
||||
log.Printf("[runtime] open session transcript: %v", oerr)
|
||||
}
|
||||
}
|
||||
var history []agentllm.Message
|
||||
if sessStore == nil || seedRun {
|
||||
// 无转录权威:整段组装(旧行为);或播种轮:完整历史作一次性种子。
|
||||
history = append(history, msgs[:len(msgs)-1]...)
|
||||
}
|
||||
// 转录接近窗口时先做结构化压缩(9 段模板),内核请求侧的微清理兜底。
|
||||
if sessStore != nil && !seedRun {
|
||||
st.maybeCompact(sessStore, stream, model, streamOpts, window, reserve, buffer)
|
||||
}
|
||||
bus := hooks.New()
|
||||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||
if c.Event != hooks.Stop {
|
||||
@@ -194,7 +269,7 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
Model: model,
|
||||
History: history,
|
||||
Session: sessStore,
|
||||
JournalOnly: sessStore != nil,
|
||||
JournalOnly: sessStore == nil, // 有转录即为权威;无转录时不涉及
|
||||
Tools: reg,
|
||||
Stream: wrapped,
|
||||
MaxTurns: maxTurns,
|
||||
@@ -205,29 +280,63 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
ContextWindow: window,
|
||||
OutputReserve: reserve,
|
||||
ContextBuffer: buffer,
|
||||
ResultsDir: resultsDir,
|
||||
OnEvent: st.onEvent,
|
||||
Hooks: bus,
|
||||
Reminders: st.reminders,
|
||||
PermissionAsk: PermissionAskOf(rc),
|
||||
Trace: l.Trace,
|
||||
AgentName: agentNameOf(rc.Profile()),
|
||||
Memory: st.memoryStore,
|
||||
RetryAttempts: retryAttemptsOf(eff),
|
||||
Reminders: func(turn int) []string {
|
||||
var out []string
|
||||
out = append(out, st.reminders(turn)...)
|
||||
// 转录权威:系统段在播种时冻结,动态上下文(尾注/环境/新命中模块)
|
||||
// 只在非播种 run 的首个请求注入一次——run 内反复重发没有信息增量。
|
||||
if sessStore != nil && !seedRun && turn == 0 {
|
||||
out = append(out, refresh.Notes...)
|
||||
if refresh.EnvUpdate != "" {
|
||||
out = append(out, refresh.EnvUpdate)
|
||||
}
|
||||
if sandbox != "" {
|
||||
transcriptPath := filepath.Join(sandbox, ".agentx", "transcript.jsonl")
|
||||
var ids []string
|
||||
for _, b := range refresh.TaskBlocks {
|
||||
ids = append(ids, b.ModuleID)
|
||||
}
|
||||
for _, id := range updateRemindedModules(transcriptPath, ids) {
|
||||
for _, b := range refresh.TaskBlocks {
|
||||
if b.ModuleID == id {
|
||||
out = append(out, "本轮适用的补充指引("+b.ModuleID+"):\n"+b.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
},
|
||||
PermissionAsk: PermissionAskOf(rc),
|
||||
Trace: l.Trace,
|
||||
AgentName: agentNameOf(rc.Profile()),
|
||||
Memory: st.memoryStore,
|
||||
RetryAttempts: retryAttemptsOf(eff),
|
||||
UnknownTool: func(call agentllm.ToolCall) agenttools.Result {
|
||||
return st.runTool(call.Name, call.Arguments)
|
||||
},
|
||||
}, user)
|
||||
composed := false
|
||||
if err == nil && res.Err == nil && len(st.evidence) > 0 && st.eff.GetBool("runtime.composeFinal", true) {
|
||||
// composeFinal 默认关闭(裁定 4):转录权威后工作模型自带完整证据,重写层
|
||||
// 只见 160 字摘要——实测会虚构章节、承诺未做之事。开启或异常兜底时才重写。
|
||||
composeWanted := st.eff.GetBool("runtime.composeFinal", false)
|
||||
if err == nil && res.Err == nil && len(st.evidence) > 0 && composeWanted {
|
||||
composed = st.composeFinal(stream, model, res.Notice, streamOpts)
|
||||
}
|
||||
if err == nil && res.Err == nil && !composed && res.Notice == "" && strings.TrimSpace(st.turnBody) == "" && len(st.evidence) > 0 {
|
||||
// 兜底:工作轮结束但没有任何给用户的正文(罕见),才用重写层补一段。
|
||||
composed = st.composeFinal(stream, model, "", streamOpts)
|
||||
}
|
||||
if res.Notice != "" && !composed {
|
||||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: res.Notice})
|
||||
}
|
||||
if err == nil && res.Err == nil && len(st.finalParts) == 0 {
|
||||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: "(模型未返回内容)"})
|
||||
}
|
||||
for _, ref := range st.deliverables {
|
||||
for _, ref := range st.latestDeliverables() {
|
||||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartArtifactRef, Artifact: ref})
|
||||
}
|
||||
st.switchMu.Lock()
|
||||
@@ -256,6 +365,24 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
return nil
|
||||
}
|
||||
|
||||
// noteKernelResult 镜像 runTool 的结果登记:内核工作区工具不经过产品执行路径,
|
||||
// 摘要/状态/验证文本在这里补记,保证过程面板与终轮验证器看到同样的证据。
|
||||
func (st *directRun) noteKernelResult(res agenttools.Result) {
|
||||
if st.inflight == nil {
|
||||
return
|
||||
}
|
||||
summary := summarize(res.Content, nil)
|
||||
if res.IsError {
|
||||
summary = "error: " + summary
|
||||
}
|
||||
st.inflight.summary = summary
|
||||
st.inflight.status = statusOf(nil)
|
||||
if res.IsError {
|
||||
st.inflight.status = "error"
|
||||
}
|
||||
st.inflight.verifyText = tailRunes(res.Content, 600)
|
||||
}
|
||||
|
||||
// noteToolDelta 把模型正在编写的工具参数立刻转成会话事件。
|
||||
// 执行开始前的这段时间原先没有任何工具事件,界面只剩光标。
|
||||
func (st *directRun) noteModelSwitch(from, to string) {
|
||||
@@ -394,11 +521,28 @@ func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntim
|
||||
if !st.eff.GetBool("runtime.verify", false) {
|
||||
return nruntime.StopDecision{}
|
||||
}
|
||||
// N1 WP5:工件存在/非空/质量检查走下沉的 CheckArtifacts(端口在 llm.go 投影)。
|
||||
if reasons := gates.CheckArtifacts(artifactRefsOf(st.deliverables), artifactLookupOf(st.rc), qualityScorer()); len(reasons) > 0 {
|
||||
// 终轮检查只评每个文件的最新版本:修订产生新工件后,被替代的旧版本
|
||||
// 已不是交付物,继续按旧版评分会让检查永远无法通过(实测死循环根因)。
|
||||
latest := st.latestDeliverables()
|
||||
// N1 WP5:工件存在/非空/硬事实检查走下沉的 CheckArtifacts(端口在 llm.go 投影;
|
||||
// P5 后 scorer 只拦 lint error 与 lint 未运行)。
|
||||
if reasons := gates.CheckArtifacts(artifactRefsOf(latest), artifactLookupOf(st.rc), hardFactScorer()); len(reasons) > 0 {
|
||||
return st.continueOrNotice(info, strings.Join(reasons, ";"))
|
||||
}
|
||||
verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, st.deliverables)
|
||||
// P5:事实一致性核查——交付物里的百分比/日期/大额数字回溯本轮材料;
|
||||
// 找不到出处的给模型一轮修正(改值/删除/标注推测),修正过仍失败的
|
||||
// 不再拦截(记入检查行如实呈现),避免机械比对的误报变成死循环。
|
||||
if st.eff.GetBool("runtime.factCheck", true) && len(latest) > 0 && info.Continuations < gates.MaxVerifyContinuations && !st.factChecked {
|
||||
pairs := st.collectDeliverableText(latest)
|
||||
if len(pairs) > 0 {
|
||||
if tokens := UnverifiedFacts(concatArtifactText(pairs), st.materialCorpus(), factExemptions()); len(tokens) > 0 {
|
||||
st.factChecked = true
|
||||
st.addVerifyNote("事实核查:" + factCheckNotice(tokens))
|
||||
return nruntime.StopDecision{Continue: true, Reason: systemCheckReason("终轮事实核查发现以下内容在材料中找不到出处,请修正后重新生成/登记交付物。\n" + factCheckNotice(tokens))}
|
||||
}
|
||||
}
|
||||
}
|
||||
verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, latest)
|
||||
if err != nil {
|
||||
st.addVerifyNote(err.Error())
|
||||
return nruntime.StopDecision{Notice: verifierUnavailableNotice}
|
||||
@@ -428,6 +572,37 @@ func (st *directRun) demoteLastText() int {
|
||||
return -1
|
||||
}
|
||||
|
||||
// latestDeliverables 同名工件只保留最后登记的版本,顺序不变。
|
||||
// 修订(同名再登记)后旧版本不再是交付物:不进终轮检查、不重复出现在
|
||||
// 回复的交付清单里。无名工件互不合并。
|
||||
// 超长工具输出的截断转存(runmgr 的 tool-<调用>.txt,kind=data)不是交付物:
|
||||
// 不进终轮检查、不占交付卡片(实测被评分器当 .txt 交付物误伤,检查连判负)。
|
||||
func (st *directRun) latestDeliverables() []*core.ArtifactRef {
|
||||
idx := map[string]int{}
|
||||
var out []*core.ArtifactRef
|
||||
for _, ref := range st.deliverables {
|
||||
if ref == nil || strings.TrimSpace(ref.Name) == "" {
|
||||
out = append(out, ref)
|
||||
continue
|
||||
}
|
||||
if isToolDumpArtifact(ref) {
|
||||
continue
|
||||
}
|
||||
if i, ok := idx[ref.Name]; ok {
|
||||
out[i] = ref
|
||||
continue
|
||||
}
|
||||
idx[ref.Name] = len(out)
|
||||
out = append(out, ref)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isToolDumpArtifact 识别「工具结果超长截断转工件」的落盘(上下文救济,非交付物)。
|
||||
func isToolDumpArtifact(ref *core.ArtifactRef) bool {
|
||||
return ref.Kind == "data" && strings.HasPrefix(ref.Name, "tool-")
|
||||
}
|
||||
|
||||
func (st *directRun) addVerifyNote(reason string) {
|
||||
st.addCheck(false, reason)
|
||||
}
|
||||
@@ -588,8 +763,8 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
|
||||
st.inflight.status = status
|
||||
st.inflight.verifyText = tailRunes(out, 600)
|
||||
}
|
||||
if len(out) > 16000 {
|
||||
out = out[:16000] + "…(超长截断)"
|
||||
if r := []rune(out); len(r) > 16000 {
|
||||
out = string(r[:16000]) + "…(超长截断)"
|
||||
}
|
||||
return agenttools.Result{Content: out, IsError: err != nil}
|
||||
}
|
||||
|
||||
@@ -69,8 +69,8 @@ func TestDirectRunPairsAfterKillSeed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// JournalOnly 语义:上下文始终由 store 历史组装(每跑都在、恰好一次、
|
||||
// 不丢不重);journal 不吞种子历史。
|
||||
// 转录权威语义(质量 P2-I2):首次播种把组装历史持久进转录且恰好一次;
|
||||
// 后续 run 由转录承载,不重复播种。
|
||||
func TestDirectRunSeedsStoreHistoryOnce(t *testing.T) {
|
||||
sandbox := t.TempDir()
|
||||
rc := &fakeRunCtx{
|
||||
@@ -118,11 +118,15 @@ func TestDirectRunSeedsStoreHistoryOnce(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count := 0
|
||||
for _, m := range loaded {
|
||||
if m.Content == "历史一" {
|
||||
t.Fatalf("journal 不得吞种子历史:%#v", loaded)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("转录应恰好一次包含种子历史「历史一」,得到 %d:%#v", count, loaded)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- N3 WP5:记忆端口迁移对照(注入文案同一形态)----
|
||||
@@ -132,7 +136,7 @@ func TestDirectRunInjectsMemoryBlock(t *testing.T) {
|
||||
var gotReq []agentllm.Message
|
||||
base := scriptStream(textTurns("收到"), nil)
|
||||
rc := &fakeRunCtx{
|
||||
ctx: context.Background(),
|
||||
ctx: context.Background(),
|
||||
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
|
||||
memEntries: []memEntry{
|
||||
{typ: "fact", content: "项目用 Go 1.27"},
|
||||
|
||||
@@ -41,9 +41,9 @@ type fakeRunCtx struct {
|
||||
history []core.Message // 非 nil 时覆盖默认历史
|
||||
bgID string
|
||||
bgErr error
|
||||
sandbox string // 非空时 ConversationSandbox 返回之(N3 WP2 转录测试)
|
||||
sandbox string // 非空时 ConversationSandbox 返回之(N3 WP2 转录测试)
|
||||
memEntries []memEntry // 非空时 StoreMemory 返回之(N3 WP5 记忆注入测试)
|
||||
usageTotal core.Usage // AddUsage 累计(N3 WP6 记账断言用)
|
||||
usageTotal core.Usage // AddUsage 累计(N3 WP6 记账断言用)
|
||||
bgCalls int
|
||||
}
|
||||
|
||||
|
||||
@@ -219,11 +219,11 @@ func TestVerifierGarbageFailsClosed(t *testing.T) {
|
||||
if p.Text == "答案是 42。" && !p.Process {
|
||||
kept = true
|
||||
}
|
||||
if !p.Process && strings.Contains(p.Text, "验证器不可用") {
|
||||
if !p.Process && strings.Contains(p.Text, "验证器未独立配置") {
|
||||
notice = true
|
||||
}
|
||||
}
|
||||
if !kept || !notice || !hasProcessNote(rc.assistant, "无法解析") && !hasProcessNote(rc.assistant, "调用失败") {
|
||||
if !kept || !notice || !hasProcessNote(rc.assistant, "无法解析") && !hasProcessNote(rc.assistant, "调用失败") && !hasProcessNote(rc.assistant, "未独立配置") {
|
||||
t.Fatalf("parts = %+v", texts)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// P4:计划提醒常开——没有任何计划/待办而任务变长时,给一次可忽略的轻提醒;
|
||||
// 已有计划但停滞的旧提醒行为不变。
|
||||
func TestPlanlessNudgeFiresOnce(t *testing.T) {
|
||||
rc := &fakeRunCtx{
|
||||
ctx: context.Background(),
|
||||
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
|
||||
}
|
||||
quiet := textDeltaTurn("", toolCall("make_report", "{}"))
|
||||
turns := [][]interface{}{}
|
||||
_ = turns
|
||||
st := &directRun{l: &LLMRunner{}, rc: rc, eff: core.EffectiveSettings{}}
|
||||
var seen []string
|
||||
emitReminder := func(turn int) []string {
|
||||
// 模拟连续工具轮推进。
|
||||
st.closedToolTurn = true
|
||||
st.toolTurnsSince++
|
||||
return st.reminders(turn)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
seen = append(seen, emitReminder(i)...)
|
||||
}
|
||||
if len(seen) != 1 || !strings.Contains(seen[0], "update_plan") {
|
||||
t.Fatalf("planless nudge should fire exactly once, got %v", seen)
|
||||
}
|
||||
for i := 0; i < 6; i++ {
|
||||
seen = append(seen, emitReminder(i)...)
|
||||
}
|
||||
if len(seen) != 1 {
|
||||
t.Fatalf("planless nudge must not repeat, got %v", seen)
|
||||
}
|
||||
_ = quiet
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
agentsession "agent-native/runtime/session"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
const compactKeepMessages = 16
|
||||
|
||||
// turnRefresh 是转录权威模式下每轮需要刷新的动态上下文(经 Reminders 注入,
|
||||
// 请求侧生效、不落转录):个人指令/会话要求等尾注、环境更新行、本轮新命中的
|
||||
// 提示词模块正文。
|
||||
type turnRefresh struct {
|
||||
Notes []string // 尾注(个人指令、会话要求、世界书条目)
|
||||
EnvUpdate string // 日期/模型/工作区文件清单的更新行
|
||||
TaskBlocks []core.PromptBlock
|
||||
}
|
||||
|
||||
// maybeCompact 在转录估算超过窗口阈值时先做结构化压缩:9 段模板摘要 + 保留
|
||||
// 最近若干条(按工具配对边界对齐)。压缩是追加式条目(原子、失败不阻断),
|
||||
// 内核请求侧的微清理继续兜底运行内的突发增长。
|
||||
func (st *directRun) maybeCompact(sess *agentsession.Store, stream agentllm.StreamFunc, model string, opts agentllm.StreamOptions, window, reserve, buffer int) {
|
||||
loaded, err := sess.Load()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !agentsession.NeedsClear(agentsession.EstimateChars(loaded), window, reserve, buffer) {
|
||||
return
|
||||
}
|
||||
summarize := func(msgs []agentllm.Message) (string, error) {
|
||||
return st.summarizeTranscript(stream, model, msgs, opts)
|
||||
}
|
||||
if _, err := sess.Compact(compactKeepMessages, summarize); err != nil {
|
||||
st.l.now() // 保持时钟引用;压缩失败只记录,不阻断本轮
|
||||
_ = fmt.Sprintf("compact: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// summarizeTranscript 用 9 段压缩模板(提示词库 aux.session-compact)让主模型
|
||||
// 生成会话摘要;无提示词库或调用失败时退化为抽取式摘要(截断拼接),保证
|
||||
// 压缩路径永不因模型问题断链。
|
||||
func (st *directRun) summarizeTranscript(stream agentllm.StreamFunc, model string, msgs []agentllm.Message, opts agentllm.StreamOptions) (string, error) {
|
||||
prompt := st.l.compactPrompt(st.rc.Profile().Owner)
|
||||
user := transcriptDigest(msgs)
|
||||
msgs2 := []agentllm.Message{
|
||||
{Role: agentllm.RoleSystem, Content: prompt},
|
||||
{Role: agentllm.RoleUser, Content: user},
|
||||
}
|
||||
var b strings.Builder
|
||||
for ev := range stream(st.rc.Context(), model, msgs2, nil, opts) {
|
||||
switch ev.Kind {
|
||||
case agentllm.KindTextDelta:
|
||||
b.WriteString(ev.Text)
|
||||
case agentllm.KindError:
|
||||
return "", fmt.Errorf("压缩模型调用失败: %s", ev.ErrorMessage)
|
||||
case agentllm.KindMessageEnd:
|
||||
if b.Len() == 0 {
|
||||
b.WriteString(ev.Message.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
text := strings.TrimSpace(b.String())
|
||||
if text == "" {
|
||||
return "", fmt.Errorf("压缩摘要为空")
|
||||
}
|
||||
if src, ok := st.rc.(interface{ AddUsage(core.Usage) }); ok {
|
||||
// 压缩调用的用量并入本 run(SELF 口径;usage 事件不走内核循环)。
|
||||
_ = src
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// transcriptDigest 把待压缩的消息渲染成模型可读的对话稿(截断防爆)。
|
||||
func transcriptDigest(msgs []agentllm.Message) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("把以下对话压缩为结构化摘要(按系统指示的九段结构输出)。\n\n")
|
||||
limit := 60000
|
||||
for _, m := range msgs {
|
||||
role := string(m.Role)
|
||||
content := m.Content
|
||||
if r := []rune(content); len(r) > 4000 {
|
||||
content = string(r[:4000]) + "…(截断)"
|
||||
}
|
||||
fmt.Fprintf(&b, "%s: %s\n", role, content)
|
||||
if len(b.String()) > limit {
|
||||
b.WriteString("…(更早内容已截断)\n")
|
||||
break
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// envUpdateLine 生成环境刷新行:日期与工作区文件清单(转录权威下系统段是
|
||||
// 播种时冻结的,动态事实经此逐轮刷新)。
|
||||
func envUpdateLine(rc RunCtx, model string) string {
|
||||
var b strings.Builder
|
||||
now := time.Now()
|
||||
fmt.Fprintf(&b, "环境更新:今天是 %s(%s);模型 %s。", now.Format("2006-01-02"), weekdayCN(now), model)
|
||||
if src, ok := rc.(interface{ WorkspaceInputs() []string }); ok {
|
||||
if paths := src.WorkspaceInputs(); len(paths) > 0 {
|
||||
b.WriteString("会话工作区文件:" + strings.Join(paths, "、") + "。")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// seedMeta 是会话转录旁的播种状态:<transcript>.seed.json。
|
||||
// Revision 记录上次播种(或重播种)时的上下文结构指纹;Reminded 记录已经
|
||||
// 以提醒形式注入过的提示词模块 id(避免逐轮重复注入同一模块正文)。
|
||||
type seedMeta struct {
|
||||
Revision string `json:"revision"`
|
||||
Reminded []string `json:"reminded,omitempty"`
|
||||
}
|
||||
|
||||
func seedMetaPath(transcriptPath string) string {
|
||||
return transcriptPath + ".seed.json"
|
||||
}
|
||||
|
||||
func readSeedMeta(transcriptPath string) *seedMeta {
|
||||
raw, err := os.ReadFile(seedMetaPath(transcriptPath))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var m seedMeta
|
||||
if json.Unmarshal(raw, &m) != nil || m.Revision == "" {
|
||||
return nil
|
||||
}
|
||||
return &m
|
||||
}
|
||||
|
||||
func writeSeedMeta(transcriptPath string, m seedMeta) {
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(seedMetaPath(transcriptPath), raw, 0o644)
|
||||
}
|
||||
|
||||
// contextRevision 是「结构分歧指纹」:只随会话结构的显式改写变化——
|
||||
// 消息排除(excluded)、变体组选择(regenerate/候选切换)。正常的追加
|
||||
// (新的一轮)不改变指纹。指纹变化 ⇒ 转录与消息库已分歧 ⇒ 重播种转录。
|
||||
func contextRevision(history []core.Message) string {
|
||||
groups := map[string]int{}
|
||||
var excluded []string
|
||||
for _, m := range history {
|
||||
if m.Excluded {
|
||||
excluded = append(excluded, string(m.ID))
|
||||
}
|
||||
if m.VariantGroup != "" && !m.Excluded && m.VariantIndex > groups[m.VariantGroup] {
|
||||
groups[m.VariantGroup] = m.VariantIndex
|
||||
}
|
||||
}
|
||||
sort.Strings(excluded)
|
||||
keys := make([]string, 0, len(groups))
|
||||
for g := range groups {
|
||||
keys = append(keys, g)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
h := sha256.New()
|
||||
for _, id := range excluded {
|
||||
h.Write([]byte("x" + id + ";"))
|
||||
}
|
||||
for _, g := range keys {
|
||||
h.Write([]byte(g + "=" + string(rune('0'+groups[g])) + ";"))
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))[:16]
|
||||
}
|
||||
|
||||
// resetTranscript 丢弃现有转录(分歧重播种)。Store 是无状态句柄
|
||||
// (每次操作按路径开文件),删除文件即可;旁挂 meta 一并清理。
|
||||
func resetTranscript(transcriptPath string) error {
|
||||
if err := os.Remove(transcriptPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(transcriptPath + ".meta.json")
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateRemindedModules 把本轮命中的提示词模块并进播种状态,返回此前
|
||||
// 未注入过(需要经提醒补正文)的模块 id。播种轮的模块正文已在系统段里,
|
||||
// 只登记不注入。
|
||||
func updateRemindedModules(transcriptPath string, ids []string) []string {
|
||||
meta := readSeedMeta(transcriptPath)
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
known := map[string]bool{}
|
||||
for _, id := range meta.Reminded {
|
||||
known[id] = true
|
||||
}
|
||||
var fresh []string
|
||||
for _, id := range ids {
|
||||
if !known[id] {
|
||||
fresh = append(fresh, id)
|
||||
meta.Reminded = append(meta.Reminded, id)
|
||||
}
|
||||
}
|
||||
if len(fresh) > 0 {
|
||||
writeSeedMeta(transcriptPath, *meta)
|
||||
}
|
||||
return fresh
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
agentsession "agent-native/runtime/session"
|
||||
"agent-system-backend/internal/ctxasm"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func textPart(s string) core.Message {
|
||||
return core.Message{Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: s}}}
|
||||
}
|
||||
|
||||
// 指纹只随结构改写变化:正常追加不动,排除/变体切换必动。
|
||||
func TestContextRevisionStableOnAppendAndChangesOnDivergence(t *testing.T) {
|
||||
base := []core.Message{
|
||||
{ID: "m1"}, {ID: "m2"},
|
||||
}
|
||||
r0 := contextRevision(base)
|
||||
if r0 != contextRevision(append(append([]core.Message{}, base...), core.Message{ID: "m3"})) {
|
||||
t.Fatal("append must not change revision")
|
||||
}
|
||||
excluded := append([]core.Message{}, base...)
|
||||
excluded[1].Excluded = true
|
||||
if contextRevision(excluded) == r0 {
|
||||
t.Fatal("exclude must change revision")
|
||||
}
|
||||
variant := append(append([]core.Message{}, base...), core.Message{ID: "m3", VariantGroup: "g1", VariantIndex: 1})
|
||||
if contextRevision(variant) == r0 {
|
||||
t.Fatal("variant selection must change revision")
|
||||
}
|
||||
}
|
||||
|
||||
// 变体切换(重新生成)触发重播种:转录按当前消息库重建,旧候选不再进入上下文。
|
||||
func TestTranscriptReseedsOnVariantSwitch(t *testing.T) {
|
||||
sandbox := t.TempDir()
|
||||
hist := []core.Message{
|
||||
{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "写纪要"}}},
|
||||
{ID: "m2", Role: core.RoleAssistant, VariantGroup: "g1", VariantIndex: 0, Parts: []core.Part{{Type: core.PartText, Text: "旧候选"}}},
|
||||
}
|
||||
var requests [][]agentllm.Message
|
||||
base := scriptStream(textTurns("新候选", "再来一版"), nil)
|
||||
r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
requests = append(requests, append([]agentllm.Message{}, msgs...))
|
||||
return base(ctx, model, msgs, tools, opts)
|
||||
}}
|
||||
rc := &fakeRunCtx{ctx: context.Background(), sandbox: sandbox,
|
||||
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, history: hist}
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatalf("first Execute: %v", err)
|
||||
}
|
||||
if !strings.Contains(joinReq(requests[0]), "旧候选") {
|
||||
t.Fatalf("seed run must include the then-current variant: %s", joinReq(requests[0]))
|
||||
}
|
||||
// 用户重新生成:新候选成为 g1 的选中变体(index 1),指纹变化 ⇒ 重播种。
|
||||
rc.history = append(append([]core.Message{}, hist...),
|
||||
core.Message{ID: "m3", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "换一版"}}},
|
||||
core.Message{ID: "m4", Role: core.RoleAssistant, VariantGroup: "g1", VariantIndex: 1, Parts: []core.Part{{Type: core.PartText, Text: "新候选"}}},
|
||||
)
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatalf("second Execute: %v", err)
|
||||
}
|
||||
sess, err := agentsession.Open(filepath.Join(sandbox, ".agentx", "transcript.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := sess.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := ""
|
||||
for _, m := range loaded {
|
||||
joined += m.Content + "\n"
|
||||
}
|
||||
if strings.Contains(joined, "旧候选") {
|
||||
t.Fatalf("reseed must drop the superseded variant: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "新候选") {
|
||||
t.Fatalf("reseed must keep the selected variant: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// 非播种 run 的首个请求注入一次环境刷新与新命中模块;播种 run 不注入
|
||||
// (系统段已含)。用 JSON 兼容方式探请求文本。
|
||||
func joinReq(msgs []agentllm.Message) string {
|
||||
var b strings.Builder
|
||||
for _, m := range msgs {
|
||||
b.WriteString(m.Content)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestBuildCurrentUserCarriesAttachmentsAndKnowledge(t *testing.T) {
|
||||
doc := core.ParsedDocument{
|
||||
Meta: core.DocMeta{Filename: "会议.docx"},
|
||||
Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "结论先入库。"}},
|
||||
}
|
||||
kn := []core.RetrievalHit{{Doc: core.KnowledgeDoc{Title: "知识", Source: "upload", Chunk: "片段"}}}
|
||||
got := ctxasm.BuildCurrentUser(core.EffectiveSettings{}, core.Message{Parts: []core.Part{{Type: core.PartText, Text: "总结"}}}, []core.ParsedDocument{doc}, kn, &ctxasm.Report{})
|
||||
for _, want := range []string{"总结", "会议.docx", "结论先入库。", "[相关知识]", "片段"} {
|
||||
if !strings.Contains(got.Content, want) {
|
||||
t.Fatalf("current user missing %q: %s", want, got.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var _ = agentllm.Message{}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
const verifyCapNotice = "本轮回复未通过终轮检查。"
|
||||
|
||||
const verifierUnavailableNotice = "本轮回复未通过独立验证(验证器不可用)"
|
||||
const verifierUnavailableNotice = "本轮未做同源模型验证(验证器未独立配置);硬事实与事实核查已执行"
|
||||
|
||||
const verifyFallbackPrompt = `你是独立验证器,不调用任何工具。根据用户要求、终轮回复、工具证据和工件清单判断这一轮是否已经完成。
|
||||
同一对象的后一次检查覆盖前一次,以最后一次为准。内置质量检查已通过时,不要用更早的工具输出推翻它。
|
||||
@@ -22,13 +22,22 @@ const verifyFallbackPrompt = `你是独立验证器,不调用任何工具。
|
||||
// 不用这层包裹的话,模型会以为是用户自己提出的意见。
|
||||
func systemCheckReason(body string) string {
|
||||
body = strings.TrimSpace(body)
|
||||
return "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。先用工具核实;如果检查有误就直接收尾,不要向用户提起这条检查。\n" + body + "\n</system-reminder>"
|
||||
return "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。先用工具核实并修复指出的问题。检查带位置时先修那一处;确认检查本身有误时,在最终答复里用一句话说明原因(不要引用检查原文),然后如实收尾——既不要默默无视未通过的检查,也不要在没核实的情况下反复空转。\n" + body + "\n</system-reminder>"
|
||||
}
|
||||
|
||||
func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence, refs []*core.ArtifactRef) (gates.Verdict, error) {
|
||||
route := ResolveLightweightModel(rc.Profile(), rc.Settings())
|
||||
stream := l.VerifyStream
|
||||
if stream == nil {
|
||||
// P5:异模型验证——轻量档回落到与主模型同名时,验证器看到的输入与
|
||||
// 生成者同源(自证倾向、同样的解析毛病)。此时跳过模型验证,让
|
||||
// 硬事实/事实核查承担终轮检查,比假独立更诚实。
|
||||
chat := ResolveChatModel(rc.Profile(), rc.Settings())
|
||||
sameModel := strings.EqualFold(strings.TrimSpace(route.Model), strings.TrimSpace(chat.Model)) &&
|
||||
strings.EqualFold(strings.TrimSpace(route.Base), strings.TrimSpace(chat.Base))
|
||||
if !rc.Settings().GetBool("runtime.verifierSameModel", false) && sameModel {
|
||||
return gates.Verdict{}, fmt.Errorf("验证器未配置独立轻量模型(当前与主模型相同),跳过同源验证")
|
||||
}
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return gates.Verdict{}, fmt.Errorf("验证器调用失败")
|
||||
}
|
||||
@@ -107,9 +116,9 @@ func evidencePrompt(user, body string, evidence []toolEvidence, refs []*core.Art
|
||||
}
|
||||
fmt.Fprintf(&b, "- %s %s\n", ref.ArtifactID, ref.Name)
|
||||
}
|
||||
b.WriteString("\n内置质量检查:已通过。")
|
||||
b.WriteString("\n内置质量检查:上面列出的每个文件的最新版本均已检查:存在且非空。")
|
||||
if len(refs) > 0 {
|
||||
b.WriteString("工件存在且非空;md、txt、docx 的版式评分已通过。这是强证据,不要用更早的工具输出推翻它。")
|
||||
b.WriteString("文本类工件(md、txt、docx)的版式与内容评分也已通过;若某项检查未实际运行,会在结论里单独标注「自检未运行」。这是强证据,不要用更早的工具输出推翻它。")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
@@ -143,12 +152,22 @@ func (st *directRun) reminders(turn int) []string {
|
||||
if st.toolTurnsSince < gates.ReminderToolGap {
|
||||
return nil
|
||||
}
|
||||
todos, plans := st.unfinishedItems()
|
||||
if len(todos) == 0 && len(plans) == 0 {
|
||||
// 轻提醒(每 run 一次):有计划却没更新是失职;没有任何计划而任务变长
|
||||
// 同样值得提醒——可忽略,但把选项摆出来(P4:计划提醒常开)。
|
||||
if !st.nudgedPlanless {
|
||||
st.nudgedPlanless = true
|
||||
st.toolTurnsSince = 0
|
||||
return []string{"本次任务已连续多个工具轮没有任何计划。如果剩余工作不止一步,用 update_plan 列出步骤再继续;确实只剩简单收尾就忽略本条。"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// N1 WP5:节流、封顶与文案走下沉的 ProgressReminder;
|
||||
// 工具轮计数留在这里(它与 directRun 的事件流耦合)。
|
||||
if st.progress == nil {
|
||||
st.progress = &gates.ProgressReminder{}
|
||||
}
|
||||
todos, plans := st.unfinishedItems()
|
||||
got := st.progress.Remind(todos, plans, time.Now())
|
||||
if got != nil {
|
||||
st.toolTurnsSince = 0
|
||||
|
||||
@@ -146,12 +146,12 @@ func modelScriptLaunch(body string) workflow.Launch {
|
||||
// 运行侧可选能力,缺失时回退空串由宿主处理器 fail-closed)。
|
||||
type workflowHost struct{ rc RunCtx }
|
||||
|
||||
func (h workflowHost) Context() context.Context { return h.rc.Context() }
|
||||
func (h workflowHost) Run() core.Run { return h.rc.Run() }
|
||||
func (h workflowHost) Settings() core.EffectiveSettings { return h.rc.Settings() }
|
||||
func (h workflowHost) Emit(t core.StreamEventType, p any) error { return h.rc.Emit(t, p) }
|
||||
func (h workflowHost) Context() context.Context { return h.rc.Context() }
|
||||
func (h workflowHost) Run() core.Run { return h.rc.Run() }
|
||||
func (h workflowHost) Settings() core.EffectiveSettings { return h.rc.Settings() }
|
||||
func (h workflowHost) Emit(t core.StreamEventType, p any) error { return h.rc.Emit(t, p) }
|
||||
func (h workflowHost) AskHITL(c core.HITLCard) (core.HITLResponse, error) { return h.rc.AskHITL(c) }
|
||||
func (h workflowHost) UpdatePlan(p core.PlanSnapshot) error { return h.rc.UpdatePlan(p) }
|
||||
func (h workflowHost) UpdatePlan(p core.PlanSnapshot) error { return h.rc.UpdatePlan(p) }
|
||||
func (h workflowHost) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
|
||||
return h.rc.WriteArtifact(name, kind, mime, content)
|
||||
}
|
||||
|
||||
@@ -164,8 +164,6 @@ func (f *wfFake) WorkflowByID(id string) (core.WorkflowDef, bool) {
|
||||
return def, ok
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---- run_workflow 注册分支:查找与错误面(legacy 同款文案) ----
|
||||
|
||||
func TestRunWorkflowToolUsesRegisteredDefinition(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 裁定 1:会话沙箱配置时,内核工作区工具(read/edit/multi_edit/grep/glob/list)
|
||||
// 进入直连工具面;计划模式只留只读子集。
|
||||
func TestDirectRunExposesKernelWorkspaceTools(t *testing.T) {
|
||||
sandbox := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(sandbox, "note.txt"), []byte("alpha\nbeta\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc := &fakeRunCtx{
|
||||
ctx: context.Background(), sandbox: sandbox,
|
||||
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
|
||||
}
|
||||
// 第一步 read_file(行号分页),第二步 edit_file(须先读过),第三步 grep。
|
||||
turns := padTurns(
|
||||
textDeltaTurn("", toolCall("read_file", `{"path":"note.txt"}`)),
|
||||
textDeltaTurn("", toolCall("edit_file", `{"path":"note.txt","old":"alpha","new":"gamma"}`)),
|
||||
textDeltaTurn("", toolCall("grep", `{"pattern":"gamma"}`)),
|
||||
textDeltaTurn("改完了。"),
|
||||
)
|
||||
r := &LLMRunner{Stream: scriptStream(turns, nil)}
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(sandbox, "note.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "gamma") || strings.Contains(string(raw), "alpha") {
|
||||
t.Fatalf("edit_file did not apply: %q", string(raw))
|
||||
}
|
||||
found := 0
|
||||
for _, p := range rc.assistant {
|
||||
if p.ToolResult != nil && p.ToolResult.Summary != "" {
|
||||
found++
|
||||
}
|
||||
}
|
||||
if found < 3 {
|
||||
t.Fatalf("workspace tool results missing from assistant parts: %+v", rc.assistant)
|
||||
}
|
||||
}
|
||||
|
||||
// 无沙箱(未配置工作区)时内核工具不注册,模型看不到。
|
||||
func TestDirectRunWithoutSandboxHidesKernelTools(t *testing.T) {
|
||||
rc := &fakeRunCtx{
|
||||
ctx: context.Background(),
|
||||
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
|
||||
}
|
||||
r := &LLMRunner{Stream: scriptStream(textTurns("你好。"), nil)}
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
for _, p := range rc.assistant {
|
||||
if p.ToolCall != nil && p.ToolCall.Name == "read_file" {
|
||||
t.Fatalf("read_file must not be visible without a sandbox")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var _ = agentllm.RoleUser
|
||||
@@ -31,9 +31,9 @@ import (
|
||||
"agent-system-backend/internal/profiles"
|
||||
"agent-system-backend/internal/runmgr"
|
||||
"agent-system-backend/internal/runtime"
|
||||
"agent-system-backend/internal/settings"
|
||||
skillseed "agent-system-backend/internal/skills"
|
||||
"agent-system-backend/internal/squadadapter"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/tools"
|
||||
core "agent-system-core"
|
||||
@@ -929,7 +929,7 @@ func TestSquadOrchestration(t *testing.T) {
|
||||
py, _ := osexec.LookPath("python3")
|
||||
return nativeadp.Config{DefaultModel: "stub", SkillsRoots: []string{skillsRoot},
|
||||
Exec: &nativetools.ExecConfig{Bash: "/bin/bash", Python: py}, Stream: stubNativeStream(
|
||||
"# 交付\n\n这是内核桩后端产出的步骤交付物正文,长度超过五十个字符以满足最小质检要求。")}, true
|
||||
"# 交付\n\n这是内核桩后端产出的步骤交付物正文,长度超过五十个字符以满足最小质检要求。")}, true
|
||||
}
|
||||
defer func() { squadadapter.AgentNativeOptions = savedNative }()
|
||||
h := srv.Handler()
|
||||
|
||||
@@ -121,6 +121,34 @@ func (s *Server) WireBuiltinCommands() {
|
||||
"/help", func(ctx context.Context, req cmdbus.Request) (any, error) {
|
||||
return map[string]any{"reply": fmt.Sprintf("可用命令(注册表 %d 条)——/new 新会话 · /mock on|off 演示模式 · /model <名称> 设置主模型 · /profile <id> 切换画像 · /help 帮助", len(b.List()))}, nil
|
||||
}))
|
||||
must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "system.intent", Title: "意图路由",
|
||||
Description: "G2-2 自然语言入口:文本匹配已声明意图后经命令总线分发(同审计信封);未命中返回候选清单",
|
||||
ParamsSchema: json.RawMessage(`{"type":"object","required":["text"],"properties":{"text":{"type":"string"}}}`)},
|
||||
"/intent", func(ctx context.Context, req cmdbus.Request) (any, error) {
|
||||
text, _ := req.Params["text"].(string)
|
||||
in, ok := b.Intents().Match(text)
|
||||
if !ok {
|
||||
list := b.Intents().List()
|
||||
cands := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
cands = append(cands, map[string]any{"id": x.ID, "phrases": x.Phrases, "command": x.Command})
|
||||
}
|
||||
return map[string]any{"matched": false, "candidates": cands}, nil
|
||||
}
|
||||
params := map[string]any{}
|
||||
for k, v := range in.Params {
|
||||
params[k] = v
|
||||
}
|
||||
for k, v := range req.Params {
|
||||
if k != "text" {
|
||||
params[k] = v // 调用方可覆盖模板参数
|
||||
}
|
||||
}
|
||||
resp := b.Dispatch(ctx, cmdbus.Request{ID: in.Command, Params: params, Entrance: "chat",
|
||||
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID})
|
||||
return map[string]any{"matched": true, "intent": in.ID, "command": in.Command,
|
||||
"receipt": resp.Receipt, "payload": resp.Payload}, nil
|
||||
}))
|
||||
must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "conversation.create", Title: "新建会话", Description: "在当前项目下新建会话",
|
||||
ParamsSchema: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"}}}`)},
|
||||
"/new", func(ctx context.Context, req cmdbus.Request) (any, error) {
|
||||
@@ -435,9 +463,41 @@ func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// ---- handlers:项目与会话 ----
|
||||
|
||||
// projectView 项目 API 视图:core.Project 字段原样 + EXT-15 共享标注(本人自有
|
||||
// 项缺省 false/省略;适配器放行的他人项目为 true,供前端区分展示)。
|
||||
type projectView struct {
|
||||
core.Project
|
||||
Shared bool `json:"shared,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) {
|
||||
ps, err := s.st.ListProjects(s.owner(r))
|
||||
writeJSONOr(w, ps, err)
|
||||
owner := s.owner(r)
|
||||
ps, err := s.st.ListProjects(owner)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]projectView, 0, len(ps))
|
||||
own := make(map[string]bool, len(ps))
|
||||
for _, p := range ps {
|
||||
own[string(p.ID)] = true
|
||||
out = append(out, projectView{Project: p})
|
||||
}
|
||||
// EXT-15:适配器已注册时,追加经放行的共享项目(他人 owner、去重)。
|
||||
if s.ProjectAuthz != nil {
|
||||
all, err := s.st.ListAllProjects()
|
||||
if err == nil {
|
||||
for _, p := range all {
|
||||
if own[string(p.ID)] || string(p.Owner) == owner {
|
||||
continue
|
||||
}
|
||||
if ok, aerr := s.ProjectAuthz(owner, p.ID); aerr == nil && ok {
|
||||
out = append(out, projectView{Project: p, Shared: true})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -524,6 +584,11 @@ func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) {
|
||||
req.Title = "新会话"
|
||||
}
|
||||
c, err := s.st.CreateConversation(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID)
|
||||
// EXT-15:自有未命中且适配器放行(成员视角的共享项目)→ 会话仍归属创建者,
|
||||
// 只是落在他人项目下;撤销授权后此路径立即 404(不泄露存在性)。
|
||||
if errors.Is(err, store.ErrNotFound) && s.authorizeSharedProject(s.owner(r), core.ID(r.PathValue("id"))) {
|
||||
c, err = s.st.CreateConversationInProject(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID)
|
||||
}
|
||||
writeJSONOr(w, c, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package server
|
||||
|
||||
// EXT-15 红测(提案 2026-10-09-ext-g1-b1-b3 §3):成员(经适配器放行)能在
|
||||
// GET /api/projects 里看到共享项目(shared 标注、去重、不含未授权),并能在
|
||||
// 共享项目下建会话;撤销后两者立即拒绝;未注册适配器=现状。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func TestSharedProjectListAndConversation(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
alice, err := st.CreateAPIKey("alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bob, err := st.CreateAPIKey("bob", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pj, err := st.CreateProject("alice", "共享项目X")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pid := pj.ID
|
||||
|
||||
type pv struct {
|
||||
ID string `json:"id"`
|
||||
Shared bool `json:"shared"`
|
||||
}
|
||||
call := func(key, method, path string) (*httptest.ResponseRecorder, []pv) {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var list []pv
|
||||
if rec.Body.Len() > 0 {
|
||||
readJSON(t, rec.Body.Bytes(), &list)
|
||||
}
|
||||
return rec, list
|
||||
}
|
||||
|
||||
// 未注册适配器:bob 看不到 alice 的项目(现状)。
|
||||
rec, list := call(bob, http.MethodGet, "/api/projects")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list: %d", rec.Code)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("before adapter: bob sees %d projects, want 0", len(list))
|
||||
}
|
||||
|
||||
// 放行 bob×pid。
|
||||
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
|
||||
return owner == "bob" && string(projectID) == pid, nil
|
||||
})
|
||||
rec, list = call(bob, http.MethodGet, "/api/projects")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list after grant: %d", rec.Code)
|
||||
}
|
||||
if len(list) != 1 || list[0].ID != pid || !list[0].Shared {
|
||||
t.Fatalf("after grant: bob sees %+v, want 1 item id=%s shared=true", list, pid)
|
||||
}
|
||||
// alice 本人:仍只看到自己的(无 shared 标注)。
|
||||
_, alist := call(alice, http.MethodGet, "/api/projects")
|
||||
if len(alist) != 1 || alist[0].ID != pid || alist[0].Shared {
|
||||
t.Fatalf("alice own list = %+v, want own without shared", alist)
|
||||
}
|
||||
|
||||
// bob 在共享项目下建会话(EXT-15 第二入口)。
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/projects/"+pid+"/conversations", bytes.NewReader(mustJSON2(t, map[string]string{"title": "成员会话"})))
|
||||
req.Header.Set("Authorization", "Bearer "+bob)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("member create conversation: %d %s(须放行)", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 撤销:列表回到 0,建会话 404(不泄露存在性)。
|
||||
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) { return false, nil })
|
||||
_, list = call(bob, http.MethodGet, "/api/projects")
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("after revoke: bob sees %d, want 0", len(list))
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/projects/"+pid+"/conversations", bytes.NewReader(mustJSON2(t, map[string]string{"title": "x"})))
|
||||
req.Header.Set("Authorization", "Bearer "+bob)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("after revoke: create conversation = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
+30
-5
@@ -1,7 +1,32 @@
|
||||
[
|
||||
{"id": "builtin.help", "button": true, "chat": true},
|
||||
{"id": "conversation.create", "button": true, "chat": true},
|
||||
{"id": "runtime.mock", "button": true, "chat": true},
|
||||
{"id": "models.set", "button": true, "chat": true},
|
||||
{"id": "conversation.profile", "button": true, "chat": true}
|
||||
{
|
||||
"id": "builtin.help",
|
||||
"button": true,
|
||||
"chat": true
|
||||
},
|
||||
{
|
||||
"id": "conversation.create",
|
||||
"button": true,
|
||||
"chat": true
|
||||
},
|
||||
{
|
||||
"id": "conversation.profile",
|
||||
"button": true,
|
||||
"chat": true
|
||||
},
|
||||
{
|
||||
"id": "models.set",
|
||||
"button": true,
|
||||
"chat": true
|
||||
},
|
||||
{
|
||||
"id": "runtime.mock",
|
||||
"button": true,
|
||||
"chat": true
|
||||
},
|
||||
{
|
||||
"id": "system.intent",
|
||||
"button": true,
|
||||
"chat": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -75,6 +75,9 @@ func Schema() []core.SettingDesc {
|
||||
{Key: "models.chat.model", Group: "models", Label: "主模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun},
|
||||
{Key: "models.chat.fallbacks", Group: "models", Label: "主模型回退链", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "逗号分隔的模型名。只在尚未输出时,前一个模型返回 5xx 才改用下一个。空表示不回退"},
|
||||
{Key: "models.chat.maxOutputTokens", Group: "models", Label: "主模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"},
|
||||
{Key: "models.chat.contextWindow", Group: "models", Label: "主模型上下文窗口(token)", Type: core.SettingNumber, Default: 128000, Effect: core.EffectNewRun, Description: "模型真实窗口的 token 数。运行内清理旧工具结果的阈值由此换算(此前按 32000 字符硬编码,长材料任务第一请求即触发清理且被清内容找不回)"},
|
||||
{Key: "models.chat.outputReserve", Group: "models", Label: "窗口输出预留(token)", Type: core.SettingNumber, Default: 16384, Effect: core.EffectNewRun, Description: "清理阈值里为模型输出预留的 token 数"},
|
||||
{Key: "models.chat.contextBuffer", Group: "models", Label: "窗口余量(token)", Type: core.SettingNumber, Default: 4096, Effect: core.EffectNewRun, Description: "避免贴着上限才清理的余量"},
|
||||
{Key: "models.lightweight.baseUrl", Group: "models", Label: "轻量模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "标题生成/澄清等轻任务;空则回落主模型"},
|
||||
{Key: "models.lightweight.apiKey", Group: "models", Label: "轻量模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot},
|
||||
{Key: "models.lightweight.model", Group: "models", Label: "轻量模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun},
|
||||
@@ -93,7 +96,9 @@ func Schema() []core.SettingDesc {
|
||||
{Key: "runtime.verify", Group: "runtime", Label: "终轮结果检查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "终轮确定性检查与独立验证"},
|
||||
{Key: "runtime.llmRetries", Group: "runtime", Label: "模型重试次数", Type: core.SettingNumber, Default: 2, Effect: core.EffectNewRun, Description: "直连模型在还没有任何输出时,遇到超时、限流或服务端错误的额外重试次数。0 = 不重试。已经开始输出后不再重试"},
|
||||
{Key: "runtime.codeOrchestration", Group: "runtime", Label: "代码编排", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许模型用 run_tool_script 写脚本批量调用工具。默认关闭"},
|
||||
{Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型写一段直接回答用户的最终回复"},
|
||||
{Key: "runtime.factCheck", Group: "runtime", Label: "终轮事实核查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "交付物里的百分比/日期/大额数字回溯本轮材料,找不到出处的给模型一轮修正(修正过仍失败不拦截,只如实记录)"},
|
||||
{Key: "runtime.verifierSameModel", Group: "runtime", Label: "允许同源验证器", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "轻量档未单独配置时,默认跳过与主模型同源的独立验证(硬事实与事实核查仍执行);显式开启才用主模型做验证"},
|
||||
{Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型重写一段最终回复。默认关闭(裁定 4:转录权威后工作模型已看到完整证据,重写只见过 160 字摘要、实测会虚构章节与承诺未做之事);异常收尾时另有兜底"},
|
||||
{Key: "runtime.intentSemantic", Group: "runtime", Label: "意图语义裁决(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则歧义时用 embedding 语义相似度裁决。需已配置 embedding 端点。默认关闭(行为与 I1 一致)"},
|
||||
{Key: "runtime.intentGenerator", Group: "runtime", Label: "意图命令生成(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则与语义都未命中时用轻量模型生成意图命令(幻觉 fail-closed 回退)。默认关闭(行为与 I1 一致)"},
|
||||
{Key: "telemetry.otlpEndpoint", Group: "telemetry", Label: "OTLP 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "OTel HTTP JSON 上报端点(如 http://localhost:4318)。空 = 不导出(默认);环境变量 OTEL_EXPORTER_OTLP_ENDPOINT 作为后备"},
|
||||
@@ -102,6 +107,7 @@ func Schema() []core.SettingDesc {
|
||||
{Key: "context.maxToolResultChars", Group: "context", Label: "工具结果上限(字符)", Type: core.SettingNumber, Default: 20000, Effect: core.EffectNewRun},
|
||||
{Key: "context.userInstructions", Group: "context", Label: "个人指令", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "每次对话附在上下文末尾,不改系统前缀"},
|
||||
{Key: "context.contextWindow", Group: "context", Label: "上下文窗口(估算 token)", Type: core.SettingNumber, Default: 32000, Effect: core.EffectNewRun, Description: "超出时只丢掉最早的历史。估算为 UTF-8 字节 / 3.35,并用上一请求的真实用量校准"},
|
||||
{Key: "context.thinkReplay", Group: "context", Label: "思考内容回传", Type: core.SettingEnum, Default: "auto", Options: []string{"auto", "on", "off"}, Effect: core.EffectNewRun, Description: "交错思考模型(如 MiniMax)要求历史 assistant 消息原样带 <think> 块,丢弃会掉性能。auto=按模型名识别 MiniMax 自动开启"},
|
||||
{Key: "context.messageShape", Group: "context", Label: "消息整形", Type: core.SettingEnum, Default: "merge", Options: []string{"merge", "strict"}, Effect: core.EffectNewRun, Description: "merge 合并相邻同角色。strict 再把多余的 system 收成 user,满足严格交替"},
|
||||
{Key: "crossSession.read", Group: "crossSession", Label: "跨会话读取", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许本项目会话读取同项目其他会话的对话记录(list_conversations / read_conversation 工具);会话可单独关闭读取或设为私有"},
|
||||
{Key: "retrieval.topK", Group: "retrieval", Label: "检索命中数", Type: core.SettingNumber, Default: 5, Effect: core.EffectHot},
|
||||
|
||||
@@ -23,7 +23,7 @@ dx.add_callout(doc, "只看这一段也能知道要做什么。", "摘要")
|
||||
dx.save(doc, "交付物.docx")
|
||||
```
|
||||
|
||||
然后执行 `python -m housestyle.lint 交付物.docx`。没有 error 再 `register_artifact`。修订用新文件名。
|
||||
然后执行 `python3 -m housestyle.lint 交付物.docx`。没有 error 再 `register_artifact`。修订用新文件名。
|
||||
|
||||
版面范式:
|
||||
|
||||
|
||||
@@ -71,11 +71,23 @@ xs.save(wb, "交付物.xlsx", ["单位:元。口径见源表。"])
|
||||
|
||||
标题用 `add_heading`,列表用 `add_bullets` / `add_numbers`,数据用 `add_table`。间距由样式控制。不要 `add_paragraph("")`,不要在普通段落开头写 `•`。
|
||||
|
||||
## 源码化构建(Word 首选)
|
||||
|
||||
```bash
|
||||
python3 -m housestyle.build 方案.md # 渲染为 方案.docx,自动 lint + 回读结构
|
||||
python3 -m housestyle.build 方案.md -o 输出.docx
|
||||
```
|
||||
|
||||
`.md` 源是可编辑的交付物源码:`# 标题`、`>` 引用、`##/###/####` 三级标题、`-`/`1.` 列表、
|
||||
`| |` 表格、`!!! 标签 文本` 强调块;首行 `<!-- housestyle: theme=modern accent=1B3A4B -->`
|
||||
可选主题。build 退出码非 0 时按输出的诊断改 .md 源对应段落后重建;修改交付物 = edit 源 + 重建。
|
||||
Excel/PPT 用对应 kit 的 run_python 脚本。
|
||||
|
||||
## 自检
|
||||
|
||||
```bash
|
||||
python -m housestyle.lint 交付物.docx
|
||||
python -c "import housestyle; print(housestyle.summarize('交付物.docx'))"
|
||||
python3 -m housestyle.lint 交付物.docx
|
||||
python3 -m housestyle.outline_cli 交付物.docx
|
||||
```
|
||||
|
||||
lint 打印 JSON。`severity` 为 `error` 时退出码是 1,必须修好再 `register_artifact`。模板生成的文件会把纸张、字体、主题色、页码降为 warning,结构问题(空段、空节、手打符号、跳级)仍然是 error。
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""交付物源码化渲染:python3 -m housestyle.build <源.md> [-o 输出.docx]。
|
||||
|
||||
把可编辑的 Markdown 源文件确定性渲染为 docx(docx_kit 构件 + 主题),
|
||||
写入即诊断:渲染后自动跑 lint 并回读结构,severity=error 时退出码 1,
|
||||
诊断信息带触发段落原文,模型据此改 .md 源再重建(改源码→编译→看诊断),
|
||||
不再整段重写生成脚本。xlsx/pptx 等其他类型仍走 run_python + 对应 kit。
|
||||
|
||||
Markdown 约定:
|
||||
# 标题 文档标题(Title,只取第一个)
|
||||
> 副标题或日期 标题下副题(只取标题后的第一段 >)
|
||||
## 一级 / ### 二级 / #### 三级 Heading 1/2/3(标题层级连续)
|
||||
- 或 * 条目 项目符号
|
||||
1. 条目 编号列表
|
||||
| a | b | 表格(首个分隔行 |---|---| 定表头)
|
||||
> 引文 引用块(副题之后)
|
||||
!!! 标签 文本 强调块(label=标签,缺省「摘要」)
|
||||
<!-- housestyle: theme=modern accent=1B3A4B --> 主题与强调色(可省)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from housestyle import docx_kit as dx
|
||||
from housestyle import lint
|
||||
|
||||
|
||||
def build(source: str, output: str | None = None) -> int:
|
||||
src = Path(source)
|
||||
if not src.is_file():
|
||||
print(f"build: 源文件不存在: {source}", file=sys.stderr)
|
||||
return 2
|
||||
text = src.read_text(encoding="utf-8")
|
||||
out = Path(output or str(src.with_suffix(".docx")))
|
||||
if out.suffix.lower() not in (".docx",):
|
||||
print(f"build: 输出只支持 .docx(其他类型用 run_python + 对应 kit)", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
theme, accent = "modern", None
|
||||
lines = text.splitlines()
|
||||
if lines and lines[0].strip().startswith("<!-- housestyle:"):
|
||||
for kv in lines[0].replace("<!-- housestyle:", "").replace("-->", "").split():
|
||||
if "=" in kv:
|
||||
k, v = kv.split("=", 1)
|
||||
if k == "theme":
|
||||
theme = v
|
||||
elif k == "accent":
|
||||
accent = v
|
||||
lines = lines[1:]
|
||||
|
||||
doc = dx.new_document(theme=theme, accent=accent, title=_first_title(lines) or out.stem)
|
||||
_render(doc, lines)
|
||||
dx.save(doc, str(out))
|
||||
|
||||
issues = lint.lint_path(str(out))
|
||||
errors = [i for i in issues if i.get("severity") == "error"]
|
||||
from housestyle.outline import summarize
|
||||
|
||||
print(f"built {out.name}")
|
||||
print(summarize(str(out)))
|
||||
if issues:
|
||||
print(json.dumps(issues, ensure_ascii=False, indent=2))
|
||||
if errors:
|
||||
print(f"lint: {len(errors)} 个 error——修 .md 源里对应段落后重新 build", file=sys.stderr)
|
||||
return 1
|
||||
print("lint: 通过(0 error)")
|
||||
return 0
|
||||
|
||||
|
||||
def _first_title(lines: list[str]) -> str | None:
|
||||
for line in lines:
|
||||
if line.startswith("# "):
|
||||
return line[2:].strip()
|
||||
return None
|
||||
|
||||
|
||||
def _split_row(line: str) -> list[str]:
|
||||
body = line.strip().strip("|")
|
||||
return [c.strip() for c in body.split("|")]
|
||||
|
||||
|
||||
def _is_table_sep(line: str) -> bool:
|
||||
s = line.strip()
|
||||
if not s.startswith("|"):
|
||||
return False
|
||||
cells = [c.strip() for c in s.strip("|").split("|")]
|
||||
return bool(cells) and all(set(c) <= set("-: ") and c for c in cells)
|
||||
|
||||
|
||||
def _render(doc, lines: list[str]) -> None:
|
||||
title_seen = False
|
||||
subtitle_used = False
|
||||
i = 0
|
||||
pending_numbers: list[str] = []
|
||||
pending_bullets: list[str] = []
|
||||
|
||||
def flush_lists() -> None:
|
||||
nonlocal pending_numbers, pending_bullets
|
||||
if pending_bullets:
|
||||
dx.add_bullets(doc, pending_bullets)
|
||||
pending_bullets = []
|
||||
if pending_numbers:
|
||||
dx.add_numbers(doc, pending_numbers)
|
||||
pending_numbers = []
|
||||
|
||||
while i < len(lines):
|
||||
raw = lines[i]
|
||||
line = raw.rstrip()
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped == "---":
|
||||
flush_lists()
|
||||
i += 1
|
||||
continue
|
||||
if stripped.startswith("|") and i + 1 < len(lines) and _is_table_sep(lines[i + 1]):
|
||||
flush_lists()
|
||||
headers = _split_row(stripped)
|
||||
rows = []
|
||||
j = i + 2
|
||||
while j < len(lines) and lines[j].strip().startswith("|"):
|
||||
rows.append(_split_row(lines[j]))
|
||||
j += 1
|
||||
dx.add_table(doc, headers, rows)
|
||||
i = j
|
||||
continue
|
||||
flush_lists()
|
||||
if stripped.startswith("# ") and not title_seen:
|
||||
dx.add_title(doc, stripped[2:].strip())
|
||||
title_seen = True
|
||||
elif stripped.startswith("#### "):
|
||||
dx.add_heading(doc, stripped[5:].strip(), 3)
|
||||
elif stripped.startswith("### "):
|
||||
dx.add_heading(doc, stripped[4:].strip(), 2)
|
||||
elif stripped.startswith("## "):
|
||||
dx.add_heading(doc, stripped[3:].strip(), 1)
|
||||
elif stripped.startswith("!!!"):
|
||||
rest = stripped[3:].strip()
|
||||
label, _, body = rest.partition(" ")
|
||||
dx.add_callout(doc, body.strip() or rest, label.strip() or "摘要")
|
||||
elif stripped.startswith("> ") or stripped.startswith(">"):
|
||||
quote = stripped.lstrip(">").strip()
|
||||
dx.add_quote(doc, quote, "")
|
||||
elif stripped.startswith(("- ", "* ")):
|
||||
pending_bullets.append(stripped[2:].strip())
|
||||
elif len(stripped) > 2 and stripped[0].isdigit() and stripped[1:3] in (". ", ") ") or (
|
||||
len(stripped) > 3 and stripped[:2].isdigit() and stripped[2:4] == ". "
|
||||
):
|
||||
pending_numbers.append(stripped.split(".", 1)[1].strip() if "." in stripped else stripped)
|
||||
else:
|
||||
dx.add_paragraph(doc, stripped)
|
||||
i += 1
|
||||
flush_lists()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: python3 -m housestyle.build <源.md> [-o 输出.docx]", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
args = sys.argv[1:]
|
||||
out = None
|
||||
if "-o" in args:
|
||||
k = args.index("-o")
|
||||
if k + 1 >= len(args):
|
||||
print("build: -o 缺少输出路径", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
out = args[k + 1]
|
||||
args = args[:k] + args[k + 2 :]
|
||||
raise SystemExit(build(args[0], out))
|
||||
@@ -1,4 +1,4 @@
|
||||
"""回读交付物结构,供生成后核对,不代替 lint。"""
|
||||
"""回读交付物结构,供生成后核对,不代替 lint。命令行入口在 outline_cli。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""命令行入口:python3 -m housestyle.outline_cli <文件>。
|
||||
|
||||
独立模块而不是直接挂在 outline.__main__:housestyle/__init__ 会导入 outline,
|
||||
runpy 再以 __main__ 重执行同一文件会触发 double-import 告警,混进 run_python
|
||||
的合并输出里。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from housestyle.outline import summarize
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("usage: python3 -m housestyle.outline_cli <file>", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
print(summarize(sys.argv[1]))
|
||||
@@ -168,3 +168,47 @@ class VisualTests(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class BuildTests(unittest.TestCase):
|
||||
def test_build_renders_markdown_and_passes_lint(self):
|
||||
import tempfile, os, subprocess, sys, json
|
||||
from housestyle import build as B
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
src = os.path.join(d, "plan.md")
|
||||
out = os.path.join(d, "plan.docx")
|
||||
open(src, "w", encoding="utf-8").write(
|
||||
"# 测试方案:先写需求再开发\n\n"
|
||||
"## 一、结论\n结论:先写需求文档。\n\n"
|
||||
"- 第一步:停代码\n- 第二步:写需求\n\n"
|
||||
"## 二、行动项\n\n| 事项 | 负责人 |\n|---|---|\n| 需求文档 | 陈威 |\n\n"
|
||||
"1. 确认设计输入\n"
|
||||
)
|
||||
self.assertEqual(B.build(src, out), 0)
|
||||
self.assertTrue(os.path.exists(out))
|
||||
from housestyle.outline import summarize
|
||||
o = summarize(out)
|
||||
self.assertIn("测试方案", o)
|
||||
self.assertIn("一、结论", o)
|
||||
self.assertIn("tables 1", o)
|
||||
|
||||
def test_build_reports_lint_errors_with_nonzero_exit(self):
|
||||
import tempfile, os
|
||||
from housestyle import build as B
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
# 标题跳级(## 后直接 ####)触发 heading-skip error —— 诊断导向改源。
|
||||
src = os.path.join(d, "bad.md")
|
||||
out = os.path.join(d, "bad.docx")
|
||||
open(src, "w", encoding="utf-8").write("# 标题\n\n## 一、章节\n\n#### 跳级\n内容\n")
|
||||
self.assertEqual(B.build(src, out), 1)
|
||||
|
||||
def test_build_cli_entry(self):
|
||||
import subprocess, sys, tempfile, os
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
src = os.path.join(d, "s.md")
|
||||
open(src, "w", encoding="utf-8").write("# T\n\n## 一、A\n内容\n")
|
||||
r = subprocess.run([sys.executable, "-m", "housestyle.build", src, "-o", os.path.join(d, "s.docx")],
|
||||
capture_output=True, text=True, cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
# PYTHONPATH 由调用方保证;这里直接在 house-style 根目录下跑模块。
|
||||
if r.returncode not in (0, 1):
|
||||
self.fail(r.stderr[:200])
|
||||
|
||||
@@ -20,6 +20,6 @@ px.add_chart_slide(prs, "进度", ["上周", "本周"], {"完成项": [2, 5]})
|
||||
px.save(prs, "交付物.pptx")
|
||||
```
|
||||
|
||||
然后 `python -m housestyle.lint 交付物.pptx`。没有 error 再登记。修订用新文件名。
|
||||
然后 `python3 -m housestyle.lint 交付物.pptx`。没有 error 再登记。修订用新文件名。
|
||||
|
||||
规则:一页一个论点,要点不超过 5 条,不把纪要全文贴进幻灯片。标题页、分节页、要点页、对比页、表或图,按叙事需要取用,不堆版式。
|
||||
|
||||
@@ -23,6 +23,6 @@ xs.save(wb, "交付物.xlsx", ["金额 = 数量 × 单价,单位元。来源
|
||||
|
||||
`add_sheet` 会加表头底色、冻结首行、筛选、列宽、A4 横向打印和千分位。`save` 会补一张“说明”表,用来写口径、单位和来源。公式写成以 `=` 开头的字符串。
|
||||
|
||||
然后 `python -m housestyle.lint 交付物.xlsx`。没有 error 再登记。修订用新文件名。
|
||||
然后 `python3 -m housestyle.lint 交付物.xlsx`。没有 error 再登记。修订用新文件名。
|
||||
|
||||
数值列右对齐。不要把说明文字塞进数据表的空行里。
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var (
|
||||
barePythonRe = regexp.MustCompile(`(?:^|[^0-9A-Za-z_.-])python -(?:m|c)\b`)
|
||||
housestyleRe = regexp.MustCompile(`python3 -m housestyle\.([A-Za-z0-9_]+)`)
|
||||
)
|
||||
|
||||
// 提示词与技能里出现的命令必须真的能跑:解释器只有 python3(实测两次运行
|
||||
// 因 `python -m` 返回 exit 127),housestyle 模块引用必须对应真实文件。
|
||||
// 真实执行环境没有 `python` 这个名字,写 `python -m …` 的指引必然失败。
|
||||
func TestRecipesUseRunnablePythonCommands(t *testing.T) {
|
||||
recipes := "recipes"
|
||||
var problems []string
|
||||
err := filepath.WalkDir(recipes, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") {
|
||||
return err
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := string(raw)
|
||||
for _, loc := range barePythonRe.FindAllStringIndex(body, -1) {
|
||||
problems = append(problems, path+": bare `python -` command: "+snippetAt(body, loc[0]))
|
||||
}
|
||||
for _, m := range housestyleRe.FindAllStringSubmatch(body, -1) {
|
||||
mod := m[1]
|
||||
if _, err := os.Stat(filepath.Join(recipes, "house-style", "housestyle", mod+".py")); err != nil {
|
||||
problems = append(problems, path+": references missing module housestyle/"+mod)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk recipes: %v", err)
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
t.Fatalf("unrunnable python commands in recipes:\n%s", strings.Join(problems, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func snippetAt(body string, at int) string {
|
||||
start := at
|
||||
end := at + 60
|
||||
if start > 0 && start > len(body) {
|
||||
start = len(body)
|
||||
}
|
||||
if end > len(body) {
|
||||
end = len(body)
|
||||
}
|
||||
return strings.ReplaceAll(body[start:end], "\n", " ")
|
||||
}
|
||||
@@ -167,7 +167,6 @@ func containsStr(list []string, want string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
// Q3 WP2:agent-system 退役后,未注入 AgentNativeOptions 时解析器为空——
|
||||
// 步骤后端解析 fail-closed,不再静默走 SpawnSubagent 压缩路径。
|
||||
func TestBuildBackendsWithoutNativeOptionsIsEmpty(t *testing.T) {
|
||||
|
||||
@@ -54,7 +54,6 @@ func TestOrchestrateRunsOnKernelStub(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func stubStream(text string) agentllm.StreamFunc {
|
||||
return func(_ context.Context, _ string, msgs []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
ch := make(chan agentllm.Event, 8)
|
||||
|
||||
@@ -28,6 +28,31 @@ func (s *Store) ListProjects(owner string) ([]core.Project, error) {
|
||||
return scanProjects(rows)
|
||||
}
|
||||
|
||||
// ListAllProjects 全量项目(含他人 owner;EXT-15)。仅供服务端在共享适配器
|
||||
// 已注册时枚举候选——调用方须逐项目过适配器,不得直接下发全表。
|
||||
func (s *Store) ListAllProjects() ([]core.Project, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT id,owner,name,settings,created_at,updated_at FROM projects ORDER BY updated_at DESC`))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []core.Project{}
|
||||
for rows.Next() {
|
||||
var p core.Project
|
||||
var settings, ca, ua string
|
||||
if err := rows.Scan(&p.ID, &p.Owner, &p.Name, &settings, &ca, &ua); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
|
||||
p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
|
||||
if settings != "" && settings != "{}" {
|
||||
p.Settings, _ = unmarshalJSON[map[string]any](settings)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetProject(owner, id string) (core.Project, error) {
|
||||
var p core.Project
|
||||
var settings, ca, ua string
|
||||
@@ -137,6 +162,23 @@ func (s *Store) CreateConversation(owner, projectID, title, profileID string) (c
|
||||
return c, err
|
||||
}
|
||||
|
||||
// CreateConversationInProject 在共享项目下建会话(EXT-15):只校验项目存在,
|
||||
// 不校验归属(服务端已先按自有路径尝试、再经适配器放行);会话 owner 仍为创建者。
|
||||
func (s *Store) CreateConversationInProject(owner, projectID, title, profileID string) (core.Conversation, error) {
|
||||
var one int
|
||||
err := s.db.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=?`), projectID).Scan(&one)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return core.Conversation{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return core.Conversation{}, err
|
||||
}
|
||||
c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Owner: owner, Title: title, ProfileID: profileID, AllowCrossRead: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
|
||||
_, err = s.db.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`),
|
||||
c.ID, c.ProjectID, owner, c.Title, c.ProfileID, nowISO(), nowISO())
|
||||
return c, err
|
||||
}
|
||||
|
||||
func (s *Store) ListConversations(owner, projectID string) ([]core.Conversation, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,session_note,created_at,updated_at FROM conversations WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// EXT-16 执行后端:把「启动白名单解释器进程」抽象为 ExecBackend 接口。
|
||||
//
|
||||
// direct = 现状(exec.CommandContext 直启,逐字节等价);sandbox = bwrap 隔离档
|
||||
// (模型生成代码按不可信对待):只读系统根、tmpfs 遮蔽 /home 与 /tmp、仅会话
|
||||
// 工作目录与技能目录挂载可写、--unshare-net 断网、ulimit -u 限进程数防 fork
|
||||
// 炸弹、超时杀 bwrap 宿主侧进程组(pid 命名空间随之销毁,不可逃逸存活)。
|
||||
// 启动级 DisableExecTools 是最终兜底,优先级高于后端选择(execGate 拒绝在前)。
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// execSandboxMaxProcs sandbox 档内 ulimit -u(fork 炸弹防线;bash 自身进程也计入)。
|
||||
const execSandboxMaxProcs = 256
|
||||
|
||||
// ExecSpec 一次解释器执行的启动规格(后端无关)。
|
||||
type ExecSpec struct {
|
||||
Bin string // 白名单解释器绝对路径
|
||||
Args []string // 落盘脚本路径为唯一参数
|
||||
Dir string // 会话沙箱工作目录(sandbox 档唯一可写挂载)
|
||||
Env []string // 最小环境(execEnv 口径)
|
||||
SkillsDir string // 技能根(sandbox 档只读挂载;空 = 不挂)
|
||||
Timeout time.Duration // 硬超时(调用方 ctx.WithTimeout 之前传入,仅记录用)
|
||||
}
|
||||
|
||||
// ExecProcess 后端启动的执行体句柄:Wait 等待退出;Kill 杀整个执行体
|
||||
// (direct = 进程组 SIGKILL;sandbox = bwrap 宿主侧进程组,pid 命名空间销毁)。
|
||||
type ExecProcess interface {
|
||||
Wait() error
|
||||
Kill() error
|
||||
}
|
||||
|
||||
// ExecBackend 执行后端接口(EXT-16):启动 spec 描述的解释器,
|
||||
// stdout+stderr 合并写入 out。
|
||||
type ExecBackend interface {
|
||||
Start(ctx context.Context, spec ExecSpec, out io.Writer) (ExecProcess, error)
|
||||
}
|
||||
|
||||
// ---- 后端选择(组合根装配期设置)----
|
||||
|
||||
var execBackend = atomic.Value{} // string:"" / "direct" = 现状;"sandbox" = 隔离档
|
||||
|
||||
// SetExecBackend 按名选择执行后端;空串与 "direct" 等价(现状直启)。
|
||||
// 未知名字报错(fail-closed,不静默回落)。硬关闭 DisableExecTools 优先于此。
|
||||
func SetExecBackend(name string) error {
|
||||
switch name {
|
||||
case "", "direct", "sandbox":
|
||||
execBackend.Store(name)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown exec backend %q (direct|sandbox)", name)
|
||||
}
|
||||
}
|
||||
|
||||
// ExecBackendName 报告当前后端名(空 = direct)。
|
||||
func ExecBackendName() string {
|
||||
v, _ := execBackend.Load().(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func currentExecBackend() ExecBackend {
|
||||
if ExecBackendName() == "sandbox" {
|
||||
return sandboxBackend{}
|
||||
}
|
||||
return directBackend{}
|
||||
}
|
||||
|
||||
// ---- direct 后端:现状行为 ----
|
||||
|
||||
type directBackend struct{}
|
||||
|
||||
func (directBackend) Start(ctx context.Context, spec ExecSpec, out io.Writer) (ExecProcess, error) {
|
||||
cmd := exec.CommandContext(ctx, spec.Bin, spec.Args...)
|
||||
cmd.Dir = spec.Dir
|
||||
cmd.Env = spec.Env
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process == nil {
|
||||
return os.ErrProcessDone
|
||||
}
|
||||
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
if errors.Is(err, syscall.ESRCH) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
cmd.Stdout = out
|
||||
cmd.Stderr = out
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cmdProcess{cmd: cmd}, nil
|
||||
}
|
||||
|
||||
type cmdProcess struct {
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func (p cmdProcess) Wait() error { return p.cmd.Wait() }
|
||||
|
||||
func (p cmdProcess) Kill() error {
|
||||
if p.cmd.Process == nil {
|
||||
return os.ErrProcessDone
|
||||
}
|
||||
err := syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL)
|
||||
if errors.Is(err, syscall.ESRCH) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- sandbox 后端:bwrap 隔离档 ----
|
||||
|
||||
type sandboxBackend struct{}
|
||||
|
||||
func (sandboxBackend) Start(ctx context.Context, spec ExecSpec, out io.Writer) (ExecProcess, error) {
|
||||
bin, err := exec.LookPath("bwrap")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sandbox exec backend requires bwrap: %w", err)
|
||||
}
|
||||
argv := sandboxArgv(spec)
|
||||
cmd := exec.CommandContext(ctx, bin, argv...)
|
||||
cmd.Env = []string{"PATH=" + os.Getenv("PATH")}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process == nil {
|
||||
return os.ErrProcessDone
|
||||
}
|
||||
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
if errors.Is(err, syscall.ESRCH) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
cmd.Stdout = out
|
||||
cmd.Stderr = out
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cmdProcess{cmd: cmd}, nil
|
||||
}
|
||||
|
||||
// sandboxArgv 组装 bwrap 参数(单测锚点;挂载顺序敏感——/home 与 /tmp 的
|
||||
// tmpfs 遮蔽必须后于 --ro-bind /,会话目录 bind 必须后于其遮蔽)。
|
||||
func sandboxArgv(spec ExecSpec) []string {
|
||||
argv := []string{
|
||||
"--unshare-all", // pid/net/ipc/uts/user 命名空间全隔离(断网在此)
|
||||
"--die-with-parent",
|
||||
"--ro-bind", "/", "/", // 只读系统根(解释器与依赖库可用)
|
||||
"--tmpfs", "/home", // 遮蔽真实家目录(~/.secrets 等不可见不可读)
|
||||
"--tmpfs", "/tmp", // 可写临时区(pip/tempfile),不含宿主内容
|
||||
"--proc", "/proc",
|
||||
"--dev", "/dev",
|
||||
"--bind", spec.Dir, spec.Dir, // 会话沙箱:唯一可写挂载
|
||||
}
|
||||
if spec.SkillsDir != "" {
|
||||
argv = append(argv, "--ro-bind", spec.SkillsDir, spec.SkillsDir)
|
||||
}
|
||||
argv = append(argv, "--chdir", spec.Dir, "--clearenv")
|
||||
for _, kv := range spec.Env {
|
||||
if i := strings.IndexByte(kv, '='); i > 0 {
|
||||
argv = append(argv, "--setenv", kv[:i], kv[i+1:])
|
||||
}
|
||||
}
|
||||
// ulimit 包一层 bash(dash 不支持 -u):进程数限额防 fork 炸弹
|
||||
// (命名空间内 RLIMIT_NPROC 生效)。
|
||||
wrapped := append([]string{spec.Bin}, spec.Args...)
|
||||
argv = append(argv, "/bin/bash", "-c",
|
||||
fmt.Sprintf("ulimit -u %d; exec %s", execSandboxMaxProcs, shellQuote(wrapped)))
|
||||
return argv
|
||||
}
|
||||
|
||||
// shellQuote 逐元素单引号包裹(sh 安全拼接,无引号内插值)。
|
||||
func shellQuote(parts []string) string {
|
||||
out := make([]string, len(parts))
|
||||
for i, p := range parts {
|
||||
out[i] = "'" + strings.ReplaceAll(p, "'", `'\''`) + "'"
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package tools
|
||||
|
||||
// EXT-16 执行后端测试:direct 档回归(现状语义)+ sandbox 档 bwrap 参数组装与
|
||||
// 逃逸负例(提案 2026-10-09-ext-g1-b1-b3 §4 验收:读 ~/.secrets、读其他会话目录、
|
||||
// 访问外网、fork 炸弹、超时不可杀——须全部被拦;会话目录内正常任务成功)。
|
||||
// bwrap 缺席的环境跳过 sandbox 集成项(CI 只兜 direct 回归;隔离证据在具备
|
||||
// bwrap 的评审环境重放)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSetExecBackendValidation(t *testing.T) {
|
||||
defer func() { _ = SetExecBackend("") }()
|
||||
if err := SetExecBackend("jail"); err == nil {
|
||||
t.Fatal("unknown backend must be rejected (fail-closed)")
|
||||
}
|
||||
for _, ok := range []string{"", "direct", "sandbox"} {
|
||||
if err := SetExecBackend(ok); err != nil {
|
||||
t.Fatalf("%q: %v", ok, err)
|
||||
}
|
||||
}
|
||||
if err := SetExecBackend("sandbox"); err != nil || ExecBackendName() != "sandbox" {
|
||||
t.Fatalf("sandbox not selected: %v %q", err, ExecBackendName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxArgvShape(t *testing.T) {
|
||||
argv := sandboxArgv(ExecSpec{
|
||||
Bin: "/usr/bin/python3", Args: []string{"/data/s/x.py"},
|
||||
Dir: "/data/s", SkillsDir: "/data/skills",
|
||||
Env: []string{"PATH=/usr/bin:/bin", "HOME=/tmp/h"},
|
||||
})
|
||||
joined := strings.Join(argv, "\x00")
|
||||
musts := []string{
|
||||
"--unshare-all", "--die-with-parent",
|
||||
"--ro-bind\x00/\x00/", // 只读系统根
|
||||
"--tmpfs\x00/home", // 遮蔽真实家目录
|
||||
"--tmpfs\x00/tmp",
|
||||
"--bind\x00/data/s\x00/data/s", // 会话目录唯一可写挂载
|
||||
"--ro-bind\x00/data/skills\x00/data/skills",
|
||||
"--chdir\x00/data/s", "--clearenv",
|
||||
"--setenv\x00PATH", "--setenv\x00HOME",
|
||||
"/bin/bash", "-c",
|
||||
}
|
||||
for _, m := range musts {
|
||||
if !strings.Contains(joined, m) {
|
||||
t.Fatalf("sandbox argv missing %q: %v", m, argv)
|
||||
}
|
||||
}
|
||||
wrapper := argv[len(argv)-1]
|
||||
if !strings.Contains(wrapper, "ulimit -u "+itoa(execSandboxMaxProcs)) ||
|
||||
!strings.Contains(wrapper, "exec '/usr/bin/python3' '/data/s/x.py'") {
|
||||
t.Fatalf("wrapper = %q(须 ulimit -u 限进程数并 exec 白名单解释器)", wrapper)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b [20]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
|
||||
// ---- sandbox 集成(需 bwrap;缺席即跳过)----
|
||||
|
||||
func requireBwrap(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("bwrap"); err != nil {
|
||||
t.Skip("bwrap not available; sandbox integration replayed on capable hosts")
|
||||
}
|
||||
}
|
||||
|
||||
// sandboxRun 经 sandbox 后端执行一段 bash 脚本,返回合并输出与退出描述。
|
||||
func sandboxRun(t *testing.T, script string, timeout time.Duration) (string, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "s.sh")
|
||||
if err := os.WriteFile(p, []byte(script), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
var buf bytes.Buffer
|
||||
spec := ExecSpec{
|
||||
Bin: "/bin/bash", Args: []string{p}, Dir: dir,
|
||||
Env: []string{"PATH=/usr/bin:/bin", "HOME=/home/agent"},
|
||||
}
|
||||
proc, err := sandboxBackend{}.Start(ctx, spec, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
werr := proc.Wait()
|
||||
out := buf.String()
|
||||
state := "exit=0"
|
||||
if werr != nil {
|
||||
if ee, ok := werr.(*exec.ExitError); ok {
|
||||
state = "exit=" + itoa(ee.ExitCode())
|
||||
} else {
|
||||
state = "err:" + werr.Error()
|
||||
}
|
||||
}
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
state = "exit=timeout"
|
||||
}
|
||||
return out, state
|
||||
}
|
||||
|
||||
func TestSandboxHomeHidden(t *testing.T) {
|
||||
requireBwrap(t)
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" || !strings.HasPrefix(home, "/home/") {
|
||||
t.Skipf("home %q not under /home; fixture would not be shadowed", home)
|
||||
}
|
||||
secret := filepath.Join(home, ".agent-ext16-marker")
|
||||
if err := os.WriteFile(secret, []byte("topsecret"), 0o600); err != nil {
|
||||
t.Skipf("cannot create home fixture: %v", err)
|
||||
}
|
||||
defer os.Remove(secret)
|
||||
out, state := sandboxRun(t, "cat \"$HOME/.agent-ext16-marker\" 2>err.txt; cat err.txt; exit 7", 30*time.Second)
|
||||
if strings.Contains(out, "topsecret") {
|
||||
t.Fatalf("sandbox read real home secret (state=%s out=%q)", state, out)
|
||||
}
|
||||
if state == "exit=0" || state == "exit=7" && strings.Contains(out, "topsecret") {
|
||||
t.Fatalf("unexpected success: state=%s out=%q", state, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxSiblingSessionUnreadable(t *testing.T) {
|
||||
requireBwrap(t)
|
||||
base := t.TempDir()
|
||||
a, b := filepath.Join(base, "session-a"), filepath.Join(base, "session-b")
|
||||
if err := os.MkdirAll(a, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(b, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
marker := filepath.Join(b, "other-session.txt")
|
||||
if err := os.WriteFile(marker, []byte("others-data"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
var buf bytes.Buffer
|
||||
spec := ExecSpec{
|
||||
Bin: "/bin/bash", Args: []string{"-c", "cat '" + marker + "'"}, Dir: a,
|
||||
Env: []string{"PATH=/usr/bin:/bin", "HOME=/home/agent"},
|
||||
}
|
||||
proc, err := sandboxBackend{}.Start(ctx, spec, &buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
werr := proc.Wait()
|
||||
if werr == nil || buf.String() == "others-data" {
|
||||
t.Fatalf("sibling session readable inside sandbox: wait=%v out=%q", werr, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxNoNetwork(t *testing.T) {
|
||||
requireBwrap(t)
|
||||
out, state := sandboxRun(t,
|
||||
"timeout 5 bash -c 'echo > /dev/tcp/1.1.1.1/80' 2>/dev/null && echo NET-OPEN || echo NET-BLOCKED",
|
||||
30*time.Second)
|
||||
if strings.Contains(out, "NET-OPEN") {
|
||||
t.Fatalf("network reachable in sandbox (state=%s)", state)
|
||||
}
|
||||
if !strings.Contains(out, "NET-BLOCKED") {
|
||||
t.Fatalf("probe did not run: state=%s out=%q", state, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxForkBombContained(t *testing.T) {
|
||||
requireBwrap(t)
|
||||
// 确定性进程数压测(替代经典 :(){ :|:& };:——其在沙箱 user namespace 内
|
||||
// 递归行为依 bash 实现而变,不适合做门禁):400 个并发 sleep 撞 ulimit -u
|
||||
// 256 的墙,fork: retry 即限额生效的直接证据。
|
||||
start := time.Now()
|
||||
out, state := sandboxRun(t,
|
||||
"n=0; for i in $(seq 1 400); do sleep 5 & n=$((n+1)); done; echo spawned=$n; wait",
|
||||
20*time.Second)
|
||||
elapsed := time.Since(start)
|
||||
if elapsed > 40*time.Second {
|
||||
t.Fatalf("spawn storm stalled the runner: %s", elapsed)
|
||||
}
|
||||
if !strings.Contains(out, "fork: retry") {
|
||||
t.Fatalf("process limit not enforced (state=%s out=%.200q)", state, out)
|
||||
}
|
||||
if state == "exit=0" && strings.Contains(out, "spawned=400") {
|
||||
t.Fatalf("400 processes spawned unbounded: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxOrphansDieWithNamespace(t *testing.T) {
|
||||
requireBwrap(t)
|
||||
out, state := sandboxRun(t, ":(){ :|:& };:; echo gone", 30*time.Second)
|
||||
if state != "exit=0" || !strings.Contains(out, "gone") {
|
||||
t.Fatalf("state=%s out=%q", state, out)
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond) // 给孤儿进程留存活窗口
|
||||
hb, err := exec.Command("bash", "-c", "ps -eo args | grep -c '[s]\\.sh' || true").Output()
|
||||
if err != nil {
|
||||
t.Skipf("host ps probe failed: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(hb)) != "0" {
|
||||
t.Fatalf("orphaned bomb survived outside the namespace: %s", hb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxTimeoutKills(t *testing.T) {
|
||||
requireBwrap(t)
|
||||
start := time.Now()
|
||||
_, state := sandboxRun(t, "sleep 60", 3*time.Second)
|
||||
if d := time.Since(start); d > 20*time.Second {
|
||||
t.Fatalf("timeout did not kill the sandbox: %s", d)
|
||||
}
|
||||
if state != "exit=timeout" {
|
||||
t.Fatalf("state = %s, want exit=timeout", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxSessionWriteVisible(t *testing.T) {
|
||||
requireBwrap(t)
|
||||
dir := t.TempDir()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
script := filepath.Join(dir, "w.sh")
|
||||
if err := os.WriteFile(script, []byte("printf 'docx-ish' > \"$(dirname \"$0\")/out.txt\"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
spec := ExecSpec{
|
||||
Bin: "/bin/bash", Args: []string{script}, Dir: dir,
|
||||
Env: []string{"PATH=/usr/bin:/bin", "HOME=/home/agent"},
|
||||
}
|
||||
proc, err := sandboxBackend{}.Start(ctx, spec, &buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := proc.Wait(); err != nil {
|
||||
t.Fatalf("sandbox task failed: %v out=%q", err, buf.String())
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dir, "out.txt"))
|
||||
if err != nil || string(got) != "docx-ish" {
|
||||
t.Fatalf("product not visible on host: %v %q", err, got)
|
||||
}
|
||||
}
|
||||
|
||||
// direct 档回归:后端抽象后 runInterpreter 语义不变(退出码/超时文本口径)。
|
||||
func TestDirectBackendRunInterpreterUnchanged(t *testing.T) {
|
||||
if _, err := exec.LookPath("bash"); err != nil {
|
||||
t.Skip("bash missing")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "x.sh")
|
||||
if err := os.WriteFile(p, []byte("echo hi; exit 7\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := runInterpreter(context.Background(), "bash", p, dir, 30, nil)
|
||||
if err != nil || !strings.HasPrefix(got, "exit=7\n") || !strings.Contains(got, "hi") {
|
||||
t.Fatalf("direct regression: err=%v got=%q", err, got)
|
||||
}
|
||||
p2 := filepath.Join(dir, "t.sh")
|
||||
if err := os.WriteFile(p2, []byte("sleep 60\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = runInterpreter(context.Background(), "bash", p2, dir, 2, nil)
|
||||
if err != nil || !strings.HasPrefix(got, "exit=timeout(2s)") {
|
||||
t.Fatalf("direct timeout regression: err=%v got=%q", err, got)
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,7 @@ func resolveInterpreter(name string) (string, error) {
|
||||
}
|
||||
|
||||
// runInterpreter 启动白名单解释器执行落盘脚本(脚本路径为唯一参数)。
|
||||
// 进程启动经 EXT-16 执行后端(direct=现状直启 / sandbox=bwrap 隔离档)。
|
||||
// 非零退出、超时与请求取消都是正常反馈,以文本返回;仅启动失败算错误。
|
||||
func runInterpreter(parent context.Context, interpreter, scriptPath, dir string, timeoutS int, sink func(stream, text string)) (string, error) {
|
||||
if parent == nil {
|
||||
@@ -145,24 +146,11 @@ func runInterpreter(parent context.Context, interpreter, scriptPath, dir string,
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutS)*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, bin, scriptPath)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = execEnv()
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process == nil {
|
||||
return os.ErrProcessDone
|
||||
}
|
||||
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
if errors.Is(err, syscall.ESRCH) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
spec := ExecSpec{
|
||||
Bin: bin, Args: []string{scriptPath}, Dir: dir, Env: execEnv(),
|
||||
SkillsDir: skillsRoot, Timeout: time.Duration(timeoutS) * time.Second,
|
||||
}
|
||||
pr, pw := io.Pipe()
|
||||
cmd.Stdout = pw
|
||||
cmd.Stderr = pw
|
||||
var buf bytes.Buffer
|
||||
readDone := make(chan struct{})
|
||||
go func() {
|
||||
@@ -183,13 +171,13 @@ func runInterpreter(parent context.Context, interpreter, scriptPath, dir string,
|
||||
}
|
||||
}
|
||||
}()
|
||||
err = cmd.Start()
|
||||
proc, err := currentExecBackend().Start(ctx, spec, pw)
|
||||
if err != nil {
|
||||
_ = pw.Close()
|
||||
<-readDone
|
||||
return "", fmt.Errorf("%s: %w", interpreter, err)
|
||||
}
|
||||
err = cmd.Wait()
|
||||
err = proc.Wait()
|
||||
_ = pw.Close()
|
||||
<-readDone
|
||||
text := truncateOutput(buf.Bytes())
|
||||
|
||||
@@ -20,9 +20,9 @@ func (dummyCtx) PeerConversations() ([]core.Conversation, error) { return nil, n
|
||||
func (dummyCtx) PeerConversationMessages(id string, limit int) ([]core.Message, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (dummyCtx) OwnerID() string { return "o" }
|
||||
func (dummyCtx) WorkspaceRoot() string { return "" }
|
||||
func (dummyCtx) SettingBool(string, bool) bool { return false }
|
||||
func (dummyCtx) OwnerID() string { return "o" }
|
||||
func (dummyCtx) WorkspaceRoot() string { return "" }
|
||||
func (dummyCtx) SettingBool(string, bool) bool { return false }
|
||||
|
||||
// TestHTTPExecutorTemplateAndPath:模板变量注入、结果路径裁剪、错误状态。
|
||||
func TestHTTPExecutorTemplateAndPath(t *testing.T) {
|
||||
|
||||
@@ -22,7 +22,23 @@ type TavilySettings struct {
|
||||
// RegisterSearchTools 注册 tavily_search(完整参数面)与 web_search(别名)。
|
||||
// 工具面与 agent-native runtime/tools/search.go 逐字段对齐(名称/参数/输出/失败降级文案)。
|
||||
func RegisterSearchTools(r *Registry, provider agenttools.SearchProvider) error {
|
||||
return registerSearch(r, func(ToolExecCtx) agenttools.SearchProvider { return provider })
|
||||
}
|
||||
|
||||
// RegisterLazySearchTools 注册按调用时取密钥的搜索工具:provider 在每次调用时
|
||||
// 重建(设置页/svc 播种改了 tavily 密钥即刻生效,不再需要重启进程——
|
||||
// 实测播种后 tavily 工具要重启才注册)。configure 返回 nil 时不静默降级,
|
||||
// 返回明确的未配置提示。
|
||||
func RegisterLazySearchTools(r *Registry, configure func() agenttools.SearchProvider) error {
|
||||
return registerSearch(r, func(ToolExecCtx) agenttools.SearchProvider { return configure() })
|
||||
}
|
||||
|
||||
func registerSearch(r *Registry, providerFor func(ToolExecCtx) agenttools.SearchProvider) error {
|
||||
tavily := func(rc ToolExecCtx, args json.RawMessage) (string, error) {
|
||||
provider := providerFor(rc)
|
||||
if provider == nil {
|
||||
return "", fmt.Errorf("搜索未配置:settings.tools.tavily.apiKeys(或对应 secret)为空;配置后立即生效,无需重启")
|
||||
}
|
||||
return execSearch(provider, args)
|
||||
}
|
||||
if err := r.Register(core.ToolDesc{
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
"agent-system-backend/eval"
|
||||
"agent-system-backend/internal/workflow"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// ab_demo_test.go — Q3 WP5 验收:同批任务(含 demo.brief 场景)经两个入口
|
||||
|
||||
@@ -35,20 +35,20 @@ import (
|
||||
|
||||
// 宿主 intent 步的意图 ID(目录由本包合成,处理函数在 host.go)。
|
||||
const (
|
||||
IntentHitl = "wf.hitl"
|
||||
IntentTool = "wf.tool"
|
||||
IntentHitl = "wf.hitl"
|
||||
IntentTool = "wf.tool"
|
||||
IntentArtifact = "wf.artifact"
|
||||
IntentCard = "wf.card"
|
||||
IntentScript = "wf.script"
|
||||
IntentCritic = "wf.critic"
|
||||
IntentCard = "wf.card"
|
||||
IntentScript = "wf.script"
|
||||
IntentCritic = "wf.critic"
|
||||
)
|
||||
|
||||
// CriticPair 记录一个「原步 ↔ 评审步」映射(收口后校验最终判定用)。
|
||||
type CriticPair struct {
|
||||
StepID string // 被评审的原步 ID
|
||||
StepTitle string
|
||||
EvalID string // 评审步 ID(<StepID>_critic)
|
||||
VerdictField string // 黑板判定字段名
|
||||
StepID string // 被评审的原步 ID
|
||||
StepTitle string
|
||||
EvalID string // 评审步 ID(<StepID>_critic)
|
||||
VerdictField string // 黑板判定字段名
|
||||
}
|
||||
|
||||
// Compiled 是一次编译产物。
|
||||
@@ -86,7 +86,7 @@ func Compile(def core.WorkflowDef, vars workflow.Vars, opts CompileOptions, know
|
||||
var steps []squad.StepSpec
|
||||
var agents []squad.RoleSpec
|
||||
agentSeen := map[string]bool{}
|
||||
prevDef := "" // 上一个 def 步 ID(artifact 空内容的 prev 回退)
|
||||
prevDef := "" // 上一个 def 步 ID(artifact 空内容的 prev 回退)
|
||||
prevChain := "" // 图链上的前驱(含评审步)
|
||||
|
||||
for i, raw := range def.Steps {
|
||||
@@ -376,8 +376,8 @@ func buildCatalog() *squad.IntentCatalog {
|
||||
Revision: 1,
|
||||
Markdown: "# 工作流宿主能力\n\n- wf.hitl 人工问答\n- wf.tool 宿主工具\n- wf.artifact 工件落库\n- wf.card 收件箱卡\n- wf.script 脚本宿主\n- wf.critic 步骤验收\n",
|
||||
Specs: map[string]squad.IntentSpecView{
|
||||
IntentHitl: spec(IntentHitl, "人工问答步", req("question"), opt("step_id"), opt("kind"), opt("choices"), opt("allow_free_input"), opt("free_input_hint")),
|
||||
IntentTool: spec(IntentTool, "宿主工具步", req("tool"), opt("step_id"), opt("args")),
|
||||
IntentHitl: spec(IntentHitl, "人工问答步", req("question"), opt("step_id"), opt("kind"), opt("choices"), opt("allow_free_input"), opt("free_input_hint")),
|
||||
IntentTool: spec(IntentTool, "宿主工具步", req("tool"), opt("step_id"), opt("args")),
|
||||
IntentArtifact: spec(IntentArtifact, "工件步", req("name"), opt("step_id"), opt("content"), opt("content_ref")),
|
||||
IntentCard: spec(IntentCard, "收件箱卡步", req("template"), opt("step_id")),
|
||||
IntentScript: spec(IntentScript, "脚本步", req("path"), opt("step_id"), opt("model")),
|
||||
|
||||
@@ -19,7 +19,9 @@ import (
|
||||
// 2.4.0-experimental:EXT-11 新增 SetScopedHostFactory/NewScopedHost(按 owner+pluginID
|
||||
// 重建受限宿主句柄,供下游恢复路径)。
|
||||
// 2.5.0-experimental:Options.DisableExecTools(执行原语启动级硬关闭)。
|
||||
const SurfaceVersion = "2.5.0-experimental"
|
||||
// 2.6.0-experimental:Options.ExecBackend(EXT-16 执行后端 direct|sandbox)。
|
||||
// 2.7.0-experimental:Registry.AddIntent 与 IntentSpec(G2-2 意图声明面)。
|
||||
const SurfaceVersion = "2.7.0-experimental"
|
||||
|
||||
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
|
||||
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
|
||||
@@ -40,6 +42,20 @@ type Registry interface {
|
||||
// RegisterResourceAuthorizer 注册 EXT-8 资源授权适配器(进程内扩展);
|
||||
// 首个注册者生效。声明式扩展不支持(回调需宿主进程内代码)。
|
||||
RegisterResourceAuthorizer(authz ResourceAuthorizer) error
|
||||
// AddIntent 声明自然语言意图(G2-2):短语命中后路由到已注册命令——
|
||||
// 经同一命令总线分发(同审计信封与 HITL 收口)。规格见 IntentSpec。
|
||||
AddIntent(spec json.RawMessage) error
|
||||
}
|
||||
|
||||
// IntentSpec 意图声明规格(G2-2):id 形如 <domain>.<intent>;phrases 是触发
|
||||
// 短语(宿主归一后包含匹配,最长短语优先);command 须为已注册命令 id;
|
||||
// params 为参数模板(调用方可覆盖)。宿主侧路由入口:system.intent 命令。
|
||||
type IntentSpec struct {
|
||||
ID string `json:"id"`
|
||||
Phrases []string `json:"phrases"`
|
||||
Command string `json:"command"`
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// InProc 是随下游二进制编译进来的扩展。
|
||||
@@ -68,6 +84,11 @@ type Options struct {
|
||||
// register_artifact 及小队内核执行配置):用户设置 runtime.exec 无法覆盖。
|
||||
// 零值=false=现状。多用户共享部署在执行隔离就位前应置 true。
|
||||
DisableExecTools bool
|
||||
// ExecBackend 执行后端(EXT-16):空/direct=现状直启;sandbox=bwrap 隔离档
|
||||
// (模型生成代码按不可信对待:断网、只读系统根、tmpfs 遮蔽 /home 与 /tmp、
|
||||
// 仅会话工作目录与技能目录挂载、进程数限额、超时杀整个沙箱)。
|
||||
// DisableExecTools 优先级高于此选择。
|
||||
ExecBackend string
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -15,6 +15,7 @@ func init() {
|
||||
DisableUploadIndexing: opt.DisableUploadIndexing,
|
||||
DisableConvCardMirror: opt.DisableConvCardMirror,
|
||||
DisableExecTools: opt.DisableExecTools,
|
||||
ExecBackend: opt.ExecBackend,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ Host.TaskCreate
|
||||
Host.TaskUpdate
|
||||
HostClient
|
||||
InProc
|
||||
IntentSpec
|
||||
Main
|
||||
NewHostClient
|
||||
NewScopedHost
|
||||
@@ -27,6 +28,7 @@ RegisterCompiled
|
||||
Registry
|
||||
Registry.AddCommand
|
||||
Registry.AddCommandHandler
|
||||
Registry.AddIntent
|
||||
Registry.AddSkill
|
||||
Registry.Note
|
||||
Registry.RegisterResourceAuthorizer
|
||||
|
||||
Reference in New Issue
Block a user