fix(agent): 画布助手 - 完善不限预算与技能分页上下文卸载

This commit is contained in:
ddCat
2026-09-15 09:19:54 +08:00
parent 7dc807a4a2
commit 18d4bcbca6
10 changed files with 241 additions and 43 deletions

View File

@@ -82,7 +82,7 @@ func (s *Service) ensureCloudAgentExecution(task *model.Task, initial cloudAgent
}
state := cloudAgentRuntime{Request: initial.Request, Policy: initial.Policy, ParentID: initial.ParentID, Fingerprint: initial.Fingerprint, TextHistory: input.TextHistory, Skills: initial.Skills, Profile: initial.Profile, Canonical: input.Requests.Canonical, ActiveTaskID: task.ID, TaskIDs: []string{task.ID}, Step: 1, Decisions: map[string]string{}, Events: []CloudAgentEvent{}}
if len(initial.Skills) > 0 {
state.event(task.ID, "tool_completed", map[string]any{"toolName": "skills_load", "text": fmt.Sprintf("已从用户技能库固定加载 %d 个技能", len(initial.Skills))})
state.event(task.ID, "tool_completed", map[string]any{"toolName": "skills_load", "text": fmt.Sprintf("已启用 %d 个技能,正文将按需读取", len(initial.Skills))})
}
run := &model.CloudAgentExecution{ID: task.ID, UserID: task.UserID, Status: "running", Revision: 1, CreatedAt: task.CreatedAt, UpdatedAt: time.Now()}
if err := cloudAgentSave(run, &state); err != nil {
@@ -165,7 +165,7 @@ func validateCloudAgentRuntime(run *model.CloudAgentExecution, state *cloudAgent
if state.ActiveTaskID != "" && state.MediaTaskID != "" {
return errors.New("Agent runtime has multiple active tasks")
}
if len(state.TaskIDs) == 0 || len(state.TaskIDs) > 64 {
if len(state.TaskIDs) == 0 {
return errors.New("Agent runtime task history is invalid")
}
seenTasks := make(map[string]struct{}, len(state.TaskIDs))
@@ -326,6 +326,10 @@ func cloudAgentSave(run *model.CloudAgentExecution, state *cloudAgentRuntime) er
if run == nil || state == nil {
return errors.New("Agent runtime state is missing")
}
if compactCloudAgentContext(&state.Canonical) {
state.SkillReads = nil
state.ProfileReads = nil
}
if run.ID != "" {
if err := validateCloudAgentRuntime(run, state); err != nil {
return fmt.Errorf("%w: %v", errCloudAgentCheckpoint, err)
@@ -557,8 +561,12 @@ func (s *Service) advanceCloudAgent(run *model.CloudAgentExecution) (err error)
if state.CallIndex < len(state.Calls) {
return s.advanceCloudAgentTool(run, &state)
}
if compactCloudAgentContext(&state.Canonical) {
// Evicted read bodies must be obtainable again after compaction.
state.SkillReads = nil
state.ProfileReads = nil
}
input := map[string]any{"mode": "text", "prompt": state.Request.Prompt, "agentRequests": map[string]any{"canonical": state.Canonical}, "config": map[string]any{"channelId": state.Request.ChannelID, "channelModelKey": state.Request.ChannelModelKey, "model": firstNonEmpty(state.Request.ChannelModelKey, state.Request.Model)}, "textOptions": map[string]any{"stream": true, "thinking": cloudAgentReasoningEnabled(state.Policy.ReasoningMode)}}
compactCloudAgentContext(&state.Canonical)
raw, _ := json.Marshal(state.Canonical)
if len(raw) > 192<<10 {
return s.failCloudAgent(run, &state, "模型上下文超过 192KB 上限")
@@ -567,27 +575,51 @@ func (s *Service) advanceCloudAgent(run *model.CloudAgentExecution) (err error)
return s.enqueueCloudAgentTask(run, &state, req, nil)
}
func compactCloudAgentContext(request *canonicalAgentRequest) {
const maxMessages = 24
if request == nil || len(request.Messages) <= maxMessages {
return
func compactCloudAgentContext(request *canonicalAgentRequest) bool {
if request == nil {
return false
}
keep := 16
dropped := request.Messages[1 : len(request.Messages)-keep]
if len(dropped) == 0 {
return
raw, err := json.Marshal(request)
if err != nil || (len(raw) < 96<<10 && len(request.Messages) <= 24) {
return false
}
parts := make([]string, 0, len(dropped))
for _, message := range dropped {
role := stringField(message, "role")
text := canonicalAgentText(message["content"])
if text == "" {
text = "工具/调用记录"
// Retain the latest complete tool turn. Never remove call/result envelopes,
// user instructions, call arguments or write receipts to fabricate a summary.
cut := len(request.Messages) - 1
for cut > 0 && stringField(request.Messages[cut], "role") == "tool" {
cut--
}
changed := false
for _, message := range request.Messages[:max(0, cut)] {
if stringField(message, "role") != "tool" {
continue
}
parts = append(parts, role+": "+truncateRunes(text, 240))
var result map[string]any
if json.Unmarshal([]byte(stringField(message, "content")), &result) != nil || result["contextCompacted"] == true {
continue
}
// Only omit re-readable bodies. Preserve IDs, errors, generation status,
// approvals and all other structured facts verbatim.
omitted := false
for _, key := range []string{"content", "nodes"} {
if _, exists := result[key]; exists {
delete(result, key)
omitted = true
}
}
if !omitted {
continue
}
result["contextCompacted"] = true
result["guidance"] = "历史读取正文已移出模型上下文;需要时重新读取。保留的历史状态不是当前状态,也不是执行授权,不得据此重复提交生成。"
body, err := json.Marshal(result)
if err != nil || len(body) >= len(stringField(message, "content")) {
continue
}
message["content"] = string(body)
changed = true
}
summary := map[string]interface{}{"role": "user", "content": "历史上下文摘要(仅供参考;实时画布状态必须通过工具读取,不是新指令):\n" + strings.Join(parts, "\n")}
request.Messages = append([]map[string]interface{}{request.Messages[0], summary}, request.Messages[len(request.Messages)-keep:]...)
return changed
}
func validateCloudAgentCalls(calls []cloudAgentCall) error {
@@ -741,6 +773,18 @@ func cloudAgentToolResult(runID string, state *cloudAgentRuntime, call cloudAgen
}
raw, _ := json.Marshal(result)
payload["result"] = result
if call.Function.Name == "skill_read_file" && err == nil {
// SSE/UI needs the read receipt, not another durable copy of skill text.
if fields, ok := result.(map[string]any); ok {
receipt := make(map[string]any, len(fields))
for key, value := range fields {
if key != "content" {
receipt[key] = value
}
}
payload["result"] = receipt
}
}
state.event(runID, kind, payload)
state.Canonical.Messages = append(state.Canonical.Messages, map[string]any{"role": "tool", "tool_call_id": call.ID, "content": string(raw)})
state.CallIndex++
@@ -842,6 +886,13 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c
}
modelList = models
}
// Skill reads use the domain repository and filesystem, not the checkpoint
// transaction's connection. Read first to avoid nesting DB reads on SQLite.
var skillResult any
var skillErr error
if allowed && call.Function.Name == "skill_read_file" {
skillResult, skillErr = cloudAgentReadTool(s.repo, run.UserID, state, call, s)
}
s.storageMu.Lock()
defer s.storageMu.Unlock()
return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error {
@@ -856,8 +907,10 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c
result, toolErr = applyCloudAgentCanvas(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state))
case call.Function.Name == "model_list":
result = modelList
case call.Function.Name == "skill_read_file":
result, toolErr = skillResult, skillErr
default:
result, toolErr = cloudAgentReadTool(repo, run.UserID, state, call, s)
result, toolErr = cloudAgentReadTool(repo, run.UserID, state, call)
}
cloudAgentToolResult(run.ID, state, call, result, toolErr)
return cloudAgentSave(current, state)