Version: 0.9.45.dev.260427

后端:
1. execute 主链路重构为“上下文工具域 + 主动优化候选闭环”——移除 order_guard,粗排后默认进入主动微调,先诊断再从后端候选中选择 move/swap,避免 LLM 自由全局乱搜
2. 工具体系升级为动态注入协议——新增 context_tools_add / remove、工具域与二级包映射、主动优化白名单;schedule / taskclass / web 工具按域按包暴露,msg0 规则包与 execute 上下文同步重写
3. analyze_health 升级为主动优化唯一裁判入口——补齐 rhythm / tightness / profile / feasibility 指标、候选扫描与复诊打分、停滞信号、forced imperfection 判定,并把连续优化状态写回运行态
4. 任务类能力并入新 Agent 执行链——新增 upsert_task_class 写工具与启动注入事务写入;任务类模型补充学科画像与整天屏蔽配置,粗排支持 excluded_days_of_week,steady 策略改为基于目标位置/单日负载/分散度/缓冲的候选打分
5. 运行态与路由补齐优化模式语义——新增 active tool domain/packs、pending context hook、active optimize only、taskclass 写入回盘快照;区分 first_full / global_reopt / local_adjust,并完善首次粗排后默认 refine 的判定

前端:
6. 助手时间线渲染细化——推理内容改为独立 reasoning block,支持与工具/状态/正文按时序交错展示,自动收口折叠,修正 confirm reject 恢复动作

仓库:
7. newAgent 文档整体迁入 docs/backend,补充主动优化执行规划与顺序约束拆解文档,删除旧调试日志文件

PS:这次科研了2天,总算是有些进展了——LLM永远只适合做选择题、判断题,不适合做开放创新题。
This commit is contained in:
Losita
2026-04-27 01:09:37 +08:00
parent 04b5836b39
commit 66c06eed0a
60 changed files with 9163 additions and 1819 deletions

View File

