Version: 0.9.50.dev.260428

后端:
1. 工具执行结果协议升级为结构化 ToolExecutionResult——execute/tool_runtime、ToolRegistry、stream extra 与 timeline 持久化统一改为透传 observation_text / summary / argument_view / result_view,不再只回写纯文本结果;context_tools、upsert_task_class 与旧 schedule/web 工具通过兼容包装接入新协议
2. 日程写工具注册继续收口——place / move / swap / batch_move / unplace / queue_apply_head_move 从 registry 内联实现下沉为独立 handler,降低注册表内参数解析与业务逻辑混写
3. 工具结果展示基础能力补齐——新增 execution_result / schedule_operation_handlers 公共件,为日程操作结果、参数本地化展示、blocked/failed/done 状态统一建模

前端:
4. AssistantPanel 接入结构化工具卡片渲染——新增 ToolCardRenderer,tool_call / tool_result 支持 argument_view / result_view 展示;schedule_completed 恢复为时间线内的占位卡片块,避免排程卡片脱离原消息顺序
5. 时间线类型与渲染收敛——schedule_agent.ts 补齐 ToolView 协议,AssistantPanel 改为按块渲染 tool / schedule_card / business_card,并移除旧 demo/prototype 路由与页面,收束正式面板代码路径

仓库:
6. AGENTS.md 新增协作约束——禁止擅自回滚、覆盖或删除用户/其他代理产生的工作区改动
This commit is contained in:
LoveLosita
2026-04-28 11:55:34 +08:00
parent 32d5dd0262
commit 509e266626
17 changed files with 2431 additions and 2199 deletions

View File

@@ -21,7 +21,7 @@ func appendToolCallResultHistory(
conversationContext *newagentmodel.ConversationContext,
toolName string,
args map[string]any,
result string,
result newagenttools.ToolExecutionResult,
) {
if conversationContext == nil {
return
@@ -50,7 +50,7 @@ func appendToolCallResultHistory(
})
conversationContext.AppendHistory(&schema.Message{
Role: schema.Tool,
Content: result,
Content: result.ObservationText,
ToolCallID: toolCallID,
ToolName: toolName,
})
@@ -98,16 +98,9 @@ func executeToolCall(
return fmt.Errorf("连续 %d 次调用临时禁用工具,终止执行: %s",
flowState.ConsecutiveCorrections, toolName)
}
blockedResult := buildTemporarilyDisabledToolResult(toolName)
_ = emitter.EmitToolCallResult(
executeStatusBlockID,
executeStageName,
toolName,
"blocked",
blockedResult,
buildToolArgumentsPreviewCN(toolCall.Arguments),
false,
)
blockedText := buildTemporarilyDisabledToolResult(toolName)
blockedResult := newagenttools.BlockedResult(toolName, toolCall.Arguments, blockedText, "tool_temporarily_disabled", blockedText)
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, blockedResult, toolCall.Arguments)
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult)
newagentshared.AppendLLMCorrectionWithHint(
conversationContext,
@@ -165,16 +158,9 @@ func executeToolCall(
}
if shouldForceFeasibilityNegotiation(flowState, registry, toolName) {
blockedResult := buildInfeasibleBlockedResult(flowState)
_ = emitter.EmitToolCallResult(
executeStatusBlockID,
executeStageName,
toolName,
"blocked",
blockedResult,
buildToolArgumentsPreviewCN(toolCall.Arguments),
false,
)
blockedText := buildInfeasibleBlockedResult(flowState)
blockedResult := newagenttools.BlockedResult(toolName, toolCall.Arguments, blockedText, "health_negotiation_required", blockedText)
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, blockedResult, toolCall.Arguments)
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult)
return nil
}
@@ -187,9 +173,10 @@ func executeToolCall(
toolCall.Arguments["_user_id"] = flowState.UserID
}
result := registry.Execute(scheduleState, toolName, toolCall.Arguments)
updateHealthSnapshotV2(flowState, toolName, result)
updateTaskClassUpsertSnapshot(flowState, toolName, result)
updateActiveToolDomainSnapshot(flowState, toolName, result)
result = newagenttools.EnsureToolResultDefaults(result, toolCall.Arguments)
updateHealthSnapshotV2(flowState, toolName, result.ObservationText)
updateTaskClassUpsertSnapshot(flowState, toolName, result.ObservationText)
updateActiveToolDomainSnapshot(flowState, toolName, result.ObservationText)
afterDigest := summarizeScheduleStateForDebug(scheduleState)
log.Printf(
"[DEBUG] execute tool chat=%s round=%d tool=%s args=%s before=%s after=%s result_preview=%.200s",
@@ -199,17 +186,9 @@ func executeToolCall(
marshalArgsForDebug(toolCall.Arguments),
beforeDigest,
afterDigest,
flattenForLog(result),
)
_ = emitter.EmitToolCallResult(
executeStatusBlockID,
executeStageName,
toolName,
resolveToolEventResultStatus(result),
buildToolEventResultSummary(result),
buildToolArgumentsPreviewCN(toolCall.Arguments),
false,
flattenForLog(result.ObservationText),
)
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, result, toolCall.Arguments)
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, result)
@@ -301,32 +280,18 @@ func executePendingTool(
}
flowState := runtimeState.EnsureCommonState()
if registry.IsToolTemporarilyDisabled(pending.ToolName) {
blockedResult := buildTemporarilyDisabledToolResult(pending.ToolName)
_ = emitter.EmitToolCallResult(
executeStatusBlockID,
executeStageName,
pending.ToolName,
"blocked",
blockedResult,
buildToolArgumentsPreviewCN(args),
false,
)
blockedText := buildTemporarilyDisabledToolResult(pending.ToolName)
blockedResult := newagenttools.BlockedResult(pending.ToolName, args, blockedText, "tool_temporarily_disabled", blockedText)
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, blockedResult, args)
appendToolCallResultHistory(conversationContext, pending.ToolName, args, blockedResult)
runtimeState.PendingConfirmTool = nil
return nil
}
if shouldForceFeasibilityNegotiation(flowState, registry, pending.ToolName) {
blockedResult := buildInfeasibleBlockedResult(flowState)
_ = emitter.EmitToolCallResult(
executeStatusBlockID,
executeStageName,
pending.ToolName,
"blocked",
blockedResult,
buildToolArgumentsPreviewCN(args),
false,
)
blockedText := buildInfeasibleBlockedResult(flowState)
blockedResult := newagenttools.BlockedResult(pending.ToolName, args, blockedText, "health_negotiation_required", blockedText)
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, blockedResult, args)
appendToolCallResultHistory(conversationContext, pending.ToolName, args, blockedResult)
runtimeState.PendingConfirmTool = nil
return nil
@@ -340,9 +305,10 @@ func executePendingTool(
args["_user_id"] = flowState.UserID
}
result := registry.Execute(scheduleState, pending.ToolName, args)
updateHealthSnapshotV2(flowState, pending.ToolName, result)
updateTaskClassUpsertSnapshot(flowState, pending.ToolName, result)
updateActiveToolDomainSnapshot(flowState, pending.ToolName, result)
result = newagenttools.EnsureToolResultDefaults(result, args)
updateHealthSnapshotV2(flowState, pending.ToolName, result.ObservationText)
updateTaskClassUpsertSnapshot(flowState, pending.ToolName, result.ObservationText)
updateActiveToolDomainSnapshot(flowState, pending.ToolName, result.ObservationText)
afterDigest := summarizeScheduleStateForDebug(scheduleState)
log.Printf(
"[DEBUG] execute pending tool chat=%s round=%d tool=%s args=%s before=%s after=%s result_preview=%.200s",
@@ -352,17 +318,9 @@ func executePendingTool(
marshalArgsForDebug(args),
beforeDigest,
afterDigest,
flattenForLog(result),
)
_ = emitter.EmitToolCallResult(
executeStatusBlockID,
executeStageName,
pending.ToolName,
resolveToolEventResultStatus(result),
buildToolEventResultSummary(result),
buildToolArgumentsPreviewCN(args),
false,
flattenForLog(result.ObservationText),
)
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, result, args)
appendToolCallResultHistory(conversationContext, pending.ToolName, args, result)
@@ -434,3 +392,27 @@ func buildExecuteNormalizedSpeakTail(streamed, normalized string) string {
}
return normalized[len(streamed):]
}
func emitToolCallResultEvent(
emitter *newagentstream.ChunkEmitter,
blockID string,
stage string,
result newagenttools.ToolExecutionResult,
args map[string]any,
) {
if emitter == nil {
return
}
result = newagenttools.EnsureToolResultDefaults(result, args)
_ = emitter.EmitToolCallResult(
blockID,
stage,
result.Tool,
result.Status,
result.Summary,
result.ArgumentsPreview,
newagenttools.ToolArgumentViewToMap(result.ArgumentView),
newagenttools.ToolDisplayViewToMap(result.ResultView),
false,
)
}

View File

