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