package run import ( "context" "strings" "sync" llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm" ) // SubmitCapture records the output a run's `submit` tool received. // // Why this exists: legacy agentkit injected a synthetic `submit` tool and // ended the loop when it fired; years of mort system prompts (agent // YAMLs, skill manifests, the executors' platform headers) teach the // model to "call submit with your final answer". majordomo's agent loop // has no submit concept — it ends when the model replies WITHOUT tool // calls. Dropping submit cold would make every prompt-trained model // burn turns on "unknown tool \"submit\"" errors. // // The compatibility shape: the executors add NewSubmitTool's tool to // every run's toolset (unless the palette already defines a `submit`). // The handler records the FIRST submitted answer and tells the model // the answer was accepted so its next turn is a bare reply (which ends // the loop naturally). After the run, the executor consults // Output(loopOutput, runErr): a captured submission wins over an empty // or budget-exhausted ending, so a model that submits on its final // allowed step still produces its answer instead of ErrMaxSteps. type SubmitCapture struct { mu sync.Mutex output string called bool } // Record stores the first submitted answer; later calls are ignored // (matching legacy agentkit's "multiple calls keep the first" contract). func (c *SubmitCapture) Record(output string) { c.mu.Lock() defer c.mu.Unlock() if c.called { return } c.called = true c.output = output } // Submitted returns the captured answer and whether submit fired. func (c *SubmitCapture) Submitted() (string, bool) { c.mu.Lock() defer c.mu.Unlock() return c.output, c.called } // Output resolves the run's final output: the submitted answer when the // model called submit (parity with legacy agentkit, where submit's argument // WAS the run output), otherwise the loop's own final text. resolvedErr // is nil when a submission exists — a run that submitted its answer and // then ran out of steps (or timed out composing the courtesy // confirmation turn) is a SUCCESS, not an error. func (c *SubmitCapture) Output(loopOutput string, runErr error) (output string, resolvedErr error) { if out, ok := c.Submitted(); ok { return out, nil } return loopOutput, runErr } // submitArgs mirrors legacy agentkit's synthetic submit tool schema so // models prompted under the old contract emit compatible calls. type submitArgs struct { Output string `json:"output" description:"The final answer, summary, or output for this task."` } // NewSubmitTool builds the compatibility `submit` tool bound to the // given capture. Both executors (skill + agent) install one per run. func NewSubmitTool(capture *SubmitCapture) llm.Tool { return llm.DefineTool[submitArgs]( "submit", "Submit your final answer or output to end this task. Call exactly once when you are done.", func(_ context.Context, args submitArgs) (any, error) { capture.Record(strings.TrimSpace(args.Output)) return "Final answer recorded. Do not call any more tools; reply now with a brief closing message.", nil }, ) }