@@ -233,12 +233,31 @@ func (e *ChunkEmitter) EmitToolCallStart(blockID, stage, toolName, summary, argu
// 协议约束:
// 1. status 由调用方明确传入(如 done/blocked/failed
// 2. 结果事件只走 extra.tool不回写 reasoning_content。
func (e *ChunkEmitter) EmitToolCallResult(blockID, stage, toolName, status, summary, argumentsPreview string, includeRole bool) error {
func (e *ChunkEmitter) EmitToolCallResult(
blockID string,
stage string,
toolName string,
status string,
summary string,
argumentsPreview string,
argumentView map[string]any,
resultView map[string]any,
includeRole bool,
) error {
if e == nil || e.emit == nil {
return nil
}
_ = includeRole
return e.emitExtraOnly(NewToolResultExtra(blockID, stage, toolName, status, summary, argumentsPreview))
return e.emitExtraOnly(NewToolResultExtra(
blockID,
stage,
toolName,
status,
summary,
argumentsPreview,
argumentView,
resultView,
))
}
// emitExtraOnly 仅输出结构化 extra 事件,不附带 content/reasoning。

View File

@@ -87,10 +87,12 @@ type StreamStatusExtra struct {
// StreamToolExtra 表示一次工具调用相关事件。
type StreamToolExtra struct {
Name string `json:"name,omitempty"`
Status string `json:"status,omitempty"`
Summary string `json:"summary,omitempty"`
ArgumentsPreview string `json:"arguments_preview,omitempty"`
Name string `json:"name,omitempty"`
Status string `json:"status,omitempty"`
Summary string `json:"summary,omitempty"`
ArgumentsPreview string `json:"arguments_preview,omitempty"`
ArgumentView map[string]any `json:"argument_view,omitempty"`
ResultView map[string]any `json:"result_view,omitempty"`
}
// StreamConfirmExtra 表示一次待确认事件的展示摘要。
@@ -234,7 +236,16 @@ func NewToolCallExtra(blockID, stage, toolName, status, summary, argumentsPrevie
}
// NewToolResultExtra 创建“工具结果”事件的 extra。
func NewToolResultExtra(blockID, stage, toolName, status, summary, argumentsPreview string) *OpenAIChunkExtra {
func NewToolResultExtra(
blockID string,
stage string,
toolName string,
status string,
summary string,
argumentsPreview string,
argumentView map[string]any,
resultView map[string]any,
) *OpenAIChunkExtra {
return &OpenAIChunkExtra{
Kind: StreamExtraKindToolResult,
BlockID: blockID,
@@ -245,6 +256,8 @@ func NewToolResultExtra(blockID, stage, toolName, status, summary, argumentsPrev
Status: status,
Summary: summary,
ArgumentsPreview: argumentsPreview,
ArgumentView: argumentView,
ResultView: resultView,
},
}
}

View File

@@ -36,20 +36,20 @@ type contextToolsRemoveResult struct {
// 职责边界:
// 1. 仅负责校验 domain/mode/packs 并返回结构化结果,不直接修改流程状态;
// 2. 真正的“激活态写回”由 execute 节点根据工具结果回写 CommonState
// 3. schedule 支持可选 packstaskclass 前不支持可选 packs。
// 3. schedule 支持可选 packstaskclass 前不支持可选 packs。
func NewContextToolsAddHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) string {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
_ = state
domain := NormalizeToolDomain(readContextToolString(args["domain"]))
if domain == "" {
return marshalContextToolsAddResult(contextToolsAddResult{
return LegacyResult(ToolNameContextToolsAdd, args, marshalContextToolsAddResult(contextToolsAddResult{
Tool: ToolNameContextToolsAdd,
Success: false,
Action: "reject",
Error: "参数非法domain 仅支持 schedule/taskclass",
ErrorCode: "invalid_domain",
})
}))
}
mode := strings.ToLower(strings.TrimSpace(readContextToolString(args["mode"])))
@@ -57,27 +57,27 @@ func NewContextToolsAddHandler() ToolHandler {
mode = "replace"
}
if mode != "replace" && mode != "merge" {
return marshalContextToolsAddResult(contextToolsAddResult{
return LegacyResult(ToolNameContextToolsAdd, args, marshalContextToolsAddResult(contextToolsAddResult{
Tool: ToolNameContextToolsAdd,
Success: false,
Action: "reject",
Domain: domain,
Error: "参数非法mode 仅支持 replace/merge",
ErrorCode: "invalid_mode",
})
}))
}
packsRaw := readContextToolStringSlice(args["packs"])
packs, errCode, errText := validateContextPacks(domain, packsRaw, false)
if errCode != "" {
return marshalContextToolsAddResult(contextToolsAddResult{
return LegacyResult(ToolNameContextToolsAdd, args, marshalContextToolsAddResult(contextToolsAddResult{
Tool: ToolNameContextToolsAdd,
Success: false,
Action: "reject",
Domain: domain,
Error: errText,
ErrorCode: errCode,
})
}))
}
// schedule 未显式传 packs 时默认启用最小可用包mutation + analyze
@@ -85,7 +85,7 @@ func NewContextToolsAddHandler() ToolHandler {
packs = ResolveEffectiveToolPacks(domain, nil)
}
return marshalContextToolsAddResult(contextToolsAddResult{
return LegacyResult(ToolNameContextToolsAdd, args, marshalContextToolsAddResult(contextToolsAddResult{
Tool: ToolNameContextToolsAdd,
Success: true,
Action: "activate",
@@ -93,7 +93,7 @@ func NewContextToolsAddHandler() ToolHandler {
Packs: packs,
Mode: mode,
Message: "已激活工具域,可继续调用对应业务工具。",
})
}))
}
}
@@ -101,10 +101,10 @@ func NewContextToolsAddHandler() ToolHandler {
//
// 职责边界:
// 1. 仅解析 domain/all/packs 语义并返回结构化结果,不直接触碰上下文存储;
// 2. all=true 表示清空动态区业务工具domain+packs 表示移除该域下指定二级包;
// 2. all=true 表示清空动态区业务工具domain+packs 表示移除该域下指定二级包;
// 3. 仅 schedule 支持按 packs 移除,且 core 不允许显式移除。
func NewContextToolsRemoveHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) string {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
_ = state
all := readContextToolBool(args["all"])
@@ -116,56 +116,56 @@ func NewContextToolsRemoveHandler() ToolHandler {
all = true
}
if all {
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{
Tool: ToolNameContextToolsRemove,
Success: true,
Action: "clear_all",
All: true,
Message: "已移除全部业务工具域,仅保留上下文管理工具。",
})
}))
}
domain := NormalizeToolDomain(domainRaw)
if domain == "" {
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{
Tool: ToolNameContextToolsRemove,
Success: false,
Action: "reject",
Error: "参数非法:需提供 domain=schedule/taskclass 或 all=true",
ErrorCode: "invalid_domain",
})
}))
}
packs, errCode, errText := validateContextPacks(domain, packsRaw, true)
if errCode != "" {
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{
Tool: ToolNameContextToolsRemove,
Success: false,
Action: "reject",
Domain: domain,
Error: errText,
ErrorCode: errCode,
})
}))
}
if len(packs) > 0 {
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{
Tool: ToolNameContextToolsRemove,
Success: true,
Action: "deactivate_packs",
Domain: domain,
Packs: packs,
Message: "已移除指定工具包。",
})
}))
}
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{
Tool: ToolNameContextToolsRemove,
Success: true,
Action: "deactivate",
Domain: domain,
Message: "已移除指定工具域。",
})
}))
}
}

View File