@@ -71,6 +71,24 @@ type CommonState struct {
TraceID string `json:"trace_id"`
UserID int `json:"user_id"`
ConversationID string `json:"conversation_id"`
// ActiveToolDomain 记录当前 msg0 动态区激活的业务工具域。
// 说明:
// 1. 空字符串表示仅保留 context 管理工具,不注入业务工具定义;
// 2. 非空时仅允许注入对应域的工具(如 schedule/taskclass
// 3. 该字段由 context_tools_add/remove 工具结果驱动更新。
ActiveToolDomain string `json:"active_tool_domain,omitempty"`
// ActiveToolPacks 记录当前激活域下的可选二级包(不含 core 固定包)。
// 说明:
// 1. 仅对 schedule 域生效queue/mutation/analyze/web
// 2. 为空时按域默认策略解释schedule 兼容为“全可选包”);
// 3. 该字段与 ActiveToolDomain 一起由 context_tools_add/remove 结果更新。
ActiveToolPacks []string `json:"active_tool_packs,omitempty"`
// PendingContextHook 保存 plan 阶段给 execute 阶段的一次性注入建议。
// 说明:
// 1. 可由 plan_done 或 rough_build->execute 分支写入;
// 2. execute 首轮消费一次后清空;
// 3. 该字段只表达建议,不直接触发工具调用。
PendingContextHook *ContextHook `json:"pending_context_hook,omitempty"`
// 流程阶段
Phase Phase `json:"phase"`
@@ -106,12 +124,70 @@ type CommonState struct {
NeedsRefineAfterRoughBuild bool `json:"needs_refine_after_rough_build,omitempty"`
// AllowReorder 表示本轮是否允许打乱 suggested 任务的相对顺序。
// 默认 false只有用户明确说明"可以打乱顺序/顺序不重要"才会为 true。
AllowReorder bool `json:"allow_reorder,omitempty"`
// SuggestedOrderBaseline 保存"本轮 execute 启动前"的 suggested 任务相对顺序基线。
// OrderGuard 节点会基于该基线判断微调是否破坏顺序约束
SuggestedOrderBaseline []int `json:"suggested_order_baseline,omitempty"`
AllowReorder bool `json:"allow_reorder,omitempty"`
OptimizationMode string `json:"optimization_mode,omitempty"`
// ActiveOptimizeOnly 标记“当前是否处于粗排后主动优化专用模式”
// 1. true 时execute 只向 LLM 暴露 analyze_health + move + swap 这组最小闭环工具;
// 2. 该开关只用于首次粗排后的自动微调,不影响用户后续明确提出的日程调整请求;
// 3. 流程收口、重开新请求或切换业务域后,必须重置为 false。
ActiveOptimizeOnly bool `json:"active_optimize_only,omitempty"`
HealthCheckDone bool `json:"health_check_done,omitempty"`
HealthIsFeasible bool `json:"health_is_feasible,omitempty"`
HealthCapacityGap int `json:"health_capacity_gap,omitempty"`
HealthReasonCode string `json:"health_reason_code,omitempty"`
// HealthShouldContinueOptimize 记录最近一次 analyze_health 是否认为“还值得继续优化”。
// 调用目的:
// 1. 让 execute prompt 直接读取后端诊断结论,而不是只根据 issues 猜下一步;
// 2. 该字段只表达“是否值得继续动”,不替 LLM 决定具体写参数;
// 3. 默认 false只有 analyze_health 明确判定后才会更新。
HealthShouldContinueOptimize bool `json:"health_should_continue_optimize,omitempty"`
// HealthTightnessLevel 记录最近一次诊断得到的优化空间等级loose / tight / locked。
// 调用目的:
// 1. 用于提示 LLM 区分“还能优化”和“已经是被迫不完美”;
// 2. 该字段只服务主动优化链路,不参与粗排可行性判断;
// 3. 空字符串表示尚未拿到有效诊断。
HealthTightnessLevel string `json:"health_tightness_level,omitempty"`
// HealthPrimaryProblem 保存最近一次诊断的主要局部问题摘要。
// 调用目的:
// 1. 帮助 execute 聚焦当前最值得处理的那个点,避免全局乱搜;
// 2. 只保存短摘要,不保存完整工具原文,避免状态膨胀;
// 3. 为空表示当前没有明确主问题或诊断失败。
HealthPrimaryProblem string `json:"health_primary_problem,omitempty"`
// HealthRecommendedOperation 保存最近一次诊断建议优先考虑的动作类型。
// 允许值由 analyze_health 控制,当前主要为 swap / move / close / ask_user。
HealthRecommendedOperation string `json:"health_recommended_operation,omitempty"`
// HealthIsForcedImperfection 标记当前剩余问题是否更像“约束代价”而非“仍值得修”的问题。
// 调用目的:
// 1. 给 LLM 一个明确的收口信号;
// 2. 仅在 analyze_health 返回结构化 decision 时更新;
// 3. false 不代表一定要继续优化,只代表“不是明确的被迫不完美”。
HealthIsForcedImperfection bool `json:"health_is_forced_imperfection,omitempty"`
// HealthImprovementSignal 保存最近一次诊断的紧凑对比信号,用于判断是否连续停滞。
// 调用目的:
// 1. execute 可基于该字段识别“连续两轮几乎没改善”;
// 2. 信号由 analyze_health 生成,格式稳定但不面向用户展示;
// 3. 若诊断失败则保持空字符串。
HealthImprovementSignal string `json:"health_improvement_signal,omitempty"`
// HealthStagnationCount 记录连续多少次 analyze_health 给出了相同的 improvement_signal。
// 调用目的:
// 1. 让 prompt 可以在“继续磨也没明显改善”时提醒 LLM 主动收口;
// 2. 仅在两次连续有效诊断的信号完全相同时递增;
// 3. 只做软提醒,不做后端硬拦截。
HealthStagnationCount int `json:"health_stagnation_count,omitempty"`
// TaskClassUpsertLastTried 标记本轮是否至少调用过一次 upsert_task_class。
// 调用目的execute_context 仅在该标记为 true 时注入“最近一次任务类写入结果”,避免噪音。
TaskClassUpsertLastTried bool `json:"task_class_upsert_last_tried,omitempty"`
// TaskClassUpsertLastSuccess 记录最近一次 upsert_task_class 是否成功。
// 调用目的:为 prompt 提供“是否需要继续追问补字段”的明确信号。
TaskClassUpsertLastSuccess bool `json:"task_class_upsert_last_success,omitempty"`
// TaskClassUpsertLastIssues 记录最近一次写入返回的校验问题validation.issues
// 调用目的:让 LLM 直接按缺失字段追问,减少泛化提问。
TaskClassUpsertLastIssues []string `json:"task_class_upsert_last_issues,omitempty"`
// TaskClassUpsertConsecutiveFailures 记录连续写入失败次数。
// 调用目的:给 prompt 注入“避免空转”的软提示,不做硬拦截。
TaskClassUpsertConsecutiveFailures int `json:"task_class_upsert_consecutive_failures,omitempty"`
// HasScheduleWriteOps 标记本轮 execute 循环是否执行过日程写工具。
// 调用目的:graph 分支函数据此判断是否需要走 order_guard非日程操作跳过守卫
// 调用目的:为 prompt/收口层提供“本轮是否真的动过日程写工具”的运行态信号
HasScheduleWriteOps bool `json:"has_schedule_write_ops,omitempty"`
// UsedQuickNote 标记本轮是否调用过 quick_note_create 工具。
// 调用目的graph 完成后据此决定是否跳过记忆抽取,避免随口记内容被错误归类。
@@ -164,8 +240,12 @@ func (s *CommonState) FinishPlan(steps []PlanStep) {
s.PlanSteps = steps
s.CurrentStep = 0
s.Phase = PhaseWaitingConfirm
s.ActiveToolDomain = ""
s.ActiveToolPacks = nil
s.PendingContextHook = nil
s.NeedsRefineAfterRoughBuild = false
s.SuggestedOrderBaseline = nil
s.ActiveOptimizeOnly = false
s.resetTaskClassUpsertSnapshot()
s.ClearTerminalOutcome()
}
@@ -173,7 +253,8 @@ func (s *CommonState) FinishPlan(steps []PlanStep) {
func (s *CommonState) ConfirmPlan() {
s.Phase = PhaseExecuting
s.NeedsRefineAfterRoughBuild = false
s.SuggestedOrderBaseline = nil
s.ActiveOptimizeOnly = false
s.resetTaskClassUpsertSnapshot()
s.ClearTerminalOutcome()
}
@@ -185,9 +266,13 @@ func (s *CommonState) StartDirectExecute() {
s.PlanSteps = nil
s.CurrentStep = 0
s.Phase = PhaseExecuting
s.ActiveToolDomain = ""
s.ActiveToolPacks = nil
s.PendingContextHook = nil
s.NeedsRoughBuild = false
s.NeedsRefineAfterRoughBuild = false
s.SuggestedOrderBaseline = nil
s.ActiveOptimizeOnly = false
s.resetTaskClassUpsertSnapshot()
s.ClearTerminalOutcome()
}
@@ -196,8 +281,12 @@ func (s *CommonState) RejectPlan() {
s.PlanSteps = nil
s.CurrentStep = 0
s.Phase = PhasePlanning
s.ActiveToolDomain = ""
s.ActiveToolPacks = nil
s.PendingContextHook = nil
s.NeedsRefineAfterRoughBuild = false
s.SuggestedOrderBaseline = nil
s.ActiveOptimizeOnly = false
s.resetTaskClassUpsertSnapshot()
s.ClearTerminalOutcome()
}
@@ -223,18 +312,50 @@ func (s *CommonState) ResetForNextRun() {
// 4. 清理计划执行游标与粗排相关临时标记,确保新请求不会误沿用旧计划。
s.PlanSteps = nil
s.CurrentStep = 0
s.ActiveToolDomain = ""
s.ActiveToolPacks = nil
s.PendingContextHook = nil
s.NeedsRoughBuild = false
s.NeedsRefineAfterRoughBuild = false
s.ActiveOptimizeOnly = false
// 5. 重置顺序约束临时态与终止结果,避免上一轮 completed/aborted/exhausted 语义串到下一轮。
s.AllowReorder = false
s.OptimizationMode = ""
s.HealthCheckDone = false
s.HealthIsFeasible = true
s.HealthCapacityGap = 0
s.HealthReasonCode = ""
s.HealthShouldContinueOptimize = false
s.HealthTightnessLevel = ""
s.HealthPrimaryProblem = ""
s.HealthRecommendedOperation = ""
s.HealthIsForcedImperfection = false
s.HealthImprovementSignal = ""
s.HealthStagnationCount = 0
s.HasScheduleWriteOps = false
s.HasScheduleChanges = false
s.UsedQuickNote = false
s.SuggestedOrderBaseline = nil
s.resetTaskClassUpsertSnapshot()
s.ClearTerminalOutcome()
}
// resetTaskClassUpsertSnapshot 清理“任务类写入回盘”运行态。
//
// 职责边界:
// 1. 仅清理 upsert_task_class 相关的临时回盘字段;
// 2. 不影响 Health/Plan/Phase 等其他执行状态;
// 3. 作为新一轮入口统一调用,避免旧失败信息污染本轮追问。
func (s *CommonState) resetTaskClassUpsertSnapshot() {
if s == nil {
return
}
s.TaskClassUpsertLastTried = false
s.TaskClassUpsertLastSuccess = false
s.TaskClassUpsertLastIssues = nil
s.TaskClassUpsertConsecutiveFailures = 0
}
// AdvanceStep 推进到下一个计划步骤,并返回是否仍有剩余步骤。
func (s *CommonState) AdvanceStep() bool {
s.CurrentStep++
@@ -248,6 +369,12 @@ func (s *CommonState) AdvanceStep() bool {
// 2. 只有在尚未写入任何终止结果时,才默认补成 completed。
func (s *CommonState) Done() {
s.Phase = PhaseDone
// 收口时自动清空工具域,确保下一轮 msg0 动态区回到最小集合(仅 context 管理工具)。
// 调用目的:把“收尾清理”从 LLM 决策中剥离,减少 done 阶段无关 tool_call 噪音。
s.ActiveToolDomain = ""
s.ActiveToolPacks = nil
s.PendingContextHook = nil
s.ActiveOptimizeOnly = false
if s.TerminalOutcome != nil {
s.TerminalOutcome.Normalize()
return

View File

@@ -3,6 +3,7 @@ package model
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
@@ -61,7 +62,7 @@ func (d *ExecuteDecision) UnmarshalJSON(data []byte) error {
Speak string `json:"speak,omitempty"`
Action ExecuteAction `json:"action"`
Reason string `json:"reason,omitempty"`
GoalCheck string `json:"goal_check,omitempty"`
GoalCheck json.RawMessage `json:"goal_check,omitempty"`
ToolCall json.RawMessage `json:"tool_call,omitempty"`
Abort json.RawMessage `json:"abort,omitempty"`
}
@@ -74,7 +75,11 @@ func (d *ExecuteDecision) UnmarshalJSON(data []byte) error {
d.Speak = raw.Speak
d.Action = raw.Action
d.Reason = raw.Reason
d.GoalCheck = raw.GoalCheck
goalCheck, err := decodeGoalCheckText(raw.GoalCheck)
if err != nil {
return fmt.Errorf("goal_check 解析失败: %w", err)
}
d.GoalCheck = goalCheck
toolCall, err := decodeOptionalJSONObject[ToolCallIntent](raw.ToolCall)
if err != nil {
@@ -91,6 +96,124 @@ func (d *ExecuteDecision) UnmarshalJSON(data []byte) error {
return nil
}
// decodeGoalCheckText 兼容 goal_check 的字符串/对象写法,统一降级为字符串。
//
// 步骤化说明:
// 1. 字符串:直接使用,保持主协议不变;
// 2. 对象:按 done_when/evidence 提取并拼接为单行证据文本;
// 3. 数组或其他标量:尽量转成可读字符串,避免仅因格式漂移导致整轮失败。
func decodeGoalCheckText(raw json.RawMessage) (string, error) {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" || trimmed == "null" {
return "", nil
}
// 1. 标准写法goal_check 为字符串。
if strings.HasPrefix(trimmed, "\"") {
var text string
if err := json.Unmarshal(raw, &text); err != nil {
return "", err
}
return strings.TrimSpace(text), nil
}
// 2. 兼容写法goal_check 被模型写成对象。
if strings.HasPrefix(trimmed, "{") {
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
return "", err
}
return compactGoalCheckObject(obj), nil
}
// 3. 兜底:数组/标量场景,尽量保留可读信息。
var generic any
if err := json.Unmarshal(raw, &generic); err != nil {
return "", err
}
return strings.TrimSpace(formatGoalCheckValue(generic)), nil
}
// compactGoalCheckObject 将对象型 goal_check 压缩为可读单行文本,优先提取 done_when/evidence。
func compactGoalCheckObject(obj map[string]any) string {
if len(obj) == 0 {
return ""
}
doneWhen := strings.TrimSpace(formatGoalCheckValue(obj["done_when"]))
evidence := strings.TrimSpace(formatGoalCheckValue(obj["evidence"]))
parts := make([]string, 0, 2)
if doneWhen != "" {
parts = append(parts, "已满足 done_when"+doneWhen)
}
if evidence != "" {
parts = append(parts, "证据:"+evidence)
}
if len(parts) > 0 {
return strings.Join(parts, "")
}
// done_when/evidence 缺失时,按 key 排序拼接,保证日志稳定可读。
keys := make([]string, 0, len(obj))
for key := range obj {
keys = append(keys, key)
}
sort.Strings(keys)
fallback := make([]string, 0, len(keys))
for _, key := range keys {
text := strings.TrimSpace(formatGoalCheckValue(obj[key]))
if text == "" {
continue
}
fallback = append(fallback, key+"="+text)
}
return strings.Join(fallback, "")
}
// formatGoalCheckValue 将任意值转成单行可读文本,用于 goal_check 压缩拼接。
func formatGoalCheckValue(value any) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(typed)
case bool:
if typed {
return "true"
}
return "false"
case []any:
parts := make([]string, 0, len(typed))
for _, item := range typed {
text := strings.TrimSpace(formatGoalCheckValue(item))
if text == "" {
continue
}
parts = append(parts, text)
}
return strings.Join(parts, "")
case map[string]any:
keys := make([]string, 0, len(typed))
for key := range typed {
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
text := strings.TrimSpace(formatGoalCheckValue(typed[key]))
if text == "" {
continue
}
parts = append(parts, key+"="+text)
}
return strings.Join(parts, "")
default:
return strings.TrimSpace(fmt.Sprintf("%v", typed))
}
}
// Normalize 统一清洗 execute 决策中的字符串字段。
func (d *ExecuteDecision) Normalize() {
if d == nil {

View File

@@ -12,6 +12,15 @@ const (
const PendingInteractionSnapshotVersion = 1
const (
// PendingMetaAskUserSpeakStreamed 表示 ask_user 文本已在上游节点流式推送过。
// interrupt 节点据此决定是否跳过二次正文推送,避免前端出现重复气泡。
PendingMetaAskUserSpeakStreamed = "ask_user_speak_streamed"
// PendingMetaAskUserHistoryAppended 表示 ask_user 文本已在上游写入过 history。
// interrupt 节点据此避免二次追加历史,防止上下文重复。
PendingMetaAskUserHistoryAppended = "ask_user_history_appended"
)
// PendingInteractionType 表示当前挂起交互的类型。
type PendingInteractionType string
@@ -179,6 +188,26 @@ func (s *AgentRuntimeState) ClearPendingInteraction() {
s.PendingInteraction = nil
}
// SetPendingInteractionMetadata 为当前 open 状态的 pending interaction 写入元信息。
//
// 职责边界:
// 1. 仅对当前挂起交互打运行态标记,不参与业务语义判断;
// 2. 若当前没有 pending interaction则静默跳过
// 3. metadata 仅用于节点间协作(如避免 ask_user 重复推送)。
func (s *AgentRuntimeState) SetPendingInteractionMetadata(key string, value any) {
if s == nil || s.PendingInteraction == nil || s.PendingInteraction.Status != PendingInteractionStatusOpen {
return
}
trimmedKey := strings.TrimSpace(key)
if trimmedKey == "" {
return
}
if s.PendingInteraction.Metadata == nil {
s.PendingInteraction.Metadata = make(map[string]any)
}
s.PendingInteraction.Metadata[trimmedKey] = value
}
func (s *AgentRuntimeState) openPendingInteraction(
interactionType PendingInteractionType,
interactionID string,

View File

@@ -55,6 +55,19 @@ type PlanDecision struct {
PlanSteps []PlanStep `json:"plan_steps,omitempty"`
NeedsRoughBuild bool `json:"needs_rough_build,omitempty"`
TaskClassIDs []int `json:"task_class_ids,omitempty"`
ContextHook *ContextHook `json:"context_hook,omitempty"`
}
// ContextHook 表示 plan 阶段给 execute 阶段的上下文注入建议。
//
// 职责边界:
// 1. 仅承载“建议激活哪个 domain/packs”不负责真正执行 context_tools_add/remove
// 2. domain 仅允许 schedule/taskclasspacks 仅允许 schedule 的可选包;
// 3. 该结构会在 execute 首轮被消费一次,消费后由后端清空。
type ContextHook struct {
Domain string `json:"domain,omitempty"`
Packs []string `json:"packs,omitempty"`
Reason string `json:"reason,omitempty"`
}
// Normalize 统一清洗规划决策中的字符串字段。
@@ -69,6 +82,9 @@ func (d *PlanDecision) Normalize() {
for i := range d.PlanSteps {
d.PlanSteps[i].Normalize()
}
if d.ContextHook != nil {
d.ContextHook.Normalize()
}
}
// Validate 校验规划决策的最小合法性。
@@ -102,6 +118,9 @@ func (d *PlanDecision) Validate() error {
if len(d.PlanSteps) > 0 {
return fmt.Errorf("%s 动作不应携带 plan_steps", d.Action)
}
if d.ContextHook != nil {
return fmt.Errorf("%s 动作不应携带 context_hook", d.Action)
}
return nil
case PlanActionDone:
if len(d.PlanSteps) == 0 {
@@ -112,6 +131,11 @@ func (d *PlanDecision) Validate() error {
return fmt.Errorf("plan_steps[%d] 非法: %w", i, err)
}
}
if d.ContextHook != nil {
if err := d.ContextHook.Validate(); err != nil {
return err
}
}
return nil
default:
return fmt.Errorf("未知 plan action: %s", d.Action)
@@ -149,3 +173,73 @@ func (s *PlanStep) Validate() error {
}
return nil
}
// Normalize 统一清洗 context hook 字段。
func (h *ContextHook) Normalize() {
if h == nil {
return
}
h.Domain = normalizeContextHookDomain(h.Domain)
h.Reason = strings.TrimSpace(h.Reason)
h.Packs = normalizeContextHookPacks(h.Domain, h.Packs)
}
// Validate 校验 context hook 最小合法性。
func (h *ContextHook) Validate() error {
if h == nil {
return nil
}
h.Normalize()
if h.Domain == "" {
return fmt.Errorf("context_hook.domain 非法,仅支持 schedule/taskclass")
}
if h.Domain == "taskclass" && len(h.Packs) > 0 {
return fmt.Errorf("context_hook.taskclass 暂不支持 packs")
}
return nil
}
func normalizeContextHookDomain(domain string) string {
switch strings.ToLower(strings.TrimSpace(domain)) {
case "schedule":
return "schedule"
case "taskclass":
return "taskclass"
default:
return ""
}
}
func normalizeContextHookPacks(domain string, packs []string) []string {
if domain != "schedule" || len(packs) == 0 {
return nil
}
allowed := map[string]struct{}{
"queue": {},
"mutation": {},
"analyze": {},
"detail_read": {},
"deep_analyze": {},
"web": {},
}
seen := make(map[string]struct{}, len(packs))
result := make([]string, 0, len(packs))
for _, raw := range packs {
pack := strings.ToLower(strings.TrimSpace(raw))
if pack == "" || pack == "core" {
continue
}
if _, ok := allowed[pack]; !ok {
continue
}
if _, exists := seen[pack]; exists {
continue
}
seen[pack] = struct{}{}
result = append(result, pack)
}
if len(result) == 0 {
return nil
}
return result
}