| 1 | /** |
| 2 | * The text generation operations every provider shares. A provider supplies a |
| 3 | * runner that sends one prompt and decodes the reply; prompts, linked source |
| 4 | * control context, and output sanitizing live here so providers cannot drift |
| 5 | * on them. |
| 6 | * |
| 7 | * @module textGeneration/TextGenerationOperations |
| 8 | */ |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Schema from "effect/Schema"; |
| 11 | |
| 12 | import { type ChatAttachment, type ModelSelection, TextGenerationError } from "@t3tools/contracts"; |
| 13 | import { formatGeneratedBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 14 | import { extractJsonObject } from "@t3tools/shared/schemaJson"; |
| 15 | |
| 16 | import type { ProviderTextGeneration } from "./textGeneration.ts"; |
| 17 | import { |
| 18 | buildBranchNamePrompt, |
| 19 | buildCommitMessagePrompt, |
| 20 | buildPrContentPrompt, |
| 21 | buildReviewGuidePrompt, |
| 22 | buildThreadTitlePrompt, |
| 23 | } from "./textGenerationPrompts.ts"; |
| 24 | import { |
| 25 | sanitizeCommitSubject, |
| 26 | sanitizePrTitle, |
| 27 | sanitizeThreadTitle, |
| 28 | } from "./textGenerationUtils.ts"; |
| 29 | |
| 30 | export type Operation = keyof ProviderTextGeneration; |
| 31 | |
| 32 | /** One prompt for a provider to run. */ |
| 33 | export interface Request<S extends Schema.Top> { |
| 34 | readonly operation: Operation; |
| 35 | readonly cwd: string; |
| 36 | readonly prompt: string; |
| 37 | readonly outputSchema: S; |
| 38 | readonly modelSelection: ModelSelection; |
| 39 | /** The user's attachments, already listed in the prompt. Providers that accept images send them. */ |
| 40 | readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; |
| 41 | /** |
| 42 | * Set when the model must investigate `cwd` before answering: it may read files and run |
| 43 | * read-only commands there, over as many turns as it needs, and must change nothing. Absent, |
| 44 | * the prompt carries everything and the model runs with no tools at all. A runner that cannot |
| 45 | * guarantee read-only tools fails rather than answering blind. |
| 46 | */ |
| 47 | readonly investigate?: { readonly timeoutMs: number } | undefined; |
| 48 | } |
| 49 | |
| 50 | /** How long an investigating run may take; a large change needs far longer than a title. */ |
| 51 | const INVESTIGATION_TIMEOUT_MS = 15 * 60_000; |
| 52 | |
| 53 | /** The refusal a runner gives when it has no read-only tool mode to investigate with. */ |
| 54 | export const investigationUnsupported = (operation: Operation, provider: string) => |
| 55 | new TextGenerationError({ |
| 56 | operation, |
| 57 | detail: `${provider} cannot investigate a checkout read-only. Choose another review guide model, or source control writer model when none is set.`, |
| 58 | }); |
| 59 | |
| 60 | /** Runs one request and decodes the reply as its `outputSchema`. */ |
| 61 | export type Runner = <S extends Schema.Top>( |
| 62 | request: Request<S>, |
| 63 | ) => Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]>; |
| 64 | |
| 65 | /** |
| 66 | * For a runner with no read-only tool mode: it answers prompts that carry everything, and |
| 67 | * refuses an investigation rather than answering one blind. |
| 68 | */ |
| 69 | export const withoutInvestigation = |
| 70 | (provider: string, run: Runner): Runner => |
| 71 | (request) => |
| 72 | request.investigate |
| 73 | ? Effect.fail(investigationUnsupported(request.operation, provider)) |
| 74 | : run(request); |
| 75 | |
| 76 | /** Decodes the JSON object in a text reply, which models often wrap in prose. */ |
| 77 | export const decodeJsonReply = <S extends Schema.Top>( |
| 78 | request: Pick<Request<S>, "operation" | "outputSchema">, |
| 79 | provider: string, |
| 80 | text: string, |
| 81 | ) => { |
| 82 | // Each request builds its own output schema, so the decoder cannot be hoisted. |
| 83 | const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(request.outputSchema)); |
| 84 | return decodeOutput(extractJsonObject(text)).pipe( |
| 85 | Effect.mapError( |
| 86 | (cause) => |
| 87 | new TextGenerationError({ |
| 88 | operation: request.operation, |
| 89 | detail: `${provider} returned invalid structured output.`, |
| 90 | cause, |
| 91 | }), |
| 92 | ), |
| 93 | ); |
| 94 | }; |
| 95 | |
| 96 | /** The text generation service over `run`. `name` prefixes each operation's span. */ |
| 97 | export function fromRunner(name: string, run: Runner): ProviderTextGeneration { |
| 98 | const generateCommitMessage: ProviderTextGeneration["generateCommitMessage"] = Effect.fn( |
| 99 | `${name}.generateCommitMessage`, |
| 100 | )(function* (input) { |
| 101 | const generated = yield* run({ |
| 102 | operation: "generateCommitMessage", |
| 103 | cwd: input.cwd, |
| 104 | modelSelection: input.modelSelection, |
| 105 | ...buildCommitMessagePrompt({ |
| 106 | branch: input.branch, |
| 107 | stagedSummary: input.stagedSummary, |
| 108 | stagedPatch: input.stagedPatch, |
| 109 | includeBranch: input.includeBranch === true, |
| 110 | policy: input.policy, |
| 111 | }), |
| 112 | }); |
| 113 | return { |
| 114 | subject: sanitizeCommitSubject(generated.subject), |
| 115 | body: generated.body.trim(), |
| 116 | ...("branch" in generated && typeof generated.branch === "string" |
| 117 | ? { branch: sanitizeFeatureBranchName(generated.branch) } |
| 118 | : {}), |
| 119 | }; |
| 120 | }); |
| 121 | |
| 122 | const generatePrContent: ProviderTextGeneration["generatePrContent"] = Effect.fn( |
| 123 | `${name}.generatePrContent`, |
| 124 | )(function* (input) { |
| 125 | const generated = yield* run({ |
| 126 | operation: "generatePrContent", |
| 127 | cwd: input.cwd, |
| 128 | modelSelection: input.modelSelection, |
| 129 | ...buildPrContentPrompt({ |
| 130 | baseBranch: input.baseBranch, |
| 131 | headBranch: input.headBranch, |
| 132 | commitSummary: input.commitSummary, |
| 133 | diffSummary: input.diffSummary, |
| 134 | diffPatch: input.diffPatch, |
| 135 | policy: input.policy, |
| 136 | changeRequestTemplate: input.changeRequestTemplate, |
| 137 | }), |
| 138 | }); |
| 139 | return { title: sanitizePrTitle(generated.title), body: generated.body.trim() }; |
| 140 | }); |
| 141 | |
| 142 | const generateBranchName: ProviderTextGeneration["generateBranchName"] = Effect.fn( |
| 143 | `${name}.generateBranchName`, |
| 144 | )(function* (input) { |
| 145 | const generated = yield* run({ |
| 146 | operation: "generateBranchName", |
| 147 | cwd: input.cwd, |
| 148 | modelSelection: input.modelSelection, |
| 149 | attachments: input.attachments, |
| 150 | ...buildBranchNamePrompt({ |
| 151 | message: input.message, |
| 152 | attachments: input.attachments, |
| 153 | naming: input.naming, |
| 154 | }), |
| 155 | }); |
| 156 | return { branch: formatGeneratedBranchName(generated.branch, input.naming) }; |
| 157 | }); |
| 158 | |
| 159 | const generateThreadTitle: ProviderTextGeneration["generateThreadTitle"] = Effect.fn( |
| 160 | `${name}.generateThreadTitle`, |
| 161 | )(function* (input) { |
| 162 | const generated = yield* run({ |
| 163 | operation: "generateThreadTitle", |
| 164 | cwd: input.cwd, |
| 165 | modelSelection: input.modelSelection, |
| 166 | attachments: input.attachments, |
| 167 | ...buildThreadTitlePrompt({ |
| 168 | message: input.message, |
| 169 | previousTitle: input.previousTitle, |
| 170 | linkedContext: input.linkedContext, |
| 171 | attachments: input.attachments, |
| 172 | }), |
| 173 | }); |
| 174 | return { |
| 175 | title: sanitizeThreadTitle(generated.title), |
| 176 | ...(generated.needsRefinement ? { needsRefinement: true } : {}), |
| 177 | }; |
| 178 | }); |
| 179 | |
| 180 | const generateReviewGuide: ProviderTextGeneration["generateReviewGuide"] = Effect.fn( |
| 181 | `${name}.generateReviewGuide`, |
| 182 | )(function* (input) { |
| 183 | return yield* run({ |
| 184 | operation: "generateReviewGuide", |
| 185 | cwd: input.cwd, |
| 186 | modelSelection: input.modelSelection, |
| 187 | investigate: { timeoutMs: INVESTIGATION_TIMEOUT_MS }, |
| 188 | ...buildReviewGuidePrompt(input), |
| 189 | }); |
| 190 | }); |
| 191 | |
| 192 | return { |
| 193 | generateCommitMessage, |
| 194 | generatePrContent, |
| 195 | generateBranchName, |
| 196 | generateThreadTitle, |
| 197 | generateReviewGuide, |
| 198 | } satisfies ProviderTextGeneration; |
| 199 | } |