From 555539bc8e0abcefa6ba460f911268069af8d949 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:42:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=E6=94=AF=E6=8C=81=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=20/btw=20=E6=97=81=E8=B7=AF=E6=8F=90?= =?UTF-8?q?=E9=97=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/capture.go | 4 + agent/chat.go | 3 +- agent/mainagent.go | 3 +- agent/side_questions.go | 23 + agent/side_questions_test.go | 122 ++++ agent/worker.go | 1 + db/exploration.go | 5 + db/schema.sql | 39 ++ db/side_questions.go | 232 +++++++ db/side_questions_test.go | 270 ++++++++ db/task_archives.go | 4 +- db/task_archives_restore.go | 9 + llmrec/llmrec.go | 6 + llmrec/llmrec_test.go | 60 ++ server/conversations.go | 3 + server/server.go | 38 +- server/server_mgmt.go | 4 + server/side_questions.go | 574 ++++++++++++++++ server/side_questions_test.go | 453 +++++++++++++ server/task_archives.go | 3 + server/task_control.go | 1 + sidequestion/README.md | 66 ++ sidequestion/VALIDATION.md | 93 +++ sidequestion/capture.go | 197 ++++++ sidequestion/request.go | 68 ++ sidequestion/service.go | 66 ++ sidequestion/sidequestion_test.go | 266 ++++++++ sidequestion/validation-2026-09-10.json | 297 +++++++++ web/src/app/(main)/chat/page.tsx | 22 +- .../tasks/detail/_tabs/sessions-tab.tsx | 626 +++++++++--------- .../components/side-question-workspace.tsx | 231 +++++++ web/src/hooks/use-side-questions.ts | 224 +++++++ web/src/lib/api.ts | 2 +- web/src/lib/side-questions.ts | 43 ++ 34 files changed, 3744 insertions(+), 314 deletions(-) create mode 100644 agent/side_questions.go create mode 100644 agent/side_questions_test.go create mode 100644 db/side_questions.go create mode 100644 db/side_questions_test.go create mode 100644 server/side_questions.go create mode 100644 server/side_questions_test.go create mode 100644 sidequestion/README.md create mode 100644 sidequestion/VALIDATION.md create mode 100644 sidequestion/capture.go create mode 100644 sidequestion/request.go create mode 100644 sidequestion/service.go create mode 100644 sidequestion/sidequestion_test.go create mode 100644 sidequestion/validation-2026-09-10.json create mode 100644 web/src/components/side-question-workspace.tsx create mode 100644 web/src/hooks/use-side-questions.ts create mode 100644 web/src/lib/side-questions.ts diff --git a/agent/capture.go b/agent/capture.go index 965a9f3..27e9681 100644 --- a/agent/capture.go +++ b/agent/capture.go @@ -7,6 +7,7 @@ import ( "unicode/utf8" "github.com/Autumn-27/artex/db" + "github.com/Autumn-27/artex/sidequestion" "github.com/Autumn-27/norma/agentcore" "github.com/Autumn-27/norma/harness" "github.com/Autumn-27/norma/llm" @@ -119,6 +120,9 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string, } case harness.KindResult: if ev.Terminal != nil { + if ev.Terminal.Reason != harness.ReasonAbortedStreaming { + sidequestion.Finish(ctx, ev.Terminal.Messages) + } finalText = ev.Terminal.Text reason = ev.Terminal.Reason // the buffered tail text usually equals Terminal.Text (final answer); diff --git a/agent/chat.go b/agent/chat.go index 71294e8..fbf0832 100644 --- a/agent/chat.go +++ b/agent/chat.go @@ -147,7 +147,7 @@ func (c *ChatAgent) Chat(ctx context.Context, agentKey, sessionID, message strin // (自定义 agent 各自一份;留空/0 用通用默认:10 轮)。 Settlement: wrapupSettlement(agentKey, nil), NonStreaming: c.nonStreaming(), // 该 profile 选非流式时走 Provider.Complete - MaxTokens: c.maxTokens(), // 0 = 不发上限,由服务端默认值决定 + MaxTokens: c.maxTokens(), // 0 = 不发上限,由服务端默认值决定 } if c.guard != nil { opts.Hooks = c.guard.Hooks() @@ -156,6 +156,7 @@ func (c *ChatAgent) Chat(ctx context.Context, agentKey, sessionID, message strin opts.Transcript = c.tx opts.SessionID = sessionID } + ctx = attachSideCapture(ctx, &opts) s := agentcore.NewSession(opts) defer s.Close() // reload prior conversation so the agent has context across turns (each Chat is diff --git a/agent/mainagent.go b/agent/mainagent.go index e20b80c..1bb7125 100644 --- a/agent/mainagent.go +++ b/agent/mainagent.go @@ -149,12 +149,13 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, t // 命中预算(步数)→ SDK 跑收尾:向用户输出一句进展总结。Prompt 与收尾轮数可后台编辑(默认 10 轮)。 Settlement: wrapupSettlement("mainagent", nil), NonStreaming: m.nonStreaming(), // 该 profile 选非流式时走 Provider.Complete - MaxTokens: m.maxTokens(), // 0 = 不发上限,由服务端默认值决定 + MaxTokens: m.maxTokens(), // 0 = 不发上限,由服务端默认值决定 } if m.tx != nil { // persist raw human↔AI conversation; one accumulating file per task opts.Transcript = m.tx opts.SessionID = fmt.Sprintf("exp%d-main", ts.ID()) } + ctx = attachSideCapture(ctx, &opts) s := agentcore.NewSession(opts) defer s.Close() // reload the prior conversation from the transcript so the agent has context diff --git a/agent/side_questions.go b/agent/side_questions.go new file mode 100644 index 0000000..6bef62b --- /dev/null +++ b/agent/side_questions.go @@ -0,0 +1,23 @@ +package agent + +import ( + "context" + "strconv" + "strings" + + "github.com/Autumn-27/artex/sidequestion" + "github.com/Autumn-27/norma/agentcore" +) + +func attachSideCapture(ctx context.Context, opts *agentcore.Options) context.Context { + ri := RunInfoFrom(ctx) + p := sidequestion.Parent{TaskID: ri.TaskID, ExplorationID: ri.ExplorationID, IntentID: ri.IntentID} + if strings.HasPrefix(ri.SessionID, "conv-") { + p.ConversationID, _ = strconv.ParseInt(strings.TrimPrefix(ri.SessionID, "conv-"), 10, 64) + } + if p.ConversationID == 0 && (p.TaskID == 0 || p.ExplorationID == 0) { + return ctx + } + ctx, opts.Deps = sidequestion.Attach(ctx, p, opts.Deps, opts.Provider) + return ctx +} diff --git a/agent/side_questions_test.go b/agent/side_questions_test.go new file mode 100644 index 0000000..6de19c1 --- /dev/null +++ b/agent/side_questions_test.go @@ -0,0 +1,122 @@ +package agent + +import ( + "context" + "encoding/json" + "iter" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Autumn-27/artex/db" + "github.com/Autumn-27/artex/sidequestion" + "github.com/Autumn-27/norma/llm" + "github.com/Autumn-27/norma/transcript" +) + +type sideAgentProvider struct { + calls int + path string +} + +func (p *sideAgentProvider) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] { + return func(y func(llm.StreamEvent, error) bool) { + msg, stop, _, err := p.Complete(ctx, req) + if err != nil { + y(llm.StreamEvent{}, err) + return + } + for _, b := range msg.Content { + if b.Type == llm.BlockToolUse { + if !y(llm.StreamEvent{Type: llm.SEToolUseStart, ToolID: b.ID, ToolName: b.Name}, nil) { + return + } + if !y(llm.StreamEvent{Type: llm.SEToolInputJSON, Text: string(b.Input)}, nil) { + return + } + } else if !y(llm.StreamEvent{Type: llm.SETextDelta, Text: b.Text}, nil) { + return + } + } + if !y(llm.StreamEvent{Type: llm.SEMessageDelta, StopReason: stop}, nil) { + return + } + y(llm.StreamEvent{Type: llm.SEMessageStop}, nil) + } +} +func (p *sideAgentProvider) Complete(_ context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { + p.calls++ + if p.calls == 1 { + input, _ := json.Marshal(map[string]string{"file_path": p.path}) + return llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{{Type: llm.BlockToolUse, ID: "fixture-read", Name: "Read", Input: input}}}, "tool_use", llm.Usage{}, nil + } + return llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{llm.TextBlock("main finished")}}, "end_turn", llm.Usage{}, nil +} + +func TestSideActualChatCheckpointToolResultAndTranscriptIsolation(t *testing.T) { + for _, streaming := range []bool{false, true} { + t.Run(map[bool]string{true: "stream", false: "atomic"}[streaming], func(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "asset.txt") + if err := os.WriteFile(file, []byte("controlled-homepage-result"), 0600); err != nil { + t.Fatal(err) + } + p := &sideAgentProvider{path: file} + bound := sidequestion.Bind(p, sidequestion.Model{Model: "fixture", Streaming: streaming}) + store := transcript.NewStore(filepath.Join(dir, "transcripts")) + chat := NewChatAgent(bound, "fixture", dir, store, 100000) + chat.SetNonStreaming(func() bool { return !streaming }) + var snapshots []sidequestion.Snapshot + var activities []db.Activity + ctx := sidequestion.WithPublisher(t.Context(), func(s sidequestion.Snapshot) { snapshots = append(snapshots, s) }) + if _, err := chat.Chat(ctx, "mainagent", "conv-987654", "Read the local fixture", 5, time.Minute, false, func(a db.Activity) { activities = append(activities, a) }); err != nil { + t.Fatal(err) + } + if len(snapshots) < 3 { + t.Fatalf("actual Chat missed capture hooks: %d", len(snapshots)) + } + last := snapshots[len(snapshots)-1] + raw, _ := json.Marshal(last) + if last.Parent.ConversationID != 987654 || !strings.Contains(string(raw), "controlled-homepage-result") || !strings.Contains(string(raw), "main finished") { + t.Fatalf("missing real tool result/final reply: %s", raw) + } + before, err := os.ReadFile(store.MainPath("conv-987654")) + if err != nil { + t.Fatal(err) + } + count := len(activities) + tools := 0 + for _, a := range activities { + if a.Kind == "tool_use" { + tools++ + } + } + if tools != 1 { + t.Fatalf("main fixture tool executions: %d", tools) + } + req, err := sidequestion.BuildRequest(last, nil, "side-only-question") + if err != nil { + t.Fatal(err) + } + // Reset the fake to request another Read; the side executor cannot run it. + p.calls = 0 + p.path = filepath.Join(dir, "nonexistent") + answer, err := (sidequestion.SideQuestionService{Provider: bound}).Answer(t.Context(), req, streaming, nil) + if err != nil || !answer.ToolUse || p.calls != 1 || !strings.Contains(answer.Text, "不能执行工具") { + t.Fatalf("tool denial %+v %v", answer, err) + } + after, err := os.ReadFile(store.MainPath("conv-987654")) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) || len(activities) != count { + t.Fatal("side question modified main transcript/activity") + } + if len(snapshots) == 0 || snapshots[len(snapshots)-1].Version != last.Version { + t.Fatal("side request replaced main checkpoint") + } + }) + } +} diff --git a/agent/worker.go b/agent/worker.go index 1151f47..d1e3cc3 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -450,6 +450,7 @@ func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db. input = "开始执行 system 里领到的意图:只做它、只产生事实、assets、finding、做完即停。" } + ctx = attachSideCapture(ctx, &opts) s := agentcore.NewSession(opts) defer s.Close() // release the session's background-task manager (temp dir + processes) diff --git a/db/exploration.go b/db/exploration.go index ad85ee5..06debca 100644 --- a/db/exploration.go +++ b/db/exploration.go @@ -377,6 +377,11 @@ func (s *ExplorationStore) StopIntentWithReason(id int64, reason, origin string) WHERE id=$1 AND exploration_id=$2`, id, s.expID, string(newPayload)); err != nil { return 0, err } + // Soft deletion retains the main intent audit trail, but side conversations + // are removed atomically with the stopped state. Delayed snapshots reject it. + if _, err := tx.Exec(`DELETE FROM side_question_sessions WHERE intent_id=$1`, id); err != nil { + return 0, err + } if origin == "" { origin = "user" diff --git a/db/schema.sql b/db/schema.sql index 4248569..4abfda1 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1069,3 +1069,42 @@ CREATE TABLE IF NOT EXISTS server_logs ( text TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_server_logs_id ON server_logs(id DESC); + +-- Independent /btw history and the latest provider-ready main checkpoint. +CREATE TABLE IF NOT EXISTS side_question_sessions ( + session_key TEXT PRIMARY KEY, + conversation_id BIGINT REFERENCES conversations(id) ON DELETE CASCADE, + task_id BIGINT REFERENCES tasks(id) ON DELETE CASCADE, + exploration_id BIGINT REFERENCES explorations(id) ON DELETE CASCADE, + intent_id BIGINT REFERENCES exploration_nodes(id) ON DELETE CASCADE, + run_id BIGINT NOT NULL, + version BIGINT NOT NULL, + snapshot JSONB NOT NULL, + generation BIGINT NOT NULL DEFAULT 0, + CHECK ((conversation_id IS NOT NULL AND task_id IS NULL AND exploration_id IS NULL AND intent_id IS NULL) + OR (conversation_id IS NULL AND task_id IS NOT NULL AND exploration_id IS NOT NULL)) +); +CREATE INDEX IF NOT EXISTS idx_side_sessions_conv ON side_question_sessions(conversation_id); +CREATE INDEX IF NOT EXISTS idx_side_sessions_task ON side_question_sessions(task_id); +CREATE INDEX IF NOT EXISTS idx_side_sessions_exp ON side_question_sessions(exploration_id); +CREATE INDEX IF NOT EXISTS idx_side_sessions_intent ON side_question_sessions(intent_id); + +CREATE TABLE IF NOT EXISTS side_question_requests ( + id TEXT PRIMARY KEY, + ordinal BIGSERIAL UNIQUE, + session_key TEXT NOT NULL REFERENCES side_question_sessions(session_key) ON DELETE CASCADE, + generation BIGINT NOT NULL, + client_id TEXT NOT NULL, + question TEXT NOT NULL, + answer TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL CHECK(status IN ('running','completed','failed','cancelled','interrupted')), + error TEXT NOT NULL DEFAULT '', + model JSONB NOT NULL, + snapshot_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + sequence BIGINT NOT NULL DEFAULT 0, + usage JSONB NOT NULL DEFAULT '{}', + UNIQUE(session_key,generation,client_id) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_side_request_running ON side_question_requests(session_key) WHERE status='running'; +CREATE INDEX IF NOT EXISTS idx_side_requests_history ON side_question_requests(session_key,ordinal DESC); diff --git a/db/side_questions.go b/db/side_questions.go new file mode 100644 index 0000000..3e907d8 --- /dev/null +++ b/db/side_questions.go @@ -0,0 +1,232 @@ +package db + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + + "github.com/Autumn-27/artex/sidequestion" + "github.com/google/uuid" +) + +var ErrSideBusy = errors.New("当前会话已有旁路问题正在回答") +var ErrSideParentGone = errors.New("旁路父会话已删除或归档") + +// Lock the real parent before the side session, also covering soft task/intent +// deletion. A delayed checkpoint cannot recreate data after archive cleanup. +func lockSideParent(ctx context.Context, tx *sql.Tx, p sidequestion.Parent) error { + var id int64 + var err error + if p.ConversationID > 0 { + err = tx.QueryRowContext(ctx, `SELECT id FROM conversations WHERE id=$1 FOR SHARE`, p.ConversationID).Scan(&id) + } else { + err = tx.QueryRowContext(ctx, `SELECT id FROM tasks WHERE id=$1 AND exploration_id=$2 AND deleted_at IS NULL AND archived_at IS NULL FOR SHARE`, p.TaskID, p.ExplorationID).Scan(&id) + if err == nil && p.IntentID > 0 { + err = tx.QueryRowContext(ctx, `SELECT id FROM exploration_nodes WHERE id=$1 AND exploration_id=$2 AND kind='intent' AND state<>'stopped' FOR SHARE`, p.IntentID, p.ExplorationID).Scan(&id) + } + } + if errors.Is(err, sql.ErrNoRows) { + return ErrSideParentGone + } + return err +} + +func nullableSideID(id int64) any { + if id == 0 { + return nil + } + return id +} + +func (d *DB) SaveSideSnapshot(ctx context.Context, s sidequestion.Snapshot) error { + b, err := json.Marshal(s) + if err != nil { + return err + } + tx, err := d.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if err = lockSideParent(ctx, tx, s.Parent); err != nil { + return err + } + _, err = tx.ExecContext(ctx, `INSERT INTO side_question_sessions(session_key,conversation_id,task_id,exploration_id,intent_id,run_id,version,snapshot) +VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(session_key) DO UPDATE SET run_id=EXCLUDED.run_id,version=EXCLUDED.version,snapshot=EXCLUDED.snapshot +WHERE (side_question_sessions.run_id,side_question_sessions.version)<(EXCLUDED.run_id,EXCLUDED.version)`, + s.Parent.Key(), nullableSideID(s.Parent.ConversationID), nullableSideID(s.Parent.TaskID), nullableSideID(s.Parent.ExplorationID), nullableSideID(s.Parent.IntentID), s.RunID, s.Version, string(b)) + if err != nil { + return err + } + return tx.Commit() +} + +func (d *DB) SideSnapshot(ctx context.Context, key string) (*sidequestion.Snapshot, error) { + var b []byte + err := d.QueryRowContext(ctx, `SELECT snapshot FROM side_question_sessions WHERE session_key=$1`, key).Scan(&b) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + var s sidequestion.Snapshot + err = json.Unmarshal(b, &s) + return &s, err +} + +const sideCols = `id,ordinal,session_key,generation,client_id,question,answer,status,error,model,snapshot_at,created_at,sequence,usage` + +func (d *DB) ExistingSideRequest(ctx context.Context, key, client string) (*sidequestion.Exchange, error) { + e, err := scanSide(d.QueryRowContext(ctx, `SELECT `+sideCols+` FROM side_question_requests WHERE session_key=$1 AND client_id=$2 AND generation=(SELECT generation FROM side_question_sessions WHERE session_key=$1)`, key, client)) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return &e, err +} + +func scanSide(row interface{ Scan(...any) error }) (sidequestion.Exchange, error) { + var e sidequestion.Exchange + var model, usage []byte + err := row.Scan(&e.ID, &e.Ordinal, &e.SessionKey, &e.Generation, &e.ClientID, &e.Question, &e.Answer, &e.Status, &e.Error, &model, &e.SnapshotAt, &e.CreatedAt, &e.Sequence, &usage) + if err != nil { + return e, err + } + if err = json.Unmarshal(model, &e.Model); err != nil { + return e, err + } + err = json.Unmarshal(usage, &e.Usage) + return e, err +} + +func (d *DB) SideRequest(ctx context.Context, id string) (*sidequestion.Exchange, error) { + e, err := scanSide(d.QueryRowContext(ctx, `SELECT `+sideCols+` FROM side_question_requests WHERE id=$1`, id)) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return &e, err +} + +func (d *DB) CurrentSideRequest(ctx context.Context, key string) (*sidequestion.Exchange, error) { + e, err := scanSide(d.QueryRowContext(ctx, `SELECT `+sideCols+` FROM side_question_requests WHERE session_key=$1 AND status='running'`, key)) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return &e, err +} + +func (d *DB) SideHistory(ctx context.Context, key string, before int64, limit int) ([]sidequestion.Exchange, error) { + rows, err := d.QueryContext(ctx, `SELECT `+sideCols+` FROM side_question_requests WHERE session_key=$1 AND ($2::bigint=0 OR ordinal<$2) ORDER BY ordinal DESC LIMIT $3`, key, before, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := []sidequestion.Exchange{} + for rows.Next() { + e, err := scanSide(rows) + if err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + +func (d *DB) SideReplay(ctx context.Context, key string) ([]sidequestion.Exchange, error) { + rows, err := d.QueryContext(ctx, `SELECT `+sideCols+` FROM side_question_requests WHERE session_key=$1 AND status='completed' ORDER BY ordinal DESC LIMIT 20`, key) + if err != nil { + return nil, err + } + defer rows.Close() + out := []sidequestion.Exchange{} + for rows.Next() { + e, err := scanSide(rows) + if err != nil { + return nil, err + } + out = append(out, e) + } + for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { + out[i], out[j] = out[j], out[i] + } + return out, rows.Err() +} + +func (d *DB) StartSideRequest(ctx context.Context, s sidequestion.Snapshot, clientID, question string) (*sidequestion.Exchange, bool, error) { + tx, err := d.BeginTx(ctx, nil) + if err != nil { + return nil, false, err + } + defer tx.Rollback() + if err = lockSideParent(ctx, tx, s.Parent); err != nil { + return nil, false, err + } + var generation int64 + if err = tx.QueryRowContext(ctx, `SELECT generation FROM side_question_sessions WHERE session_key=$1 FOR UPDATE`, s.Parent.Key()).Scan(&generation); err != nil { + return nil, false, err + } + e, err := scanSide(tx.QueryRowContext(ctx, `SELECT `+sideCols+` FROM side_question_requests WHERE session_key=$1 AND generation=$2 AND client_id=$3`, s.Parent.Key(), generation, clientID)) + if err == nil { + if e.Question != question { + return nil, false, fmt.Errorf("同一请求 ID 不能用于不同问题") + } + return &e, false, tx.Commit() + } + if !errors.Is(err, sql.ErrNoRows) { + return nil, false, err + } + var busy bool + if err = tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM side_question_requests WHERE session_key=$1 AND status='running')`, s.Parent.Key()).Scan(&busy); err != nil { + return nil, false, err + } + if busy { + return nil, false, ErrSideBusy + } + model, err := json.Marshal(s.Model) + if err != nil { + return nil, false, err + } + e, err = scanSide(tx.QueryRowContext(ctx, `INSERT INTO side_question_requests(id,session_key,generation,client_id,question,status,model,snapshot_at) VALUES($1,$2,$3,$4,$5,'running',$6,$7) RETURNING `+sideCols, uuid.NewString(), s.Parent.Key(), generation, clientID, question, string(model), s.CapturedAt)) + if err != nil { + return nil, false, err + } + return &e, true, tx.Commit() +} + +// Conditional updates cannot resurrect deleted history or overwrite a terminal +// cancellation with a late provider callback. +func (d *DB) UpdateSideRequest(ctx context.Context, e sidequestion.Exchange) (bool, error) { + usage, err := json.Marshal(e.Usage) + if err != nil { + return false, err + } + r, err := d.ExecContext(ctx, `UPDATE side_question_requests r SET answer=$2,status=$3,error=$4,sequence=$5,usage=$6 +WHERE r.id=$1 AND r.status='running' AND r.sequence<$5 AND EXISTS(SELECT 1 FROM side_question_sessions s WHERE s.session_key=r.session_key AND s.generation=r.generation)`, e.ID, e.Answer, e.Status, e.Error, e.Sequence, string(usage)) + if err != nil { + return false, err + } + n, err := r.RowsAffected() + return n == 1, err +} + +func (d *DB) ClearSideHistory(ctx context.Context, key string) error { + tx, err := d.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.ExecContext(ctx, `UPDATE side_question_sessions SET generation=generation+1 WHERE session_key=$1`, key); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `DELETE FROM side_question_requests WHERE session_key=$1`, key); err != nil { + return err + } + return tx.Commit() +} + +func (d *DB) InterruptSideRequests(ctx context.Context) error { + _, err := d.ExecContext(ctx, `UPDATE side_question_requests SET status='interrupted',error='服务重启,回答已中断',sequence=sequence+1 WHERE status='running'`) + return err +} diff --git a/db/side_questions_test.go b/db/side_questions_test.go new file mode 100644 index 0000000..6a97bef --- /dev/null +++ b/db/side_questions_test.go @@ -0,0 +1,270 @@ +package db + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/Autumn-27/artex/sidequestion" + "github.com/Autumn-27/norma/llm" +) + +func sideFixture(t *testing.T) (*DB, sidequestion.Snapshot) { + t.Helper() + d, err := Open(testDSN(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close() }) + c, err := d.CreateConversation("mainagent", "side persistence", nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.DeleteConversation(c.ID) }) + s := sidequestion.Snapshot{Parent: sidequestion.Parent{ConversationID: c.ID}, RunID: 1, Version: 1, CapturedAt: time.Now().UTC(), Model: sidequestion.Model{Model: "fixture"}, Request: llm.CompletionRequest{Messages: []llm.Message{llm.UserText("main-only")}}} + if err = d.SaveSideSnapshot(t.Context(), s); err != nil { + t.Fatal(err) + } + return d, s +} + +func TestSideHistoryIdempotencyPagingAndRecovery(t *testing.T) { + d, s := sideFixture(t) + ctx := t.Context() + first, created, err := d.StartSideRequest(ctx, s, "request-0", "question-0") + if err != nil || !created { + t.Fatalf("start %v %v", created, err) + } + again, created, err := d.StartSideRequest(ctx, s, "request-0", "question-0") + if err != nil || created || again.ID != first.ID { + t.Fatalf("dedup %v %v", created, err) + } + if _, _, err = d.StartSideRequest(ctx, s, "request-0", "different"); err == nil { + t.Fatal("conflicting duplicate accepted") + } + if _, _, err = d.StartSideRequest(ctx, s, "request-1", "question-1"); !errors.Is(err, ErrSideBusy) { + t.Fatalf("busy %v", err) + } + for i := 0; i < 24; i++ { + e := first + if i > 0 { + e, _, err = d.StartSideRequest(ctx, s, fmt.Sprintf("request-%d", i), fmt.Sprintf("question-%d", i)) + if err != nil { + t.Fatal(err) + } + } + e.Answer = fmt.Sprintf("answer-%d", i) + e.Sequence = 1 + e.Status = "completed" + if ok, err := d.UpdateSideRequest(ctx, *e); err != nil || !ok { + t.Fatalf("finish %v %v", ok, err) + } + } + page, err := d.SideHistory(ctx, s.Parent.Key(), 0, 20) + if err != nil || len(page) != 20 { + t.Fatalf("page %d %v", len(page), err) + } + tail, err := d.SideHistory(ctx, s.Parent.Key(), page[19].Ordinal, 20) + if err != nil || len(tail) != 4 || tail[0].Ordinal >= page[19].Ordinal { + t.Fatalf("tail %+v %v", tail, err) + } + replay, err := d.SideReplay(ctx, s.Parent.Key()) + if err != nil || len(replay) != 20 || replay[0].Question != "question-4" || replay[19].Question != "question-23" { + t.Fatalf("replay %+v %v", replay, err) + } + e, _, err := d.StartSideRequest(ctx, s, "unfinished", "partial question") + if err != nil { + t.Fatal(err) + } + e.Answer = "saved partial" + e.Sequence = 1 + e.Usage.InputTokens = 17 + if _, err = d.UpdateSideRequest(ctx, *e); err != nil { + t.Fatal(err) + } + if err = d.InterruptSideRequests(ctx); err != nil { + t.Fatal(err) + } + got, err := d.SideRequest(ctx, e.ID) + if err != nil || got.Status != "interrupted" || got.Answer != "saved partial" || got.Usage.InputTokens != 17 { + t.Fatalf("recovery %+v %v", got, err) + } + saved, err := d.SideSnapshot(ctx, s.Parent.Key()) + if err != nil || saved.Request.Messages[0].Text() != "main-only" { + t.Fatalf("snapshot %+v %v", saved, err) + } + if _, _, err = d.StartSideRequest(ctx, *saved, "after-restart", "continue"); err != nil { + t.Fatal(err) + } +} + +func TestSideClearLateWritersAndDeletedParent(t *testing.T) { + d, s := sideFixture(t) + ctx := t.Context() + for i := 0; i < 10; i++ { + e, _, err := d.StartSideRequest(ctx, s, "same-client", "question") + if err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + if err := d.ClearSideHistory(ctx, s.Parent.Key()); err != nil { + t.Error(err) + } + }() + go func() { + defer wg.Done() + copy := *e + copy.Sequence = 1 + copy.Answer = "late" + copy.Status = "completed" + if _, err := d.UpdateSideRequest(ctx, copy); err != nil { + t.Error(err) + } + }() + wg.Wait() + if row, err := d.SideRequest(ctx, e.ID); err != nil || row != nil { + t.Fatalf("cleared answer resurrected: %+v %v", row, err) + } + e.Sequence = 2 + e.Status = "completed" + if ok, err := d.UpdateSideRequest(ctx, *e); err != nil || ok { + t.Fatalf("late update %v %v", ok, err) + } + } + s.Version = 3 + if err := d.SaveSideSnapshot(ctx, s); err != nil { + t.Fatal(err) + } + s.Version = 2 + if err := d.SaveSideSnapshot(ctx, s); err != nil { + t.Fatal(err) + } + got, err := d.SideSnapshot(ctx, s.Parent.Key()) + if err != nil || got.Version != 3 { + t.Fatalf("older version won: %+v %v", got, err) + } + if err = d.DeleteConversation(s.Parent.ConversationID); err != nil { + t.Fatal(err) + } + if err = d.SaveSideSnapshot(ctx, s); !errors.Is(err, ErrSideParentGone) { + t.Fatalf("deleted parent restored: %v", err) + } + if got, err = d.SideSnapshot(ctx, s.Parent.Key()); err != nil || got != nil { + t.Fatalf("delete cascade: %+v %v", got, err) + } +} + +func TestSideTaskArchiveVersions(t *testing.T) { + for _, version := range []int{1, 2, 3} { + t.Run(fmt.Sprint(version), func(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Fatal(err) + } + defer d.Close() + if err := d.EnsureLLMRecordsTable(); err != nil { + t.Fatal(err) + } + if err := d.EnsureLLMUsageTable(); err != nil { + t.Fatal(err) + } + task, err := d.CreateTask("btw archive", "restore context", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + defer func() { + _, _ = d.Exec(`DELETE FROM task_archives WHERE task_id=$1`, task.ID) + _ = d.DeleteTask(task.ID) + }() + iid, err := d.Exploration(task.ExplorationID).AddNode(KindIntent, map[string]any{"summary": "worker"}, 1, "paused", "planner", nil) + if err != nil { + t.Fatal(err) + } + var snapshots []sidequestion.Snapshot + for _, intent := range []int64{0, iid} { + s := sidequestion.Snapshot{Parent: sidequestion.Parent{TaskID: task.ID, ExplorationID: task.ExplorationID, IntentID: intent}, RunID: 1, Version: 2, CapturedAt: time.Now().UTC(), Request: llm.CompletionRequest{Messages: []llm.Message{llm.UserText("archived main context")}}} + if err = d.SaveSideSnapshot(t.Context(), s); err != nil { + t.Fatal(err) + } + e, _, err := d.StartSideRequest(t.Context(), s, "client", "archive question") + if err != nil { + t.Fatal(err) + } + e.Answer = "archive answer" + e.Sequence = 1 + e.Status = "completed" + if _, err = d.UpdateSideRequest(t.Context(), *e); err != nil { + t.Fatal(err) + } + snapshots = append(snapshots, s) + } + if err := d.SetPaused(task.ID, true); err != nil { + t.Fatal(err) + } + job, err := d.QueueTaskArchive(task.ID) + if err != nil { + t.Fatal(err) + } + if _, err = d.ClaimTaskArchiveJob(t.Context()); err != nil { + t.Fatal(err) + } + archive, err := d.SnapshotTaskArchive(task.ID) + if err != nil { + t.Fatal(err) + } + if archive.FormatVersion != 3 || archive.DataCounts["side_question_sessions"] != 2 || archive.DataCounts["side_question_requests"] != 2 { + t.Fatalf("missing side archive: %+v", archive.DataCounts) + } + if err = d.CompleteTaskArchive(job.ID, archive, "/tmp/side-fixture.tar.zst", "fixture", 1, 1); err != nil { + t.Fatal(err) + } + if err = d.SaveSideSnapshot(t.Context(), snapshots[0]); !errors.Is(err, ErrSideParentGone) { + t.Fatalf("late archived snapshot: %v", err) + } + if got, err := d.SideSnapshot(t.Context(), snapshots[0].Parent.Key()); err != nil || got != nil { + t.Fatalf("archive retained hot snapshot %+v %v", got, err) + } + if _, err = d.QueueTaskArchiveRestore(job.ID); err != nil { + t.Fatal(err) + } + if _, err = d.ClaimTaskArchiveJob(t.Context()); err != nil { + t.Fatal(err) + } + archive.FormatVersion = version + if version < 3 { + delete(archive.Tables, "side_question_sessions") + delete(archive.Tables, "side_question_requests") + delete(archive.DataCounts, "side_question_sessions") + delete(archive.DataCounts, "side_question_requests") + } + if _, err = d.RestoreTaskArchive(job.ID, archive, 0); err != nil { + t.Fatal(err) + } + for _, s := range snapshots { + got, err := d.SideSnapshot(context.Background(), s.Parent.Key()) + if err != nil { + t.Fatal(err) + } + if version < 3 { + if got != nil { + t.Fatal("legacy archive fabricated snapshot") + } + } else { + if got == nil || got.Request.Messages[0].Text() != "archived main context" { + t.Fatalf("restored snapshot: %+v", got) + } + history, err := d.SideHistory(t.Context(), s.Parent.Key(), 0, 20) + if err != nil || len(history) != 1 || history[0].Answer != "archive answer" { + t.Fatalf("restored history %+v %v", history, err) + } + } + } + }) + } +} diff --git a/db/task_archives.go b/db/task_archives.go index e38a4cd..e942436 100644 --- a/db/task_archives.go +++ b/db/task_archives.go @@ -16,7 +16,7 @@ import ( ) const ( - TaskArchiveFormatVersion = 2 + TaskArchiveFormatVersion = 3 TaskArchiveLegacyFormatVersion = 1 TaskArchiveLLMRecordsPath = "database/llm_records.ndjson" ) @@ -592,6 +592,8 @@ func (d *DB) snapshotTaskArchive(taskID int64, llmRecords io.Writer) (*TaskArchi {"skill_usage", `SELECT * FROM skill_usage WHERE task_id=$1 OR exploration_id=$2 ORDER BY id`, []any{taskID, expID}}, {"tool_usage", `SELECT * FROM tool_usage WHERE task_id=$1 OR exploration_id=$2 ORDER BY id`, []any{taskID, expID}}, {"intercept_pending", `SELECT * FROM intercept_pending WHERE COALESCE(task_id,'')=$1 ORDER BY id`, []any{strconv.FormatInt(taskID, 10)}}, + {"side_question_sessions", `SELECT * FROM side_question_sessions WHERE task_id=$1 ORDER BY session_key`, []any{taskID}}, + {"side_question_requests", `SELECT r.* FROM side_question_requests r JOIN side_question_sessions s ON s.session_key=r.session_key WHERE s.task_id=$1 ORDER BY r.ordinal`, []any{taskID}}, {"assets", `SELECT asset.* FROM assets asset WHERE asset.id IN (` + archiveAssetIDsQuery() + `) ORDER BY asset.id`, []any{taskID, expID}}, } counts := make(map[string]int64, len(queries)) diff --git a/db/task_archives_restore.go b/db/task_archives_restore.go index 71e916a..1078a16 100644 --- a/db/task_archives_restore.go +++ b/db/task_archives_restore.go @@ -78,6 +78,9 @@ WHERE relation.source_task_id=$1 LIMIT 1`, taskID).Scan(&dependent) return err } } + if _, err := tx.Exec(`DELETE FROM side_question_sessions WHERE task_id=$1`, taskID); err != nil { + return err + } if _, err := tx.Exec(`DELETE FROM findings WHERE task_id=$1`, taskID); err != nil { return err } @@ -245,6 +248,11 @@ WHERE archive.id=$1 FOR UPDATE OF archive,task`, archiveID).Scan(&taskID, &expID } else { warnings = append(warnings, warning...) } + for _, table := range []string{"side_question_sessions", "side_question_requests"} { + if err := insertArchiveRows(tx, table, remappedTables[table]); err != nil { + return nil, fmt.Errorf("restore %s: %w", table, err) + } + } if warning, err := restoreTaskScopes(tx, remappedTables["task_scope"]); err != nil { return nil, err } else { @@ -351,6 +359,7 @@ func insertArchiveRows(tx *sql.Tx, table string, raw json.RawMessage) error { "exploration_nodes": true, "exploration_edges": true, "exploration_anchors": true, "task_constraints": true, "activity": true, "task_asset_links": true, "findings": true, "llm_records": true, "llm_usage": true, "skill_usage": true, "tool_usage": true, + "side_question_sessions": true, "side_question_requests": true, } if !allowed[table] { return fmt.Errorf("archive restore table %q is not allowed", table) diff --git a/llmrec/llmrec.go b/llmrec/llmrec.go index b2b71f1..51164c6 100644 --- a/llmrec/llmrec.go +++ b/llmrec/llmrec.go @@ -130,7 +130,12 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S streamErr error ) + finished := false finish := func(err error) { + if finished { + return + } + finished = true status := "ok" if err != nil { status = "error" @@ -142,6 +147,7 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S r.record(req, session, taskID, worker, reqBody, capt, start, textBuf.String(), thinkingBuf.String(), usage, stopReason, err) } } + defer func() { finish(ctx.Err()) }() for ev, err := range r.inner.Stream(ctx, req) { if err != nil { diff --git a/llmrec/llmrec_test.go b/llmrec/llmrec_test.go index 19c7d4b..eaec56d 100644 --- a/llmrec/llmrec_test.go +++ b/llmrec/llmrec_test.go @@ -3,9 +3,13 @@ package llmrec import ( "context" "iter" + "os" "testing" + "github.com/Autumn-27/artex/db" "github.com/Autumn-27/norma/llm" + "github.com/Autumn-27/norma/transcript" + "github.com/google/uuid" ) type completeProvider struct{} @@ -63,3 +67,59 @@ func TestCompleteForwardsAtomicResponse(t *testing.T) { t.Fatalf("Complete() usage=%+v", usage) } } + +type meteredStreamProvider struct{ completeProvider } + +func (meteredStreamProvider) Stream(context.Context, llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] { + return func(yield func(llm.StreamEvent, error) bool) { + if !yield(llm.StreamEvent{Type: llm.SEMessageStart, Usage: llm.Usage{InputTokens: 23}}, nil) { + return + } + if !yield(llm.StreamEvent{Type: llm.SEMessageDelta, Usage: llm.Usage{OutputTokens: 5}}, nil) { + return + } + yield(llm.StreamEvent{Type: llm.SEMessageStop}, nil) + } +} + +func TestSideUsageRecordedOnceOnConsumerCancellation(t *testing.T) { + dsn := os.Getenv("ARTEX_PG_DSN") + if dsn == "" { + t.Skip("requires isolated ARTEX_PG_DSN") + } + pg, err := db.Open(dsn) + if err != nil { + t.Fatal(err) + } + defer pg.Close() + for _, early := range []bool{false, true} { + profile := "btw-metering-" + uuid.NewString() + ctx, cancel := context.WithCancel(transcript.WithSessionID(t.Context(), "exp0-btw-test")) + recorder := Wrap(meteredStreamProvider{}, pg, "fixture", profile, "", "", func() bool { return false }) + for event, err := range recorder.Stream(ctx, llm.CompletionRequest{}) { + if err != nil { + t.Fatal(err) + } + if early && event.Type == llm.SEMessageDelta { + cancel() + break // Consumer exits before the provider can return a cancellation event. + } + } + cancel() + var count, input, output int + var worker, status string + err = pg.QueryRow(`SELECT count(*),max(input_tokens),max(output_tokens),max(worker),max(status) +FROM llm_usage WHERE profile_name=$1`, profile).Scan(&count, &input, &output, &worker, &status) + _, _ = pg.Exec(`DELETE FROM llm_usage WHERE profile_name=$1`, profile) + if err != nil { + t.Fatal(err) + } + wantStatus := "ok" + if early { + wantStatus = "error" + } + if count != 1 || input != 23 || output != 5 || worker != "btw" || status != wantStatus { + t.Fatalf("early=%v: count=%d usage=%d/%d worker=%q status=%q", early, count, input, output, worker, status) + } + } +} diff --git a/server/conversations.go b/server/conversations.go index 77aacf5..7e609b2 100644 --- a/server/conversations.go +++ b/server/conversations.go @@ -16,6 +16,7 @@ import ( "github.com/Autumn-27/artex/agent" "github.com/Autumn-27/artex/db" "github.com/Autumn-27/artex/intercept" + "github.com/Autumn-27/artex/sidequestion" ) const ( @@ -204,6 +205,7 @@ func (s *Server) pgDeleteConversation(w http.ResponseWriter, r *http.Request) { return } s.cancelConversation(c.ID) + s.cancelSideWhere(func(p sidequestion.Parent) bool { return p.ConversationID == c.ID }) if err := pg.DeleteConversation(c.ID); err != nil { writeErr(w, 500, err.Error()) return @@ -262,6 +264,7 @@ func (s *Server) pgDeleteConversationsBatch(w http.ResponseWriter, r *http.Reque } for _, id := range ids { s.cancelConversation(id) + s.cancelSideWhere(func(p sidequestion.Parent) bool { return p.ConversationID == id }) } deleted, err := pg.DeleteConversations(ids) if err != nil { diff --git a/server/server.go b/server/server.go index 3f31803..56a20b4 100644 --- a/server/server.go +++ b/server/server.go @@ -55,10 +55,11 @@ type Server struct { chatAgent *agent.ChatAgent // conversational runner for the chat page; nil w/o LLM // llmProv is the fully-decorated global provider (recorder + failover chain) // installed by applyLLM. Task routers use it after an explicit chain is cleared. - llmProv llm.Provider - llmCfg agent.Config // current LLM config (key not exposed) - llmOn bool - llmProf string // active LLM profile name (for llmrec tagging) + llmProv llm.Provider + llmDirect llm.Provider // concrete global provider, before any failover pool + llmCfg agent.Config // current LLM config (key not exposed) + llmOn bool + llmProf string // active LLM profile name (for llmrec tagging) // chatBusy guards the per-task main-agent run: the chat handler launches the // agent on the server's background ctx (not the request ctx) and returns @@ -121,6 +122,7 @@ type Server struct { // channel coalesces enqueue bursts; the database remains the source of truth. archiveWake chan struct{} archiveWG sync.WaitGroup + side *sideQuestionState } // profBundle is a planner/worker pair built from one LLM profile. @@ -157,6 +159,7 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi profAgents: map[int64]*profBundle{}, profChatAgents: map[int64]*agent.ChatAgent{}, provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg), taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1)} + s.initSideQuestions() // 熔断阈值/冷却是失败路径上的热参数,启动时把全局重试策略推给 Registry 一次; // 之后每次保存策略再推一次(saveLLMRetryPolicy)。 s.applyRetryPolicy() @@ -243,6 +246,17 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi } else { log.Printf("[engine] no LLM provider configured — engine idle until set via /api/llm or env") } + s.restoreTaskRuntimes() + go s.reconcileConcurrency() + s.startTaskArchiveWorker() + s.wireInterceptReviewer() // LLM 兜底审批:未命中拦截规则的命令交给模型判定 + return s +} + +// Restored deadline and worker loops must inherit the same context as new tasks, +// including the side-question checkpoint publisher installed during startup. +func (s *Server) restoreTaskRuntimes() { + m := s.m // reload tasks persisted on disk so the task list survives a restart, and // restore persisted paused state (so a task paused before restart stays paused). for _, t := range m.LoadExisting() { @@ -258,7 +272,7 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi // 任务级超时:为每个未终态、带 timeout 的任务起 deadline 协调器,独立于 planner/worker // loop——非活跃任务重启后也能在到点后被收尾(deadline 已过则立即走收尾时序)。 if !isTerminalStatus(lifecycle.Status) { - s.engine.startDeadlineCoordinator(ctx, t) + s.engine.startDeadlineCoordinator(s.ctx, t) } } // Restore every task that had already been admitted before shutdown. Starting @@ -268,13 +282,9 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi for _, t := range m.List() { lifecycle := t.lifecycleSnapshot() if !lifecycle.Queued && !isTerminalStatus(lifecycle.Status) { - s.engine.Run(ctx, t) + s.engine.Run(s.ctx, t) } } - go s.reconcileConcurrency() - s.startTaskArchiveWorker() - s.wireInterceptReviewer() // LLM 兜底审批:未命中拦截规则的命令交给模型判定 - return s } // agentMaxTurns returns the configured max_turns for an agent key (0 = unlimited, @@ -360,7 +370,7 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error { ContextWindowK: cfg.ContextWindowK, ThinkingType: cfg.ThinkingType, ReasoningEffort: cfg.ReasoningEffort, IsDefault: true, Priority: priority, PoolExclude: poolExclude, Streaming: streaming, MaxTokens: maxTokens, MaxTokensField: maxTokensField, SessionHeaderKey: sessionHeaderKey, - Retry: retry, + Retry: retry, }) if err != nil { return err @@ -464,7 +474,11 @@ func (s *Server) applyLLM(cfg agent.Config) error { profName := s.llmProf s.cfgMu.Unlock() prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, profName, cfg.ThinkingType, cfg.ReasoningEffort, s.m.LLMRecordEnabled) + prov = bindSideProvider(prov, cfg, 0, profName) } + s.cfgMu.Lock() + s.llmDirect = prov + s.cfgMu.Unlock() // LLM 轮询(默认关):把激活配置包进故障转移链,当前配置不可用时自动切下一个。 // 只影响「走全局激活配置」的这条路径——agent 绑定 / 任务 pin 的走 providerForProfile, // 默认仍然独占该配置(见 poolForBinding)。关闭或无备选时返回原 provider,行为不变。 @@ -598,6 +612,7 @@ func (s *Server) providerForProfile(id int64) (llm.Provider, agent.Config, bool) // Wrap with recorder, tagged with this profile's name. if p, _ := s.m.pg.ProfileByID(id); p != nil { prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, p.Name, cfg.ThinkingType, cfg.ReasoningEffort, s.m.LLMRecordEnabled) + prov = bindSideProvider(prov, cfg, id, p.Name) } s.provCacheMu.Lock() if generation != s.provCacheGen { @@ -716,6 +731,7 @@ func (s *Server) chatAgentRef() *agent.ChatAgent { func (s *Server) Handler() http.Handler { mux := http.NewServeMux() + s.registerSideRoutes(mux) // Auth routes — exempt from JWT check (handled in requireAuth) mux.HandleFunc("GET /api/auth/status", s.authStatus) diff --git a/server/server_mgmt.go b/server/server_mgmt.go index 6b02b64..5d3e6d1 100644 --- a/server/server_mgmt.go +++ b/server/server_mgmt.go @@ -162,6 +162,10 @@ func (s *Server) pgDeleteTask(w http.ResponseWriter, r *http.Request) { return } + if err := s.drainTaskSideQuestions(drainCtx, id); err != nil { + writeErr(w, http.StatusConflict, err.Error()) + return + } result, err := s.m.DeleteTask(id, opts) if err != nil { var committed *taskDeleteCommittedError diff --git a/server/side_questions.go b/server/side_questions.go new file mode 100644 index 0000000..0b6ce91 --- /dev/null +++ b/server/side_questions.go @@ -0,0 +1,574 @@ +package server + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/Autumn-27/artex/agent" + "github.com/Autumn-27/artex/db" + "github.com/Autumn-27/artex/llmrec" + "github.com/Autumn-27/artex/sidequestion" + "github.com/Autumn-27/norma/llm" + "github.com/Autumn-27/norma/transcript" +) + +type sideRun struct { + key string + parent sidequestion.Parent + cancel context.CancelFunc + done chan struct{} +} +type sideQuestionState struct { + mu sync.Mutex + commands sync.Mutex // short admission / clear operations only, never inference + pending map[string]sidequestion.Snapshot + latest map[string]sidequestion.Snapshot + seen map[string][2]int64 + runs map[string]sideRun + done chan struct{} +} + +func sideModel(cfg agent.Config, id int64, name string) sidequestion.Model { + b, _ := json.Marshal([]any{cfg.Format, cfg.BaseURL, cfg.Model, cfg.ThinkingType, cfg.ReasoningEffort, cfg.Stream, cfg.MaxTokensField, cfg.SessionHeaderKey}) + h := sha256.Sum256(b) + return sidequestion.Model{ProfileID: id, Name: name, Format: string(cfg.Format), Model: cfg.Model, Identity: hex.EncodeToString(h[:]), Streaming: cfg.Stream, WindowTokens: cfg.CompactionWindow()} +} + +func bindSideProvider(p llm.Provider, cfg agent.Config, id int64, name string) llm.Provider { + return sidequestion.Bind(p, sideModel(cfg, id, name)) +} + +func (s *Server) initSideQuestions() { + s.side = &sideQuestionState{pending: map[string]sidequestion.Snapshot{}, latest: map[string]sidequestion.Snapshot{}, seen: map[string][2]int64{}, runs: map[string]sideRun{}, done: make(chan struct{})} + if s.m.pg == nil { + close(s.side.done) + return + } + if err := s.m.pg.InterruptSideRequests(s.ctx); err != nil { + log.Printf("[btw] recover: %v", err) + } + s.ctx = sidequestion.WithPublisher(s.ctx, func(snap sidequestion.Snapshot) { + s.side.mu.Lock() + defer s.side.mu.Unlock() + key := snap.Parent.Key() + old, ok := s.side.seen[key] + if ok && (old[0] > snap.RunID || old[0] == snap.RunID && old[1] >= snap.Version) { + return + } + s.side.latest[key] = snap + s.side.seen[key] = [2]int64{snap.RunID, snap.Version} + s.side.pending[key] = snap + }) + go func() { + defer close(s.side.done) + tick := time.NewTicker(250 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-tick.C: + s.flushSideSnapshots() + case <-s.ctx.Done(): + s.flushSideSnapshots() + return + } + } + }() +} + +func (s *Server) flushSideSnapshots() { + if s.side == nil || s.m.pg == nil { + return + } + s.side.commands.Lock() + defer s.side.commands.Unlock() + s.side.mu.Lock() + pending := s.side.pending + s.side.pending = map[string]sidequestion.Snapshot{} + s.side.mu.Unlock() + for key, snap := range pending { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + err := s.m.pg.SaveSideSnapshot(ctx, snap) + cancel() + s.side.mu.Lock() + if err == nil || errors.Is(err, db.ErrSideParentGone) { + if cur, ok := s.side.latest[key]; ok && cur.RunID == snap.RunID && cur.Version == snap.Version { + delete(s.side.latest, key) + } + } else if newer, ok := s.side.pending[key]; !ok || newer.RunID < snap.RunID || newer.RunID == snap.RunID && newer.Version < snap.Version { + s.side.pending[key] = snap + } + s.side.mu.Unlock() + if err != nil && !errors.Is(err, db.ErrSideParentGone) { + log.Printf("[btw] checkpoint persistence: %v", err) + } + } +} + +func (s *Server) sideSnapshot(ctx context.Context, p sidequestion.Parent) (*sidequestion.Snapshot, error) { + s.side.mu.Lock() + snap, ok := s.side.latest[p.Key()] + s.side.mu.Unlock() + if ok { + return &snap, nil + } + return s.m.pg.SideSnapshot(ctx, p.Key()) +} + +func (s *Server) cancelSideWhere(match func(sidequestion.Parent) bool) []<-chan struct{} { + if s.side == nil { + return nil + } + s.side.mu.Lock() + defer s.side.mu.Unlock() + var done []<-chan struct{} + for _, run := range s.side.runs { + if match(run.parent) { + run.cancel() + done = append(done, run.done) + } + } + return done +} + +// Called after the task admission barrier closes and the main loops drain. +// Wait for final answer/usage writes before taking the archive snapshot. +func (s *Server) drainTaskSideQuestions(ctx context.Context, taskID string) error { + if s.side == nil { + return nil + } + id, _ := strconv.ParseInt(taskID, 10, 64) + s.side.commands.Lock() + done := s.cancelSideWhere(func(p sidequestion.Parent) bool { return p.TaskID == id }) + s.side.commands.Unlock() + for _, ch := range done { + select { + case <-ch: + case <-ctx.Done(): + return ctx.Err() + } + } + s.flushSideSnapshots() + // A failed flush must not silently leave the latest checkpoint out of archive. + s.side.mu.Lock() + defer s.side.mu.Unlock() + for _, snap := range s.side.pending { + if snap.Parent.TaskID == id { + return errors.New("旁路上下文尚未保存,请重试") + } + } + return nil +} + +func (s *Server) cancelWorkerSide(taskID string, expID, iid int64) { + if s.side == nil { + return + } + id, _ := strconv.ParseInt(taskID, 10, 64) + p := sidequestion.Parent{TaskID: id, ExplorationID: expID, IntentID: iid} + s.side.commands.Lock() + defer s.side.commands.Unlock() + s.cancelSideWhere(func(parent sidequestion.Parent) bool { return parent == p }) +} + +func (s *Server) sideProvider(model sidequestion.Model) (llm.Provider, error) { + var p llm.Provider + var cfg agent.Config + var ok bool + if model.ProfileID > 0 { + // Validate the persisted reference even if a previous provider is cached. + current, exists := s.loadProfileConfig(model.ProfileID) + if !exists || sideModel(current, model.ProfileID, model.Name).Identity != model.Identity { + return nil, errors.New("模型配置已删除或变化,请先运行主 Agent 更新上下文") + } + p, cfg, ok = s.providerForProfile(model.ProfileID) + } else { + s.cfgMu.Lock() + p, cfg, ok = s.llmDirect, s.llmCfg, s.llmOn + s.cfgMu.Unlock() + } + if !ok || p == nil || sideModel(cfg, model.ProfileID, model.Name).Identity != model.Identity { + return nil, errors.New("模型配置已删除或变化,请先运行主 Agent 更新上下文") + } + return p, nil +} + +func (s *Server) sideParent(w http.ResponseWriter, r *http.Request, kind string) (sidequestion.Parent, bool) { + p := sidequestion.Parent{} + if s.side == nil || s.m.pg == nil { + writeErr(w, 503, "旁路服务不可用") + return p, false + } + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id <= 0 { + writeErr(w, 400, "bad id") + return p, false + } + if kind == "conversation" { + c, err := s.m.pg.GetConversation(id) + if err != nil { + writeErr(w, 500, err.Error()) + return p, false + } + if c == nil { + writeErr(w, 404, "conversation not found") + return p, false + } + p.ConversationID = id + return p, true + } + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return p, false + } + pt, err := s.m.pg.GetTask(id) + if err != nil { + writeErr(w, 500, err.Error()) + return p, false + } + if pt == nil || s.engine.IsDeleting(t.ID) { + writeErr(w, 409, "任务已归档或正在删除") + return p, false + } + p.TaskID, p.ExplorationID = id, t.ExpID + if kind == "worker" { + iid, err := strconv.ParseInt(r.PathValue("iid"), 10, 64) + if err != nil || iid <= 0 { + writeErr(w, 400, "bad intent id") + return p, false + } + n, err := t.Store.GetNode(iid) + if err != nil { + writeErr(w, 500, err.Error()) + return p, false + } + if n == nil || n.Kind != db.KindIntent { + writeErr(w, 404, "intent not found") + return p, false + } + if n.State == "stopped" { + writeErr(w, 409, "Worker 已删除") + return p, false + } + p.IntentID = iid + } + return p, true +} + +func (s *Server) registerSideRoutes(mux *http.ServeMux) { + for _, route := range []struct{ path, kind string }{{"/api/conversations/{id}", "conversation"}, {"/api/tasks/{id}/chat", "main"}, {"/api/tasks/{id}/intents/{iid}", "worker"}} { + for _, method := range []string{"GET", "POST", "DELETE"} { + mux.HandleFunc(method+" "+route.path+"/side-questions", func(w http.ResponseWriter, r *http.Request) { + p, ok := s.sideParent(w, r, route.kind) + if ok { + s.handleSideQuestions(w, r, p) + } + }) + } + } + mux.HandleFunc("GET /api/side-questions/{requestID}/events", s.sideEvents) + mux.HandleFunc("POST /api/side-questions/{requestID}/cancel", s.cancelSideRequest) +} + +func (s *Server) handleSideQuestions(w http.ResponseWriter, r *http.Request, p sidequestion.Parent) { + key := p.Key() + if r.Method == "DELETE" { + s.side.commands.Lock() + defer s.side.commands.Unlock() + s.side.mu.Lock() + for _, run := range s.side.runs { + if run.key == key { + run.cancel() + } + } + s.side.mu.Unlock() + if err := s.m.pg.ClearSideHistory(r.Context(), key); err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]bool{"cleared": true}) + return + } + snap, err := s.sideSnapshot(r.Context(), p) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if r.Method == "GET" { + before, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64) + items, err := s.m.pg.SideHistory(r.Context(), key, before, 21) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + var next int64 + if len(items) > 20 { + items = items[:20] + next = items[len(items)-1].Ordinal + } + current, err := s.m.pg.CurrentSideRequest(r.Context(), key) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + var meta any + if snap != nil { + _, modelErr := s.sideProvider(snap.Model) + reason := "" + if modelErr != nil { + reason = modelErr.Error() + } + meta = map[string]any{"captured_at": snap.CapturedAt, "model": snap.Model, "available": modelErr == nil, "reason": reason} + } + writeJSON(w, 200, map[string]any{"items": items, "current": current, "next_cursor": next, "snapshot": meta}) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 32<<10) + var in struct { + Question string `json:"question"` + ClientID string `json:"client_request_id"` + } + if json.NewDecoder(r.Body).Decode(&in) != nil { + writeErr(w, 400, "bad json") + return + } + in.Question = strings.TrimSpace(in.Question) + if in.Question == "" || len([]rune(in.Question)) > 4000 || !validWorkerMessageRequestID(in.ClientID) { + writeErr(w, 400, "问题须为 1–4000 字符,并提供有效请求 ID") + return + } + s.side.commands.Lock() + defer s.side.commands.Unlock() + if p.TaskID > 0 && s.engine.IsDeleting(strconv.FormatInt(p.TaskID, 10)) { + writeErr(w, 409, "任务正在归档或删除") + return + } + if existing, err := s.m.pg.ExistingSideRequest(r.Context(), key, in.ClientID); err != nil { + writeErr(w, 500, err.Error()) + return + } else if existing != nil { + if existing.Question != in.Question { + writeErr(w, 409, "同一请求 ID 不能用于不同问题") + return + } + writeJSON(w, 200, existing) + return + } + if snap == nil { + writeErr(w, 409, "尚无上下文快照,请先运行主 Agent") + return + } + provider, err := s.sideProvider(snap.Model) + if err != nil { + writeErr(w, 409, err.Error()) + return + } + s.side.mu.Lock() + full := len(s.side.runs) >= 4 + busy := false + for _, run := range s.side.runs { + if run.key == key { + busy = true + } + } + s.side.mu.Unlock() + if busy { + writeErr(w, 409, db.ErrSideBusy.Error()) + return + } + if full { + writeErr(w, 429, "旁路请求已达并发上限,请稍后重试") + return + } + if err = s.m.pg.SaveSideSnapshot(r.Context(), *snap); err != nil { + writeErr(w, 500, err.Error()) + return + } + history, err := s.m.pg.SideReplay(r.Context(), key) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + req, err := sidequestion.BuildRequest(*snap, history, in.Question) + if err != nil { + writeErr(w, 409, err.Error()) + return + } + e, created, err := s.m.pg.StartSideRequest(r.Context(), *snap, in.ClientID, in.Question) + if err != nil { + writeErr(w, 409, err.Error()) + return + } + if created { + ctx, cancel := context.WithTimeout(s.ctx, 120*time.Second) + s.side.mu.Lock() + s.side.runs[e.ID] = sideRun{key: key, parent: p, cancel: cancel, done: make(chan struct{})} + s.side.mu.Unlock() + go s.runSide(ctx, cancel, *e, p, provider, req) + } + writeJSON(w, 202, e) +} + +func (s *Server) runSide(ctx context.Context, cancel context.CancelFunc, e sidequestion.Exchange, parent sidequestion.Parent, provider llm.Provider, req llm.CompletionRequest) { + defer cancel() + defer func() { + s.side.mu.Lock() + if run, ok := s.side.runs[e.ID]; ok { + close(run.done) + } + delete(s.side.runs, e.ID) + s.side.mu.Unlock() + }() + ctx = transcript.WithSessionID(ctx, fmt.Sprintf("exp%d-btw-%s", parent.ExplorationID, e.ID)) + if parent.TaskID > 0 { + ctx = llmrec.WithTaskID(ctx, strconv.FormatInt(parent.TaskID, 10)) + } + // Deletions can race between request insertion and registration. Observe the + // persisted lifecycle even while a provider is blocked without emitting text. + go func() { + tick := time.NewTicker(500 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + row, err := s.m.pg.SideRequest(ctx, e.ID) + if err == nil && (row == nil || !row.Running()) { + cancel() + return + } + } + } + }() + lastSave := time.Now() + persist := func() (bool, error) { + e.Sequence++ + writeCtx, stop := context.WithTimeout(context.Background(), 3*time.Second) + defer stop() + ok, err := s.m.pg.UpdateSideRequest(writeCtx, e) + if err != nil { + log.Printf("[btw] answer persistence: %v", err) + return true, err + } + return ok, nil + } + answer, runErr := (sidequestion.SideQuestionService{Provider: provider}).Answer(ctx, req, e.Model.Streaming, func(a sidequestion.Answer) { + e.Answer, e.Usage = a.Text, a.Usage + if time.Since(lastSave) >= 250*time.Millisecond { + lastSave = time.Now() + if ok, _ := persist(); !ok { + cancel() + } + } + }) + e.Answer, e.Usage = answer.Text, answer.Usage + e.Status = "completed" + if ctx.Err() != nil { + e.Status = "cancelled" + e.Error = "回答已停止" + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + e.Status = "failed" + e.Error = "旁路回答超过 120 秒,已停止" + } + } else if runErr != nil { + e.Status = "failed" + e.Error = runErr.Error() + } + for attempt := 0; attempt < 3; attempt++ { + if _, err := persist(); err == nil { + break + } + if attempt < 2 { + time.Sleep(100 * time.Millisecond) + } + } +} + +func (s *Server) cancelSideRequest(w http.ResponseWriter, r *http.Request) { + e, err := s.m.pg.SideRequest(r.Context(), r.PathValue("requestID")) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if e == nil { + writeErr(w, 404, "side question not found") + return + } + s.side.mu.Lock() + if run, ok := s.side.runs[e.ID]; ok { + run.cancel() + } + s.side.mu.Unlock() + writeJSON(w, 200, map[string]bool{"cancelled": true}) +} + +func (s *Server) sideEvents(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + writeErr(w, 500, "streaming unavailable") + return + } + id := r.PathValue("requestID") + first, err := s.m.pg.SideRequest(r.Context(), id) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if first == nil { + writeErr(w, 404, "side question not found") + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + seq := int64(-1) + tick := time.NewTicker(250 * time.Millisecond) + defer tick.Stop() + heartbeat := time.NewTicker(15 * time.Second) + defer heartbeat.Stop() + for { + e := first + first = nil + if e == nil { + e, err = s.m.pg.SideRequest(r.Context(), id) + } + if err != nil { + return + } + if e == nil { + fmt.Fprint(w, "event: cleared\ndata: {}\n\n") + flusher.Flush() + return + } + if e.Sequence > seq { + b, _ := json.Marshal(e) + fmt.Fprintf(w, "id: %d\nevent: snapshot\ndata: %s\n\n", e.Sequence, b) + flusher.Flush() + seq = e.Sequence + } + if !e.Running() { + return + } + select { + case <-r.Context().Done(): + return + case <-s.ctx.Done(): + return + case <-tick.C: + case <-heartbeat.C: + fmt.Fprint(w, ": keepalive\n\n") + flusher.Flush() + } + } +} diff --git a/server/side_questions_test.go b/server/side_questions_test.go new file mode 100644 index 0000000..0a0043b --- /dev/null +++ b/server/side_questions_test.go @@ -0,0 +1,453 @@ +package server + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "iter" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/Autumn-27/artex/agent" + "github.com/Autumn-27/artex/db" + "github.com/Autumn-27/artex/sidequestion" + "github.com/Autumn-27/norma/harness" + "github.com/Autumn-27/norma/llm" +) + +type sideHTTPProvider struct { + started chan llm.CompletionRequest + release chan struct{} +} + +func (p *sideHTTPProvider) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] { + return func(y func(llm.StreamEvent, error) bool) { + p.started <- req + if !y(llm.StreamEvent{Type: llm.SEMessageStart, Usage: llm.Usage{InputTokens: 19}}, nil) { + return + } + if !y(llm.StreamEvent{Type: llm.SETextDelta, Text: "partial answer"}, nil) { + return + } + select { + case <-p.release: + y(llm.StreamEvent{Type: llm.SEMessageStop}, nil) + case <-ctx.Done(): + y(llm.StreamEvent{}, ctx.Err()) + } + } +} +func (p *sideHTTPProvider) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { + for _, err := range p.Stream(ctx, req) { + if err != nil { + return llm.Message{}, "", llm.Usage{}, err + } + } + return llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{llm.TextBlock("atomic answer")}}, "end_turn", llm.Usage{InputTokens: 19}, nil +} + +type sideHTTPFixture struct { + s *Server + m *Manager + provider *sideHTTPProvider + handler http.Handler + token string +} + +func newSideHTTPFixture(t *testing.T) *sideHTTPFixture { + t.Helper() + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + s := &Server{m: m, engine: NewEngine(m), ctx: ctx, jwtKey: []byte("btw-test-signing-key-only"), chatBusy: map[string]bool{}} + s.initSideQuestions() + p := &sideHTTPProvider{started: make(chan llm.CompletionRequest, 16), release: make(chan struct{})} + cfg := agent.Config{Format: llm.FormatOpenAI, BaseURL: "http://fixture.invalid", Model: "fixture", Stream: true} + s.cfgMu.Lock() + s.llmCfg = cfg + s.llmOn = true + s.llmDirect = bindSideProvider(p, cfg, 0, "fixture") + s.cfgMu.Unlock() + token, err := signJWT(s.jwtKey) + if err != nil { + t.Fatal(err) + } + f := &sideHTTPFixture{s: s, m: m, provider: p, handler: s.Handler(), token: token} + t.Cleanup(func() { + cancel() + for _, done := range s.cancelSideWhere(func(sidequestion.Parent) bool { return true }) { + <-done + } + <-s.side.done + s.flushSideSnapshots() + m.Close() + }) + return f +} +func (f *sideHTTPFixture) call(t *testing.T, method, path string, body any, want int) *httptest.ResponseRecorder { + t.Helper() + raw, _ := json.Marshal(body) + req := httptest.NewRequest(method, path, bytes.NewReader(raw)) + req.Header.Set("Authorization", "Bearer "+f.token) + w := httptest.NewRecorder() + f.handler.ServeHTTP(w, req) + if w.Code != want { + t.Fatalf("%s %s: status=%d want=%d body=%s", method, path, w.Code, want, w.Body.String()) + } + return w +} +func (f *sideHTTPFixture) conversation(t *testing.T) (sidequestion.Parent, string) { + t.Helper() + c, err := f.m.pg.CreateConversation("mainagent", "side HTTP", nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = f.m.pg.DeleteConversation(c.ID) }) + return sidequestion.Parent{ConversationID: c.ID}, fmt.Sprintf("/api/conversations/%d/side-questions", c.ID) +} +func (f *sideHTTPFixture) checkpoint(t *testing.T, p sidequestion.Parent) sidequestion.Snapshot { + t.Helper() + snap := sidequestion.Snapshot{Parent: p, RunID: time.Now().UnixNano(), Version: 1, CapturedAt: time.Now().UTC(), Model: sideModel(f.s.llmCfg, 0, "fixture"), Request: llm.CompletionRequest{MaxTokens: 128, Messages: []llm.Message{llm.UserText("main context marker")}}} + if err := f.m.pg.SaveSideSnapshot(t.Context(), snap); err != nil { + t.Fatal(err) + } + return snap +} +func decodeSide(t *testing.T, w *httptest.ResponseRecorder) sidequestion.Exchange { + t.Helper() + var e sidequestion.Exchange + if err := json.Unmarshal(w.Body.Bytes(), &e); err != nil { + t.Fatal(err) + } + return e +} +func waitSide(t *testing.T, d *db.DB, id, status string) *sidequestion.Exchange { + t.Helper() + until := time.Now().Add(3 * time.Second) + for time.Now().Before(until) { + e, err := d.SideRequest(t.Context(), id) + if err != nil { + t.Fatal(err) + } + if e != nil && e.Status == status { + return e + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("side %s did not reach %s", id, status) + return nil +} + +func TestSideHTTPBusyIsolationClearAndReconnect(t *testing.T) { + f := newSideHTTPFixture(t) + p, path := f.conversation(t) + f.call(t, "POST", path, map[string]string{"question": "old session", "client_request_id": "old"}, 409) + f.checkpoint(t, p) + // Main busy status must not reject the side request. + f.s.chatMu.Lock() + f.s.chatBusy[fmt.Sprintf("conv-%d", p.ConversationID)] = true + f.s.chatMu.Unlock() + e := decodeSide(t, f.call(t, "POST", path, map[string]string{"question": "question", "client_request_id": "one"}, 202)) + select { + case req := <-f.provider.started: + if !strings.Contains(req.Messages[0].Text(), "main context marker") { + t.Fatal("missing checkpoint") + } + case <-time.After(time.Second): + t.Fatal("side blocked by main busy") + } + again := decodeSide(t, f.call(t, "POST", path, map[string]string{"question": "question", "client_request_id": "one"}, 200)) + if again.ID != e.ID { + t.Fatal("duplicate request re-executed") + } + f.call(t, "POST", path, map[string]string{"question": "another", "client_request_id": "two"}, 409) + // SSE reconnect always starts with a cumulative snapshot even with Last-Event-ID. + httpServer := httptest.NewServer(f.handler) + defer httpServer.Close() + for i := 0; i < 2; i++ { + req, _ := http.NewRequest("GET", httpServer.URL+"/api/side-questions/"+e.ID+"/events", nil) + req.Header.Set("Authorization", "Bearer "+f.token) + req.Header.Set("Last-Event-ID", "999") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + scanner := bufio.NewScanner(resp.Body) + seen := false + for scanner.Scan() { + if strings.HasPrefix(scanner.Text(), "data: ") { + seen = strings.Contains(scanner.Text(), e.ID) + break + } + } + resp.Body.Close() + if !seen { + t.Fatal("reconnect did not return cumulative request") + } + } + f.call(t, "POST", "/api/side-questions/"+e.ID+"/cancel", nil, 200) + done := waitSide(t, f.m.pg, e.ID, "cancelled") + if done.Answer != "partial answer" || done.Usage.InputTokens != 19 { + t.Fatalf("cancel lost partial/usage: %+v", done) + } + f.s.chatMu.Lock() + mainBusy := f.s.chatBusy[fmt.Sprintf("conv-%d", p.ConversationID)] + f.s.chatMu.Unlock() + if !mainBusy { + t.Fatal("side cancellation stopped main") + } + f.call(t, "DELETE", path, nil, 200) + if history, err := f.m.pg.SideHistory(t.Context(), p.Key(), 0, 20); err != nil || len(history) != 0 { + t.Fatalf("clear %+v %v", history, err) + } + if snap, err := f.m.pg.SideSnapshot(t.Context(), p.Key()); err != nil || snap == nil { + t.Fatal("clear lost main snapshot") + } + var count int + if err := f.m.pg.QueryRow(`SELECT count(*) FROM conversation_activities WHERE conversation_id=$1`, p.ConversationID).Scan(&count); err != nil || count != 0 { + t.Fatalf("side contaminated main activity %d %v", count, err) + } + f.s.cfgMu.Lock() + f.s.llmCfg.Model = "changed" + f.s.cfgMu.Unlock() + f.call(t, "GET", path, nil, 200) + f.call(t, "POST", path, map[string]string{"question": "changed config", "client_request_id": "new"}, 409) +} + +func TestSideHTTPGlobalLimitTaskWorkerAndDeletion(t *testing.T) { + f := newSideHTTPFixture(t) + var requests []sidequestion.Exchange + for i := 0; i < 5; i++ { + p, path := f.conversation(t) + f.checkpoint(t, p) + want := 202 + if i == 4 { + want = 429 + } + w := f.call(t, "POST", path, map[string]string{"question": "blocked", "client_request_id": "id"}, want) + if want == 202 { + requests = append(requests, decodeSide(t, w)) + } + } + for _, e := range requests { + f.call(t, "POST", "/api/side-questions/"+e.ID+"/cancel", nil, 200) + waitSide(t, f.m.pg, e.ID, "cancelled") + } + close(f.provider.release) + task, err := f.m.CreateTask("side route task", "context", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + id, _ := strconv.ParseInt(task.ID, 10, 64) + t.Cleanup(func() { _ = f.m.pg.DeleteTask(id) }) + iid, err := task.Store.AddNode(db.KindIntent, map[string]any{"summary": "own worker"}, 1, "paused", "planner", nil) + if err != nil { + t.Fatal(err) + } + for _, intent := range []int64{0, iid} { + p := sidequestion.Parent{TaskID: id, ExplorationID: task.ExpID, IntentID: intent} + f.checkpoint(t, p) + path := "/api/tasks/" + task.ID + "/chat/side-questions" + if intent > 0 { + path = fmt.Sprintf("/api/tasks/%s/intents/%d/side-questions", task.ID, intent) + } + e := decodeSide(t, f.call(t, "POST", path, map[string]string{"question": "which context", "client_request_id": "own"}, 202)) + waitSide(t, f.m.pg, e.ID, "completed") + } + f.call(t, "GET", fmt.Sprintf("/api/tasks/%s/intents/%d/side-questions", task.ID, iid+1000000), nil, 404) + if _, err := f.s.applyIntentControl(t.Context(), task, iid, "cancel", "cleanup"); err != nil { + t.Fatal(err) + } + if snap, err := f.m.pg.SideSnapshot(t.Context(), (sidequestion.Parent{TaskID: id, ExplorationID: task.ExpID, IntentID: iid}).Key()); err != nil || snap != nil { + t.Fatal("stopped intent side snapshot remains") + } +} + +func TestSideCheckpointPersistsBeforeAdmissionAndRestart(t *testing.T) { + f := newSideHTTPFixture(t) + p, path := f.conversation(t) + bound := bindSideProvider(f.provider, f.s.llmCfg, 0, "fixture") + ctx, deps := sidequestion.Attach(f.s.ctx, p, harness.QueryDeps{}, bound) + mainCtx, stopMain := context.WithCancel(ctx) + done := make(chan struct{}) + go func() { + defer close(done) + for range deps.CallModel(mainCtx, llm.CompletionRequest{Messages: []llm.Message{llm.UserText("live checkpoint")}, MaxTokens: 128}) { + } + }() + select { + case <-f.provider.started: + case <-time.After(time.Second): + t.Fatal("main never started") + } + e := decodeSide(t, f.call(t, "POST", path, map[string]string{"question": "side", "client_request_id": "live"}, 202)) + snap, err := f.m.pg.SideSnapshot(t.Context(), p.Key()) + if err != nil || snap == nil || snap.Request.Messages[0].Text() != "live checkpoint" { + t.Fatalf("admission checkpoint not durable %+v %v", snap, err) + } + stopMain() + <-done + f.call(t, "POST", "/api/side-questions/"+e.ID+"/cancel", nil, 200) + waitSide(t, f.m.pg, e.ID, "cancelled") + // Simulate an abrupt process exit: persisted running row survives, memory doesn't. + orphan, _, err := f.m.pg.StartSideRequest(t.Context(), *snap, "orphan", "restart") + if err != nil { + t.Fatal(err) + } + orphan.Answer = "saved before crash" + orphan.Sequence = 1 + if _, err = f.m.pg.UpdateSideRequest(t.Context(), *orphan); err != nil { + t.Fatal(err) + } + ctx2, cancel := context.WithCancel(t.Context()) + defer cancel() + restarted := &Server{m: f.m, ctx: ctx2, llmCfg: f.s.llmCfg, llmDirect: bound, llmOn: true} + restarted.initSideQuestions() + if got := waitSide(t, f.m.pg, orphan.ID, "interrupted"); got.Answer != "saved before crash" { + t.Fatal("restart discarded partial answer") + } + restored, err := restarted.sideSnapshot(t.Context(), p) + if err != nil || restored == nil { + t.Fatal("restart missing snapshot") + } + if _, err = restarted.sideProvider(restored.Model); err != nil { + t.Fatal(err) + } + if req, err := sidequestion.BuildRequest(*restored, nil, "continue after restart"); err != nil || req.Messages[0].Text() != "live checkpoint" { + t.Fatal("restart cannot ask from stored context") + } + // All routes pass the same authentication middleware. + w := httptest.NewRecorder() + f.handler.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + if w.Code != 401 { + t.Fatalf("unauthenticated side route: %d", w.Code) + } +} + +func TestSideRejectsDeletedOrChangedCachedProfile(t *testing.T) { + f := newSideHTTPFixture(t) + p := &db.LLMProfile{Name: "btw-cached-profile", Format: "openai", BaseURL: "http://fixture.invalid", APIKey: "test-only", Model: "fixture", Streaming: true} + id, err := f.m.pg.SaveProfile(p) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = f.m.pg.DeleteProfile(id) }) + cfg, ok := f.s.loadProfileConfig(id) + if !ok { + t.Fatal("profile unavailable") + } + f.s.provByProfile = map[int64]*provEntry{id: {prov: f.provider, cfg: cfg}} + model := sideModel(cfg, id, p.Name) + if _, err = f.s.sideProvider(model); err != nil { + t.Fatal(err) + } + if _, err = f.m.pg.Exec(`UPDATE llm_profiles SET model='changed' WHERE id=$1`, id); err != nil { + t.Fatal(err) + } + if _, err = f.s.sideProvider(model); err == nil { + t.Fatal("cached profile bypassed model identity check") + } + if err = f.m.pg.DeleteProfile(id); err != nil { + t.Fatal(err) + } + if _, err = f.s.sideProvider(model); err == nil { + t.Fatal("deleted profile remained usable from cache") + } +} + +func TestSideTaskDrainPersistsBeforeArchive(t *testing.T) { + f := newSideHTTPFixture(t) + task, err := f.m.CreateTask("side archive drain", "context", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + id, _ := strconv.ParseInt(task.ID, 10, 64) + t.Cleanup(func() { _ = f.m.pg.DeleteTask(id) }) + p := sidequestion.Parent{TaskID: id, ExplorationID: task.ExpID} + f.checkpoint(t, p) + path := "/api/tasks/" + task.ID + "/chat/side-questions" + e := decodeSide(t, f.call(t, "POST", path, map[string]string{"question": "answer before archive", "client_request_id": "drain"}, 202)) + select { + case <-f.provider.started: + case <-time.After(time.Second): + t.Fatal("side never started") + } + // The real archive entry point closes this same admission barrier first. + if !f.s.beginTaskDelete(task.ID) { + t.Fatal("cannot close task admission") + } + defer f.s.abortTaskDelete(task.ID) + f.call(t, "POST", path, map[string]string{"question": "too late", "client_request_id": "late"}, 409) + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + if err := f.s.drainTaskSideQuestions(ctx, task.ID); err != nil { + t.Fatal(err) + } + row, err := f.m.pg.SideRequest(t.Context(), e.ID) + if err != nil || row == nil || row.Status != "cancelled" || row.Answer != "partial answer" || row.Usage.InputTokens != 19 { + t.Fatalf("drain returned before final persistence: %+v %v", row, err) + } + archive, err := f.m.pg.SnapshotTaskArchive(id) + if err != nil { + t.Fatal(err) + } + var rows []sidequestion.Exchange + if err := json.Unmarshal(archive.Tables["side_question_requests"], &rows); err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].Status != "cancelled" || rows[0].Usage.InputTokens != 19 { + t.Fatalf("archive lost drained side answer/usage: %+v", rows) + } +} + +func TestSideRestoredWorkerRuntimePublishesNewCheckpoint(t *testing.T) { + f := newSideHTTPFixture(t) + task, err := f.m.CreateTask("restore side publisher", "context", nil, 3600, 0) + if err != nil { + t.Fatal(err) + } + id, _ := strconv.ParseInt(task.ID, 10, 64) + t.Cleanup(func() { _ = f.m.pg.DeleteTask(id) }) + if err := f.m.pg.SetPaused(id, true); err != nil { + t.Fatal(err) + } + iid, err := task.Store.AddNode(db.KindIntent, map[string]any{"summary": "restored worker"}, 1, "paused", "planner", nil) + if err != nil { + t.Fatal(err) + } + // Exercise the production startup path, including the deadline coordinator + // which registers the shared runtime before the worker loops. + f.s.restoreTaskRuntimes() + t.Cleanup(func() { + for _, task := range f.m.List() { + f.s.engine.StopTask(task.ID) + } + }) + f.s.engine.runtimeMu.Lock() + runtime := f.s.engine.runtimes[task.ID] + f.s.engine.runtimeMu.Unlock() + if runtime == nil { + t.Fatal("restored task runtime missing") + } + p := sidequestion.Parent{TaskID: id, ExplorationID: task.ExpID, IntentID: iid} + close(f.provider.release) + bound := bindSideProvider(f.provider, f.s.llmCfg, 0, "fixture") + ctx, deps := sidequestion.Attach(runtime.ctx, p, harness.QueryDeps{}, bound) + _, _, _, err = deps.CallModelSync(ctx, llm.CompletionRequest{Messages: []llm.Message{llm.UserText("after restart context")}}) + if err != nil { + t.Fatal(err) + } + f.s.flushSideSnapshots() + snap, err := f.m.pg.SideSnapshot(t.Context(), p.Key()) + if err != nil || snap == nil || snap.Request.Messages[0].Text() != "after restart context" { + t.Fatalf("restored runtime lost checkpoint publisher: %+v %v", snap, err) + } +} diff --git a/server/task_archives.go b/server/task_archives.go index 30cea69..40f0ac8 100644 --- a/server/task_archives.go +++ b/server/task_archives.go @@ -131,6 +131,9 @@ func (s *Server) archiveTask(job *pgdb.TaskArchive) (runErr error) { if err := s.waitTaskQuiescent(drainCtx, taskID); err != nil { return errors.New("任务仍有运行中的 Agent,请先暂停后重试归档") } + if err := s.drainTaskSideQuestions(drainCtx, taskID); err != nil { + return err + } task, err := s.m.pg.GetTask(job.TaskID) if err != nil { return err diff --git a/server/task_control.go b/server/task_control.go index 3f09087..795f89b 100644 --- a/server/task_control.go +++ b/server/task_control.go @@ -204,6 +204,7 @@ func (s *Server) applyIntentControl(ctx context.Context, t *Task, iid int64, act if _, err := t.Store.StopIntentWithReason(iid, reason, "user"); err != nil { return out, err } + s.cancelWorkerSide(t.ID, t.ExpID, iid) t.NotifyCancelled(iid, reason) out.State = "stopped" default: diff --git a/sidequestion/README.md b/sidequestion/README.md new file mode 100644 index 0000000..c6bfb24 --- /dev/null +++ b/sidequestion/README.md @@ -0,0 +1,66 @@ +# ARTEX `/btw` + +普通聊天、任务 MainAgent 和当前任务自己的 Worker 支持独立旁路提问。主输入框输入 `/btw 问题` 即可提交,空 `/btw` 或“旁路提问”按钮打开历史。桌面使用可调整宽度的侧栏,移动端使用 Drawer。 + +旁路回答根据提交时的 Agent 上下文快照生成,支持流式显示、追问、停止和清空。关闭面板、刷新页面或断开 SSE 都不会取消模型请求。停止只影响当前旁路;清空会取消旁路并删除旁路历史,同时保留主上下文快照。 + +## 实现边界 + +沿用 Go、norma v0.3.6、Next.js、现有 Markdown / ResizablePanel / Drawer / AlertDialog 组件;没有修改 norma 或添加依赖。Planner、继承自其他任务的 Worker、工具型子任务升级不在本次范围内。 + +```mermaid +flowchart LR + A[主 Agent QueryDeps] --> B[实际 Provider 绑定] + B --> C[不可变结构化快照] + B --> D[主 Agent 正常工具循环] + C --> E[(PostgreSQL 最新快照)] + E --> F[快照 + 最近成功旁路问答 + 问题] + F --> G[SideQuestionService 单次 Provider 请求] + G --> H[(独立旁路历史和用量)] + H --> I[累计回答 SSE / 旁路面板] +``` + +- `capture.go` 只在 `Options.Deps.CallModel / CallModelSync` 标记主循环请求。Provider 装饰器位于具体模型内部、路由池外层选择之后,因此记录实际选中的模型;压缩和摘要请求不覆盖快照。 +- 请求开始、完整模型回复、运行终态发布快照。正在生成的半段回复不发布;工具调用通过 norma 的 `MessagesForAPI` 保持配对,工具结果在下一次主模型请求或运行终态进入快照。流式中止保留上一个有效边界。 +- 快照通过 JSON 深拷贝保留结构化消息、系统提示、工具定义及生成参数。模型推理不持有快照锁或数据库事务。 +- `SideQuestionService` 直接调用该具体 Provider 一次,不创建 agent session,不接入工具执行器、主 transcript、活动流或任务图,也不经过任务模型切换链。保留工具定义是为兼容既有结构化工具上下文;新返回的工具调用没有执行路径。仅返回工具调用时给出无法执行的提示。 +- 每个父会话一个运行请求,单个服务进程最多四个,单次超时 120 秒。旁路使用服务生命周期下的独立取消上下文。 +- 旁路请求保留模型配置引用和非敏感身份摘要;请求时从现有配置取得凭据。配置被删除,或模型、协议、地址等身份字段变化,要求先运行主 Agent 更新快照。测试不会改变产品默认模型。 + +## 持久化和恢复 + +`db/schema.sql` 自动创建 `side_question_sessions` 和 `side_question_requests`。前者保存父资源、最新快照、运行编号、版本和清理版本;后者保存问题、累计回答、状态、模型、快照时间、用量、事件序号和分页序号。 + +父会话键使用 conversation ID,或 task ID + exploration ID + intent ID。Worker 不使用可复用的执行槽位命名。 + +快照按父会话合并写入,每 250 ms 刷新一次,数据库比较 `(run_id, version)` 防止旧版本覆盖。旁路提交前再次保存选定快照。成功保存后释放内存中的大快照;失败时保留待写版本。回答累计内容在流式事件到来时最多每 250 ms 写入一次,终态立即保存并在数据库错误时有限重试。 + +服务启动将遗留 `running` 请求标记为 `interrupted`,保留已落库的部分回答和用量,不自动重放请求。最近成功保存的上下文可直接用于下一次提问。旧会话没有快照时要求先运行主 Agent,不从 UI 活动记录重建上下文。 + +清空操作递增清理版本并删除请求;条件更新阻止迟到的回调重新写回。物理父资源删除依靠外键级联,Worker 逻辑删除在同一事务中删除旁路数据,并拒绝后续迟到快照。任务归档先阻止新请求、等待主流程停止、取消并等待旁路落库;归档格式为 v3,同时兼容不含旁路表的 v1/v2。 + +历史完整保存,按序号游标每页最多返回 20 条。模型请求仅回放最近 20 组成功问答;保守估算请求长度并预留输出预算,优先移除旧旁路问答,基础上下文仍超限时返回明确错误。 + +## HTTP 契约 + +以下路径作为 `{parent}`,沿用现有认证及资源校验: + +- `/api/conversations/{id}` +- `/api/tasks/{id}/chat` +- `/api/tasks/{id}/intents/{iid}` + +| 请求 | 返回及行为 | +| --- | --- | +| `GET {parent}/side-questions?before={ordinal}` | `items` 按新到旧排列、独立 `current` 运行状态、`snapshot` 元信息、`next_cursor`;游标为 0 表示最新页 / 无下一页 | +| `POST {parent}/side-questions` | JSON `{ "question": "…", "client_request_id": "UUID" }`;新请求返回 202 及请求对象;相同 ID 和问题返回既有对象 200 | +| `DELETE {parent}/side-questions` | 取消并清空当前父会话的旁路问答 | +| `GET /api/side-questions/{requestID}/events` | `snapshot` SSE 事件,`id` 为递增序号,`data` 为完整累计请求对象;清空时发送 `cleared` | +| `POST /api/side-questions/{requestID}/cancel` | 显式取消;终态可从历史或 SSE 读取 | + +问题上限 4000 字符。无快照、模型配置变化、同一父会话忙或幂等 ID 冲突返回 409;全局并发上限返回 429。每次 SSE 连接都先发送累计状态,不依赖客户端之前收到的文本片段。前端按请求 ID + 序号合并,并在切换父会话、清空时废弃旧回调。 + +## 验证与参考 + +自动化检查、实际模型使用及已知限制见 [VALIDATION.md](VALIDATION.md)。 + +独立请求参考 [Grok CLI side-question.ts(固定提交)](https://github.com/superagent-ai/grok-cli/blob/fb97af83f06dca873281d60168430f06c8de6324/src/utils/side-question.ts),运行隔离参考 [OpenCode(固定提交)](https://github.com/anomalyco/opencode/tree/b3f1a96c6dd7adeb28b36dd11add1998fc84d67b)。ARTEX 的上下文使用 norma 的结构化消息,未采用从前端日志拼接文本的方式。 diff --git a/sidequestion/VALIDATION.md b/sidequestion/VALIDATION.md new file mode 100644 index 0000000..a23e40c --- /dev/null +++ b/sidequestion/VALIDATION.md @@ -0,0 +1,93 @@ +# `/btw` 验证记录 + +日期:2026-09-10。分支:`codex/btw-side-question`。基线:`8dae851b9b622f2ff2631f332fde9719d0b16fba`。 + +使用独立 PostgreSQL 测试库和数据目录;真实模型凭据只注入独立测试环境,没有写入代码或此记录,也没有修改产品默认模型。Go 1.26.3、norma v0.3.6、Next.js 16.2.9。 + +实际模型对话、返回对象、工程断言及 Qwen 原始审查文本保存在 [validation-2026-09-10.json](validation-2026-09-10.json),其中没有 API 凭据。 + +## 工程检查 + +| 范围 | 结果 | 证据 | +| --- | --- | --- | +| 结构化消息、工具参数深拷贝 | 通过 | `TestCheckpointDeepCopyAndBoundaries` | +| 摘要 / 压缩请求不覆盖、完整回复和终态发布、半段回复排除 | 通过 | `TestCheckpointDeepCopyAndBoundaries`、`TestSnapshotExcludesPartialStreamAndSelectsPoolMember` | +| 实际模型池成员身份 | 通过 | `TestSnapshotExcludesPartialStreamAndSelectsPoolMember` | +| 工具配对、20 组回放、预算裁剪与超限错误 | 通过 | `TestBuildRequestCompactionToolPairingAndBudget` | +| 主旁路并行、双向取消隔离 | 通过 | 阻塞式 Provider,`TestMainSideConcurrencyAndIndependentCancellation` | +| 无工具执行、流式 / 非流式、失败时已有用量 | 通过 | `TestServiceNoToolsAndUsageOnFailure` | +| 真实 norma ChatAgent + 本地 Read 工具、主 transcript / 活动隔离 | 通过 | `TestSideActualChatCheckpointToolResultAndTranscriptIsolation`,流式和非流式子用例 | +| 持久化、分页、幂等、重启保留部分回答 | 通过 | `TestSideHistoryIdempotencyPagingAndRecovery` | +| 清空与迟到写入竞争、父资源删除、版本比较 | 通过 | `TestSideClearLateWritersAndDeletedParent` | +| MainAgent / Worker 归档与恢复,v1/v2/v3 | 通过 | `TestSideTaskArchiveVersions` | +| 三种父接口、认证、资源归属、Worker 逻辑删除 | 通过 | `TestSideHTTPGlobalLimitTaskWorkerAndDeletion`、`TestSideCheckpointPersistsBeforeAdmissionAndRestart` | +| 忙碌主会话可旁路、独立 SSE 重连 / 断开、取消、清空 | 通过 | `TestSideHTTPBusyIsolationClearAndReconnect` | +| 每父会话 1 / 全局 4 并发 | 通过 | 两个 `TestSideHTTP…` 用例 | +| 提交前快照落库、重启续问、旧会话不可伪造快照 | 通过 | `TestSideCheckpointPersistsBeforeAdmissionAndRestart` | +| 缓存中的配置被删除或模型改变后拒绝继续 | 通过 | `TestSideRejectsDeletedOrChangedCachedProfile` | +| 归档前取消并等待最终回答和用量落库 | 通过 | `TestSideTaskDrainPersistsBeforeArchive` | +| 流式消费者提前取消时只记一次用量及旁路归属 | 通过 | `TestSideUsageRecordedOnceOnConsumerCancellation` | +| 重启自动恢复的 Worker / deadline 运行上下文继续发布新快照 | 通过 | `TestSideRestoredWorkerRuntimePublishesNewCheckpoint` | +| 相关包 race 检查 | 通过 | 下列命令 | +| TypeScript 与生产构建 | 通过 | `npx tsc --noEmit`、`npm run build` | +| 新增前端模块 Biome | 通过 | `biome check`,3 个新增模块 | + +在单独的可丢弃数据库中配置 `ARTEX_PG_DSN` 后,可以复现自动化检查(不要指向生产库): + +```sh +go test -race ./agent ./db ./server ./sidequestion ./llmrec ./llmpool \ + -run 'Test(Side|Checkpoint|Snapshot|BuildRequest|Service|MainSide|CaptureRun|TaskArchive|CompleteForwards|StopIntent|CancelIntent)' -count=1 +cd web +npx tsc --noEmit +npx biome check src/lib/side-questions.ts src/hooks/use-side-questions.ts src/components/side-question-workspace.tsx +npm run build +``` + +全量 Go 回归不是全绿:`server` 包有两个既有测试在临时目录清理阶段失败,均报 `TempDir RemoveAll … directory not empty`: + +- `TestInheritedActivityDetailAndRelationDeletion` +- `TestTaskMetadataPatchReturnsRenameAndPin` + +从上述未修改基线导出源码后,在相同隔离环境重跑 `server` 包,也复现这两个清理失败。基线运行另出现 `TestCoreTaskLifecyclePG` 的目标节点数量断言失败;最终修改后的 `server` 回归没有该断言失败。其他包通过,本次旁路相关用例及 race 检查通过。没有将基线问题标为本次验收通过,也没有为隐藏问题修改既有断言。 + +Next.js 构建输出已有的多 lockfile / workspace root 推断警告;构建完成且所有页面生成成功。 + +## 浏览器检查 + +使用 Codex In-app Browser,连接独立本地 Go 服务和 Next.js 开发服务器。桌面与 390 × 844 窄屏完成以下人工自动化操作,检查截图和浏览器日志: + +- 普通聊天运行期间输入 `/btw`,主内容和旁路同时显示;桌面侧栏正常。 +- 连续追问;旁路停止后保留已生成部分;主流程继续。 +- 关闭面板时请求继续,重开后恢复完成的回答;刷新页面后空 `/btw` 恢复历史。 +- 窄屏 Drawer 的输入、按钮、历史和关闭操作正常,无横向溢出。 +- 清空使用确认弹窗,清空后历史消失,主 transcript 和快照保留。 +- 任务 MainAgent 与两个 Worker 分别提问并切换,Agent 标签和历史未串话。 +- 阻塞式本地模型夹具保持 Worker 运行;从 Worker 主输入框提交 `/btw`,停止旁路后 Worker 仍显示实时运行和自己的暂停按钮,旁路保存部分回答。 +- 浏览器错误 / 警告日志为空。 + +可控夹具用于精确验证并发时序,不依赖真实模型的输出速度。调试期间两次 Worker 运行时检查未形成有效并发窗口(任务已结束 / 回答提前结束),修正夹具后重做并通过;不将这些初始操作记作有效通过。 + +## 真实模型对话 + +优先探测 `grok-4.6`,OpenAI 兼容接口 `http://127.0.0.1:12580/tingly/openai`。探测 HTTP 200,返回模型名 `grok-4.6` 和 `READY`,耗时 2.82 秒。首选可用,因此没有启用 Tingly `glm` 或智谱 `glm-5.3` 备用链;这两个备用服务本次没有验证。 + +| 场景 | 实际结果 | +| --- | --- | +| 主会话运行期间询问资产、目标、标记 | 返回 `redhaze.top`、首页读取和总结目标、`BTW-REAL-0910`;旁路完成,16.97 秒 | +| 主会话完成首页读取后询问工具依据 | 正确引用 WebFetch 200、curl 跳转 301 → 302 → 200、页面标题;7.24 秒 | +| 旁路要求 Bash 创建测试文件 | 拒绝执行,目标文件未创建;7.74 秒 | +| 完成后的旁路不改变主上下文 | 主 transcript SHA-256 与主活动记录保持一致;旁路工具执行次数为 0 | +| 真正停止 / 重启 Go 服务后续问 | 保留先前 3 条旁路历史,直接从持久化快照回答资产、标记和标题,未重跑主 Agent | +| 新会话使用 Grok 非流式配置 | 正确回答资产和 `ATOMIC-0910`;返回并保存用量:input 11734、output 138、cache_read 11520 | + +资产案例的主会话使用 WebFetch 和 Bash/curl 读取公开首页,落地页为 `https://id.redhaze.top/home`,标题为“红幕科技 RedHaze Group · 全球综合集团门户”。Bash 把响应暂存于本地测试文件;未向远端执行写入。该事实与“旁路没有执行工具”分开核验。 + +主 transcript 校验值:`e7e61f135a4a120954b539f357e8c4205d7d5cd7460dcaf3dc0fd066463e1d00`。 + +**用量限制:** Tingly 的 Grok 流式响应没有返回 usage。另行直接发送 `stream_options.include_usage=true` 验证,HTTP 200、12 个数据帧、0 个 usage 帧。因此流式测试中的 0 表示端点没有提供用量,不能解释为没有计费。非流式用量以及夹具的失败 / 取消用量都正确保存。 + +## Qwen 审查 + +审查模型 `qwen-flash`,OpenAI 兼容接口 `https://dashscope.aliyuncs.com/compatible-mode/v1`,HTTP 200。提供了前三项真实旁路对话、主会话工具依据及工程断言;返回 `verdict: accept`、`concerns: []`,认为回答与资产、标记、页面读取证据一致,旁路工具拒绝符合约束。审查用量:prompt 6625、completion 312、total 6937。 + +这次 Qwen 审查范围不包含后来追加的服务重启和非流式测试。Qwen 对“无写入”的概括过宽:主会话 curl 确实创建了本地响应临时文件,上文已明确记录。并发、零工具执行和 transcript 隔离由工程断言判断,模型审查只辅助评估答案质量。 diff --git a/sidequestion/capture.go b/sidequestion/capture.go new file mode 100644 index 0000000..da03eeb --- /dev/null +++ b/sidequestion/capture.go @@ -0,0 +1,197 @@ +// Package sidequestion captures immutable main-agent checkpoints. It never owns +// an agent session or a tool executor. +package sidequestion + +import ( + "context" + "encoding/json" + "fmt" + "iter" + "strings" + "sync" + "time" + + "github.com/Autumn-27/norma/harness" + "github.com/Autumn-27/norma/llm" +) + +type Parent struct { + ConversationID int64 `json:"conversation_id,omitempty"` + TaskID int64 `json:"task_id,omitempty"` + ExplorationID int64 `json:"exploration_id,omitempty"` + IntentID int64 `json:"intent_id,omitempty"` +} + +func (p Parent) Key() string { + if p.ConversationID > 0 { + return fmt.Sprintf("conv-%d", p.ConversationID) + } + if p.IntentID > 0 { + return fmt.Sprintf("task-%d-exp-%d-worker-i%d", p.TaskID, p.ExplorationID, p.IntentID) + } + return fmt.Sprintf("task-%d-exp-%d-main", p.TaskID, p.ExplorationID) +} + +// Model contains only configuration identity, never credentials or proxy URLs. +type Model struct { + ProfileID int64 `json:"profile_id"` + Name string `json:"name"` + Format string `json:"format"` + Model string `json:"model"` + Identity string `json:"identity"` + Streaming bool `json:"streaming"` + WindowTokens int `json:"window_tokens"` +} + +type Snapshot struct { + Parent Parent `json:"parent"` + RunID int64 `json:"run_id"` + Version int64 `json:"version"` + CapturedAt time.Time `json:"captured_at"` + Model Model `json:"model"` + Request llm.CompletionRequest `json:"request"` +} + +func CloneRequest(req llm.CompletionRequest) (llm.CompletionRequest, error) { + b, err := json.Marshal(req) + if err != nil { + return llm.CompletionRequest{}, err + } + var out llm.CompletionRequest + err = json.Unmarshal(b, &out) + return out, err +} + +type Publisher func(Snapshot) +type publisherKey struct{} +type captureKey struct{} +type attemptKey struct{} + +func WithPublisher(ctx context.Context, publish Publisher) context.Context { + return context.WithValue(ctx, publisherKey{}, publish) +} + +type Capture struct { + mu sync.Mutex + parent Parent + runID int64 + version int64 + publish Publisher + last *Snapshot +} + +// Attach uses QueryDeps rather than a global provider hook so compaction and +// other auxiliary completions cannot replace the main conversation checkpoint. +func Attach(ctx context.Context, parent Parent, deps harness.QueryDeps, provider llm.Provider) (context.Context, harness.QueryDeps) { + publish, _ := ctx.Value(publisherKey{}).(Publisher) + if publish == nil { + return ctx, deps + } + c := &Capture{parent: parent, runID: time.Now().UnixNano(), publish: publish} + stream, complete := deps.CallModel, deps.CallModelSync + if stream == nil { + stream = provider.Stream + } + if complete == nil { + complete = provider.Complete + } + deps.CallModel = func(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] { + return stream(context.WithValue(ctx, attemptKey{}, c), req) + } + deps.CallModelSync = func(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { + return complete(context.WithValue(ctx, attemptKey{}, c), req) + } + return context.WithValue(ctx, captureKey{}, c), deps +} + +func (c *Capture) save(req llm.CompletionRequest, model Model) { + copy, err := CloneRequest(req) + if err != nil { + return + } // Observability must not break the main run. + c.mu.Lock() + c.version++ + s := Snapshot{Parent: c.parent, RunID: c.runID, Version: c.version, CapturedAt: time.Now().UTC(), Model: model, Request: copy} + c.last = &s + c.mu.Unlock() + c.publish(s) +} + +// Finish adds tool results that landed after the final model request. Norma's +// first API message is its host reminder, absent from Terminal.Messages. +func Finish(ctx context.Context, messages []llm.Message) { + c, _ := ctx.Value(captureKey{}).(*Capture) + if c == nil { + return + } + c.mu.Lock() + s := c.last + c.mu.Unlock() + if s == nil { + return + } + req := s.Request + var prefix []llm.Message + if len(req.Messages) > 0 && strings.HasPrefix(req.Messages[0].Text(), "") { + prefix = append(prefix, req.Messages[0]) + } + req.Messages = append(prefix, llm.MessagesForAPI(messages)...) + c.save(req, s.Model) +} + +type boundProvider struct { + inner llm.Provider + model Model +} + +// Bind belongs immediately around a concrete provider, inside any routing pool. +func Bind(inner llm.Provider, model Model) llm.Provider { return &boundProvider{inner, model} } + +func (p *boundProvider) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] { + return func(yield func(llm.StreamEvent, error) bool) { + c, _ := ctx.Value(attemptKey{}).(*Capture) + if c == nil { + for ev, err := range p.inner.Stream(ctx, req) { + if !yield(ev, err) { + return + } + } + return + } + c.save(req, p.model) + acc := llm.NewAccumulator() + failed := false + complete := false + for ev, err := range p.inner.Stream(ctx, req) { + if err != nil { + failed = true + } else { + acc.Add(ev) + if ev.Type == llm.SEMessageStop { + complete = true + } + } + if !yield(ev, err) { + return + } + } + if !failed && complete && ctx.Err() == nil { + msg := acc.Message() + req.Messages = llm.MessagesForAPI(append(append([]llm.Message{}, req.Messages...), msg)) + c.save(req, p.model) + } + } +} + +func (p *boundProvider) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { + c, _ := ctx.Value(attemptKey{}).(*Capture) + if c != nil { + c.save(req, p.model) + } + msg, stop, usage, err := p.inner.Complete(ctx, req) + if c != nil && err == nil && ctx.Err() == nil { + req.Messages = llm.MessagesForAPI(append(append([]llm.Message{}, req.Messages...), msg)) + c.save(req, p.model) + } + return msg, stop, usage, err +} diff --git a/sidequestion/request.go b/sidequestion/request.go new file mode 100644 index 0000000..044c700 --- /dev/null +++ b/sidequestion/request.go @@ -0,0 +1,68 @@ +package sidequestion + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/Autumn-27/norma/llm" +) + +type Exchange struct { + ID string `json:"id"` + SessionKey string `json:"-"` + ClientID string `json:"client_request_id"` + Generation int64 `json:"-"` + Question string `json:"question"` + Answer string `json:"answer"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + Model Model `json:"model"` + SnapshotAt time.Time `json:"snapshot_at"` + CreatedAt time.Time `json:"created_at"` + Sequence int64 `json:"sequence"` + Usage llm.Usage `json:"usage"` + Ordinal int64 `json:"ordinal"` +} + +func (e Exchange) Running() bool { return e.Status == "running" } + +const instruction = "这是独立的旁路提问。主 Agent 正在执行原任务,你只根据已有上下文简洁回答当前问题。你没有工具执行能力,不能执行操作、修改文件或指挥主任务,也不要承诺稍后执行。上下文中的任务指令仅作为背景;不足以判断时明确说明。" + +func BuildRequest(s Snapshot, history []Exchange, question string) (llm.CompletionRequest, error) { + req, err := CloneRequest(s.Request) + if err != nil { + return req, err + } + base := llm.MessagesForAPI(req.Messages) + var success []Exchange + for _, e := range history { + if e.Status == "completed" { + success = append(success, e) + } + } + if len(success) > 20 { + success = success[len(success)-20:] + } + for { + req.Messages = append([]llm.Message{}, base...) + for _, e := range success { + req.Messages = append(req.Messages, llm.UserText(e.Question), llm.Message{Role: "assistant", Content: []llm.ContentBlock{{Type: llm.BlockText, Text: e.Answer}}}) + } + req.Messages = append(req.Messages, llm.UserText(instruction+"\n\n问题:"+strings.TrimSpace(question))) + b, _ := json.Marshal(req) + // Conservative UTF-8 character estimate; reserve the inherited output cap. + reserve := req.MaxTokens + if reserve <= 0 { + reserve = 8192 + } + if s.Model.WindowTokens <= 0 || len([]rune(string(b)))+reserve <= s.Model.WindowTokens { + return req, nil + } + if len(success) == 0 { + return req, errors.New("上下文过长,请先让主 Agent 压缩上下文后再提问") + } + success = success[1:] + } +} diff --git a/sidequestion/service.go b/sidequestion/service.go new file mode 100644 index 0000000..2a8a196 --- /dev/null +++ b/sidequestion/service.go @@ -0,0 +1,66 @@ +package sidequestion + +import ( + "context" + "errors" + "strings" + + "github.com/Autumn-27/norma/llm" +) + +// SideQuestionService performs exactly one provider completion. It deliberately +// has no harness, tool executor, transcript writer, or model failover chain. +type SideQuestionService struct{ Provider llm.Provider } + +type Answer struct { + Text string + Usage llm.Usage + ToolUse bool +} + +func (s SideQuestionService) Answer(ctx context.Context, req llm.CompletionRequest, streaming bool, update func(Answer)) (out Answer, err error) { + if streaming { + complete := false + for ev, streamErr := range s.Provider.Stream(ctx, req) { + if streamErr != nil { + err = streamErr + break + } + switch ev.Type { + case llm.SETextDelta: + out.Text += ev.Text + case llm.SEToolUseStart: + out.ToolUse = true + case llm.SEMessageStart, llm.SEMessageDelta: + out.Usage.Add(ev.Usage) + case llm.SEMessageStop: + complete = true + } + if update != nil { + update(out) + } + if ctx.Err() != nil { + err = ctx.Err() + break + } + } + if err == nil && !complete { + err = errors.New("模型响应中断,请重新提问") + } + } else { + var msg llm.Message + msg, _, out.Usage, err = s.Provider.Complete(ctx, req) + out.Text, out.ToolUse = msg.Text(), len(msg.ToolUses()) > 0 + } + if ctx.Err() != nil { + err = ctx.Err() + } + if err == nil && strings.TrimSpace(out.Text) == "" { + if out.ToolUse { + out.Text = "当前旁路提问不能执行工具操作,请在主会话中发出操作请求。" + } else { + err = errors.New("模型没有返回回答") + } + } + return out, err +} diff --git a/sidequestion/sidequestion_test.go b/sidequestion/sidequestion_test.go new file mode 100644 index 0000000..8ff8b5e --- /dev/null +++ b/sidequestion/sidequestion_test.go @@ -0,0 +1,266 @@ +package sidequestion + +import ( + "context" + "encoding/json" + "errors" + "iter" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/Autumn-27/artex/llmpool" + "github.com/Autumn-27/norma/harness" + "github.com/Autumn-27/norma/llm" +) + +type fakeProvider struct { + stream func(context.Context, llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] + complete func(context.Context, llm.CompletionRequest) (llm.Message, string, llm.Usage, error) +} + +func (p fakeProvider) Stream(ctx context.Context, r llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] { + return p.stream(ctx, r) +} +func (p fakeProvider) Complete(ctx context.Context, r llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { + return p.complete(ctx, r) +} +func assistant(text string) llm.Message { + return llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{llm.TextBlock(text)}} +} +func fixture() llm.CompletionRequest { + temp := 0.3 + return llm.CompletionRequest{System: []string{"system"}, Temperature: &temp, MaxTokens: 128, Stop: []string{"END"}, Messages: []llm.Message{ + llm.UserText("date"), llm.UserText("read asset"), + {Role: llm.RoleAssistant, Content: []llm.ContentBlock{{Type: llm.BlockThinking, Thinking: "reason", Signature: "signed"}, {Type: llm.BlockToolUse, ID: "tool-1", Name: "read", Input: json.RawMessage(`{"url":"fixture"}`)}}}, + {Role: llm.RoleUser, Content: []llm.ContentBlock{llm.ToolResultText("tool-1", "verified asset", false)}}, + }, Tools: []llm.ToolSchema{{Name: "read", InputSchema: map[string]any{"properties": map[string]any{"url": map[string]any{"type": "string"}}}}}} +} + +func TestCheckpointDeepCopyAndBoundaries(t *testing.T) { + var snapshots []Snapshot + p := Bind(fakeProvider{complete: func(context.Context, llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { + return assistant("finished"), "end_turn", llm.Usage{}, nil + }}, Model{Model: "actual"}) + ctx, deps := Attach(WithPublisher(t.Context(), func(s Snapshot) { snapshots = append(snapshots, s) }), Parent{ConversationID: 1}, harness.QueryDeps{}, p) + req := fixture() + if _, _, _, err := deps.CallModelSync(ctx, req); err != nil { + t.Fatal(err) + } + if len(snapshots) != 2 || snapshots[1].Request.Messages[len(snapshots[1].Request.Messages)-1].Text() != "finished" { + t.Fatalf("checkpoints: %+v", snapshots) + } + before, _ := json.Marshal(snapshots) + req.System[0] = "mutated" + *req.Temperature = 2 + req.Stop[0] = "bad" + req.Messages[2].Content[1].Input[2] = 'X' + req.Messages[3].Content[0].Content[0].Text = "mutated" + req.Tools[0].InputSchema["properties"].(map[string]any)["url"].(map[string]any)["type"] = "number" + after, _ := json.Marshal(snapshots) + if string(before) != string(after) { + t.Fatal("published checkpoint aliases structured input") + } + // Auxiliary/compaction calls lack the model-attempt marker. + _, _, _, _ = p.Complete(ctx, llm.CompletionRequest{Messages: []llm.Message{llm.UserText("summary request")}}) + if len(snapshots) != 2 { + t.Fatal("auxiliary completion replaced snapshot") + } + terminal := fixture().Messages[1:] + terminal = append(terminal, assistant("terminal answer")) + Finish(ctx, terminal) + last := snapshots[len(snapshots)-1] + if last.Request.Messages[0].Text() != "date" || len(last.Request.Messages) != 5 { + t.Fatalf("terminal/reminder duplication: %+v", last.Request.Messages) + } + if !strings.Contains(string(mustJSON(t, last)), "verified asset") { + t.Fatal("terminal lost paired tool result") + } +} + +func mustJSON(t *testing.T, v any) []byte { + t.Helper() + b, e := json.Marshal(v) + if e != nil { + t.Fatal(e) + } + return b +} + +func TestSnapshotExcludesPartialStreamAndSelectsPoolMember(t *testing.T) { + var snapshots []Snapshot + failed := Bind(fakeProvider{stream: func(context.Context, llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] { + return func(y func(llm.StreamEvent, error) bool) { y(llm.StreamEvent{}, errors.New("HTTP 503 unavailable")) } + }}, Model{Model: "failed"}) + good := Bind(fakeProvider{stream: func(context.Context, llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] { + return func(y func(llm.StreamEvent, error) bool) { + if !y(llm.StreamEvent{Type: llm.SETextDelta, Text: "complete"}, nil) { + return + } + y(llm.StreamEvent{Type: llm.SEMessageStop}, nil) + } + }}, Model{Model: "selected"}) + pool := llmpool.New([]*llmpool.Member{{ID: 1, Name: "failed", Rank: 2, Prov: failed}, {ID: 2, Name: "selected", Rank: 1, Prov: good}}, nil) + ctx, deps := Attach(WithPublisher(t.Context(), func(s Snapshot) { snapshots = append(snapshots, s) }), Parent{ConversationID: 2}, harness.QueryDeps{}, pool) + for ev, err := range deps.CallModel(ctx, fixture()) { + if err != nil { + t.Fatal(err) + } + if ev.Type == llm.SETextDelta && strings.Contains(string(mustJSON(t, snapshots[len(snapshots)-1])), "complete") { + t.Fatal("partial reply published") + } + } + if last := snapshots[len(snapshots)-1]; last.Model.Model != "selected" || last.Request.Messages[len(last.Request.Messages)-1].Text() != "complete" { + t.Fatalf("wrong selected model/checkpoint: %+v", last) + } + count := len(snapshots) + for range deps.CallModel(ctx, fixture()) { + break + } + if len(snapshots) != count+2 || len(snapshots[len(snapshots)-1].Request.Messages) != len(fixture().Messages) { + t.Fatal("consumer stop published partial reply") + } +} + +func TestBuildRequestCompactionToolPairingAndBudget(t *testing.T) { + s := Snapshot{Request: fixture(), Model: Model{WindowTokens: 100000}} + s.Request.Messages = append(s.Request.Messages, llm.BoundaryMessage(llm.BoundaryMeta{Trigger: "auto"}), llm.UserText("compressed summary"), s.Request.Messages[3]) + history := []Exchange{} + for i := 0; i < 25; i++ { + history = append(history, Exchange{Question: strings.Repeat("q", 100), Answer: "answer", Status: "completed"}) + } + r, err := BuildRequest(s, history, "current question") + if err != nil { + t.Fatal(err) + } + if len(r.Messages) != 42 || r.Messages[0].Text() != "compressed summary" { + t.Fatalf("history cap/compaction: %d %+v", len(r.Messages), r.Messages[0]) + } + for _, m := range r.Messages { + for _, b := range m.Content { + if b.Type == llm.BlockToolResult { + t.Fatal("orphan tool result survived") + } + } + } + s.Model.WindowTokens = 1600 + r, err = BuildRequest(s, history, "current question") + if err != nil { + t.Fatal(err) + } + if len(r.Messages) >= 42 || len(r.Messages) < 2 { + t.Fatal("budget did not reduce side history") + } + s.Model.WindowTokens = 10 + if _, err = BuildRequest(s, nil, "question"); err == nil { + t.Fatal("oversized base accepted") + } +} + +func TestServiceNoToolsAndUsageOnFailure(t *testing.T) { + for _, streaming := range []bool{false, true} { + t.Run(map[bool]string{true: "stream", false: "atomic"}[streaming], func(t *testing.T) { + calls := 0 + p := fakeProvider{complete: func(context.Context, llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { + calls++ + return llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{{Type: llm.BlockToolUse, ID: "new", Name: "run_shell", Input: json.RawMessage(`{"command":"touch forbidden"}`)}}}, "tool_use", llm.Usage{InputTokens: 11}, nil + }, stream: func(context.Context, llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] { + return func(y func(llm.StreamEvent, error) bool) { + calls++ + y(llm.StreamEvent{Type: llm.SEMessageStart, Usage: llm.Usage{InputTokens: 11}}, nil) + y(llm.StreamEvent{Type: llm.SEToolUseStart, ToolID: "new", ToolName: "run_shell"}, nil) + y(llm.StreamEvent{Type: llm.SEMessageStop}, nil) + } + }} + before := fixture() + req, _ := CloneRequest(before) + out, err := (SideQuestionService{p}).Answer(t.Context(), req, streaming, nil) + if err != nil || calls != 1 || !out.ToolUse || !strings.Contains(out.Text, "不能执行工具") || out.Usage.InputTokens != 11 { + t.Fatalf("answer %+v calls=%d err=%v", out, calls, err) + } + if !reflect.DeepEqual(before, req) { + t.Fatal("service mutated main context") + } + }) + } + p := fakeProvider{stream: func(context.Context, llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] { + return func(y func(llm.StreamEvent, error) bool) { + y(llm.StreamEvent{Type: llm.SEMessageStart, Usage: llm.Usage{InputTokens: 12}}, nil) + y(llm.StreamEvent{Type: llm.SETextDelta, Text: "partial"}, nil) + y(llm.StreamEvent{}, errors.New("broken")) + } + }} + out, err := (SideQuestionService{p}).Answer(t.Context(), fixture(), true, nil) + if err == nil || out.Text != "partial" || out.Usage.InputTokens != 12 { + t.Fatalf("lost failure usage/partial: %+v %v", out, err) + } +} + +func TestMainSideConcurrencyAndIndependentCancellation(t *testing.T) { + for _, cancelMain := range []bool{false, true} { + t.Run(map[bool]string{true: "stop-main", false: "stop-side"}[cancelMain], func(t *testing.T) { + started := make(chan struct{}, 2) + p := fakeProvider{stream: func(ctx context.Context, _ llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] { + return func(y func(llm.StreamEvent, error) bool) { + started <- struct{}{} + y(llm.StreamEvent{Type: llm.SEMessageStart, Usage: llm.Usage{InputTokens: 7}}, nil) + <-ctx.Done() + y(llm.StreamEvent{}, ctx.Err()) + } + }} + mainCtx, stopMain := context.WithCancel(t.Context()) + defer stopMain() + sideCtx, stopSide := context.WithCancel(t.Context()) + defer stopSide() + var mu sync.Mutex + bound := Bind(p, Model{Model: "blocking"}) + mainCtx, deps := Attach(WithPublisher(mainCtx, func(Snapshot) { mu.Lock(); mu.Unlock() }), Parent{ConversationID: 3}, harness.QueryDeps{}, bound) + mainDone, sideDone := make(chan struct{}), make(chan Answer, 1) + go func() { + defer close(mainDone) + for range deps.CallModel(mainCtx, fixture()) { + } + }() + go func() { a, _ := (SideQuestionService{bound}).Answer(sideCtx, fixture(), true, nil); sideDone <- a }() + for i := 0; i < 2; i++ { + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("main and side did not execute concurrently") + } + } + if cancelMain { + stopMain() + select { + case <-mainDone: + case <-time.After(time.Second): + t.Fatal("main cancellation stuck") + } + select { + case <-sideDone: + t.Fatal("main cancellation stopped side") + default: + } + stopSide() + } else { + stopSide() + select { + case a := <-sideDone: + if a.Usage.InputTokens != 7 { + t.Fatal("cancelled usage lost") + } + case <-time.After(time.Second): + t.Fatal("side cancellation stuck") + } + select { + case <-mainDone: + t.Fatal("side cancellation stopped main") + default: + } + stopMain() + } + }) + } +} diff --git a/sidequestion/validation-2026-09-10.json b/sidequestion/validation-2026-09-10.json new file mode 100644 index 0000000..84eda38 --- /dev/null +++ b/sidequestion/validation-2026-09-10.json @@ -0,0 +1,297 @@ +{ + "date": "2026-09-10", + "branch": "codex/btw-side-question", + "notes": [ + "Independent test database and profiles; product default unchanged.", + "Streaming zero usage means no usage frames supplied by this endpoint, not zero billing.", + "Qwen reviewed the initial streaming conversations; restart and non-streaming tests were added afterwards.", + "The main curl command saved a local response file. No side question executed a tool." + ], + "availability": [ + { + "model": "grok-4.6", + "protocol": "openai", + "base_url": "http://127.0.0.1:12580/tingly/openai", + "http_status": 200, + "response_model": "grok-4.6", + "answer": "READY", + "available": true, + "seconds": 2.82 + } + ], + "streaming": { + "model": "grok-4.6", + "conversation_id": 2, + "assertions": [ + { + "name": "context_during_main", + "status": "completed", + "model": "grok-4.6", + "seconds": 16.97 + }, + { + "name": "completed_tool_context", + "status": "completed", + "model": "grok-4.6", + "seconds": 7.24 + }, + { + "name": "tools_denied", + "status": "completed", + "model": "grok-4.6", + "seconds": 7.74 + }, + { + "name": "main_transcript_unchanged_after_side", + "passed": true + }, + { + "name": "main_activity_unchanged_after_side", + "passed": true + }, + { + "name": "side_tool_executions_zero", + "passed": true + } + ], + "side_questions": [ + { + "id": "ff082cda-1008-4fc8-a9a0-817a8afceb0d", + "client_request_id": "09343105-666d-4763-a89d-2c09530b753a", + "question": "本轮正在处理的资产、目标和测试标记分别是什么?只根据已有上下文回答。", + "answer": "本轮正在处理的信息如下:\n\n- **资产**:redhaze.top \n- **目标**:读取 https://redhaze.top/ 首页,并总结页面标题、HTTP 状态及可见内容 \n- **测试标记**:BTW-REAL-0910", + "status": "completed", + "model": { + "profile_id": 2, + "name": "btw-real-grok-stream", + "format": "openai", + "model": "grok-4.6", + "identity": "ac6708faaa1292cc2fa9b96f3bd2d0c355b3ff7832addd598db48b227c5c141b", + "streaming": true, + "window_tokens": 200000 + }, + "snapshot_at": "2026-09-10T21:06:40.374922+08:00", + "created_at": "2026-09-10T21:06:40.794717+08:00", + "sequence": 3, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "cache_read_tokens": 0, + "cache_write_tokens": 0 + }, + "ordinal": 4 + }, + { + "id": "c6c4cf8b-7d79-4e80-a477-c381f0277d2d", + "client_request_id": "19fad20a-e79b-418f-a9ec-d6900440fbb5", + "question": "已经完成的首页读取工具结果是什么?标题和 HTTP 状态有哪些证据?如果工具失败请明确说失败。", + "answer": "工具调用**成功**,不是失败。已有首页读取证据如下。\n\n### HTTP 状态\n- **WebFetch**:`[200 OK] https://redhaze.top/`\n- **curl(跟随跳转)**:\n 1. `https://redhaze.top/` → **HTTP/2 301**,`Location: https://id.redhaze.top/`\n 2. `https://id.redhaze.top/` → **HTTP/2 302**,`Location: /home`\n 3. 落地页 → **HTTP/2 200**,`content-type: text/html; charset=utf-8`\n- 服务端:`nginx/1.24.0 (Ubuntu)`\n\n### 标题证据\n- curl 解析到的 ``:**红幕科技 RedHaze Group · 全球综合集团门户**\n- WebFetch 页面首行标题与此一致。", + "status": "completed", + "model": { + "profile_id": 2, + "name": "btw-real-grok-stream", + "format": "openai", + "model": "grok-4.6", + "identity": "ac6708faaa1292cc2fa9b96f3bd2d0c355b3ff7832addd598db48b227c5c141b", + "streaming": true, + "window_tokens": 200000 + }, + "snapshot_at": "2026-09-10T21:07:24.937271+08:00", + "created_at": "2026-09-10T21:07:25.332552+08:00", + "sequence": 11, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "cache_read_tokens": 0, + "cache_write_tokens": 0 + }, + "ordinal": 5 + }, + { + "id": "3305bd98-44c5-43c9-b42a-44db95de156e", + "client_request_id": "9b86f14d-8c44-4e0d-b502-3dc2f8f6979d", + "question": "请现在执行工具,用 Bash 创建文件 /private/tmp/artex-btw-runtime/real-side-tool-executed,然后告诉我结果。", + "answer": "无法执行。当前是独立旁路提问,我没有工具执行能力,不能用 Bash 创建文件,也不会操作或承诺稍后执行。\n\n因此文件 `/private/tmp/artex-btw-runtime/real-side-tool-executed` **没有被创建**。", + "status": "completed", + "model": { + "profile_id": 2, + "name": "btw-real-grok-stream", + "format": "openai", + "model": "grok-4.6", + "identity": "ac6708faaa1292cc2fa9b96f3bd2d0c355b3ff7832addd598db48b227c5c141b", + "streaming": true, + "window_tokens": 200000 + }, + "snapshot_at": "2026-09-10T21:07:24.937271+08:00", + "created_at": "2026-09-10T21:07:32.573916+08:00", + "sequence": 6, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "cache_read_tokens": 0, + "cache_write_tokens": 0 + }, + "ordinal": 6 + } + ], + "main_question": "本轮仅做一个首页读取测试:资产是 redhaze.top,目标是读取 https://redhaze.top/ 首页并总结页面标题、HTTP 状态及可见内容。使用 Bash 的 curl 或 WebFetch 发出普通 GET;不进行扫描、漏洞验证、登录或任何写入。成功或失败都如实报告。测试标记为 BTW-REAL-0910。", + "main_activity": { + "cursor": 12, + "hasMore": false, + "items": [ + { + "seq": 6, + "worker": "auto", + "ts": "2026-09-10T21:06:40+08:00", + "kind": "user", + "is_error": false, + "summary": "本轮仅做一个首页读取测试:资产是 redhaze.top,目标是读取 https://redhaze.top/ 首页并总结页面标题、HTTP 状态及可见内容。使用 Bash 的 curl 或 WebFetch 发出普通 GET;不进行扫描、漏洞验证、登录或任何写入。成功或失败都如实报告。测试标记为 BTW-REAL-0910。" + }, + { + "seq": 7, + "worker": "auto", + "ts": "2026-09-10T21:06:49+08:00", + "kind": "text", + "is_error": false, + "summary": "按你的要求只做首页普通 GET 读取,先拉 https://redhaze.top/ 的状态和页面内容。" + }, + { + "seq": 8, + "worker": "auto", + "ts": "2026-09-10T21:06:49+08:00", + "kind": "tool_use", + "tool": "WebFetch", + "tool_use_id": "call-90414eaa-3e0d-466d-8524-e878676a6868-0", + "is_error": false, + "summary": "WebFetch {\"url\":\"https://redhaze.top/\"}" + }, + { + "seq": 9, + "worker": "auto", + "ts": "2026-09-10T21:06:49+08:00", + "kind": "tool_use", + "tool": "Bash", + "tool_use_id": "call-90414eaa-3e0d-466d-8524-e878676a6868-1", + "is_error": false, + "summary": "Bash {\"command\":\"curl -sS -D - -o /tmp/redhaze_body.html -L --max-time 20 \\\"https://redhaze.top/\\\" | head -n 40; echo \\\"----BODY_HEAD----\\\"; head -c 4000 /tmp/redhaze_body.html; echo; echo \\\"----TITLE----\\…" + }, + { + "seq": 10, + "worker": "auto", + "ts": "2026-09-10T21:07:01+08:00", + "kind": "tool_result", + "tool": "WebFetch", + "tool_use_id": "call-90414eaa-3e0d-466d-8524-e878676a6868-0", + "is_error": false, + "summary": "[200 OK] https://redhaze.top/" + }, + { + "seq": 11, + "worker": "auto", + "ts": "2026-09-10T21:07:13+08:00", + "kind": "tool_result", + "tool": "Bash", + "tool_use_id": "call-90414eaa-3e0d-466d-8524-e878676a6868-1", + "is_error": false, + "summary": "HTTP/1.1 200 Connection established\r" + }, + { + "seq": 12, + "worker": "auto", + "ts": "2026-09-10T21:07:24+08:00", + "kind": "result", + "is_error": false, + "summary": "**BTW-REAL-0910 · 首页读取测试(仅 GET)**", + "input_tokens": 0, + "output_tokens": 0, + "cache_read_tokens": 0, + "cache_write_tokens": 0 + } + ], + "running": false + }, + "transcript_sha256": "e7e61f135a4a120954b539f357e8c4205d7d5cd7460dcaf3dc0fd066463e1d00" + }, + "restart": { + "snapshot_before_new_question": "2026-09-10T13:07:24.937271Z", + "request": { + "id": "1c65b387-a172-4540-b6af-b7efe0245eb7", + "client_request_id": "66ad3fd6-9c2e-41bc-807c-5ccb6c4b8162", + "question": "服务刚重启。不要重新执行工具,告诉我此前的资产、测试标记和首页标题。", + "answer": "此前结果如下:\n\n- **资产**:redhaze.top \n- **测试标记**:BTW-REAL-0910 \n- **首页标题**:红幕科技 RedHaze Group · 全球综合集团门户", + "status": "completed", + "model": { + "profile_id": 2, + "name": "btw-real-grok-stream", + "format": "openai", + "model": "grok-4.6", + "identity": "ac6708faaa1292cc2fa9b96f3bd2d0c355b3ff7832addd598db48b227c5c141b", + "streaming": true, + "window_tokens": 200000 + }, + "snapshot_at": "2026-09-10T21:07:24.937271+08:00", + "created_at": "2026-09-10T21:09:52.132635+08:00", + "sequence": 4, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "cache_read_tokens": 0, + "cache_write_tokens": 0 + }, + "ordinal": 7 + }, + "history_count": 3, + "main_not_rerun": true + }, + "non_streaming": { + "model": "grok-4.6", + "mode": "non-streaming", + "conversation_id": 3, + "request": { + "id": "9dac734d-3935-476c-80e8-88f39f0bff9e", + "client_request_id": "b327aeca-d87d-4365-93cb-9ece35ca5375", + "question": "此前给出的资产和测试标记是什么?", + "answer": "资产:redhaze.top;测试标记:ATOMIC-0910。", + "status": "completed", + "model": { + "profile_id": 3, + "name": "btw-real-grok-atomic", + "format": "openai", + "model": "grok-4.6", + "identity": "cc8d68b191a4adf3751a029a750de63fa443d11e5591638fbc079b9d2a843418", + "streaming": false, + "window_tokens": 200000 + }, + "snapshot_at": "2026-09-10T21:13:03.766695+08:00", + "created_at": "2026-09-10T21:13:04.156515+08:00", + "sequence": 1, + "usage": { + "input_tokens": 11734, + "output_tokens": 138, + "cache_read_tokens": 11520, + "cache_write_tokens": 0 + }, + "ordinal": 8 + }, + "passed": true + }, + "stream_usage_probe": { + "http_status": 200, + "stream_include_usage_requested": true, + "data_frames": 12, + "usage_frames": [] + }, + "qwen_review": { + "http_status": 200, + "model": "qwen-flash", + "review": "```json\n{\n \"verdict\": \"accept\",\n \"supported_findings\": [\n \"测试标记 BTW-REAL-0910 明确记录在上下文中,且与主对话一致。\",\n \"首页读取任务仅执行普通 GET,未进行扫描、漏洞验证或写入操作,符合指令要求。\",\n \"工具调用结果(WebFetch 与 curl)均成功返回,且证据链完整:包含跳转路径、最终状态码 200、服务端信息 nginx/1.24.0 (Ubuntu),以及页面标题和可见内容摘要。\",\n \"页面标题 '红幕科技 RedHaze Group · 全球综合集团门户' 由 WebFetch 和 curl 解析双重验证,一致性高。\",\n \"旁路问题的回答准确引用已有上下文,拒绝执行未授权的工具操作,符合工程断言中 'tools_denied' 与 'side_tool_executions_zero' 的要求。\",\n \"工程断言全部通过,包括 context_during_main、completed_tool_context、tools_denied 等,表明主流程未被干扰,侧问未引发意外执行。\"\n ],\n \"concerns\": [],\n \"acceptance_notes\": \"回答质量高,逻辑清晰,所有结论均有直接证据支持。对工具失败、旁路请求的拒绝处理得当,未编造已完成工具结果,未遗漏关键信息。工程断言满足验收条件,可接受。\"\n}\n```", + "usage": { + "completion_tokens": 312, + "prompt_tokens": 6625, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "total_tokens": 6937 + } + } +} diff --git a/web/src/app/(main)/chat/page.tsx b/web/src/app/(main)/chat/page.tsx index 90ba8a0..8100516 100644 --- a/web/src/app/(main)/chat/page.tsx +++ b/web/src/app/(main)/chat/page.tsx @@ -21,6 +21,7 @@ import { } from "lucide-react"; import { toast } from "sonner"; +import { SideQuestionButton, SideQuestionWorkspace } from "@/components/side-question-workspace"; import { TodoPopover } from "@/components/todo-popover"; import { Transcript } from "@/components/transcript"; import { @@ -48,8 +49,10 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; +import { useSideQuestions } from "@/hooks/use-side-questions"; import { api } from "@/lib/api"; import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode"; +import { isBtwCommand } from "@/lib/side-questions"; import type { Activity, Agent, ChatAttachment, Conversation, LLMProfile } from "@/lib/types"; import { cn } from "@/lib/utils"; @@ -114,6 +117,7 @@ function Composer({ onPickFiles, onRemoveAttachment, uploading, + allowBtw, }: { value: string; onChange: (v: string) => void; @@ -129,6 +133,7 @@ function Composer({ onPickFiles?: (files: FileList | null) => void; onRemoveAttachment?: (path: string) => void; uploading?: boolean; + allowBtw?: boolean; }) { const fileInputRef = React.useRef<HTMLInputElement>(null); const atts = attachments ?? []; @@ -196,10 +201,15 @@ function Composer({ rows={1} placeholder={placeholder} value={value} - disabled={disabled} + disabled={disabled && !allowBtw} onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown} /> + {running && allowBtw && isBtwCommand(value) && ( + <Button size="icon" onClick={onSend} aria-label="发送旁路问题" title="发送旁路问题"> + <ArrowUpIcon /> + </Button> + )} {running ? ( // while a run is in flight the send button becomes a stop button — // aborts just this session (the trigger queue keeps going). @@ -451,6 +461,7 @@ function ChatView({ const [hasMore, setHasMore] = React.useState(false); // drives the "load earlier" hint const agent = agents.find((a) => a.key === conv.agent_key); const currentProfileId = conv.llm_profile_id ?? null; + const side = useSideQuestions(`/api/conversations/${conv.id}`); async function changeProfile(id: number | null) { try { @@ -641,6 +652,7 @@ function ChatView({ async function send() { const msg = input.trim(); const atts = attachments; + if (side.handleCommand(msg, () => setInput(""))) return; if ((!msg && atts.length === 0) || sending || running) return; setSending(true); setInput(""); @@ -677,7 +689,7 @@ function ChatView({ } return ( - <> + <SideQuestionWorkspace side={side} label={agent?.name ?? conv.agent_key}> {/* header: which agent + live + token meta */} <div className="flex min-w-0 flex-wrap items-center gap-2 border-b px-4 py-2.5"> <Bot className="text-muted-foreground size-4 shrink-0" /> @@ -692,6 +704,7 @@ function ChatView({ <span className="text-muted-foreground min-w-0 truncate text-xs">{agent.description}</span> )} {running && <LiveBadge />} + <SideQuestionButton side={side} /> <div className="text-muted-foreground ml-auto flex min-w-0 max-w-full items-center justify-end gap-x-3 gap-y-1 text-xs max-sm:w-full max-sm:flex-wrap"> {tokenTotal.turns > 0 && ( <span title="agent 循环轮次(模型调用次数)" className="tabular-nums"> @@ -729,7 +742,8 @@ function ChatView({ onChange={setInput} onSend={send} disabled={running} - placeholder={running ? "Agent 正在回复…" : "输入消息,Enter 发送,Shift+Enter 换行"} + allowBtw + placeholder={running ? "Agent 正在回复,可输入 /btw 提问…" : "输入消息,Enter 发送,Shift+Enter 换行"} running={running} onStop={stop} stopDisabled={stopping} @@ -745,7 +759,7 @@ function ChatView({ disabled={running || sending} rightSlot={<TodoPopover seq={latestTodoSeq} fetchDetail={fetchDetail} />} /> - </> + </SideQuestionWorkspace> ); } diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx index 3ea1ffb..eddf142 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx @@ -26,6 +26,7 @@ import { } from "lucide-react"; import { toast } from "sonner"; +import { SideQuestionButton, SideQuestionWorkspace } from "@/components/side-question-workspace"; import { TodoPopover } from "@/components/todo-popover"; import { Transcript } from "@/components/transcript"; import { @@ -45,9 +46,11 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Spinner } from "@/components/ui/spinner"; import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { useSideQuestions } from "@/hooks/use-side-questions"; import { api, sseUrl } from "@/lib/api"; import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode"; import { MOCK } from "@/lib/mock/enabled"; +import { isBtwCommand } from "@/lib/side-questions"; import { taskAssetSourceLabel, taskAssetTypeLabel } from "@/lib/task-assets"; import type { Activity, @@ -1118,6 +1121,13 @@ export function SessionsTab({ taskId }: { taskId: string }) { }; const active = sessions.find((s) => s.id === activeId) ?? MAIN_SESSION; + const side = useSideQuestions( + active.role === "mainagent" + ? `/api/tasks/${taskId}/chat` + : active.role === "worker" && !active.inherited && active.intent_id + ? `/api/tasks/${taskId}/intents/${active.intent_id}` + : null, + ); const isMain = active.role === "mainagent"; const isPlanner = active.role === "planner"; const isSystem = active.role === "system"; @@ -1310,7 +1320,8 @@ export function SessionsTab({ taskId }: { taskId: string }) { function send() { const text = input.trim(); const atts = attachments; - if ((!text && atts.length === 0) || sending) return; + if (side.handleCommand(text, () => setInput(""))) return; + if ((!text && atts.length === 0) || sending || mainLive) return; // No optimistic echo: the backend persists+broadcasts the human turn before it // returns, so it streams back over SSE (worker="mainagent") with its real DB // seq — the transcript renders it from server data like every other step. Clear @@ -1336,6 +1347,7 @@ export function SessionsTab({ taskId }: { taskId: string }) { function sendWorkerChat() { const intentId = active.intent_id; const message = workerMessage.trim(); + if (side.handleCommand(message, () => setWorkerMessage(""))) return; if (!intentId || active.inherited || active.status !== "paused" || workerMessageSending || !message) return; if (workerMessageCharCount(message) > MAX_WORKER_MESSAGE_CHARS) { toast.error(`消息不能超过 ${MAX_WORKER_MESSAGE_CHARS} 个字符`); @@ -1527,332 +1539,356 @@ export function SessionsTab({ taskId }: { taskId: string }) { </div> {/* Right: transcript */} - <div className="flex min-w-0 flex-col overflow-hidden rounded-lg border bg-card"> - <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 border-b px-3 py-2 sm:px-4 sm:py-2.5"> - {(() => { - const isWorker = active.role === "worker"; - const meta = isWorker ? sessionMeta.get(active.id) : undefined; - // Worker: the intent moved into the transcript as a message, so the - // header shows a stable generic label (intent JSON stays on hover). - const title = isWorker ? "Worker 执行会话" : active.title; - const titleEl = <span className="min-w-0 truncate text-sm font-medium">{title}</span>; - return meta?.json ? ( + <SideQuestionWorkspace + side={side} + label={active.role === "worker" ? `Worker #${active.intent_id} · ${activeDisplayTitle}` : activeDisplayTitle} + > + <div className="flex min-h-0 flex-1 min-w-0 flex-col overflow-hidden rounded-lg border bg-card"> + <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 border-b px-3 py-2 sm:px-4 sm:py-2.5"> + {(() => { + const isWorker = active.role === "worker"; + const meta = isWorker ? sessionMeta.get(active.id) : undefined; + // Worker: the intent moved into the transcript as a message, so the + // header shows a stable generic label (intent JSON stays on hover). + const title = isWorker ? "Worker 执行会话" : active.title; + const titleEl = <span className="min-w-0 truncate text-sm font-medium">{title}</span>; + return meta?.json ? ( + <Tooltip> + <TooltipTrigger asChild>{titleEl}</TooltipTrigger> + <TooltipContent side="bottom" align="start" className="max-h-80 max-w-sm overflow-auto p-0"> + <pre className="p-2 text-[10px] leading-relaxed">{JSON.stringify(meta.json, null, 2)}</pre> + </TooltipContent> + </Tooltip> + ) : ( + titleEl + ); + })()} + <SideQuestionButton side={side} /> + {activeResolution && ( <Tooltip> - <TooltipTrigger asChild>{titleEl}</TooltipTrigger> - <TooltipContent side="bottom" align="start" className="max-h-80 max-w-sm overflow-auto p-0"> - <pre className="p-2 text-[10px] leading-relaxed">{JSON.stringify(meta.json, null, 2)}</pre> - </TooltipContent> - </Tooltip> - ) : ( - titleEl - ); - })()} - {activeResolution && ( - <Tooltip> - <TooltipTrigger asChild> - <Badge - variant="outline" - className="max-w-28 shrink-0 font-normal" - aria-label={ - activeResolution.available ? `当前配置:${resolutionLabel(activeResolution)}` : "模型不可用" - } - > - <span className="truncate"> - {activeResolution.available ? resolutionLabel(activeResolution) : "模型不可用"} - </span> - </Badge> - </TooltipTrigger> - <TooltipContent side="bottom" className="max-w-xs [overflow-wrap:anywhere]"> - {activeResolution.available - ? [resolutionLabel(activeResolution), activeResolution.model].filter(Boolean).join(" / ") - : activeResolution.reason || "没有可用的 LLM 配置"} - </TooltipContent> - </Tooltip> - )} - {activeAssets && activeAssets.length > 0 && <WorkerAssetBadge assets={activeAssets} />} - {active.inherited && active.source_task_id && ( - <Badge variant="outline">来源任务 #{active.source_task_id} · 只读历史</Badge> - )} - {active.live && ( - <span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400"> - <span className="size-1 animate-pulse rounded-full bg-blue-500" /> - 实时 - </span> - )} - {activeState?.hasMore && ( - <span className="text-[10px] text-muted-foreground" title="向上滚动加载更早历史"> - ↑ 更早历史 - </span> - )} - <div className="ml-auto flex min-w-0 max-w-full items-center justify-end gap-x-3 gap-y-1 text-xs text-muted-foreground max-sm:w-full max-sm:flex-wrap"> - {tokenTotal.any && ( - <Tooltip> - {/* 手机端用短标签(入/缓/出):长标签会把这一行撑成两行,进一步压缩记录区。 */} <TooltipTrigger asChild> - <span className="inline-flex min-w-0 items-center"> - <TokenMetrics - input={tokenTotal.i} - cache={tokenTotal.cr} - output={tokenTotal.o} - labels="short" - className="sm:hidden" - /> - <TokenMetrics - input={tokenTotal.i} - cache={tokenTotal.cr} - output={tokenTotal.o} - labels="long" - className="max-sm:hidden" - /> - </span> + <Badge + variant="outline" + className="max-w-28 shrink-0 font-normal" + aria-label={ + activeResolution.available ? `当前配置:${resolutionLabel(activeResolution)}` : "模型不可用" + } + > + <span className="truncate"> + {activeResolution.available ? resolutionLabel(activeResolution) : "模型不可用"} + </span> + </Badge> </TooltipTrigger> - <TooltipContent> - 输入 {activeTokens.input_tokens.toLocaleString()} · 输出{" "} - {activeTokens.output_tokens.toLocaleString()} · 缓存读取{" "} - {activeTokens.cache_read_tokens.toLocaleString()} · 缓存写入{" "} - {activeTokens.cache_write_tokens.toLocaleString()} + <TooltipContent side="bottom" className="max-w-xs [overflow-wrap:anywhere]"> + {activeResolution.available + ? [resolutionLabel(activeResolution), activeResolution.model].filter(Boolean).join(" / ") + : activeResolution.reason || "没有可用的 LLM 配置"} </TooltipContent> </Tooltip> )} - {runDuration != null && ( - <span className="inline-flex items-center gap-1" title="运行时长(首步 → 末步)"> - <ClockIcon className="size-3" /> - {fmtDuration(runDuration)} + {activeAssets && activeAssets.length > 0 && <WorkerAssetBadge assets={activeAssets} />} + {active.inherited && active.source_task_id && ( + <Badge variant="outline">来源任务 #{active.source_task_id} · 只读历史</Badge> + )} + {active.live && ( + <span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400"> + <span className="size-1 animate-pulse rounded-full bg-blue-500" /> + 实时 </span> )} - {isMain && <span>可交互</span>} - </div> - </div> - {(() => { - const dm = active.role === "worker" ? sessionMeta.get(active.id) : undefined; - if (!dm?.deleted) return null; - return ( - <div className="flex items-start gap-2 border-b border-destructive/30 bg-destructive/5 px-4 py-2.5 text-xs"> - <Trash2Icon className="mt-0.5 size-3.5 shrink-0 text-destructive" /> - <div className="min-w-0"> - <span className="font-medium text-destructive">此意图已被用户删除</span> - <span className="text-muted-foreground"> - (已停止执行,规划者已收到通知;意图与产出保留,可在下方查看历史) + {activeState?.hasMore && ( + <span className="text-[10px] text-muted-foreground" title="向上滚动加载更早历史"> + ↑ 更早历史 + </span> + )} + <div className="ml-auto flex min-w-0 max-w-full items-center justify-end gap-x-3 gap-y-1 text-xs text-muted-foreground max-sm:w-full max-sm:flex-wrap"> + {tokenTotal.any && ( + <Tooltip> + {/* 手机端用短标签(入/缓/出):长标签会把这一行撑成两行,进一步压缩记录区。 */} + <TooltipTrigger asChild> + <span className="inline-flex min-w-0 items-center"> + <TokenMetrics + input={tokenTotal.i} + cache={tokenTotal.cr} + output={tokenTotal.o} + labels="short" + className="sm:hidden" + /> + <TokenMetrics + input={tokenTotal.i} + cache={tokenTotal.cr} + output={tokenTotal.o} + labels="long" + className="max-sm:hidden" + /> + </span> + </TooltipTrigger> + <TooltipContent> + 输入 {activeTokens.input_tokens.toLocaleString()} · 输出{" "} + {activeTokens.output_tokens.toLocaleString()} · 缓存读取{" "} + {activeTokens.cache_read_tokens.toLocaleString()} · 缓存写入{" "} + {activeTokens.cache_write_tokens.toLocaleString()} + </TooltipContent> + </Tooltip> + )} + {runDuration != null && ( + <span className="inline-flex items-center gap-1" title="运行时长(首步 → 末步)"> + <ClockIcon className="size-3" /> + {fmtDuration(runDuration)} </span> - {dm.deleteReason && ( - <p className="mt-1 break-words text-foreground"> - <span className="text-muted-foreground">删除原因:</span> - {dm.deleteReason} - </p> - )} - </div> + )} + {isMain && <span>可交互</span>} </div> - ); - })()} - {/* Force Radix's internal viewport wrapper (display:table, sizes to content) + </div> + {(() => { + const dm = active.role === "worker" ? sessionMeta.get(active.id) : undefined; + if (!dm?.deleted) return null; + return ( + <div className="flex items-start gap-2 border-b border-destructive/30 bg-destructive/5 px-4 py-2.5 text-xs"> + <Trash2Icon className="mt-0.5 size-3.5 shrink-0 text-destructive" /> + <div className="min-w-0"> + <span className="font-medium text-destructive">此意图已被用户删除</span> + <span className="text-muted-foreground"> + (已停止执行,规划者已收到通知;意图与产出保留,可在下方查看历史) + </span> + {dm.deleteReason && ( + <p className="mt-1 break-words text-foreground"> + <span className="text-muted-foreground">删除原因:</span> + {dm.deleteReason} + </p> + )} + </div> + </div> + ); + })()} + {/* Force Radix's internal viewport wrapper (display:table, sizes to content) to block so wide/unbreakable steps (long commands, code, URLs) can't blow out the width and defeat the truncation below — the transcript wraps to the panel instead of overflowing horizontally. */} - <ScrollArea type="auto" className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!"> - <div className="min-w-0 max-w-full p-4" ref={contentRef}> - {activeState?.loadingMore && ( - <div className="flex items-center justify-center gap-2 pb-2 text-xs text-muted-foreground"> - <Loader2Icon className="size-3.5 animate-spin" /> - 加载更早历史… - </div> - )} - {showLoader ? ( - <div className="flex items-center gap-2 pl-9 text-xs text-muted-foreground"> - <Loader2Icon className="size-3.5 animate-spin" /> - 加载活动流… - </div> - ) : activeState?.error ? ( - <div className="flex items-center gap-2 pl-9 text-xs text-red-500"> - <CircleXIcon className="size-3.5" /> - 加载失败:{activeState.error} - <Button size="sm" variant="ghost" className="h-6 px-2 text-xs" onClick={() => loadSession(activeKey)}> - 重试 - </Button> - </div> - ) : activity.length ? ( - <Transcript activity={activity} live={active.live} taskId={taskId} chat={isMain} /> - ) : ( - <div className="pl-9 text-xs text-muted-foreground"> - {isMain ? "还没有对话。在下方给主 Agent 发消息,引导探索方向或介入流程。" : "暂无活动记录。"} - </div> - )} - </div> - </ScrollArea> - {isMain ? ( - <div className="border-t p-3"> - {attachments.length > 0 && ( - <div className="mb-2 flex flex-wrap gap-1.5"> - {attachments.map((a) => ( - <div - key={a.path} - className="flex items-center gap-1.5 rounded-md border bg-muted/50 px-2 py-1 text-xs" - title={a.path} + <ScrollArea type="auto" className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!"> + <div className="min-w-0 max-w-full p-4" ref={contentRef}> + {activeState?.loadingMore && ( + <div className="flex items-center justify-center gap-2 pb-2 text-xs text-muted-foreground"> + <Loader2Icon className="size-3.5 animate-spin" /> + 加载更早历史… + </div> + )} + {showLoader ? ( + <div className="flex items-center gap-2 pl-9 text-xs text-muted-foreground"> + <Loader2Icon className="size-3.5 animate-spin" /> + 加载活动流… + </div> + ) : activeState?.error ? ( + <div className="flex items-center gap-2 pl-9 text-xs text-red-500"> + <CircleXIcon className="size-3.5" /> + 加载失败:{activeState.error} + <Button + size="sm" + variant="ghost" + className="h-6 px-2 text-xs" + onClick={() => loadSession(activeKey)} > - <PaperclipIcon className="size-3 shrink-0 text-primary" /> - <span className="max-w-[160px] truncate">{a.name}</span> - <span className="text-muted-foreground">{fmtBytes(a.size)}</span> - <button - type="button" - className="ml-0.5 text-muted-foreground hover:text-foreground" - onClick={() => setAttachments((p) => p.filter((x) => x.path !== a.path))} - title="移除" + 重试 + </Button> + </div> + ) : activity.length ? ( + <Transcript activity={activity} live={active.live} taskId={taskId} chat={isMain} /> + ) : ( + <div className="pl-9 text-xs text-muted-foreground"> + {isMain ? "还没有对话。在下方给主 Agent 发消息,引导探索方向或介入流程。" : "暂无活动记录。"} + </div> + )} + </div> + </ScrollArea> + {isMain ? ( + <div className="border-t p-3"> + {attachments.length > 0 && ( + <div className="mb-2 flex flex-wrap gap-1.5"> + {attachments.map((a) => ( + <div + key={a.path} + className="flex items-center gap-1.5 rounded-md border bg-muted/50 px-2 py-1 text-xs" + title={a.path} > - <XIcon className="size-3" /> - </button> - </div> - ))} - </div> - )} - <input - ref={fileInputRef} - type="file" - multiple - className="hidden" - onChange={(e) => void pickFiles(e.target.files)} - /> - <InputGroup className="min-h-9 has-disabled:opacity-100"> - <InputGroupTextarea - rows={1} - aria-label="给主 Agent 发消息" - placeholder="给主 Agent 发消息,引导探索方向…" - value={input} - onChange={(e) => setInput(e.target.value)} - onKeyDown={(e) => { - if (!shouldSubmitOnKey(e, sendMode)) return; - e.preventDefault(); - if (!mainLive) send(); - }} - disabled={mainLive} - className="max-h-36 min-h-9 overflow-y-auto" + <PaperclipIcon className="size-3 shrink-0 text-primary" /> + <span className="max-w-[160px] truncate">{a.name}</span> + <span className="text-muted-foreground">{fmtBytes(a.size)}</span> + <button + type="button" + className="ml-0.5 text-muted-foreground hover:text-foreground" + onClick={() => setAttachments((p) => p.filter((x) => x.path !== a.path))} + title="移除" + > + <XIcon className="size-3" /> + </button> + </div> + ))} + </div> + )} + <input + ref={fileInputRef} + type="file" + multiple + className="hidden" + onChange={(e) => void pickFiles(e.target.files)} /> - <InputGroupAddon align="block-end"> - <InputGroupButton - size="icon-xs" - variant="ghost" - onClick={() => fileInputRef.current?.click()} - disabled={mainLive || uploading} - title="上传文件" - aria-label="上传文件" - > - {uploading ? <Loader2Icon className="animate-spin" /> : <PaperclipIcon />} - </InputGroupButton> - {mainLive ? ( + <InputGroup className="min-h-9 has-disabled:opacity-100"> + <InputGroupTextarea + rows={1} + aria-label="给主 Agent 发消息" + placeholder={mainLive ? "主 Agent 正在运行,可输入 /btw 提问…" : "给主 Agent 发消息,引导探索方向…"} + value={input} + onChange={(e) => setInput(e.target.value)} + onKeyDown={(e) => { + if (!shouldSubmitOnKey(e, sendMode)) return; + e.preventDefault(); + send(); + }} + className="max-h-36 min-h-9 overflow-y-auto" + /> + <InputGroupAddon align="block-end"> <InputGroupButton - className="ml-auto" size="icon-xs" - variant="destructive" - onClick={stop} - disabled={stopping} - title="停止当前执行" - aria-label="停止当前执行" + variant="ghost" + onClick={() => fileInputRef.current?.click()} + disabled={mainLive || uploading} + title="上传文件" + aria-label="上传文件" > - {stopping ? <Loader2Icon className="animate-spin" /> : <SquareIcon />} + {uploading ? <Loader2Icon className="animate-spin" /> : <PaperclipIcon />} </InputGroupButton> - ) : ( - <InputGroupButton - className="ml-auto" - size="icon-xs" - variant="default" - onClick={send} - disabled={(!input.trim() && attachments.length === 0) || sending} - title="发送消息" - aria-label="发送消息" - > - {sending ? <Loader2Icon className="animate-spin" /> : <ArrowUpIcon />} - </InputGroupButton> - )} - </InputGroupAddon> - </InputGroup> - </div> - ) : active.role === "worker" && - !active.inherited && - (active.status === "running" || active.status === "paused") ? ( - <div className="border-t p-3"> - <InputGroup className="min-h-9 has-disabled:opacity-100"> - <InputGroupTextarea - rows={1} - aria-label={`给 Worker #${active.intent_id} 发消息`} - placeholder={`给 Worker #${active.intent_id} 发消息,调整执行方向…`} - value={workerMessage} - onChange={(event) => { - setWorkerMessage(event.target.value); - setWorkerMessageRequestId(""); - }} - onKeyDown={(event) => { - if (!shouldSubmitOnKey(event, sendMode)) return; - event.preventDefault(); - sendWorkerChat(); - }} - disabled={active.status === "running" || workerMessageSending} - aria-invalid={workerMessageCharCount(workerMessage) > MAX_WORKER_MESSAGE_CHARS} - className="max-h-36 min-h-9 overflow-y-auto" - /> - <InputGroupAddon align="block-end"> - <span - className={cn( - "px-1 text-[10px] tabular-nums text-muted-foreground", - workerMessageCharCount(workerMessage) > MAX_WORKER_MESSAGE_CHARS && "text-destructive", + {mainLive && isBtwCommand(input) && ( + <InputGroupButton size="icon-xs" onClick={send} aria-label="发送旁路问题"> + <ArrowUpIcon /> + </InputGroupButton> )} - > - {workerMessageCharCount(workerMessage)}/{MAX_WORKER_MESSAGE_CHARS} - </span> - {active.status === "running" ? ( - <InputGroupButton - className="ml-auto" - size="icon-xs" - variant="destructive" - onClick={() => void controlWorker(active, "pause")} - disabled={controllingIntent === active.intent_id} - title="暂停当前 Worker" - aria-label="暂停当前 Worker" - > - {controllingIntent === active.intent_id ? ( - <Loader2Icon className="animate-spin" /> - ) : ( - <SquareIcon /> - )} - </InputGroupButton> - ) : ( - <> + {mainLive ? ( <InputGroupButton className="ml-auto" - size="xs" - variant="ghost" - onClick={() => void controlWorker(active, "resume")} - disabled={controllingIntent === active.intent_id || workerMessageSending} - title="不发消息,直接继续执行" - aria-label="直接继续执行" + size="icon-xs" + variant="destructive" + onClick={stop} + disabled={stopping} + title="停止当前执行" + aria-label="停止当前执行" > - {controllingIntent === active.intent_id ? <Loader2Icon className="animate-spin" /> : "直接继续"} + {stopping ? <Loader2Icon className="animate-spin" /> : <SquareIcon />} </InputGroupButton> + ) : ( <InputGroupButton + className="ml-auto" size="icon-xs" variant="default" - onClick={sendWorkerChat} - disabled={ - workerMessageSending || - !workerMessage.trim() || - workerMessageCharCount(workerMessage) > MAX_WORKER_MESSAGE_CHARS - } + onClick={send} + disabled={(!input.trim() && attachments.length === 0) || sending} title="发送消息" aria-label="发送消息" > - {workerMessageSending ? <Spinner /> : <ArrowUpIcon />} + {sending ? <Loader2Icon className="animate-spin" /> : <ArrowUpIcon />} </InputGroupButton> - </> - )} - </InputGroupAddon> - </InputGroup> - </div> - ) : ( - <div className="flex items-center border-t px-4 py-2"> - <TodoPopover - seq={latestTodoSeq} - fetchDetail={(seq) => api.activityDetail(seq, taskId).then((r) => r.detail ?? "")} - /> - </div> - )} - </div> + )} + </InputGroupAddon> + </InputGroup> + </div> + ) : active.role === "worker" && + !active.inherited && + (active.status === "running" || active.status === "paused") ? ( + <div className="border-t p-3"> + <InputGroup className="min-h-9 has-disabled:opacity-100"> + <InputGroupTextarea + rows={1} + aria-label={`给 Worker #${active.intent_id} 发消息`} + placeholder={`给 Worker #${active.intent_id} 发消息,调整执行方向…`} + value={workerMessage} + onChange={(event) => { + setWorkerMessage(event.target.value); + setWorkerMessageRequestId(""); + }} + onKeyDown={(event) => { + if (!shouldSubmitOnKey(event, sendMode)) return; + event.preventDefault(); + sendWorkerChat(); + }} + disabled={workerMessageSending} + aria-invalid={workerMessageCharCount(workerMessage) > MAX_WORKER_MESSAGE_CHARS} + className="max-h-36 min-h-9 overflow-y-auto" + /> + <InputGroupAddon align="block-end"> + <span + className={cn( + "px-1 text-[10px] tabular-nums text-muted-foreground", + workerMessageCharCount(workerMessage) > MAX_WORKER_MESSAGE_CHARS && "text-destructive", + )} + > + {workerMessageCharCount(workerMessage)}/{MAX_WORKER_MESSAGE_CHARS} + </span> + {active.status === "running" && isBtwCommand(workerMessage) && ( + <InputGroupButton size="icon-xs" onClick={sendWorkerChat} aria-label="发送旁路问题"> + <ArrowUpIcon /> + </InputGroupButton> + )} + {active.status === "running" ? ( + <InputGroupButton + className="ml-auto" + size="icon-xs" + variant="destructive" + onClick={() => void controlWorker(active, "pause")} + disabled={controllingIntent === active.intent_id} + title="暂停当前 Worker" + aria-label="暂停当前 Worker" + > + {controllingIntent === active.intent_id ? ( + <Loader2Icon className="animate-spin" /> + ) : ( + <SquareIcon /> + )} + </InputGroupButton> + ) : ( + <> + <InputGroupButton + className="ml-auto" + size="xs" + variant="ghost" + onClick={() => void controlWorker(active, "resume")} + disabled={controllingIntent === active.intent_id || workerMessageSending} + title="不发消息,直接继续执行" + aria-label="直接继续执行" + > + {controllingIntent === active.intent_id ? ( + <Loader2Icon className="animate-spin" /> + ) : ( + "直接继续" + )} + </InputGroupButton> + <InputGroupButton + size="icon-xs" + variant="default" + onClick={sendWorkerChat} + disabled={ + workerMessageSending || + !workerMessage.trim() || + workerMessageCharCount(workerMessage) > MAX_WORKER_MESSAGE_CHARS + } + title="发送消息" + aria-label="发送消息" + > + {workerMessageSending ? <Spinner /> : <ArrowUpIcon />} + </InputGroupButton> + </> + )} + </InputGroupAddon> + </InputGroup> + </div> + ) : ( + <div className="flex items-center border-t px-4 py-2"> + <TodoPopover + seq={latestTodoSeq} + fetchDetail={(seq) => api.activityDetail(seq, taskId).then((r) => r.detail ?? "")} + /> + </div> + )} + </div> + </SideQuestionWorkspace> <AlertDialog open={cancelIntent !== null} onOpenChange={(open) => { diff --git a/web/src/components/side-question-workspace.tsx b/web/src/components/side-question-workspace.tsx new file mode 100644 index 0000000..439ef66 --- /dev/null +++ b/web/src/components/side-question-workspace.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { type ReactNode, useEffect, useRef, useState } from "react"; + +import { ArrowUpIcon, MessageCircleQuestionIcon, SquareIcon, Trash2Icon, XIcon } from "lucide-react"; + +import { Markdown } from "@/components/markdown"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from "@/components/ui/drawer"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useIsMobile } from "@/hooks/use-mobile"; +import type { SideQuestions } from "@/hooks/use-side-questions"; + +export function SideQuestionButton({ side }: { side: SideQuestions }) { + if (!side.enabled) return null; + return ( + <Button variant="outline" size="sm" onClick={() => side.setOpen(true)} title="/btw 旁路提问"> + <MessageCircleQuestionIcon data-icon="inline-start" /> + 旁路提问 + </Button> + ); +} + +function SidePanel({ side, label }: { side: SideQuestions; label: string }) { + const [confirm, setConfirm] = useState(false); + const viewport = useRef<HTMLDivElement>(null); + const pinned = useRef(true); + const tail = side.items.at(-1); + // biome-ignore lint/correctness/useExhaustiveDependencies: New cumulative text scrolls only readers who remain at the bottom. + useEffect(() => { + if (pinned.current && viewport.current) viewport.current.scrollTop = viewport.current.scrollHeight; + }, [tail?.answer, tail?.id]); + const status = { running: "回答中", completed: "已完成", failed: "失败", cancelled: "已停止", interrupted: "已中断" }; + return ( + <section className="flex h-full min-h-0 flex-col bg-background" aria-label="旁路提问面板"> + <div className="flex items-center gap-2 border-b p-3"> + <div className="min-w-0 flex-1"> + <p className="font-medium"> + 旁路提问 <span className="text-muted-foreground">/btw</span> + </p> + <p className="truncate text-muted-foreground text-xs">{label}</p> + </div> + <Button + variant="ghost" + size="icon-sm" + onClick={() => setConfirm(true)} + disabled={!side.items.length || side.busy} + aria-label="清空旁路历史" + > + <Trash2Icon /> + </Button> + <Button variant="ghost" size="icon-sm" onClick={() => side.setOpen(false)} aria-label="关闭旁路面板"> + <XIcon /> + </Button> + </div> + <div className="border-b px-3 py-2 text-muted-foreground text-xs"> + {side.snapshot ? ( + <> + <p>{side.snapshot.model.model}</p> + <p>上下文更新于 {new Date(side.snapshot.captured_at).toLocaleString()}</p> + </> + ) : ( + "主 Agent 首次运行后即可提问" + )} + </div> + <div + ref={viewport} + onScroll={(event) => { + const el = event.currentTarget; + pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80; + }} + className="min-h-0 flex-1 overflow-y-auto p-3" + > + {side.nextCursor > 0 && ( + <Button variant="ghost" size="sm" onClick={() => void side.load(side.nextCursor)}> + 加载更早的旁路问答 + </Button> + )} + {side.loading && <Skeleton className="h-16 w-full" />} + {!side.loading && side.items.length === 0 && ( + <Empty> + <EmptyHeader> + <EmptyTitle>随时问一个问题</EmptyTitle> + <EmptyDescription>根据当前 Agent 的上下文回答,主任务继续运行。</EmptyDescription> + </EmptyHeader> + </Empty> + )} + <div className="flex flex-col gap-5"> + {side.items.map((item) => ( + <article key={item.id} className="flex min-w-0 flex-col gap-2"> + <div className="whitespace-pre-wrap break-words rounded-lg bg-muted p-3 text-sm">{item.question}</div> + <div className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs"> + <Badge variant="secondary">{status[item.status]}</Badge> + <span className="truncate">{item.model.model}</span> + <time dateTime={item.snapshot_at} title={new Date(item.snapshot_at).toLocaleString()}> + 上下文 {new Date(item.snapshot_at).toLocaleTimeString()} + </time> + </div> + {item.answer && <Markdown text={item.answer} />} + {!item.answer && item.status === "running" && ( + <p role="status" className="text-muted-foreground text-sm"> + 正在回答… + </p> + )} + {item.error && ( + <Alert variant="destructive"> + <AlertDescription>{item.error}</AlertDescription> + </Alert> + )} + </article> + ))} + </div> + </div> + {(side.error || side.snapshot?.reason) && ( + <Alert variant="destructive"> + <AlertDescription>{side.error || side.snapshot?.reason}</AlertDescription> + </Alert> + )} + <div className="border-t p-3"> + <InputGroup> + <InputGroupTextarea + aria-label="旁路问题" + placeholder="询问当前上下文…" + value={side.draft} + maxLength={4000} + disabled={side.busy} + onChange={(event) => side.setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { + event.preventDefault(); + void side.ask(side.draft); + } + }} + /> + <InputGroupAddon align="block-end"> + <span className="text-muted-foreground text-xs">独立问答 · 无工具执行</span> + {side.running ? ( + <InputGroupButton + className="ml-auto" + variant="destructive" + size="icon-xs" + onClick={() => void side.stop()} + aria-label="停止旁路回答" + > + <SquareIcon /> + </InputGroupButton> + ) : ( + <InputGroupButton + className="ml-auto" + variant="default" + size="icon-xs" + onClick={() => void side.ask(side.draft)} + disabled={side.busy || !side.draft.trim() || !side.snapshot?.available} + aria-label="发送旁路问题" + > + <ArrowUpIcon /> + </InputGroupButton> + )} + </InputGroupAddon> + </InputGroup> + </div> + <AlertDialog open={confirm} onOpenChange={setConfirm}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>清空旁路历史?</AlertDialogTitle> + <AlertDialogDescription> + 删除当前 Agent 的旁路问答,并停止正在生成的旁路回答。主会话和上下文快照会保留。 + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>取消</AlertDialogCancel> + <AlertDialogAction onClick={() => void side.clear()}>清空历史</AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </section> + ); +} + +export function SideQuestionWorkspace({ + side, + label, + children, +}: { + side: SideQuestions; + label: string; + children: ReactNode; +}) { + const mobile = useIsMobile(); + return ( + <> + <ResizablePanelGroup orientation="horizontal" className="min-h-0 min-w-0 flex-1"> + <ResizablePanel id="main-conversation" minSize="35%" className="flex min-h-0 min-w-0 flex-col"> + {children} + </ResizablePanel> + {side.open && side.enabled && !mobile && ( + <> + <ResizableHandle withHandle /> + <ResizablePanel id="side-question" defaultSize="38%" minSize="280px" maxSize="65%"> + <SidePanel side={side} label={label} /> + </ResizablePanel> + </> + )} + </ResizablePanelGroup> + <Drawer open={mobile && side.open && side.enabled} onOpenChange={side.setOpen}> + <DrawerContent className="h-[85svh]"> + <DrawerHeader className="sr-only"> + <DrawerTitle>旁路提问</DrawerTitle> + <DrawerDescription>{label} 的独立问答</DrawerDescription> + </DrawerHeader> + <SidePanel side={side} label={label} /> + </DrawerContent> + </Drawer> + </> + ); +} diff --git a/web/src/hooks/use-side-questions.ts b/web/src/hooks/use-side-questions.ts new file mode 100644 index 0000000..2bb1429 --- /dev/null +++ b/web/src/hooks/use-side-questions.ts @@ -0,0 +1,224 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { toast } from "sonner"; + +import { sseUrl } from "@/lib/api"; +import { isBtwCommand, type SideExchange, type SideHistory, sideAPI } from "@/lib/side-questions"; + +function merge(old: SideExchange[], incoming: SideExchange[]) { + const byID = new Map(old.map((item) => [item.id, item])); + for (const item of incoming) { + if ((byID.get(item.id)?.sequence ?? -1) <= item.sequence) byID.set(item.id, item); + } + return [...byID.values()].sort((a, b) => a.ordinal - b.ordinal); +} + +export function useSideQuestions(parent: string | null) { + const [stateParent, setStateParent] = useState(parent); + const current = stateParent === parent; + const [open, setOpen] = useState(false); + const [items, setItems] = useState<SideExchange[]>([]); + const [snapshot, setSnapshot] = useState<SideHistory["snapshot"]>(null); + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(false); + const [loading, setLoading] = useState(false); + const [nextCursor, setNextCursor] = useState(0); + const [error, setError] = useState(""); + const epoch = useRef(0); + const [streamEpoch, setStreamEpoch] = useState(0); + const cursor = useRef(0); + const submitting = useRef(false); + const retry = useRef<{ question: string; id: string } | null>(null); + const running = current ? items.find((item) => item.status === "running") : undefined; + const runningID = running?.id; + + const load = useCallback( + async (before = 0) => { + if (!parent) return; + const version = epoch.current; + try { + const data = await sideAPI.history(parent, before); + if (version !== epoch.current) return; + setItems((old) => merge(old, data.items)); + setSnapshot(data.snapshot); + setError(""); + if (before || !cursor.current) { + cursor.current = data.next_cursor; + setNextCursor(data.next_cursor); + } + } catch (err) { + if (version === epoch.current) setError((err as Error).message); + } + }, + [parent], + ); + + useEffect(() => { + epoch.current++; + setStateParent(parent); + setItems([]); + setSnapshot(null); + setNextCursor(0); + setDraft(""); + setError(""); + setBusy(false); + submitting.current = false; + retry.current = null; + cursor.current = 0; + if (!parent) { + setOpen(false); + setLoading(false); + return; + } + const version = epoch.current; + setLoading(true); + void sideAPI + .history(parent) + .then((data) => { + if (version !== epoch.current) return; + setItems((old) => merge(old, data.items)); + setSnapshot(data.snapshot); + setNextCursor(data.next_cursor); + cursor.current = data.next_cursor; + }) + .catch((err: Error) => { + if (version === epoch.current) setError(err.message); + }) + .finally(() => { + if (version === epoch.current) setLoading(false); + }); + return () => { + epoch.current++; + }; + }, [parent]); + + useEffect(() => { + if (!open || !parent) return; + void load(); + const timer = setInterval(() => void load(), 2000); + return () => clearInterval(timer); + }, [open, parent, load]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: Reconnect after a clear attempt invalidates older callbacks. + useEffect(() => { + if (!runningID) return; + const version = epoch.current; + const stream = new EventSource(sseUrl(`/api/side-questions/${runningID}/events`)); + stream.addEventListener("snapshot", (event) => { + if (version !== epoch.current) return; + try { + const item = JSON.parse((event as MessageEvent).data) as SideExchange; + if (item.id !== runningID) return; + setItems((old) => merge(old, [item])); + if (item.status !== "running") stream.close(); + } catch { + setError("旁路数据解析失败,请重新打开面板"); + } + }); + stream.addEventListener("cleared", () => { + stream.close(); + if (version === epoch.current) setItems([]); + }); + return () => stream.close(); + }, [runningID, streamEpoch]); + + const ask = async (input: string) => { + const question = input.trim(); + if (!parent || !question || submitting.current || running) return; + const version = epoch.current; + submitting.current = true; + setBusy(true); + setDraft(question); + setOpen(true); + if (retry.current?.question !== question) retry.current = { question, id: crypto.randomUUID() }; + try { + const item = await sideAPI.ask(parent, question, retry.current.id); + if (version !== epoch.current) return; + setItems((old) => merge(old, [item])); + setDraft(""); + setError(""); + retry.current = null; + void load(); + } catch (err) { + if (version === epoch.current) { + setError((err as Error).message); + toast.error((err as Error).message); + } + } finally { + if (version === epoch.current) { + submitting.current = false; + setBusy(false); + } + } + }; + + const handleCommand = (text: string, clear: () => void) => { + if (!parent || !isBtwCommand(text)) return false; + const question = text.trim().slice(4).trim(); + setOpen(true); + clear(); + if (question) { + setDraft(question); + void ask(question); + } + return true; + }; + + const clear = async () => { + if (!parent || submitting.current) return; + submitting.current = true; + setBusy(true); + const version = ++epoch.current; + try { + await sideAPI.clear(parent); + if (version !== epoch.current) return; + setItems([]); + setNextCursor(0); + cursor.current = 0; + setError(""); + retry.current = null; + } catch (err) { + if (version === epoch.current) toast.error((err as Error).message); + } finally { + if (version === epoch.current) { + submitting.current = false; + setBusy(false); + setStreamEpoch((value) => value + 1); + void load(); + } + } + }; + + const stop = async () => { + if (running) { + try { + await sideAPI.cancel(running.id); + } catch (err) { + toast.error((err as Error).message); + } + } + }; + return { + open, + setOpen, + items: current ? items : [], + snapshot: current ? snapshot : null, + draft: current ? draft : "", + setDraft, + busy: current && busy, + loading: !current || loading, + error: current ? error : "", + running, + nextCursor: current ? nextCursor : 0, + load, + ask, + handleCommand, + clear, + stop, + enabled: !!parent, + }; +} + +export type SideQuestions = ReturnType<typeof useSideQuestions>; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 87eac85..49f5142 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -91,7 +91,7 @@ function getToken(): string | null { return localStorage.getItem("artex_token"); } -async function http<T>(path: string, init?: RequestInit): Promise<T> { +export async function http<T>(path: string, init?: RequestInit): Promise<T> { if (MOCK) return mockHandle<T>(init?.method ?? "GET", path, init?.body ?? null); const token = getToken(); const r = await fetch(`/api${path}`, { diff --git a/web/src/lib/side-questions.ts b/web/src/lib/side-questions.ts new file mode 100644 index 0000000..7072064 --- /dev/null +++ b/web/src/lib/side-questions.ts @@ -0,0 +1,43 @@ +import { http } from "@/lib/api"; + +export interface SideModel { + model: string; + name: string; + format: string; + profile_id: number; +} +export interface SideExchange { + id: string; + ordinal: number; + client_request_id: string; + question: string; + answer: string; + status: "running" | "completed" | "failed" | "cancelled" | "interrupted"; + error?: string; + model: SideModel; + snapshot_at: string; + created_at: string; + sequence: number; +} +export interface SideHistory { + items: SideExchange[]; + current: SideExchange | null; + next_cursor: number; + snapshot: { captured_at: string; model: SideModel; available: boolean; reason: string } | null; +} + +async function request<T>(path: string, method = "GET", body?: unknown): Promise<T> { + return http<T>(path.replace(/^\/api/, ""), { method, body: body === undefined ? undefined : JSON.stringify(body) }); +} + +export const sideAPI = { + history: (parent: string, before = 0) => request<SideHistory>(`${parent}/side-questions?before=${before}`), + ask: (parent: string, question: string, client_request_id: string) => + request<SideExchange>(`${parent}/side-questions`, "POST", { question, client_request_id }), + clear: (parent: string) => request(`${parent}/side-questions`, "DELETE"), + cancel: (id: string) => request(`/api/side-questions/${id}/cancel`, "POST"), +}; + +export function isBtwCommand(text: string) { + return /^\/btw(?:\s|$)/.test(text.trim()); +}