@avocadostudio-ai/orchestrator-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/LICENSE +201 -0
  2. package/dist/agent/agent-context.d.ts +19 -0
  3. package/dist/agent/agent-context.js +67 -0
  4. package/dist/agent/agent-logger.d.ts +5 -0
  5. package/dist/agent/agent-logger.js +22 -0
  6. package/dist/agent/agent-loop-openai.d.ts +8 -0
  7. package/dist/agent/agent-loop-openai.js +172 -0
  8. package/dist/agent/agent-loop.d.ts +56 -0
  9. package/dist/agent/agent-loop.js +167 -0
  10. package/dist/agent/agent-provider.d.ts +28 -0
  11. package/dist/agent/agent-provider.js +63 -0
  12. package/dist/agent/agent-tools.d.ts +28 -0
  13. package/dist/agent/agent-tools.js +899 -0
  14. package/dist/agent/context/editing-guidelines.md +46 -0
  15. package/dist/agent/context/role.md +39 -0
  16. package/dist/agent/integration-prompt.d.ts +9 -0
  17. package/dist/agent/integration-prompt.js +154 -0
  18. package/dist/agent/sites-agent-context.d.ts +12 -0
  19. package/dist/agent/sites-agent-context.js +316 -0
  20. package/dist/agent/sites-agent-shared.d.ts +161 -0
  21. package/dist/agent/sites-agent-shared.js +1101 -0
  22. package/dist/agent/sites-agent-tools.d.ts +18 -0
  23. package/dist/agent/sites-agent-tools.js +1227 -0
  24. package/dist/chat/anthropic-cache.d.ts +20 -0
  25. package/dist/chat/anthropic-cache.js +54 -0
  26. package/dist/chat/anthropic-planner.d.ts +98 -0
  27. package/dist/chat/anthropic-planner.js +1012 -0
  28. package/dist/chat/changelog-coverage-validator.d.ts +37 -0
  29. package/dist/chat/changelog-coverage-validator.js +215 -0
  30. package/dist/chat/chat-pipeline-context.d.ts +211 -0
  31. package/dist/chat/chat-pipeline-context.js +249 -0
  32. package/dist/chat/chat-pipeline-deterministic.d.ts +61 -0
  33. package/dist/chat/chat-pipeline-deterministic.js +407 -0
  34. package/dist/chat/chat-pipeline-image.d.ts +86 -0
  35. package/dist/chat/chat-pipeline-image.js +897 -0
  36. package/dist/chat/chat-pipeline-shared.d.ts +69 -0
  37. package/dist/chat/chat-pipeline-shared.js +212 -0
  38. package/dist/chat/chat-pipeline-translation.d.ts +27 -0
  39. package/dist/chat/chat-pipeline-translation.js +417 -0
  40. package/dist/chat/chat-pipeline-ui.d.ts +14 -0
  41. package/dist/chat/chat-pipeline-ui.js +244 -0
  42. package/dist/chat/chat-pipeline.d.ts +99 -0
  43. package/dist/chat/chat-pipeline.js +3999 -0
  44. package/dist/chat/decomposer.d.ts +21 -0
  45. package/dist/chat/decomposer.js +65 -0
  46. package/dist/chat/gemini-planner.d.ts +70 -0
  47. package/dist/chat/gemini-planner.js +541 -0
  48. package/dist/chat/hallucination-validator.d.ts +36 -0
  49. package/dist/chat/hallucination-validator.js +110 -0
  50. package/dist/chat/locale-strings.d.ts +47 -0
  51. package/dist/chat/locale-strings.js +100 -0
  52. package/dist/chat/plan-json-schema.d.ts +133 -0
  53. package/dist/chat/plan-json-schema.js +112 -0
  54. package/dist/chat/planner-types.d.ts +120 -0
  55. package/dist/chat/planner-types.js +66 -0
  56. package/dist/chat/planner.d.ts +148 -0
  57. package/dist/chat/planner.js +1361 -0
  58. package/dist/chat/prompts.d.ts +67 -0
  59. package/dist/chat/prompts.js +356 -0
  60. package/dist/chat/provider-routing.d.ts +14 -0
  61. package/dist/chat/provider-routing.js +27 -0
  62. package/dist/chat/variation-pipeline.d.ts +135 -0
  63. package/dist/chat/variation-pipeline.js +837 -0
  64. package/dist/chat/vision-alt-generator.d.ts +35 -0
  65. package/dist/chat/vision-alt-generator.js +152 -0
  66. package/dist/cms/adapter.d.ts +62 -0
  67. package/dist/cms/adapter.js +1 -0
  68. package/dist/cms/bootstrap.d.ts +17 -0
  69. package/dist/cms/bootstrap.js +85 -0
  70. package/dist/cms/editor-api-adapter.d.ts +23 -0
  71. package/dist/cms/editor-api-adapter.js +71 -0
  72. package/dist/cms/index.d.ts +4 -0
  73. package/dist/cms/index.js +3 -0
  74. package/dist/cms/json-file-adapter.d.ts +11 -0
  75. package/dist/cms/json-file-adapter.js +62 -0
  76. package/dist/demo-mode.d.ts +59 -0
  77. package/dist/demo-mode.js +201 -0
  78. package/dist/errors.d.ts +67 -0
  79. package/dist/errors.js +129 -0
  80. package/dist/http/chat-stream-resumable.d.ts +108 -0
  81. package/dist/http/chat-stream-resumable.js +290 -0
  82. package/dist/http/chat-stream.d.ts +99 -0
  83. package/dist/http/chat-stream.js +92 -0
  84. package/dist/image/gdrive-client.d.ts +22 -0
  85. package/dist/image/gdrive-client.js +215 -0
  86. package/dist/image/image-helpers.d.ts +95 -0
  87. package/dist/image/image-helpers.js +488 -0
  88. package/dist/index.d.ts +1 -0
  89. package/dist/index.js +1 -0
  90. package/dist/jira/jira-approval.d.ts +22 -0
  91. package/dist/jira/jira-approval.js +51 -0
  92. package/dist/jira/jira-client.d.ts +44 -0
  93. package/dist/jira/jira-client.js +313 -0
  94. package/dist/jira/jira-poller.d.ts +46 -0
  95. package/dist/jira/jira-poller.js +184 -0
  96. package/dist/jira/jira-processor.d.ts +103 -0
  97. package/dist/jira/jira-processor.js +1085 -0
  98. package/dist/jira/jira-types.d.ts +117 -0
  99. package/dist/jira/jira-types.js +38 -0
  100. package/dist/logger.d.ts +12 -0
  101. package/dist/logger.js +28 -0
  102. package/dist/migration/mcp-server-stdio.d.ts +8 -0
  103. package/dist/migration/mcp-server-stdio.js +672 -0
  104. package/dist/migration/migration-prompt.d.ts +7 -0
  105. package/dist/migration/migration-prompt.js +197 -0
  106. package/dist/migration/migration-tools.d.ts +17 -0
  107. package/dist/migration/migration-tools.js +159 -0
  108. package/dist/migration/scrape-cache.d.ts +9 -0
  109. package/dist/migration/scrape-cache.js +19 -0
  110. package/dist/nlp/deterministic-planner-context.d.ts +141 -0
  111. package/dist/nlp/deterministic-planner-context.js +362 -0
  112. package/dist/nlp/deterministic-planner-pages.d.ts +26 -0
  113. package/dist/nlp/deterministic-planner-pages.js +170 -0
  114. package/dist/nlp/deterministic-planner-patches.d.ts +80 -0
  115. package/dist/nlp/deterministic-planner-patches.js +508 -0
  116. package/dist/nlp/deterministic-planner-refs.d.ts +33 -0
  117. package/dist/nlp/deterministic-planner-refs.js +164 -0
  118. package/dist/nlp/deterministic-planner-suggestions.d.ts +49 -0
  119. package/dist/nlp/deterministic-planner-suggestions.js +579 -0
  120. package/dist/nlp/deterministic-planner.d.ts +85 -0
  121. package/dist/nlp/deterministic-planner.js +1631 -0
  122. package/dist/nlp/intent-detection.d.ts +309 -0
  123. package/dist/nlp/intent-detection.js +730 -0
  124. package/dist/nlp/intent-helpers.d.ts +15 -0
  125. package/dist/nlp/intent-helpers.js +243 -0
  126. package/dist/nlp/intent-patterns.d.ts +40 -0
  127. package/dist/nlp/intent-patterns.js +223 -0
  128. package/dist/nlp/plan-normalizer.d.ts +41 -0
  129. package/dist/nlp/plan-normalizer.js +1537 -0
  130. package/dist/ops/destructive-action-gate.d.ts +44 -0
  131. package/dist/ops/destructive-action-gate.js +90 -0
  132. package/dist/ops/ops-engine.d.ts +151 -0
  133. package/dist/ops/ops-engine.js +1394 -0
  134. package/dist/publish/diff-engine.d.ts +18 -0
  135. package/dist/publish/diff-engine.js +305 -0
  136. package/dist/publish/publish-helpers.d.ts +87 -0
  137. package/dist/publish/publish-helpers.js +521 -0
  138. package/dist/publish/publish-target-registry.d.ts +7 -0
  139. package/dist/publish/publish-target-registry.js +61 -0
  140. package/dist/publish/publish-target.d.ts +81 -0
  141. package/dist/publish/publish-target.js +1 -0
  142. package/dist/publish/targets/deploy-hook.d.ts +13 -0
  143. package/dist/publish/targets/deploy-hook.js +123 -0
  144. package/dist/publish/targets/git.d.ts +13 -0
  145. package/dist/publish/targets/git.js +55 -0
  146. package/dist/publish/targets/site-contract.d.ts +19 -0
  147. package/dist/publish/targets/site-contract.js +124 -0
  148. package/dist/state/content-source.d.ts +17 -0
  149. package/dist/state/content-source.js +1 -0
  150. package/dist/state/in-memory-content-source.d.ts +27 -0
  151. package/dist/state/in-memory-content-source.js +51 -0
  152. package/dist/state/session-lock.d.ts +13 -0
  153. package/dist/state/session-lock.js +29 -0
  154. package/dist/state/session-state.d.ts +310 -0
  155. package/dist/state/session-state.js +1083 -0
  156. package/dist/state/sqlite-store-singleton.d.ts +31 -0
  157. package/dist/state/sqlite-store-singleton.js +170 -0
  158. package/dist/state/sqlite-store.d.ts +135 -0
  159. package/dist/state/sqlite-store.js +421 -0
  160. package/dist/telemetry/chat-telemetry.d.ts +105 -0
  161. package/dist/telemetry/chat-telemetry.js +247 -0
  162. package/dist/telemetry/eval-candidate-store.d.ts +50 -0
  163. package/dist/telemetry/eval-candidate-store.js +120 -0
  164. package/dist/telemetry/feedback-store.d.ts +34 -0
  165. package/dist/telemetry/feedback-store.js +76 -0
  166. package/dist/telemetry/jira-telemetry.d.ts +57 -0
  167. package/dist/telemetry/jira-telemetry.js +68 -0
  168. package/dist/telemetry/migration-telemetry.d.ts +35 -0
  169. package/dist/telemetry/migration-telemetry.js +40 -0
  170. package/dist/telemetry/usage.d.ts +24 -0
  171. package/dist/telemetry/usage.js +80 -0
  172. package/dist/tools/builtin-registrations.d.ts +12 -0
  173. package/dist/tools/builtin-registrations.js +33 -0
  174. package/dist/tools/builtins/gdrive-browse.d.ts +3 -0
  175. package/dist/tools/builtins/gdrive-browse.js +68 -0
  176. package/dist/tools/builtins/image-generate.d.ts +3 -0
  177. package/dist/tools/builtins/image-generate.js +211 -0
  178. package/dist/tools/builtins/unsplash-get-by-id.d.ts +23 -0
  179. package/dist/tools/builtins/unsplash-get-by-id.js +119 -0
  180. package/dist/tools/builtins/unsplash-search.d.ts +3 -0
  181. package/dist/tools/builtins/unsplash-search.js +74 -0
  182. package/dist/tools/executor.d.ts +23 -0
  183. package/dist/tools/executor.js +169 -0
  184. package/dist/tools/index.d.ts +5 -0
  185. package/dist/tools/index.js +5 -0
  186. package/dist/tools/registry.d.ts +21 -0
  187. package/dist/tools/registry.js +75 -0
  188. package/dist/tools/runtime.d.ts +27 -0
  189. package/dist/tools/runtime.js +48 -0
  190. package/dist/tools/schema-validator.d.ts +24 -0
  191. package/dist/tools/schema-validator.js +88 -0
  192. package/dist/tools/types.d.ts +86 -0
  193. package/dist/tools/types.js +1 -0
  194. package/dist/variation-images.d.ts +19 -0
  195. package/dist/variation-images.js +12 -0
  196. package/package.json +78 -0
