merge: sync official main into PR branch

This commit is contained in:
pphua committed 2026-08-28 14:14:41 +08:00
commit d803ae2b11
23 files changed
+1325 -68

No files matched your search

+133
View File
@@ -0,0 +1,133 @@
package server
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/llmrec"
"github.com/Autumn-27/norma/llm"
"github.com/Autumn-27/norma/transcript"
)
// Full chain against a real PG: Recorder → norma provider → capturing transport
// → llm_records. The unit tests cover each hop; this proves the raw bodies
// actually survive all the way into the row an operator opens when debugging.
func TestRecorderPersistsRawWireBodies(t *testing.T) {
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
defer m.Close()
pg := m.PG()
if err := pg.EnsureLLMRecordsTable(); err != nil {
t.Fatalf("ensure table: %v", err)
}
sse := strings.Join([]string{
`event: message_start`,
`data: {"type":"message_start","message":{"id":"msg_raw","usage":{"input_tokens":5,"output_tokens":1}}}`,
``,
`event: content_block_delta`,
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"probing"}}`,
``,
// A tool_use block: the normalized response body drops these entirely, so
// its presence in raw_response is the whole point of the feature.
`event: content_block_start`,
`data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_raw","name":"bash"}}`,
``,
`event: content_block_delta`,
`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"command\":\"id\"}"}}`,
``,
`event: message_delta`,
`data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":9}}`,
``,
`event: message_stop`,
`data: {"type":"message_stop"}`,
``,
}, "\n")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "text/event-stream")
_, _ = io.WriteString(w, sse)
}))
defer srv.Close()
cfg := agent.Config{Format: llm.FormatAnthropic, BaseURL: srv.URL, APIKey: "k", Model: "claude-raw-test"}
inner, err := cfg.NewProvider()
if err != nil {
t.Fatalf("NewProvider: %v", err)
}
const session = "exp999999-rawtest"
rec := llmrec.Wrap(inner, pg, cfg.Model, "raw-test-profile", "", "", func() bool { return true })
// Clean up whatever this test writes, whether or not it passes. Must be a
// defer registered after `defer m.Close()` (LIFO puts it first) — a
// t.Cleanup would run after the manager already closed the pool.
defer func() {
if _, err := pg.Exec(`DELETE FROM llm_records WHERE session_id = $1`, session); err != nil {
t.Logf("cleanup: %v", err)
}
}()
ctx := transcript.WithSessionID(context.Background(), session)
for _, err := range rec.Stream(ctx, llm.CompletionRequest{
System: []string{"system prompt"},
Messages: []llm.Message{{Role: "user", Content: []llm.ContentBlock{{Type: "text", Text: "go"}}}},
Tools: []llm.ToolSchema{{
Name: "bash",
Description: "run a shell command",
InputSchema: map[string]any{"type": "object"},
}},
MaxTokens: 512,
}) {
if err != nil {
t.Fatalf("stream: %v", err)
}
}
var id int64
if err := pg.QueryRow(`SELECT id FROM llm_records WHERE session_id=$1 ORDER BY id DESC LIMIT 1`, session).Scan(&id); err != nil {
t.Fatalf("no record written: %v", err)
}
got, err := pg.GetLLMRecord(id)
if err != nil {
t.Fatalf("GetLLMRecord: %v", err)
}
if got.RawResponse != sse {
t.Errorf("raw_response is not the wire bytes:\n got: %q\nwant: %q", got.RawResponse, sse)
}
// The tool call is absent from the normalized body but present in the raw one
// — the specific gap this feature closes.
if strings.Contains(got.ResponseBody, "toolu_raw") {
t.Error("normalized body unexpectedly contains the tool_use block; test no longer proves the gap")
}
if !strings.Contains(got.RawResponse, "toolu_raw") {
t.Error("raw_response lost the tool_use block")
}
var body map[string]any
if err := json.Unmarshal([]byte(got.RawRequest), &body); err != nil {
t.Fatalf("raw_request is not valid JSON: %v\n%s", err, got.RawRequest)
}
if body["model"] != "claude-raw-test" {
t.Errorf("raw_request model=%v", body["model"])
}
tools, _ := body["tools"].([]any)
if len(tools) != 1 {
t.Fatalf("raw_request tools=%v", body["tools"])
}
if tool, _ := tools[0].(map[string]any); tool["input_schema"] == nil {
t.Errorf("raw_request lost the tool schema: %v", tool)
}
// The normalized request keeps tool names only, so the schema is unique to raw.
if strings.Contains(got.RequestBody, "input_schema") {
t.Error("normalized request unexpectedly carries schemas; test no longer proves the gap")
}
}
+60
View File
@@ -573,6 +573,66 @@ func (s *Server) reseedMainAgentPrompt() {
log.Printf("[prompts] mainagent 提示词已追加新默认版本(加入目标达成后反问建目标,一次性)")
}
// reseedPlannerPrompt 把 planner 提示词刷成【当前代码默认】——默认正文重写了「0 意图」的正当理由
// (从笼统"最常见"改为"仅在已覆盖/等待在跑 work 依赖时才 0 意图")、并新增「量化验收核对」(覆盖度等
// 可量化目标未达标禁止 prove_goal)。SeedPromptIfEmpty 首插入only,旧库已有版本收不到,故用版本管理
// 【追加一个新版本】并切过去(ResetPromptToDefault),旧版本仍保留在历史里,用户若自定义过可从版本记录
// 找回。settings flag 守卫 → 只做一次。全新库无需处理(SeedPromptIfEmpty 已 seed 最新默认)。与
// reseedGoalsPrompt 完全同构。
func (s *Server) reseedPlannerPrompt() {
const flag = "planner_prompt_zerointent_acceptance_v1"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
defer func() { _ = s.m.pg.SetSetting(flag, "true") }() // 无论成功与否只尝试一次
a, err := s.m.pg.GetAgentByKey("planner")
if err != nil || a == nil {
return // 全新库尚未建 agent 行时,seedPrompts 会直接 seed 最新默认,无需此迁移
}
tmpl := agent.BuiltinPromptSeeds()["planner"]
if tmpl == "" {
return
}
// 全新库 seedPrompts 已 seed 最新默认 → 当前版本已等于代码默认,不必再追加重复版本。
if cur, err := s.m.pg.CurrentPrompt(a.ID); err == nil && cur == tmpl {
return
}
if _, err := s.m.pg.ResetPromptToDefault(a.ID, tmpl); err != nil {
log.Printf("[prompts] planner 提示词重刷为新默认失败: %v", err)
return
}
log.Printf("[prompts] planner 提示词已追加新默认版本(重写0意图理由+加量化验收核对,一次性)")
}
// reseedWorkerPrompt 把 worker 提示词刷成【当前代码默认】——默认正文强化了「否定结论的证据门槛」
// (未穷尽手段/证据弱一律标 inferred,别用轻率 observed 否定焊死路线)。SeedPromptIfEmpty 首插入only,
// 旧库已有版本收不到,故用版本管理【追加一个新版本】并切过去,旧版本仍保留在历史里可找回。settings flag
// 守卫 → 只做一次。全新库无需处理。与 reseedGoalsPrompt 完全同构。
func (s *Server) reseedWorkerPrompt() {
const flag = "worker_prompt_negfact_threshold_v1"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
defer func() { _ = s.m.pg.SetSetting(flag, "true") }() // 无论成功与否只尝试一次
a, err := s.m.pg.GetAgentByKey("worker")
if err != nil || a == nil {
return // 全新库尚未建 agent 行时,seedPrompts 会直接 seed 最新默认,无需此迁移
}
tmpl := agent.BuiltinPromptSeeds()["worker"]
if tmpl == "" {
return
}
// 全新库 seedPrompts 已 seed 最新默认 → 当前版本已等于代码默认,不必再追加重复版本。
if cur, err := s.m.pg.CurrentPrompt(a.ID); err == nil && cur == tmpl {
return
}
if _, err := s.m.pg.ResetPromptToDefault(a.ID, tmpl); err != nil {
log.Printf("[prompts] worker 提示词重刷为新默认失败: %v", err)
return
}
log.Printf("[prompts] worker 提示词已追加新默认版本(加否定结论证据门槛,一次性)")
}
// seedReporterAgent 预置一个「报告撰写」自定义 agent(builtin=false,可在 UI 编辑/删除):
// 绑定 update_finding_report + 任务查询工具,并挂一个「report_finding 被调用即触发」的
// 触发器 —— 每登记一个漏洞就唤起它写详细报告。一次性(settings flag 守卫):用户删掉后不再重建。
+2 -3
View File
@@ -323,10 +323,9 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) {
// saveLLMConfig persists the LLM config as the active "default" profile in PG.
func (s *Server) saveLLMConfig(cfg agent.Config) error {
// cfg.Provider() already returns one of the three valid format strings
// (anthropic / openai / openai-responses), matching the DB CHECK constraint.
format := cfg.Provider()
if format != "anthropic" {
format = "openai"
}
var id int64
// agent.Config carries no failover fields, so carry the stored ones forward —
// otherwise this legacy endpoint would silently reset the profile's priority,
+4 -2
View File
@@ -1926,11 +1926,13 @@ func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) {
var candidates []candidate
switch provider {
case "openai":
case "openai", "openai-responses":
// Responses API shares the OpenAI model list at /v1/models; tolerate a full
// endpoint URL of either format.
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
b := strings.TrimRight(strings.TrimSuffix(baseURL, "/chat/completions"), "/")
b := strings.TrimRight(strings.TrimSuffix(strings.TrimSuffix(baseURL, "/chat/completions"), "/responses"), "/")
candidates = append(candidates, candidate{b + "/models", bearerHdr()})
default: // anthropic
if baseURL == "" {