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 */
9import * as Effect from "effect/Effect";
10import * as Schema from "effect/Schema";
11
12import { type ChatAttachment, type ModelSelection, TextGenerationError } from "@t3tools/contracts";
13import { formatGeneratedBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git";
14import { extractJsonObject } from "@t3tools/shared/schemaJson";
15
16import type { ProviderTextGeneration } from "./textGeneration.ts";
17import {
18 buildBranchNamePrompt,
19 buildCommitMessagePrompt,
20 buildPrContentPrompt,
21 buildReviewGuidePrompt,
22 buildThreadTitlePrompt,
23} from "./textGenerationPrompts.ts";
24import {
25 sanitizeCommitSubject,
26 sanitizePrTitle,
27 sanitizeThreadTitle,
28} from "./textGenerationUtils.ts";
29
30export type Operation = keyof ProviderTextGeneration;
31
32/** One prompt for a provider to run. */
33export 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. */
51const INVESTIGATION_TIMEOUT_MS = 15 * 60_000;
52
53/** The refusal a runner gives when it has no read-only tool mode to investigate with. */
54export 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`. */
61export 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 */
69export 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. */
77export 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. */
97export 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}