You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
200 lines
7.5 KiB
200 lines
7.5 KiB
package controlplane
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Multi-turn conversations keep a single Agent's follow-up work in context, so a
|
|
// user can say "把它复制到某个目录" without repeating where the previous step put it.
|
|
const (
|
|
defaultSessionTurnLimit = 50
|
|
maxSessionTurnLimit = 200
|
|
// One turn is bounded, and the assembled history has its own budget: the
|
|
// controller always sends plain user/assistant text, never tool transcripts.
|
|
maxSessionTurnChars = 4000
|
|
maxSessionHistory = 60000
|
|
)
|
|
|
|
// createTaskSession starts an empty conversation with the default turn limit.
|
|
func (c *ControlPlane) createTaskSession(agentID string) (string, error) {
|
|
sessionID, err := randomHex(16)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
|
if _, err := c.db.Exec(
|
|
"INSERT INTO task_sessions(session_id, agent_id, turn_limit, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
|
sessionID, agentID, defaultSessionTurnLimit, now, now,
|
|
); err != nil {
|
|
return "", fmt.Errorf("无法创建会话: %w", err)
|
|
}
|
|
return sessionID, nil
|
|
}
|
|
|
|
func normaliseTurnLimit(turnLimit int) int {
|
|
if turnLimit < 1 {
|
|
return defaultSessionTurnLimit
|
|
}
|
|
if turnLimit > maxSessionTurnLimit {
|
|
return maxSessionTurnLimit
|
|
}
|
|
return turnLimit
|
|
}
|
|
|
|
func (c *ControlPlane) sessionTurnCount(sessionID string) (int, error) {
|
|
var turns int
|
|
if err := c.db.QueryRow("SELECT COUNT(*) FROM task_session_messages WHERE session_id = ? AND role = 'user'", sessionID).Scan(&turns); err != nil {
|
|
return 0, fmt.Errorf("无法统计会话轮数: %w", err)
|
|
}
|
|
return turns, nil
|
|
}
|
|
|
|
// ContinueTaskSession dispatches a follow-up turn based on one completed task.
|
|
// The one-shot task that starts a thread becomes its first turn, so 单独指派 keeps
|
|
// sending context-free tasks while only an explicit follow-up carries context.
|
|
func (c *ControlPlane) ContinueTaskSession(taskID, instruction string, timeoutSeconds int, inputs []TaskImageInput) (CreateTaskSessionResult, error) {
|
|
images, err := normalizeTaskImages(inputs)
|
|
if err != nil {
|
|
return CreateTaskSessionResult{}, err
|
|
}
|
|
var agentID, sessionID, previousInstruction, previousOutput, status string
|
|
if err := c.db.QueryRow(
|
|
"SELECT agent_id, COALESCE(session_id, ''), instruction, COALESCE(output, ''), status FROM task_runs WHERE task_id = ?", taskID,
|
|
).Scan(&agentID, &sessionID, &previousInstruction, &previousOutput, &status); err != nil {
|
|
return CreateTaskSessionResult{}, errors.New("未找到要继续的任务")
|
|
}
|
|
if status != "completed" {
|
|
return CreateTaskSessionResult{}, errors.New("只有已完成的任务才能继续对话")
|
|
}
|
|
if sessionID == "" {
|
|
created, createErr := c.createTaskSession(agentID)
|
|
if createErr != nil {
|
|
return CreateTaskSessionResult{}, createErr
|
|
}
|
|
sessionID = created
|
|
c.appendSessionTurn(sessionID, previousInstruction, previousOutput)
|
|
_, _ = c.db.Exec("UPDATE task_runs SET session_id = ? WHERE task_id = ?", sessionID, taskID)
|
|
c.recordAudit("task.session_started", "controller", "agent:"+agentID, "completed", "已基于一条已完成任务开始多轮对话。")
|
|
}
|
|
followUpID, err := c.createTaskWithImages(agentID, instruction, timeoutSeconds, 1, "", images, sessionID)
|
|
if err != nil {
|
|
return CreateTaskSessionResult{}, err
|
|
}
|
|
turns, countErr := c.sessionTurnCount(sessionID)
|
|
if countErr != nil {
|
|
return CreateTaskSessionResult{TaskID: followUpID, SessionID: sessionID, Turns: 1}, nil
|
|
}
|
|
return CreateTaskSessionResult{TaskID: followUpID, SessionID: sessionID, Turns: turns + 1}, nil
|
|
}
|
|
|
|
// TaskSessionTurn is one stored turn of a conversation: exactly the plain
|
|
// question/answer text the Agent receives as context.
|
|
type TaskSessionTurn struct {
|
|
Instruction string `json:"instruction"`
|
|
Answer string `json:"answer"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
|
|
// TaskSessionTurns returns the stored turns of one conversation, oldest first, so
|
|
// the task detail dialog can show what the follow-up request will carry.
|
|
func (c *ControlPlane) TaskSessionTurns(sessionID string) ([]TaskSessionTurn, error) {
|
|
turns := make([]TaskSessionTurn, 0)
|
|
sessionID = strings.TrimSpace(sessionID)
|
|
if sessionID == "" {
|
|
return turns, nil
|
|
}
|
|
rows, err := c.db.Query("SELECT role, content, created_at FROM task_session_messages WHERE session_id = ? ORDER BY position", sessionID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("无法读取会话内容: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var role, content, createdAt string
|
|
if err := rows.Scan(&role, &content, &createdAt); err != nil {
|
|
return nil, err
|
|
}
|
|
switch {
|
|
case role == "user":
|
|
turns = append(turns, TaskSessionTurn{Instruction: content, CreatedAt: createdAt})
|
|
case len(turns) > 0 && turns[len(turns)-1].Answer == "":
|
|
turns[len(turns)-1].Answer = content
|
|
}
|
|
}
|
|
return turns, rows.Err()
|
|
}
|
|
|
|
// sessionHistory returns the newest turns that fit the budget, oldest first, so a
|
|
// follow-up request carries the context the model needs without unbounded growth.
|
|
func (c *ControlPlane) sessionHistory(sessionID string, turnLimit int) ([]map[string]string, error) {
|
|
rows, err := c.db.Query(
|
|
"SELECT role, content FROM task_session_messages WHERE session_id = ? ORDER BY position DESC LIMIT ?",
|
|
sessionID, normaliseTurnLimit(turnLimit)*2,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("无法读取会话历史: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
newestFirst := make([]map[string]string, 0)
|
|
for rows.Next() {
|
|
var role, content string
|
|
if err := rows.Scan(&role, &content); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(content) == "" {
|
|
continue
|
|
}
|
|
newestFirst = append(newestFirst, map[string]string{"role": role, "content": content})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
// Keep the newest messages that fit the character budget.
|
|
budget, kept := maxSessionHistory, make([]map[string]string, 0, len(newestFirst))
|
|
for _, message := range newestFirst {
|
|
budget -= len([]rune(message["content"]))
|
|
if budget < 0 && len(kept) > 0 {
|
|
break
|
|
}
|
|
kept = append(kept, message)
|
|
}
|
|
history := make([]map[string]string, 0, len(kept))
|
|
for index := len(kept) - 1; index >= 0; index-- {
|
|
history = append(history, kept[index])
|
|
}
|
|
return history, nil
|
|
}
|
|
|
|
// appendSessionTurn records one completed turn and trims the oldest turns once the
|
|
// conversation exceeds its limit. Failed or cancelled turns never enter history.
|
|
func (c *ControlPlane) appendSessionTurn(sessionID, instruction, output string) {
|
|
instruction, output = strings.TrimSpace(instruction), strings.TrimSpace(output)
|
|
if instruction == "" || output == "" {
|
|
return
|
|
}
|
|
var limitValue, messageCount int
|
|
if err := c.db.QueryRow("SELECT turn_limit FROM task_sessions WHERE session_id = ?", sessionID).Scan(&limitValue); err != nil {
|
|
return
|
|
}
|
|
if err := c.db.QueryRow("SELECT COUNT(*) FROM task_session_messages WHERE session_id = ?", sessionID).Scan(&messageCount); err != nil {
|
|
return
|
|
}
|
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
|
if _, err := c.db.Exec(
|
|
"INSERT INTO task_session_messages(session_id, position, role, content, created_at) VALUES (?, ?, 'user', ?, ?), (?, ?, 'assistant', ?, ?)",
|
|
sessionID, messageCount+1, limit(instruction, maxSessionTurnChars), now,
|
|
sessionID, messageCount+2, limit(output, maxSessionTurnChars), now,
|
|
); err != nil {
|
|
return
|
|
}
|
|
// Drop the oldest turns beyond the configured limit.
|
|
if excess := messageCount + 2 - normaliseTurnLimit(limitValue)*2; excess > 0 {
|
|
_, _ = c.db.Exec(
|
|
"DELETE FROM task_session_messages WHERE session_id = ? AND position <= ?",
|
|
sessionID, excess,
|
|
)
|
|
}
|
|
_, _ = c.db.Exec("UPDATE task_sessions SET updated_at = ? WHERE session_id = ?", now, sessionID)
|
|
}
|
|
|