narraeon 0.7.5 → 0.7.7

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.
@@ -353,6 +353,10 @@ function validateRequestFields(request) {
353
353
  if (request.type === "play.timeline.detail" &&
354
354
  (!Number.isSafeInteger(request.eventId) || Number(request.eventId) < 1))
355
355
  throw new V1ProtocolError("invalid_request", "play.timeline.detail.eventId is invalid");
356
+ if ("turnNote" in request &&
357
+ request.turnNote !== undefined &&
358
+ typeof request.turnNote !== "string")
359
+ throw new V1ProtocolError("invalid_request", "turnNote is invalid");
356
360
  if (request.type === "play.chain.revise-player" &&
357
361
  request.continuation !== "continue_context" &&
358
362
  request.continuation !== "fresh_context")
@@ -458,6 +458,9 @@ export class V1Runtime {
458
458
  }), `content:${request.packageId}.message.genesis.narrator`, "content-preview"),
459
459
  playerInputPlacement: "append",
460
460
  playerInput: request.playerInput,
461
+ ...(request.turnNote === undefined
462
+ ? {}
463
+ : { turnNote: request.turnNote }),
461
464
  modelBinding: modelHost.binding(),
462
465
  }, playPreset);
463
466
  const tools = structuredClone(preview.compilation.toolUniverse ?? preview.compilation.tools);
@@ -669,6 +672,9 @@ export class V1Runtime {
669
672
  eventId: request.eventId,
670
673
  replacementExchangeId: request.replacementExchangeId,
671
674
  replacementText: request.replacementText,
675
+ ...(request.turnNote === undefined
676
+ ? {}
677
+ : { turnNote: request.turnNote }),
672
678
  };