@@ -0,0 +1,1012 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import { allowedBlockTypes, editPlanSchema } from "@avocadostudio-ai/shared";
3
+ import { buildIntentParserSystemPrompt, buildPlannerSystemPromptSegments } from "./prompts.js";
4
+ import { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER } from "./chat-pipeline.js";
5
+ import { extractAudienceTarget, fetchImageAsBase64, resolveAttachmentsForLlm, intentSchema } from "../nlp/deterministic-planner.js";
6
+ import { isBatchAddRequest, isBatchRemoveRequest, isBatchReorderRequest, isPageWideRewriteRequest, isDuplicateAndModifyRequest, requestsPlanFirst } from "../nlp/intent-detection.js";
7
+ import { extractJsonObject, normalizePlanCandidate, repairAndParseJsonWithMeta } from "../nlp/plan-normalizer.js";
8
+ import { buildPlannerSchemaContext, extractUpdatePropsFieldDraftsFromPlanBuffer, extractOpsFromPlanBuffer, extractSummaryFromPlanBuffer, isChatStrictPrimaryOpMode, isPageWideTranslationRequest } from "./planner.js";
9
+ import { buildEditPlanJsonSchema, intentJsonSchema } from "./plan-json-schema.js";
10
+ import { extractUsage, ZERO_USAGE } from "../telemetry/usage.js";
11
+ import { anthropicSystemPromptWithCache, anthropicSegmentedSystemPromptWithCache, anthropicToolWithCache, ANTHROPIC_FINE_GRAINED_STREAM_HEADERS } from "./anthropic-cache.js";
12
+ import { executeToolCall } from "../tools/runtime.js";
13
+ import { DEFERRABLE_IMAGE_TOOLS } from "./chat-pipeline-shared.js";
14
+ // Minimum text length to treat a text-only model response as meaningful
15
+ // content (rather than discarding it in favor of the hardcoded fallback).
16
+ const MIN_MEANINGFUL_RESPONSE_LENGTH = 20;
17
+ const LLM_TIMEOUT_MS = 120_000;
18
+ /** Detect API-level errors (rate limits, auth failures, quota exhaustion) that
19
+ * should NOT be treated as recoverable stream parse errors. */
20
+ function isApiLevelError(err) {
21
+ if (!(err instanceof Error))
22
+ return false;
23
+ const msg = err.message.toLowerCase();
24
+ return (msg.includes("usage limit") ||
25
+ msg.includes("rate limit") ||
26
+ msg.includes("quota") ||
27
+ msg.includes("authentication") ||
28
+ msg.includes("unauthorized") ||
29
+ msg.includes("invalid.*api.*key") ||
30
+ msg.includes("billing") ||
31
+ /\b40[0-13]\b/.test(err.message));
32
+ }
33
+ /**
34
+ * Try JSON.parse, then repairAndParseJson. Returns parsed object or null.
35
+ */
36
+ function tryParseOrRepair(buf, log, model) {
37
+ try {
38
+ return JSON.parse(buf);
39
+ }
40
+ catch {
41
+ try {
42
+ const meta = repairAndParseJsonWithMeta(buf);
43
+ log?.warn({ event: "anthropic_planner_json_repaired", model: model ?? "unknown", strategy: meta.strategy, mutationCount: meta.mutationCount, discardedBytes: meta.discardedBytes }, "Anthropic planner: repaired malformed tool JSON from stream buffer");
44
+ return meta.parsed;
45
+ }
46
+ catch (repairErr) {
47
+ log?.warn({
48
+ event: "anthropic_planner_repair_failed",
49
+ model: model ?? "unknown",
50
+ repairError: repairErr instanceof Error ? repairErr.message : String(repairErr),
51
+ }, "Anthropic planner: repairAndParseJson failed");
52
+ return null;
53
+ }
54
+ }
55
+ }
56
+ // ---------------------------------------------------------------------------
57
+ // Singleton Anthropic client — reuses HTTP/2 connection pool across requests.
58
+ // Lazily initialized on first use so module-level import doesn't throw if
59
+ // ANTHROPIC_API_KEY isn't set yet.
60
+ // ---------------------------------------------------------------------------
61
+ let _anthropicSingleton = null;
62
+ function getAnthropicClient() {
63
+ if (!_anthropicSingleton) {
64
+ _anthropicSingleton = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
65
+ }
66
+ return _anthropicSingleton;
67
+ }
68
+ /** Reset the singleton (useful for tests that swap API keys). */
69
+ export function resetAnthropicClient() {
70
+ _anthropicSingleton = null;
71
+ }
72
+ export async function parseIntentWithAnthropic(args) {
73
+ const client = getAnthropicClient();
74
+ const system = buildIntentParserSystemPrompt();
75
+ const user = {
76
+ request: args.message,
77
+ slug: args.slug,
78
+ activeBlockId: args.activeBlockId ?? null,
79
+ activeBlockType: args.activeBlockType ?? null,
80
+ activeEditablePath: args.activeEditablePath ?? null,
81
+ availableBlockTypes: allowedBlockTypes,
82
+ blocks: args.currentPage.blocks.map((b) => ({ id: b.id, type: b.type, props: Object.keys(b.props) }))
83
+ };
84
+ const response = await client.messages.create({
85
+ model: args.model,
86
+ max_tokens: 2048,
87
+ system: anthropicSystemPromptWithCache(system),
88
+ output_config: {
89
+ format: { type: "json_schema", schema: intentJsonSchema }
90
+ },
91
+ messages: [
92
+ { role: "user", content: JSON.stringify(user) }
93
+ ],
94
+ }, { signal: AbortSignal.timeout(LLM_TIMEOUT_MS) });
95
+ if (response.stop_reason === "max_tokens") {
96
+ const textBlock = response.content.find((b) => b.type === "text");
97
+ const raw = textBlock && "text" in textBlock ? textBlock.text : "";
98
+ args.log?.warn({
99
+ event: "anthropic_intent_truncated",
100
+ model: args.model,
101
+ stopReason: response.stop_reason,
102
+ rawPreview: raw.slice(0, 500)
103
+ }, "Anthropic intent parser: response truncated (max_tokens)");
104
+ throw new Error("Intent parser response was truncated (max_tokens reached)");
105
+ }
106
+ const textBlock = response.content.find((b) => b.type === "text");
107
+ const raw = textBlock && "text" in textBlock ? textBlock.text : "";
108
+ if (!raw.trim()) {
109
+ args.log?.warn({
110
+ event: "anthropic_intent_no_json",
111
+ model: args.model,
112
+ stopReason: response.stop_reason ?? "unknown",
113
+ rawPreview: raw.slice(0, 500),
114
+ contentBlockTypes: response.content.map((b) => b.type)
115
+ }, "Anthropic intent parser: model returned empty response");
116
+ throw new Error("Intent parser did not return JSON");
117
+ }
118
+ // output_config guarantees valid JSON matching our schema — parse directly
119
+ const normalized = JSON.parse(raw);
120
+ // Nulls from the schema become undefined for Zod optional fields
121
+ for (const key of Object.keys(normalized)) {
122
+ if (normalized[key] === null)
123
+ delete normalized[key];
124
+ }
125
+ const parsed = intentSchema.safeParse(normalized);
126
+ if (!parsed.success) {
127
+ const issue = parsed.error.issues[0];
128
+ const detail = issue?.message ?? "Invalid intent parser output";
129
+ const at = issue?.path?.length ? ` at ${issue.path.join(".")}` : "";
130
+ throw new Error(`${detail}${at}`);
131
+ }
132
+ return parsed.data;
133
+ }
134
+ function sumTokenUsage(a, b) {
135
+ return {
136
+ inputTokens: a.inputTokens + b.inputTokens,
137
+ outputTokens: a.outputTokens + b.outputTokens,
138
+ totalTokens: a.totalTokens + b.totalTokens,
139
+ ...(a.cacheCreationInputTokens !== undefined || b.cacheCreationInputTokens !== undefined
140
+ ? { cacheCreationInputTokens: (a.cacheCreationInputTokens ?? 0) + (b.cacheCreationInputTokens ?? 0) }
141
+ : {}),
142
+ ...(a.cacheReadInputTokens !== undefined || b.cacheReadInputTokens !== undefined
143
+ ? { cacheReadInputTokens: (a.cacheReadInputTokens ?? 0) + (b.cacheReadInputTokens ?? 0) }
144
+ : {})
145
+ };
146
+ }
147
+ function asToolUseBlock(block) {
148
+ if (block.type !== "tool_use")
149
+ return null;
150
+ return block;
151
+ }
152
+ function toAnthropicToolAlias(name, used) {
153
+ let alias = name.replace(/[^a-zA-Z0-9_-]/g, "_");
154
+ if (!alias)
155
+ alias = "tool";
156
+ if (/^[0-9]/.test(alias))
157
+ alias = `tool_${alias}`;
158
+ alias = alias.slice(0, 120);
159
+ let unique = alias;
160
+ let counter = 1;
161
+ while (used.has(unique)) {
162
+ unique = `${alias}_${counter}`;
163
+ counter += 1;
164
+ }
165
+ used.add(unique);
166
+ return unique;
167
+ }
168
+ export async function generatePlanWithAnthropic(args) {
169
+ const client = args.client ?? getAnthropicClient();
170
+ const effectiveSignal = args.signal
171
+ ? AbortSignal.any([args.signal, AbortSignal.timeout(LLM_TIMEOUT_MS)])
172
+ : AbortSignal.timeout(LLM_TIMEOUT_MS);
173
+ const effectiveBlockTypes = args.componentsManifest ? args.componentsManifest.blocks.map(c => c.type) : allowedBlockTypes;
174
+ const batchOverride = isBatchAddRequest(args.message) || isBatchRemoveRequest(args.message) || isBatchReorderRequest(args.message) || isPageWideRewriteRequest(args.message) || isDuplicateAndModifyRequest(args.message);
175
+ const pageWideRewrite = isPageWideRewriteRequest(args.message);
176
+ const planFirstMode = requestsPlanFirst(args.message);
177
+ const pageWideTranslation = isPageWideTranslationRequest(args.message);
178
+ const chatStrictPrimaryOpMode = isChatStrictPrimaryOpMode() && !batchOverride && !pageWideTranslation;
179
+ const selectedBlockId = String(args.contextPack.selected.blockId ?? "");
180
+ const audienceHint = extractAudienceTarget(args.message);
181
+ const explicitOtherReference = selectedBlockId.length > 0 &&
182
+ Array.isArray(args.contextPack.resolvedReferences.mentionedBlocks) &&
183
+ args.contextPack.resolvedReferences.mentionedBlocks.some((entry) => entry && typeof entry === "object" && "id" in entry && entry.id !== selectedBlockId);
184
+ const systemSegments = buildPlannerSystemPromptSegments({
185
+ provider: "anthropic",
186
+ lightweight: !!args.lightweight,
187
+ selectedBlockId,
188
+ explicitOtherReference: !!explicitOtherReference,
189
+ chatStrictPrimaryOpMode,
190
+ pageWideTranslation,
191
+ pageWideRewrite,
192
+ planFirstMode,
193
+ effectiveBlockTypes,
194
+ siteContextBlock: args.siteContextBlock,
195
+ imageUrlForVision: args.contextPack.selected?.imageUrlForVision,
196
+ editablePath: args.contextPack.selected?.editablePath,
197
+ blockId: args.contextPack.selected?.blockId,
198
+ locale: args.locale,
199
+ imageSourceChoiceOpen: args.imageSourceChoiceOpen,
200
+ attachmentCount: args.contextPack.attachments?.length,
201
+ });
202
+ // Stable prefix carries cache_control; dynamic tail is sent as a second
203
+ // text block without it, so per-request flags don't bust the cached prefix.
204
+ const cachedSystem = anthropicSegmentedSystemPromptWithCache(systemSegments);
205
+ const lowerMsg = args.message.toLowerCase();
206
+ const includeContracts = !args.lightweight && (batchOverride ||
207
+ pageWideTranslation ||
208
+ /\b(create|add|insert|build|generate)\b/.test(lowerMsg) ||
209
+ /\b(seo|meta|metadata|og\s*image|open\s*graph|description|structured\s*data|schema\.org)\b/.test(lowerMsg) ||
210
+ /\d{2,3}\s*char/i.test(args.message) ||
211
+ // Multi-field updates need block contracts to know valid prop names
212
+ (lowerMsg.match(/['''"""\u201C\u201D\u2018\u2019]/g)?.length ?? 0) >= 4);
213
+ const schemaContext = args.lightweight
214
+ ? {
215
+ payload: {},
216
+ meta: {
217
+ contractMode: "minimal",
218
+ contractBytes: 0,
219
+ contractBlockCount: 0,
220
+ targetBlockTypes: [],
221
+ strictJsonEnabled: false
222
+ }
223
+ }
224
+ : buildPlannerSchemaContext({
225
+ message: args.message,
226
+ contextPack: args.contextPack,
227
+ batchOverride,
228
+ pageWideTranslation,
229
+ legacyIncludeContracts: includeContracts,
230
+ forceFullContracts: args.forceFullSchemaContracts,
231
+ componentsManifest: args.componentsManifest
232
+ });
233
+ const user = {
234
+ request: args.message,
235
+ audienceHint: audienceHint ?? null,
236
+ slug: args.slug,
237
+ contextPack: args.contextPack,
238
+ ...schemaContext.payload,
239
+ feedback: args.feedback ?? null
240
+ };
241
+ const imageUrlForVision = typeof args.contextPack.selected?.imageUrlForVision === "string"
242
+ ? args.contextPack.selected.imageUrlForVision
243
+ : null;
244
+ const imageBase64 = imageUrlForVision ? await fetchImageAsBase64(imageUrlForVision) : null;
245
+ // User-attached files (images + PDFs) become native content blocks: images as
246
+ // `image` blocks, PDFs as `document` blocks (Claude reads text + per-page visuals).
247
+ const resolvedAttachments = await resolveAttachmentsForLlm(args.contextPack.attachments);
248
+ const attachmentBlocks = resolvedAttachments.map((attachment) => attachment.kind === "pdf"
249
+ ? { type: "document", source: { type: "base64", media_type: "application/pdf", data: attachment.base64 } }
250
+ : { type: "image", source: { type: "base64", media_type: attachment.mediaType, data: attachment.base64 } });
251
+ const visionBlocks = imageUrlForVision
252
+ ? [
253
+ imageBase64
254
+ ? { type: "image", source: { type: "base64", media_type: imageBase64.mediaType, data: imageBase64.base64 } }
255
+ : { type: "image", source: { type: "url", url: imageUrlForVision } }
256
+ ]
257
+ : [];
258
+ const userContent = visionBlocks.length > 0 || attachmentBlocks.length > 0
259
+ ? [...visionBlocks, ...attachmentBlocks, { type: "text", text: JSON.stringify(user) }]
260
+ : JSON.stringify(user);
261
+ // Anthropic rejects the WHOLE request ("text content blocks must contain non-whitespace
262
+ // text") if any message carries an empty/whitespace text block — e.g. a blank chat turn
263
+ // that slipped into history. Drop those so one stray turn can't fail the entire plan.
264
+ const historyMessages = (args.history ?? [])
265
+ .filter((h) => typeof h.content === "string" && h.content.trim().length > 0)
266
+ .map((h) => ({
267
+ role: h.role,
268
+ content: h.content
269
+ }));
270
+ const submitPlanToolDef = {
271
+ name: "submit_edit_plan",
272
+ description: "Submit the structured EditPlan JSON.",
273
+ input_schema: buildEditPlanJsonSchema({ planFirstMode }),
274
+ eager_input_streaming: true
275
+ };
276
+ const runtimeToolNameByAlias = new Map();
277
+ const usedAliases = new Set(["submit_edit_plan"]);
278
+ const runtimeTools = args.toolRuntime
279
+ ? args.toolRuntime.registry.listEnabled().map((entry) => {
280
+ const alias = toAnthropicToolAlias(entry.manifest.name, usedAliases);
281
+ runtimeToolNameByAlias.set(alias, entry.manifest.name);
282
+ return {
283
+ name: alias,
284
+ description: entry.manifest.description,
285
+ input_schema: entry.manifest.inputSchema
286
+ };
287
+ })
288
+ : [];
289
+ // Anthropic allows max 4 cache_control breakpoints. Cache the *first* tool
290
+ // (submitPlanToolDef) — it carries the large editPlanJsonSchema and is
291
+ // present on every request. Caching the first tool means the cached prefix
292
+ // stays a hit even when runtime tools (gdrive, unsplash, image.generate)
293
+ // come and go between requests.
294
+ const toolDefs = [
295
+ anthropicToolWithCache(submitPlanToolDef),
296
+ ...runtimeTools,
297
+ ];
298
+ let parsed;
299
+ let usage = { ...ZERO_USAGE };
300
+ const deferredNativeImageCalls = [];
301
+ let streamedOpsCount = 0;
302
+ let lastSummaryLen = 0;
303
+ let emittedChangeLogCount = 0;
304
+ const emittedFieldDraftByKey = new Map();
305
+ const maxToolTurns = 6;
306
+ // Anthropic adaptive thinking — added to every messages.create/stream call when
307
+ // args.thinking is set. Adaptive thinking + `output_config.effort` is the current
308
+ // API; the legacy `budget_tokens` shape is rejected (400) on Opus 4.8 / Sonnet 5.
309
+ // `display: "summarized"` keeps streamed thinking tokens visible (the default is
310
+ // "omitted", which streams empty thinking blocks and would blank the reasoning UI).
311
+ // Plan-first review mode disables thinking: thinking forces the tool_choice="auto"
312
+ // path, where the model can emit free-form prose instead of calling
313
+ // submit_edit_plan — and a text-only response is wrapped as needs_clarification
314
+ // (see fallback below). With thinking OFF we route to constrained decoding
315
+ // (output_config.format) / forced tool_choice, both of which honor the narrowed
316
+ // intent enum and guarantee a reviewable edit_plan.
317
+ const thinkingParam = !planFirstMode && args.thinking
318
+ ? { type: "adaptive", display: "summarized" }
319
+ : undefined;
320
+ const effortParam = thinkingParam ? args.thinking.effort : undefined;
321
+ // Adaptive thinking counts thinking tokens against max_tokens; give headroom so
322
+ // the actual response isn't truncated. Effort (not max_tokens) governs spend.
323
+ const plannerMaxTokens = thinkingParam ? 16000 : 8192;
324
+ // Collapsed request params spread into every tool-loop create/stream call.
325
+ // effort rides inside output_config alongside thinking; only the constrained-
326
+ // decoding fallback (thinking OFF) sets output_config.format instead.
327
+ const thinkingRequestParams = thinkingParam
328
+ ? { thinking: thinkingParam, output_config: { effort: effortParam } }
329
+ : {};
330
+ // Plan-first review mode forces the submit_edit_plan tool so the model cannot
331
+ // emit free-form prose (which the text-only fallback wraps as
332
+ // needs_clarification). Combined with the narrowed intent enum, this
333
+ // guarantees a reviewable edit_plan. Safe here because planFirstMode also
334
+ // disables thinking above, and forced tool_choice is incompatible with thinking.
335
+ const submitToolChoice = planFirstMode
336
+ ? { type: "tool", name: "submit_edit_plan" }
337
+ : { type: "auto" };
338
+ // Track thinking lifecycle for callbacks (Path 1 tool-loop and Path 2 stream)
339
+ let thinkingStartedAt = null;
340
+ const emitThinkingStart = () => {
341
+ if (!args.onThinking || thinkingStartedAt !== null)
342
+ return;
343
+ thinkingStartedAt = Date.now();
344
+ args.onThinking({ type: "start" });
345
+ };
346
+ const emitThinkingToken = (text) => {
347
+ if (!args.onThinking || !text)
348
+ return;
349
+ emitThinkingStart();
350
+ args.onThinking({ type: "token", text });
351
+ };
352
+ const emitThinkingEnd = () => {
353
+ if (!args.onThinking || thinkingStartedAt === null)
354
+ return;
355
+ const durationMs = Date.now() - thinkingStartedAt;
356
+ thinkingStartedAt = null;
357
+ args.onThinking({ type: "end", durationMs });
358
+ };
359
+ const emitProgressFromToolJson = (toolJsonBuf) => {
360
+ if (args.onFieldDraft) {
361
+ const fieldDrafts = extractUpdatePropsFieldDraftsFromPlanBuffer(toolJsonBuf);
362
+ for (const draft of fieldDrafts) {
363
+ const key = `${draft.opIndex}:${draft.blockId}:${draft.editablePath}`;
364
+ const prev = emittedFieldDraftByKey.get(key);
365
+ if (prev === draft.value)
366
+ continue;
367
+ emittedFieldDraftByKey.set(key, draft.value);
368
+ args.onFieldDraft({ blockId: draft.blockId, editablePath: draft.editablePath, value: draft.value, ...(draft.pageSlug ? { pageSlug: draft.pageSlug } : {}) });
369
+ }
370
+ }
371
+ if (args.onSummaryChunk || args.onChangeLogEntry) {
372
+ const extracted = extractSummaryFromPlanBuffer(toolJsonBuf);
373
+ if (extracted.summary && extracted.summary.length > lastSummaryLen) {
374
+ args.onSummaryChunk?.(extracted.summary.slice(lastSummaryLen));
375
+ lastSummaryLen = extracted.summary.length;
376
+ }
377
+ for (let i = emittedChangeLogCount; i < extracted.changeLog.length; i++) {
378
+ args.onChangeLogEntry?.(extracted.changeLog[i]);
379
+ }
380
+ emittedChangeLogCount = extracted.changeLog.length;
381
+ }
382
+ if (args.onPlannedOp) {
383
+ const next = extractOpsFromPlanBuffer(toolJsonBuf, streamedOpsCount);
384
+ streamedOpsCount = next.nextEmittedCount;
385
+ for (let idx = 0; idx < next.newOps.length; idx += 1) {
386
+ args.onPlannedOp(next.newOps[idx], streamedOpsCount - next.newOps.length + idx + 1);
387
+ }
388
+ }
389
+ };
390
+ if (runtimeTools.length > 0) {
391
+ const loopMessages = [
392
+ ...historyMessages,
393
+ { role: "user", content: userContent }
394
+ ];
395
+ for (let turn = 0; turn < maxToolTurns; turn += 1) {
396
+ let response;
397
+ let emittedTextDeltas = false;
398
+ if (client.messages.stream) {
399
+ const stream = client.messages.stream({
400
+ model: args.model,
401
+ max_tokens: plannerMaxTokens,
402
+ system: cachedSystem,
403
+ tools: toolDefs,
404
+ tool_choice: submitToolChoice,
405
+ messages: loopMessages,
406
+ ...thinkingRequestParams
407
+ }, {
408
+ headers: ANTHROPIC_FINE_GRAINED_STREAM_HEADERS,
409
+ signal: effectiveSignal,
410
+ });
411
+ const toolNameByIndex = new Map();
412
+ const submitToolJsonByIndex = new Map();
413
+ const thinkingBlockIndexes = new Set();
414
+ let path1TextBuf = "";
415
+ // Wrap stream iteration — SDK may throw on message_stop if tool JSON is malformed
416
+ let path1StreamError;
417
+ try {
418
+ for await (const event of stream) {
419
+ if (event.type === "content_block_start") {
420
+ const idx = typeof event.index === "number" ? event.index : -1;
421
+ if (idx >= 0 && event.content_block?.type === "tool_use" && typeof event.content_block.name === "string") {
422
+ toolNameByIndex.set(idx, event.content_block.name);
423
+ }
424
+ if (idx >= 0 && event.content_block?.type === "thinking") {
425
+ thinkingBlockIndexes.add(idx);
426
+ emitThinkingStart();
427
+ }
428
+ continue;
429
+ }
430
+ if (event.type === "content_block_stop") {
431
+ const idx = typeof event.index === "number" ? event.index : -1;
432
+ if (idx >= 0 && thinkingBlockIndexes.has(idx)) {
433
+ thinkingBlockIndexes.delete(idx);
434
+ if (thinkingBlockIndexes.size === 0)
435
+ emitThinkingEnd();
436
+ }
437
+ continue;
438
+ }
439
+ if (event.type !== "content_block_delta")
440
+ continue;
441
+ if (event.delta?.type === "thinking_delta") {
442
+ const thinkingText = event.delta.thinking ?? "";
443
+ if (thinkingText.length > 0)
444
+ emitThinkingToken(thinkingText);
445
+ continue;
446
+ }
447
+ if (event.delta?.type === "text_delta") {
448
+ const text = event.delta.text ?? "";
449
+ if (text.length > 0) {
450
+ emittedTextDeltas = true;
451
+ path1TextBuf += text;
452
+ args.onToken?.(text);
453
+ }
454
+ continue;
455
+ }
456
+ if (event.delta?.type !== "input_json_delta")
457
+ continue;
458
+ const idx = typeof event.index === "number" ? event.index : -1;
459
+ if (idx < 0 || toolNameByIndex.get(idx) !== "submit_edit_plan")
460
+ continue;
461
+ const nextBuf = (submitToolJsonByIndex.get(idx) ?? "") + (event.delta.partial_json ?? "");
462
+ submitToolJsonByIndex.set(idx, nextBuf);
463
+ emitProgressFromToolJson(nextBuf);
464
+ }
465
+ }
466
+ catch (err) {
467
+ // Re-throw API-level errors (rate limits, auth failures, quota exhaustion)
468
+ // instead of treating them as recoverable stream parse errors.
469
+ if (isApiLevelError(err))
470
+ throw err;
471
+ path1StreamError = err;
472
+ }
473
+ // If stream threw, try to parse from accumulated buffers instead of finalMessage
474
+ if (path1StreamError) {
475
+ args.log?.warn({
476
+ event: "anthropic_path1_stream_loop_error",
477
+ model: args.model,
478
+ error: path1StreamError instanceof Error ? path1StreamError.message : String(path1StreamError),
479
+ submitToolJsonEntries: submitToolJsonByIndex.size,
480
+ textBufLength: path1TextBuf.length,
481
+ }, "Anthropic Path 1: stream loop threw (SDK JSON parse) — will attempt buffer repair");
482
+ // Find the submit_edit_plan buffer and attempt repair
483
+ for (const [idx, buf] of submitToolJsonByIndex) {
484
+ if (toolNameByIndex.get(idx) === "submit_edit_plan" && buf.length > 0) {
485
+ parsed = tryParseOrRepair(buf, args.log, args.model) ?? undefined;
486
+ if (parsed)
487
+ break;
488
+ }
489
+ }
490
+ if (parsed)
491
+ break;
492
+ // Buffer repair failed. If we accumulated meaningful text, use it as
493
+ // an info response rather than falling through to the hardcoded fallback.
494
+ if (path1TextBuf.trim().length > MIN_MEANINGFUL_RESPONSE_LENGTH) {
495
+ emitThinkingEnd();
496
+ return {
497
+ plan: {
498
+ intent: "needs_clarification",
499
+ summary_for_user: path1TextBuf.trim(),
500
+ change_log: [],
501
+ ops: []
502
+ },
503
+ usage,
504
+ schemaContext: schemaContext.meta
505
+ };
506
+ }
507
+ // If we couldn't parse, build a synthetic response to let the outer logic handle it
508
+ response = { content: [], stop_reason: "end_turn", usage: { input_tokens: 0, output_tokens: 0 } };
509
+ }
510
+ else {
511
+ const finalMessage = await stream.finalMessage();
512
+ response = finalMessage;
513
+ }
514
+ }
515
+ else {
516
+ response = await client.messages.create({
517
+ model: args.model,
518
+ max_tokens: plannerMaxTokens,
519
+ system: cachedSystem,
520
+ tools: toolDefs,
521
+ tool_choice: submitToolChoice,
522
+ messages: loopMessages,
523
+ ...thinkingRequestParams
524
+ }, { signal: effectiveSignal });
525
+ }
526
+ usage = sumTokenUsage(usage, extractUsage(response));
527
+ if (response.stop_reason === "max_tokens") {
528
+ args.log?.warn({
529
+ event: "anthropic_planner_truncated",
530
+ model: args.model,
531
+ stopReason: "max_tokens",
532
+ turn,
533
+ contentBlockTypes: response.content.map((b) => b.type)
534
+ }, "Anthropic planner: response truncated (max_tokens) during tool loop");
535
+ throw new Error("Model response was truncated (max_tokens reached)");
536
+ }
537
+ if (args.onToken && !emittedTextDeltas) {
538
+ for (const block of response.content) {
539
+ if (block.type === "text" && "text" in block && typeof block.text === "string") {
540
+ args.onToken(block.text);
541
+ }
542
+ }
543
+ }
544
+ const submitToolUse = response.content
545
+ .map((block) => asToolUseBlock(block))
546
+ .find((block) => block?.name === "submit_edit_plan");
547
+ if (submitToolUse && submitToolUse.input && typeof submitToolUse.input === "object") {
548
+ parsed = submitToolUse.input;
549
+ break;
550
+ }
551
+ const runtimeToolCalls = response.content
552
+ .map((block) => asToolUseBlock(block))
553
+ .filter((block) => Boolean(block && block.name !== "submit_edit_plan"));
554
+ if (runtimeToolCalls.length === 0) {
555
+ const textBlock = response.content.find((block) => block.type === "text");
556
+ const raw = textBlock && "text" in textBlock ? textBlock.text : "";
557
+ const jsonText = extractJsonObject(raw);
558
+ if (jsonText) {
559
+ try {
560
+ parsed = JSON.parse(jsonText);
561
+ }
562
+ catch {
563
+ try {
564
+ const meta = repairAndParseJsonWithMeta(jsonText);
565
+ parsed = meta.parsed;
566
+ args.log?.warn({ event: "anthropic_planner_json_repaired", model: args.model, strategy: meta.strategy, mutationCount: meta.mutationCount, discardedBytes: meta.discardedBytes }, "Anthropic planner: repaired malformed text-block JSON (non-streaming tool loop)");
567
+ }
568
+ catch { /* fall through */ }
569
+ }
570
+ if (parsed)
571
+ break;
572
+ }
573
+ // Model responded with text only (no tool call). If we have meaningful
574
+ // text content, treat it as an "info" response rather than discarding it.
575
+ const trimmed = raw.trim();
576
+ if (trimmed.length > MIN_MEANINGFUL_RESPONSE_LENGTH) {
577
+ emitThinkingEnd();
578
+ return {
579
+ plan: {
580
+ intent: "needs_clarification",
581
+ summary_for_user: trimmed,
582
+ change_log: [],
583
+ ops: []
584
+ },
585
+ usage,
586
+ schemaContext: schemaContext.meta
587
+ };
588
+ }
589
+ emitThinkingEnd();
590
+ return {
591
+ plan: {
592
+ intent: "needs_clarification",
593
+ summary_for_user: "I'm not sure what to do with that. You can try:",
594
+ change_log: [],
595
+ ops: [],
596
+ suggested_next_actions: [
597
+ "Create a new page",
598
+ "Add a section to the page",
599
+ "Rewrite the copy",
600
+ "Change the images"
601
+ ]
602
+ },
603
+ usage,
604
+ schemaContext: schemaContext.meta
605
+ };
606
+ }
607
+ loopMessages.push({ role: "assistant", content: response.content });
608
+ const toolResults = [];
609
+ for (const toolCall of runtimeToolCalls) {
610
+ const input = "input" in toolCall ? toolCall.input : {};
611
+ const runtimeToolName = runtimeToolNameByAlias.get(toolCall.name) ?? toolCall.name;
612
+ // Defer slow image tools — return a placeholder so text ops stream immediately
613
+ if (DEFERRABLE_IMAGE_TOOLS.has(runtimeToolName)) {
614
+ const placeholderUrl = runtimeToolName === "image.generate"
615
+ ? GENERATING_IMAGE_PLACEHOLDER
616
+ : SEARCHING_IMAGE_PLACEHOLDER;
617
+ const placeholderData = runtimeToolName === "image.generate"
618
+ ? { imageUrl: placeholderUrl, alt: String(input.prompt ?? "Generating image…"), width: 768, height: 512 }
619
+ : { items: [{ id: "placeholder", imageUrl: placeholderUrl, thumbUrl: placeholderUrl, alt: String(input.query ?? "Searching…"), author: "Placeholder", sourceUrl: "" }] };
620
+ deferredNativeImageCalls.push({
621
+ toolName: runtimeToolName,
622
+ input: input,
623
+ placeholderUrl
624
+ });
625
+ args.onToolExecution?.({
626
+ toolName: runtimeToolName,
627
+ ok: true,
628
+ latencyMs: 0,
629
+ attempts: 0,
630
+ traceId: args.toolCallContext?.traceId ?? "tool-call",
631
+ sessionId: args.toolCallContext?.sessionId ?? "dev",
632
+ siteId: args.toolCallContext?.siteId ?? "default",
633
+ plannerProvider: "anthropic",
634
+ deferred: true
635
+ });
636
+ toolResults.push({
637
+ type: "tool_result",
638
+ tool_use_id: toolCall.id,
639
+ content: JSON.stringify(placeholderData)
640
+ });
641
+ continue;
642
+ }
643
+ const result = await executeToolCall({
644
+ runtime: args.toolRuntime,
645
+ toolName: runtimeToolName,
646
+ input,
647
+ context: {
648
+ siteId: args.toolCallContext?.siteId ?? "default",
649
+ sessionId: args.toolCallContext?.sessionId ?? "dev",
650
+ userId: args.toolCallContext?.userId,
651
+ traceId: args.toolCallContext?.traceId ?? "tool-call",
652
+ plannerProvider: "anthropic",
653
+ gdriveFolderId: args.toolCallContext?.gdriveFolderId,
654
+ onStatusUpdate: args.onStatusUpdate,
655
+ onImageProgress: args.onImageProgress
656
+ },
657
+ policy: args.toolRuntime?.defaultPolicy
658
+ });
659
+ args.onToolExecution?.({
660
+ toolName: runtimeToolName,
661
+ ok: result.ok,
662
+ latencyMs: result.latencyMs,
663
+ attempts: result.attempts,
664
+ errorCode: result.error?.code,
665
+ traceId: args.toolCallContext?.traceId ?? "tool-call",
666
+ sessionId: args.toolCallContext?.sessionId ?? "dev",
667
+ siteId: args.toolCallContext?.siteId ?? "default",
668
+ plannerProvider: "anthropic"
669
+ });
670
+ toolResults.push({
671
+ type: "tool_result",
672
+ tool_use_id: toolCall.id,
673
+ content: JSON.stringify(result.ok ? result.data : { error: result.error }),
674
+ ...(result.ok ? {} : { is_error: true })
675
+ });
676
+ }
677
+ loopMessages.push({
678
+ role: "user",
679
+ content: toolResults
680
+ });
681
+ }
682
+ }
683
+ else if (args.onToken) {
684
+ let toolJsonBuf = "";
685
+ let textBuf = "";
686
+ if (client.messages.stream) {
687
+ // NOTE: forced tool_choice is incompatible with extended thinking on Anthropic;
688
+ // when thinking is enabled we relax to `auto` so the model can think freely
689
+ // before emitting the tool call.
690
+ const stream = client.messages.stream({
691
+ model: args.model,
692
+ max_tokens: plannerMaxTokens,
693
+ system: cachedSystem,
694
+ tools: [anthropicToolWithCache(submitPlanToolDef)],
695
+ tool_choice: thinkingParam
696
+ ? { type: "auto" }
697
+ : { type: "tool", name: "submit_edit_plan" },
698
+ messages: [
699
+ ...historyMessages,
700
+ { role: "user", content: userContent }
701
+ ],
702
+ ...thinkingRequestParams
703
+ }, {
704
+ headers: ANTHROPIC_FINE_GRAINED_STREAM_HEADERS,
705
+ signal: effectiveSignal,
706
+ });
707
+ // Wrap stream iteration — SDK may throw on message_stop if tool JSON is malformed
708
+ let streamLoopError;
709
+ const path2ThinkingBlockIndexes = new Set();
710
+ try {
711
+ for await (const event of stream) {
712
+ if (event.type === "content_block_start") {
713
+ const idx = typeof event.index === "number" ? event.index : -1;
714
+ if (idx >= 0 && event.content_block?.type === "thinking") {
715
+ path2ThinkingBlockIndexes.add(idx);
716
+ emitThinkingStart();
717
+ }
718
+ continue;
719
+ }
720
+ if (event.type === "content_block_stop") {
721
+ const idx = typeof event.index === "number" ? event.index : -1;
722
+ if (idx >= 0 && path2ThinkingBlockIndexes.has(idx)) {
723
+ path2ThinkingBlockIndexes.delete(idx);
724
+ if (path2ThinkingBlockIndexes.size === 0)
725
+ emitThinkingEnd();
726
+ }
727
+ continue;
728
+ }
729
+ if (event.type === "content_block_delta") {
730
+ if (event.delta?.type === "thinking_delta") {
731
+ const thinkingText = event.delta.thinking ?? "";
732
+ if (thinkingText.length > 0)
733
+ emitThinkingToken(thinkingText);
734
+ }
735
+ else if (event.delta?.type === "input_json_delta") {
736
+ toolJsonBuf += event.delta.partial_json ?? "";
737
+ emitProgressFromToolJson(toolJsonBuf);
738
+ }
739
+ else if (event.delta?.type === "text_delta") {
740
+ textBuf += event.delta.text ?? "";
741
+ args.onToken(event.delta.text ?? "");
742
+ }
743
+ }
744
+ }
745
+ }
746
+ catch (err) {
747
+ if (isApiLevelError(err))
748
+ throw err;
749
+ streamLoopError = err;
750
+ args.log?.warn({
751
+ event: "anthropic_path2_stream_loop_error",
752
+ model: args.model,
753
+ error: err instanceof Error ? err.message : String(err),
754
+ toolJsonBufLength: toolJsonBuf.length,
755
+ toolJsonBufPreview: toolJsonBuf.slice(0, 500),
756
+ }, "Anthropic Path 2: stream loop threw (SDK JSON parse) — will attempt toolJsonBuf repair");
757
+ }
758
+ // If stream completed normally, try finalMessage() for validated tool input.
759
+ // Skip if stream threw — finalMessage() would also fail.
760
+ if (!streamLoopError) {
761
+ let finalMessage;
762
+ try {
763
+ finalMessage = await stream.finalMessage();
764
+ usage = extractUsage(finalMessage);
765
+ const streamStopReason = finalMessage?.stop_reason;
766
+ if (streamStopReason === "max_tokens") {
767
+ args.log?.warn({
768
+ event: "anthropic_planner_truncated",
769
+ model: args.model,
770
+ stopReason: streamStopReason,
771
+ toolJsonBufLength: toolJsonBuf.length,
772
+ textBufLength: textBuf.length
773
+ }, "Anthropic planner: response truncated (max_tokens)");
774
+ throw new Error("Model response was truncated (max_tokens reached)");
775
+ }
776
+ const finalToolBlock = finalMessage.content
777
+ ?.map((block) => asToolUseBlock(block))
778
+ .find((block) => block?.name === "submit_edit_plan");
779
+ if (finalToolBlock?.input && typeof finalToolBlock.input === "object") {
780
+ parsed = finalToolBlock.input;
781
+ }
782
+ }
783
+ catch (finalMsgErr) {
784
+ if (finalMsgErr instanceof Error && finalMsgErr.message.includes("max_tokens"))
785
+ throw finalMsgErr;
786
+ args.log?.warn({
787
+ event: "anthropic_planner_final_message_failed",
788
+ model: args.model,
789
+ error: finalMsgErr instanceof Error ? finalMsgErr.message : String(finalMsgErr),
790
+ toolJsonBufLength: toolJsonBuf.length,
791
+ }, "Anthropic planner: finalMessage() threw — falling back to streamed buffer repair");
792
+ }
793
+ }
794
+ // Fallback: streamed buffer + repair (if finalMessage didn't yield tool input)
795
+ if (!parsed && toolJsonBuf.length > 0) {
796
+ parsed = tryParseOrRepair(toolJsonBuf, args.log, args.model) ?? undefined;
797
+ }
798
+ if (!parsed && textBuf.length > 0) {
799
+ const jsonText = extractJsonObject(textBuf);
800
+ if (jsonText) {
801
+ try {
802
+ parsed = JSON.parse(jsonText);
803
+ }
804
+ catch {
805
+ try {
806
+ const meta = repairAndParseJsonWithMeta(jsonText);
807
+ parsed = meta.parsed;
808
+ args.log?.warn({ event: "anthropic_planner_json_repaired", model: args.model, strategy: meta.strategy, mutationCount: meta.mutationCount, discardedBytes: meta.discardedBytes }, "Anthropic planner: repaired malformed text-block JSON (streaming)");
809
+ }
810
+ catch { /* fall through to non-parsed state */ }
811
+ }
812
+ }
813
+ }
814
+ }
815
+ else {
816
+ const response = await client.messages.create({
817
+ model: args.model,
818
+ max_tokens: plannerMaxTokens,
819
+ system: cachedSystem,
820
+ tools: [anthropicToolWithCache(submitPlanToolDef)],
821
+ tool_choice: thinkingParam
822
+ ? { type: "auto" }
823
+ : { type: "tool", name: "submit_edit_plan" },
824
+ messages: [
825
+ ...historyMessages,
826
+ { role: "user", content: userContent }
827
+ ],
828
+ ...thinkingRequestParams
829
+ }, { signal: effectiveSignal });
830
+ usage = extractUsage(response);
831
+ if (response.stop_reason === "max_tokens") {
832
+ args.log?.warn({
833
+ event: "anthropic_planner_truncated",
834
+ model: args.model,
835
+ stopReason: response.stop_reason,
836
+ contentBlockTypes: response.content.map((b) => b.type)
837
+ }, "Anthropic planner: response truncated (max_tokens)");
838
+ throw new Error("Model response was truncated (max_tokens reached)");
839
+ }
840
+ const toolBlock = response.content.find((b) => b.type === "tool_use");
841
+ if (toolBlock && "input" in toolBlock && toolBlock.input && typeof toolBlock.input === "object") {
842
+ parsed = toolBlock.input;
843
+ }
844
+ else {
845
+ const textBlock = response.content.find((b) => b.type === "text");
846
+ const raw = textBlock && "text" in textBlock ? textBlock.text : "";
847
+ const jsonText = extractJsonObject(raw);
848
+ if (jsonText) {
849
+ try {
850
+ parsed = JSON.parse(jsonText);
851
+ }
852
+ catch {
853
+ try {
854
+ const meta = repairAndParseJsonWithMeta(jsonText);
855
+ parsed = meta.parsed;
856
+ args.log?.warn({ event: "anthropic_planner_json_repaired", model: args.model, strategy: meta.strategy, mutationCount: meta.mutationCount, discardedBytes: meta.discardedBytes }, "Anthropic planner: repaired malformed text-block JSON (non-streaming)");
857
+ }
858
+ catch { /* fall through */ }
859
+ }
860
+ }
861
+ }
862
+ }
863
+ }
864
+ else {
865
+ // No runtime tools — try output_config.format for constrained decoding first,
866
+ // then fall back to tool_choice if the model doesn't return parseable JSON
867
+ // (some models like Haiku 4.5 may not reliably support output_config).
868
+ // NOTE: output_config is incompatible with extended thinking. When thinking
869
+ // is requested, skip straight to the tool_choice fallback below.
870
+ if (!thinkingParam)
871
+ try {
872
+ const response = await client.messages.create({
873
+ model: args.model,
874
+ max_tokens: plannerMaxTokens,
875
+ system: cachedSystem,
876
+ output_config: {
877
+ format: { type: "json_schema", schema: buildEditPlanJsonSchema({ planFirstMode }) }
878
+ },
879
+ messages: [
880
+ ...historyMessages,
881
+ { role: "user", content: userContent }
882
+ ],
883
+ }, { signal: effectiveSignal });
884
+ usage = extractUsage(response);
885
+ if (response.stop_reason === "max_tokens") {
886
+ args.log?.warn({
887
+ event: "anthropic_planner_truncated",
888
+ model: args.model,
889
+ stopReason: response.stop_reason,
890
+ contentBlockTypes: response.content.map((b) => b.type)
891
+ }, "Anthropic planner: response truncated (max_tokens)");
892
+ throw new Error("Model response was truncated (max_tokens reached)");
893
+ }
894
+ const textBlock = response.content.find((b) => b.type === "text");
895
+ const raw = textBlock && "text" in textBlock ? textBlock.text : "";
896
+ if (raw.trim()) {
897
+ try {
898
+ parsed = JSON.parse(raw);
899
+ }
900
+ catch {
901
+ try {
902
+ const meta = repairAndParseJsonWithMeta(raw);
903
+ parsed = meta.parsed;
904
+ args.log?.warn({ event: "anthropic_planner_json_repaired", model: args.model, strategy: meta.strategy, mutationCount: meta.mutationCount, discardedBytes: meta.discardedBytes }, "Anthropic planner: repaired malformed output_config JSON");
905
+ }
906
+ catch { /* fall through to tool_choice fallback below */ }
907
+ }
908
+ }
909
+ }
910
+ catch (outputConfigErr) {
911
+ // output_config may not be supported by this model — log and fall through
912
+ // to the tool_choice fallback below (don't re-throw truncation errors).
913
+ if (outputConfigErr instanceof Error && outputConfigErr.message.includes("truncated"))
914
+ throw outputConfigErr;
915
+ args.log?.warn({
916
+ event: "anthropic_planner_output_config_failed",
917
+ model: args.model,
918
+ error: outputConfigErr instanceof Error ? outputConfigErr.message : String(outputConfigErr)
919
+ }, "Anthropic planner: output_config call failed, falling back to tool_choice");
920
+ }
921
+ // Fallback: if output_config didn't produce JSON, retry with tool_choice
922
+ if (!parsed) {
923
+ args.log?.warn({
924
+ event: "anthropic_planner_output_config_fallback",
925
+ model: args.model
926
+ }, "Anthropic planner: output_config returned no JSON, retrying with tool_choice");
927
+ const fallbackResponse = await client.messages.create({
928
+ model: args.model,
929
+ max_tokens: plannerMaxTokens,
930
+ system: cachedSystem,
931
+ tools: [anthropicToolWithCache(submitPlanToolDef)],
932
+ tool_choice: thinkingParam
933
+ ? { type: "auto" }
934
+ : { type: "tool", name: "submit_edit_plan" },
935
+ messages: [
936
+ ...historyMessages,
937
+ { role: "user", content: userContent }
938
+ ],
939
+ ...thinkingRequestParams
940
+ }, { signal: effectiveSignal });
941
+ const fallbackUsage = extractUsage(fallbackResponse);
942
+ usage = {
943
+ inputTokens: usage.inputTokens + fallbackUsage.inputTokens,
944
+ outputTokens: usage.outputTokens + fallbackUsage.outputTokens,
945
+ totalTokens: usage.totalTokens + fallbackUsage.totalTokens,
946
+ cacheCreationInputTokens: (usage.cacheCreationInputTokens ?? 0) + (fallbackUsage.cacheCreationInputTokens ?? 0),
947
+ cacheReadInputTokens: (usage.cacheReadInputTokens ?? 0) + (fallbackUsage.cacheReadInputTokens ?? 0)
948
+ };
949
+ if (fallbackResponse.stop_reason === "max_tokens") {
950
+ throw new Error("Model response was truncated (max_tokens reached)");
951
+ }
952
+ const toolBlock = fallbackResponse.content.find((b) => b.type === "tool_use");
953
+ if (toolBlock && "input" in toolBlock && toolBlock.input && typeof toolBlock.input === "object") {
954
+ parsed = toolBlock.input;
955
+ }
956
+ else {
957
+ const textBlock = fallbackResponse.content.find((b) => b.type === "text");
958
+ const raw = textBlock && "text" in textBlock ? textBlock.text : "";
959
+ const jsonText = extractJsonObject(raw);
960
+ if (jsonText) {
961
+ try {
962
+ parsed = JSON.parse(jsonText);
963
+ }
964
+ catch {
965
+ try {
966
+ const meta = repairAndParseJsonWithMeta(jsonText);
967
+ parsed = meta.parsed;
968
+ }
969
+ catch { /* fall through */ }
970
+ }
971
+ }
972
+ }
973
+ }
974
+ }
975
+ // Safety: close any dangling thinking event before exit paths
976
+ emitThinkingEnd();
977
+ if (!parsed) {
978
+ args.log?.warn({
979
+ event: "anthropic_planner_no_json",
980
+ model: args.model,
981
+ hasHistory: (args.history?.length ?? 0) > 0,
982
+ hasFeedback: !!args.feedback
983
+ }, "Anthropic planner: model did not return parseable JSON");
984
+ throw new Error(`Model did not return JSON (model=${args.model})`);
985
+ }
986
+ const normalized = normalizePlanCandidate(parsed, {
987
+ defaultSlug: args.slug,
988
+ currentPage: args.currentPage,
989
+ userMessage: args.message
990
+ });
991
+ const planResult = editPlanSchema.safeParse(normalized);
992
+ if (!planResult.success) {
993
+ const first = planResult.error.issues[0];
994
+ const message = first?.message ?? "Invalid model output";
995
+ const path = first?.path?.length ? ` at ${first.path.join(".")}` : "";
996
+ const sample = JSON.stringify(normalized).slice(0, 700);
997
+ throw new Error(`${message}${path}. Parsed sample: ${sample}`);
998
+ }
999
+ const deferredImageMeta = deferredNativeImageCalls.length > 0 ? { deferredNativeImageCalls } : {};
1000
+ if (chatStrictPrimaryOpMode && planResult.data.intent === "edit_plan" && planResult.data.ops.length > 1) {
1001
+ return {
1002
+ plan: {
1003
+ ...planResult.data,
1004
+ ops: [planResult.data.ops[0]]
1005
+ },
1006
+ usage,
1007
+ schemaContext: schemaContext.meta,
1008
+ ...deferredImageMeta
1009
+ };
1010
+ }
1011
+ return { plan: planResult.data, usage, schemaContext: schemaContext.meta, ...deferredImageMeta };
1012
+ }