Merge upstream/main into codex/btw-side-question
同步官方 main 的任务会话与 spawn_task 更新,保留 /btw 旁路上下文能力。
This commit is contained in:
commit
272004c2e4
10 files changed
+410
-64
No files matched your search
@@ -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)
|
||||
|
||||
+36
-6
@@ -244,6 +244,7 @@ func (s *Server) toolSpawnTask() actool.CoreTool {
|
||||
"description": strParam("任务描述(简短标题)"),
|
||||
"goal": strParam("任务目标(要达成什么)"),
|
||||
"parent_ref": strParam("可选:父任务 id(做父子关联)"),
|
||||
"source_task_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": fmt.Sprintf("可选:只读继承的来源任务 id 列表(最多 %d 个)。子任务可只读引用这些任务已探明的资产/结论作为起点;与 parent_ref 的纯父子指针不同,这是内容继承。", db.MaxTaskSourceCount)},
|
||||
"llm_profile_id": map[string]any{"type": "integer", "description": "可选:指定本子任务 planner/worker 用的 LLM 配置 id(见 list_llm_profiles);留空则继承父任务、再回退全局激活配置"},
|
||||
"timeout_seconds": map[string]any{"type": "integer", "description": "可选:任务级超时(秒)。到点后触发优雅收尾并进入 timeout 终态;留空或 0 = 不限时"},
|
||||
"plan_heartbeat_seconds": map[string]any{"type": "integer", "description": "可选:planner 心跳触发间隔(秒)。距上轮规划结束/任务开始满该值且期间无触发 → 触发一轮规划(兜底死锁 + 唤醒去监督飞行中的 worker)。留空或 0 = 默认 600(10min);"},
|
||||
@@ -251,11 +252,14 @@ func (s *Server) toolSpawnTask() actool.CoreTool {
|
||||
}, "description", "goal"),
|
||||
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
var a struct {
|
||||
Description, Goal, ParentRef string
|
||||
LLMProfileID json.RawMessage `json:"llm_profile_id"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"`
|
||||
SeedFirstIntent bool `json:"seed_first_intent"`
|
||||
Description string `json:"description"`
|
||||
Goal string `json:"goal"`
|
||||
ParentRef string `json:"parent_ref"`
|
||||
SourceTaskIDs []string `json:"source_task_ids"`
|
||||
LLMProfileID json.RawMessage `json:"llm_profile_id"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"`
|
||||
SeedFirstIntent bool `json:"seed_first_intent"`
|
||||
}
|
||||
_ = json.Unmarshal(in, &a)
|
||||
if strings.TrimSpace(a.Description) == "" {
|
||||
@@ -267,6 +271,23 @@ func (s *Server) toolSpawnTask() actool.CoreTool {
|
||||
if a.TimeoutSeconds < 0 {
|
||||
a.TimeoutSeconds = 0
|
||||
}
|
||||
// 只读继承来源任务:数量上限 + 每个 id 有效/去重/存在,校验规则与 HTTP 建任务一致。
|
||||
if len(a.SourceTaskIDs) > db.MaxTaskSourceCount {
|
||||
return actool.Errorf(fmt.Sprintf("关联任务最多选择 %d 个", db.MaxTaskSourceCount)), nil
|
||||
}
|
||||
sourceIDs := make([]int64, 0, len(a.SourceTaskIDs))
|
||||
seenSources := map[int64]bool{}
|
||||
for _, raw := range a.SourceTaskIDs {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil || id <= 0 || seenSources[id] {
|
||||
return actool.Errorf("关联任务 id 无效或重复"), nil
|
||||
}
|
||||
if _, ok := s.m.Task(strconv.FormatInt(id, 10)); !ok {
|
||||
return actool.Errorf(fmt.Sprintf("关联任务 #%d 不存在", id)), nil
|
||||
}
|
||||
seenSources[id] = true
|
||||
sourceIDs = append(sourceIDs, id)
|
||||
}
|
||||
// LLM profile resolution: explicit id > inherit parent's pin > active(nil).
|
||||
var pin *int64
|
||||
if id := parseProfileID(a.LLMProfileID); id > 0 {
|
||||
@@ -279,7 +300,16 @@ func (s *Server) toolSpawnTask() actool.CoreTool {
|
||||
pin = pt.LLMProfileID
|
||||
}
|
||||
}
|
||||
t, err := s.m.CreateTask(a.Description, a.Goal, pin, a.TimeoutSeconds, a.PlanHeartbeatSeconds)
|
||||
var llmIDs []int64
|
||||
if pin != nil {
|
||||
llmIDs = []int64{*pin}
|
||||
}
|
||||
t, err := s.m.CreateTaskWithOptions(a.Description, a.Goal, db.TaskCreateOptions{
|
||||
SourceTaskIDs: sourceIDs,
|
||||
LLMProfileIDs: llmIDs,
|
||||
TimeoutSeconds: a.TimeoutSeconds,
|
||||
PlanHeartbeatSeconds: a.PlanHeartbeatSeconds,
|
||||
})
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
|
||||
+82
-6
@@ -845,6 +845,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)
|
||||
@@ -2585,7 +2587,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:"):
|
||||
@@ -2648,6 +2657,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)
|
||||
@@ -3393,6 +3410,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 {
|
||||
@@ -3408,6 +3466,7 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
Attachments []chatAttachment `json:"attachments,omitempty"` // 方式1 上传的文件(路径相对任务工作目录)
|
||||
Seg *int `json:"seg,omitempty"` // 目标主会话分段;缺省=最新段
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
@@ -3432,12 +3491,26 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
|
||||
s.chatCancel[t.ID] = cancel
|
||||
s.chatMu.Unlock()
|
||||
|
||||
// The turn belongs to whichever main-agent segment the user is chatting in (any
|
||||
// segment is interactive, like the top-level chat conversations). Stamp every
|
||||
// mainagent row with it so this turn's transcript + activity land in that segment.
|
||||
// Missing seg (older clients) falls back to the newest segment.
|
||||
mainSeg := 0
|
||||
if req.Seg != nil && *req.Seg >= 0 {
|
||||
mainSeg = *req.Seg
|
||||
} else {
|
||||
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
|
||||
@@ -3455,7 +3528,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 打开文件。
|
||||
@@ -3463,17 +3539,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"})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user