feat(agent): 任务内主 Agent 支持多会话,可切换且上下文可重置

主 Agent 会话原为一个任务恒定单例,对话历史随 transcript 只增不减、
上下文越滚越大。现引入会话分段(seq):新建会话切一个干净的 transcript
段,任务的图谱/资产/目标共享保留,旧段以只读历史随时切回查看。

- db: 新增 main_sessions 表与 activity.main_seg 列(ALTER ADD COLUMN
  IF NOT EXISTS 兼容旧库);ActivitySessionFilter 按段过滤(段 0 兼容
  旧 NULL 行);新增 CurrentMainSeg/ListMainSessions/NewMainSession;
  per-session token 统计按 main:<seg> 分段
- agent: MainAgent.Chat 增加 mainSeg,transcript SessionID 分段
  (段 0 保留旧名 exp<id>-main 兼容既有对话)
- server: parseActivitySession 支持 main:<seq>;chat handler 每轮
  stamp 当前段;新增 GET main-sessions / POST main-session/new
- web: 主 Agent 分组渲染多段会话,支持新建(轻确认)、切换、旧段只读
This commit is contained in:
Autumn-27 committed 2026-09-10 12:56:59 -04:00
1 parent 9ad88d5ce8
commit ee3eb22433
8 files changed
+360 -51

No files matched your search

