package domain import ( "fmt" "strings" ) // DecisionRequest is a bounded question to the human. It exists because some // ambiguity cannot be resolved by reading the repository, and guessing would // waste a session or ship the wrong behaviour. // // Grilling is not a mode here. It is one blocker, one question, one answer, // and the answer arrives through the ordinary human-decision mechanism. The // bounds are what keep it from becoming an interview. type DecisionRequest struct { Question string `json:"question"` Why string `json:"why"` Options []DecisionOption `json:"options,omitempty"` Evidence []string `json:"evidence,omitempty"` } // DecisionOption is one way forward, with the cost of taking it. A request // without options is legal: sometimes the honest question is open. type DecisionOption struct { ID string `json:"id"` Description string `json:"description"` Tradeoff string `json:"tradeoff,omitempty"` } const ( maxRequestField = 500 maxRequestOption = 4 maxRequestFacts = 8 ) func (r DecisionRequest) Validate() error { if err := requestLine("question", r.Question, true); err != nil { return err } if err := requestLine("why", r.Why, true); err != nil { return err } if len(r.Options) > maxRequestOption { return fmt.Errorf("%w: at most %d options", ErrInvalid, maxRequestOption) } if len(r.Evidence) > maxRequestFacts { return fmt.Errorf("%w: at most %d evidence lines", ErrInvalid, maxRequestFacts) } seen := map[string]bool{} for i, o := range r.Options { if err := requestLine(fmt.Sprintf("options[%d].id", i), o.ID, true); err != nil { return err } if seen[o.ID] { return fmt.Errorf("%w: duplicate option id %q", ErrInvalid, o.ID) } seen[o.ID] = true if err := requestLine(fmt.Sprintf("options[%d].description", i), o.Description, true); err != nil { return err } if err := requestLine(fmt.Sprintf("options[%d].tradeoff", i), o.Tradeoff, false); err != nil { return err } } for i, e := range r.Evidence { if err := requestLine(fmt.Sprintf("evidence[%d]", i), e, true); err != nil { return err } } return nil } // requestLine enforces the single-line, bounded shape. A multi-line field // would let a request carry the transcript this type exists to exclude. func requestLine(field, v string, required bool) error { s := strings.TrimSpace(v) if s == "" { if required { return fmt.Errorf("%w: %s is required", ErrInvalid, field) } return nil } if len(s) > maxRequestField { return fmt.Errorf("%w: %s exceeds %d characters", ErrInvalid, field, maxRequestField) } if strings.ContainsAny(s, "\n\r") { return fmt.Errorf("%w: %s must be a single line", ErrInvalid, field) } return nil } // Render is the human-facing form, delivered in the blocker field that // notification surfaces already read. func (r DecisionRequest) Render() string { var b strings.Builder b.WriteString("Human decision required.\n") fmt.Fprintf(&b, "\nQuestion: %s\n", r.Question) fmt.Fprintf(&b, "Why it blocks: %s\n", r.Why) if len(r.Options) > 0 { b.WriteString("\nOptions:\n") for _, o := range r.Options { if o.Tradeoff != "" { fmt.Fprintf(&b, "- %s: %s (tradeoff: %s)\n", o.ID, o.Description, o.Tradeoff) } else { fmt.Fprintf(&b, "- %s: %s\n", o.ID, o.Description) } } } if len(r.Evidence) > 0 { b.WriteString("\nEvidence:\n") for _, e := range r.Evidence { fmt.Fprintf(&b, "- %s\n", e) } } b.WriteString("\nReply with your decision. Any reply is recorded as a decision and resumes the task.\n") return b.String() } // DeferredFinding is a real observation that is not this task's business. It // is recorded outside agent context so a discovery neither derails the task // nor evaporates into a promise the next session cannot see. type DeferredFinding struct { Summary string `json:"summary"` Why string `json:"why"` } // EventDeferredFindingRecorded keeps a deferred finding in the log without // putting it in front of an agent. const EventDeferredFindingRecorded = "DeferredFindingRecorded" func (f DeferredFinding) Validate() error { if err := requestLine("summary", f.Summary, true); err != nil { return err } return requestLine("why", f.Why, true) } // decodeDecisionRequest reads the request out of a generic event payload. // Validation lives on the type, so the wire form and the projection agree. func decodeDecisionRequest(m map[string]any) DecisionRequest { var r DecisionRequest r.Question, _ = m["question"].(string) r.Why, _ = m["why"].(string) if list, ok := m["options"].([]any); ok { for _, item := range list { o, ok := item.(map[string]any) if !ok { continue } var opt DecisionOption opt.ID, _ = o["id"].(string) opt.Description, _ = o["description"].(string) opt.Tradeoff, _ = o["tradeoff"].(string) r.Options = append(r.Options, opt) } } if list, ok := m["evidence"].([]any); ok { for _, item := range list { if s, ok := item.(string); ok { r.Evidence = append(r.Evidence, s) } } } return r } // DecodeDecisionRequest is decodeDecisionRequest for callers outside this // package (the store's projection). func DecodeDecisionRequest(m map[string]any) DecisionRequest { return decodeDecisionRequest(m) }