| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "strings" |
| 7 | |
| 8 | "reasonix/internal/agent" |
| 9 | "reasonix/internal/jobs" |
| 10 | "reasonix/internal/skill" |
| 11 | "reasonix/internal/tool" |
| 12 | ) |
| 13 | |
| 14 | // RunTurn executes one foreground turn synchronously through the same lifecycle |
| 15 | // used by interactive frontends: transient memory/background-job |
| 16 | // composition, checkpoints, hooks, and plan approval. It is for transports that |
| 17 | // need a blocking request/response boundary, such as ACP session/prompt. |
| 18 | func (c *Controller) RunTurn(ctx context.Context, input string) error { |
| 19 | prepared, failures := c.prepareSubmissionImagesContext(ctx, SubmissionRequest{Input: input}) |
| 20 | if len(failures) > 0 { |
| 21 | return ImageReferenceFailures(failures) |
| 22 | } |
| 23 | ctx = contextWithPreparedImageReferences(ctx, prepared) |
| 24 | err := c.runSynchronousTurn(ctx, nil, func(runCtx context.Context) error { |
| 25 | return c.runTurn(runCtx, input) |
| 26 | }) |
| 27 | if err != nil { |
| 28 | return err |
| 29 | } |
| 30 | return c.waitForGoalTerminal(ctx) |
| 31 | } |
| 32 | |
| 33 | // RunSubagentProfile executes one named runAs=subagent skill synchronously and |
| 34 | // returns only its final answer. It is the headless CLI counterpart to explicit |
| 35 | // slash invocation: the child keeps an isolated session, while the caller owns |
| 36 | // stdout rendering and exit status. readOnly selects the preview-safe runner |
| 37 | // used by `reasonix subagent try`. |
| 38 | func (c *Controller) RunSubagentProfile(ctx context.Context, name, task string, readOnly bool) (string, error) { |
| 39 | ctx = c.withAuthentication(ctx) |
| 40 | if err := c.authentication.admissionError(); err != nil { |
| 41 | return "", err |
| 42 | } |
| 43 | if _, ok := ctx.Value(preparedImageReferencesContextKey{}).(preparedImageReferences); !ok { |
| 44 | prepared, failures := c.prepareSubmissionImagesContext(ctx, SubmissionRequest{Input: task}) |
| 45 | if len(failures) > 0 { |
| 46 | return "", ImageReferenceFailures(failures) |
| 47 | } |
| 48 | ctx = contextWithPreparedImageReferences(ctx, prepared) |
| 49 | } |
| 50 | name = strings.TrimSpace(name) |
| 51 | task = strings.TrimSpace(task) |
| 52 | if name == "" { |
| 53 | return "", fmt.Errorf("subagent name is required") |
| 54 | } |
| 55 | if task == "" { |
| 56 | return "", fmt.Errorf("subagent task is required") |
| 57 | } |
| 58 | sk, ok := c.skills.bySlashName(name) |
| 59 | if !ok { |
| 60 | return "", fmt.Errorf("unknown or disabled subagent profile %q", name) |
| 61 | } |
| 62 | if sk.RunAs != skill.RunSubagent { |
| 63 | return "", fmt.Errorf("skill %q is not runAs=subagent", name) |
| 64 | } |
| 65 | sk = c.skills.prepare(sk) |
| 66 | runner := c.skillRunner |
| 67 | if readOnly { |
| 68 | runner = c.readOnlySkillRunner |
| 69 | } |
| 70 | if runner == nil { |
| 71 | return "", fmt.Errorf("subagent skill runner is unavailable for %q", name) |
| 72 | } |
| 73 | |
| 74 | c.maybeSessionStart(ctx) |
| 75 | parentSession := c.parentSessionID() |
| 76 | ctx = agent.WithParentSession(ctx, parentSession) |
| 77 | ctx = jobs.WithSession(ctx, parentSession) |
| 78 | ctx = c.withTurnImages(ctx, task) |
| 79 | ctx = agent.WithResponseLanguagePreference(ctx, c.responseLanguage) |
| 80 | ctx = agent.WithReasoningLanguagePreference(ctx, c.reasoningLanguage) |
| 81 | ctx = agent.WithSubagentDepth(ctx, 0) |
| 82 | answer, err := runner(ctx, sk, task, skill.SubagentRunOptions{HostInitiated: true}) |
| 83 | c.authentication.recordFailure(err, c.ModelRef()) |
| 84 | if err != nil { |
| 85 | return "", err |
| 86 | } |
| 87 | return tool.GuardSubagentHostDecisionText(answer), nil |
| 88 | } |
| 89 | |
| 90 | // SubmitHTTPFormat is SubmitHTTP with an optional structured-output format |
| 91 | // ("json_object") applied to the turn's completion requests. Empty format |
| 92 | // behaves exactly like SubmitHTTP. A format attached to a slash command, |
| 93 | // or other non-turn input is discarded; @reference turns preserve it because |
| 94 | // the format is bound to every submitted turn rather than a global slot. |
| 95 | func (c *Controller) SubmitHTTPFormat(input, format string) { |
| 96 | // format 绑定到本次提交的 turn(随请求参数传递),不再写入 Controller |
| 97 | // 全局一次性槽——评审 #7234 第 2 点:全局槽存在跨请求串用的逻辑竞态 |
| 98 | // (后提交的 JSON 请求先写槽,更早的普通请求先启动消费掉)。 |
| 99 | f := strings.TrimSpace(format) |
| 100 | if f != "" && isNonTurnHTTPInput(input) { |
| 101 | f = "" // 非 turn 输入(slash 命令/! 前缀)不携带 format |
| 102 | } |
| 103 | // @ 引用 turn(FileRefLine/SlashPathLineRef 等)同样绑定 format—— |
| 104 | // runRefTurnWithFormat 族 wrapper 注入 ctx(review fix7234and7168: |
| 105 | // format 是每个被接纳 turn 的属性,统一架构)。 |
| 106 | c.submitHTTPWithFormat(input, "", f) |
| 107 | } |
| 108 | |
| 109 | // isNonTurnHTTPInput reports inputs that never reach the agent turn loop, so a |
| 110 | // structured-output request attached to them would otherwise leak into the |
| 111 | // next real turn (the format slot is consumed only by runGoalLoopWithRawDisplay). |
| 112 | func isNonTurnHTTPInput(input string) bool { |
| 113 | trimmed := strings.TrimSpace(input) |
| 114 | if trimmed == "" { |
| 115 | return true |
| 116 | } |
| 117 | // Memory quick-add / remember shortcuts and goal commands bypass turns. |
| 118 | if _, ok := MemoryQuickAddNote(trimmed); ok { |
| 119 | return true |
| 120 | } |
| 121 | if _, ok := RememberCommandNote(trimmed); ok { |
| 122 | return true |
| 123 | } |
| 124 | // "!" shell commands are rejected by submitHTTP before the turn loop |
| 125 | // (403 over HTTP); a format attached to them would never be consumed. |
| 126 | if strings.HasPrefix(trimmed, "!") { |
| 127 | return true |
| 128 | } |
| 129 | // Slash commands are management verbs (/compact /new /clear /model ...) |
| 130 | // or notices, not completion turns. |
| 131 | if strings.HasPrefix(trimmed, "/") { |
| 132 | return true |
| 133 | } |
| 134 | return false |
| 135 | } |
| 136 | |
| 137 | // isSessionManagementSubmission reports commands that mutate or inspect the |
| 138 | // current session without admitting a model turn. They retain the original |
| 139 | // submission gate but must not enter attachment preparation: /new and /clear |
| 140 | // rotate the owner that attachment preparation is bound to. |
| 141 | func isSessionManagementSubmission(input string) bool { |
| 142 | trimmed := strings.TrimSpace(input) |
| 143 | return trimmed == "/new" || trimmed == "/clear" || trimmed == "/context" || |
| 144 | trimmed == "/compact" || strings.HasPrefix(trimmed, "/compact ") |
| 145 | } |
| 146 |