@avocadostudio-ai/orchestrator-core 0.3.2 → 0.4.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.
- 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 +312 -54
- 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/prompts.d.ts +5 -0
- package/dist/chat/prompts.js +92 -9
- package/dist/chat/translation-chunking.d.ts +124 -0
- package/dist/chat/translation-chunking.js +371 -0
- package/dist/checks/field-walk.d.ts +42 -0
- package/dist/checks/field-walk.js +198 -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 +439 -0
- package/dist/checks/run-checks.d.ts +42 -0
- package/dist/checks/run-checks.js +159 -0
- package/dist/checks/session-runner.d.ts +19 -0
- package/dist/checks/session-runner.js +99 -0
- package/dist/checks/types.d.ts +109 -0
- package/dist/checks/types.js +1 -0
- package/dist/cms/adapter.d.ts +74 -1
- package/dist/cms/adapter.js +1 -0
- package/dist/cms/index.d.ts +1 -1
- package/dist/cms/index.js +1 -1
- package/dist/cms/media-sources.d.ts +29 -1
- package/dist/cms/media-sources.js +188 -7
- 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 +283 -32
- 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 +44 -1
- package/dist/http/history-actions.js +122 -0
- 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 +15 -4
- package/dist/http/publish-actions.js +3 -3
- 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 +9 -2
- package/dist/index.js +28 -1
- package/dist/nlp/deterministic-planner-context.d.ts +16 -0
- package/dist/nlp/deterministic-planner-context.js +33 -7
- package/dist/nlp/intent-detection.d.ts +16 -0
- package/dist/nlp/intent-detection.js +15 -1
- package/dist/nlp/plan-normalizer.js +66 -32
- 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-helpers.d.ts +12 -2
- package/dist/publish/publish-helpers.js +10 -3
- package/dist/publish/publish-selection.d.ts +84 -0
- package/dist/publish/publish-selection.js +113 -0
- package/dist/publish/publish-target-registry.js +1 -1
- package/dist/publish/publish-target.d.ts +1 -1
- package/dist/publish/targets/git.js +2 -2
- package/dist/state/session-state.js +8 -1
- package/dist/state/site-assets.d.ts +41 -0
- package/dist/state/site-assets.js +40 -0
- package/package.json +3 -3
|
@@ -77,6 +77,8 @@ export declare function generatePlanWithAnthropic(args: {
|
|
|
77
77
|
warn: (obj: Record<string, unknown>, msg: string) => void;
|
|
78
78
|
};
|
|
79
79
|
forceFullSchemaContracts?: boolean;
|
|
80
|
+
/** Restrict block-schema contracts to these types. See CommonGeneratePlanArgs. */
|
|
81
|
+
contractBlockTypeAllowlist?: string[];
|
|
80
82
|
componentsManifest?: BlockManifest;
|
|
81
83
|
lightweight?: boolean;
|
|
82
84
|
signal?: AbortSignal;
|
|
@@ -90,6 +92,12 @@ export declare function generatePlanWithAnthropic(args: {
|
|
|
90
92
|
thinking?: {
|
|
91
93
|
effort: PlannerEffort;
|
|
92
94
|
};
|
|
95
|
+
/**
|
|
96
|
+
* Multiplier on the computed output-token budget. The pipeline raises it on a
|
|
97
|
+
* retry that follows a `max_tokens` truncation so the next attempt has room the
|
|
98
|
+
* previous one lacked. Clamped to the per-path ceilings above.
|
|
99
|
+
*/
|
|
100
|
+
outputTokenScale?: number;
|
|
93
101
|
}): Promise<{
|
|
94
102
|
plan: EditPlan;
|
|
95
103
|
usage: TokenUsage;
|
|
@@ -11,6 +11,7 @@ import { extractUsage, ZERO_USAGE } from "../telemetry/usage.js";
|
|
|
11
11
|
import { anthropicSystemPromptWithCache, anthropicSegmentedSystemPromptWithCache, anthropicToolWithCache, ANTHROPIC_FINE_GRAINED_STREAM_HEADERS } from "./anthropic-cache.js";
|
|
12
12
|
import { executeToolCall } from "../tools/runtime.js";
|
|
13
13
|
import { DEFERRABLE_IMAGE_TOOLS } from "./chat-pipeline-shared.js";
|
|
14
|
+
import { buildFullPageTranslationChecklist } from "./chat-pipeline-translation.js";
|
|
14
15
|
// Minimum text length to treat a text-only model response as meaningful
|
|
15
16
|
// content (rather than discarding it in favor of the hardcoded fallback).
|
|
16
17
|
const MIN_MEANINGFUL_RESPONSE_LENGTH = 20;
|
|
@@ -30,6 +31,14 @@ function isApiLevelError(err) {
|
|
|
30
31
|
msg.includes("billing") ||
|
|
31
32
|
/\b40[0-13]\b/.test(err.message));
|
|
32
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Output-token ceilings for the planner. Current Claude models (Sonnet 5, Opus 5)
|
|
36
|
+
* accept up to 128K output tokens, but the SDK only supports budgets that large
|
|
37
|
+
* on a stream — a non-streaming `messages.create` with a 64K budget can sit past
|
|
38
|
+
* the HTTP timeout. The two paths therefore get separate ceilings.
|
|
39
|
+
*/
|
|
40
|
+
const PLANNER_MAX_TOKENS_NON_STREAMING = 32000;
|
|
41
|
+
const PLANNER_MAX_TOKENS_STREAMING = 64000;
|
|
33
42
|
/**
|
|
34
43
|
* Try JSON.parse, then repairAndParseJson. Returns parsed object or null.
|
|
35
44
|
*/
|
|
@@ -53,6 +62,49 @@ function tryParseOrRepair(buf, log, model) {
|
|
|
53
62
|
}
|
|
54
63
|
}
|
|
55
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Recover a usable plan from a turn that stopped on `max_tokens`.
|
|
67
|
+
*
|
|
68
|
+
* A truncated turn still buffered every token the model emitted before it hit
|
|
69
|
+
* the ceiling, and the repair strategies close the open brackets — so a
|
|
70
|
+
* page-wide translation that ran out of room comes back as most blocks
|
|
71
|
+
* translated rather than none. The coverage check then backfills or repairs the
|
|
72
|
+
* remainder, which costs a fraction of re-running the whole generation.
|
|
73
|
+
*
|
|
74
|
+
* Returns null when nothing usable survived; the caller should then throw so the
|
|
75
|
+
* pipeline retries with a larger budget.
|
|
76
|
+
*/
|
|
77
|
+
function salvageTruncatedPlan(args) {
|
|
78
|
+
const parsed = args.parsed ?? (args.buffer.length > 0 ? tryParseOrRepair(args.buffer, args.log, args.model) : null);
|
|
79
|
+
if (!parsed)
|
|
80
|
+
return null;
|
|
81
|
+
// Closing open brackets can leave the final op holding a half-written string —
|
|
82
|
+
// "Под" where the model was writing "Подзаголовок" — and that would be written
|
|
83
|
+
// into the page as if it were the translation. The buffer parses cleanly only
|
|
84
|
+
// when nothing was cut mid-value, so otherwise discard the trailing op.
|
|
85
|
+
let bufferComplete = false;
|
|
86
|
+
try {
|
|
87
|
+
JSON.parse(args.buffer);
|
|
88
|
+
bufferComplete = true;
|
|
89
|
+
}
|
|
90
|
+
catch { /* truncated mid-value — trailing op is suspect */ }
|
|
91
|
+
const ops = parsed.ops;
|
|
92
|
+
if (!bufferComplete && Array.isArray(ops))
|
|
93
|
+
ops.pop();
|
|
94
|
+
const salvagedOpCount = Array.isArray(parsed.ops)
|
|
95
|
+
? (parsed.ops).length
|
|
96
|
+
: 0;
|
|
97
|
+
if (salvagedOpCount === 0)
|
|
98
|
+
return null;
|
|
99
|
+
args.log?.warn({
|
|
100
|
+
event: "anthropic_planner_truncation_salvaged",
|
|
101
|
+
model: args.model,
|
|
102
|
+
toolJsonBufLength: args.buffer.length,
|
|
103
|
+
droppedTrailingOp: !bufferComplete,
|
|
104
|
+
opCount: salvagedOpCount
|
|
105
|
+
}, "Anthropic planner: salvaged a partial plan from the truncated stream");
|
|
106
|
+
return parsed;
|
|
107
|
+
}
|
|
56
108
|
// ---------------------------------------------------------------------------
|
|
57
109
|
// Singleton Anthropic client — reuses HTTP/2 connection pool across requests.
|
|
58
110
|
// Lazily initialized on first use so module-level import doesn't throw if
|
|
@@ -228,14 +280,50 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
228
280
|
pageWideTranslation,
|
|
229
281
|
legacyIncludeContracts: includeContracts,
|
|
230
282
|
forceFullContracts: args.forceFullSchemaContracts,
|
|
231
|
-
componentsManifest: args.componentsManifest
|
|
283
|
+
componentsManifest: args.componentsManifest,
|
|
284
|
+
contractBlockTypeAllowlist: args.contractBlockTypeAllowlist
|
|
232
285
|
});
|
|
286
|
+
// A full-page translation is a coverage problem, not a writing problem: the
|
|
287
|
+
// model reliably translates what it looks at, and the failures are sections it
|
|
288
|
+
// never looked at. "Translate all blocks" leaves it to infer the scope from the
|
|
289
|
+
// outline; an enumerated checklist with a count makes a forgotten section
|
|
290
|
+
// visible to the model itself, which is cheaper than catching it afterwards.
|
|
291
|
+
// Per-request, so it rides in the user turn and leaves the cached system
|
|
292
|
+
// prefix untouched.
|
|
293
|
+
const translationChecklist = pageWideTranslation
|
|
294
|
+
? buildFullPageTranslationChecklist(args.currentPage)
|
|
295
|
+
: null;
|
|
233
296
|
const user = {
|
|
234
297
|
request: args.message,
|
|
235
298
|
audienceHint: audienceHint ?? null,
|
|
236
299
|
slug: args.slug,
|
|
237
300
|
contextPack: args.contextPack,
|
|
238
301
|
...schemaContext.payload,
|
|
302
|
+
...(translationChecklist && translationChecklist.length > 0
|
|
303
|
+
? {
|
|
304
|
+
translationChecklist: {
|
|
305
|
+
instruction: `This page has ${translationChecklist.length} blocks carrying text that must be translated. ` +
|
|
306
|
+
`Emit an update_props op for EVERY blockId listed below and translate EVERY field path listed under it. ` +
|
|
307
|
+
`Before you finish, count your ops: fewer than ${translationChecklist.length} means you skipped a section — go back and add it. ` +
|
|
308
|
+
// The engine deep-merges list rows by index, so a row may carry only
|
|
309
|
+
// the keys being translated — but it merges positionally, so a short
|
|
310
|
+
// array truncates the list. Echoing whole rows is the expensive
|
|
311
|
+
// failure (it doubled one CardGrid chunk's output); dropping rows is
|
|
312
|
+
// the destructive one.
|
|
313
|
+
`For list fields (paths like items[0].q), send EVERY row of the array in its original order, ` +
|
|
314
|
+
`but inside each row include ONLY the fields you translated — omitted keys keep their current values, ` +
|
|
315
|
+
`while omitted rows would delete list entries. ` +
|
|
316
|
+
// "Leave URL-like fields alone" read as "leave ctaText alone" too, and
|
|
317
|
+
// four card buttons stayed in English while their titles translated.
|
|
318
|
+
// Destination vs. label is the distinction that matters, so name both
|
|
319
|
+
// sides rather than describing one of them.
|
|
320
|
+
`Leave DESTINATIONS out of the patch entirely — ctaHref, href, url, imageUrl, and any id. ` +
|
|
321
|
+
`But LABELS are visible text and must be translated like any other string, including ctaText and secondaryCtaText: ` +
|
|
322
|
+
`ctaText is the words on the button, not the link it points at.`,
|
|
323
|
+
blocks: translationChecklist
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
: {}),
|
|
239
327
|
feedback: args.feedback ?? null
|
|
240
328
|
};
|
|
241
329
|
const imageUrlForVision = typeof args.contextPack.selected?.imageUrlForVision === "string"
|
|
@@ -320,7 +408,31 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
320
408
|
const effortParam = thinkingParam ? args.thinking.effort : undefined;
|
|
321
409
|
// Adaptive thinking counts thinking tokens against max_tokens; give headroom so
|
|
322
410
|
// the actual response isn't truncated. Effort (not max_tokens) governs spend.
|
|
323
|
-
const
|
|
411
|
+
const baseMaxTokens = thinkingParam ? 16000 : 8192;
|
|
412
|
+
// Whole-page translations and rewrites re-emit every translatable string on the
|
|
413
|
+
// page inside an op envelope, so their output length scales with the page — a
|
|
414
|
+
// flat budget truncates them.
|
|
415
|
+
//
|
|
416
|
+
// Size these from the page, and size them generously: `max_tokens` is a ceiling,
|
|
417
|
+
// not a reservation, so over-asking costs nothing while under-asking costs a
|
|
418
|
+
// whole extra generation. "translate this page to russian" on an 8.3 KB page
|
|
419
|
+
// needed 9139 output tokens and was budgeted 8772 — a 4% miss that cost 78
|
|
420
|
+
// seconds of retry. The estimate has to hold for the worst case, not the median:
|
|
421
|
+
// - ~3x the page's own JSON, because the op envelope repeats
|
|
422
|
+
// op/pageSlug/blockId/patch per block on top of the text itself, and target
|
|
423
|
+
// languages run longer than the English source.
|
|
424
|
+
// - ~2.5 bytes/token, not the ~4 that English averages: Cyrillic, Greek, and
|
|
425
|
+
// CJK tokenize far denser, which is exactly the case that truncates.
|
|
426
|
+
const wholePageEdit = pageWideTranslation || pageWideRewrite;
|
|
427
|
+
const wholePageBudget = wholePageEdit
|
|
428
|
+
? Math.ceil((JSON.stringify(args.currentPage).length * 3) / 2.5) + (thinkingParam ? 24000 : 8000)
|
|
429
|
+
: 0;
|
|
430
|
+
// The pipeline raises this on a retry that follows a truncation, so attempt N+1
|
|
431
|
+
// doesn't reproduce attempt N's failure with an identical budget.
|
|
432
|
+
const outputTokenScale = Math.max(1, args.outputTokenScale ?? 1);
|
|
433
|
+
const requestedMaxTokens = Math.ceil(Math.max(baseMaxTokens, wholePageBudget) * outputTokenScale);
|
|
434
|
+
const plannerMaxTokens = Math.min(requestedMaxTokens, PLANNER_MAX_TOKENS_NON_STREAMING);
|
|
435
|
+
const plannerStreamMaxTokens = Math.min(requestedMaxTokens, PLANNER_MAX_TOKENS_STREAMING);
|
|
324
436
|
// Collapsed request params spread into every tool-loop create/stream call.
|
|
325
437
|
// effort rides inside output_config alongside thinking; only the constrained-
|
|
326
438
|
// decoding fallback (thinking OFF) sets output_config.format instead.
|
|
@@ -395,10 +507,13 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
395
507
|
for (let turn = 0; turn < maxToolTurns; turn += 1) {
|
|
396
508
|
let response;
|
|
397
509
|
let emittedTextDeltas = false;
|
|
510
|
+
// Mirrors the submit_edit_plan tool JSON streamed this turn, hoisted out of
|
|
511
|
+
// the stream branch so the max_tokens handler below can salvage from it.
|
|
512
|
+
let submitToolJsonBuf = "";
|
|
398
513
|
if (client.messages.stream) {
|
|
399
514
|
const stream = client.messages.stream({
|
|
400
515
|
model: args.model,
|
|
401
|
-
max_tokens:
|
|
516
|
+
max_tokens: plannerStreamMaxTokens,
|
|
402
517
|
system: cachedSystem,
|
|
403
518
|
tools: toolDefs,
|
|
404
519
|
tool_choice: submitToolChoice,
|
|
@@ -460,6 +575,7 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
460
575
|
continue;
|
|
461
576
|
const nextBuf = (submitToolJsonByIndex.get(idx) ?? "") + (event.delta.partial_json ?? "");
|
|
462
577
|
submitToolJsonByIndex.set(idx, nextBuf);
|
|
578
|
+
submitToolJsonBuf = nextBuf;
|
|
463
579
|
emitProgressFromToolJson(nextBuf);
|
|
464
580
|
}
|
|
465
581
|
}
|
|
@@ -530,9 +646,25 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
530
646
|
model: args.model,
|
|
531
647
|
stopReason: "max_tokens",
|
|
532
648
|
turn,
|
|
649
|
+
toolJsonBufLength: submitToolJsonBuf.length,
|
|
533
650
|
contentBlockTypes: response.content.map((b) => b.type)
|
|
534
|
-
}, "Anthropic planner: response truncated (max_tokens) during tool loop");
|
|
535
|
-
|
|
651
|
+
}, "Anthropic planner: response truncated (max_tokens) during tool loop — attempting salvage");
|
|
652
|
+
const truncatedToolBlock = response.content
|
|
653
|
+
.map((block) => asToolUseBlock(block))
|
|
654
|
+
.find((block) => block?.name === "submit_edit_plan");
|
|
655
|
+
const salvaged = salvageTruncatedPlan({
|
|
656
|
+
parsed: truncatedToolBlock?.input && typeof truncatedToolBlock.input === "object"
|
|
657
|
+
? truncatedToolBlock.input
|
|
658
|
+
: undefined,
|
|
659
|
+
buffer: submitToolJsonBuf,
|
|
660
|
+
log: args.log,
|
|
661
|
+
model: args.model
|
|
662
|
+
});
|
|
663
|
+
// Nothing parseable survived — let the caller retry with a bigger budget.
|
|
664
|
+
if (!salvaged)
|
|
665
|
+
throw new Error("Model response was truncated (max_tokens reached)");
|
|
666
|
+
parsed = salvaged;
|
|
667
|
+
break;
|
|
536
668
|
}
|
|
537
669
|
if (args.onToken && !emittedTextDeltas) {
|
|
538
670
|
for (const block of response.content) {
|
|
@@ -679,8 +811,22 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
679
811
|
content: toolResults
|
|
680
812
|
});
|
|
681
813
|
}
|
|
814
|
+
// Path 2: streaming. Chosen whenever *any* incremental callback is listening,
|
|
815
|
+
// not just `onToken`.
|
|
816
|
+
//
|
|
817
|
+
// Testing `onToken` alone conflated two different questions: "is a human
|
|
818
|
+
// watching prose arrive" and "does someone need the ops as they are parsed".
|
|
819
|
+
// A chunked full-page translation separates them — prose callbacks go to the
|
|
820
|
+
// first chunk only, because six chunks streaming six summaries into one
|
|
821
|
+
// message box reads as a stutter — so every other chunk fell through to the
|
|
822
|
+
// non-streaming `messages.create` below and never fired `onPlannedOp`. Those
|
|
823
|
+
// chunks' ops reached the merged plan but never reached the pipeline's
|
|
824
|
+
// streamed per-op apply, which then reported the partial stream as a finished
|
|
825
|
+
// apply and skipped the rest of the plan. A page-wide translation applied the
|
|
826
|
+
// first chunk's blocks, left the other five in the source language, and said
|
|
827
|
+
// it had translated the page.
|
|
682
828
|
}
|
|
683
|
-
else if (args.onToken) {
|
|
829
|
+
else if (args.onToken || args.onPlannedOp || args.onFieldDraft || args.onSummaryChunk || args.onChangeLogEntry) {
|
|
684
830
|
let toolJsonBuf = "";
|
|
685
831
|
let textBuf = "";
|
|
686
832
|
if (client.messages.stream) {
|
|
@@ -689,7 +835,7 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
689
835
|
// before emitting the tool call.
|
|
690
836
|
const stream = client.messages.stream({
|
|
691
837
|
model: args.model,
|
|
692
|
-
max_tokens:
|
|
838
|
+
max_tokens: plannerStreamMaxTokens,
|
|
693
839
|
system: cachedSystem,
|
|
694
840
|
tools: [anthropicToolWithCache(submitPlanToolDef)],
|
|
695
841
|
tool_choice: thinkingParam
|
|
@@ -738,7 +884,7 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
738
884
|
}
|
|
739
885
|
else if (event.delta?.type === "text_delta") {
|
|
740
886
|
textBuf += event.delta.text ?? "";
|
|
741
|
-
args.onToken(event.delta.text ?? "");
|
|
887
|
+
args.onToken?.(event.delta.text ?? "");
|
|
742
888
|
}
|
|
743
889
|
}
|
|
744
890
|
}
|
|
@@ -757,6 +903,7 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
757
903
|
}
|
|
758
904
|
// If stream completed normally, try finalMessage() for validated tool input.
|
|
759
905
|
// Skip if stream threw — finalMessage() would also fail.
|
|
906
|
+
let streamTruncated = false;
|
|
760
907
|
if (!streamLoopError) {
|
|
761
908
|
let finalMessage;
|
|
762
909
|
try {
|
|
@@ -764,14 +911,16 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
764
911
|
usage = extractUsage(finalMessage);
|
|
765
912
|
const streamStopReason = finalMessage?.stop_reason;
|
|
766
913
|
if (streamStopReason === "max_tokens") {
|
|
914
|
+
// Don't throw yet — the buffer below usually holds a nearly-complete
|
|
915
|
+
// plan. Record it and let the repair fallback try to salvage one.
|
|
916
|
+
streamTruncated = true;
|
|
767
917
|
args.log?.warn({
|
|
768
918
|
event: "anthropic_planner_truncated",
|
|
769
919
|
model: args.model,
|
|
770
920
|
stopReason: streamStopReason,
|
|
771
921
|
toolJsonBufLength: toolJsonBuf.length,
|
|
772
922
|
textBufLength: textBuf.length
|
|
773
|
-
}, "Anthropic planner: response truncated (max_tokens)");
|
|
774
|
-
throw new Error("Model response was truncated (max_tokens reached)");
|
|
923
|
+
}, "Anthropic planner: response truncated (max_tokens) — attempting salvage from streamed buffer");
|
|
775
924
|
}
|
|
776
925
|
const finalToolBlock = finalMessage.content
|
|
777
926
|
?.map((block) => asToolUseBlock(block))
|
|
@@ -781,8 +930,6 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
781
930
|
}
|
|
782
931
|
}
|
|
783
932
|
catch (finalMsgErr) {
|
|
784
|
-
if (finalMsgErr instanceof Error && finalMsgErr.message.includes("max_tokens"))
|
|
785
|
-
throw finalMsgErr;
|
|
786
933
|
args.log?.warn({
|
|
787
934
|
event: "anthropic_planner_final_message_failed",
|
|
788
935
|
model: args.model,
|
|
@@ -811,6 +958,13 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
811
958
|
}
|
|
812
959
|
}
|
|
813
960
|
}
|
|
961
|
+
if (streamTruncated) {
|
|
962
|
+
const salvaged = salvageTruncatedPlan({ parsed, buffer: toolJsonBuf, log: args.log, model: args.model });
|
|
963
|
+
// Nothing parseable survived — let the caller retry with a bigger budget.
|
|
964
|
+
if (!salvaged)
|
|
965
|
+
throw new Error("Model response was truncated (max_tokens reached)");
|
|
966
|
+
parsed = salvaged;
|
|
967
|
+
}
|
|
814
968
|
}
|
|
815
969
|
else {
|
|
816
970
|
const response = await client.messages.create({
|
|
@@ -18,6 +18,19 @@ export declare function findExplicitCtaTargetCoverageGap(args: {
|
|
|
18
18
|
currentPage: PageDoc;
|
|
19
19
|
slug: string;
|
|
20
20
|
}): string | null;
|
|
21
|
+
/**
|
|
22
|
+
* The checklist a full-page translation has to satisfy, block by block.
|
|
23
|
+
*
|
|
24
|
+
* Telling the planner "translate all blocks" is not the same as telling it which
|
|
25
|
+
* blocks exist — prose scope is easy to under-read, and a forgotten section
|
|
26
|
+
* looks identical to a section that was never in scope. This enumerates the work
|
|
27
|
+
* so the model can be held to a count it can see.
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildFullPageTranslationChecklist(page: PageDoc): {
|
|
30
|
+
blockId: string;
|
|
31
|
+
blockType: string;
|
|
32
|
+
fields: string[];
|
|
33
|
+
}[];
|
|
21
34
|
export declare function findFullPageTranslationCoverageGap(args: {
|
|
22
35
|
plan: EditPlan;
|
|
23
36
|
message: string;
|
|
@@ -228,6 +228,64 @@ export function findExplicitCtaTargetCoverageGap(args) {
|
|
|
228
228
|
}
|
|
229
229
|
return null;
|
|
230
230
|
}
|
|
231
|
+
/** Field kinds whose values are prose and must be translated. */
|
|
232
|
+
function isTranslatableKind(kind) {
|
|
233
|
+
return kind === "text" || kind === "richtext" || kind === "imageAlt";
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* The translatable surface of one block: which top-level props and which
|
|
237
|
+
* list-child fields carry prose. One definition, used both to tell the planner
|
|
238
|
+
* what it must cover and to check afterwards whether it did.
|
|
239
|
+
*/
|
|
240
|
+
function translatableFieldKeys(meta) {
|
|
241
|
+
const topLevel = Object.entries(meta?.fields ?? {})
|
|
242
|
+
.filter(([, fm]) => isTranslatableKind(fm.kind))
|
|
243
|
+
.map(([key]) => key);
|
|
244
|
+
const lists = Object.entries(meta?.listFields ?? {})
|
|
245
|
+
.map(([listKey, listMeta]) => [
|
|
246
|
+
listKey,
|
|
247
|
+
Object.entries(listMeta.itemFields ?? {})
|
|
248
|
+
.filter(([, fm]) => isTranslatableKind(fm.kind))
|
|
249
|
+
.map(([key]) => key)
|
|
250
|
+
])
|
|
251
|
+
.filter(([, itemFields]) => itemFields.length > 0);
|
|
252
|
+
return { topLevel, lists };
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The checklist a full-page translation has to satisfy, block by block.
|
|
256
|
+
*
|
|
257
|
+
* Telling the planner "translate all blocks" is not the same as telling it which
|
|
258
|
+
* blocks exist — prose scope is easy to under-read, and a forgotten section
|
|
259
|
+
* looks identical to a section that was never in scope. This enumerates the work
|
|
260
|
+
* so the model can be held to a count it can see.
|
|
261
|
+
*/
|
|
262
|
+
export function buildFullPageTranslationChecklist(page) {
|
|
263
|
+
const blockMeta = getAllBlockMeta();
|
|
264
|
+
const entries = [];
|
|
265
|
+
for (const block of page.blocks) {
|
|
266
|
+
const { topLevel, lists } = translatableFieldKeys(blockMeta[block.type]);
|
|
267
|
+
const props = block.props;
|
|
268
|
+
const fields = topLevel.filter((key) => isNonEmptyString(props[key]));
|
|
269
|
+
for (const [listKey, itemFields] of lists) {
|
|
270
|
+
const listValue = props[listKey];
|
|
271
|
+
if (!Array.isArray(listValue))
|
|
272
|
+
continue;
|
|
273
|
+
for (let idx = 0; idx < listValue.length; idx += 1) {
|
|
274
|
+
const item = listValue[idx];
|
|
275
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
276
|
+
continue;
|
|
277
|
+
const row = item;
|
|
278
|
+
for (const field of itemFields) {
|
|
279
|
+
if (isNonEmptyString(row[field]))
|
|
280
|
+
fields.push(`${listKey}[${idx}].${field}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (fields.length > 0)
|
|
285
|
+
entries.push({ blockId: block.id, blockType: String(block.type), fields });
|
|
286
|
+
}
|
|
287
|
+
return entries;
|
|
288
|
+
}
|
|
231
289
|
export function findFullPageTranslationCoverageGap(args) {
|
|
232
290
|
if (inferTranslationScopeFromMessage(args.message) !== "page")
|
|
233
291
|
return null;
|
|
@@ -244,47 +302,38 @@ export function findFullPageTranslationCoverageGap(args) {
|
|
|
244
302
|
.filter((id) => typeof id === "string" && id.length > 0));
|
|
245
303
|
if (touchedBlockIds.size === 0)
|
|
246
304
|
return null;
|
|
305
|
+
// Every block is checked, not just the ones the plan touched. Scoping this to
|
|
306
|
+
// touchedBlockIds meant a block the model skipped *entirely* was invisible to
|
|
307
|
+
// the coverage check — a page-wide translation could leave a whole FAQ section
|
|
308
|
+
// in English and still report full coverage, because the section it forgot was
|
|
309
|
+
// also the section it was never asked about.
|
|
247
310
|
const missingPaths = [];
|
|
248
311
|
for (const block of args.currentPage.blocks) {
|
|
249
|
-
|
|
250
|
-
continue;
|
|
251
|
-
const meta = blockMeta[block.type];
|
|
312
|
+
const { topLevel: translatableTopLevel, lists: translatableLists } = translatableFieldKeys(blockMeta[block.type]);
|
|
252
313
|
// --- Top-level scalar prop coverage ---
|
|
253
|
-
if (
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const patch = normalizeUpdatePropsPatch(op);
|
|
264
|
-
for (const key of translatableTopLevel) {
|
|
265
|
-
if (isNonEmptyString(patch[key]))
|
|
266
|
-
coveredTopLevel.add(key);
|
|
267
|
-
}
|
|
314
|
+
if (translatableTopLevel.length > 0) {
|
|
315
|
+
const coveredTopLevel = new Set();
|
|
316
|
+
for (const op of args.plan.ops) {
|
|
317
|
+
if (!("pageSlug" in op) || op.pageSlug !== args.slug)
|
|
318
|
+
continue;
|
|
319
|
+
if (op.op === "update_props" && op.blockId === block.id) {
|
|
320
|
+
const patch = normalizeUpdatePropsPatch(op);
|
|
321
|
+
for (const key of translatableTopLevel) {
|
|
322
|
+
if (isNonEmptyString(patch[key]))
|
|
323
|
+
coveredTopLevel.add(key);
|
|
268
324
|
}
|
|
269
325
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
326
|
+
}
|
|
327
|
+
const blockProps = block.props;
|
|
328
|
+
for (const key of translatableTopLevel) {
|
|
329
|
+
if (!isNonEmptyString(blockProps[key]))
|
|
330
|
+
continue;
|
|
331
|
+
if (!coveredTopLevel.has(key))
|
|
332
|
+
missingPaths.push(`${block.id}.${key}`);
|
|
277
333
|
}
|
|
278
334
|
}
|
|
279
335
|
// --- List field child coverage ---
|
|
280
|
-
const
|
|
281
|
-
const listEntries = Object.entries(listFields);
|
|
282
|
-
for (const [listKey, listMeta] of listEntries) {
|
|
283
|
-
const translatableItemFields = Object.entries(listMeta.itemFields ?? {})
|
|
284
|
-
.filter(([, fieldMeta]) => fieldMeta.kind === "text" || fieldMeta.kind === "richtext" || fieldMeta.kind === "imageAlt")
|
|
285
|
-
.map(([key]) => key);
|
|
286
|
-
if (translatableItemFields.length === 0)
|
|
287
|
-
continue;
|
|
336
|
+
for (const [listKey, translatableItemFields] of translatableLists) {
|
|
288
337
|
const listValue = block.props[listKey];
|
|
289
338
|
if (!Array.isArray(listValue) || listValue.length === 0)
|
|
290
339
|
continue;
|
|
@@ -350,9 +399,17 @@ export function findFullPageTranslationCoverageGap(args) {
|
|
|
350
399
|
}
|
|
351
400
|
if (missingPaths.length === 0)
|
|
352
401
|
return null;
|
|
353
|
-
// Auto-patch:
|
|
354
|
-
//
|
|
402
|
+
// Auto-patch: where the plan already edits a block but left one of its fields
|
|
403
|
+
// out, fill that field with the original (untranslated) value rather than
|
|
404
|
+
// failing — a partially translated block still applies, and untranslated text
|
|
405
|
+
// beats losing the field.
|
|
406
|
+
//
|
|
407
|
+
// A block the plan never touched is a different case: there is no op to patch
|
|
408
|
+
// into, and writing the English back would be a no-op that papers over the
|
|
409
|
+
// miss. Those paths stay unresolved and are reported, so the caller can ask
|
|
410
|
+
// the model for the translation it skipped.
|
|
355
411
|
let patched = 0;
|
|
412
|
+
const unresolved = [];
|
|
356
413
|
for (const path of missingPaths) {
|
|
357
414
|
// Parse path: "b_hero_1.heading" or "b_cardgrid_7.cards[0].imageAlt"
|
|
358
415
|
const topMatch = path.match(/^([^.]+)\.([^[]+)$/);
|
|
@@ -374,6 +431,9 @@ export function findFullPageTranslationCoverageGap(args) {
|
|
|
374
431
|
patched++;
|
|
375
432
|
}
|
|
376
433
|
}
|
|
434
|
+
else {
|
|
435
|
+
unresolved.push(path);
|
|
436
|
+
}
|
|
377
437
|
}
|
|
378
438
|
else if (listMatch) {
|
|
379
439
|
const [, blockId, listKey, indexStr, field] = listMatch;
|
|
@@ -390,23 +450,27 @@ export function findFullPageTranslationCoverageGap(args) {
|
|
|
390
450
|
continue;
|
|
391
451
|
// Find the existing update_props op and patch the list item
|
|
392
452
|
const existingOp = args.plan.ops.find(op => op.op === "update_props" && "blockId" in op && op.blockId === blockId && "pageSlug" in op && op.pageSlug === args.slug);
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
453
|
+
const patch = existingOp && "patch" in existingOp ? existingOp.patch : null;
|
|
454
|
+
const itemPatch = patch && Array.isArray(patch[listKey]) && patch[listKey][idx] && typeof patch[listKey][idx] === "object"
|
|
455
|
+
? patch[listKey][idx]
|
|
456
|
+
: null;
|
|
457
|
+
if (itemPatch) {
|
|
458
|
+
if (!isNonEmptyString(itemPatch[field])) {
|
|
459
|
+
itemPatch[field] = originalValue;
|
|
460
|
+
patched++;
|
|
401
461
|
}
|
|
402
462
|
}
|
|
463
|
+
else {
|
|
464
|
+
unresolved.push(path);
|
|
465
|
+
}
|
|
403
466
|
}
|
|
404
467
|
}
|
|
405
468
|
if (patched > 0) {
|
|
406
469
|
console.log(`[translation-coverage] Auto-patched ${patched} missing fields with original values`);
|
|
407
|
-
return null; // Patched — no gap anymore
|
|
408
470
|
}
|
|
409
|
-
|
|
471
|
+
if (unresolved.length === 0)
|
|
472
|
+
return null;
|
|
473
|
+
return `Invalid full-page translation coverage. Missing translated fields: ${unresolved.join(", ")}`;
|
|
410
474
|
}
|
|
411
475
|
export function normalizeVariationTypos(text) {
|
|
412
476
|
return text
|
|
@@ -9,7 +9,7 @@ import { generatePlanWithAnthropic, parseIntentWithAnthropic } from "./anthropic
|
|
|
9
9
|
import { generatePlanWithGemini, parseIntentWithGemini } from "./gemini-planner.ts";
|
|
10
10
|
import { type ThinkingEvent } from "./planner-types.ts";
|
|
11
11
|
export { sentenceCase, firstUrlFromText, preferredImageAltText, collectMentionedSlugsFromPlan, collectMentionedSlugsFromOps, normalizePlanCopyForUi, futureToPastTense } from "./chat-pipeline-ui.ts";
|
|
12
|
-
export { sanitizeMessageForPlanning, inferTranslationScopeFromMessage, isNonEmptyString, findFullPageTranslationCoverageGap, findExplicitCtaTargetCoverageGap, parseCloneAndTranslateRequest, type CloneAndTranslateRequest, type TranslationScope } from "./chat-pipeline-translation.ts";
|
|
12
|
+
export { sanitizeMessageForPlanning, inferTranslationScopeFromMessage, isNonEmptyString, findFullPageTranslationCoverageGap, buildFullPageTranslationChecklist, findExplicitCtaTargetCoverageGap, parseCloneAndTranslateRequest, type CloneAndTranslateRequest, type TranslationScope } from "./chat-pipeline-translation.ts";
|
|
13
13
|
export { shouldPreferFastModelForMessage, shouldUseLlmIntentRouter, compactPlannerContextPack, minimalPlannerContextPack, shouldUseMinimalPlannerContext, shouldPreferFocusedTranslation, shouldEnableReasoningForMessage } from "./chat-pipeline-context.ts";
|
|
14
14
|
export { isRewriteLikeMessage, isPerformanceAwareMessage, isLikelyTextField, collectChangedTextFields, buildMetaChangeLogEntries, buildAiInsightChanges, buildOpChangeLogEntries, deterministicCreatePagePlan, deterministicDuplicatePagePlan, deterministicSelectedTextRewritePlan, shouldReturnDeterministicClarification, fmtSlug } from "./chat-pipeline-deterministic.ts";
|
|
15
15
|
export { blockHasImageUrlProp, parsePath, getValueAtPath, setValueAtPath, deleteValueAtPath, extractIndexedQueries, extractReferencedItemIndices, blockSupportsImageAtPath, detectImagePaths, imageQueryFromItem, shouldPopulateAllChildImages, findImageTargets, rewriteAddBlockToChildImageUpdate, withUnsplashHeroImage, shouldResolveCreatePageHeroImage, resolveHeroImageForCreatePage, detectImageOps } from "./chat-pipeline-image.ts";
|