feat(agent): 支持持久化 /btw 旁路提问

This commit is contained in:
ji ruo committed 2026-09-10 21:42:25 +08:00
1 parent 8dae851b9b
commit 555539bc8e
34 files changed
+3744 -314

No files matched your search

+4
View File
@@ -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);
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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
+23
View File
@@ -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
}
+122
View File
@@ -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")
}
})
}
}
+1
View File
@@ -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)