merge: sync official main into PR branch
This commit is contained in:
commit
d803ae2b11
23 files changed
+1325
-68
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
Reference in new issue
Block a user