@@ -0,0 +1,773 @@
package newagenttools
import (
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
)
const (
ToolStatusDone = "done"
ToolStatusFailed = "failed"
ToolStatusBlocked = "blocked"
)
// ToolDisplayView 描述工具结果的结构化展示视图。
type ToolDisplayView struct {
ViewType string `json:"view_type,omitempty"`
Version int `json:"version,omitempty"`
Collapsed map[string]any `json:"collapsed,omitempty"`
Expanded map[string]any `json:"expanded,omitempty"`
}
// ToolArgumentView 描述工具参数的结构化展示视图。
type ToolArgumentView struct {
ViewType string `json:"view_type,omitempty"`
Version int `json:"version,omitempty"`
Collapsed map[string]any `json:"collapsed,omitempty"`
Expanded map[string]any `json:"expanded,omitempty"`
}
// ToolExecutionResult 是 newAgent 工具主接口的统一结果结构。
//
// 职责边界:
// 1. 负责承载 execute、SSE、timeline 所需的最小公共字段;
// 2. 负责保留 ObservationText保证第一阶段 LLM 观察文本不变;
// 3. 不负责具体工具业务语义,工具语义由各工具 handler 决定。
type ToolExecutionResult struct {
Tool string `json:"tool,omitempty"`
Status string `json:"status,omitempty"` // done / failed / blocked
Success bool `json:"success"`
ObservationText string `json:"observation_text,omitempty"`
Summary string `json:"summary,omitempty"`
ArgumentsPreview string `json:"arguments_preview,omitempty"`
ArgumentView *ToolArgumentView `json:"argument_view,omitempty"`
ResultView *ToolDisplayView `json:"result_view,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
// LegacyResult 用于未做专属卡片的工具兜底。
func LegacyResult(toolName string, args map[string]any, oldText string) ToolExecutionResult {
return LegacyResultWithState(toolName, args, nil, oldText)
}
// LegacyResultWithState 在 LegacyResult 基础上支持读取 ScheduleState 补齐中文参数展示。
func LegacyResultWithState(toolName string, args map[string]any, state *schedule.ScheduleState, oldText string) ToolExecutionResult {
status, success := resolveToolStatusAndSuccess(oldText)
errorCode, errorMessage := extractToolErrorInfo(oldText, status)
tool := strings.TrimSpace(toolName)
toolLabel := resolveToolLabelCN(tool)
argumentView := buildLocalizedArgumentView(tool, args, state)
argumentsPreview := readArgumentSummary(argumentView)
result := ToolExecutionResult{
Tool: tool,
Status: status,
Success: success,
ObservationText: oldText,
Summary: buildToolSummary(oldText),
ArgumentsPreview: argumentsPreview,
ArgumentView: argumentView,
ResultView: &ToolDisplayView{
ViewType: "legacy_text",
Version: 1,
Collapsed: map[string]any{
"title": buildLegacyTitle(toolLabel, status),
"status": status,
"status_label": resolveToolStatusLabelCN(status),
"tool": tool,
"tool_label": toolLabel,
"has_output": strings.TrimSpace(oldText) != "",
},
Expanded: map[string]any{
"raw_text_label": "原始结果",
"raw_text": oldText,
},
},
ErrorCode: errorCode,
ErrorMessage: errorMessage,
}
return ensureToolResultDefaults(result, args)
}
// BlockedResult 构造被拦截类结果,供 execute 链路统一复用。
func BlockedResult(toolName string, args map[string]any, observationText, errorCode, errorMessage string) ToolExecutionResult {
result := LegacyResult(toolName, args, observationText)
result.Status = ToolStatusBlocked
result.Success = false
result.ErrorCode = strings.TrimSpace(errorCode)
result.ErrorMessage = strings.TrimSpace(errorMessage)
if result.ResultView != nil {
if result.ResultView.Collapsed == nil {
result.ResultView.Collapsed = make(map[string]any)
}
result.ResultView.Collapsed["status"] = ToolStatusBlocked
result.ResultView.Collapsed["status_label"] = resolveToolStatusLabelCN(ToolStatusBlocked)
result.ResultView.Collapsed["title"] = buildLegacyTitle(resolveToolLabelCN(toolName), ToolStatusBlocked)
}
return ensureToolResultDefaults(result, args)
}
// EnsureToolResultDefaults 负责兜底补齐 execute 侧依赖字段,避免空值扩散。
func EnsureToolResultDefaults(result ToolExecutionResult, args map[string]any) ToolExecutionResult {
return ensureToolResultDefaults(result, args)
}
func ensureToolResultDefaults(result ToolExecutionResult, args map[string]any) ToolExecutionResult {
if strings.TrimSpace(result.Tool) == "" {
result.Tool = "unknown_tool"
}
if strings.TrimSpace(result.Status) == "" {
if result.Success {
result.Status = ToolStatusDone
} else {
result.Status = ToolStatusFailed
}
}
if strings.TrimSpace(result.Summary) == "" {
result.Summary = buildToolSummary(result.ObservationText)
}
if result.ArgumentView == nil {
result.ArgumentView = buildLocalizedArgumentView(result.Tool, args, nil)
}
if strings.TrimSpace(result.ArgumentsPreview) == "" {
result.ArgumentsPreview = readArgumentSummary(result.ArgumentView)
}
if strings.TrimSpace(result.ArgumentsPreview) == "" && len(args) > 0 {
result.ArgumentsPreview = fmt.Sprintf("共 %d 个参数", len(args))
}
if result.ResultView == nil {
result.ResultView = &ToolDisplayView{
ViewType: "legacy_text",
Version: 1,
Collapsed: map[string]any{
"title": buildLegacyTitle(resolveToolLabelCN(result.Tool), result.Status),
"status": result.Status,
"status_label": resolveToolStatusLabelCN(result.Status),
"tool": strings.TrimSpace(result.Tool),
"tool_label": resolveToolLabelCN(result.Tool),
"has_output": strings.TrimSpace(result.ObservationText) != "",
},
Expanded: map[string]any{
"raw_text_label": "原始结果",
"raw_text": result.ObservationText,
},
}
}
return result
}
// ToolArgumentViewToMap 把参数视图转换成 stream/timeline 可直接落库的 map。
func ToolArgumentViewToMap(view *ToolArgumentView) map[string]any {
if view == nil {
return nil
}
out := map[string]any{
"view_type": strings.TrimSpace(view.ViewType),
"version": view.Version,
}
if len(view.Collapsed) > 0 {
out["collapsed"] = cloneAnyMap(view.Collapsed)
}
if len(view.Expanded) > 0 {
out["expanded"] = cloneAnyMap(view.Expanded)
}
return out
}
// ToolDisplayViewToMap 把结果视图转换成 stream/timeline 可直接落库的 map。
func ToolDisplayViewToMap(view *ToolDisplayView) map[string]any {
if view == nil {
return nil
}
out := map[string]any{
"view_type": strings.TrimSpace(view.ViewType),
"version": view.Version,
}
if len(view.Collapsed) > 0 {
out["collapsed"] = cloneAnyMap(view.Collapsed)
}
if len(view.Expanded) > 0 {
out["expanded"] = cloneAnyMap(view.Expanded)
}
return out
}
func resolveToolStatusAndSuccess(observation string) (string, bool) {
trimmed := strings.TrimSpace(observation)
if trimmed == "" {
return ToolStatusDone, true
}
// 1. 优先解析 JSON 结构字段,避免依赖自然语言文本。
// 2. 若 JSON 明确给出 success/status/error则以结构字段为准。
// 3. 仅在无法结构化解析时,回退关键词兜底。
if payload, ok := parseObservationJSON(trimmed); ok {
if statusText, ok := readStringFromMap(payload, "status"); ok {
status := normalizeToolStatus(statusText)
if status != "" {
return status, status == ToolStatusDone
}
}
if blocked, ok := readBoolFromMap(payload, "blocked"); ok && blocked {
return ToolStatusBlocked, false
}
if success, ok := readBoolFromMap(payload, "success"); ok {
if success {
return ToolStatusDone, true
}
return ToolStatusFailed, false
}
if errText, ok := readStringFromMap(payload, "error", "err"); ok && strings.TrimSpace(errText) != "" {
return ToolStatusFailed, false
}
}
lower := strings.ToLower(trimmed)
if strings.Contains(trimmed, "阻断") || strings.Contains(trimmed, "禁用") || strings.Contains(lower, "blocked") {
return ToolStatusBlocked, false
}
if strings.Contains(trimmed, "失败") || strings.Contains(lower, "failed") || strings.Contains(lower, "error") {
return ToolStatusFailed, false
}
return ToolStatusDone, true
}
func normalizeToolStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case ToolStatusDone:
return ToolStatusDone
case ToolStatusFailed:
return ToolStatusFailed
case ToolStatusBlocked:
return ToolStatusBlocked
default:
return ""
}
}
func extractToolErrorInfo(observation string, status string) (string, string) {
trimmed := strings.TrimSpace(observation)
if trimmed == "" || status == ToolStatusDone {
return "", ""
}
if payload, ok := parseObservationJSON(trimmed); ok {
errorCode, _ := readStringFromMap(payload, "error_code", "code")
errorMessage, _ := readStringFromMap(payload, "error", "err", "message", "reason")
if strings.TrimSpace(errorCode) == "" && status == ToolStatusBlocked {
errorCode = "blocked"
}
if strings.TrimSpace(errorMessage) != "" {
return strings.TrimSpace(errorCode), strings.TrimSpace(errorMessage)
}
if status == ToolStatusBlocked {
return strings.TrimSpace(errorCode), "工具被策略阻断"
}
return strings.TrimSpace(errorCode), ""
}
if status == ToolStatusBlocked {
return "blocked", trimmed
}
return "", trimmed
}
func buildToolSummary(observation string) string {
trimmed := strings.TrimSpace(observation)
if trimmed == "" {
return "工具已执行完成。"
}
if payload, ok := parseObservationJSON(trimmed); ok {
if errText, ok := readStringFromMap(payload, "error", "err", "message"); ok && strings.TrimSpace(errText) != "" {
return truncateSummary(fmt.Sprintf("执行失败:%s", strings.TrimSpace(errText)))
}
if message, ok := readStringFromMap(payload, "result", "summary", "reason", "message"); ok && strings.TrimSpace(message) != "" {
return truncateSummary(strings.TrimSpace(message))
}
if success, ok := readBoolFromMap(payload, "success"); ok && success {
return "工具执行成功。"
}
}
flat := strings.Join(strings.Fields(trimmed), " ")
return truncateSummary(flat)
}
func truncateSummary(text string) string {
runes := []rune(strings.TrimSpace(text))
if len(runes) <= 48 {
return string(runes)
}
return string(runes[:48]) + "..."
}
func buildLegacyTitle(toolLabel string, status string) string {
switch normalizeToolStatus(status) {
case ToolStatusDone:
return fmt.Sprintf("%s已完成", strings.TrimSpace(toolLabel))
case ToolStatusBlocked:
return fmt.Sprintf("%s已阻断", strings.TrimSpace(toolLabel))
default:
return fmt.Sprintf("%s失败", strings.TrimSpace(toolLabel))
}
}
func resolveToolStatusLabelCN(status string) string {
switch normalizeToolStatus(status) {
case ToolStatusDone:
return "已完成"
case ToolStatusBlocked:
return "已阻断"
default:
return "失败"
}
}
func resolveToolLabelCN(toolName string) string {
name := strings.TrimSpace(toolName)
switch name {
case "query_available_slots":
return "查询可用时段"
case "query_target_tasks":
return "查询目标任务"
case "queue_status":
return "查看队列状态"
case "queue_pop_head":
return "获取队首任务"
case "queue_skip_head":
return "跳过队首任务"
case "analyze_health":
return "综合体检"
case "analyze_rhythm":
return "分析学习节奏"
case "web_search":
return "网页搜索"
case "web_fetch":
return "网页抓取"
case "upsert_task_class":
return "写入任务类"
case ToolNameContextToolsAdd:
return "激活工具域"
case ToolNameContextToolsRemove:
return "移除工具域"
case "move":
return "移动任务"
case "place":
return "预排任务"
case "swap":
return "交换任务"
case "batch_move":
return "批量移动"
case "unplace":
return "移出任务"
case "queue_apply_head_move":
return "应用队首任务"
case "get_overview":
return "查看总览"
case "query_range":
return "查询时间范围"
case "get_task_info":
return "查看任务信息"
default:
if name == "" {
return "工具"
}
return name
}
}
func resolveOperationLabelCN(operation string) string {
switch strings.TrimSpace(operation) {
case "move":
return "移动任务"
case "place":
return "预排任务"
case "swap":
return "交换任务"
case "batch_move":
return "批量移动"
case "unplace":
return "移出任务"
case "queue_apply_head_move":
return "应用队首任务"
default:
return resolveToolLabelCN(operation)
}
}
func readArgumentSummary(view *ToolArgumentView) string {
if view == nil || len(view.Collapsed) == 0 {
return ""
}
summary, ok := view.Collapsed["summary"].(string)
if !ok {
return ""
}
return strings.TrimSpace(summary)
}
func buildLocalizedArgumentView(toolName string, args map[string]any, state *schedule.ScheduleState) *ToolArgumentView {
fields := buildArgumentFields(toolName, args, state)
summary := buildArgumentSummary(fields)
if summary == "" {
summary = "无参数"
}
return &ToolArgumentView{
ViewType: "tool.arguments",
Version: 1,
Collapsed: map[string]any{
"summary": summary,
"args_count": len(args),
},
Expanded: map[string]any{
"args": cloneAnyMap(args),
"fields": fields,
},
}
}
func buildArgumentFields(toolName string, args map[string]any, state *schedule.ScheduleState) []map[string]any {
if len(args) == 0 {
return make([]map[string]any, 0)
}
keys := make([]string, 0, len(args))
for key := range args {
if strings.TrimSpace(key) == "_user_id" {
continue
}
keys = append(keys, key)
}
sort.SliceStable(keys, func(i, j int) bool {
leftRank := argumentDisplayRank(keys[i])
rightRank := argumentDisplayRank(keys[j])
if leftRank != rightRank {
return leftRank < rightRank
}
return keys[i] < keys[j]
})
fields := make([]map[string]any, 0, len(keys))
for _, key := range keys {
raw := args[key]
label := resolveArgumentLabelCN(strings.TrimSpace(key))
display := formatArgumentDisplay(toolName, strings.TrimSpace(key), raw, args, state)
field := map[string]any{
"key": key,
"label": label,
"value": raw,
"display": display,
}
fields = append(fields, field)
}
return fields
}
func argumentDisplayRank(key string) int {
switch strings.TrimSpace(key) {
case "task_id", "task_a", "task_b":
return 10
case "day", "new_day":
return 20
case "slot_start", "new_slot_start":
return 30
case "moves":
return 40
case "reason":
return 50
default:
return 100
}
}
func buildArgumentSummary(fields []map[string]any) string {
if len(fields) == 0 {
return ""
}
items := make([]string, 0, 2)
for _, field := range fields {
label, _ := field["label"].(string)
display, _ := field["display"].(string)
label = strings.TrimSpace(label)
display = strings.TrimSpace(display)
if label == "" || display == "" {
continue
}
items = append(items, fmt.Sprintf("%s%s", label, display))
if len(items) >= 2 {
break
}
}
if len(items) == 0 {
return fmt.Sprintf("共 %d 个参数", len(fields))
}
if len(fields) > len(items) {
return strings.Join(items, "") + fmt.Sprintf(" 等 %d 项", len(fields))
}
return strings.Join(items, "")
}
func resolveArgumentLabelCN(key string) string {
switch strings.TrimSpace(key) {
case "task_id":
return "任务"
case "task_a":
return "任务A"
case "task_b":
return "任务B"
case "day":
return "目标日期"
case "new_day":
return "目标日期"
case "slot_start":
return "目标时段"
case "new_slot_start":
return "目标时段"
case "moves":
return "移动列表"
case "reason":
return "原因"
case "status":
return "状态"
case "limit":
return "数量"
case "query":
return "查询内容"
case "url":
return "链接"
default:
return "参数"
}
}
func formatArgumentDisplay(
toolName string,
key string,
value any,
args map[string]any,
state *schedule.ScheduleState,
) string {
switch key {
case "task_id", "task_a", "task_b":
if taskID, ok := toInt(value); ok {
return resolveTaskLabelByID(state, taskID, true)
}
case "day", "new_day":
if day, ok := toInt(value); ok {
return formatDayLabelCN(day)
}
case "slot_start", "new_slot_start":
if slotStart, ok := toInt(value); ok {
slotEnd := slotStart
if taskID, ok := toInt(args["task_id"]); ok {
if task := stateTaskByID(state, taskID); task != nil && task.Duration > 1 {
slotEnd = slotStart + task.Duration - 1
}
}
if day, ok := toInt(args["day"]); ok {
return fmt.Sprintf("%s%s", formatDayLabelCN(day), formatSlotRangeCN(slotStart, slotEnd))
}
if day, ok := toInt(args["new_day"]); ok {
return fmt.Sprintf("%s%s", formatDayLabelCN(day), formatSlotRangeCN(slotStart, slotEnd))
}
return formatSlotRangeCN(slotStart, slotEnd)
}
case "moves":
return formatMovesArgumentCN(value, state)
}
return formatAnyValueCN(value)
}
func formatMovesArgumentCN(value any, state *schedule.ScheduleState) string {
list, ok := value.([]any)
if !ok {
return formatAnyValueCN(value)
}
if len(list) == 0 {
return "空"
}
parts := make([]string, 0, len(list))
for _, item := range list {
move, ok := item.(map[string]any)
if !ok {
continue
}
taskID, _ := toInt(move["task_id"])
day, _ := toInt(move["new_day"])
slotStart, _ := toInt(move["new_slot_start"])
taskLabel := resolveTaskLabelByID(state, taskID, false)
slotEnd := slotStart
if task := stateTaskByID(state, taskID); task != nil && task.Duration > 1 {
slotEnd = slotStart + task.Duration - 1
}
if day > 0 && slotStart > 0 {
parts = append(parts, fmt.Sprintf("%s→%s%s", taskLabel, formatDayLabelCN(day), formatSlotRangeCN(slotStart, slotEnd)))
}
}
if len(parts) == 0 {
return fmt.Sprintf("%d 项", len(list))
}
if len(parts) > 3 {
return strings.Join(parts[:3], "") + fmt.Sprintf(" 等 %d 项", len(parts))
}
return strings.Join(parts, "")
}
func formatAnyValueCN(value any) string {
switch typed := value.(type) {
case string:
text := strings.TrimSpace(typed)
if text == "" {
return "空"
}
return text
case int:
return fmt.Sprintf("%d", typed)
case int8:
return fmt.Sprintf("%d", typed)
case int16:
return fmt.Sprintf("%d", typed)
case int32:
return fmt.Sprintf("%d", typed)
case int64:
return fmt.Sprintf("%d", typed)
case float32:
return fmt.Sprintf("%g", typed)
case float64:
return fmt.Sprintf("%g", typed)
case bool:
if typed {
return "是"
}
return "否"
case []any:
return fmt.Sprintf("%d 项", len(typed))
default:
if value == nil {
return "空"
}
raw, err := json.Marshal(value)
if err != nil {
return fmt.Sprintf("%v", value)
}
return strings.TrimSpace(string(raw))
}
}
func resolveTaskLabelByID(state *schedule.ScheduleState, taskID int, withID bool) string {
if taskID <= 0 {
return "未知任务"
}
task := stateTaskByID(state, taskID)
if task == nil {
if withID {
return fmt.Sprintf("[%d]任务", taskID)
}
return fmt.Sprintf("任务%d", taskID)
}
name := strings.TrimSpace(task.Name)
if name == "" {
name = "任务"
}
if withID {
return fmt.Sprintf("[%d]%s", task.StateID, name)
}
return name
}
func stateTaskByID(state *schedule.ScheduleState, taskID int) *schedule.ScheduleTask {
if state == nil || taskID <= 0 {
return nil
}
return state.TaskByStateID(taskID)
}
func formatDayLabelCN(day int) string {
if day <= 0 {
return "未知日期"
}
return fmt.Sprintf("第%d天", day)
}
func formatSlotRangeCN(start int, end int) string {
if start <= 0 && end <= 0 {
return "未知时段"
}
if end <= 0 {
end = start
}
if end < start {
end = start
}
return fmt.Sprintf("第%d-%d节", start, end)
}
func toInt(value any) (int, bool) {
switch typed := value.(type) {
case int:
return typed, true
case int8:
return int(typed), true
case int16:
return int(typed), true
case int32:
return int(typed), true
case int64:
return int(typed), true
case float32:
return int(typed), true
case float64:
return int(typed), true
default:
return 0, false
}
}
func parseObservationJSON(text string) (map[string]any, bool) {
var payload map[string]any
if err := json.Unmarshal([]byte(text), &payload); err != nil {
return nil, false
}
return payload, true
}
func readStringFromMap(payload map[string]any, keys ...string) (string, bool) {
for _, key := range keys {
raw, exists := payload[key]
if !exists || raw == nil {
continue
}
text := strings.TrimSpace(fmt.Sprintf("%v", raw))
if text == "" || text == "<nil>" {
continue
}
return text, true
}
return "", false
}
func readBoolFromMap(payload map[string]any, key string) (bool, bool) {
raw, exists := payload[key]
if !exists {
return false, false
}
value, ok := raw.(bool)
return value, ok
}
func cloneAnyMap(input map[string]any) map[string]any {
if len(input) == 0 {
return nil
}
out := make(map[string]any, len(input))
for k, v := range input {
out[k] = v
}
return out
}

View File

@@ -11,11 +11,12 @@ import (
)
// ToolHandler 约定所有工具的统一执行签名。
//
// 职责边界:
// 1. 负责消费当前 ScheduleState 与模型传入参数;
// 2. 返回统一 string 结果,供 execute 节点写回 observation
// 2. 返回 ToolExecutionResult,供 execute 节点写回 observation 与结构化事件
// 3. 不负责 confirm、上下文注入、轮次控制这些由上层节点处理。
type ToolHandler func(state *schedule.ScheduleState, args map[string]any) string
type ToolHandler func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult
// ToolSchemaEntry 描述注入给模型的工具快照。
type ToolSchemaEntry struct {
@@ -25,6 +26,7 @@ type ToolSchemaEntry struct {
}
// DefaultRegistryDeps 描述默认注册表需要的外部依赖。
//
// 职责边界:
// 1. 这里只承载工具层需要的依赖注入,不承载业务状态;
// 2. 某些依赖即便暂未使用也允许保留,避免业务层重新到处 new
@@ -48,6 +50,7 @@ type ToolRegistry struct {
}
// temporaryDisabledTools 描述“已注册但当前阶段临时禁用”的工具。
//
// 设计说明:
// 1. 这些工具仍保留定义,避免 prompt / 旧链路 / 历史日志里出现悬空名字;
// 2. execute 会在调用前统一阻断,并向模型返回纠错提示;
@@ -84,19 +87,27 @@ func (r *ToolRegistry) Register(name, desc, schemaText string, handler ToolHandl
}
// Execute 执行指定工具。
//
// 职责边界:
// 1. 这里只负责找到 handler 并调用;
// 2. 工具临时禁用直接返回只读失败文案,不进入 handler
// 3. 不负责参数 schema 级纠错,具体参数错误交由 handler 返回
func (r *ToolRegistry) Execute(state *schedule.ScheduleState, toolName string, args map[string]any) string {
// 2. 工具临时禁用直接返回 blocked 结构化结果,不进入 handler
// 3. 参数 schema 级纠错由 handler 内处理
func (r *ToolRegistry) Execute(state *schedule.ScheduleState, toolName string, args map[string]any) ToolExecutionResult {
if r.IsToolTemporarilyDisabled(toolName) {
return fmt.Sprintf("工具 %q 当前阶段已临时禁用,请优先使用 analyze_health、move、swap 等当前主链工具。", strings.TrimSpace(toolName))
observation := fmt.Sprintf("工具 %q 当前阶段已临时禁用,请优先使用 analyze_health、move、swap 等当前主链工具。", strings.TrimSpace(toolName))
return BlockedResult(toolName, args, observation, "tool_temporarily_disabled", observation)
}
handler, ok := r.handlers[toolName]
if !ok {
return fmt.Sprintf("工具调用失败:未知工具 %q。可用工具%s", toolName, strings.Join(r.ToolNames(), "、"))
observation := fmt.Sprintf("工具调用失败:未知工具 %q。可用工具%s", toolName, strings.Join(r.ToolNames(), "、"))
result := LegacyResult(toolName, args, observation)
result.Status = ToolStatusFailed
result.Success = false
result.ErrorCode = "unknown_tool"
result.ErrorMessage = observation
return EnsureToolResultDefaults(result, args)
}
return handler(state, args)
return EnsureToolResultDefaults(handler(state, args), args)
}
// HasTool 判断工具是否已注册且当前可见。
@@ -138,6 +149,7 @@ func (r *ToolRegistry) Schemas() []ToolSchemaEntry {
}
// SchemasForActiveDomain 返回某业务域当前真正可见的工具 schema。
//
// 职责边界:
// 1. context_tools_add/remove 始终保留,用于动态区协议;
// 2. 仅当工具域已激活时,才暴露该域下可见工具;
@@ -209,7 +221,7 @@ func (r *ToolRegistry) IsWriteTool(name string) bool {
}
// IsScheduleMutationTool 判断工具是否会真实修改 ScheduleState 中的日程布局。
// 说明:upsert_task_class 会写库,但不修改当前日程预览,因此不计入此集合。
// upsert_task_class 会写库,但不修改当前日程预览,因此不计入此集合。
func (r *ToolRegistry) IsScheduleMutationTool(name string) bool {
return scheduleMutationTools[strings.TrimSpace(name)]
}
@@ -253,6 +265,7 @@ func NewDefaultRegistry() *ToolRegistry {
}
// NewDefaultRegistryWithDeps 创建带依赖的默认注册表。
//
// 步骤化说明:
// 1. 先注册上下文管理工具,保证动态区协议随时可用;
// 2. 再注册 schedule 域的读、诊断、写工具;
@@ -293,66 +306,66 @@ func registerScheduleReadTools(r *ToolRegistry) {
"get_overview",
"获取当前窗口总览:保留课程占位统计,展开任务清单。",
`{"name":"get_overview","parameters":{}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
wrapLegacyToolHandler("get_overview", func(state *schedule.ScheduleState, args map[string]any) string {
_ = args
return schedule.GetOverview(state)
},
}),
)
r.Register(
"query_range",
"查看某天或某时段的占用详情。day 必填slot_start/slot_end 选填。",
`{"name":"query_range","parameters":{"day":{"type":"int","required":true},"slot_start":{"type":"int"},"slot_end":{"type":"int"}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
wrapLegacyToolHandler("query_range", func(state *schedule.ScheduleState, args map[string]any) string {
day, ok := schedule.ArgsInt(args, "day")
if !ok {
return "查询失败:缺少必填参数 day。"
}
return schedule.QueryRange(state, day, schedule.ArgsIntPtr(args, "slot_start"), schedule.ArgsIntPtr(args, "slot_end"))
},
}),
)
r.Register(
"query_available_slots",
"查询候选空位池,适合 move 前筛落点。",
`{"name":"query_available_slots","parameters":{"span":{"type":"int"},"duration":{"type":"int"},"limit":{"type":"int"},"allow_embed":{"type":"bool"},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"slot_type":{"type":"string"},"slot_types":{"type":"array","items":{"type":"string"}},"exclude_sections":{"type":"array","items":{"type":"int"}},"after_section":{"type":"int"},"before_section":{"type":"int"},"section_from":{"type":"int"},"section_to":{"type":"int"}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
wrapLegacyToolHandler("query_available_slots", func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.QueryAvailableSlots(state, args)
},
}),
)
r.Register(
"query_target_tasks",
"查询候选任务集合,可按 status/week/day/task_id/category 筛选;支持 enqueue。",
`{"name":"query_target_tasks","parameters":{"status":{"type":"string","enum":["all","existing","suggested","pending"]},"category":{"type":"string"},"limit":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"task_ids":{"type":"array","items":{"type":"int"}},"task_id":{"type":"int"},"task_item_ids":{"type":"array","items":{"type":"int"}},"task_item_id":{"type":"int"},"enqueue":{"type":"bool"},"reset_queue":{"type":"bool"}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
wrapLegacyToolHandler("query_target_tasks", func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.QueryTargetTasks(state, args)
},
}),
)
r.Register(
"queue_pop_head",
"弹出并返回当前队首任务;若已有 current 则复用。",
`{"name":"queue_pop_head","parameters":{}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
wrapLegacyToolHandler("queue_pop_head", func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.QueuePopHead(state, args)
},
}),
)
r.Register(
"queue_status",
"查看当前队列状态pending/current/completed/skipped。",
`{"name":"queue_status","parameters":{}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
wrapLegacyToolHandler("queue_status", func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.QueueStatus(state, args)
},
}),
)
r.Register(
"get_task_info",
"查看单个任务详情,包括类别、状态与落位。",
`{"name":"get_task_info","parameters":{"task_id":{"type":"int","required":true}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
wrapLegacyToolHandler("get_task_info", func(state *schedule.ScheduleState, args map[string]any) string {
taskID, ok := schedule.ArgsInt(args, "task_id")
if !ok {
return "查询失败:缺少必填参数 task_id。"
}
return schedule.GetTaskInfo(state, taskID)
},
}),
)
}
@@ -361,17 +374,17 @@ func registerScheduleAnalyzeTools(r *ToolRegistry) {
"analyze_rhythm",
"分析学习节奏与切换情况。",
`{"name":"analyze_rhythm","parameters":{"category":{"type":"string"},"include_pending":{"type":"bool"},"detail":{"type":"string","enum":["summary","full"]},"hard_categories":{"type":"array","items":{"type":"string"}}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
wrapLegacyToolHandler("analyze_rhythm", func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.AnalyzeRhythm(state, args)
},
}),
)
r.Register(
"analyze_health",
"主动优化裁判入口:聚焦 rhythm/semantic_profile/tightness判断当前是否还值得继续优化并给出候选。",
`{"name":"analyze_health","parameters":{"detail":{"type":"string","enum":["summary","full"]},"dimensions":{"type":"array","items":{"type":"string"}},"threshold":{"type":"string","enum":["strict","normal","relaxed"]}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
wrapLegacyToolHandler("analyze_health", func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.AnalyzeHealth(state, args)
},
}),
)
}
@@ -380,97 +393,45 @@ func registerScheduleMutationTools(r *ToolRegistry) {
"place",
"将一个待安排任务预排到指定位置。task_id/day/slot_start 必填。",
`{"name":"place","parameters":{"task_id":{"type":"int","required":true},"day":{"type":"int","required":true},"slot_start":{"type":"int","required":true}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
taskID, ok := schedule.ArgsInt(args, "task_id")
if !ok {
return "放置失败:缺少必填参数 task_id。"
}
day, ok := schedule.ArgsInt(args, "day")
if !ok {
return "放置失败:缺少必填参数 day。"
}
slotStart, ok := schedule.ArgsInt(args, "slot_start")
if !ok {
return "放置失败:缺少必填参数 slot_start。"
}
return schedule.Place(state, taskID, day, slotStart)
},
NewPlaceToolHandler(),
)
r.Register(
"move",
"将一个已预排任务(仅 suggested移动到新位置。task_id/new_day/new_slot_start 必填。",
`{"name":"move","parameters":{"task_id":{"type":"int","required":true},"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
taskID, ok := schedule.ArgsInt(args, "task_id")
if !ok {
return "移动失败:缺少必填参数 task_id。"
}
newDay, ok := schedule.ArgsInt(args, "new_day")
if !ok {
return "移动失败:缺少必填参数 new_day。"
}
newSlotStart, ok := schedule.ArgsInt(args, "new_slot_start")
if !ok {
return "移动失败:缺少必填参数 new_slot_start。"
}
return schedule.Move(state, taskID, newDay, newSlotStart)
},
NewMoveToolHandler(),
)
r.Register(
"swap",
"交换两个已落位任务的位置。task_a/task_b 必填,且两任务时长必须一致。",
`{"name":"swap","parameters":{"task_a":{"type":"int","required":true},"task_b":{"type":"int","required":true}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
taskA, ok := schedule.ArgsInt(args, "task_a")
if !ok {
return "交换失败:缺少必填参数 task_a。"
}
taskB, ok := schedule.ArgsInt(args, "task_b")
if !ok {
return "交换失败:缺少必填参数 task_b。"
}
return schedule.Swap(state, taskA, taskB)
},
NewSwapToolHandler(),
)
r.Register(
"batch_move",
"原子性批量移动多个任务。moves 必填。",
`{"name":"batch_move","parameters":{"moves":{"type":"array","required":true,"items":{"task_id":"int","new_day":"int","new_slot_start":"int"}}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
moves, err := schedule.ArgsMoveList(args)
if err != nil {
return fmt.Sprintf("批量移动失败:%s", err.Error())
}
return schedule.BatchMove(state, moves)
},
NewBatchMoveToolHandler(),
)
r.Register(
"queue_apply_head_move",
"将当前队首任务移动到指定位置并自动出队。new_day/new_slot_start 必填。",
`{"name":"queue_apply_head_move","parameters":{"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.QueueApplyHeadMove(state, args)
},
NewQueueApplyHeadMoveToolHandler(),
)
r.Register(
"queue_skip_head",
"跳过当前队首任务,将其标记为 skipped。",
`{"name":"queue_skip_head","parameters":{"reason":{"type":"string"}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
wrapLegacyToolHandler("queue_skip_head", func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.QueueSkipHead(state, args)
},
}),
)
r.Register(
"unplace",
"将一个已落位任务移除恢复为待安排状态。task_id 必填。",
`{"name":"unplace","parameters":{"task_id":{"type":"int","required":true}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
taskID, ok := schedule.ArgsInt(args, "task_id")
if !ok {
return "移除失败:缺少必填参数 task_id。"
}
return schedule.Unplace(state, taskID)
},
NewUnplaceToolHandler(),
)
}
@@ -491,18 +452,24 @@ func registerWebTools(r *ToolRegistry, deps DefaultRegistryDeps) {
"web_search",
"Web 搜索:根据 query 返回结构化检索结果。query 必填。",
`{"name":"web_search","parameters":{"query":{"type":"string","required":true},"top_k":{"type":"int"},"domain_allow":{"type":"array","items":{"type":"string"}},"recency_days":{"type":"int"}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
wrapLegacyToolHandler("web_search", func(state *schedule.ScheduleState, args map[string]any) string {
_ = state
return webSearchHandler.Handle(args)
},
}),
)
r.Register(
"web_fetch",
"抓取指定 URL 的正文内容并做最小清洗。url 必填。",
`{"name":"web_fetch","parameters":{"url":{"type":"string","required":true},"max_chars":{"type":"int"}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
wrapLegacyToolHandler("web_fetch", func(state *schedule.ScheduleState, args map[string]any) string {
_ = state
return webFetchHandler.Handle(args)
},
}),
)
}
func wrapLegacyToolHandler(toolName string, handler func(state *schedule.ScheduleState, args map[string]any) string) ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
return LegacyResultWithState(toolName, args, state, handler(state, args))
}
}

View File

@@ -0,0 +1,730 @@
package newagenttools
import (
"fmt"
"sort"
"strings"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
)
type scheduleTaskSnapshot struct {
Exists bool
TaskID int
Name string
Status string
Slots []schedule.TaskSlot
DayInfo map[int]schedule.DayMapping
}
type scheduleQueueSnapshot struct {
PendingCount int
CompletedCount int
SkippedCount int
CurrentTaskID int
CurrentAttempt int
LastError string
}
// NewPlaceToolHandler 返回 place 的结构化结果 handler第一轮真实 result_view
func NewPlaceToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
taskID, ok := schedule.ArgsInt(args, "task_id")
if !ok {
return buildScheduleArgErrorResult("place", args, "缺少必填参数 task_id。", state)
}
day, ok := schedule.ArgsInt(args, "day")
if !ok {
return buildScheduleArgErrorResult("place", args, "缺少必填参数 day。", state)
}
slotStart, ok := schedule.ArgsInt(args, "slot_start")
if !ok {
return buildScheduleArgErrorResult("place", args, "缺少必填参数 slot_start。", state)
}
if state == nil {
return buildScheduleArgErrorResult("place", args, "日程状态为空,无法执行预排。", nil)
}
beforeState := state.Clone()
observation := schedule.Place(state, taskID, day, slotStart)
afterState := state.Clone()
before := snapshotTask(beforeState, taskID)
after := snapshotTask(afterState, taskID)
success := after.Exists && taskHasSlotAt(after, day, slotStart) && !sameSlots(before.Slots, after.Slots)
changes := []map[string]any{
buildTaskChange("place", before, after),
}
affectedDays := collectAffectedDays(changes)
return buildScheduleOperationResult("place", args, afterState, observation, success, affectedDays, changes, nil, "")
}
}
// NewMoveToolHandler 返回 move 的结构化结果 handler第一轮真实 result_view
func NewMoveToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
taskID, ok := schedule.ArgsInt(args, "task_id")
if !ok {
return buildScheduleArgErrorResult("move", args, "缺少必填参数 task_id。", state)
}
newDay, ok := schedule.ArgsInt(args, "new_day")
if !ok {
return buildScheduleArgErrorResult("move", args, "缺少必填参数 new_day。", state)
}
newSlotStart, ok := schedule.ArgsInt(args, "new_slot_start")
if !ok {
return buildScheduleArgErrorResult("move", args, "缺少必填参数 new_slot_start。", state)
}
if state == nil {
return buildScheduleArgErrorResult("move", args, "日程状态为空,无法执行移动。", nil)
}
beforeState := state.Clone()
observation := schedule.Move(state, taskID, newDay, newSlotStart)
afterState := state.Clone()
before := snapshotTask(beforeState, taskID)
after := snapshotTask(afterState, taskID)
success := before.Exists && after.Exists && taskHasSlotAt(after, newDay, newSlotStart) && !sameSlots(before.Slots, after.Slots)
changes := []map[string]any{
buildTaskChange("move", before, after),
}
affectedDays := collectAffectedDays(changes)
return buildScheduleOperationResult("move", args, afterState, observation, success, affectedDays, changes, nil, "")
}
}
// NewSwapToolHandler 返回 swap 的结构化结果 handler第一轮真实 result_view
func NewSwapToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
taskA, ok := schedule.ArgsInt(args, "task_a")
if !ok {
return buildScheduleArgErrorResult("swap", args, "缺少必填参数 task_a。", state)
}
taskB, ok := schedule.ArgsInt(args, "task_b")
if !ok {
return buildScheduleArgErrorResult("swap", args, "缺少必填参数 task_b。", state)
}
if state == nil {
return buildScheduleArgErrorResult("swap", args, "日程状态为空,无法执行交换。", nil)
}
beforeState := state.Clone()
observation := schedule.Swap(state, taskA, taskB)
afterState := state.Clone()
beforeA := snapshotTask(beforeState, taskA)
afterA := snapshotTask(afterState, taskA)
beforeB := snapshotTask(beforeState, taskB)
afterB := snapshotTask(afterState, taskB)
success := beforeA.Exists &&
beforeB.Exists &&
afterA.Exists &&
afterB.Exists &&
sameSlots(beforeA.Slots, afterB.Slots) &&
sameSlots(beforeB.Slots, afterA.Slots)
changes := []map[string]any{
buildTaskChange("swap", beforeA, afterA),
buildTaskChange("swap", beforeB, afterB),
}
affectedDays := collectAffectedDays(changes)
return buildScheduleOperationResult("swap", args, afterState, observation, success, affectedDays, changes, nil, "")
}
}
// NewBatchMoveToolHandler 返回 batch_move 的结构化结果 handler第一轮真实 result_view
func NewBatchMoveToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
if state == nil {
return buildScheduleArgErrorResult("batch_move", args, "日程状态为空,无法执行批量移动。", nil)
}
moves, err := schedule.ArgsMoveList(args)
if err != nil {
return buildScheduleArgErrorResult("batch_move", args, err.Error(), state)
}
beforeState := state.Clone()
observation := schedule.BatchMove(state, moves)
afterState := state.Clone()
changes := make([]map[string]any, 0, len(moves))
success := len(moves) > 0
for _, move := range moves {
before := snapshotTask(beforeState, move.TaskID)
after := snapshotTask(afterState, move.TaskID)
changes = append(changes, buildTaskChange("batch_move", before, after))
if !after.Exists || !taskHasSlotAt(after, move.NewDay, move.NewSlotStart) {
success = false
}
}
affectedDays := collectAffectedDays(changes)
return buildScheduleOperationResult("batch_move", args, afterState, observation, success, affectedDays, changes, nil, "")
}
}
// NewUnplaceToolHandler 返回 unplace 的结构化结果 handler第一轮真实 result_view
func NewUnplaceToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
taskID, ok := schedule.ArgsInt(args, "task_id")
if !ok {
return buildScheduleArgErrorResult("unplace", args, "缺少必填参数 task_id。", state)
}
if state == nil {
return buildScheduleArgErrorResult("unplace", args, "日程状态为空,无法执行移出。", nil)
}
beforeState := state.Clone()
observation := schedule.Unplace(state, taskID)
afterState := state.Clone()
before := snapshotTask(beforeState, taskID)
after := snapshotTask(afterState, taskID)
success := before.Exists && len(before.Slots) > 0 && len(after.Slots) == 0 && strings.EqualFold(strings.TrimSpace(after.Status), schedule.TaskStatusPending)
changes := []map[string]any{
buildTaskChange("unplace", before, after),
}
affectedDays := collectAffectedDays(changes)
return buildScheduleOperationResult("unplace", args, afterState, observation, success, affectedDays, changes, nil, "")
}
}
// NewQueueApplyHeadMoveToolHandler 返回 queue_apply_head_move 的结构化结果 handler第一轮真实 result_view
func NewQueueApplyHeadMoveToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
newDay, dayOK := schedule.ArgsInt(args, "new_day")
newSlotStart, slotOK := schedule.ArgsInt(args, "new_slot_start")
if state == nil {
return buildScheduleArgErrorResult("queue_apply_head_move", args, "日程状态为空,无法执行队首任务应用。", nil)
}
// 1. 执行前先记录 current 任务与队列快照,保证成功/失败都可构造稳定结构化视图。
// 2. 再执行工具并抓取执行后快照,基于 before/after 计算差异,不依赖自然语言解析。
// 3. 如果快照构造异常,外层仍会回退 LegacyResult保证工具主链路不被展示层影响。
beforeState := state.Clone()
beforeQueue := snapshotQueue(beforeState)
currentTaskID := 0
if beforeState != nil && beforeState.RuntimeQueue != nil {
currentTaskID = beforeState.RuntimeQueue.CurrentTaskID
}
beforeTask := snapshotTask(beforeState, currentTaskID)
observation := schedule.QueueApplyHeadMove(state, args)
afterState := state.Clone()
afterQueue := snapshotQueue(afterState)
afterTask := snapshotTask(afterState, currentTaskID)
success := false
if payload, ok := parseObservationJSON(strings.TrimSpace(observation)); ok {
if parsedSuccess, exists := payload["success"].(bool); exists {
success = parsedSuccess
}
}
if !success {
success = currentTaskID > 0 &&
(afterQueue.CompletedCount > beforeQueue.CompletedCount) &&
(afterQueue.CurrentTaskID != currentTaskID)
if dayOK && slotOK && success {
success = taskHasSlotAt(afterTask, newDay, newSlotStart)
}
}
changes := []map[string]any{
buildTaskChange("queue_apply_head_move", beforeTask, afterTask),
}
affectedDays := collectAffectedDays(changes)
queueSnapshot := buildQueueSnapshotWithLabels(beforeQueue, afterQueue)
return buildScheduleOperationResult(
"queue_apply_head_move",
args,
afterState,
observation,
success,
affectedDays,
changes,
queueSnapshot,
pickFailureReason(observation, success),
)
}
}
func buildScheduleArgErrorResult(toolName string, args map[string]any, reason string, state *schedule.ScheduleState) ToolExecutionResult {
observation := fmt.Sprintf("%s失败%s", scheduleOperationFailurePrefix(toolName), strings.TrimSpace(reason))
return buildScheduleOperationResult(
toolName,
args,
state,
observation,
false,
nil,
nil,
nil,
strings.TrimSpace(reason),
)
}
func buildScheduleOperationResult(
toolName string,
args map[string]any,
displayState *schedule.ScheduleState,
observation string,
success bool,
affectedDays []int,
changes []map[string]any,
queueSnapshot map[string]any,
failureReason string,
) ToolExecutionResult {
result := LegacyResultWithState(toolName, args, displayState, observation)
status := ToolStatusFailed
if success {
status = ToolStatusDone
}
operationLabel := resolveOperationLabelCN(toolName)
title := fmt.Sprintf("%s%s", operationLabel, resolveResultTitleSuffix(status))
subtitle := buildScheduleSubtitle(toolName, changes, success)
metrics := []map[string]any{
{"label": "任务数量", "value": fmt.Sprintf("%d个", maxInt(len(changes), countMovesFromArgs(args)))},
{"label": "影响天数", "value": fmt.Sprintf("%d天", len(affectedDays))},
}
collapsed := map[string]any{
"title": title,
"subtitle": subtitle,
"status": status,
"status_label": resolveToolStatusLabelCN(status),
"operation": strings.TrimSpace(toolName),
"operation_label": operationLabel,
"task_count": maxInt(len(changes), countMovesFromArgs(args)),
"affected_days_count": len(affectedDays),
"metrics": metrics,
}
expanded := map[string]any{
"operation": strings.TrimSpace(toolName),
"operation_label": operationLabel,
"changes": changes,
"affected_days": affectedDays,
"affected_days_label": formatAffectedDaysLabel(affectedDays),
"raw_text": observation,
}
if queueSnapshot != nil {
expanded["queue_snapshot"] = queueSnapshot
}
if !success {
expanded["failure_reason"] = strings.TrimSpace(pickFailureReason(failureReason, false))
}
result.Status = status
result.Success = success
result.Summary = title
result.ArgumentsPreview = readArgumentSummary(result.ArgumentView)
result.ResultView = &ToolDisplayView{
ViewType: "schedule.operation_result",
Version: 1,
Collapsed: collapsed,
Expanded: expanded,
}
if !success {
result.ErrorCode = "schedule_operation_failed"
if strings.TrimSpace(result.ErrorMessage) == "" {
result.ErrorMessage = strings.TrimSpace(pickFailureReason(failureReason, false))
}
}
return EnsureToolResultDefaults(result, args)
}
func buildScheduleSubtitle(operation string, changes []map[string]any, success bool) string {
if len(changes) == 0 {
if success {
return fmt.Sprintf("%s已完成", resolveOperationLabelCN(operation))
}
return fmt.Sprintf("%s执行失败", resolveOperationLabelCN(operation))
}
firstTask := readStringMap(changes[0], "task_label")
firstBefore := readStringMap(changes[0], "before_label")
firstAfter := readStringMap(changes[0], "after_label")
switch strings.TrimSpace(operation) {
case "move":
return fmt.Sprintf("%s从%s移动到%s", firstTask, firstBefore, firstAfter)
case "place":
return fmt.Sprintf("%s预排到%s", firstTask, firstAfter)
case "unplace":
return fmt.Sprintf("%s已从%s移出", firstTask, firstBefore)
case "swap":
if len(changes) >= 2 {
secondTask := readStringMap(changes[1], "task_label")
return fmt.Sprintf("%s 与 %s 已交换位置", firstTask, secondTask)
}
return fmt.Sprintf("%s已交换位置", firstTask)
case "batch_move":
return fmt.Sprintf("批量移动 %d 个任务", len(changes))
case "queue_apply_head_move":
if success {
return fmt.Sprintf("队首任务已移动到%s", firstAfter)
}
return fmt.Sprintf("队首任务移动失败:%s", firstTask)
default:
return fmt.Sprintf("%s%s", resolveOperationLabelCN(operation), firstTask)
}
}
func resolveResultTitleSuffix(status string) string {
switch normalizeToolStatus(status) {
case ToolStatusDone:
return "成功"
case ToolStatusBlocked:
return "已阻断"
default:
return "失败"
}
}
func pickFailureReason(raw string, success bool) string {
if success {
return ""
}
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "操作失败,请查看原始结果。"
}
if payload, ok := parseObservationJSON(trimmed); ok {
if text, ok := readStringFromMap(payload, "result", "error", "reason", "message", "err"); ok {
return strings.TrimSpace(text)
}
}
return trimmed
}
func snapshotTask(state *schedule.ScheduleState, taskID int) scheduleTaskSnapshot {
if state == nil || taskID <= 0 {
return scheduleTaskSnapshot{
Exists: false,
TaskID: taskID,
DayInfo: buildDayInfo(state),
}
}
task := state.TaskByStateID(taskID)
if task == nil {
return scheduleTaskSnapshot{
Exists: false,
TaskID: taskID,
DayInfo: buildDayInfo(state),
}
}
return scheduleTaskSnapshot{
Exists: true,
TaskID: task.StateID,
Name: strings.TrimSpace(task.Name),
Status: strings.TrimSpace(task.Status),
Slots: cloneSlots(task.Slots),
DayInfo: buildDayInfo(state),
}
}
func snapshotQueue(state *schedule.ScheduleState) scheduleQueueSnapshot {
if state == nil || state.RuntimeQueue == nil {
return scheduleQueueSnapshot{}
}
return scheduleQueueSnapshot{
PendingCount: len(state.RuntimeQueue.PendingTaskIDs),
CompletedCount: len(state.RuntimeQueue.CompletedTaskIDs),
SkippedCount: len(state.RuntimeQueue.SkippedTaskIDs),
CurrentTaskID: state.RuntimeQueue.CurrentTaskID,
CurrentAttempt: state.RuntimeQueue.CurrentAttempts,
LastError: strings.TrimSpace(state.RuntimeQueue.LastError),
}
}
func buildQueueSnapshotWithLabels(before scheduleQueueSnapshot, after scheduleQueueSnapshot) map[string]any {
return map[string]any{
"before": queueSnapshotToMap(before),
"after": queueSnapshotToMap(after),
"before_label": queueSummaryLabel(before),
"after_label": queueSummaryLabel(after),
"summary_label": queueSummaryLabel(after),
"last_error_label": strings.TrimSpace(after.LastError),
}
}
func queueSnapshotToMap(s scheduleQueueSnapshot) map[string]any {
return map[string]any{
"pending_count": s.PendingCount,
"completed_count": s.CompletedCount,
"skipped_count": s.SkippedCount,
"current_task_id": s.CurrentTaskID,
"current_attempt": s.CurrentAttempt,
"last_error": s.LastError,
}
}
func queueSummaryLabel(s scheduleQueueSnapshot) string {
return fmt.Sprintf(
"待处理%d个已完成%d个已跳过%d个当前任务%d尝试%d次",
s.PendingCount,
s.CompletedCount,
s.SkippedCount,
s.CurrentTaskID,
s.CurrentAttempt,
)
}
func buildTaskChange(operation string, before scheduleTaskSnapshot, after scheduleTaskSnapshot) map[string]any {
taskLabel := resolveChangeTaskLabel(before, after)
beforeStatusLabel := resolveTaskStatusLabelCN(before.Status)
afterStatusLabel := resolveTaskStatusLabelCN(after.Status)
beforeLabel := formatPlacementLabel(operation, before.Slots, before.Status, false, false)
afterLabel := formatPlacementLabel(operation, after.Slots, after.Status, true, len(before.Slots) > 0)
change := map[string]any{
"task_id": before.TaskID,
"name": firstNonEmpty(before.Name, after.Name),
"status": map[string]any{
"before": before.Status,
"after": after.Status,
},
"before_slots": slotsToView(before.Slots, before.DayInfo),
"after_slots": slotsToView(after.Slots, after.DayInfo),
"task_label": taskLabel,
"before_label": beforeLabel,
"after_label": afterLabel,
"status_label": fmt.Sprintf("%s -> %s", beforeStatusLabel, afterStatusLabel),
"operation_key": operation,
}
return change
}
func resolveChangeTaskLabel(before scheduleTaskSnapshot, after scheduleTaskSnapshot) string {
name := firstNonEmpty(before.Name, after.Name)
if name == "" {
if before.TaskID > 0 {
return fmt.Sprintf("[%d]任务", before.TaskID)
}
return "任务"
}
if before.TaskID > 0 {
return fmt.Sprintf("[%d]%s", before.TaskID, name)
}
return name
}
func resolveTaskStatusLabelCN(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case schedule.TaskStatusPending:
return "待安排"
case schedule.TaskStatusSuggested:
return "已预排"
case schedule.TaskStatusExisting:
return "已安排"
default:
return "未知状态"
}
}
func formatPlacementLabel(operation string, slots []schedule.TaskSlot, status string, isAfter bool, hadBefore bool) string {
if len(slots) > 0 {
return formatSlotsLabelCN(slots)
}
if isAfter && strings.TrimSpace(operation) == "unplace" && hadBefore {
return "已移出"
}
if strings.EqualFold(strings.TrimSpace(status), schedule.TaskStatusPending) {
return "未安排"
}
return "未安排"
}
func formatSlotsLabelCN(slots []schedule.TaskSlot) string {
if len(slots) == 0 {
return "未安排"
}
parts := make([]string, 0, len(slots))
for _, slot := range slots {
parts = append(parts, fmt.Sprintf("%s %s", formatDayLabelCN(slot.Day), formatSlotRangeCN(slot.SlotStart, slot.SlotEnd)))
}
return strings.Join(parts, "、")
}
func formatAffectedDaysLabel(affectedDays []int) string {
if len(affectedDays) == 0 {
return "无"
}
parts := make([]string, 0, len(affectedDays))
for _, day := range affectedDays {
parts = append(parts, formatDayLabelCN(day))
}
return strings.Join(parts, "、")
}
func slotsToView(slots []schedule.TaskSlot, dayInfo map[int]schedule.DayMapping) []map[string]any {
if len(slots) == 0 {
return make([]map[string]any, 0)
}
result := make([]map[string]any, 0, len(slots))
for _, slot := range slots {
entry := map[string]any{
"day": slot.Day,
"slot_start": slot.SlotStart,
"slot_end": slot.SlotEnd,
}
if info, ok := dayInfo[slot.Day]; ok {
entry["week"] = info.Week
entry["day_of_week"] = info.DayOfWeek
}
result = append(result, entry)
}
return result
}
func collectAffectedDays(changes []map[string]any) []int {
if len(changes) == 0 {
return make([]int, 0)
}
set := make(map[int]struct{})
for _, change := range changes {
collectDaysFromSlotView(set, change["before_slots"])
collectDaysFromSlotView(set, change["after_slots"])
}
days := make([]int, 0, len(set))
for day := range set {
days = append(days, day)
}
sort.Ints(days)
return days
}
func collectDaysFromSlotView(target map[int]struct{}, raw any) {
list, ok := raw.([]map[string]any)
if ok {
for _, item := range list {
day, ok := item["day"].(int)
if ok {
target[day] = struct{}{}
}
}
return
}
anyList, ok := raw.([]any)
if !ok {
return
}
for _, item := range anyList {
itemMap, ok := item.(map[string]any)
if !ok {
continue
}
switch day := itemMap["day"].(type) {
case int:
target[day] = struct{}{}
case float64:
target[int(day)] = struct{}{}
}
}
}
func buildDayInfo(state *schedule.ScheduleState) map[int]schedule.DayMapping {
if state == nil || len(state.Window.DayMapping) == 0 {
return map[int]schedule.DayMapping{}
}
info := make(map[int]schedule.DayMapping, len(state.Window.DayMapping))
for _, item := range state.Window.DayMapping {
info[item.DayIndex] = item
}
return info
}
func cloneSlots(slots []schedule.TaskSlot) []schedule.TaskSlot {
if len(slots) == 0 {
return nil
}
out := make([]schedule.TaskSlot, len(slots))
copy(out, slots)
return out
}
func sameSlots(a []schedule.TaskSlot, b []schedule.TaskSlot) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].Day != b[i].Day || a[i].SlotStart != b[i].SlotStart || a[i].SlotEnd != b[i].SlotEnd {
return false
}
}
return true
}
func taskHasSlotAt(snapshot scheduleTaskSnapshot, day int, slotStart int) bool {
for _, slot := range snapshot.Slots {
if slot.Day == day && slot.SlotStart == slotStart {
return true
}
}
return false
}
func readStringMap(input map[string]any, key string) string {
value, ok := input[key]
if !ok || value == nil {
return ""
}
text, _ := value.(string)
return strings.TrimSpace(text)
}
func countMovesFromArgs(args map[string]any) int {
moves, ok := args["moves"].([]any)
if !ok {
return 0
}
return len(moves)
}
func maxInt(values ...int) int {
if len(values) == 0 {
return 0
}
maxValue := values[0]
for _, value := range values[1:] {
if value > maxValue {
maxValue = value
}
}
return maxValue
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
func scheduleOperationFailurePrefix(toolName string) string {
switch strings.TrimSpace(toolName) {
case "place":
return "放置"
case "move", "queue_apply_head_move":
return "移动"
case "swap":
return "交换"
case "batch_move":
return "批量移动"
case "unplace":
return "移除"
default:
return strings.TrimSpace(toolName)
}
}

View File

@@ -56,76 +56,76 @@ type taskClassUpsertToolResult struct {
//
// 职责边界:
// 1. 只做参数解析、合法性校验、调用依赖、返回统一 JSON
// 2. 不负责草生成,草由 prompt+LLM 完成;
// 2. 不负责草稿生成,草稿由 prompt+LLM 完成;
// 3. 不依赖 ScheduleState可在纯聊天场景调用execute 会注入 _user_id
func NewTaskClassUpsertToolHandler(deps TaskClassWriteDeps) ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) string {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
_ = state
if deps.UpsertTaskClass == nil {
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"任务类写库依赖未注入"}},
Error: "任务类写库依赖未注入",
ErrorCode: "dependency_missing",
})
}))
}
userID, ok := readUpsertUserID(args["_user_id"])
if !ok || userID <= 0 {
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"无法识别用户身份"}},
Error: "工具调用失败:无法识别用户身份",
ErrorCode: "missing_user_id",
})
}))
}
input, parseErr := parseTaskClassUpsertInput(args)
if parseErr != nil {
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{parseErr.Error()}},
Error: parseErr.Error(),
ErrorCode: "invalid_args",
})
}))
}
issues := validateTaskClassUpsertRequest(input.Request, input.ID)
if len(issues) > 0 {
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: issues},
Error: strings.Join(issues, ""),
ErrorCode: "validation_failed",
})
}))
}
result, err := deps.UpsertTaskClass(userID, input)
if err != nil {
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"持久化写入失败"}},
Error: err.Error(),
ErrorCode: "persist_failed",
})
}))
}
if result.TaskClassID <= 0 {
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"未返回有效 task_class_id"}},
Error: "写入后未返回有效 task_class_id",
ErrorCode: "invalid_persist_result",
})
}))
}
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: true,
TaskClassID: result.TaskClassID,
@@ -133,7 +133,7 @@ func NewTaskClassUpsertToolHandler(deps TaskClassWriteDeps) ToolHandler {
Validation: taskClassValidationResult{OK: true, Issues: []string{}},
Error: "",
ErrorCode: "",
})
}))
}
}

View File

@@ -363,12 +363,19 @@ func buildTimelinePayloadFromStreamExtra(extra *newagentstream.OpenAIChunkExtra)
"display_mode": string(extra.DisplayMode),
}
if extra.Tool != nil {
payload["tool"] = map[string]any{
toolPayload := map[string]any{
"name": strings.TrimSpace(extra.Tool.Name),
"status": strings.TrimSpace(extra.Tool.Status),
"summary": strings.TrimSpace(extra.Tool.Summary),
"arguments_preview": strings.TrimSpace(extra.Tool.ArgumentsPreview),
}
if len(extra.Tool.ArgumentView) > 0 {
toolPayload["argument_view"] = cloneTimelinePayload(extra.Tool.ArgumentView)
}
if len(extra.Tool.ResultView) > 0 {
toolPayload["result_view"] = cloneTimelinePayload(extra.Tool.ResultView)
}
payload["tool"] = toolPayload
}
if extra.Confirm != nil {
payload["confirm"] = map[string]any{