673
679
  const result = await playCall(async () => {
674
680
  await this.#worlds.assertWorldRevisionUnlocked(request.worldId);
@@ -695,6 +701,9 @@ export class V1Runtime {
695
701
  chainId: request.chainId,
696
702
  exchangeId: request.exchangeId,
697
703
  playerText: request.playerText,
704
+ ...(request.turnNote === undefined
705
+ ? {}
706
+ : { turnNote: request.turnNote }),
698
707
  hostBinding,
699
708
  playPreset,
700
709
  modelBinding,
@@ -712,6 +721,9 @@ export class V1Runtime {
712
721
  chainId: request.chainId,
713
722
  exchangeId: request.exchangeId,
714
723
  playerText: request.playerText,
724
+ ...(request.turnNote === undefined
725
+ ? {}
726
+ : { turnNote: request.turnNote }),
715
727
  modelHost: await this.#modelHost(),
716
728
  resolvePrompt: () => this.#continuousBinding(),
717
729
  ...(playCallChainObserver === undefined
@@ -0,0 +1,138 @@
1
+ /** Root arguments are depth 0; parsing and descent stop after two levels. */
2
+ const maxCoercionDepth = 2;
3
+ export function coerceToolArguments(schema, args) {
4
+ const coerced = [];
5
+ const value = coerceValue(schema, args, "", 0, coerced);
6
+ return { arguments: value, coerced };
7
+ }
8
+ /**
9
+ * Renders the Runtime note appended to a tool result whose arguments were
10
+ * parsed. It tells the model its arguments were repaired, so the next call
11
+ * can send native JSON values.
12
+ */
13
+ export function renderCoercionNote(paths, locale) {
14
+ const names = paths.map((path) => `"${path}"`).join(", ");
15
+ return locale === "zh-CN"
16
+ ? `Runtime 说明:以下参数以 JSON 字符串到达,本次已解析后执行:${names}。请以 JSON 数组或对象的原生值发送,不要用字符串。`
17
+ : `Runtime note: these arguments arrived as JSON strings and were parsed for this call: ${names}. Send array and object arguments as native JSON values, not strings.`;
18
+ }
19
+ function coerceValue(schema, value, path, depth, coerced) {
20
+ const type = definiteType(schema);
21
+ let current = value;
22
+ if ((type === "array" || type === "object") &&
23
+ typeof current === "string" &&
24
+ depth > 0 &&
25
+ depth <= maxCoercionDepth) {
26
+ const parsed = parseJson(current);
27
+ if (matchesType(type, parsed)) {
28
+ current = parsed;
29
+ coerced.push(path);
30
+ }
31
+ }
32
+ if (depth >= maxCoercionDepth)
33
+ return current;
34
+ if (type === "array" && isArray(current)) {
35
+ const items = itemsSchema(schema);
36
+ if (items === undefined)
37
+ return current;
38
+ let next = current;
39
+ current.forEach((element, index) => {
40
+ const repaired = coerceValue(items, element, `${path}[${index}]`, depth + 1, coerced);
41
+ if (repaired !== element) {
42
+ if (next === current)
43
+ next = [...current];
44
+ next[index] = repaired;
45
+ }
46
+ });
47
+ return next;
48
+ }
49
+ if (type === "object" && isRecord(current)) {
50
+ let next = current;
51
+ for (const key of Object.keys(current)) {
52
+ const property = propertySchema(schema, key);
53
+ if (property === undefined)
54
+ continue;
55
+ const repaired = coerceValue(property, current[key], path === "" ? key : `${path}.${key}`, depth + 1, coerced);
56
+ if (repaired !== current[key]) {
57
+ if (next === current)
58
+ next = { ...current };
59
+ next[key] = repaired;
60
+ }
61
+ }
62
+ return next;
63
+ }
64
+ return current;
65
+ }
66
+ /** The single type a schema position admits, or undefined when it is not definite. */
67
+ function definiteType(schema) {
68
+ if (!isRecord(schema))
69
+ return undefined;
70
+ if (schema.type !== undefined) {
71
+ if (typeof schema.type === "string")
72
+ return schema.type;
73
+ if (Array.isArray(schema.type) &&
74
+ schema.type.length === 1 &&
75
+ typeof schema.type[0] === "string")
76
+ return schema.type[0];
77
+ return undefined;
78
+ }
79
+ const branches = Array.isArray(schema.oneOf)
80
+ ? schema.oneOf
81
+ : Array.isArray(schema.anyOf)
82
+ ? schema.anyOf
83
+ : undefined;
84
+ if (branches === undefined || branches.length === 0)
85
+ return undefined;
86
+ const types = branches.map(definiteType);
87
+ const first = types[0];
88
+ return first !== undefined && types.every((type) => type === first)
89
+ ? first
90
+ : undefined;
91
+ }
92
+ function itemsSchema(schema) {
93
+ return isRecord(schema.items) && !Array.isArray(schema.items)
94
+ ? schema.items
95
+ : undefined;
96
+ }
97
+ /**
98
+ * A property schema is taken directly from `properties`, or from oneOf/anyOf
99
+ * branches only when every branch that declares the key declares it the same.
100
+ */
101
+ function propertySchema(schema, key) {
102
+ if (isRecord(schema.properties) && isRecord(schema.properties[key]))
103
+ return schema.properties[key];
104
+ const branches = Array.isArray(schema.oneOf)
105
+ ? schema.oneOf
106
+ : Array.isArray(schema.anyOf)
107
+ ? schema.anyOf
108
+ : [];
109
+ const declared = branches
110
+ .map((branch) => isRecord(branch) && isRecord(branch.properties)
111
+ ? branch.properties[key]
112
+ : undefined)
113
+ .filter((property) => property !== undefined);
114
+ if (declared.length === 0)
115
+ return undefined;
116
+ const serialized = declared.map((property) => JSON.stringify(property));
117
+ return serialized.every((text) => text === serialized[0]) &&
118
+ isRecord(declared[0])
119
+ ? declared[0]
120
+ : undefined;
121
+ }
122
+ function parseJson(text) {
123
+ try {
124
+ return JSON.parse(text);
125
+ }
126
+ catch {
127
+ return undefined;
128
+ }
129
+ }
130
+ function matchesType(type, value) {
131
+ return type === "array" ? Array.isArray(value) : isRecord(value);
132
+ }
133
+ function isArray(value) {
134
+ return Array.isArray(value);
135
+ }
136
+ function isRecord(value) {
137
+ return typeof value === "object" && value !== null && !Array.isArray(value);
138
+ }
@@ -4,7 +4,7 @@ import { FileNativePromptCompiler, } from "../prompt/FileNativePromptCompiler.js
4
4
  import { validateMaterialList } from "../prompt/MaterialSelection.js";
5
5
  import { FileNativeWorldCreationError, } from "../world/FileNativeWorldStore.js";
6
6
  import { WorldOperationBusyError, } from "../world/WorldOperationCoordinator.js";
7
- import { beginWorldStateRevision, fileNativeStateChanges, reviseWorldState, worldDocumentRevisionFailureMessage, worldStateRevisionChanges, } from "../world/WorldStateRevision.js";
7
+ import { beginWorldStateRevision, fileNativeStateChanges, acceptWorldStateRevision, worldDocumentRevisionFailureMessage, worldStateRevisionChanges, } from "../world/WorldStateRevision.js";
8
8
  export class FileNativeContinuityCorrection {
9
9
  #worlds;
10
10
  #compiler;
@@ -260,9 +260,17 @@ export class FileNativeContinuityCorrection {
260
260
  return document;
261
261
  }
262
262
  #reviseDocument(candidate, command) {
263
- const revised = reviseWorldState(candidate, { commands: [command] });
264
- if (!revised.ok || revised.snapshotStatus !== "usable")
265
- throw new Error(worldDocumentRevisionFailureMessage(revised.diagnostics));
263
+ // A correction applies all of its edits or none of them, so a skipped edit
264
+ // is a failure here even though the revision itself settled.
265
+ const revised = candidate.snapshot.revise({ commands: [command] });
266
+ const failures = revised.ok
267
+ ? revised.editOutcomes.flatMap(({ status, diagnostic }) => status === "failed" && diagnostic !== undefined ? [diagnostic] : [])
268
+ : revised.diagnostics;
269
+ if (!revised.ok ||
270
+ revised.snapshotStatus !== "usable" ||
271
+ failures.length > 0)
272
+ throw new Error(worldDocumentRevisionFailureMessage(failures));
273
+ acceptWorldStateRevision(candidate, revised);
266
274
  candidate.reads.clear();
267
275
  }
268
276
  #advance(candidate) {
@@ -948,7 +948,8 @@ function assertPlayEvent(value) {
948
948
  if (value.kind === "tool_call") {
949
949
  if (typeof value.callId !== "string" ||
950
950
  typeof value.name !== "string" ||
951
- typeof value.replayed !== "boolean")
951
+ typeof value.replayed !== "boolean" ||
952
+ !validOptionalString(value.executedName))
952
953
  throw new Error("Tool-call timeline event has an invalid shape");
953
954
  return;
954
955
  }
@@ -960,7 +961,8 @@ function assertPlayEvent(value) {
960
961
  typeof value.name !== "string" ||
961
962
  typeof value.ok !== "boolean" ||
962
963
  typeof value.markdown !== "string" ||
963
- typeof value.replayed !== "boolean")
964
+ typeof value.replayed !== "boolean" ||
965
+ !validOptionalString(value.executedName))
964
966
  throw new Error("Tool-result timeline event has an invalid shape");
965
967
  return;
966
968
  }
@@ -1014,6 +1016,7 @@ function assertTimelineEventSummary(value) {
1014
1016
  if (typeof value.callId !== "string" ||
1015
1017
  typeof value.name !== "string" ||
1016
1018
  typeof value.replayed !== "boolean" ||
1019
+ !validOptionalString(value.executedName) ||
1017
1020
  value.detailsAvailable !== true)
1018
1021
  throw new Error("Tool-call timeline summary has an invalid shape");
1019
1022
  return;
@@ -1026,6 +1029,7 @@ function assertTimelineEventSummary(value) {
1026
1029
  typeof value.name !== "string" ||
1027
1030
  typeof value.ok !== "boolean" ||
1028
1031
  typeof value.replayed !== "boolean" ||
1032
+ !validOptionalString(value.executedName) ||
1029
1033
  value.detailsAvailable !== true)
1030
1034
  throw new Error("Tool-result timeline summary has an invalid shape");
1031
1035
  return;
@@ -1,8 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { isMap, isSeq, parseDocument } from "yaml";
3
- import { builtinPlayPrompts, isPromptMessageRole, promptGroups, } from "../../shared/ordered-play-prompts.js";
4
- /** One turn-guide line: no line breaks, bounded length; empty clears a builtin default. */
5
- const turnGuideLine = /^[^\r\n\0]{0,1000}$/u;
3
+ import { builtinPlayPrompts, isPromptMessageRole, isTurnGuideLine, promptGroups, } from "../../shared/ordered-play-prompts.js";
6
4
  const promptName = /^[^\r\n\0]{1,160}$/u;
7
5
  /** The same strict permission gate is used by portable imports and structured saves. */
8
6
  export function parseOrderedPlayPrompts(value, catalog = builtinPlayPrompts("en"), worldRequired = true) {
@@ -33,8 +31,7 @@ export function parseOrderedPlayPrompts(value, catalog = builtinPlayPrompts("en"
33
31
  key === "id" ||
34
32
  key === "kind") && keys.every((key) => Object.hasOwn(item, key));
35
33
  const validTurnGuide = item.turnGuide === undefined ||
36
- (typeof item.turnGuide === "string" &&
37
- turnGuideLine.test(item.turnGuide));
34
+ (typeof item.turnGuide === "string" && isTurnGuideLine(item.turnGuide));
38
35
  const turnGuide = typeof item.turnGuide === "string" ? { turnGuide: item.turnGuide } : {};
39
36
  if (item.kind === "world" && exact(["id", "kind"])) {
40
37
  worlds++;
@@ -47,7 +44,7 @@ export function parseOrderedPlayPrompts(value, catalog = builtinPlayPrompts("en"
47
44
  item.name.trim().length > 0 &&
48
45
  promptName.test(item.name) &&
49
46
  typeof item.body === "string" &&
50
- turnGuideLine.test(item.body) &&
47
+ isTurnGuideLine(item.body) &&
51
48
  typeof item.enabled === "boolean")
52
49
  return [
53
50
  {
@@ -4,13 +4,15 @@ import { isResolutionTool, resolutionMarkdown, resolutionToolNames, resolveTable
4
4
  import { captureNarrativeOrigin, snapshotBackground, narrativeBackground, } from "../history/HistoryBackground.js";
5
5
  import { isDeepStrictEqual } from "node:util";
6
6
  import { hasLegacyHistoryTools } from "../prompt/FileNativeToolRegistry.js";
7
+ import { renderToolAliasNote, resolveToolName, } from "../prompt/ToolNameAlias.js";
7
8
  import { PlayCallChainError } from "./PlayCallChainError.js";
8
9
  import { preparePlayerRevisionTrace, prepareForkTrace, restorePlayDocuments, independentContextCopy, } from "./PlayTraceRetention.js";
9
10
  import { cloneSharingPromptRuns, currentPlayPrompt, recordedPlayPrompt, playPromptRunsThroughEvents, promptRunStrings, sharePromptRun, } from "./PlayPromptRun.js";
10
11
  import { comparePromptPrefixes } from "../prompt/WorldPromptDiagnostics.js";
11
12
  import { createHash, randomBytes } from "node:crypto";
13
+ import { isTurnGuideLine } from "../../shared/ordered-play-prompts.js";
14
+ import { turnGuideWithTurnNote } from "../../shared/turn-guide.js";
12
15
  import { completedPlayerRounds, continuationNotice, playerInputAppend, toolStepNotice, turnGuideNotice, } from "./PlayContinuity.js";
13
- import { renderFreshContextCoverage } from "../prompt/WorldMaterialCoverage.js";
14
16
  import { renderWriteSizeAdvice, readDeclaredWorldClock, } from "../prompt/WorldMaintenanceReport.js";
15
17
  import { WorldDocumentStore } from "../world/WorldDocumentStore.js";
16
18
  import { equalModelHostBinding, ModelHostCancelledError, } from "../model/ModelHost.js";
@@ -103,6 +105,7 @@ export class PlayCallChain {
103
105
  validateIdentity(input.chainId, "call-chain ID");
104
106
  validateIdentity(input.exchangeId, "exchange ID");
105
107
  validatePlayerText(input.playerText);
108
+ validTurnNote(input.turnNote);
106
109
  assertModelHostBinding(input.modelBinding, input.modelHost);
107
110
  // Only a resubmission to the current context needs that context loaded.
108
111
  await this.#worlds.ensureCurrentStorage(input.worldId);
@@ -152,6 +155,7 @@ export class PlayCallChain {
152
155
  canRetry: false,
153
156
  bootstrap: fresh.bootstrap,
154
157
  tools: fresh.tools,
158
+ ...turnNoteField(validTurnNote(input.turnNote)),
155
159
  transcript: [],
156
160
  events: [],
157
161
  completedTools: [],
@@ -243,6 +247,9 @@ export class PlayCallChain {
243
247
  return this.#dispatch(session, input.modelHost, structuredClone(session.lastRequest), session.lastRequestAttempt + 1, signal, input.observer);
244
248
  }
245
249
  await this.#refreshPrompt(session, input);
250
+ // Each dispatch carries exactly the request the client sent with it; a
251
+ // continuation without one must not inherit the previous turn's line.
252
+ setTurnNote(session, validTurnNote(input.turnNote));
246
253
  session.exchange += 1;
247
254
  const last = session.transcript.at(-1);
248
255
  if (last?.kind === "assistant" &&
@@ -251,7 +258,7 @@ export class PlayCallChain {
251
258
  session.narrativeOrigin = await this.#captureOrigin(session);
252
259
  session.transcript.push(continuationNotice(this.#compiler.locale));
253
260
  }
254
- return this.#dispatch(session, input.modelHost, createRequest(session, input.modelHost, session.exchange), 1, signal, input.observer);
261
+ return this.#dispatch(session, input.modelHost, createRequest(session, input.modelHost, session.exchange, this.#compiler.locale), 1, signal, input.observer);
255
262
  }
256
263
  if (duplicatePlayerExchange(session, input.exchangeId, input.playerText, "append"))
257
264
  return projectView(session);
@@ -260,6 +267,7 @@ export class PlayCallChain {
260
267
  ? "The call chain has an incomplete model response; clear the input and append a continuation, or use a fresh context."
261
268
  : "The call chain was interrupted; use a fresh context.");
262
269
  await this.#refreshPrompt(session, input);
270
+ setTurnNote(session, validTurnNote(input.turnNote));
263
271
  return this.#submitPlayer(session, input.exchangeId, input.playerText, "append", input.modelHost, signal, input.observer);
264
272
  }
265
273
  async #refreshPrompt(session, input) {
@@ -746,6 +754,7 @@ export class PlayCallChain {
746
754
  eventId: input.eventId,
747
755
  replacementExchangeId: input.replacementExchangeId,
748
756
  replacementText: input.replacementText,
757
+ ...turnNoteField(validTurnNote(input.turnNote)),
749
758
  continuation: input.continuation,
750
759
  restoresHead: playerRevisionRestoresHead(sourceContext, eventIndex),
751
760
  discardChainIds: contexts
@@ -854,6 +863,7 @@ export class PlayCallChain {
854
863
  previousContexts: [],
855
864
  previousChainId,
856
865
  timelineGeneration,
866
+ ...turnNoteField(intent.turnNote),
857
867
  parentHead: head,
858
868
  updatedAt: Date.now(),
859
869
  };
@@ -974,6 +984,7 @@ export class PlayCallChain {
974
984
  revision: input.freshContext.playPreset.revision,
975
985
  },
976
986
  followups: structuredClone(fresh.followups),
987
+ ...turnNoteField(input.intent.turnNote),
977
988
  playPresetScriptsEnabled: input.freshContext.playPreset.scriptsEnabled,
978
989
  presetFiles: structuredClone(input.freshContext.playPreset.files),
979
990
  modelBinding: structuredClone(input.freshContext.modelBinding),
@@ -1141,10 +1152,7 @@ export class PlayCallChain {
1141
1152
  throw new PlayCallChainError("The call chain is waiting for the model to return.");
1142
1153
  session.narrativeOrigin = await this.#captureOrigin(session);
1143
1154
  const exchange = session.exchange + 1;
1144
- const nextRequest = createRequest(session, modelHost, exchange, [
1145
- ...session.transcript,
1146
- ...playerInputAppend(playerText),
1147
- ]);
1155
+ const nextRequest = createRequest(session, modelHost, exchange, this.#compiler.locale, [...session.transcript, ...playerInputAppend(playerText)]);
1148
1156
  const operationKey = `player:${exchangeId}`;
1149
1157
  const playerOperationId = operationId(session.chainId, operationKey);
1150
1158
  const advance = {
@@ -1276,7 +1284,11 @@ export class PlayCallChain {
1276
1284
  let nextEventId = advance.eventId + 1;
1277
1285
  // Fresh in-memory seed per settlement. It is never persisted: a crashed
1278
1286
  // exchange is retried by the player and re-rolls (ADR-0044).
1279
- const resolutionSeed = calls.some(({ name }) => isResolutionTool(name))
1287
+ const providedToolNames = currentPlayPrompt(session).tools.map(({ name }) => name);
1288
+ const resolutionSeed = calls.some(({ name }) => {
1289
+ const resolved = resolveToolName(name, providedToolNames);
1290
+ return resolved.kind !== "rejected" && isResolutionTool(resolved.name);
1291
+ })
1280
1292
  ? randomBytes(32).toString("hex")
1281
1293
  : undefined;
1282
1294
  for (const call of calls) {
@@ -1287,6 +1299,9 @@ export class PlayCallChain {
1287
1299
  kind: "tool_call",
1288
1300
  callId: call.id,
1289
1301
  name: call.name,
1302
+ ...(item.event.executedName === undefined
1303
+ ? {}
1304
+ : { executedName: item.event.executedName }),
1290
1305
  arguments: structuredClone(call.arguments),
1291
1306
  replayed: item.replayed,
1292
1307
  });
@@ -1313,7 +1328,7 @@ export class PlayCallChain {
1313
1328
  ],
1314
1329
  });
1315
1330
  worldClock = readDeclaredWorldClock(snapshot);
1316
- const { coverage, maintenance } = this.#compiler.inspectWorldMaterials({
1331
+ const { maintenance } = this.#compiler.inspectWorldMaterials({
1317
1332
  controlFingerprint: "receipt",
1318
1333
  documentSnapshot: snapshot,
1319
1334
  history: Object.fromEntries(session.history.map(({ path, contents }) => [path, contents])),
@@ -1321,23 +1336,18 @@ export class PlayCallChain {
1321
1336
  });
1322
1337
  for (const { result } of writes) {
1323
1338
  const write = result.candidateWrite;
1324
- write.freshContextCoverage = renderFreshContextCoverage(snapshot, coverage, write.shortRef, this.#compiler.locale);
1325
1339
  const previous = session.documents.committedSnapshot.query({
1326
1340
  kind: "read_document",
1327
1341
  document: { shortRef: write.shortRef },
1328
1342
  });
1329
- const advice = renderWriteSizeAdvice(maintenance, write.shortRef, previous.kind === "read_document"
1343
+ write.sizeAdvice = renderWriteSizeAdvice(maintenance, write.shortRef, previous.kind === "read_document"
1330
1344
  ? Buffer.byteLength(previous.body, "utf8")
1331
1345
  : null, this.#compiler.locale);
1332
- if (advice.length > 0)
1333
- write.freshContextCoverage += `\n${advice}`;
1334
1346
  }
1335
1347
  }
1336
- catch (error) {
1337
- // A coverage prediction must never reject an otherwise valid write.
1338
- const reason = error instanceof Error ? error.message : "Material selection failed";
1339
- for (const { result } of writes)
1340
- result.candidateWrite.freshContextCoverage = `${this.#compiler.locale === "zh-CN" ? "暂时无法判定下次注入;写入结果不受影响。原因" : "Fresh-context coverage unavailable; the write result is unaffected. Reason"}: ${reason}`;
1348
+ catch {
1349
+ // Maintenance advice is diagnostic: a failure omits it and never
1350
+ // rejects or undoes an accepted write.
1341
1351
  }
1342
1352
  }
1343
1353
  const previousNarrative = session.events.findLast((event) => event.kind === "assistant" &&
@@ -1348,7 +1358,7 @@ export class PlayCallChain {
1348
1358
  session.events.some((event) => event.id >
1349
1359
  Math.max(previousNarrative?.id ?? 0, previousPlayer?.id ?? 0) &&
1350
1360
  event.kind === "tool_result" &&
1351
- event.name === "world_checkpoint" &&
1361
+ (event.executedName ?? event.name) === "world_checkpoint" &&
1352
1362
  event.ok);
1353
1363
  const narrativeCheckpoint = checkpointRequested
1354
1364
  ? {
@@ -1475,7 +1485,7 @@ export class PlayCallChain {
1475
1485
  }
1476
1486
  if (hasToolCalls) {
1477
1487
  session.exchange += 1;
1478
- const nextRequest = createRequestWithMaxOutputTokens(session, session.lastRequest?.maxOutputTokens ?? 1, session.exchange);
1488
+ const nextRequest = createRequestWithMaxOutputTokens(session, session.lastRequest?.maxOutputTokens ?? 1, session.exchange, this.#compiler.locale);
1479
1489
  if (session.lastRequest?.modelConfig !== undefined)
1480
1490
  nextRequest.modelConfig = structuredClone(session.lastRequest.modelConfig);
1481
1491
  await this.#advances.end(session, {
@@ -1942,14 +1952,16 @@ export class PlayCallChain {
1942
1952
  function followupOperationId(session) {
1943
1953
  return `${session.chainId}:followup:${session.parentHead}`;
1944
1954
  }
1945
- function createRequest(session, modelHost, exchange, appended = session.transcript) {
1946
- const request = createRequestWithMaxOutputTokens(session, modelHost.binding().maxOutputTokens, exchange, appended);
1955
+ function createRequest(session, modelHost, exchange, locale, appended = session.transcript) {
1956
+ const request = createRequestWithMaxOutputTokens(session, modelHost.binding().maxOutputTokens, exchange, locale, appended);
1947
1957
  return modelHost.freezeRequest?.(request) ?? request;
1948
1958
  }
1949
- function createRequestWithMaxOutputTokens(session, maxOutputTokens, exchange, appended = session.transcript) {
1959
+ function createRequestWithMaxOutputTokens(session, maxOutputTokens, exchange, locale, appended = session.transcript) {
1950
1960
  // The turn guide is a request-level suffix: it follows whatever this
1951
- // dispatch appends and is never part of the transcript it is built on.
1952
- const turnGuide = currentPlayPrompt(session).bootstrap.turnGuide;
1961
+ // dispatch appends and is never part of the transcript it is built on. The
1962
+ // player's one-turn request joins the suffix here, not in the bootstrap, so
1963
+ // the bootstrap snapshot stays shared by every dispatch of the turn.
1964
+ const turnGuide = turnGuideWithTurnNote(locale, currentPlayPrompt(session).bootstrap.turnGuide, session.turnNote);
1953
1965
  return {
1954
1966
  bootstrap: structuredClone(currentPlayPrompt(session).bootstrap),
1955
1967
  tools: structuredClone(currentPlayPrompt(session).tools),
@@ -1997,7 +2009,7 @@ function finalizeWriteReceipts(settlement) {
1997
2009
  const status = candidateWrite.changed && committedWriteRefs.has(candidateWrite.shortRef)
1998
2010
  ? `@${candidateWrite.shortRef} write succeeded`
1999
2011
  : `@${candidateWrite.shortRef} unchanged`;
2000
- const markdown = [status, candidateWrite.freshContextCoverage]
2012
+ const markdown = [status, candidateWrite.sizeAdvice]
2001
2013
  .filter(Boolean)
2002
2014
  .join("\n");
2003
2015
  completed.result.markdown = markdown;
@@ -2043,13 +2055,17 @@ function appendCommittedHistory(session, head, messages) {
2043
2055
  function prepareTool(session, working, exchange, call, locale, resolutionSeed) {
2044
2056
  const key = `${exchange}:${call.id}`;
2045
2057
  const signature = JSON.stringify(call.arguments);
2058
+ const provided = currentPlayPrompt(session).tools.map(({ name }) => name);
2059
+ const resolved = resolveToolName(call.name, provided);
2060
+ // The executed name, not the model's spelling, decides replay identity.
2061
+ const executedName = resolved.kind === "rejected" ? call.name : resolved.name;
2046
2062
  const completed = working.get(key);
2047
2063
  let result;
2048
2064
  let replayed = false;
2049
2065
  if (completed !== undefined) {
2050
2066
  replayed = true;
2051
2067
  result =
2052
- completed.name === call.name && completed.signature === signature
2068
+ completed.name === executedName && completed.signature === signature
2053
2069
  ? structuredClone(completed.result)
2054
2070
  : {
2055
2071
  ok: false,
@@ -2057,19 +2073,19 @@ function prepareTool(session, working, exchange, call, locale, resolutionSeed) {
2057
2073
  markdown: "# Runtime tool rejected\n\nThe same tool-call ID is already bound to another tool name or argument set.",
2058
2074
  };
2059
2075
  }
2060
- else if (!callChainToolNames.has(call.name) ||
2061
- !currentPlayPrompt(session).tools.some(({ name }) => name === call.name)) {
2076
+ else if (resolved.kind === "rejected" ||
2077
+ !callChainToolNames.has(executedName)) {
2062
2078
  result = {
2063
2079
  ok: false,
2064
2080
  failureKind: "protocol",
2065
- markdown: `# Runtime tool rejected\n\nThe current call chain does not provide ${call.name}.`,
2081
+ markdown: `# Runtime tool rejected\n\nThe current call chain does not provide ${call.name}. Available tools: ${provided.join(", ")}.`,
2066
2082
  };
2067
2083
  }
2068
2084
  else {
2069
2085
  try {
2070
- result = isResolutionTool(call.name)
2071
- ? resolutionToolResult(call.name, call.arguments, resolutionSeed, key, locale)
2072
- : call.name === "world_checkpoint"
2086
+ result = isResolutionTool(executedName)
2087
+ ? resolutionToolResult(executedName, call.arguments, resolutionSeed, key, locale)
2088
+ : executedName === "world_checkpoint"
2073
2089
  ? typeof call.arguments === "object" &&
2074
2090
  call.arguments !== null &&
2075
2091
  !Array.isArray(call.arguments) &&
@@ -2085,7 +2101,7 @@ function prepareTool(session, working, exchange, call, locale, resolutionSeed) {
2085
2101
  failureKind: "protocol",
2086
2102
  markdown: "world_checkpoint requires an empty argument object.",
2087
2103
  }
2088
- : session.documents.execute(call, session.history, {
2104
+ : session.documents.execute({ ...call, name: executedName }, session.history, {
2089
2105
  historyScope: session.worldId,
2090
2106
  locale,
2091
2107
  legacyHistoryTools: hasLegacyHistoryTools(currentPlayPrompt(session).tools),
@@ -2098,9 +2114,14 @@ function prepareTool(session, working, exchange, call, locale, resolutionSeed) {
2098
2114
  markdown: `# Runtime tool failed\n\n${error instanceof Error ? error.message : "Tool execution failed."}`,
2099
2115
  };
2100
2116
  }
2117
+ if (resolved.kind === "alias")
2118
+ result = {
2119
+ ...result,
2120
+ markdown: `${result.markdown}\n\n${renderToolAliasNote(call.name, executedName, locale)}`,
2121
+ };
2101
2122
  working.set(key, {
2102
2123
  key,
2103
- name: call.name,
2124
+ name: executedName,
2104
2125
  signature,
2105
2126
  result: structuredClone(result),
2106
2127
  });
@@ -2110,6 +2131,7 @@ function prepareTool(session, working, exchange, call, locale, resolutionSeed) {
2110
2131
  kind: "tool_result",
2111
2132
  callId: call.id,
2112
2133
  name: call.name,
2134
+ ...(executedName === call.name ? {} : { executedName }),
2113
2135
  ok: result.ok,
2114
2136
  markdown: result.markdown,
2115
2137
  ...(result.resolution === undefined
@@ -2240,7 +2262,8 @@ function projectContextReads(session) {
2240
2262
  exchange = event.exchange;
2241
2263
  continue;
2242
2264
  }
2243
- if (event.kind === "tool_call" && event.name === "context_read") {
2265
+ if (event.kind === "tool_call" &&
2266
+ (event.executedName ?? event.name) === "context_read") {
2244
2267
  const ref = isRecord(event.arguments) && typeof event.arguments.ref === "string"
2245
2268
  ? event.arguments.ref
2246
2269
  : "(invalid ref)";
@@ -2250,7 +2273,8 @@ function projectContextReads(session) {
2250
2273
  });
2251
2274
  continue;
2252
2275
  }
2253
- if (event.kind !== "tool_result" || event.name !== "context_read")
2276
+ if (event.kind !== "tool_result" ||
2277
+ (event.executedName ?? event.name) !== "context_read")
2254
2278
  continue;
2255
2279
  const call = calls.get(event.callId);
2256
2280
  if (call === undefined)
@@ -2354,6 +2378,28 @@ function validatePlayerText(value) {
2354
2378
  if (value.trim().length === 0)
2355
2379
  throw new PlayCallChainError("Player text must not be empty.");
2356
2380
  }
2381
+ /**
2382
+ * The player's one-turn request: one line, bounded like a turn-guide line.
2383
+ * Blank input means no request. Length beyond the soft limit is only warned
2384
+ * about by the client and Prompt Preview, never rejected here.
2385
+ */
2386
+ function validTurnNote(value) {
2387
+ if (value === undefined)
2388
+ return undefined;
2389
+ if (!isTurnGuideLine(value))
2390
+ throw new PlayCallChainError("The turn request must be a single line of at most 1000 characters.");
2391
+ const trimmed = value.trim();
2392
+ return trimmed.length === 0 ? undefined : trimmed;
2393
+ }
2394
+ function setTurnNote(session, turnNote) {
2395
+ if (turnNote === undefined)
2396
+ delete session.turnNote;
2397
+ else
2398
+ session.turnNote = turnNote;
2399
+ }
2400
+ function turnNoteField(turnNote) {
2401
+ return turnNote === undefined ? {} : { turnNote };
2402
+ }
2357
2403
  function touch(session) {
2358
2404
  session.updatedAt = Date.now();
2359
2405
  }