package recovery import ( "context" "encoding/json" "fmt" "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" ) type ToolInvoker interface { CallToolDirect(ctx context.Context, serverID, toolName string, args map[string]any) (*transport.ToolCallResult, error) } type Probe func(ctx context.Context, invoker ToolInvoker, rc RecoveryContext, replay Replay, plan RecoveryPlan) *ProbeResult type Executor struct { planner *Planner invoker ToolInvoker probes []Probe } func NewExecutor(planner *Planner, invoker ToolInvoker) *Executor { return &Executor{ planner: planner, invoker: invoker, probes: defaultProbes(), } } func (e *Executor) Execute(ctx context.Context, last LastError) RecoveryBundle { bundle := RecoveryBundle{ Status: "needs_agent_action", EventID: last.EventID, Context: cloneRecoveryContext(last.Context), Replay: cloneReplay(last.Replay), } if e == nil || e.planner == nil { bundle.Status = "analysis_failed" bundle.AnalysisError = "recovery executor unavailable" return bundle } plan := e.planner.PlanWithOptions(ctx, last.Context, PlanOptions{ EventID: last.EventID, EnableDocSearch: true, }) HydratePlanForEvent(last.EventID, last.Context, last.Replay, &plan) bundle.Plan = plan bundle.DocSearch = cloneDocSearch(plan.DocSearch) bundle.KBHits = cloneKBHits(plan.KBHits) bundle.DocActions = cloneDocActions(plan.DocActions) bundle.HumanActions = nonNilStrings(plan.HumanActions) bundle.ProbeResults = e.runProbes(ctx, last.Context, last.Replay, plan) if bundle.Plan.AgentRoute.Payload != nil { bundle.Plan.AgentRoute.Payload.ProbeResults = cloneProbeResults(bundle.ProbeResults) } bundle.AgentTask = buildAgentTask(last.EventID, last.Context, last.Replay, plan, bundle.ProbeResults) bundle.FinalizeHint = buildFinalizeHint(last.EventID) analysisErrors := make([]string, 0, 3) if isEmptyReplay(last.Replay) { analysisErrors = append(analysisErrors, "replay data missing") } if plan.DocSearch.Status == "error" { analysisErrors = append(analysisErrors, "doc search failed") } for _, probe := range bundle.ProbeResults { if probe.Status == "error" { analysisErrors = append(analysisErrors, fmt.Sprintf("probe %s failed", probe.Name)) } } if len(analysisErrors) > 0 { bundle.Status = "analysis_failed" bundle.AnalysisError = strings.Join(uniqueStrings(analysisErrors), "; ") } return bundle } func (e *Executor) runProbes(ctx context.Context, rc RecoveryContext, replay Replay, plan RecoveryPlan) []ProbeResult { if len(e.probes) == 0 { return nil } results := make([]ProbeResult, 0, len(e.probes)) for _, probe := range e.probes { result := probe(ctx, e.invoker, rc, replay, plan) if result == nil { continue } results = append(results, *result) } return results } func defaultProbes() []Probe { return []Probe{probeUnknownContextAudit, probeAITableBaseCatalog} } func probeUnknownContextAudit(ctx context.Context, invoker ToolInvoker, rc RecoveryContext, replay Replay, plan RecoveryPlan) *ProbeResult { if normalizeDecisionOwner(plan) != DecisionOwnerAgent { return nil } sourcesChecked := []string{ "recovery_context.command_path", "recovery_context.args_summary", "replay.tool_args", "replay.redacted_command", "plan.doc_search", "plan.kb_hits", "plan.doc_actions", } availableSources := make([]string, 0, len(sourcesChecked)) missingSources := make([]string, 0, len(sourcesChecked)) candidateIdentifiers := make([]map[string]any, 0) appendCandidates := func(source string, values map[string]any) { for key, value := range values { text := strings.TrimSpace(fmt.Sprint(value)) if text == "" { continue } lowerKey := strings.ToLower(key) if !strings.Contains(lowerKey, "id") && !strings.Contains(lowerKey, "uuid") { continue } candidateIdentifiers = append(candidateIdentifiers, map[string]any{ "source": source, "field": key, "value": text, }) } } appendCandidates("context.args_summary", rc.ArgsSummary) appendCandidates("replay.tool_args", replay.ToolArgs) if len(rc.CommandPath) > 0 { availableSources = append(availableSources, "recovery_context.command_path") } else { missingSources = append(missingSources, "recovery_context.command_path") } if len(rc.ArgsSummary) > 0 { availableSources = append(availableSources, "recovery_context.args_summary") } else { missingSources = append(missingSources, "recovery_context.args_summary") } if len(replay.ToolArgs) > 0 { availableSources = append(availableSources, "replay.tool_args") } else { missingSources = append(missingSources, "replay.tool_args") } if strings.TrimSpace(replay.RedactedCommand) != "" { availableSources = append(availableSources, "replay.redacted_command") } else { missingSources = append(missingSources, "replay.redacted_command") } if plan.DocSearch.Status != "" && plan.DocSearch.Status != "skipped" { availableSources = append(availableSources, "plan.doc_search") } else { missingSources = append(missingSources, "plan.doc_search") } if len(plan.KBHits) > 0 { availableSources = append(availableSources, "plan.kb_hits") } else { missingSources = append(missingSources, "plan.kb_hits") } if len(plan.DocActions) > 0 { availableSources = append(availableSources, "plan.doc_actions") } else { missingSources = append(missingSources, "plan.doc_actions") } summary := fmt.Sprintf("audited %d local recovery source(s)", len(sourcesChecked)) if len(candidateIdentifiers) == 0 { summary += "; no identifier candidates found" } else { summary += fmt.Sprintf("; found %d identifier candidate(s)", len(candidateIdentifiers)) } return &ProbeResult{ Name: "unknown_context_audit", Status: "success", Summary: summary, Output: map[string]any{ "sources_checked": sourcesChecked, "available_sources": availableSources, "missing_sources": missingSources, "candidate_identifiers": candidateIdentifiers, "decision_owner": string(normalizeDecisionOwner(plan)), }, } } func probeAITableBaseCatalog(ctx context.Context, invoker ToolInvoker, rc RecoveryContext, replay Replay, plan RecoveryPlan) *ProbeResult { if len(rc.CommandPath) < 2 || rc.CommandPath[0] != "aitable" || rc.CommandPath[1] != "base" { return nil } if len(rc.CommandPath) < 3 || rc.CommandPath[2] != "get" { return &ProbeResult{Name: "aitable_base_catalog_probe", Status: "skipped", Summary: "no registered read-only probe for this aitable command"} } if invoker == nil { return &ProbeResult{Name: "aitable_base_catalog_probe", Status: "skipped", Summary: "tool invoker unavailable"} } result, err := invoker.CallToolDirect(ctx, replay.ServerID, "list_bases", map[string]any{"limit": 5}) if err != nil { return &ProbeResult{ Name: "aitable_base_catalog_probe", Status: "error", ServerID: replay.ServerID, ToolName: "list_bases", Error: err.Error(), Summary: "failed to run read-only aitable base catalog probe", } } payload, summary := summarizeProbeOutput(result) return &ProbeResult{ Name: "aitable_base_catalog_probe", Status: "success", ServerID: replay.ServerID, ToolName: "list_bases", ArgsSummary: SummarizeArgs(map[string]any{"limit": 5}), Summary: summary, Output: payload, } } func summarizeProbeOutput(result *transport.ToolCallResult) (any, string) { if result == nil { return nil, "probe returned no output" } for _, block := range result.Blocks { if block.Type != "text" || strings.TrimSpace(block.Text) == "" { continue } var parsed any if err := json.Unmarshal([]byte(block.Text), &parsed); err == nil { return parsed, "probe returned JSON payload" } return block.Text, "probe returned text payload" } if len(result.Content) > 0 { return result.Content, "probe returned content payload" } return result, "probe returned non-text payload" } func buildAgentTask(eventID string, rc RecoveryContext, replay Replay, plan RecoveryPlan, probes []ProbeResult) AgentTask { whyParts := []string{fmt.Sprintf("recovery category=%s", plan.Category)} if len(probes) > 0 { whyParts = append(whyParts, fmt.Sprintf("probe results=%d", len(probes))) } if plan.DocSearch.Status != "" && plan.DocSearch.Status != "skipped" { whyParts = append(whyParts, "doc search results included") } if replay.RedactedCommand != "" { whyParts = append(whyParts, "failed command="+replay.RedactedCommand) } return AgentTask{ Goal: "阅读完整 RecoveryBundle,并基于 dws skill 对 unknown 恢复场景做整体分析后再决定是否发起 grounded 的下一次 dws 尝试", Why: strings.Join(whyParts, "; "), MustReadRefs: buildMustReadRefs(rc.CommandPath), AllowedActions: []string{ "读取完整 recovery bundle、recovery-guide 和对应产品参考文档", "基于 doc_actions、kb_hits、probe_results 与 replay/context 整体判断下一步", "仅在修复依据明确时重新发起新的 dws 命令", }, ForbiddenActions: []string{ "编造 ID、UUID、token、URL 或其他业务参数", "绕过 dws 直接调用 HTTP API、curl 或浏览器", "未确认前把失败命令替换成另一套业务流程", }, StopConditions: []string{ "新的 dws 命令已经成功并可回写 recovered", "没有 grounded 修复依据,只能回写 handoff", "继续尝试会要求猜测或扩大副作用边界", }, FinalizeRequirement: buildFinalizeHint(eventID).Command, } } func buildFinalizeHint(eventID string) FinalizeHint { return FinalizeHint{ Required: true, Command: fmt.Sprintf( "dws recovery finalize --event-id %s --outcome --execution-file --format json", eventID, ), ExecutionFileFields: []string{"actions", "attempts", "result", "error_summary"}, AllowedOutcomes: []string{"recovered", "failed", "handoff"}, } } func buildMustReadRefs(commandPath []string) []string { refs := []string{ "skills/references/recovery-guide.md", "skills/references/error-codes.md", "skills/references/global-reference.md", } if len(commandPath) > 0 { if ref := productReferenceFor(commandPath[0]); ref != "" { refs = append(refs, ref) } } return refs } func productReferenceFor(commandRoot string) string { refs := map[string]string{ "aitable": "skills/references/products/aitable.md", "attendance": "skills/references/products/attendance.md", "calendar": "skills/references/products/calendar.md", "chat": "skills/references/products/chat.md", "contact": "skills/references/products/contact.md", "devdoc": "skills/references/products/simple.md", "ding": "skills/references/products/ding.md", "report": "skills/references/products/report.md", "todo": "skills/references/products/todo.md", "workbench": "skills/references/products/workbench.md", "approval": "skills/references/products/simple.md", } return refs[commandRoot] } func cloneProbeResults(results []ProbeResult) []ProbeResult { if len(results) == 0 { return nil } out := make([]ProbeResult, len(results)) for i, result := range results { out[i] = result out[i].ArgsSummary = cloneMap(result.ArgsSummary) } return out }