Files
smartmate/backend/newAgent/tools/execution_result.go
LoveLosita 509e266626 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 新增协作约束——禁止擅自回滚、覆盖或删除用户/其他代理产生的工作区改动
2026-04-28 11:55:34 +08:00

774 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}