94 lines
4.8 KiB
Go
94 lines
4.8 KiB
Go
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
|
|
}
|