package domain import ( "errors" "fmt" "git.cin.su/m/Assistant/contracts/work" ) var ErrInvalidSolutionSearch = errors.New("invalid solution search input") // DecideSolutionSearch keeps an unresolved goal alive across bounded waves. // A wave has limits, but an unresolved goal is never converted into FAILED // merely because one wave is exhausted. The caller persists the decision and // schedules the next wave through the durable Work Graph. func DecideSolutionSearch(input work.SolutionSearchInput) (work.SolutionSearchDecision, error) { if input.GoalID == "" || input.SearchRound < 0 || input.MaxRound < 1 || input.ActiveOptionCount < 0 { return work.SolutionSearchDecision{}, ErrInvalidSolutionSearch } decision := work.SolutionSearchDecision{GoalID: input.GoalID, NextSearchRound: input.SearchRound} if input.CancelRequested || input.GoalState == work.GoalCancelled { decision.State = work.OrchestrationCancelled decision.Reason = "goal cancellation is durable and stops further search waves" return decision, nil } if input.GoalState == work.GoalCompleted && input.VerificationReady { decision.State = work.OrchestrationCompleted decision.Reason = "goal has an independently verified accepted result" return decision, nil } if input.ActiveOptionCount > 0 { decision.State = work.OrchestrationExecuting decision.ContinueSearching = true decision.Options = []work.SearchOptionKind{work.SearchOptionAgents} decision.Reason = "current solution wave is still running" return decision, nil } decision.State = work.OrchestrationSearching decision.ContinueSearching = true decision.NextSearchRound++ decision.Options = []work.SearchOptionKind{work.SearchOptionAgents, work.SearchOptionCapability, work.SearchOptionResearch, work.SearchOptionReflection} if input.Unresolved { decision.Reason = "previous wave did not establish a verified solution; start all independent search avenues" } else { decision.Reason = "no verified result exists; start the first parallel solution wave" } if input.SearchRound >= input.MaxRound { decision.State = work.OrchestrationWaiting decision.Options = append(decision.Options, work.SearchOptionHuman) decision.Reason = "bounded wave budget exhausted; continue with a new durable wave and human attention, never claim failure" } return decision, nil } var ErrInvalidSolutionSearchWave = errors.New("invalid solution search wave") // BuildSolutionSearchWave turns a policy decision into a durable Work Graph // fan-out. Human attention remains in the decision but is not an agent dispatch. func BuildSolutionSearchWave(decision work.SolutionSearchDecision, request work.SolutionSearchWaveRequest) (work.WorkGraph, []work.AgentDispatchIntent, error) { if !decision.ContinueSearching || (decision.State != work.OrchestrationSearching && decision.State != work.OrchestrationWaiting) || request.GraphID == "" || request.IdempotencyPrefix == "" || request.Budget.MaxParallel < 1 || request.Budget.MaxAttempts < 1 { return work.WorkGraph{}, nil, ErrInvalidSolutionSearchWave } seen := make(map[work.SearchOptionKind]struct{}, len(request.Dispatches)) nodes := make([]work.GraphNode, 0, len(request.Dispatches)) intents := make([]work.AgentDispatchIntent, 0, len(request.Dispatches)) for index, dispatch := range request.Dispatches { if dispatch.Option == work.SearchOptionHuman || dispatch.Option == "" || dispatch.Intent.DispatchID == "" || dispatch.Intent.IdempotencyKey == "" { return work.WorkGraph{}, nil, ErrInvalidSolutionSearchWave } if _, exists := seen[dispatch.Option]; exists || !containsSearchOption(decision.Options, dispatch.Option) { return work.WorkGraph{}, nil, ErrInvalidSolutionSearchWave } seen[dispatch.Option] = struct{}{} intent := dispatch.Intent intent.SpecialistPurpose = string(dispatch.Option) intents = append(intents, intent) nodes = append(nodes, work.GraphNode{ID: work.GraphNodeID(fmt.Sprintf("%s-node-%d", request.GraphID, index+1)), DispatchID: intent.DispatchID, IdempotencyKey: fmt.Sprintf("%s:%s:%d", request.IdempotencyPrefix, dispatch.Option, decision.NextSearchRound), State: work.GraphNodeBlocked, Purpose: work.GraphNodePurposeGeneric}) } if len(nodes) == 0 { return work.WorkGraph{}, nil, ErrInvalidSolutionSearchWave } searchInput := request.Input searchInput.SearchRound = decision.NextSearchRound return work.WorkGraph{ID: request.GraphID, Revision: 1, State: work.GraphDraft, Nodes: nodes, Budget: request.Budget, ParentGraphID: request.ParentGraphID, ParentGraphRevision: request.ParentGraphRevision, TriggerEvidenceRef: request.TriggerEvidenceRef, SearchInputBound: true, SearchInput: searchInput}, intents, nil } func containsSearchOption(options []work.SearchOptionKind, target work.SearchOptionKind) bool { for _, option := range options { if option == target { return true } } return false }