+2
View File
@@ -473,6 +473,7 @@ type ActivityDTO struct {
Metadata json.RawMessage `json:"metadata,omitempty"`
SourceTaskID string `json:"source_task_id,omitempty"`
Inherited bool `json:"inherited,omitempty"`
MainSeg *int `json:"main_seg,omitempty"` // main-agent conversation segment (nil for non-mainagent rows)
// token usage (set only on kind='result'); used for per-session token totals.
InputTokens *int `json:"input_tokens,omitempty"`
OutputTokens *int `json:"output_tokens,omitempty"`
@@ -501,6 +502,7 @@ func activityDTO(a db.Activity) ActivityDTO {
OutputTokens: a.OutputTokens,
CacheReadTokens: a.CacheReadTokens,
CacheWriteTokens: a.CacheWriteTokens,
MainSeg: a.MainSeg,
}
if a.SourceTaskID > 0 {
d.SourceTaskID = i64s(a.SourceTaskID)
+75 -6
View File
@@ -829,6 +829,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/exploration/graph", s.explorationGraph)
mux.HandleFunc("GET /api/exploration/activity", s.activity)
mux.HandleFunc("GET /api/exploration/activity/history", s.activityHistory)
mux.HandleFunc("GET /api/exploration/main-sessions", s.mainSessions)
mux.HandleFunc("POST /api/exploration/main-session/new", s.newMainSession)
mux.HandleFunc("GET /api/exploration/activity/stream", s.streamActivity)
mux.HandleFunc("GET /api/exploration/activity/{seq}", s.activityDetail)
mux.HandleFunc("GET /api/exploration/tokens", s.tokenStats)
@@ -2569,7 +2571,14 @@ func (s *Server) activity(w http.ResponseWriter, r *http.Request) {
func parseActivitySession(sess string) (db.ActivitySessionFilter, bool) {
switch {
case sess == "" || sess == "main":
return db.ActivitySessionFilter{Worker: "mainagent"}, true
// bare "main" = the current segment; caller resolves MainSeg via the store.
return db.ActivitySessionFilter{Main: true}, true
case strings.HasPrefix(sess, "main:"):
seg, err := strconv.Atoi(strings.TrimPrefix(sess, "main:"))
if err != nil || seg < 0 {
return db.ActivitySessionFilter{}, false
}
return db.ActivitySessionFilter{Main: true, MainSeg: &seg}, true
case sess == "plan":
return db.ActivitySessionFilter{Worker: "planner"}, true
case strings.HasPrefix(sess, "intent:"):
@@ -2632,6 +2641,14 @@ func (s *Server) activityHistory(w http.ResponseWriter, r *http.Request) {
writeErr(w, 400, "bad session")
return
}
if filter.Main && filter.MainSeg == nil { // bare "main" → the current segment
seg, err := t.Store.CurrentMainSeg()
if err != nil {
writeErr(w, 500, err.Error())
return
}
filter.MainSeg = &seg
}
before := int64(atoiDefault(q.Get("before"), 0))
limit := min(atoiDefault(q.Get("limit"), 200), 500) // cap so one request can't pull an unbounded slice
store, sourceTaskID, err := activitySessionStore(t, filter)
@@ -3377,6 +3394,47 @@ func (s *Server) testWebSearch(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"ok": true, "count": len(results), "backend": backend})
}
// mainSessions lists the task's main-agent conversation segments (newest-first) and
// the current one. The frontend renders these as switchable sessions under 主 Agent.
func (s *Server) mainSessions(w http.ResponseWriter, r *http.Request) {
t := s.m.ResolveTask(r.URL.Query().Get("task"))
if t == nil {
writeErr(w, 404, "task not found")
return
}
list, err := t.Store.ListMainSessions()
if err != nil {
writeErr(w, 500, err.Error())
return
}
current := 0
if len(list) > 0 {
current = list[0].Seq // newest-first
}
writeJSON(w, 200, map[string]any{"sessions": list, "current": current})
}
// newMainSession starts a fresh main-agent conversation segment. Only the segment
// counter advances — the task's exploration graph, assets and goal are untouched, so
// the main agent continues over the same task with a clean transcript/context.
func (s *Server) newMainSession(w http.ResponseWriter, r *http.Request) {
t := s.m.ResolveTask(r.URL.Query().Get("task"))
if t == nil {
writeErr(w, 404, "task not found")
return
}
if s.engine.IsDeleting(t.ID) {
writeErr(w, 409, "任务正在删除,无法新建会话")
return
}
m, err := t.Store.NewMainSession()
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"seq": m.Seq, "created_at": rfc3339(m.CreatedAt), "current": m.Seq})
}
func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
t := s.m.ResolveTask(r.URL.Query().Get("task"))
if t == nil {
@@ -3416,12 +3474,20 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
s.chatCancel[t.ID] = cancel
s.chatMu.Unlock()
// The whole turn belongs to the current main-agent segment. Read it once (chatBusy
// serializes turns, so it can't change under us) and stamp every mainagent row with
// it, so this turn's transcript + activity land in the segment the user is viewing.
mainSeg, _ := t.Store.CurrentMainSeg()
segPtr := &mainSeg
// Persist + broadcast the human turn so the 主 Agent 编排会话 survives page
// reloads and updates live: the conversation lives in the activity stream as
// worker="mainagent" (the per-task activity table, replayed via SSE). With
// attachments, the activity's Detail carries {text, attachments} so the transcript
// renders attachment cards.
s.engine.emitActivity(t, userActivityWithAttachments("mainagent", req.Message, req.Attachments))
humanTurn := userActivityWithAttachments("mainagent", req.Message, req.Attachments)
humanTurn.MainSeg = segPtr
s.engine.emitActivity(t, humanTurn)
var ma *agent.MainAgent
if s.taskRuntimeAvailable(t, "mainagent") {
ma = s.agentsForTask(t).main
@@ -3439,7 +3505,10 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
// emit every step (thinking/tool_use/tool_result/text/result) so the main
// agent session shows its work live, like worker/planner. The final answer
// is the captured "result" step — no separate reply emit (would duplicate).
emit := func(rec db.Activity) { s.engine.emitActivity(t, rec) }
emit := func(rec db.Activity) {
rec.MainSeg = segPtr
s.engine.emitActivity(t, rec)
}
maTaskID, _ := strconv.ParseInt(t.ID, 10, 64)
resume := func() { s.reviveTask(t) } // set_goals 新增目标 → 把任务拉回 running
// 把上传附件的【绝对路径】清单拼进发给 agent 的消息,它据此用 Read/Bash 打开文件。
@@ -3447,17 +3516,17 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
taskDir := filepath.Join(s.m.dir, "tasks", t.ID)
agentMsg := composeAgentMessage(req.Message, req.Attachments, taskDir)
s.engine.BeginLLMCall(t.ID)
_, err := ma.Chat(ctx, maTaskID, s.m.Assets(), t.Store, t.Goal, agentMsg, emit, t.Notify, resume, t.NotifyGoal)
_, err := ma.Chat(ctx, maTaskID, mainSeg, s.m.Assets(), t.Store, t.Goal, agentMsg, emit, t.Notify, resume, t.NotifyGoal)
s.engine.EndLLMCall(t.ID)
if err != nil && ctx.Err() == nil {
s.engine.emitActivity(t, db.Activity{Worker: "mainagent", Kind: "text", IsError: true, Summary: "(主 Agent 出错:" + err.Error() + ")"})
s.engine.emitActivity(t, db.Activity{Worker: "mainagent", Kind: "text", IsError: true, Summary: "(主 Agent 出错:" + err.Error() + ")", MainSeg: segPtr})
}
}()
writeJSON(w, 202, map[string]any{"status": "accepted", "mode": "llm"})
return
}
reply := s.fallbackChat(t, req.Message)
s.engine.emitActivity(t, db.Activity{Worker: "mainagent", Kind: "text", Summary: reply})
s.engine.emitActivity(t, db.Activity{Worker: "mainagent", Kind: "text", Summary: reply, MainSeg: segPtr})
s.finishTaskChat(t.ID, cancel)
writeJSON(w, 200, map[string]any{"reply": reply, "mode": "rule"})
}