@avocadostudio-ai/orchestrator-core 0.3.2 → 0.3.3
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.
- package/dist/chat/anthropic-planner.d.ts +8 -0
- package/dist/chat/anthropic-planner.js +166 -12
- package/dist/chat/chat-pipeline-translation.d.ts +13 -0
- package/dist/chat/chat-pipeline-translation.js +109 -45
- package/dist/chat/chat-pipeline.d.ts +1 -1
- package/dist/chat/chat-pipeline.js +296 -53
- package/dist/chat/gemini-planner.d.ts +2 -0
- package/dist/chat/gemini-planner.js +2 -1
- package/dist/chat/planner-types.d.ts +15 -0
- package/dist/chat/planner-types.js +2 -2
- package/dist/chat/planner.d.ts +12 -0
- package/dist/chat/planner.js +16 -2
- package/dist/chat/translation-chunking.d.ts +124 -0
- package/dist/chat/translation-chunking.js +371 -0
- package/dist/checks/field-walk.d.ts +25 -0
- package/dist/checks/field-walk.js +152 -0
- package/dist/checks/index.d.ts +5 -0
- package/dist/checks/index.js +4 -0
- package/dist/checks/page-weight.d.ts +22 -0
- package/dist/checks/page-weight.js +200 -0
- package/dist/checks/rules-draft.d.ts +2 -0
- package/dist/checks/rules-draft.js +375 -0
- package/dist/checks/run-checks.d.ts +32 -0
- package/dist/checks/run-checks.js +152 -0
- package/dist/checks/session-runner.d.ts +19 -0
- package/dist/checks/session-runner.js +95 -0
- package/dist/checks/types.d.ts +65 -0
- package/dist/checks/types.js +1 -0
- package/dist/durable/durable-store-singleton.d.ts +37 -0
- package/dist/durable/durable-store-singleton.js +179 -0
- package/dist/durable/finding-impact.d.ts +30 -0
- package/dist/durable/finding-impact.js +53 -0
- package/dist/durable/in-memory-durable-store.d.ts +203 -0
- package/dist/durable/in-memory-durable-store.js +363 -0
- package/dist/durable/index.d.ts +5 -0
- package/dist/durable/index.js +4 -0
- package/dist/durable/pending-plan-store.d.ts +28 -0
- package/dist/durable/pending-plan-store.js +156 -0
- package/dist/durable/sqlite-durable-store.d.ts +71 -0
- package/dist/durable/sqlite-durable-store.js +631 -0
- package/dist/durable/types.d.ts +265 -0
- package/dist/durable/types.js +1 -0
- package/dist/handler/create-orchestrator.d.ts +4 -0
- package/dist/handler/create-orchestrator.js +67 -4
- package/dist/http/audio-actions.d.ts +1 -1
- package/dist/http/checks-actions.d.ts +39 -0
- package/dist/http/checks-actions.js +122 -0
- package/dist/http/history-actions.d.ts +1 -1
- package/dist/http/image-generate-actions.d.ts +2 -2
- package/dist/http/ops-actions.d.ts +2 -2
- package/dist/http/publish-actions.d.ts +4 -4
- package/dist/http/restore-actions.d.ts +3 -3
- package/dist/http/screenshot-actions.d.ts +2 -2
- package/dist/http/session-actions.d.ts +1 -1
- package/dist/http/telemetry-feedback-actions.d.ts +2 -2
- package/dist/http/unsplash-actions.d.ts +2 -2
- package/dist/http/variations-actions.d.ts +2 -2
- package/dist/index.d.ts +7 -0
- package/dist/index.js +27 -0
- package/dist/nlp/deterministic-planner-context.d.ts +16 -0
- package/dist/nlp/deterministic-planner-context.js +33 -7
- package/dist/nlp/plan-normalizer.js +54 -6
- package/dist/ops/destructive-action-gate.js +7 -2
- package/dist/ops/ops-engine.d.ts +12 -1
- package/dist/ops/ops-engine.js +41 -14
- package/dist/publish/publish-target-registry.js +1 -1
- package/dist/publish/publish-target.d.ts +1 -1
- package/dist/state/session-state.js +8 -1
- package/package.json +3 -3
|
@@ -3,13 +3,14 @@ import { blockManifestSchema } from "@avocadostudio-ai/shared";
|
|
|
3
3
|
import { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER, isGeneratingPlaceholder, cleanupImagePlaceholders, buildPageDirectory, isVariationRequestMessage, variationVerbIntent, resolveEffectiveSlug, throwIfCanceled, raceCancel, sleepMs, suppressCancelOnly } from "./chat-pipeline-shared.js";
|
|
4
4
|
import { siteCapabilitiesSchema, isBatchAddRequest, isDuplicateBlockRequest, isBlockCatalogQuery, isInfoQuery, isAdviceQuery, adviceResponse, isContentQuery, isPageListQuery, requestsPlanFirst, plannerMessageWithPendingContext, buildSiteContextBlock, infoResponse } from "../nlp/intent-detection.js";
|
|
5
5
|
import { isLikelyClarificationFollowUp } from "../nlp/intent-helpers.js";
|
|
6
|
-
import { versions, pendingClarificationBySession, chatHistoryBySession,
|
|
7
|
-
import {
|
|
6
|
+
import { versions, pendingClarificationBySession, chatHistoryBySession, continuationChainBySession, imageSourcePreferenceBySession, getSessionDraft, getPage, setPage, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, pushChatHistory, schedulePersistState, removePage } from "../state/session-state.js";
|
|
7
|
+
import { loadPendingPlan, savePendingPlan, clearPendingPlan } from "../durable/pending-plan-store.js";
|
|
8
|
+
import { toErrorDetail, isNoEffectiveChangeError, isAlreadyCurrentError, classifyGuardrailError, formatValidationError, isRepairEligibleCategory, buildDeterministicRepairFeedback, validateOperations, applyOpsAtomically, isStructuralOperation, pickFocusBlockId, pickUpdatedSlug } from "../ops/ops-engine.js";
|
|
8
9
|
import { evaluateDestructiveActions } from "../ops/destructive-action-gate.js";
|
|
9
10
|
import { clarificationSuggestions, postEditSuggestions, demoPlanFromMessage, plannerContextPack, compileDeterministicPlan, inferDeterministicIntent, isHighConfidenceDeterministicCase, tryCompoundDeterministicPlan, resolveImageUrlForAltField } from "../nlp/deterministic-planner.js";
|
|
10
11
|
import { generatePlanWithOpenAI, isPlannerOutputError, isStrictJsonResponseEnabled, parseIntentWithOpenAI } from "./planner.js";
|
|
11
12
|
import { isDemoModeEnabled, splitDemoOps, getDemoAllowedBlockTypes } from "../demo-mode.js";
|
|
12
|
-
import { isCancelError as _isCancelError, OperationError } from "../errors.js";
|
|
13
|
+
import { isCancelError as _isCancelError, OperationError, OrchestrationError } from "../errors.js";
|
|
13
14
|
import { acquireSessionLock } from "../state/session-lock.js";
|
|
14
15
|
const ERROR_CATEGORIES = [
|
|
15
16
|
"schema_violation",
|
|
@@ -38,7 +39,7 @@ import { executeToolCall } from "../tools/runtime.js";
|
|
|
38
39
|
// Re-exports from extracted modules (for backwards compat with external importers)
|
|
39
40
|
// ---------------------------------------------------------------------------
|
|
40
41
|
export { sentenceCase, firstUrlFromText, preferredImageAltText, collectMentionedSlugsFromPlan, collectMentionedSlugsFromOps, normalizePlanCopyForUi, futureToPastTense } from "./chat-pipeline-ui.js";
|
|
41
|
-
export { sanitizeMessageForPlanning, inferTranslationScopeFromMessage, isNonEmptyString, findFullPageTranslationCoverageGap, findExplicitCtaTargetCoverageGap, parseCloneAndTranslateRequest } from "./chat-pipeline-translation.js";
|
|
42
|
+
export { sanitizeMessageForPlanning, inferTranslationScopeFromMessage, isNonEmptyString, findFullPageTranslationCoverageGap, buildFullPageTranslationChecklist, findExplicitCtaTargetCoverageGap, parseCloneAndTranslateRequest } from "./chat-pipeline-translation.js";
|
|
42
43
|
export { shouldPreferFastModelForMessage, shouldUseLlmIntentRouter, compactPlannerContextPack, minimalPlannerContextPack, shouldUseMinimalPlannerContext, shouldPreferFocusedTranslation, shouldEnableReasoningForMessage } from "./chat-pipeline-context.js";
|
|
43
44
|
export { isRewriteLikeMessage, isPerformanceAwareMessage, isLikelyTextField, collectChangedTextFields, buildMetaChangeLogEntries, buildAiInsightChanges, buildOpChangeLogEntries, deterministicCreatePagePlan, deterministicDuplicatePagePlan, deterministicSelectedTextRewritePlan, shouldReturnDeterministicClarification, fmtSlug } from "./chat-pipeline-deterministic.js";
|
|
44
45
|
export { blockHasImageUrlProp, parsePath, getValueAtPath, setValueAtPath, deleteValueAtPath, extractIndexedQueries, extractReferencedItemIndices, blockSupportsImageAtPath, detectImagePaths, imageQueryFromItem, shouldPopulateAllChildImages, findImageTargets, rewriteAddBlockToChildImageUpdate, withUnsplashHeroImage, shouldResolveCreatePageHeroImage, resolveHeroImageForCreatePage, detectImageOps } from "./chat-pipeline-image.js";
|
|
@@ -46,6 +47,7 @@ export { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER, isGenerating
|
|
|
46
47
|
// Internal imports from extracted modules (used by this file)
|
|
47
48
|
import { collectMentionedSlugsFromPlan, normalizePlanCopyForUi, futureToPastTense, pastToFutureTense } from "./chat-pipeline-ui.js";
|
|
48
49
|
import { sanitizeMessageForPlanning, inferTranslationScopeFromMessage, findFullPageTranslationCoverageGap, findExplicitCtaTargetCoverageGap, parseCloneAndTranslateRequest } from "./chat-pipeline-translation.js";
|
|
50
|
+
import { planTranslationChunks, shouldChunkTranslation, generateChunkedTranslationPlan } from "./translation-chunking.js";
|
|
49
51
|
import { shouldPreferFastModelForMessage, shouldUseLlmIntentRouter, compactPlannerContextPack, minimalPlannerContextPack, shouldUseMinimalPlannerContext, shouldPreferFocusedTranslation, classifyMessageComplexity, isRouterPlanTooShallow, shouldEnableReasoningForMessage } from "./chat-pipeline-context.js";
|
|
50
52
|
import { buildAiInsightChanges, buildMetaChangeLogEntries, buildOpChangeLogEntries, deterministicCreatePagePlan, deterministicDuplicatePagePlan, shouldReturnDeterministicClarification, fmtSlug } from "./chat-pipeline-deterministic.js";
|
|
51
53
|
import { getValueAtPath, setValueAtPath, deleteValueAtPath, blockSupportsImageAtPath, detectImageOps, rewriteAddBlockToChildImageUpdate, withUnsplashHeroImage, resolveHeroImageForCreatePage } from "./chat-pipeline-image.js";
|
|
@@ -260,7 +262,7 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
260
262
|
}
|
|
261
263
|
const siteCapabilities = parsedCapabilities.data;
|
|
262
264
|
if (executionMode === "discard_pending_plan") {
|
|
263
|
-
const existing =
|
|
265
|
+
const existing = await loadPendingPlan(body.session);
|
|
264
266
|
if (!existing) {
|
|
265
267
|
const defaultProvider = ctx.availableProviders[0] ?? "openai";
|
|
266
268
|
const defaultModelKey = process.env.OPENAI_MODEL_KEY ?? "balanced";
|
|
@@ -280,7 +282,7 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
280
282
|
if (body.pendingPlanId && body.pendingPlanId !== existing.id) {
|
|
281
283
|
return { code: 409, payload: { error: "pending plan mismatch" } };
|
|
282
284
|
}
|
|
283
|
-
|
|
285
|
+
await clearPendingPlan(body.session, "discarded");
|
|
284
286
|
return {
|
|
285
287
|
code: 200,
|
|
286
288
|
payload: {
|
|
@@ -731,7 +733,7 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
731
733
|
totalDurationMs: 0
|
|
732
734
|
});
|
|
733
735
|
if (executionMode === "auto") {
|
|
734
|
-
const existingPendingPlan =
|
|
736
|
+
const existingPendingPlan = await loadPendingPlan(body.session);
|
|
735
737
|
const normalizedIncomingMessage = typeof body.message === "string" ? body.message.trim() : "";
|
|
736
738
|
const normalizedPendingMessage = typeof existingPendingPlan?.originalMessage === "string"
|
|
737
739
|
? existingPendingPlan.originalMessage.trim()
|
|
@@ -1041,7 +1043,7 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
1041
1043
|
strictJsonEnabled: isStrictJsonResponseEnabled()
|
|
1042
1044
|
};
|
|
1043
1045
|
const guardrailFailureResponse = (args) => {
|
|
1044
|
-
const category = classifyGuardrailError(args.reason);
|
|
1046
|
+
const category = args.category ?? classifyGuardrailError(args.reason);
|
|
1045
1047
|
ctx.chatTelemetry.push({
|
|
1046
1048
|
id: chatRequestId,
|
|
1047
1049
|
at: new Date().toISOString(),
|
|
@@ -1086,7 +1088,7 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
1086
1088
|
status: "validation_error",
|
|
1087
1089
|
summary: "I could not apply that change safely.",
|
|
1088
1090
|
changes: [],
|
|
1089
|
-
validationErrors: [formatValidationError(args.reason)],
|
|
1091
|
+
validationErrors: [formatValidationError(args.reason, category)],
|
|
1090
1092
|
previewVersion: versions.get(body.session) ?? 0,
|
|
1091
1093
|
plannerSource: args.source,
|
|
1092
1094
|
modelUsed,
|
|
@@ -1098,6 +1100,71 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
1098
1100
|
let usedNativeUnsplashTool = false;
|
|
1099
1101
|
let usedNativeImageTool = false;
|
|
1100
1102
|
let deferredNativeImageCalls = [];
|
|
1103
|
+
const streamApplyState = {
|
|
1104
|
+
appliedCount: 0,
|
|
1105
|
+
/**
|
|
1106
|
+
* Ops the stream actually took responsibility for — applied *or* skipped as
|
|
1107
|
+
* a benign no-op. `appliedCount` alone cannot answer "did the stream carry
|
|
1108
|
+
* the whole plan", because a legitimately unchanged op is not a gap.
|
|
1109
|
+
*/
|
|
1110
|
+
processedCount: 0,
|
|
1111
|
+
failedAtIndex: null,
|
|
1112
|
+
hasStructuralOps: false,
|
|
1113
|
+
/** Snapshots per slug — `null` means the page didn't exist before streaming (needs removal on rollback). */
|
|
1114
|
+
rollbackSnapshots: new Map()
|
|
1115
|
+
};
|
|
1116
|
+
/*
|
|
1117
|
+
* Undo everything the streamed per-op apply landed on the draft.
|
|
1118
|
+
*
|
|
1119
|
+
* Declared up here, above `respondFromPlan`, because the invariant it exists
|
|
1120
|
+
* to hold is one only `respondFromPlan` can see: **a plan held for approval
|
|
1121
|
+
* has applied nothing.**
|
|
1122
|
+
*
|
|
1123
|
+
* The stream decides what to defer one op at a time, from the op alone, and
|
|
1124
|
+
* three of the four things that hold a plan are properties of the whole plan
|
|
1125
|
+
* — `multi_page_plan` fires on touched-slug count with no destructive op
|
|
1126
|
+
* required, and the compound-image hold fires on a plan that is not
|
|
1127
|
+
* image-only. An additive six-op plan trips both defer lists' blind spot: it
|
|
1128
|
+
* streams clean onto the page, and *then* the gate holds it. The user is
|
|
1129
|
+
* shown an "Approve" card for content the draft already contains, and the
|
|
1130
|
+
* `undoSnapshot` threaded into the applied path never reaches the held path,
|
|
1131
|
+
* so they cannot even undo it. Re-running the same prompt then collides with
|
|
1132
|
+
* the leftovers — `Block id b_featuregrid_wellness already exists` — which is
|
|
1133
|
+
* the visible symptom and not the bug.
|
|
1134
|
+
*
|
|
1135
|
+
* Rolling back where the decision is made, rather than teaching the per-op
|
|
1136
|
+
* defer list to predict it, is what keeps the invariant true for the next
|
|
1137
|
+
* hold condition somebody adds.
|
|
1138
|
+
*/
|
|
1139
|
+
const rollbackStreamedApply = (reason) => {
|
|
1140
|
+
if (streamApplyState.rollbackSnapshots.size === 0)
|
|
1141
|
+
return [];
|
|
1142
|
+
const rolledBackSlugs = [];
|
|
1143
|
+
for (const [slug, snapshot] of streamApplyState.rollbackSnapshots) {
|
|
1144
|
+
if (snapshot) {
|
|
1145
|
+
setPage(body.session, { ...snapshot, slug });
|
|
1146
|
+
}
|
|
1147
|
+
else {
|
|
1148
|
+
removePage(body.session, slug);
|
|
1149
|
+
}
|
|
1150
|
+
rolledBackSlugs.push(slug);
|
|
1151
|
+
}
|
|
1152
|
+
ctx.log.warn({
|
|
1153
|
+
event: "streamed_op_rollback",
|
|
1154
|
+
chatRequestId,
|
|
1155
|
+
appliedCount: streamApplyState.appliedCount,
|
|
1156
|
+
processedCount: streamApplyState.processedCount,
|
|
1157
|
+
failedAtIndex: streamApplyState.failedAtIndex,
|
|
1158
|
+
rolledBackSlugs
|
|
1159
|
+
}, `Rolled back ${rolledBackSlugs.length} page(s) — ${reason}`);
|
|
1160
|
+
streamApplyState.appliedCount = 0;
|
|
1161
|
+
streamApplyState.processedCount = 0;
|
|
1162
|
+
streamApplyState.rollbackSnapshots.clear();
|
|
1163
|
+
// The client already rendered every streamed op. Bump so it refetches the
|
|
1164
|
+
// draft it is actually holding rather than the one it was shown.
|
|
1165
|
+
bumpVersion(body.session);
|
|
1166
|
+
return rolledBackSlugs;
|
|
1167
|
+
};
|
|
1101
1168
|
const respondFromPlan = async (plan, source, applyMode = "apply_now", optionsOverride, plannerTier) => {
|
|
1102
1169
|
if (plannerTier)
|
|
1103
1170
|
activePlannerTier = plannerTier;
|
|
@@ -1472,7 +1539,12 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
1472
1539
|
const forcedInfo = infoResponse({ body, current, plannerSource: source, modelUsed, modelKey });
|
|
1473
1540
|
return { done: true, response: forcedInfo };
|
|
1474
1541
|
}
|
|
1475
|
-
|
|
1542
|
+
// The gate gets one shot. On the repair pass it is skipped entirely: by then
|
|
1543
|
+
// the model has already been handed the explicit checklist plus the list of
|
|
1544
|
+
// fields it missed, and a still-imperfect plan is worth applying. Failing it
|
|
1545
|
+
// twice would return 400 and discard every translation that did land —
|
|
1546
|
+
// strictly worse for the user than a page that is 90% translated.
|
|
1547
|
+
if (translationScope === "page" && !optionsOverride?.skipTranslationCoverageGate) {
|
|
1476
1548
|
const translationCoverageGap = findFullPageTranslationCoverageGap({
|
|
1477
1549
|
plan: resolvedPlan,
|
|
1478
1550
|
message: plannerMessage,
|
|
@@ -1480,7 +1552,7 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
1480
1552
|
slug: effectiveSlug
|
|
1481
1553
|
});
|
|
1482
1554
|
if (translationCoverageGap)
|
|
1483
|
-
return { done: false, reason: translationCoverageGap };
|
|
1555
|
+
return { done: false, reason: translationCoverageGap, reasonCategory: undefined };
|
|
1484
1556
|
}
|
|
1485
1557
|
const explicitCtaCoverageGap = findExplicitCtaTargetCoverageGap({
|
|
1486
1558
|
plan: resolvedPlan,
|
|
@@ -1489,7 +1561,7 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
1489
1561
|
slug: effectiveSlug
|
|
1490
1562
|
});
|
|
1491
1563
|
if (explicitCtaCoverageGap)
|
|
1492
|
-
return { done: false, reason: explicitCtaCoverageGap };
|
|
1564
|
+
return { done: false, reason: explicitCtaCoverageGap, reasonCategory: undefined };
|
|
1493
1565
|
// Tier-1 destructive-action gate. Hold any edit_plan containing destructive
|
|
1494
1566
|
// ops (remove_page on a page with content, multi-page scope, bulk deletes)
|
|
1495
1567
|
// for explicit approval — undo protects recovery but not accidental intent.
|
|
@@ -1642,6 +1714,20 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
1642
1714
|
};
|
|
1643
1715
|
}
|
|
1644
1716
|
if (effectiveApplyMode === "plan_only") {
|
|
1717
|
+
/*
|
|
1718
|
+
* A held plan has applied nothing. See `rollbackStreamedApply` for why
|
|
1719
|
+
* this cannot be decided op-by-op while the plan is still streaming.
|
|
1720
|
+
*
|
|
1721
|
+
* This has to run before the dry-run below, not only before the response:
|
|
1722
|
+
* the preview is computed against the live draft, so on a stream that
|
|
1723
|
+
* already landed its `add_block`s the dry run would report every one of
|
|
1724
|
+
* them as a duplicate-id failure and render an approval card claiming the
|
|
1725
|
+
* plan cannot be applied.
|
|
1726
|
+
*/
|
|
1727
|
+
const rolledBack = rollbackStreamedApply("plan held for approval");
|
|
1728
|
+
if (rolledBack.length > 0) {
|
|
1729
|
+
options?.onRollbackStarted?.({ appliedCount: rolledBack.length, reason: "plan_held_for_approval" });
|
|
1730
|
+
}
|
|
1645
1731
|
// Dry-run the held plan up front to build the structured before→after
|
|
1646
1732
|
// preview the approval card renders AND to learn whether it changes anything.
|
|
1647
1733
|
// Best-effort: a preview failure must never block showing the plan.
|
|
@@ -1666,7 +1752,7 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
1666
1752
|
// with a "Will change X" summary the draft never honours (#23). Return an
|
|
1667
1753
|
// honest no-effective-change response instead.
|
|
1668
1754
|
if (planPreview && planPreview.appliedCount === 0 && planPreview.failedCount === 0 && planPreview.skippedCount > 0) {
|
|
1669
|
-
|
|
1755
|
+
await clearPendingPlan(body.session, "discarded");
|
|
1670
1756
|
const onlyUnchanged = planPreview.opResults.every((r) => r.status !== "skipped" || r.reason === "unchanged_value");
|
|
1671
1757
|
const noopSummary = onlyUnchanged
|
|
1672
1758
|
? "No changes needed — that content is already set."
|
|
@@ -1716,7 +1802,7 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
1716
1802
|
// so the copy matches the "Approve plan" UX.
|
|
1717
1803
|
const approvalSummary = pastToFutureTense(resolvedPlan.summary_for_user);
|
|
1718
1804
|
const approvalChangeLog = resolvedPlan.change_log.map(pastToFutureTense);
|
|
1719
|
-
|
|
1805
|
+
await savePendingPlan(body.session, {
|
|
1720
1806
|
id: pendingPlanId,
|
|
1721
1807
|
createdAt: new Date().toISOString(),
|
|
1722
1808
|
promptHash,
|
|
@@ -1795,7 +1881,7 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
1795
1881
|
}
|
|
1796
1882
|
if (resolvedPlan.ops.length === 0) {
|
|
1797
1883
|
pendingClarificationBySession.delete(body.session);
|
|
1798
|
-
|
|
1884
|
+
await clearPendingPlan(body.session, "discarded");
|
|
1799
1885
|
/*
|
|
1800
1886
|
* Zero ops is not the same fact as "already correct".
|
|
1801
1887
|
*
|
|
@@ -2436,7 +2522,20 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
2436
2522
|
pushUndo(body.session, effectiveSlug, undoSnapshot);
|
|
2437
2523
|
}
|
|
2438
2524
|
pendingClarificationBySession.delete(body.session);
|
|
2439
|
-
|
|
2525
|
+
/*
|
|
2526
|
+
* Only the plan we actually applied is `approved`.
|
|
2527
|
+
*
|
|
2528
|
+
* This path is shared: a plain `auto` turn that succeeds runs it too. A
|
|
2529
|
+
* held `plan_ready` proposal the user never looked at — they typed
|
|
2530
|
+
* something else instead — was superseded, not approved, and recording
|
|
2531
|
+
* otherwise poisons the single number this outcome exists to produce.
|
|
2532
|
+
*/
|
|
2533
|
+
if (optionsOverride?.approvedPlanId) {
|
|
2534
|
+
await clearPendingPlan(body.session, "approved", optionsOverride.approvedPlanId);
|
|
2535
|
+
}
|
|
2536
|
+
else {
|
|
2537
|
+
await clearPendingPlan(body.session, "discarded");
|
|
2538
|
+
}
|
|
2440
2539
|
const planUpdatedSlug = pickUpdatedSlug(body.session, effectiveSlug, resolvedPlan.ops);
|
|
2441
2540
|
const updatedSlug = planUpdatedSlug ?? (effectiveSlug !== requestedSlug ? effectiveSlug : undefined);
|
|
2442
2541
|
pushRecentEdit(body.session, { slug: updatedSlug ?? effectiveSlug, summary: futureToPastTense(resolvedPlan.summary_for_user), ops: resolvedPlan.ops });
|
|
@@ -2531,8 +2630,25 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
2531
2630
|
applyDurationMs = Date.now() - applyStartedAtMs;
|
|
2532
2631
|
}
|
|
2533
2632
|
const reason = toErrorDetail(error);
|
|
2633
|
+
/*
|
|
2634
|
+
* The category the throw already carried, in preference to one guessed
|
|
2635
|
+
* back out of its message.
|
|
2636
|
+
*
|
|
2637
|
+
* The ops engine labels `Block id … already exists` a `schema_violation`
|
|
2638
|
+
* and is right to. By the time it reached here it was a bare string, and
|
|
2639
|
+
* `classifyGuardrailError` matches none of its schema_violation keywords
|
|
2640
|
+
* against those words — so the failure arrived as `internal_error`, which
|
|
2641
|
+
* `isDeterministicRepairEligible` refuses. The one plan a repair pass
|
|
2642
|
+
* could have trivially fixed (rename the block, apply the other five ops)
|
|
2643
|
+
* was the one plan that never reached it, and the user got "I could not
|
|
2644
|
+
* apply that change safely" instead.
|
|
2645
|
+
*
|
|
2646
|
+
* Keyword matching stays as the fallback for reasons that are only ever
|
|
2647
|
+
* strings — planner output failures, coverage gates.
|
|
2648
|
+
*/
|
|
2649
|
+
const reasonCategory = error instanceof OrchestrationError ? error.category : undefined;
|
|
2534
2650
|
if (isNoEffectiveChangeError(reason)) {
|
|
2535
|
-
|
|
2651
|
+
await clearPendingPlan(body.session, "discarded");
|
|
2536
2652
|
ctx.chatTelemetry.push({
|
|
2537
2653
|
id: chatRequestId,
|
|
2538
2654
|
at: new Date().toISOString(),
|
|
@@ -2618,15 +2734,15 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
2618
2734
|
promptLength: plannerMessage.length,
|
|
2619
2735
|
outcome: "apply_failed",
|
|
2620
2736
|
reason: reason.slice(0, 300),
|
|
2621
|
-
reasonCategory: classifyGuardrailError(reason),
|
|
2737
|
+
reasonCategory: reasonCategory ?? classifyGuardrailError(reason),
|
|
2622
2738
|
plannerTier,
|
|
2623
2739
|
...timingFields()
|
|
2624
2740
|
});
|
|
2625
|
-
return { done: false, reason };
|
|
2741
|
+
return { done: false, reason, reasonCategory };
|
|
2626
2742
|
}
|
|
2627
2743
|
};
|
|
2628
2744
|
if (executionMode === "apply_pending_plan") {
|
|
2629
|
-
const pending =
|
|
2745
|
+
const pending = await loadPendingPlan(body.session);
|
|
2630
2746
|
if (!pending) {
|
|
2631
2747
|
const fallbackMessage = typeof body.message === "string" ? body.message.trim() : "";
|
|
2632
2748
|
if (fallbackMessage.length > 0) {
|
|
@@ -2724,7 +2840,7 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
2724
2840
|
});
|
|
2725
2841
|
imageResolutionDurationMs += Date.now() - imageResolutionStartMs;
|
|
2726
2842
|
}
|
|
2727
|
-
const approvedOutcome = await respondFromPlan(approvalPlan, pending.source, "apply_now", { preResolvedPlan: true });
|
|
2843
|
+
const approvedOutcome = await respondFromPlan(approvalPlan, pending.source, "apply_now", { preResolvedPlan: true, approvedPlanId: pending.id });
|
|
2728
2844
|
if (approvedOutcome.done)
|
|
2729
2845
|
return approvedOutcome.response;
|
|
2730
2846
|
return guardrailFailureResponse({ reason: approvedOutcome.reason, source: pending.source });
|
|
@@ -2732,7 +2848,7 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
2732
2848
|
catch (error) {
|
|
2733
2849
|
const reason = toErrorDetail(error);
|
|
2734
2850
|
ctx.log.error({ event: "apply_pending_plan_error", chatRequestId, error: reason }, "Pending plan execution failed");
|
|
2735
|
-
|
|
2851
|
+
await clearPendingPlan(body.session, "discarded");
|
|
2736
2852
|
ctx.chatTelemetry.push({
|
|
2737
2853
|
id: chatRequestId,
|
|
2738
2854
|
at: new Date().toISOString(),
|
|
@@ -3093,7 +3209,67 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
3093
3209
|
// `plannerSource === "demo"` is handled earlier; here it's always a real planner.
|
|
3094
3210
|
const planner = plannerRegistry.get(plannerSource);
|
|
3095
3211
|
const supportsNativeTools = planner.supportsNativeTools;
|
|
3096
|
-
|
|
3212
|
+
// A full-page translation is a fan-out, not one long request: its output length
|
|
3213
|
+
// scales with the page, and one request's output is strictly serial. Splitting
|
|
3214
|
+
// it into independent per-block-group requests turns wall clock from the sum of
|
|
3215
|
+
// the blocks into the slowest chunk, and keeps every chunk far from the token
|
|
3216
|
+
// ceiling that made a single request an all-or-nothing bet. Substituting it
|
|
3217
|
+
// here — rather than at each call site — keeps the planner's contract identical
|
|
3218
|
+
// for the pipeline: same args in, one merged plan out.
|
|
3219
|
+
const translationChunks = translationScope === "page" ? planTranslationChunks({ page: current }) : [];
|
|
3220
|
+
const chunkTranslation = translationScope === "page" && shouldChunkTranslation(current, translationChunks);
|
|
3221
|
+
if (chunkTranslation) {
|
|
3222
|
+
ctx.log.info({
|
|
3223
|
+
event: "translation_chunking_enabled",
|
|
3224
|
+
slug: effectiveSlug,
|
|
3225
|
+
chunkCount: translationChunks.length,
|
|
3226
|
+
blocksPerChunk: translationChunks.map((chunk) => chunk.blockIds.length),
|
|
3227
|
+
bytesPerChunk: translationChunks.map((chunk) => chunk.bytes)
|
|
3228
|
+
}, "Full-page translation will be planned in parallel chunks");
|
|
3229
|
+
}
|
|
3230
|
+
const generatePlanImpl = chunkTranslation
|
|
3231
|
+
? (plannerArgs) =>
|
|
3232
|
+
// A repair pass is already scoped to the fields the first pass missed —
|
|
3233
|
+
// it is small, targeted, and must see the whole page to fix cross-block
|
|
3234
|
+
// gaps, so it runs unchunked.
|
|
3235
|
+
plannerArgs.feedback
|
|
3236
|
+
? planner.generatePlan(plannerArgs)
|
|
3237
|
+
: generateChunkedTranslationPlan({
|
|
3238
|
+
plannerArgs,
|
|
3239
|
+
chunks: translationChunks,
|
|
3240
|
+
generate: planner.generatePlan,
|
|
3241
|
+
log: ctx.log,
|
|
3242
|
+
onChunksSettled: (outcomes) => {
|
|
3243
|
+
const failed = outcomes.filter((outcome) => !outcome.ok);
|
|
3244
|
+
plannerContextTelemetryFields.translationChunkCount = outcomes.length;
|
|
3245
|
+
plannerContextTelemetryFields.translationChunksFailed = failed.length;
|
|
3246
|
+
// The slowest chunk is the request's latency. Logging every chunk's
|
|
3247
|
+
// wall clock next to its weight is what makes the split tunable —
|
|
3248
|
+
// a balanced split whose slowest chunk is no faster means the
|
|
3249
|
+
// bottleneck is somewhere other than chunk size.
|
|
3250
|
+
ctx.log.info({
|
|
3251
|
+
event: "translation_chunks_settled",
|
|
3252
|
+
slug: effectiveSlug,
|
|
3253
|
+
slowestMs: Math.max(...outcomes.map((outcome) => outcome.durationMs)),
|
|
3254
|
+
chunks: outcomes.map((outcome) => ({
|
|
3255
|
+
index: outcome.index,
|
|
3256
|
+
ms: outcome.durationMs,
|
|
3257
|
+
bytes: outcome.bytes,
|
|
3258
|
+
outputTokens: outcome.outputTokens,
|
|
3259
|
+
ops: outcome.opCount,
|
|
3260
|
+
attempts: outcome.attempts
|
|
3261
|
+
}))
|
|
3262
|
+
}, "Translation chunks settled");
|
|
3263
|
+
if (failed.length > 0) {
|
|
3264
|
+
ctx.log.warn({
|
|
3265
|
+
event: "translation_chunks_failed",
|
|
3266
|
+
slug: effectiveSlug,
|
|
3267
|
+
failedChunks: failed.map((outcome) => ({ index: outcome.index, blockIds: outcome.blockIds, reason: outcome.reason?.slice(0, 200) }))
|
|
3268
|
+
}, "Some translation chunks failed — the coverage gate will repair the gap");
|
|
3269
|
+
}
|
|
3270
|
+
}
|
|
3271
|
+
})
|
|
3272
|
+
: planner.generatePlan;
|
|
3097
3273
|
const maxPlanningAttempts = 3;
|
|
3098
3274
|
let initialPlan = null;
|
|
3099
3275
|
let routerDetectedInfo = false;
|
|
@@ -3588,13 +3764,10 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
3588
3764
|
incrementalPlanStreamEnabled &&
|
|
3589
3765
|
!/^(0|false|no|off)$/i.test((process.env.CHAT_STREAMED_OP_APPLY ?? "1").trim()) &&
|
|
3590
3766
|
Boolean(options?.onOpApplied);
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
/** Snapshots per slug — `null` means the page didn't exist before streaming (needs removal on rollback). */
|
|
3596
|
-
rollbackSnapshots: new Map()
|
|
3597
|
-
};
|
|
3767
|
+
// Raised when an attempt dies on `max_tokens`. Retrying a truncation with the
|
|
3768
|
+
// same budget reproduces it exactly — three identical failures cost ~250s and
|
|
3769
|
+
// told us nothing new — so each such retry gets more room to finish.
|
|
3770
|
+
let outputTokenScale = 1;
|
|
3598
3771
|
// Skip the planning loop if the parallel race already produced an initialPlan
|
|
3599
3772
|
for (let attempt = initialPlan ? maxPlanningAttempts + 1 : 1; attempt <= maxPlanningAttempts; attempt += 1) {
|
|
3600
3773
|
throwIfCanceled(options?.signal);
|
|
@@ -3610,6 +3783,7 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
3610
3783
|
currentPage: current,
|
|
3611
3784
|
contextPack: plannerContext,
|
|
3612
3785
|
model: modelUsed,
|
|
3786
|
+
outputTokenScale,
|
|
3613
3787
|
locale: body.locale,
|
|
3614
3788
|
imageSourceChoiceOpen,
|
|
3615
3789
|
history: isLightweightEdit ? [] : plannerChatHistory,
|
|
@@ -3708,6 +3882,7 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
3708
3882
|
// would set failedAtIndex and force a full rollback + re-apply of the
|
|
3709
3883
|
// whole plan — discarding the blocks that genuinely changed.
|
|
3710
3884
|
const stepResult = await applyOpsAtomically(body.session, [op], { componentsManifest, allowNoEffectiveChange: true });
|
|
3885
|
+
streamApplyState.processedCount += 1;
|
|
3711
3886
|
if (stepResult.appliedCount === 0 || stepResult.skippedOps.length > 0) {
|
|
3712
3887
|
options?.onOpSkipped?.({
|
|
3713
3888
|
index,
|
|
@@ -3789,6 +3964,11 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
3789
3964
|
};
|
|
3790
3965
|
}
|
|
3791
3966
|
const reasonCategory = isPlannerOutputError(error) ? error.reasonCategory : classifyGuardrailError(reason);
|
|
3967
|
+
// Truncation is a budget failure, not a model failure — an identical retry
|
|
3968
|
+
// hits the same ceiling. Double the output budget for the next attempt
|
|
3969
|
+
// instead (the planner clamps it to the per-path ceiling).
|
|
3970
|
+
if (/truncated \(max_tokens/i.test(errorMsg))
|
|
3971
|
+
outputTokenScale *= 2;
|
|
3792
3972
|
ctx.log.warn({ event: "plan_attempt_failed", attempt, model: modelUsed, reason: reason.slice(0, 300) }, `Planning attempt ${attempt} failed`);
|
|
3793
3973
|
ctx.chatTelemetry.push({
|
|
3794
3974
|
id: chatRequestId,
|
|
@@ -3942,33 +4122,46 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
3942
4122
|
}, { outcome: "planning_missing", plannerTier: "full_llm" })
|
|
3943
4123
|
};
|
|
3944
4124
|
}
|
|
3945
|
-
// If streamed per-op apply already
|
|
3946
|
-
// ops are pending, skip the normal apply step in respondFromPlan.
|
|
4125
|
+
// If streamed per-op apply already handled every op successfully and no
|
|
4126
|
+
// structural ops are pending, skip the normal apply step in respondFromPlan.
|
|
4127
|
+
//
|
|
4128
|
+
// The count has to be checked. `appliedCount > 0` read "the stream applied
|
|
4129
|
+
// something" as "the stream applied everything", which is only true when the
|
|
4130
|
+
// planner streams every op it returns. A chunked full-page translation does
|
|
4131
|
+
// not: its chunks run as separate planner calls, and a chunk that produced no
|
|
4132
|
+
// streamed ops still contributes its ops to the merged plan. Marking that plan
|
|
4133
|
+
// pre-applied dropped those ops on the floor — the page kept whole sections in
|
|
4134
|
+
// the source language while the response listed every op as applied.
|
|
4135
|
+
const streamedPlanFullyStreamed = streamApplyState.processedCount >= initialPlan.ops.length;
|
|
3947
4136
|
const streamedApplyComplete = streamedPerOpApplyEnabled &&
|
|
3948
4137
|
streamApplyState.appliedCount > 0 &&
|
|
3949
4138
|
streamApplyState.failedAtIndex === null &&
|
|
3950
|
-
!streamApplyState.hasStructuralOps
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
ctx.log.warn({ event: "streamed_op_rollback", chatRequestId, appliedCount: streamApplyState.appliedCount, failedAtIndex: streamApplyState.failedAtIndex, rolledBackSlugs }, `Rolled back ${rolledBackSlugs.length} page(s) after streamed op failure at index ${streamApplyState.failedAtIndex}`);
|
|
4139
|
+
!streamApplyState.hasStructuralOps &&
|
|
4140
|
+
streamedPlanFullyStreamed;
|
|
4141
|
+
// Anything the stream already landed has to be undone before respondFromPlan
|
|
4142
|
+
// replays the plan, or a non-idempotent op (an `add_block` that streamed
|
|
4143
|
+
// before the stream fell short) would apply twice. This covers both an outright
|
|
4144
|
+
// streamed failure and a stream that only carried part of the plan.
|
|
4145
|
+
if (streamedPerOpApplyEnabled &&
|
|
4146
|
+
!streamedApplyComplete &&
|
|
4147
|
+
(streamApplyState.failedAtIndex !== null || streamApplyState.appliedCount > 0) &&
|
|
4148
|
+
streamApplyState.rollbackSnapshots.size > 0) {
|
|
4149
|
+
rollbackStreamedApply(streamApplyState.failedAtIndex !== null
|
|
4150
|
+
? `streamed op failure at index ${streamApplyState.failedAtIndex}`
|
|
4151
|
+
: `streamed apply covered ${streamApplyState.processedCount} of ${initialPlan.ops.length} planned ops`);
|
|
3964
4152
|
}
|
|
3965
4153
|
const initialOutcome = await respondFromPlan(initialPlan, plannerSource, applyMode, streamedApplyComplete
|
|
3966
4154
|
? { preApplied: true, undoSnapshot: streamApplyState.rollbackSnapshots.get(effectiveSlug) ?? undefined }
|
|
3967
4155
|
: undefined, "full_llm");
|
|
3968
4156
|
if (initialOutcome.done)
|
|
3969
4157
|
return initialOutcome.response;
|
|
3970
|
-
|
|
3971
|
-
|
|
4158
|
+
const initialReasonCategory = initialOutcome.reasonCategory ?? classifyGuardrailError(initialOutcome.reason);
|
|
4159
|
+
if (!isRepairEligibleCategory(initialReasonCategory)) {
|
|
4160
|
+
return guardrailFailureResponse({
|
|
4161
|
+
reason: initialOutcome.reason,
|
|
4162
|
+
source: plannerSource,
|
|
4163
|
+
category: initialReasonCategory
|
|
4164
|
+
});
|
|
3972
4165
|
}
|
|
3973
4166
|
let repairedPlan;
|
|
3974
4167
|
try {
|
|
@@ -3989,13 +4182,13 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
3989
4182
|
promptLength: plannerMessage.length,
|
|
3990
4183
|
outcome: "repair_started",
|
|
3991
4184
|
reason: initialOutcome.reason.slice(0, 300),
|
|
3992
|
-
reasonCategory:
|
|
4185
|
+
reasonCategory: initialReasonCategory,
|
|
3993
4186
|
...timingFields()
|
|
3994
4187
|
});
|
|
3995
4188
|
planningAttempts += 1;
|
|
3996
4189
|
plannerContextTelemetryFields.schemaRetryUsed = true;
|
|
3997
4190
|
const repairFeedback = /full-page translation coverage/i.test(initialOutcome.reason)
|
|
3998
|
-
? `${initialOutcome.reason}. Repair for translation completeness:
|
|
4191
|
+
? `${initialOutcome.reason}. Repair for translation completeness: emit an update_props op for every blockId named above — some of them are blocks your previous plan did not touch at all, so adding fields to existing ops is not enough. Include the missing translated text fields for list children too. Preserve links/hrefs unchanged.`
|
|
3999
4192
|
: /explicit cta target coverage/i.test(initialOutcome.reason)
|
|
4000
4193
|
? `${initialOutcome.reason}. Repair for multi-target CTA completeness: update both hero and footer CTA text targets requested by the user, keep all CTA links/hrefs unchanged, and preserve user constraints such as punctuation bans.`
|
|
4001
4194
|
: buildDeterministicRepairFeedback(initialOutcome.reason);
|
|
@@ -4025,7 +4218,57 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
4025
4218
|
: undefined,
|
|
4026
4219
|
signal: options?.signal
|
|
4027
4220
|
}), options?.signal);
|
|
4028
|
-
|
|
4221
|
+
// The repair supplements the first plan; it does not stand in for it.
|
|
4222
|
+
//
|
|
4223
|
+
// The feedback handed to a repair names only what the first pass missed, so
|
|
4224
|
+
// the plan that comes back answers a narrower question than the one it used
|
|
4225
|
+
// to replace — and replacing meant discarding every op the first pass got
|
|
4226
|
+
// right. A chunked page-wide translation produced eight correct ops, tripped
|
|
4227
|
+
// the coverage gate on blocks that were already in the target language (the
|
|
4228
|
+
// gate walks every block and has no notion of "already translated"), and
|
|
4229
|
+
// then applied the four-op repair on its own. Three of those four were
|
|
4230
|
+
// no-ops. A page with six untranslated sections gained one and reported
|
|
4231
|
+
// success.
|
|
4232
|
+
//
|
|
4233
|
+
// Where both plans address the same target the repair wins — it is the pass
|
|
4234
|
+
// that saw the failure — and it keeps its position in the first plan's
|
|
4235
|
+
// ordering so structural ops stay where they were planned.
|
|
4236
|
+
const opTarget = (op) => "blockId" in op && typeof op.blockId === "string" ? `${op.op}:${op.blockId}` : op.op;
|
|
4237
|
+
const repairByTarget = new Map(repairResult.plan.ops.map((op) => [opTarget(op), op]));
|
|
4238
|
+
const takenFromRepair = new Set();
|
|
4239
|
+
const mergedRepairOps = [];
|
|
4240
|
+
for (const op of initialPlan.ops) {
|
|
4241
|
+
const target = opTarget(op);
|
|
4242
|
+
if (!repairByTarget.has(target)) {
|
|
4243
|
+
mergedRepairOps.push(op);
|
|
4244
|
+
continue;
|
|
4245
|
+
}
|
|
4246
|
+
// Superseded: the repair rewrote this target, so the original is dropped
|
|
4247
|
+
// rather than re-applied on top of the correction.
|
|
4248
|
+
if (takenFromRepair.has(target))
|
|
4249
|
+
continue;
|
|
4250
|
+
takenFromRepair.add(target);
|
|
4251
|
+
mergedRepairOps.push(repairByTarget.get(target));
|
|
4252
|
+
}
|
|
4253
|
+
for (const op of repairResult.plan.ops) {
|
|
4254
|
+
const target = opTarget(op);
|
|
4255
|
+
if (takenFromRepair.has(target))
|
|
4256
|
+
continue;
|
|
4257
|
+
takenFromRepair.add(target);
|
|
4258
|
+
mergedRepairOps.push(op);
|
|
4259
|
+
}
|
|
4260
|
+
if (mergedRepairOps.length > repairResult.plan.ops.length) {
|
|
4261
|
+
ctx.log.info({
|
|
4262
|
+
event: "repair_plan_merged",
|
|
4263
|
+
chatRequestId,
|
|
4264
|
+
repairOpCount: repairResult.plan.ops.length,
|
|
4265
|
+
initialOpCount: initialPlan.ops.length,
|
|
4266
|
+
mergedOpCount: mergedRepairOps.length
|
|
4267
|
+
}, "Repair plan merged with the ops the first plan already had right");
|
|
4268
|
+
}
|
|
4269
|
+
// change_log is left as the repair wrote it; the incomplete-changelog
|
|
4270
|
+
// synthesis downstream fills in entries for the carried-over ops.
|
|
4271
|
+
repairedPlan = { ...repairResult.plan, ops: mergedRepairOps };
|
|
4029
4272
|
planUsage = repairResult.usage;
|
|
4030
4273
|
if (repairResult.schemaContext) {
|
|
4031
4274
|
plannerContextTelemetryFields.contractMode = repairResult.schemaContext.contractMode;
|
|
@@ -4092,7 +4335,7 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
4092
4335
|
}, { outcome: "repair_failed", reasonCategory: classifyGuardrailError(reason), reason: reason.slice(0, 300), plannerTier: "full_llm" })
|
|
4093
4336
|
};
|
|
4094
4337
|
}
|
|
4095
|
-
const repairedOutcome = await respondFromPlan(repairedPlan, plannerSource, applyMode);
|
|
4338
|
+
const repairedOutcome = await respondFromPlan(repairedPlan, plannerSource, applyMode, { skipTranslationCoverageGate: true });
|
|
4096
4339
|
if (repairedOutcome.done)
|
|
4097
4340
|
return repairedOutcome.response;
|
|
4098
4341
|
const repairedReason = repairedOutcome.reason;
|
|
@@ -4137,7 +4380,7 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
4137
4380
|
status: "validation_error",
|
|
4138
4381
|
summary: "I could not apply that change safely.",
|
|
4139
4382
|
changes: [],
|
|
4140
|
-
validationErrors: [formatValidationError(repairedReason)],
|
|
4383
|
+
validationErrors: [formatValidationError(repairedReason, repairedOutcome.reasonCategory)],
|
|
4141
4384
|
previewVersion: versions.get(body.session) ?? 0,
|
|
4142
4385
|
plannerSource,
|
|
4143
4386
|
modelUsed,
|
|
@@ -57,6 +57,8 @@ export declare function generatePlanWithGemini(args: {
|
|
|
57
57
|
warn: (obj: Record<string, unknown>, msg: string) => void;
|
|
58
58
|
};
|
|
59
59
|
forceFullSchemaContracts?: boolean;
|
|
60
|
+
/** Restrict block-schema contracts to these types. See CommonGeneratePlanArgs. */
|
|
61
|
+
contractBlockTypeAllowlist?: string[];
|
|
60
62
|
componentsManifest?: BlockManifest;
|
|
61
63
|
lightweight?: boolean;
|
|
62
64
|
signal?: AbortSignal;
|
|
@@ -193,7 +193,8 @@ export async function generatePlanWithGemini(args) {
|
|
|
193
193
|
pageWideTranslation,
|
|
194
194
|
legacyIncludeContracts: includeContracts,
|
|
195
195
|
forceFullContracts: args.forceFullSchemaContracts,
|
|
196
|
-
componentsManifest: args.componentsManifest
|
|
196
|
+
componentsManifest: args.componentsManifest,
|
|
197
|
+
contractBlockTypeAllowlist: args.contractBlockTypeAllowlist
|
|
197
198
|
});
|
|
198
199
|
const userPayload = {
|
|
199
200
|
request: args.message,
|
|
@@ -87,6 +87,21 @@ export type CommonGeneratePlanArgs = {
|
|
|
87
87
|
thinking?: {
|
|
88
88
|
effort: PlannerEffort;
|
|
89
89
|
};
|
|
90
|
+
/**
|
|
91
|
+
* Multiplier on the planner's output-token budget (Anthropic). The pipeline
|
|
92
|
+
* raises it on a retry that followed a `max_tokens` truncation, so the next
|
|
93
|
+
* attempt has room the previous one lacked. Ignored by other providers.
|
|
94
|
+
*/
|
|
95
|
+
outputTokenScale?: number;
|
|
96
|
+
/**
|
|
97
|
+
* Restrict the block-schema contracts sent to the model to these types.
|
|
98
|
+
*
|
|
99
|
+
* Set by the translation chunker: a chunk holding a Hero and a CTA cannot
|
|
100
|
+
* legitimately emit an op for a Gallery, so shipping every block's contract to
|
|
101
|
+
* every chunk repeats the largest part of the request for nothing. Leave unset
|
|
102
|
+
* anywhere the model may reference a type that isn't already on the page.
|
|
103
|
+
*/
|
|
104
|
+
contractBlockTypeAllowlist?: string[];
|
|
90
105
|
};
|
|
91
106
|
/**
|
|
92
107
|
* Effort levels accepted by the Messages API `output_config.effort` field on the
|