From 4d9580ebfae17912b199582fd0329341ffef9a3a Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:55:27 -0400 Subject: [PATCH 01/14] =?UTF-8?q?feat(ops):=20=E6=96=B0=E5=A2=9E=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E5=AF=86=E7=A0=81=E9=87=8D=E7=BD=AE=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=20reset-password.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 登录用户名固定 ARTEX,密码以 bcrypt 存于 settings 表 auth.password_hash。 脚本连库后用 pgcrypto 在库内生成 bcrypt(gen_salt('bf',10)),与后端 golang.org/x/crypto/bcrypt 校验兼容,写回该键,重置后无需重启。 - 两种部署:local(宿主机 psql)与 docker(compose/exec 在 postgres 容器内跑, 因 compose 默认不暴露 5432)。 - 连接信息优先级:命令行 > --dsn/$ARTEX_PG_DSN > config.json 的 database.* (python3 解析,缺则 grep 后备)。 - 安全:新密码经环境变量 + psql \getenv 传入,不进 argv;SQL 用 :'newpw' 自动转义防注入;DB 密码走 PGPASSWORD;docker 用 -e 名字继承、不带值。 验证: 临时库实测三条路径(显式 dsn / config.json / 含 ';DROP TABLE' 特殊 字符密码),写回的 hash 均能被 Go bcrypt 接受、错误密码被拒、注入未生效; bash -n 通过。全程未触碰真实 artex 库。 --- CHANGELOG.md | 6 ++ reset-password.sh | 235 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100755 reset-password.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index a3fbdeb..09e4d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ## [Unreleased] +### 运维 + +#### 新增的功能 + +- 新增 `reset-password.sh` 重置管理员(用户名固定 `ARTEX`)密码:支持 local / docker 两种部署,连接信息可显式指定或自动从 `--dsn`/`$ARTEX_PG_DSN`/`config.json` 读取;在库内用 `pgcrypto` 生成与后端登录兼容的 bcrypt 哈希并写回 `settings.auth.password_hash`,重置后无需重启服务。密码经环境变量传入、不进入进程 argv,并做转义防注入。 + ## [0.3.4] - 2026-08-24 ### UI diff --git a/reset-password.sh b/reset-password.sh new file mode 100755 index 0000000..d48f3b2 --- /dev/null +++ b/reset-password.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +# ============================================================================= +# ARTEX 管理员密码重置脚本 +# +# 登录用户名固定为 ARTEX;密码以 bcrypt 哈希存放在数据库 settings 表的 +# auth.password_hash 键。本脚本连上数据库后,用 pgcrypto 在库内生成 bcrypt 哈希 +# 并写回该键——与后端登录校验(golang.org/x/crypto/bcrypt)完全兼容。 +# +# 两种部署: +# local (默认)—— 宿主机直接用 psql 连数据库。连接信息按以下优先级获取: +# 命令行参数 > --dsn/$ARTEX_PG_DSN > config.json 的 database.* +# docker —— 通过 `docker compose exec`(或 `docker exec`)在 postgres +# 容器内执行 psql(compose 默认不对宿主暴露 5432,故走容器内)。 +# +# 用法示例: +# ./reset-password.sh # 本地,自动读 config.json/环境,交互输入新密码 +# ./reset-password.sh -p 'NewPass!' # 本地,直接给定新密码 +# ./reset-password.sh --dsn postgres://u:p@h:5432/artex +# ./reset-password.sh -H 127.0.0.1 -P 5433 -U autopentest -W pass -d artex +# ./reset-password.sh -m docker # docker 部署(读 .env 的 POSTGRES_*) +# ./reset-password.sh -m docker -c pg容器名 --exec docker +# +# 安全:新密码经环境变量 + psql \getenv 传入(不进入进程 argv),并用 :'var' +# 自动转义(防 SQL 注入);数据库密码经 PGPASSWORD 传递,同样不进 argv。 +# ============================================================================= +set -euo pipefail + +PASS_KEY="auth.password_hash" +BCRYPT_COST=10 + +MODE="" # local | docker(空=自动判定) +DSN="" +HOST="" PORT="" USER="" DBPASS="" DBNAME="" SSLMODE="" +CONFIG="" +CONTAINER="" # docker 模式的 postgres 服务/容器名(默认 postgres) +EXEC_KIND="" # compose | docker(docker 模式下用哪种 exec;空=自动) +NEWPASS="" +ASSUME_YES=0 + +die() { echo "错误:$*" >&2; exit 1; } +info() { echo "· $*" >&2; } + +usage() { sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; exit 0; } + +# ---- 参数解析 ------------------------------------------------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + -m|--mode) MODE="${2:-}"; shift 2 ;; + --dsn) DSN="${2:-}"; shift 2 ;; + -H|--host) HOST="${2:-}"; shift 2 ;; + -P|--port) PORT="${2:-}"; shift 2 ;; + -U|--user) USER="${2:-}"; shift 2 ;; + -W|--db-password) DBPASS="${2:-}"; shift 2 ;; + -d|--dbname) DBNAME="${2:-}"; shift 2 ;; + --sslmode) SSLMODE="${2:-}"; shift 2 ;; + --config) CONFIG="${2:-}"; shift 2 ;; + -c|--container) CONTAINER="${2:-}"; shift 2 ;; + --exec) EXEC_KIND="${2:-}"; shift 2 ;; + -p|--new-password) NEWPASS="${2:-}"; shift 2 ;; + -y|--yes) ASSUME_YES=1; shift ;; + -h|--help) usage ;; + *) die "未知参数:$1(-h 查看用法)" ;; + esac +done + +# ---- 从 config.json 读取 database.*(仅 local 模式、且未显式给出连接时)----- +# 优先用 python3 解析(健壮);缺 python3 时退回 grep(config.json 为规整分字段)。 +read_config_json() { + local path="$1" + [[ -f "$path" ]] || return 1 + if command -v python3 >/dev/null 2>&1; then + python3 - "$path" <<'PY' +import json, sys +try: + d = json.load(open(sys.argv[1])).get("database", {}) +except Exception: + sys.exit(1) +# 支持直接给 dsn,或分字段 +if d.get("dsn"): + print("DSN\t" + d["dsn"]); sys.exit(0) +for k in ("host","port","user","password","dbname","sslmode"): + if d.get(k) is not None: + print(k.upper() + "\t" + str(d[k])) +PY + else + # 极简后备:逐键 grep(值为字符串或数字) + local k + for k in host port user password dbname sslmode; do + local v + v=$(grep -oE "\"$k\"[[:space:]]*:[[:space:]]*(\"[^\"]*\"|[0-9]+)" "$path" 2>/dev/null \ + | head -1 | sed -E "s/.*:[[:space:]]*//; s/^\"//; s/\"$//") || true + [[ -n "$v" ]] && echo -e "${k^^}\t$v" + done + fi +} + +apply_config_fields() { + local line key val + while IFS=$'\t' read -r key val; do + [[ -z "$key" ]] && continue + case "$key" in + DSN) [[ -z "$DSN" ]] && DSN="$val" ;; + HOST) [[ -z "$HOST" ]] && HOST="$val" ;; + PORT) [[ -z "$PORT" ]] && PORT="$val" ;; + USER) [[ -z "$USER" ]] && USER="$val" ;; + PASSWORD) [[ -z "$DBPASS" ]] && DBPASS="$val" ;; + DBNAME) [[ -z "$DBNAME" ]] && DBNAME="$val" ;; + SSLMODE) [[ -z "$SSLMODE" ]] && SSLMODE="$val" ;; + esac + done +} + +# ---- 自动判定模式 --------------------------------------------------------- +if [[ -z "$MODE" ]]; then + if [[ -n "$DSN$HOST$USER$DBNAME" || -n "${ARTEX_PG_DSN:-}" || -f "${CONFIG:-config.json}" ]]; then + MODE="local" + elif command -v docker >/dev/null 2>&1 && [[ -f docker-compose.yml ]]; then + MODE="docker" + else + MODE="local" + fi +fi +info "部署模式:$MODE" + +# ---- 采集新密码 ----------------------------------------------------------- +if [[ -z "$NEWPASS" ]]; then + read -r -s -p "输入新密码(用户名固定为 ARTEX):" NEWPASS; echo >&2 + [[ -n "$NEWPASS" ]] || die "密码不能为空" + read -r -s -p "再次输入以确认:" NEWPASS2; echo >&2 + [[ "$NEWPASS" == "$NEWPASS2" ]] || die "两次输入不一致" +fi +[[ -n "$NEWPASS" ]] || die "密码不能为空" + +# 通过环境变量把密码交给 psql(\getenv 读取,不进入 argv/ps) +export ARTEX_RESET_NEWPASS="$NEWPASS" + +# 库内生成 bcrypt 并 upsert;密码用 :'newpw' 自动转义。CREATE EXTENSION 幂等, +# 若数据库角色无建扩展权限会在此报错(提示见下方 run 的失败分支)。 +SQL=$(cat < --dsn/$ARTEX_PG_DSN > config.json + if [[ -z "$DSN" && -z "$HOST$USER$DBNAME" ]]; then + [[ -n "${ARTEX_PG_DSN:-}" ]] && DSN="$ARTEX_PG_DSN" + fi + if [[ -z "$DSN" && -z "$HOST$USER$DBNAME" ]]; then + cfg="${CONFIG:-config.json}" + if [[ -f "$cfg" ]]; then + info "从 $cfg 读取数据库配置" + apply_config_fields < <(read_config_json "$cfg") + fi + fi + + command -v psql >/dev/null 2>&1 || die "本机未找到 psql(请安装 postgresql-client,或改用 -m docker)" + + declare -a PSQL_ARGS=() + if [[ -n "$DSN" ]]; then + PSQL_ARGS=("$DSN") + target="$DSN" + else + [[ -n "$USER" ]] || die "缺少数据库用户(-U)或有效的 config.json/DSN" + [[ -n "$DBNAME" ]] || die "缺少数据库名(-d)或有效的 config.json/DSN" + HOST="${HOST:-127.0.0.1}"; PORT="${PORT:-5432}"; SSLMODE="${SSLMODE:-disable}" + PSQL_ARGS=(-h "$HOST" -p "$PORT" -U "$USER" -d "$DBNAME") + [[ -n "$SSLMODE" ]] && export PGSSLMODE="$SSLMODE" + [[ -n "$DBPASS" ]] && export PGPASSWORD="$DBPASS" + target="$USER@$HOST:$PORT/$DBNAME" + fi + + info "目标数据库:$target" + if [[ "$ASSUME_YES" -ne 1 ]]; then + read -r -p "确认在该库重置 ARTEX 密码?[y/N] " ans + [[ "$ans" == "y" || "$ans" == "Y" ]] || die "已取消" + fi + + if ! printf '%s\n' "$SQL" | psql "${PSQL_ARGS[@]}" -v ON_ERROR_STOP=1 -q >/dev/null; then + die "写入失败。若报 pgcrypto 权限/缺失,请用具备建扩展权限的角色,或先手动执行 CREATE EXTENSION pgcrypto。" + fi + +else + # ---- docker ---- + command -v docker >/dev/null 2>&1 || die "未找到 docker" + CONTAINER="${CONTAINER:-postgres}" + + # 选择 exec 方式:优先 docker compose exec(服务名),否则 docker exec(容器名) + if [[ -z "$EXEC_KIND" ]]; then + if docker compose version >/dev/null 2>&1 && [[ -f docker-compose.yml ]]; then + EXEC_KIND="compose" + else + EXEC_KIND="docker" + fi + fi + + # 容器内的 psql 凭据:优先命令行,其次 .env 的 POSTGRES_*,再退回 compose 默认(artex) + if [[ -f .env ]]; then + # shellcheck disable=SC1091 + set -a; . ./.env; set +a + fi + DUSER="${USER:-${POSTGRES_USER:-artex}}" + DNAME="${DBNAME:-${POSTGRES_DB:-artex}}" + [[ -n "$DBPASS" ]] && export PGPASSWORD="$DBPASS" + [[ -z "${PGPASSWORD:-}" && -n "${POSTGRES_PASSWORD:-}" ]] && export PGPASSWORD="$POSTGRES_PASSWORD" + + info "目标:容器 $CONTAINER 内 psql -U $DUSER -d $DNAME(exec=$EXEC_KIND)" + if [[ "$ASSUME_YES" -ne 1 ]]; then + read -r -p "确认在该容器数据库重置 ARTEX 密码?[y/N] " ans + [[ "$ans" == "y" || "$ans" == "Y" ]] || die "已取消" + fi + + # -e 只带名字不带值 → 从当前环境继承,密码不出现在 docker 命令 argv 里。 + declare -a EXEC_CMD + if [[ "$EXEC_KIND" == "compose" ]]; then + EXEC_CMD=(docker compose exec -T -e ARTEX_RESET_NEWPASS -e PGPASSWORD "$CONTAINER" + psql -U "$DUSER" -d "$DNAME" -v ON_ERROR_STOP=1 -q) + else + EXEC_CMD=(docker exec -i -e ARTEX_RESET_NEWPASS -e PGPASSWORD "$CONTAINER" + psql -U "$DUSER" -d "$DNAME" -v ON_ERROR_STOP=1 -q) + fi + + if ! printf '%s\n' "$SQL" | "${EXEC_CMD[@]}" >/dev/null; then + die "写入失败。请确认容器名(-c)、数据库账号(.env 的 POSTGRES_*),以及角色有 pgcrypto 权限。" + fi +fi + +unset ARTEX_RESET_NEWPASS +echo "✓ 已重置 ARTEX 管理员密码。请用用户名 ARTEX + 新密码登录(无需重启服务)。" From 19a7206d12e066f9a8e9638d7f7888961e6c59ef Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:40:05 -0400 Subject: [PATCH 02/14] =?UTF-8?q?feat(llmrec):=20=E5=BD=95=E5=88=B6=20LLM?= =?UTF-8?q?=20=E8=AF=B7=E6=B1=82/=E5=93=8D=E5=BA=94=20HTTP=20=E5=8E=9F?= =?UTF-8?q?=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 归一化视图(request_body/response_body)会丢弃工具 schema、tool_use 块, 且看不到原始 SSE 帧,排查 provider 侧问题时信息不足。新增在 HTTP transport 层捕获真实 wire body 的能力: - llmrec: 新增 Capture,Recorder 挂到 ctx,quotaAwareTransport 填充。 请求取 GetBody 副本(不消费原 body),响应用 tee 保持流式;norma 内部重试 的多次尝试逐次保留(旧代码会读掉丢弃),单次时为字节级原文。 - db: llm_records 增加 raw_request/raw_response 两列,含旧库 ALTER 迁移。 - web: 录制页详情面板增加「原文」视图切换,及请求/响应内容复制按钮 (兼容非安全上下文的 execCommand 回退)。 - 测试: 单元(capture)、端到端(逐字节比对捕获=服务器实际收到)、 迁移(真实 PG 事务回滚)、全链路(Recorder→provider→PG 落库)。 --- agent/provider.go | 37 ++++- agent/provider_capture_e2e_test.go | 131 ++++++++++++++++ agent/provider_capture_test.go | 125 ++++++++++++++++ db/commands.go | 28 +++- db/llm_records_migrate_test.go | 62 ++++++++ llmrec/capture.go | 140 ++++++++++++++++++ llmrec/capture_test.go | 103 +++++++++++++ llmrec/llmrec.go | 15 +- server/llmrec_raw_test.go | 133 +++++++++++++++++ .../app/(main)/function/llm-records/page.tsx | 86 ++++++++++- web/src/lib/mock/data.ts | 36 +++++ web/src/lib/types.ts | 5 + 12 files changed, 884 insertions(+), 17 deletions(-) create mode 100644 agent/provider_capture_e2e_test.go create mode 100644 agent/provider_capture_test.go create mode 100644 db/llm_records_migrate_test.go create mode 100644 llmrec/capture.go create mode 100644 llmrec/capture_test.go create mode 100644 server/llmrec_raw_test.go diff --git a/agent/provider.go b/agent/provider.go index 6011ada..3a576e7 100644 --- a/agent/provider.go +++ b/agent/provider.go @@ -19,6 +19,7 @@ import ( "strings" "time" + "github.com/Autumn-27/artex/llmrec" "github.com/Autumn-27/norma/agentcore" "github.com/Autumn-27/norma/compaction" "github.com/Autumn-27/norma/llm" @@ -247,10 +248,25 @@ func IsQuotaExhaustedMessage(message string) bool { type quotaAwareTransport struct{ base http.RoundTripper } func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // When LLM recording is on, the Recorder puts a Capture on the context so the + // raw wire bodies can be persisted. This is the only layer that still sees + // them: norma builds the request body internally and decodes the SSE response + // before either reaches the recorder. + capt := llmrec.CaptureFrom(req.Context()) + capt.SetRequest(requestBodySnapshot(req)) + resp, err := t.base.RoundTrip(req) - if err != nil || resp == nil || resp.StatusCode != http.StatusTooManyRequests { + if err != nil || resp == nil { return resp, err } + // Tee rather than read: a 200 is an SSE stream that must keep streaming. The + // 429 branch below reads through this wrapper, so its body lands in the + // capture before being replaced. + resp.Body = capt.TeeResponse(resp.StatusCode, resp.Body) + + if resp.StatusCode != http.StatusTooManyRequests { + return resp, nil + } body, readErr := io.ReadAll(resp.Body) _ = resp.Body.Close() resp.Body = io.NopCloser(bytes.NewReader(body)) @@ -265,6 +281,25 @@ func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error return resp, nil } +// requestBodySnapshot copies an outgoing request body without consuming it. +// norma builds every model request from a *bytes.Reader, so net/http populates +// GetBody and the copy has no effect on what gets sent. +func requestBodySnapshot(req *http.Request) string { + if req.GetBody == nil { + return "" + } + rc, err := req.GetBody() + if err != nil { + return "" + } + defer rc.Close() + b, err := io.ReadAll(rc) + if err != nil { + return "" + } + return string(b) +} + func quotaAwareHTTPClient(proxy string) (*http.Client, error) { transport := http.DefaultTransport.(*http.Transport).Clone() proxy = strings.TrimSpace(proxy) diff --git a/agent/provider_capture_e2e_test.go b/agent/provider_capture_e2e_test.go new file mode 100644 index 0000000..3405f6d --- /dev/null +++ b/agent/provider_capture_e2e_test.go @@ -0,0 +1,131 @@ +package agent + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Autumn-27/artex/llmrec" + "github.com/Autumn-27/norma/llm" +) + +// End-to-end through a real norma provider: the Capture rides the context into +// norma, survives its internal request building, and comes back holding the +// exact bytes buildBody() put on the wire. This is the load-bearing assumption +// of the whole feature — norma must propagate the caller's context down to +// http.NewRequestWithContext. +func TestCapturePropagatesThroughNormaProvider(t *testing.T) { + sse := strings.Join([]string{ + `event: message_start`, + `data: {"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":11,"output_tokens":1}}}`, + ``, + `event: content_block_delta`, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}`, + ``, + `event: message_delta`, + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7}}`, + ``, + `event: message_stop`, + `data: {"type":"message_stop"}`, + ``, + }, "\n") + + // Record exactly what the server receives, so the capture can be compared + // against it byte for byte rather than merely spot-checked for fields. + var gotPath, serverSaw string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + b, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("server read body: %v", err) + } + serverSaw = string(b) + w.Header().Set("content-type", "text/event-stream") + _, _ = io.WriteString(w, sse) + })) + defer srv.Close() + + cfg := Config{ + Format: llm.FormatAnthropic, + BaseURL: srv.URL, + APIKey: "test-key", + Model: "claude-test", + } + prov, err := cfg.NewProvider() + if err != nil { + t.Fatalf("NewProvider: %v", err) + } + + ctx, capt := llmrec.NewCapture(context.Background()) + req := llm.CompletionRequest{ + System: []string{"you are a scanner"}, + 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", "properties": map[string]any{"command": map[string]any{"type": "string"}}}, + }}, + MaxTokens: 1024, + } + + var text strings.Builder + for ev, err := range prov.Stream(ctx, req) { + if err != nil { + t.Fatalf("stream: %v", err) + } + if ev.Type == llm.SETextDelta { + text.WriteString(ev.Text) + } + } + if text.String() != "hello" { + t.Fatalf("stream text=%q, capture interfered with delivery", text.String()) + } + if gotPath != "/v1/messages" { + t.Fatalf("path=%q", gotPath) + } + + // The raw request must be what norma actually sent, not the recorder's + // re-serialization — which is exactly why it carries fields the normalized + // view drops. + raw := capt.RawRequest() + if raw == "" { + t.Fatal("no raw request captured — context did not reach the transport") + } + // The load-bearing claim: what we stored equals, byte for byte, what the + // server received — not merely "has the right fields". + if raw != serverSaw { + t.Fatalf("captured request != what the server received:\n got: %q\nsaw: %q", raw, serverSaw) + } + var body map[string]any + if err := json.Unmarshal([]byte(raw), &body); err != nil { + t.Fatalf("raw request is not valid JSON: %v\n%s", err, raw) + } + if body["model"] != "claude-test" { + t.Errorf("model=%v want claude-test (absent from CompletionRequest)", body["model"]) + } + if body["stream"] != true { + t.Errorf("stream=%v want true", body["stream"]) + } + // The full tool schema is the headline gain: the normalized view keeps names only. + tools, _ := body["tools"].([]any) + if len(tools) != 1 { + t.Fatalf("tools=%v", body["tools"]) + } + tool, _ := tools[0].(map[string]any) + if tool["description"] != "run a shell command" { + t.Errorf("tool description missing: %v", tool) + } + if tool["input_schema"] == nil { + t.Errorf("tool input_schema missing: %v", tool) + } + + // And the response is the untouched SSE frames, including the events the + // recorder never turns into stored output. + if capt.RawResponse() != sse { + t.Errorf("RawResponse mismatch:\n got: %q\nwant: %q", capt.RawResponse(), sse) + } +} diff --git a/agent/provider_capture_test.go b/agent/provider_capture_test.go new file mode 100644 index 0000000..514b2c7 --- /dev/null +++ b/agent/provider_capture_test.go @@ -0,0 +1,125 @@ +package agent + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Autumn-27/artex/llmrec" +) + +// The transport is the only layer that still sees the wire bodies: norma builds +// the request body internally and decodes the SSE response before the recorder +// gets it. This checks the round trip preserves both directions untouched. +func TestRoundTripCapturesRawBodies(t *testing.T) { + const sse = "event: message_start\ndata: {\"type\":\"message_start\"}\n\nevent: message_stop\ndata: {}\n\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() + + client, err := quotaAwareHTTPClient("") + if err != nil { + t.Fatalf("client: %v", err) + } + + const reqBody = `{"model":"claude","messages":[{"role":"user","content":"hi"}],"tools":[{"name":"t","input_schema":{}}]}` + req, err := http.NewRequest(http.MethodPost, srv.URL, strings.NewReader(reqBody)) + if err != nil { + t.Fatalf("request: %v", err) + } + ctx, capt := llmrec.NewCapture(req.Context()) + req = req.WithContext(ctx) + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + got, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read: %v", err) + } + _ = resp.Body.Close() + + if string(got) != sse { + t.Fatal("capture altered the response delivered to norma") + } + if capt.RawRequest() != reqBody { + t.Fatalf("RawRequest()=%q want %q", capt.RawRequest(), reqBody) + } + if capt.RawResponse() != sse { + t.Fatalf("RawResponse()=%q want the raw SSE frames", capt.RawResponse()) + } +} + +// A 429 body is read and replaced in-place by the quota check. Capturing must +// still see it, and the replacement body must remain readable downstream. +func TestRoundTripCaptures429BodyAlongsideQuotaRewrite(t *testing.T) { + const body = `{"error":{"message":"insufficient_quota"}}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + _, _ = io.WriteString(w, body) + })) + defer srv.Close() + + client, err := quotaAwareHTTPClient("") + if err != nil { + t.Fatalf("client: %v", err) + } + req, err := http.NewRequest(http.MethodPost, srv.URL, strings.NewReader("{}")) + if err != nil { + t.Fatalf("request: %v", err) + } + ctx, capt := llmrec.NewCapture(req.Context()) + req = req.WithContext(ctx) + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + + // Quota exhaustion is normalized to 402 so the router fails over. + if resp.StatusCode != http.StatusPaymentRequired { + t.Fatalf("status=%d want 402", resp.StatusCode) + } + if capt.RawResponse() != body { + t.Fatalf("RawResponse()=%q want %q", capt.RawResponse(), body) + } + rest, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read replaced body: %v", err) + } + if string(rest) != body { + t.Fatalf("replaced body=%q want it still readable", rest) + } +} + +// Recording off = no Capture on the context. The transport must behave exactly +// as before, including the quota rewrite. +func TestRoundTripWithoutCaptureIsUnchanged(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, "ok") + })) + defer srv.Close() + + client, err := quotaAwareHTTPClient("") + if err != nil { + t.Fatalf("client: %v", err) + } + resp, err := client.Post(srv.URL, "application/json", strings.NewReader("{}")) + if err != nil { + t.Fatalf("post: %v", err) + } + defer resp.Body.Close() + got, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(got) != "ok" { + t.Fatalf("body=%q", got) + } +} diff --git a/db/commands.go b/db/commands.go index 27172da..9a696d0 100644 --- a/db/commands.go +++ b/db/commands.go @@ -99,6 +99,13 @@ type LLMRecord struct { Error string `json:"error,omitempty"` RequestBody string `json:"request_body,omitempty"` ResponseBody string `json:"response_body,omitempty"` + // RawRequest / RawResponse are the untouched HTTP bodies exchanged with the + // provider — the request as buildBody() sent it (full tool schemas included) + // and the raw SSE frames. RequestBody/ResponseBody above are the normalized + // view, which drops tool schemas and tool_use blocks entirely. Empty for + // records written before this was added, or when the call never reached HTTP. + RawRequest string `json:"raw_request,omitempty"` + RawResponse string `json:"raw_response,omitempty"` } const llmRecordsSchema = ` @@ -118,7 +125,9 @@ CREATE TABLE IF NOT EXISTS llm_records ( status TEXT, error TEXT, request_body TEXT, - response_body TEXT + response_body TEXT, + raw_request TEXT, + raw_response TEXT ); CREATE INDEX IF NOT EXISTS idx_llm_records_ts ON llm_records(ts); CREATE INDEX IF NOT EXISTS idx_llm_records_session ON llm_records(session_id); @@ -129,6 +138,8 @@ const llmRecordsMigrate = ` ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS task_id TEXT; ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS worker TEXT; ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS profile_name TEXT; +ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS raw_request TEXT; +ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS raw_response TEXT; ` // EnsureLLMRecordsTable creates the llm_records table if it does not exist. @@ -143,11 +154,12 @@ func (d *DB) EnsureLLMRecordsTable() error { // InsertLLMRecord stores one LLM call record. func (d *DB) InsertLLMRecord(r *LLMRecord) error { _, err := d.Exec(` -INSERT INTO llm_records(model, profile_name, session_id, task_id, worker, latency_ms, input_tokens, output_tokens, cache_read, cache_write, status, error, request_body, response_body) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, +INSERT INTO llm_records(model, profile_name, session_id, task_id, worker, latency_ms, input_tokens, output_tokens, cache_read, cache_write, status, error, request_body, response_body, raw_request, raw_response) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`, r.Model, nullIfEmpty(r.ProfileName), r.SessionID, nullIfEmpty(r.TaskID), nullIfEmpty(r.Worker), r.LatencyMs, r.InputTokens, r.OutputTokens, r.CacheRead, r.CacheWrite, - r.Status, nullIfEmpty(r.Error), nullIfEmpty(r.RequestBody), nullIfEmpty(r.ResponseBody)) + r.Status, nullIfEmpty(r.Error), nullIfEmpty(r.RequestBody), nullIfEmpty(r.ResponseBody), + nullIfEmpty(r.RawRequest), nullIfEmpty(r.RawResponse)) return err } @@ -260,19 +272,21 @@ func (d *DB) DeleteLLMRecords(task string) (int64, error) { // GetLLMRecord returns a single LLM record with full request/response bodies. func (d *DB) GetLLMRecord(id int64) (*LLMRecord, error) { var r LLMRecord - var reqBody, respBody sql.NullString + var reqBody, respBody, rawReq, rawResp sql.NullString err := d.QueryRow(`SELECT id, ts, COALESCE(model,''), COALESCE(profile_name,''), COALESCE(session_id,''), COALESCE(task_id,''), COALESCE(worker,''), COALESCE(latency_ms,0), COALESCE(input_tokens,0), COALESCE(output_tokens,0), COALESCE(cache_read,0), COALESCE(cache_write,0), - COALESCE(status,''), COALESCE(error,''), request_body, response_body + COALESCE(status,''), COALESCE(error,''), request_body, response_body, raw_request, raw_response FROM llm_records WHERE id=$1`, id). Scan(&r.ID, &r.Ts, &r.Model, &r.ProfileName, &r.SessionID, &r.TaskID, &r.Worker, &r.LatencyMs, &r.InputTokens, &r.OutputTokens, &r.CacheRead, &r.CacheWrite, &r.Status, &r.Error, - &reqBody, &respBody) + &reqBody, &respBody, &rawReq, &rawResp) if err != nil { return nil, err } r.RequestBody = reqBody.String r.ResponseBody = respBody.String + r.RawRequest = rawReq.String + r.RawResponse = rawResp.String return &r, nil } diff --git a/db/llm_records_migrate_test.go b/db/llm_records_migrate_test.go new file mode 100644 index 0000000..135f06f --- /dev/null +++ b/db/llm_records_migrate_test.go @@ -0,0 +1,62 @@ +package db + +import ( + "testing" +) + +// The raw-body columns were added after release, so existing installs only get +// them through llmRecordsMigrate. This runs the real migration against the dev +// PG on a table stripped back to its pre-upgrade shape, inside a transaction +// that is always rolled back — PG does transactional DDL, so nothing persists. +func TestLLMRecordsMigrateAddsRawColumnsToOldTable(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + if err := d.EnsureLLMRecordsTable(); err != nil { + t.Fatalf("ensure: %v", err) + } + + tx, err := d.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback() //nolint:errcheck // the test never commits + + // Roll the table back to how a pre-upgrade install looks. + if _, err := tx.Exec(`ALTER TABLE llm_records DROP COLUMN IF EXISTS raw_request, DROP COLUMN IF EXISTS raw_response`); err != nil { + t.Fatalf("simulate old table: %v", err) + } + if _, err := tx.Exec(llmRecordsMigrate); err != nil { + t.Fatalf("migrate: %v", err) + } + + for _, col := range []string{"raw_request", "raw_response"} { + var n int + if err := tx.QueryRow( + `SELECT count(*) FROM information_schema.columns + WHERE table_name='llm_records' AND column_name=$1`, col).Scan(&n); err != nil { + t.Fatalf("inspect %s: %v", col, err) + } + if n != 1 { + t.Errorf("column %s missing after migrate", col) + } + } + + // Re-running must stay a no-op (the migration runs on every startup). + if _, err := tx.Exec(llmRecordsMigrate); err != nil { + t.Fatalf("migrate is not idempotent: %v", err) + } + + // An insert carrying raw bodies must round-trip through the migrated table. + var got string + if err := tx.QueryRow( + `INSERT INTO llm_records(model, raw_request, raw_response) VALUES ('m','{"a":1}','data: x') + RETURNING raw_response`).Scan(&got); err != nil { + t.Fatalf("insert into migrated table: %v", err) + } + if got != "data: x" { + t.Errorf("raw_response=%q want %q", got, "data: x") + } +} diff --git a/llmrec/capture.go b/llmrec/capture.go new file mode 100644 index 0000000..d0fea40 --- /dev/null +++ b/llmrec/capture.go @@ -0,0 +1,140 @@ +package llmrec + +import ( + "context" + "fmt" + "io" + "strings" + "sync" +) + +type captureContextKey struct{} + +// Capture collects the untouched wire bodies of one logical LLM call. The +// Recorder creates it and puts it on the context; the HTTP transport that norma +// dials through (agent.quotaAwareTransport) finds it there and fills it in. +// +// This exists because everything the Recorder itself sees is already normalized: +// llm.CompletionRequest is re-serialized rather than the body buildBody() sent, +// and the response arrives as decoded StreamEvents, not the SSE frames. For +// debugging a live provider, the bytes on the wire are the only ground truth. +// +// norma's doStream retries the request-establishment phase, so one Stream can +// issue several HTTP attempts. Each attempt is kept: the discarded ones (see +// norma/llm/retry.go, which closes non-final bodies unread) are exactly what +// makes rate-limit and gateway failures diagnosable. +// +// The transport writes from norma's stream-reading goroutine while the Recorder +// snapshots at stream end, so all state is mutex-guarded. +type Capture struct { + mu sync.Mutex + request string + attempts []*attempt +} + +type attempt struct { + status int + body strings.Builder +} + +// NewCapture returns a context carrying a fresh Capture, plus the Capture itself. +func NewCapture(ctx context.Context) (context.Context, *Capture) { + c := &Capture{} + return context.WithValue(ctx, captureContextKey{}, c), c +} + +// CaptureFrom returns the Capture attached to ctx, or nil when raw capture is +// off. Callers must tolerate nil — recording is a toggle, and non-recorded +// providers dial through the same transport. +func CaptureFrom(ctx context.Context) *Capture { + if ctx == nil { + return nil + } + c, _ := ctx.Value(captureContextKey{}).(*Capture) + return c +} + +// SetRequest stores the outgoing request body. Retries re-send identical bytes, +// so only the first attempt's body is kept. +func (c *Capture) SetRequest(body string) { + if c == nil || body == "" { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if c.request == "" { + c.request = body + } +} + +// TeeResponse opens a new attempt and wraps rc so everything read from it is +// mirrored into that attempt. It tees rather than reads because a successful +// response is an SSE stream that must keep streaming to the caller. +func (c *Capture) TeeResponse(status int, rc io.ReadCloser) io.ReadCloser { + if c == nil || rc == nil { + return rc + } + a := &attempt{status: status} + c.mu.Lock() + c.attempts = append(c.attempts, a) + c.mu.Unlock() + return &teeBody{rc: rc, c: c, a: a} +} + +// RawRequest returns the request body as sent, or "" if nothing was captured. +func (c *Capture) RawRequest() string { + if c == nil { + return "" + } + c.mu.Lock() + defer c.mu.Unlock() + return c.request +} + +// RawResponse returns the response bytes as received. A single attempt yields +// the untouched original (copy-pasteable straight into a replay); multiple +// attempts are concatenated behind per-attempt header lines so a retry sequence +// stays readable. +func (c *Capture) RawResponse() string { + if c == nil { + return "" + } + c.mu.Lock() + defer c.mu.Unlock() + switch len(c.attempts) { + case 0: + return "" + case 1: + return c.attempts[0].body.String() + } + var b strings.Builder + for i, a := range c.attempts { + fmt.Fprintf(&b, "===== attempt %d/%d — HTTP %d =====\n", i+1, len(c.attempts), a.status) + body := a.body.String() + b.WriteString(body) + if !strings.HasSuffix(body, "\n") { + b.WriteByte('\n') + } + } + return b.String() +} + +// teeBody mirrors reads into a Capture attempt, guarded by the Capture's mutex +// so a snapshot taken mid-stream never races the writer. +type teeBody struct { + rc io.ReadCloser + c *Capture + a *attempt +} + +func (t *teeBody) Read(p []byte) (int, error) { + n, err := t.rc.Read(p) + if n > 0 { + t.c.mu.Lock() + t.a.body.Write(p[:n]) + t.c.mu.Unlock() + } + return n, err +} + +func (t *teeBody) Close() error { return t.rc.Close() } diff --git a/llmrec/capture_test.go b/llmrec/capture_test.go new file mode 100644 index 0000000..3386202 --- /dev/null +++ b/llmrec/capture_test.go @@ -0,0 +1,103 @@ +package llmrec + +import ( + "context" + "io" + "strings" + "testing" +) + +func TestCaptureFromNilAndMissing(t *testing.T) { + var nilCtx context.Context // the transport reaches here on any unrecorded call + if CaptureFrom(nilCtx) != nil { + t.Fatal("nil context should yield no capture") + } + if CaptureFrom(context.Background()) != nil { + t.Fatal("bare context should yield no capture") + } +} + +// A nil *Capture is the "recording off" path: the transport calls these on every +// request, so they must all be no-ops rather than panics. +func TestNilCaptureIsInert(t *testing.T) { + var c *Capture + c.SetRequest("body") + if got := c.RawRequest(); got != "" { + t.Fatalf("RawRequest()=%q want empty", got) + } + if got := c.RawResponse(); got != "" { + t.Fatalf("RawResponse()=%q want empty", got) + } + rc := io.NopCloser(strings.NewReader("x")) + if c.TeeResponse(200, rc) != rc { + t.Fatal("nil capture must pass the body through untouched") + } +} + +func TestCaptureSingleAttemptKeepsBytesVerbatim(t *testing.T) { + ctx, c := NewCapture(context.Background()) + if CaptureFrom(ctx) != c { + t.Fatal("capture not retrievable from its own context") + } + c.SetRequest(`{"model":"x"}`) + // Retries re-send identical bytes; only the first is kept. + c.SetRequest(`{"model":"ignored"}`) + + const sse = "event: message_start\ndata: {\"type\":\"message_start\"}\n\nevent: message_stop\ndata: {}\n\n" + body := c.TeeResponse(200, io.NopCloser(strings.NewReader(sse))) + got, err := io.ReadAll(body) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(got) != sse { + t.Fatal("tee altered the stream delivered to the caller") + } + if err := body.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + if c.RawRequest() != `{"model":"x"}` { + t.Fatalf("RawRequest()=%q", c.RawRequest()) + } + // A lone attempt must stay byte-identical — no headers, no framing added, so + // it can be replayed as-is. + if c.RawResponse() != sse { + t.Fatalf("RawResponse()=%q want verbatim SSE", c.RawResponse()) + } +} + +// norma's doStream discards the bodies of retried attempts (retry.go closes them +// unread), so the capture is the only place a 429 body survives. +func TestCaptureRetriedAttemptsAreAllKept(t *testing.T) { + _, c := NewCapture(context.Background()) + + first := c.TeeResponse(429, io.NopCloser(strings.NewReader(`{"error":"rate_limit"}`))) + if _, err := io.ReadAll(first); err != nil { + t.Fatalf("read first: %v", err) + } + second := c.TeeResponse(200, io.NopCloser(strings.NewReader("data: ok\n"))) + if _, err := io.ReadAll(second); err != nil { + t.Fatalf("read second: %v", err) + } + + raw := c.RawResponse() + if !strings.Contains(raw, `{"error":"rate_limit"}`) { + t.Fatalf("dropped the retried attempt body: %q", raw) + } + if !strings.Contains(raw, "data: ok") { + t.Fatalf("dropped the final attempt body: %q", raw) + } + if !strings.Contains(raw, "attempt 1/2 — HTTP 429") || !strings.Contains(raw, "attempt 2/2 — HTTP 200") { + t.Fatalf("attempts not delimited: %q", raw) + } + if strings.Index(raw, "rate_limit") > strings.Index(raw, "data: ok") { + t.Fatal("attempts stored out of order") + } +} + +func TestCaptureEmptyWhenNoHTTPHappened(t *testing.T) { + _, c := NewCapture(context.Background()) + if c.RawRequest() != "" || c.RawResponse() != "" { + t.Fatal("unused capture should be empty") + } +} diff --git a/llmrec/llmrec.go b/llmrec/llmrec.go index 1bbd55c..5fc77d1 100644 --- a/llmrec/llmrec.go +++ b/llmrec/llmrec.go @@ -110,9 +110,15 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S } // Serialize the request only when storing bodies (this is the expensive part). + // Alongside it, attach a Capture so the HTTP transport can hand back the + // untouched wire bodies — the normalized view below cannot reconstruct them + // (tool schemas are dropped, tool_use blocks never reach this layer, and the + // SSE framing is already decoded). See capture.go. reqBody := "" + var capt *Capture if recordBodies { reqBody = r.serializeRequest(req) + ctx, capt = NewCapture(ctx) } return func(yield func(llm.StreamEvent, error) bool) { @@ -133,7 +139,7 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S r.recordUsage(taskID, expID, worker, usage, int(time.Since(start).Milliseconds()), status) // Heavy trace row — only when body recording is on. if recordBodies { - r.record(req, session, taskID, worker, reqBody, start, textBuf.String(), thinkingBuf.String(), usage, stopReason, err) + r.record(req, session, taskID, worker, reqBody, capt, start, textBuf.String(), thinkingBuf.String(), usage, stopReason, err) } } @@ -210,7 +216,7 @@ func (r *Recorder) recordUsage(taskID string, expID int64, worker string, usage // record persists one LLM call to PostgreSQL before the provider stream returns. // Keeping the write inside the owning task operation means task deletion can // drain calls and then remove records without a late async insert recreating one. -func (r *Recorder) record(req llm.CompletionRequest, session, taskID, worker, reqBody string, start time.Time, text, thinking string, usage llm.Usage, stopReason string, streamErr error) { +func (r *Recorder) record(req llm.CompletionRequest, session, taskID, worker, reqBody string, capt *Capture, start time.Time, text, thinking string, usage llm.Usage, stopReason string, streamErr error) { latency := int(time.Since(start).Milliseconds()) status := "ok" errMsg := "" @@ -256,6 +262,11 @@ func (r *Recorder) record(req llm.CompletionRequest, session, taskID, worker, re Error: errMsg, RequestBody: reqBody, ResponseBody: string(respBody), + // Raw wire bodies; empty when the transport did not fill the Capture + // (e.g. a provider dialing through a client without the capture hook, or + // a call that failed before any HTTP request went out). + RawRequest: capt.RawRequest(), + RawResponse: capt.RawResponse(), } if err := r.pg.InsertLLMRecord(rec); err != nil { diff --git a/server/llmrec_raw_test.go b/server/llmrec_raw_test.go new file mode 100644 index 0000000..673ce1a --- /dev/null +++ b/server/llmrec_raw_test.go @@ -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") + } +} diff --git a/web/src/app/(main)/function/llm-records/page.tsx b/web/src/app/(main)/function/llm-records/page.tsx index cb70c44..5f05618 100644 --- a/web/src/app/(main)/function/llm-records/page.tsx +++ b/web/src/app/(main)/function/llm-records/page.tsx @@ -9,6 +9,8 @@ import { Loader2Icon, XIcon, Trash2Icon, + CopyIcon, + CheckIcon, } from "lucide-react"; import { @@ -74,6 +76,47 @@ function tryFormatJSON(s: string): string { } } +// 复制当前框内文本的小按钮。复制成功后短暂显示对勾。text 为空/仅占位符时禁用。 +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = React.useState(false); + const timer = React.useRef | null>(null); + React.useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []); + + const disabled = !text; + const copy = async () => { + if (disabled) return; + try { + await navigator.clipboard.writeText(text); + } catch { + // navigator.clipboard 在非安全上下文(如 http 局域网)不可用,回退到 execCommand。 + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand("copy"); } catch { /* 忽略:不支持则静默 */ } + document.body.removeChild(ta); + } + setCopied(true); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => setCopied(false), 1500); + }; + + return ( + + ); +} + const PAGE_SIZES = [25, 50, 100]; export default function LLMRecordsPage() { @@ -96,6 +139,21 @@ export default function LLMRecordsPage() { const [selected, setSelected] = React.useState(null); const [detail, setDetail] = React.useState(null); const [detailLoading, setDetailLoading] = React.useState(false); + // 归一化视图 / HTTP 原文视图。原文是排查 provider 侧问题的唯一依据:归一化视图 + // 不含工具 schema,响应里也没有 tool_use 块。 + const [rawView, setRawView] = React.useState(false); + + const hasRaw = !!(detail?.raw_request || detail?.raw_response); + // 开关保持用户选择,但切到一条无原文的旧记录时自动落回解析视图,而不是显示空白。 + const showRaw = rawView && hasRaw; + // 原文请求体是 JSON,pretty-print 只改排版不改语义,便于阅读;原文响应是 SSE + // 帧,tryFormatJSON 解析失败会原样返回,故两边共用一个函数即可。 + const reqText = showRaw + ? detail?.raw_request && tryFormatJSON(detail.raw_request) + : detail?.request_body && tryFormatJSON(detail.request_body); + const respText = showRaw + ? detail?.raw_response + : detail?.response_body && tryFormatJSON(detail.response_body); // Per-task delete (task picker + confirm dialog) const [tasks, setTasks] = React.useState([]); @@ -427,10 +485,22 @@ export default function LLMRecordsPage() { ) : ( Error )} + {/* 原文视图开关。旧记录没有原文,此时禁用而非静默回退,避免看着像 + 「原文与解析一致」。 */} + - {convs.length > 0 && ( -
- toggleAllConversations(checked === true)} - aria-label="选择全部对话" - /> - - {selectedConversationCount > 0 ? `已选 ${selectedConversationCount} 个` : `共 ${convs.length} 个`} - - {selectedConversationCount > 0 && ( + {convs.length > 0 && + (selectionMode ? ( +
+ toggleAllConversations(checked === true)} + aria-label="选择全部对话" + /> + + {selectedConversationCount > 0 ? `已选 ${selectedConversationCount} 个` : `共 ${convs.length} 个`} + + {selectedConversationCount > 0 && ( + + )} + +
+ ) : ( +
+ + 共 {convs.length} 个 + - )} -
- )} +
+ ))}
@@ -1126,6 +1162,7 @@ export default function ChatPage() { onCancelRename={() => setRenamingId(null)} onTogglePinned={() => void togglePinned(c)} onDelete={() => del(c.id)} + selectionMode={selectionMode} selectedForDelete={selectedConversationIds.has(c.id)} onSelectedForDeleteChange={(checked) => toggleConversationSelected(c.id, checked)} /> From 99ac96da2fc01585d8428948dbc024b465972855 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:19:04 -0400 Subject: [PATCH 06/14] =?UTF-8?q?feat(agent):=20=E4=BC=98=E5=8C=96=20plann?= =?UTF-8?q?er/worker=20=E6=8F=90=E7=A4=BA=E8=AF=8D=E5=B9=B6=E5=8A=A0?= =?UTF-8?q?=E9=87=8F=E5=8C=96=E9=AA=8C=E6=94=B6=E6=A0=B8=E5=AF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - planner 判目标新增「量化验收核对」:覆盖度/flag 数/权限等可量化目标 未达标时禁止 prove_goal,修复覆盖度要求 100%、实测 40% 却被判完成 - 重写 planner「0 意图」判定:仅在「方向已被现有意图覆盖」或「下一步 依赖在跑 work 的产出」两种情况才 0 意图,避免目标未达成时过早收手 - 强化 worker 否定结论证据门槛:未穷尽手段/证据弱一律标 inferred, 避免轻率的 observed 否定把整条路线焊死 - 加 reseedPlannerPrompt / reseedWorkerPrompt 一次性迁移,旧库追加新 默认版本并切换,旧版保留在历史可恢复 --- CHANGELOG.md | 12 ++++++++ agent/planner.go | 6 +++- agent/worker.go | 2 +- server/orchestration.go | 62 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09e4d36..3ec251f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ ## [Unreleased] +### Agent + +#### 新增的功能 + +- planner 判目标时新增「量化验收核对」:目标含可量化验收条件(资产测试覆盖度达到 X%、拿到 N 个 flag、获得某权限)时,`prove_goal` 前必须核对 `graph_overview` 的实测值(`coverage.pct`、findings 计数等),实测未达标则禁止 `prove_goal`、改为派意图补足差距,不得以「主要部分已完成/大体达成」为由提前标 met。修复了目标要求覆盖度 100%、实测仅 40% 却被判完成的问题。 + +#### 修改的功能 + +- 重写 planner 提示词中「本轮 0 意图」的判定依据:原文将 0 意图描述为「最常见、最重要的原则」,会让规划者在目标未达成、范围内仍有未测面时过早收手。现改为只在两种具体情况下才应产出 0 意图——① 想到的方向都已被 open/running/recent_done 意图覆盖;② 下一步依赖当前正在运行的 work 的产出、而产出尚未出现(应等其跑完、图更新后的下一次唤醒再规划)。并补充反向约束:确有未被覆盖且不依赖在跑 work 的新方向、或目标未达成且仍有未测面时,不要因「0 意图常见」而收手。 +- 强化 worker 提示词中「否定结论的证据门槛」:对「不可注入/端口关闭/无登录入口」等可能让规划者放弃一整条方向的否定结论,要求下结论前先穷尽该意图内的合理手段(换编码/参数/路径/方法);手段未走完或证据偏弱一律标 `confidence=inferred`,避免用轻率的 `observed` 否定把整条路线焊死(任务早期的错误否定尤其会把方向带偏且难以自愈)。 +- 上述 planner / worker 默认提示词变更通过一次性迁移(`reseedPlannerPrompt` / `reseedWorkerPrompt`,各带 settings flag 守卫)追加为新版本并切为当前版本;旧版本保留在版本历史中,自定义过提示词的用户可在版本记录里恢复。 + ### 运维 #### 新增的功能 diff --git a/agent/planner.go b/agent/planner.go index 01c1bb8..88f2013 100644 --- a/agent/planner.go +++ b/agent/planner.go @@ -242,7 +242,10 @@ const plannerDefaultTmpl = `你是一个授权渗透测试系统的"规划者" 任务目标:{{.Goal}} -⚠️ 最重要的原则:**这一轮生成 0 个意图是完全正常、而且是最常见的结果。** 你不是"每次都要产出意图"的机器。绝大多数唤醒,frontier 里已有的意图已经覆盖了所有已知方向,你应当什么都不加、直接结束。重复/换措辞地生成已经存在的意图是严重错误。 +⚠️ **生成 0 个意图是合法、正常的结果,但它有【具体的正当理由】,不是"少派更稳"的默认姿态。** 只在下列情况才该产出 0 个意图: +- **已覆盖**:你想到的方向都已被 open / running / recent_done 意图覆盖——重复或换措辞地再生成已存在的意图是严重错误;或 +- **等待依赖**:下一步依赖【当前正在运行的 work】的产出,而产出还没出来——此时应【等这些 work 跑完、图更新后的下一次唤醒再规划】,现在硬派会让下游 worker 拿不到还不存在的前置而空转。 +反过来:若确有【未被任何意图覆盖、且不依赖在跑 work】的新方向,或【目标尚未达成且范围内仍有未测面】,就【不要】因为"0 意图常见"而收手——该派就派。别把 0 意图当成偷懒的默认。 决策流程(每次唤醒): 1. **完整态势已直接附在本提示下方(就是 graph_overview 的返回,无需再调它)**:task(**原始任务标题+目标**,即根节点)、资产计数、goals 及其状态、open/running/recent_done 意图、sites_without_endpoints、findings(**确认漏洞**数)、facts(**探索事实/结论**数,与漏洞是两类)、recent_facts(最近事实的 {id, summary, confidence?},含"端口关闭/不可注入"等**否定结论**——据此别再为已探明的死路生成意图;但 confidence=inferred 的否定结论只是【推断】、证据弱,别当铁案,若该方向对目标很关键值得派一条复核意图)。**这里的探索节点(goals/意图/facts/findings)都只含本任务的**(绝不会有别的任务的目标);**资产图则全局共享**(多任务同一份,资产计数是全局在范围内的数据,非本任务独有)。需要更深的细节时才按需调:list_facts 列全部事实、list_findings 列全部漏洞、**node_detail(id)** 取某条的完整证据/详情(列表/recent_facts 只给摘要)。 @@ -250,6 +253,7 @@ const plannerDefaultTmpl = `你是一个授权渗透测试系统的"规划者" - sites_without_endpoints / findings——"哪些方向【可能】需要探索"。 - 只有需要某一片的细节时,才**按需**调 list_assets(pull 模式:可用 q 关键字搜索,可叠加 type/company_id/task_id 过滤,分页 limit/offset;或用 id/ids 直接取)、asset_neighbors、list_findings。资产图全局共享,别默认拉全量。资产中可能包含非本次任务涉及到的资产,所以需要主要出现非本次任务相关的资产时忽略这些资产。 2. 判目标(核心职责):graph_overview 的 goals 字段已含目标与状态;对已被某发现/事实证明的未达成目标,调 prove_goal(goal_id,evidence_id,reason) 标记 met。**当你用 prove_goal 标记的这一个恰好是最后一个未完成目标时,系统会自动判定整个任务完成**——收官完全由逐个 prove_goal 驱动,你无需、也没有别的“一键完成”手段。 + ⚠️ **量化验收核对(严禁提前盖章)**:若某目标含【可量化的验收条件】(如"资产测试覆盖度达到 X%"、"拿到 N 个 flag"、"获得某权限"),prove_goal **前【必须】核对本提示上方 graph_overview 里的实测值**(coverage.pct、findings 计数等):实测【未达标】就【禁止】prove_goal,改为派意图补足差距——**【不得】以"主要部分已完成/大体达成/核心目标已拿下"为由提前标 met**。例:目标要求覆盖度 100%、而 graph_overview 实测 coverage.pct=40%,则该目标【未达成】,继续派补测意图,不许 prove_goal。 2.5. **(可选,仅限开局、极轻量)探测理解**:你具备 Bash 等执行能力,它的**唯一正当用途**是——当**图里几乎还没有事实**(recent_facts 基本为空、任务刚开始)、仅凭态势无法把初始意图描述具体时,对目标做**极少量、只读**的探测(如 1–2 次 curl 看首页/指纹),据此产出更精准的**初始意图**。 ⚠️ **牢记你的身份边界:你是"规划者",不是"执行者"。你在这里做的一切都只为【生成/说清意图】,绝不是【在 plan 里把活干了】。** 探测的**唯一合法产物是一句更精准的意图描述**——绝不能是漏洞的发现、验证、利用,也不能是端点/目录/参数的枚举结果。任何"我顺手把这个也测了/确认了"的念头都是越界:那是 worker 在 work 阶段该做的事,你只需把它**写成一个意图派下去**。 **三条硬性边界,务必守住**: diff --git a/agent/worker.go b/agent/worker.go index b6c6e8c..c75c97b 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -169,7 +169,7 @@ const workerDefaultTmpl = `你是一个授权渗透测试系统的"执行者"(wo 可用工具: - insert_assets:登记新资产(资产图)。**你只传原始信息,key 与父子关联由代码算**:新接口→传完整 url+method(代码自动建 domain→site→endpoint、自动抽 URL 里的 query 参数,body/header 参数放 params);指纹→type=tech,name=技术名,on_url=站点地址,props填{version,category}。多个资产放 assets 数组一次批量登记。属性写在资产自己的 props 上,探索结论不要写这里。 -- record_fact:把探索【事实/结论】写入探索图并连到意图(传 intent_id)。正向/否定结论、观察、判断用它;**一次探索的多个观察汇总成一条事实**(summary 总结一句话 + detail 放细节),不要一个属性一条。真有多条不同结论才用 facts 数组。**只写你真实看到的**:给 evidence(一行关键证据:命令+关键输出,简洁,别粘大段——细节在 detail)、标 confidence(observed 直接看到 / inferred 推断);**否定结论**(不可注入/端口关闭等)尤其要给证据、证据弱就标 inferred,别让错的否定误导规划者放弃方向。 +- record_fact:把探索【事实/结论】写入探索图并连到意图(传 intent_id)。正向/否定结论、观察、判断用它;**一次探索的多个观察汇总成一条事实**(summary 总结一句话 + detail 放细节),不要一个属性一条。真有多条不同结论才用 facts 数组。**只写你真实看到的**:给 evidence(一行关键证据:命令+关键输出,简洁,别粘大段——细节在 detail)、标 confidence(observed 直接看到 / inferred 推断);**否定结论的证据门槛**(不可注入/端口关闭/无登录入口等【可能让规划者放弃一整条方向】的结论):下结论前先确认你已【穷尽这条意图内的合理手段】(换编码/换参数/换路径/换方法);手段没走完、或证据只是"看起来像/大概率如此",一律标 confidence=inferred。**宁可标 inferred 让规划者复核,也别用一个轻率的 observed 否定把一整条路线焊死**——尤其任务早期,一个错误的 observed 否定会把整个任务带偏、且后续很难自己扳回来。 - report_finding:确认漏洞 → 记录(含 PoC,传 intent_id=你领到的意图id)。**只有你在本次运行里真实触发过该漏洞、拿到了可复现的证据(请求/响应或命令输出)才用它。** 严禁把下列当作已确认漏洞上报:仅凭版本号/指纹匹配到某 CVE、仅凭"参数看起来可注入"、仅凭外部漏洞库/更新日志/代码 diff 推断。**不要用查 CVE 库或"对比补丁版本"替代实际触发。** 触发不了但确有嫌疑,就用 record_fact 记一条 confidence=inferred 的事实(描述嫌疑点+为何未能触发),交给规划者派后续意图,别硬记成 finding。 - list_assets(查询资产,非探索节点) / asset_neighbors / list_facts(探索事实) / list_findings(漏洞) / node_detail(探索节点 id,非资产 id):按需查上下文。 diff --git a/server/orchestration.go b/server/orchestration.go index 9057ea7..287eaa0 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -452,6 +452,8 @@ func (s *Server) seedOrchestrationTools() { s.unbindGoalMetDefault() s.reseedGoalsPrompt() // goals 提示词加入「抽操作约束」步 → 旧库追加一版新默认(一次性) s.reseedMainAgentPrompt() // mainagent 提示词加入「目标达成后 add_intent 反问是否建目标」(一次性) + s.reseedPlannerPrompt() // planner 提示词:重写「0 意图」正当理由 + 加量化验收核对(一次性) + s.reseedWorkerPrompt() // worker 提示词:加否定结论证据门槛(一次性) s.seedReporterAgent() // 预置「报告撰写」agent + 工具绑定 + finding 触发器(一次性) // 注:pentest 的默认工具绑定无需迁移——BuiltinToolSeeds 在全新初始化时就把 // list_assets/insert_assets/report_finding/list_findings/list_companies 连同 @@ -571,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 守卫):用户删掉后不再重建。 From 48f260fdde5fc04e01443adbfd2ad9d355d84229 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:22:06 -0400 Subject: [PATCH 07/14] =?UTF-8?q?docs(changelog):=20=E5=8F=91=E5=B8=83=200?= =?UTF-8?q?.3.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ec251f..f6564cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [0.3.5] - 2026-08-25 + ### Agent #### 新增的功能 @@ -22,6 +24,10 @@ - 新增 `reset-password.sh` 重置管理员(用户名固定 `ARTEX`)密码:支持 local / docker 两种部署,连接信息可显式指定或自动从 `--dsn`/`$ARTEX_PG_DSN`/`config.json` 读取;在库内用 `pgcrypto` 生成与后端登录兼容的 bcrypt 哈希并写回 `settings.auth.password_hash`,重置后无需重启服务。密码经环境变量传入、不进入进程 argv,并做转义防注入。 +### 贡献者 + +- [@Autumn-27](https://github.com/Autumn-27) + ## [0.3.4] - 2026-08-24 ### UI @@ -261,7 +267,8 @@ - [@Autumn-27](https://github.com/Autumn-27) - [@neouks](https://github.com/neouks) -[Unreleased]: https://github.com/Autumn-27/ARTEX/compare/v0.3.4...HEAD +[Unreleased]: https://github.com/Autumn-27/ARTEX/compare/v0.3.5...HEAD +[0.3.5]: https://github.com/Autumn-27/ARTEX/compare/v0.3.4...v0.3.5 [0.3.4]: https://github.com/Autumn-27/ARTEX/compare/v0.3.3...v0.3.4 [0.3.3]: https://github.com/Autumn-27/ARTEX/compare/v0.3.2...v0.3.3 [0.3.2]: https://github.com/Autumn-27/ARTEX/compare/v0.3.1...v0.3.2 From f6c4cbb013db9eaf700e3a39db74492af4254e29 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:27:38 -0400 Subject: [PATCH 08/14] =?UTF-8?q?docs(changelog):=20=E8=A1=A5=E5=85=A8=200?= =?UTF-8?q?.3.5=20=E9=81=97=E6=BC=8F=E6=9D=A1=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补上 v0.3.4 以来已合入但未记录的变更:LLM 流式/非流式切换、 OpenAI Responses API 格式、LLM 请求/响应原文录制、会话列表多选模式、 以及 #57 的任务/会话/流量体验增强;0.3.5 贡献者补记 @neouks。 --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6564cd..f9cb758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,30 @@ ## [0.3.5] - 2026-08-25 +### LLM + +#### 新增的功能 + +- 每个 LLM 配置支持流式/非流式切换(默认流式):开走流式 SSE;关走真·非流式(`stream:false`、一次性返回完整 JSON),可绕开部分网关糟糕的 SSE 实现(空帧、思考字段丢帧),代价是失去运行中的实时进度与实时 Token 计数。worker / planner / mainagent / chat / goals 均按当前激活配置动态取值;`llmpool` / `llmrec` / 任务运行时三层 Provider 包装均兼容非流式;`llm_profiles` 新增 `streaming` 列并 `ALTER` 补旧库(默认 `true`,旧配置无感)。 +- 支持 OpenAI Responses API 格式的 LLM 配置:每个配置新增第三种格式 `openai-responses`(打 `POST /v1/responses`),与 Chat Completions / Anthropic 并列;`BaseURL` 归一化、默认模型 `gpt-5`;`llm_profiles` 的 `format` 约束加入 `openai-responses` 并幂等迁移补旧库;前端格式下拉新增「OpenAI (Responses API)」。依赖升级 norma v0.3.1(含 `reasoning_content` 回传修复)。 +- 录制 LLM 请求/响应 HTTP 原文:在 HTTP transport 层捕获真实 wire body,保留归一化视图看不到的工具 schema、`tool_use` 块与原始 SSE 帧;norma 内部重试的多次尝试逐次保留;`llm_records` 新增 `raw_request` / `raw_response` 两列并 `ALTER` 补旧库;录制页详情面板增加「原文」视图切换与请求/响应复制按钮(兼容非安全上下文的 `execCommand` 回退)。 + +### 对话 + +#### 修改的功能 + +- 会话列表多选改为「多选」模式开关:默认列表不再常驻每行勾选框(观感更干净),表头改为「共 N 个 + 多选」按钮;点「多选」进入选择模式(勾选框、全选、批量删除),「完成」退出并清空选择,批量删除全部成功后自动退出(有失败则留在选择模式便于重试);单条重命名 / 置顶 / 删除仍走每行 ⋯ 菜单。 + +### UI + +#### 修改的功能 + +- 任务管理、会话操作与流量查看体验增强(#57):任务列表列排序偏好可持久化记忆、行内重命名改用图标直接触发(不再经菜单)、抽屉(sheet)交互与流量查看细节优化。 + +#### 修复的问题 + +- Worker 资产标签只显示域名与 IP,并正确处理空标签的场景。 + ### Agent #### 新增的功能 @@ -27,6 +51,7 @@ ### 贡献者 - [@Autumn-27](https://github.com/Autumn-27) +- [@neouks](https://github.com/neouks) ## [0.3.4] - 2026-08-24 From f891f4ffb8f47ac247ee033e17bca4f9c7903e9c Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:00:04 -0400 Subject: [PATCH 09/14] =?UTF-8?q?chore(deps):=20=E5=8D=87=E7=BA=A7=20norma?= =?UTF-8?q?=20v0.3.1=20=E2=86=92=20v0.3.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 带入本轮 norma 修复: - OpenAI 系响应里被静默丢弃的 reasoning/refusal,Responses 补 reasoning_text - reasoning 字段三名去重 - 空响应(零内容块)有界重试 - 压缩边界切断工具配对导致的网关 400(同时修复已烤进 transcript 的存量孤儿) --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index e79c704..ae76393 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/Autumn-27/artex go 1.26.3 require ( - github.com/Autumn-27/norma v0.3.1 + github.com/Autumn-27/norma v0.3.2 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/jackc/pgx/v5 v5.10.0 github.com/lqqyt2423/go-mitmproxy v1.9.2 diff --git a/go.sum b/go.sum index 1ba35d1..23fac3a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/Autumn-27/norma v0.3.1 h1:cSRojFJjivm4l3ck/KJXym7VyCnM1GLY7iq1pRYk4kk= github.com/Autumn-27/norma v0.3.1/go.mod h1:BudWrf7iHl96FgHqFr/HwN3mKYmGjJDt+xL2ZHXfFZw= +github.com/Autumn-27/norma v0.3.2 h1:3gW84SbSrz8luW8kbi2tv3O7CLdRMQvyylCHj1QUdNk= +github.com/Autumn-27/norma v0.3.2/go.mod h1:BudWrf7iHl96FgHqFr/HwN3mKYmGjJDt+xL2ZHXfFZw= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= From 8be7631eb3bef1b121509985da13d8704612c7fc Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:23:06 -0400 Subject: [PATCH 10/14] =?UTF-8?q?fix(llm):=20=E9=85=8D=E7=BD=AE=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E8=BF=94=E5=9B=9E=20streaming=20=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLMProfileDTO 漏了 streaming,接口从不返回该字段,前端读到 undefined 后一律回落成默认的流式,导致「非流式」保存成功但重新打开开关仍是开的。 字段不加 omitempty:false 必须出现在响应里。 Closes #69 --- server/dto.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/dto.go b/server/dto.go index 8db7ba7..3b1f7dc 100644 --- a/server/dto.go +++ b/server/dto.go @@ -566,6 +566,9 @@ type LLMProfileDTO struct { // pool_exclude=true 则不作为故障转移目标,但仍可被 agent/任务显式绑定。 Priority int `json:"priority"` PoolExclude bool `json:"pool_exclude"` + // 收发模式:true=流式(SSE) | false=非流式。没有 omitempty —— false 必须出现在 + // 响应里,否则前端读不到「非流式」,开关会回落成默认的流式。 + Streaming bool `json:"streaming"` } func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO { @@ -585,6 +588,7 @@ func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO { IsDefault: p.IsDefault, Priority: p.Priority, PoolExclude: p.PoolExclude, + Streaming: p.Streaming, } } From e10f8413dcf0ae6f8c979866bfe323c9e4244511 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:56:11 -0400 Subject: [PATCH 11/14] =?UTF-8?q?feat(commands):=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E9=A1=B5=E6=96=B0=E5=A2=9E=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E6=AC=A1=E6=95=B0=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「工具执行」页工具栏加「统计」按钮,弹窗按工具展示调用次数、占比与 失败次数,按次数降序。统计沿用列表的 task/q 筛选,算的是整个结果集 而非当前页;弹窗打开时才请求,翻页不会多付一次聚合查询。 - db: 抽出 commandFilter,列表与统计共用同一份 WHERE;新增 ToolStats - server: GET /api/commands/stats - web: 统计弹窗 + demo 模式 mock --- db/commands.go | 69 +++++++++++--- server/commands.go | 40 ++++++-- server/server.go | 1 + web/src/app/(main)/function/commands/page.tsx | 93 ++++++++++++++++++- web/src/lib/api.ts | 8 ++ web/src/lib/mock/handler.ts | 11 +++ web/src/lib/types.ts | 7 ++ 7 files changed, 207 insertions(+), 22 deletions(-) diff --git a/db/commands.go b/db/commands.go index 9a696d0..d95f0b8 100644 --- a/db/commands.go +++ b/db/commands.go @@ -19,18 +19,10 @@ type CommandRecord struct { CreatedAt time.Time `json:"created_at"` } -// ListCommands returns tool executions (tool_use + paired tool_result) across all -// explorations, with optional filtering and pagination. Covers every tool, not -// just Bash; q matches the tool name or its input. -func (d *DB) ListCommands(expID *int64, q string, page, size int) ([]CommandRecord, int, error) { - if size <= 0 { - size = 50 - } - if page < 0 { - page = 0 - } - offset := page * size - +// commandFilter builds the WHERE clause shared by the tool-execution list and +// its per-tool tally, so the summary always describes exactly the rows the table +// pages through. Returns the clause, its args, and the next placeholder index. +func commandFilter(expID *int64, q string) (string, []any, int) { where := `WHERE u.kind = 'tool_use'` args := []any{} argN := 1 @@ -45,6 +37,59 @@ func (d *DB) ListCommands(expID *int64, q string, page, size int) ([]CommandReco args = append(args, "%"+q+"%") argN++ } + return where, args, argN +} + +// ToolStat is one tool's execution tally for the usage summary. +type ToolStat struct { + Tool string `json:"tool"` + Total int `json:"total"` + Errors int `json:"errors"` +} + +// ToolStats counts executions grouped by tool under the same filters +// ListCommands takes. Unpaginated on purpose: the tally describes the whole +// filtered set, not the page currently on screen. +func (d *DB) ToolStats(expID *int64, q string) ([]ToolStat, error) { + where, args, _ := commandFilter(expID, q) + + rows, err := d.Query(` +SELECT COALESCE(NULLIF(u.tool,''),'-') AS tool, COUNT(*) AS total, + COUNT(*) FILTER (WHERE COALESCE(r.is_error,false)) AS errors +FROM activity u +LEFT JOIN activity r ON r.tool_use_id = u.tool_use_id AND r.kind = 'tool_result' +`+where+` +GROUP BY 1 +ORDER BY total DESC, tool ASC`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []ToolStat{} + for rows.Next() { + var s ToolStat + if err := rows.Scan(&s.Tool, &s.Total, &s.Errors); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +// ListCommands returns tool executions (tool_use + paired tool_result) across all +// explorations, with optional filtering and pagination. Covers every tool, not +// just Bash; q matches the tool name or its input. +func (d *DB) ListCommands(expID *int64, q string, page, size int) ([]CommandRecord, int, error) { + if size <= 0 { + size = 50 + } + if page < 0 { + page = 0 + } + offset := page * size + + where, args, argN := commandFilter(expID, q) // count var total int diff --git a/server/commands.go b/server/commands.go index 12904a7..aa05427 100644 --- a/server/commands.go +++ b/server/commands.go @@ -15,14 +15,7 @@ func (s *Server) pgListCommands(w http.ResponseWriter, r *http.Request) { size := atoiDefault(q.Get("size"), 50) keyword := q.Get("q") - var expID *int64 - if tv := q.Get("task"); tv != "" { - if n, err := strconv.ParseInt(tv, 10, 64); err == nil { - expID = &n - } - } - - records, total, err := s.m.PG().ListCommands(expID, keyword, page, size) + records, total, err := s.m.PG().ListCommands(commandTaskFilter(q.Get("task")), keyword, page, size) if err != nil { writeErr(w, 500, err.Error()) return @@ -30,6 +23,37 @@ func (s *Server) pgListCommands(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"commands": records, "total": total}) } +// pgToolStats returns per-tool call counts for the tool-execution history, under +// the same task/keyword filters the list takes — the summary describes the whole +// filtered set, not the page on screen. +func (s *Server) pgToolStats(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + pg := s.m.PG() + if pg == nil { + writeJSON(w, 200, map[string]any{"stats": []db.ToolStat{}}) + return + } + stats, err := pg.ToolStats(commandTaskFilter(q.Get("task")), q.Get("q")) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"stats": stats}) +} + +// commandTaskFilter parses the ?task= exploration id. Absent or unparsable → nil +// (no filter), matching the list endpoint's long-standing lenient behaviour. +func commandTaskFilter(v string) *int64 { + if v == "" { + return nil + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return nil + } + return &n +} + // pgListLLMRecords returns paginated LLM call records. func (s *Server) pgListLLMRecords(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() diff --git a/server/server.go b/server/server.go index 303ef72..dca9193 100644 --- a/server/server.go +++ b/server/server.go @@ -787,6 +787,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/traffic/exchange", s.getTrafficExchange) mux.HandleFunc("GET /api/traffic/blob", s.getTrafficBlob) mux.HandleFunc("GET /api/commands", s.pgListCommands) + mux.HandleFunc("GET /api/commands/stats", s.pgToolStats) // 按工具聚合调用次数 mux.HandleFunc("GET /api/llm/records", s.pgListLLMRecords) mux.HandleFunc("DELETE /api/llm/records", s.pgDeleteLLMRecords) mux.HandleFunc("GET /api/llm/records/tasks", s.pgLLMTasks) diff --git a/web/src/app/(main)/function/commands/page.tsx b/web/src/app/(main)/function/commands/page.tsx index 7d58b48..6a5954e 100644 --- a/web/src/app/(main)/function/commands/page.tsx +++ b/web/src/app/(main)/function/commands/page.tsx @@ -2,17 +2,18 @@ import * as React from "react"; -import { ChevronLeftIcon, ChevronRightIcon, Loader2Icon, SearchIcon, TerminalIcon } from "lucide-react"; +import { BarChart3Icon, ChevronLeftIcon, ChevronRightIcon, Loader2Icon, SearchIcon, TerminalIcon } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { api } from "@/lib/api"; -import type { CommandRecord } from "@/lib/types"; +import type { CommandRecord, ToolStat } from "@/lib/types"; import { cn } from "@/lib/utils"; function fmtTime(ts: string) { @@ -59,6 +60,11 @@ export default function CommandsPage() { const [total, setTotal] = React.useState(0); const [loading, setLoading] = React.useState(false); + // 各工具调用次数。弹窗打开时才拉取(多一次聚合查询,不必每次翻页都付)。 + const [statsOpen, setStatsOpen] = React.useState(false); + const [stats, setStats] = React.useState([]); + const [statsLoading, setStatsLoading] = React.useState(false); + // Selected execution is rendered in a right-side detail sheet. const [selected, setSelected] = React.useState(null); @@ -96,6 +102,24 @@ export default function CommandsPage() { }; }, [page, size, queryQ, taskFilter]); + // 统计跟随筛选条件走,和表格描述的是同一批记录(但不分页)。 + React.useEffect(() => { + if (!statsOpen) return; + let alive = true; + setStatsLoading(true); + api + .commandStats({ task: taskFilter || undefined, q: queryQ || undefined }) + .then((r) => alive && setStats(r.stats ?? [])) + .catch(() => alive && setStats([])) + .finally(() => alive && setStatsLoading(false)); + return () => { + alive = false; + }; + }, [statsOpen, queryQ, taskFilter]); + + const statsTotal = stats.reduce((n, s) => n + s.total, 0); + const statsErrors = stats.reduce((n, s) => n + s.errors, 0); + const statsMax = stats.reduce((n, s) => Math.max(n, s.total), 0); const totalPages = Math.max(1, Math.ceil(total / size)); const rangeStart = total === 0 ? 0 : page * size + 1; const rangeEnd = page * size + commands.length; @@ -141,6 +165,11 @@ export default function CommandsPage() { + +
{rangeStart}–{rangeEnd} / {total} @@ -243,6 +272,66 @@ export default function CommandsPage() {
+ {/* 工具调用统计:与表格同一批记录(同筛选、不分页) */} + + + + 工具调用统计 + + {taskFilter || queryQ ? "当前筛选条件下的全部记录" : "全部工具执行记录"} + {stats.length > 0 && ( + <> + {" · "} + {stats.length} 个工具 ·{" "} + {statsTotal} 次调用 + {statsErrors > 0 && ( + <> + {" · 失败 "} + {statsErrors} + + )} + + )} + + + + {statsLoading && stats.length === 0 ? ( +
+ +
+ ) : stats.length === 0 ? ( +
暂无统计数据
+ ) : ( +
+ {stats.map((s) => ( +
+
+
+ {s.tool} + {s.errors > 0 && ( + 失败 {s.errors} + )} +
+
+
0 ? (s.total / statsMax) * 100 : 0}%` }} + /> +
+
+
+
{s.total}
+
+ {statsTotal > 0 ? ((s.total / statsTotal) * 100).toFixed(1) : "0.0"}% +
+
+
+ ))} +
+ )} + +
+ !open && setSelected(null)}> {selected && ( diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f606f6c..6a0232e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -71,6 +71,7 @@ import type { TokenTotal, TokenUsage, Tool, + ToolStat, TrafficDetail, TrafficHost, TrafficResp, @@ -941,6 +942,13 @@ export const api = { sp.set("size", String(params?.size ?? 50)); return get<{ commands: CommandRecord[]; total: number }>(`/commands?${sp}`); }, + // 各工具调用次数;沿用列表的 task/q 筛选,统计的是整个结果集而非当前页。 + commandStats: (params?: { task?: string; q?: string }) => { + const sp = new URLSearchParams(); + if (params?.task) sp.set("task", params.task); + if (params?.q) sp.set("q", params.q); + return get<{ stats: ToolStat[] }>(`/commands/stats?${sp}`); + }, // ---- LLM records ---- llmRecords: (params?: { model?: string; session?: string; task?: string; page?: number; size?: number }) => { diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 6e9b4fd..9e69c14 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -1128,6 +1128,17 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re // ── 工具执行历史 ── if (path === "/commands" && m === "GET") return { commands: D.commandRecords, total: D.commandRecords.length }; + if (path === "/commands/stats" && m === "GET") { + const tally = new Map(); + for (const c of D.commandRecords) { + const tool = c.tool || "-"; + const s = tally.get(tool) ?? { tool, total: 0, errors: 0 }; + s.total++; + if (c.is_error) s.errors++; + tally.set(tool, s); + } + return { stats: [...tally.values()].sort((a, b) => b.total - a.total || a.tool.localeCompare(b.tool)) }; + } // ── LLM ── if (path === "/llm/records" && m === "GET") return { records: mockLLMRecords, total: mockLLMRecords.length }; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 0569277..092871d 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1065,6 +1065,13 @@ export interface CommandRecord { created_at: string; } +// 单个工具的调用统计(/commands/stats);errors 为其中失败的次数。 +export interface ToolStat { + tool: string; + total: number; + errors: number; +} + // ---- LLM recording ---- export interface LLMRecordItem { id: number; From 7b1f7e141b3e5deba717094c7f4ba7718e8fd433 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:14:48 -0400 Subject: [PATCH 12/14] =?UTF-8?q?fix(llm):=20=E6=B5=8B=E8=AF=95=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E6=A0=A1=E9=AA=8C=E6=A8=A1=E5=9E=8B=E7=A1=AE=E6=9C=89?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=EF=BC=8C=E5=B9=B6=E6=8C=89=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=9A=84=E6=94=B6=E5=8F=91=E6=A8=A1=E5=BC=8F=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 测试连接此前只判 err:请求通了但模型一个字不吐(思考烧光预算、正文被 安全策略吞掉、兼容层丢 content)照样报成功,而这种配置在会话里就是不 回话——正是「测试成功、对话不成功」的来源。 - TestConnection 返回回复文本,空回复直接判失败 - 回复回传前端,成功 toast 里展示,测试通过有据可查 - 测试连接改用该配置真实的收发模式:此前恒走流式(ConfigFrom 默认 Stream=true),选了非流式的配置测的是另一条通道;请求省略 streaming 时仍默认流式,老客户端不受影响 Closes #65 --- agent/provider.go | 22 +++++++++++++++++----- server/server.go | 25 +++++++++++++++++++++++-- web/src/app/(main)/system/llm/page.tsx | 18 ++++++++++++++++-- web/src/lib/api.ts | 5 ++++- web/src/lib/mock/handler.ts | 3 ++- 5 files changed, 62 insertions(+), 11 deletions(-) diff --git a/agent/provider.go b/agent/provider.go index ba12d5e..09b277b 100644 --- a/agent/provider.go +++ b/agent/provider.go @@ -357,11 +357,12 @@ func quotaAwareHTTPClient(proxy string) (*http.Client, error) { } // TestConnection makes a minimal real completion to verify the provider/model/ -// endpoint/key actually work. Returns the round-trip latency. -func TestConnection(ctx context.Context, c Config) (time.Duration, error) { +// endpoint/key actually work. Returns the round-trip latency and the model's +// reply text. +func TestConnection(ctx context.Context, c Config) (time.Duration, string, error) { prov, err := c.NewProvider() if err != nil { - return 0, err + return 0, "", err } ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() @@ -371,7 +372,7 @@ func TestConnection(ctx context.Context, c Config) (time.Duration, error) { // 就撞到输出上限(finish=length)、被截断,连接测试虽仍算通(err=nil)但显示成 // "已中断/length/resume" 一团糟。给足预算让它把 OK 干净吐完(finish=stop)。 // EscalateMaxTokens 保持 false:不因截断而抬额重试,避免 resume 循环空烧。 - _, err = agentcore.Run(ctx, agentcore.Options{ + reply, err := agentcore.Run(ctx, agentcore.Options{ Provider: prov, SystemPrompt: []string{"你是连接测试。直接输出两个字符 OK 即可,不要思考、不要解释、不要别的。"}, PermissionMode: acperm.ModeBypass, @@ -379,5 +380,16 @@ func TestConnection(ctx context.Context, c Config) (time.Duration, error) { MaxTokens: 8192, NonStreaming: !c.Stream, // 用该 profile 的真实收发模式做连接测试 }, "ping") - return time.Since(start), err + lat := time.Since(start) + if err != nil { + return lat, "", err + } + // err==nil 还不够:请求通了但模型一个字都不吐的情况真实存在(思考把预算烧光、 + // 正文被安全策略吞掉、兼容层把 content 丢了)。这种配置在会话里就是"不回话", + // 测试却报成功——正是本项要消除的落差。没有可见正文一律判失败。 + reply = strings.TrimSpace(reply) + if reply == "" { + return lat, "", fmt.Errorf("模型无回复内容(请求已通,但未返回任何文本)") + } + return lat, reply, nil } diff --git a/server/server.go b/server/server.go index dca9193..f7ab8c7 100644 --- a/server/server.go +++ b/server/server.go @@ -1337,6 +1337,7 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) { ThinkingType string `json:"thinking_type"` ReasoningEffort string `json:"reasoning_effort"` ProfileID *int64 `json:"profile_id"` // 测已存 profile 时传入:api_key 为空则用它存的 key + Streaming *bool `json:"streaming"` // 省略=流式,与保存 profile 时同一套默认 } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, 400, err.Error()) @@ -1347,6 +1348,11 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) { // reasoning_effort/thinking field fails the test too (no false "test ok, run 400"). cfg.ThinkingType = req.ThinkingType cfg.ReasoningEffort = req.ReasoningEffort + // 同理,收发模式也照该 profile 的选择来:只支持其中一种通道的端点必须在这里就 + // 暴露,而不是等会话里才发现"测试通过的配置根本跑不动"。 + if req.Streaming != nil { + cfg.Stream = *req.Streaming + } // API Key 解析优先级:表单输入 > 指定 profile 存的 key > 全局配置的 key。 // 已存 profile 的 key 不回传浏览器,所以测试已存配置时表单为空,需从 DB 取。 if cfg.APIKey == "" && req.ProfileID != nil { @@ -1363,12 +1369,27 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"ok": false, "error": "未提供 API Key"}) return } - lat, err := agent.TestConnection(r.Context(), cfg) + lat, reply, err := agent.TestConnection(r.Context(), cfg) if err != nil { writeJSON(w, 200, map[string]any{"ok": false, "error": err.Error()}) return } - writeJSON(w, 200, map[string]any{"ok": true, "latency_ms": lat.Milliseconds(), "model": cfg.Model}) + // 回传模型实际回复,让"测试通过"有据可查:看得见它确实说了话,而不只是 HTTP 200。 + writeJSON(w, 200, map[string]any{ + "ok": true, "latency_ms": lat.Milliseconds(), "model": cfg.Model, "reply": truncateReply(reply), + }) +} + +// truncateReply clips a connection-test reply for display. A model told to answer +// "OK" can still ramble (or think out loud); the UI only needs enough to show it +// really said something. Rune-based so multibyte text never splits mid-character. +func truncateReply(s string) string { + const max = 200 + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) + "…" } type createTaskReq struct { diff --git a/web/src/app/(main)/system/llm/page.tsx b/web/src/app/(main)/system/llm/page.tsx index 3a3e5af..7065a29 100644 --- a/web/src/app/(main)/system/llm/page.tsx +++ b/web/src/app/(main)/system/llm/page.tsx @@ -369,8 +369,22 @@ function ProfileSheet({ try { // 用配置实际会跑的思考参数来测,这样不支持该字段的模型在这里就失败, // 而不是等到跑任务时才炸。传 profile id:Key 输入框留空时用已存的 Key。 - const r = await api.testLLM(format, model, baseUrl, apiKey, proxy, toStore(thinkingType), toStore(effort), profileId); - if (r.ok) toast.success(`连接成功 · ${r.latency_ms ?? "?"}ms · ${r.model ?? model}`); + const r = await api.testLLM( + format, + model, + baseUrl, + apiKey, + proxy, + toStore(thinkingType), + toStore(effort), + profileId, + streaming, + ); + // 回复内容一并展示:看得见模型确实说了话,才算和会话里跑通是一回事。 + if (r.ok) + toast.success(`连接成功 · ${r.latency_ms ?? "?"}ms · ${r.model ?? model}`, { + description: r.reply ? `回复:${r.reply}` : undefined, + }); else toast.error(`连接失败:${r.error ?? "未知"}`); } catch (e) { toast.error(`测试出错:${(e as Error).message}`); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 6a0232e..03ce937 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -646,8 +646,10 @@ export const api = { thinking_type = "", reasoning_effort = "", profile_id?: number, + streaming = true, // 用该配置真实的收发模式来测,别让"流式能通、非流式不通"漏到会话里 ) => - post<{ ok: boolean; error?: string; latency_ms?: number; model?: string }>("/llm/test", { + // reply = 模型实际回复(已截断);一个字都不回的配置后端直接判失败 + post<{ ok: boolean; error?: string; latency_ms?: number; model?: string; reply?: string }>("/llm/test", { provider, model, base_url, @@ -656,6 +658,7 @@ export const api = { thinking_type, reasoning_effort, profile_id, + streaming, }), llmProfiles: () => get<{ profiles: LLMProfile[] }>("/llm/profiles").then((r) => arr(r.profiles)), saveLLMProfile: (p: { diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 9e69c14..dd13347 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -1155,7 +1155,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re } if (path === "/llm" && m === "GET") return D.llmConfig; if (path === "/llm" && m === "POST") return { ok: true }; - if (path === "/llm/test") return { ok: true, latency_ms: 128, model: String(b.model ?? "claude-opus-4-8") }; + if (path === "/llm/test") + return { ok: true, latency_ms: 128, model: String(b.model ?? "claude-opus-4-8"), reply: "OK" }; if (path === "/llm/profiles" && m === "GET") return { profiles: D.llmProfiles }; if (path === "/llm/profiles" && m === "POST") return { id: Number(b.id) || 3 }; if (path === "/llm/profiles/active") return { ok: true }; From 10f6a4fbc7bde0edec0934594fc116b270ba54b8 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:15:02 -0400 Subject: [PATCH 13/14] =?UTF-8?q?feat(agent):=20list=5Ffacts=20=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=88=86=E9=A1=B5+=E5=85=B3=E9=94=AE=E8=AF=8D?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=EF=BC=8C=E9=81=BF=E5=85=8D=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=E8=86=A8=E8=83=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fact 多时旧的 list_facts 一次返回每任务至多 500 条且 summary 不截断, 一次调用就可能撑爆上下文。改为分页(默认 20,最新在前)+ q 关键词过滤, 返回 {facts, total, has_more, next_before},summary 超长按 rune 截断。 - db: listByKindPageFiltered/countByKindFiltered(payload->>'summary' ILIKE) + ListByKindPageWithSources 跨本任务/继承任务合并分页 - agent: list_facts 加 limit/before/q 参数,compactFact 截断 summary; worker/planner 提示词同步说明分页语义 - server: 旧库一次性迁移把 list_facts 新 schema 刷进 tools 目录表 (SeedTool 首插入only,否则工具管理页显示「无参数」);flag bump 到 v7 --- agent/blackboard_inheritance_test.go | 2 +- agent/planner.go | 2 +- agent/tools.go | 57 +++++++++++++++++++++++++--- agent/worker.go | 2 +- db/exploration.go | 34 +++++++++++++++++ db/exploration_sources.go | 47 +++++++++++++++++++++++ server/orchestration.go | 6 ++- 7 files changed, 139 insertions(+), 11 deletions(-) diff --git a/agent/blackboard_inheritance_test.go b/agent/blackboard_inheritance_test.go index fcbcb6f..4611bc1 100644 --- a/agent/blackboard_inheritance_test.go +++ b/agent/blackboard_inheritance_test.go @@ -223,7 +223,7 @@ func TestBlackboardToolsReadDirectSources(t *testing.T) { t.Fatalf("inherited host context missing: %#v", coverage) } - facts := callReadJSON(t, tools.listFacts(), `{}`).([]any) + facts := callReadJSON(t, tools.listFacts(), `{}`).(map[string]any)["facts"].([]any) seenSource, seenGrand := false, false for _, raw := range facts { item := raw.(map[string]any) diff --git a/agent/planner.go b/agent/planner.go index 88f2013..95802f8 100644 --- a/agent/planner.go +++ b/agent/planner.go @@ -248,7 +248,7 @@ const plannerDefaultTmpl = `你是一个授权渗透测试系统的"规划者" 反过来:若确有【未被任何意图覆盖、且不依赖在跑 work】的新方向,或【目标尚未达成且范围内仍有未测面】,就【不要】因为"0 意图常见"而收手——该派就派。别把 0 意图当成偷懒的默认。 决策流程(每次唤醒): -1. **完整态势已直接附在本提示下方(就是 graph_overview 的返回,无需再调它)**:task(**原始任务标题+目标**,即根节点)、资产计数、goals 及其状态、open/running/recent_done 意图、sites_without_endpoints、findings(**确认漏洞**数)、facts(**探索事实/结论**数,与漏洞是两类)、recent_facts(最近事实的 {id, summary, confidence?},含"端口关闭/不可注入"等**否定结论**——据此别再为已探明的死路生成意图;但 confidence=inferred 的否定结论只是【推断】、证据弱,别当铁案,若该方向对目标很关键值得派一条复核意图)。**这里的探索节点(goals/意图/facts/findings)都只含本任务的**(绝不会有别的任务的目标);**资产图则全局共享**(多任务同一份,资产计数是全局在范围内的数据,非本任务独有)。需要更深的细节时才按需调:list_facts 列全部事实、list_findings 列全部漏洞、**node_detail(id)** 取某条的完整证据/详情(列表/recent_facts 只给摘要)。 +1. **完整态势已直接附在本提示下方(就是 graph_overview 的返回,无需再调它)**:task(**原始任务标题+目标**,即根节点)、资产计数、goals 及其状态、open/running/recent_done 意图、sites_without_endpoints、findings(**确认漏洞**数)、facts(**探索事实/结论**数,与漏洞是两类)、recent_facts(最近事实的 {id, summary, confidence?},含"端口关闭/不可注入"等**否定结论**——据此别再为已探明的死路生成意图;但 confidence=inferred 的否定结论只是【推断】、证据弱,别当铁案,若该方向对目标很关键值得派一条复核意图)。**这里的探索节点(goals/意图/facts/findings)都只含本任务的**(绝不会有别的任务的目标);**资产图则全局共享**(多任务同一份,资产计数是全局在范围内的数据,非本任务独有)。需要更深的细节时才按需调:list_facts 分页列事实(最新在前,默认 20 条,可传 q 关键词过滤、before 翻页,返回带 total/has_more)、list_findings 列全部漏洞、**node_detail(id)** 取某条的完整证据/详情(列表/recent_facts 只给摘要)。 - open_intents / running_intents / recent_done_intents——"哪些方向已经有意图在覆盖/已尝试"。每个意图还带 **parents(上游:它派生自哪些事实/意图)和 yields(下游:它产生了哪些事实/发现)**——这就是探索图的**血缘关系**,据此理解"哪些事实来自哪个方向、能否综合成新方向"。recent_facts 里每个事实带 **from_intent**(由哪个意图产生)。 - sites_without_endpoints / findings——"哪些方向【可能】需要探索"。 - 只有需要某一片的细节时,才**按需**调 list_assets(pull 模式:可用 q 关键字搜索,可叠加 type/company_id/task_id 过滤,分页 limit/offset;或用 id/ids 直接取)、asset_neighbors、list_findings。资产图全局共享,别默认拉全量。资产中可能包含非本次任务涉及到的资产,所以需要主要出现非本次任务相关的资产时忽略这些资产。 diff --git a/agent/tools.go b/agent/tools.go index f8493ab..ebed6d8 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -898,19 +898,64 @@ func (t *ToolSet) listFindings() actool.CoreTool { }) } +// factsPageSize is the default page size for list_facts. Facts pile up on long +// tasks; returning all of them at once (the old behaviour) could blow up the +// context, so default to the newest page and let the agent page/filter for more. +const factsPageSize = 20 + func (t *ToolSet) listFacts() actool.CoreTool { - return readTool("list_facts", "列本任务及直接关联任务的【探索事实/结论】(紧凑:id+摘要+状态)。关联任务条目带 source_task_id/inherited=true 且只读。详情用 node_detail(id),漏洞看 list_findings。", - obj(map[string]any{}), - func(context.Context, json.RawMessage) (actool.Result, error) { - f, _ := t.ts.ListByKindWithSources(db.KindFact, 500) + return readTool("list_facts", "分页列本任务及直接关联任务的【探索事实/结论】,最新在前(紧凑:id+摘要+状态,摘要过长会截断,全文用 node_detail(id))。参数均可选:limit(默认 20,上限 100)、before(游标,传上一页返回的 next_before 取更旧的一页;省略/0=最新一页)、q(按摘要关键词过滤)。返回 {facts, total, has_more, next_before}:total 是过滤后的总数,has_more=true 时用 next_before 继续翻页。关联任务条目带 source_task_id/inherited=true 且只读。漏洞看 list_findings。", + obj(map[string]any{ + "limit": intp("返回条数,默认 20,上限 100"), + "before": intp("分页游标:只返回 id 小于该值的更旧事实;省略或 0 = 最新一页"), + "q": str("按事实摘要关键词过滤(不区分大小写);省略 = 不过滤"), + }), + func(_ context.Context, in json.RawMessage) (actool.Result, error) { + var a struct { + Limit int `json:"limit"` + Before int64 `json:"before"` + Q string `json:"q"` + } + _ = json.Unmarshal(in, &a) + limit := a.Limit + if limit <= 0 { + limit = factsPageSize + } + if limit > 100 { + limit = 100 + } + f, hasMore, total, err := t.ts.ListByKindPageWithSources(db.KindFact, a.Before, limit, strings.TrimSpace(a.Q)) + if err != nil { + return actool.Result{}, err + } out := make([]map[string]any, 0, len(f)) for _, n := range f { - out = append(out, compactNode(n)) + out = append(out, compactFact(n)) } - return jsonResult(out) + res := map[string]any{"facts": out, "total": total, "has_more": hasMore} + if hasMore && len(f) > 0 { + res["next_before"] = f[len(f)-1].ID // 传回它取下一页(更旧的) + } + return jsonResult(res) }) } +// factSummaryMax caps a fact summary in list_facts output. Facts carry one-line +// conclusions, but nothing enforces brevity; a runaway summary must not bloat a +// whole page. Full text stays available via node_detail(id). +const factSummaryMax = 160 + +// compactFact is compactNode with the summary rune-capped for list_facts, so a +// page of facts stays bounded regardless of how long any single summary grew. +func compactFact(n *db.Node) map[string]any { + m := compactNode(n) + if s, ok := m["summary"].(string); ok && len([]rune(s)) > factSummaryMax { + m["summary"] = string([]rune(s)[:factSummaryMax]) + "…" + m["summary_truncated"] = true + } + return m +} + func (t *ToolSet) nodeDetail() actool.CoreTool { return readTool("node_detail", "按 id 取本任务或直接关联任务的【探索图节点】完整内容。继承节点带 source_task_id/inherited=true 且只读。仅限 list_facts/list_findings/graph_overview 返回的探索节点 id;资产请用 list_assets/asset_neighbors。", obj(map[string]any{"id": idp("探索图节点 id(非资产 id)")}, "id"), diff --git a/agent/worker.go b/agent/worker.go index c75c97b..2c77722 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -171,7 +171,7 @@ const workerDefaultTmpl = `你是一个授权渗透测试系统的"执行者"(wo - insert_assets:登记新资产(资产图)。**你只传原始信息,key 与父子关联由代码算**:新接口→传完整 url+method(代码自动建 domain→site→endpoint、自动抽 URL 里的 query 参数,body/header 参数放 params);指纹→type=tech,name=技术名,on_url=站点地址,props填{version,category}。多个资产放 assets 数组一次批量登记。属性写在资产自己的 props 上,探索结论不要写这里。 - record_fact:把探索【事实/结论】写入探索图并连到意图(传 intent_id)。正向/否定结论、观察、判断用它;**一次探索的多个观察汇总成一条事实**(summary 总结一句话 + detail 放细节),不要一个属性一条。真有多条不同结论才用 facts 数组。**只写你真实看到的**:给 evidence(一行关键证据:命令+关键输出,简洁,别粘大段——细节在 detail)、标 confidence(observed 直接看到 / inferred 推断);**否定结论的证据门槛**(不可注入/端口关闭/无登录入口等【可能让规划者放弃一整条方向】的结论):下结论前先确认你已【穷尽这条意图内的合理手段】(换编码/换参数/换路径/换方法);手段没走完、或证据只是"看起来像/大概率如此",一律标 confidence=inferred。**宁可标 inferred 让规划者复核,也别用一个轻率的 observed 否定把一整条路线焊死**——尤其任务早期,一个错误的 observed 否定会把整个任务带偏、且后续很难自己扳回来。 - report_finding:确认漏洞 → 记录(含 PoC,传 intent_id=你领到的意图id)。**只有你在本次运行里真实触发过该漏洞、拿到了可复现的证据(请求/响应或命令输出)才用它。** 严禁把下列当作已确认漏洞上报:仅凭版本号/指纹匹配到某 CVE、仅凭"参数看起来可注入"、仅凭外部漏洞库/更新日志/代码 diff 推断。**不要用查 CVE 库或"对比补丁版本"替代实际触发。** 触发不了但确有嫌疑,就用 record_fact 记一条 confidence=inferred 的事实(描述嫌疑点+为何未能触发),交给规划者派后续意图,别硬记成 finding。 -- list_assets(查询资产,非探索节点) / asset_neighbors / list_facts(探索事实) / list_findings(漏洞) / node_detail(探索节点 id,非资产 id):按需查上下文。 +- list_assets(查询资产,非探索节点) / asset_neighbors / list_facts(探索事实,分页最新在前;默认 20 条,可传 q 关键词过滤、before 翻页) / list_findings(漏洞) / node_detail(探索节点 id,非资产 id):按需查上下文。 只在授权范围内操作;若系统提示顶部附有【操作约束】,那是最高优先级红线——任何命令/探测在执行前先自检是否违反,违反即不做(哪怕它落在你领到的意图里)。完成本意图后用一句话总结你做了什么、写回了哪些事实。务实、克制、聚焦这一条意图。` diff --git a/db/exploration.go b/db/exploration.go index 3a8c563..2ede3b5 100644 --- a/db/exploration.go +++ b/db/exploration.go @@ -672,6 +672,40 @@ ORDER BY id DESC LIMIT $4`, s.expID, kind, before, limit+1) return nodes, hasMore, nil } +// listByKindPageFiltered is ListByKindPage with an optional summary keyword +// filter (payload->>'summary' ILIKE %q%). Fetches one extra row so the caller can +// probe hasMore. Empty q = no filter. Newest-first by id. +func (s *ExplorationStore) listByKindPageFiltered(kind string, before int64, limit int, q string) ([]*Node, error) { + where := `exploration_id=$1 AND kind=$2 AND ($3 <= 0 OR id < $3)` + args := []any{s.expID, kind, before} + if q != "" { + args = append(args, "%"+q+"%") + where += fmt.Sprintf(` AND payload->>'summary' ILIKE $%d`, len(args)) + } + args = append(args, limit+1) + rows, err := s.db.Query(`SELECT `+nodeCols+` FROM exploration_nodes +WHERE `+where+` ORDER BY id DESC LIMIT $`+fmt.Sprintf("%d", len(args)), args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanNodes(rows) +} + +// countByKindFiltered counts nodes of a kind under the same summary filter, for a +// page's total. Empty q = count all of that kind. +func (s *ExplorationStore) countByKindFiltered(kind, q string) (int, error) { + where := `exploration_id=$1 AND kind=$2` + args := []any{s.expID, kind} + if q != "" { + args = append(args, "%"+q+"%") + where += fmt.Sprintf(` AND payload->>'summary' ILIKE $%d`, len(args)) + } + var n int + err := s.db.QueryRow(`SELECT COUNT(*) FROM exploration_nodes WHERE `+where, args...).Scan(&n) + return n, err +} + // GetNode returns one node of this exploration by id (nil, nil if not found). func (s *ExplorationStore) GetNode(id int64) (*Node, error) { n, err := scanNode(s.db.QueryRow(`SELECT `+nodeCols+` FROM exploration_nodes WHERE id=$1 AND exploration_id=$2`, id, s.expID)) diff --git a/db/exploration_sources.go b/db/exploration_sources.go index 3ccbb96..c12d5ba 100644 --- a/db/exploration_sources.go +++ b/db/exploration_sources.go @@ -108,6 +108,53 @@ func (s *ExplorationStore) ListByKindWithSources(kind string, limit int) ([]*Nod return out, nil } +// ListByKindPageWithSources is the paginated, keyword-filterable sibling of +// ListByKindWithSources: it returns one newest-first page (id < before, before<=0 +// = newest) spanning this exploration and its direct sources, plus hasMore and the +// filtered total across all of them. Node ids are globally unique, so merging each +// store's own page and re-sorting by id DESC yields the true global page; fetching +// limit+1 per store guarantees the merged top-`limit` is complete. +func (s *ExplorationStore) ListByKindPageWithSources(kind string, before int64, limit int, q string) (nodes []*Node, hasMore bool, total int, err error) { + if limit <= 0 { + limit = 20 + } + own, err := s.listByKindPageFiltered(kind, before, limit, q) + if err != nil { + return nil, false, 0, err + } + merged := own + total, err = s.countByKindFiltered(kind, q) + if err != nil { + return nil, false, 0, err + } + + sources, err := s.DirectSourceStores() + if err != nil { + return nil, false, 0, err + } + for _, source := range sources { + page, err := source.Store.listByKindPageFiltered(kind, before, limit, q) + if err != nil { + return nil, false, 0, err + } + for _, n := range page { + merged = append(merged, markInheritedNode(n, source.Task.TaskID)) + } + cnt, err := source.Store.countByKindFiltered(kind, q) + if err != nil { + return nil, false, 0, err + } + total += cnt + } + + sort.Slice(merged, func(i, j int) bool { return merged[i].ID > merged[j].ID }) + hasMore = len(merged) > limit + if hasMore { + merged = merged[:limit] + } + return merged, hasMore, total, nil +} + // GetNodeWithSources reads a node only when it belongs to this exploration or // one of its direct sources. Inherited nodes are tagged so tool callers can keep // them read-only and show their provenance. diff --git a/server/orchestration.go b/server/orchestration.go index 287eaa0..08f0167 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -466,7 +466,7 @@ func (s *Server) seedOrchestrationTools() { // reaches an old DB otherwise. Preserves each tool's agent binding + enabled flag. // Bump the flag whenever these tools' schemas/descriptions change in code. func (s *Server) refreshBuiltinToolSchemas() { - const flag = "tool_schema_refresh_v6_insert_assets_related" + const flag = "tool_schema_refresh_v7_list_facts_paging" if v, _, _ := s.m.pg.GetSetting(flag); v == "true" { return } @@ -482,7 +482,9 @@ func (s *Server) refreshBuiltinToolSchemas() { // “结束空轮”的手段、刚开跑就误判整个任务完成。 // - insert_assets:新增 related 入参(标记资产是否与当前任务相关、决定是否入覆盖度), // SeedTool 首插入only,旧库已 seed 的 schema 否则收不到这个新参数。 - refreshBuiltin := map[string]bool{"goal_met": true, "insert_assets": true} + // - list_facts:改为分页,新增 limit/before/q 入参;旧库已 seed 的空 schema 否则 + // 在工具管理页显示「无参数」,模型也拿不到这几个参数说明。 + refreshBuiltin := map[string]bool{"goal_met": true, "insert_assets": true, "list_facts": true} for _, sd := range agent.BuiltinToolSeeds() { if !refreshBuiltin[sd.Key] { continue From 051ca84a20bbda634cbc40b32211e0a546852070 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:34:51 -0400 Subject: [PATCH 14/14] =?UTF-8?q?docs(changelog):=20=E5=8F=91=E5=B8=83=200?= =?UTF-8?q?.3.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9cb758..b6e5616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,37 @@ ## [Unreleased] +## [0.3.6] - 2026-08-27 + +### LLM + +#### 修复的问题 + +- 修复「非流式」开关保存后重新打开又变回「流式」的问题(#69):配置列表接口的 DTO 漏了 `streaming` 字段,响应从不返回它,前端读到 `undefined` 后一律回落成默认流式;实际写入与 DB 存储本无问题,只是读不回来。DTO 补回该字段(不加 `omitempty`,`false` 也必须出现在响应里)。 +- 测试连接改为校验模型确有回复、并按该配置真实的收发模式来测(#65):此前只判 HTTP 是否成功,请求通但模型零回复(思考烧光预算 / 正文被安全策略吞掉 / 兼容层丢 `content`)也报「连接成功」,与会话里「不回话」的表现割裂;且测试恒走流式,非流式配置测的其实是另一条通道。现在空回复直接判失败、成功时在提示里展示模型回复,并把当前流式开关一并带入测试,让「测试通过」与「会话跑得通」保持一致。 + +### Agent + +#### 修改的功能 + +- `list_facts` 改为分页 + 关键词过滤,避免事实多时单次调用撑爆上下文(#74):默认返回最新 20 条,支持 `limit`(上限 100)/ `before` 游标 / `q` 摘要关键词,返回 `{facts, total, has_more, next_before}`;单条 summary 过长按字数截断(全文仍走 `node_detail`)。worker / planner 提示词同步为分页语义。旧库通过一次性迁移把新参数 schema 刷进工具目录表(`SeedTool` 首插入only,否则工具管理页显示「无参数」)。 + +### 工具 + +#### 新增的功能 + +- 「工具执行」页新增工具调用次数统计(#72):工具栏「统计」按钮打开弹窗,按工具展示调用次数、占比与失败次数(次数降序);沿用列表的任务 / 关键词筛选,统计整个结果集而非当前页,弹窗打开时才拉取。 + +### 依赖 + +#### 修改的功能 + +- 升级 norma v0.3.1 → v0.3.2:修复 OpenAI 系响应里被静默丢弃的 reasoning/refusal(Responses 补 `reasoning_text`)、reasoning 字段三名去重、空响应(零内容块)有界重试、压缩边界切断工具配对导致的网关 400(同时修复已烤进 transcript 的存量孤儿)。 + +### 贡献者 + +- [@Autumn-27](https://github.com/Autumn-27) + ## [0.3.5] - 2026-08-25 ### LLM