narraeon 0.7.5 → 0.7.6

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
+ }
@@ -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,11 +4,14 @@ 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
16
  import { renderFreshContextCoverage } from "../prompt/WorldMaterialCoverage.js";
14
17
  import { renderWriteSizeAdvice, readDeclaredWorldClock, } from "../prompt/WorldMaintenanceReport.js";
@@ -103,6 +106,7 @@ export class PlayCallChain {
103
106
  validateIdentity(input.chainId, "call-chain ID");
104
107
  validateIdentity(input.exchangeId, "exchange ID");
105
108
  validatePlayerText(input.playerText);
109
+ validTurnNote(input.turnNote);
106
110
  assertModelHostBinding(input.modelBinding, input.modelHost);
107
111
  // Only a resubmission to the current context needs that context loaded.
108
112
  await this.#worlds.ensureCurrentStorage(input.worldId);
@@ -152,6 +156,7 @@ export class PlayCallChain {
152
156
  canRetry: false,
153
157
  bootstrap: fresh.bootstrap,
154
158
  tools: fresh.tools,
159
+ ...turnNoteField(validTurnNote(input.turnNote)),
155
160
  transcript: [],
156
161
  events: [],
157
162
  completedTools: [],
@@ -243,6 +248,9 @@ export class PlayCallChain {
243
248
  return this.#dispatch(session, input.modelHost, structuredClone(session.lastRequest), session.lastRequestAttempt + 1, signal, input.observer);
244
249
  }
245
250
  await this.#refreshPrompt(session, input);
251
+ // Each dispatch carries exactly the request the client sent with it; a
252
+ // continuation without one must not inherit the previous turn's line.
253
+ setTurnNote(session, validTurnNote(input.turnNote));
246
254
  session.exchange += 1;
247
255
  const last = session.transcript.at(-1);
248
256
  if (last?.kind === "assistant" &&
@@ -251,7 +259,7 @@ export class PlayCallChain {
251
259
  session.narrativeOrigin = await this.#captureOrigin(session);
252
260
  session.transcript.push(continuationNotice(this.#compiler.locale));
253
261
  }
254
- return this.#dispatch(session, input.modelHost, createRequest(session, input.modelHost, session.exchange), 1, signal, input.observer);
262
+ return this.#dispatch(session, input.modelHost, createRequest(session, input.modelHost, session.exchange, this.#compiler.locale), 1, signal, input.observer);
255
263
  }
256
264
  if (duplicatePlayerExchange(session, input.exchangeId, input.playerText, "append"))
257
265
  return projectView(session);
@@ -260,6 +268,7 @@ export class PlayCallChain {
260
268
  ? "The call chain has an incomplete model response; clear the input and append a continuation, or use a fresh context."
261
269
  : "The call chain was interrupted; use a fresh context.");
262
270
  await this.#refreshPrompt(session, input);
271
+ setTurnNote(session, validTurnNote(input.turnNote));
263
272
  return this.#submitPlayer(session, input.exchangeId, input.playerText, "append", input.modelHost, signal, input.observer);
264
273
  }
265
274
  async #refreshPrompt(session, input) {
@@ -746,6 +755,7 @@ export class PlayCallChain {
746
755
  eventId: input.eventId,
747
756
  replacementExchangeId: input.replacementExchangeId,
748
757
  replacementText: input.replacementText,
758
+ ...turnNoteField(validTurnNote(input.turnNote)),
749
759
  continuation: input.continuation,
750
760
  restoresHead: playerRevisionRestoresHead(sourceContext, eventIndex),
751
761
  discardChainIds: contexts
@@ -854,6 +864,7 @@ export class PlayCallChain {
854
864
  previousContexts: [],
855
865
  previousChainId,
856
866
  timelineGeneration,
867
+ ...turnNoteField(intent.turnNote),
857
868
  parentHead: head,
858
869
  updatedAt: Date.now(),
859
870
  };
@@ -974,6 +985,7 @@ export class PlayCallChain {
974
985
  revision: input.freshContext.playPreset.revision,
975
986
  },
976
987
  followups: structuredClone(fresh.followups),
988
+ ...turnNoteField(input.intent.turnNote),
977
989
  playPresetScriptsEnabled: input.freshContext.playPreset.scriptsEnabled,
978
990
  presetFiles: structuredClone(input.freshContext.playPreset.files),
979
991
  modelBinding: structuredClone(input.freshContext.modelBinding),
@@ -1141,10 +1153,7 @@ export class PlayCallChain {
1141
1153
  throw new PlayCallChainError("The call chain is waiting for the model to return.");
1142
1154
  session.narrativeOrigin = await this.#captureOrigin(session);
1143
1155
  const exchange = session.exchange + 1;
1144
- const nextRequest = createRequest(session, modelHost, exchange, [
1145
- ...session.transcript,
1146
- ...playerInputAppend(playerText),
1147
- ]);
1156
+ const nextRequest = createRequest(session, modelHost, exchange, this.#compiler.locale, [...session.transcript, ...playerInputAppend(playerText)]);
1148
1157
  const operationKey = `player:${exchangeId}`;
1149
1158
  const playerOperationId = operationId(session.chainId, operationKey);
1150
1159
  const advance = {
@@ -1276,7 +1285,11 @@ export class PlayCallChain {
1276
1285
  let nextEventId = advance.eventId + 1;
1277
1286
  // Fresh in-memory seed per settlement. It is never persisted: a crashed
1278
1287
  // exchange is retried by the player and re-rolls (ADR-0044).
1279
- const resolutionSeed = calls.some(({ name }) => isResolutionTool(name))
1288
+ const providedToolNames = currentPlayPrompt(session).tools.map(({ name }) => name);
1289
+ const resolutionSeed = calls.some(({ name }) => {
1290
+ const resolved = resolveToolName(name, providedToolNames);
1291
+ return resolved.kind !== "rejected" && isResolutionTool(resolved.name);
1292
+ })
1280
1293
  ? randomBytes(32).toString("hex")
1281
1294
  : undefined;
1282
1295
  for (const call of calls) {
@@ -1287,6 +1300,9 @@ export class PlayCallChain {
1287
1300
  kind: "tool_call",
1288
1301
  callId: call.id,
1289
1302
  name: call.name,
1303
+ ...(item.event.executedName === undefined
1304
+ ? {}
1305
+ : { executedName: item.event.executedName }),
1290
1306
  arguments: structuredClone(call.arguments),
1291
1307
  replayed: item.replayed,
1292
1308
  });
@@ -1348,7 +1364,7 @@ export class PlayCallChain {
1348
1364
  session.events.some((event) => event.id >
1349
1365
  Math.max(previousNarrative?.id ?? 0, previousPlayer?.id ?? 0) &&
1350
1366
  event.kind === "tool_result" &&
1351
- event.name === "world_checkpoint" &&
1367
+ (event.executedName ?? event.name) === "world_checkpoint" &&
1352
1368
  event.ok);
1353
1369
  const narrativeCheckpoint = checkpointRequested
1354
1370
  ? {
@@ -1475,7 +1491,7 @@ export class PlayCallChain {
1475
1491
  }
1476
1492
  if (hasToolCalls) {
1477
1493
  session.exchange += 1;
1478
- const nextRequest = createRequestWithMaxOutputTokens(session, session.lastRequest?.maxOutputTokens ?? 1, session.exchange);
1494
+ const nextRequest = createRequestWithMaxOutputTokens(session, session.lastRequest?.maxOutputTokens ?? 1, session.exchange, this.#compiler.locale);
1479
1495
  if (session.lastRequest?.modelConfig !== undefined)
1480
1496
  nextRequest.modelConfig = structuredClone(session.lastRequest.modelConfig);
1481
1497
  await this.#advances.end(session, {
@@ -1942,14 +1958,16 @@ export class PlayCallChain {
1942
1958
  function followupOperationId(session) {
1943
1959
  return `${session.chainId}:followup:${session.parentHead}`;
1944
1960
  }
1945
- function createRequest(session, modelHost, exchange, appended = session.transcript) {
1946
- const request = createRequestWithMaxOutputTokens(session, modelHost.binding().maxOutputTokens, exchange, appended);
1961
+ function createRequest(session, modelHost, exchange, locale, appended = session.transcript) {
1962
+ const request = createRequestWithMaxOutputTokens(session, modelHost.binding().maxOutputTokens, exchange, locale, appended);
1947
1963
  return modelHost.freezeRequest?.(request) ?? request;
1948
1964
  }
1949
- function createRequestWithMaxOutputTokens(session, maxOutputTokens, exchange, appended = session.transcript) {
1965
+ function createRequestWithMaxOutputTokens(session, maxOutputTokens, exchange, locale, appended = session.transcript) {
1950
1966
  // 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;
1967
+ // dispatch appends and is never part of the transcript it is built on. The
1968
+ // player's one-turn request joins the suffix here, not in the bootstrap, so
1969
+ // the bootstrap snapshot stays shared by every dispatch of the turn.
1970
+ const turnGuide = turnGuideWithTurnNote(locale, currentPlayPrompt(session).bootstrap.turnGuide, session.turnNote);
1953
1971
  return {
1954
1972
  bootstrap: structuredClone(currentPlayPrompt(session).bootstrap),
1955
1973
  tools: structuredClone(currentPlayPrompt(session).tools),
@@ -2043,13 +2061,17 @@ function appendCommittedHistory(session, head, messages) {
2043
2061
  function prepareTool(session, working, exchange, call, locale, resolutionSeed) {
2044
2062
  const key = `${exchange}:${call.id}`;
2045
2063
  const signature = JSON.stringify(call.arguments);
2064
+ const provided = currentPlayPrompt(session).tools.map(({ name }) => name);
2065
+ const resolved = resolveToolName(call.name, provided);
2066
+ // The executed name, not the model's spelling, decides replay identity.
2067
+ const executedName = resolved.kind === "rejected" ? call.name : resolved.name;
2046
2068
  const completed = working.get(key);
2047
2069
  let result;
2048
2070
  let replayed = false;
2049
2071
  if (completed !== undefined) {
2050
2072
  replayed = true;
2051
2073
  result =
2052
- completed.name === call.name && completed.signature === signature
2074
+ completed.name === executedName && completed.signature === signature
2053
2075
  ? structuredClone(completed.result)
2054
2076
  : {
2055
2077
  ok: false,
@@ -2057,19 +2079,19 @@ function prepareTool(session, working, exchange, call, locale, resolutionSeed) {
2057
2079
  markdown: "# Runtime tool rejected\n\nThe same tool-call ID is already bound to another tool name or argument set.",
2058
2080
  };
2059
2081
  }
2060
- else if (!callChainToolNames.has(call.name) ||
2061
- !currentPlayPrompt(session).tools.some(({ name }) => name === call.name)) {
2082
+ else if (resolved.kind === "rejected" ||
2083
+ !callChainToolNames.has(executedName)) {
2062
2084
  result = {
2063
2085
  ok: false,
2064
2086
  failureKind: "protocol",
2065
- markdown: `# Runtime tool rejected\n\nThe current call chain does not provide ${call.name}.`,
2087
+ markdown: `# Runtime tool rejected\n\nThe current call chain does not provide ${call.name}. Available tools: ${provided.join(", ")}.`,
2066
2088
  };
2067
2089
  }
2068
2090
  else {
2069
2091
  try {
2070
- result = isResolutionTool(call.name)
2071
- ? resolutionToolResult(call.name, call.arguments, resolutionSeed, key, locale)
2072
- : call.name === "world_checkpoint"
2092
+ result = isResolutionTool(executedName)
2093
+ ? resolutionToolResult(executedName, call.arguments, resolutionSeed, key, locale)
2094
+ : executedName === "world_checkpoint"
2073
2095
  ? typeof call.arguments === "object" &&
2074
2096
  call.arguments !== null &&
2075
2097
  !Array.isArray(call.arguments) &&
@@ -2085,7 +2107,7 @@ function prepareTool(session, working, exchange, call, locale, resolutionSeed) {
2085
2107
  failureKind: "protocol",
2086
2108
  markdown: "world_checkpoint requires an empty argument object.",
2087
2109
  }
2088
- : session.documents.execute(call, session.history, {
2110
+ : session.documents.execute({ ...call, name: executedName }, session.history, {
2089
2111
  historyScope: session.worldId,
2090
2112
  locale,
2091
2113
  legacyHistoryTools: hasLegacyHistoryTools(currentPlayPrompt(session).tools),
@@ -2098,9 +2120,14 @@ function prepareTool(session, working, exchange, call, locale, resolutionSeed) {
2098
2120
  markdown: `# Runtime tool failed\n\n${error instanceof Error ? error.message : "Tool execution failed."}`,
2099
2121
  };
2100
2122
  }
2123
+ if (resolved.kind === "alias")
2124
+ result = {
2125
+ ...result,
2126
+ markdown: `${result.markdown}\n\n${renderToolAliasNote(call.name, executedName, locale)}`,
2127
+ };
2101
2128
  working.set(key, {
2102
2129
  key,
2103
- name: call.name,
2130
+ name: executedName,
2104
2131
  signature,
2105
2132
  result: structuredClone(result),
2106
2133
  });
@@ -2110,6 +2137,7 @@ function prepareTool(session, working, exchange, call, locale, resolutionSeed) {
2110
2137
  kind: "tool_result",
2111
2138
  callId: call.id,
2112
2139
  name: call.name,
2140
+ ...(executedName === call.name ? {} : { executedName }),
2113
2141
  ok: result.ok,
2114
2142
  markdown: result.markdown,
2115
2143
  ...(result.resolution === undefined
@@ -2240,7 +2268,8 @@ function projectContextReads(session) {
2240
2268
  exchange = event.exchange;
2241
2269
  continue;
2242
2270
  }
2243
- if (event.kind === "tool_call" && event.name === "context_read") {
2271
+ if (event.kind === "tool_call" &&
2272
+ (event.executedName ?? event.name) === "context_read") {
2244
2273
  const ref = isRecord(event.arguments) && typeof event.arguments.ref === "string"
2245
2274
  ? event.arguments.ref
2246
2275
  : "(invalid ref)";
@@ -2250,7 +2279,8 @@ function projectContextReads(session) {
2250
2279
  });
2251
2280
  continue;
2252
2281
  }
2253
- if (event.kind !== "tool_result" || event.name !== "context_read")
2282
+ if (event.kind !== "tool_result" ||
2283
+ (event.executedName ?? event.name) !== "context_read")
2254
2284
  continue;
2255
2285
  const call = calls.get(event.callId);
2256
2286
  if (call === undefined)
@@ -2354,6 +2384,28 @@ function validatePlayerText(value) {
2354
2384
  if (value.trim().length === 0)
2355
2385
  throw new PlayCallChainError("Player text must not be empty.");
2356
2386
  }
2387
+ /**
2388
+ * The player's one-turn request: one line, bounded like a turn-guide line.
2389
+ * Blank input means no request. Length beyond the soft limit is only warned
2390
+ * about by the client and Prompt Preview, never rejected here.
2391
+ */
2392
+ function validTurnNote(value) {
2393
+ if (value === undefined)
2394
+ return undefined;
2395
+ if (!isTurnGuideLine(value))
2396
+ throw new PlayCallChainError("The turn request must be a single line of at most 1000 characters.");
2397
+ const trimmed = value.trim();
2398
+ return trimmed.length === 0 ? undefined : trimmed;
2399
+ }
2400
+ function setTurnNote(session, turnNote) {
2401
+ if (turnNote === undefined)
2402
+ delete session.turnNote;
2403
+ else
2404
+ session.turnNote = turnNote;
2405
+ }
2406
+ function turnNoteField(turnNote) {
2407
+ return turnNote === undefined ? {} : { turnNote };
2408
+ }
2357
2409
  function touch(session) {
2358
2410
  session.updatedAt = Date.now();
2359
2411
  }
@@ -5,6 +5,8 @@ import { stateDirectoryHandle, parseStateDirectoryHandle, } from "../world/State
5
5
  import { renderDocumentWritePosition } from "../prompt/WorldMaintenanceReport.js";
6
6
  import { stringify } from "yaml";
7
7
  import { renderPlayerViewBindings } from "../world/PlayerViewBindings.js";
8
+ import { coerceToolArguments, renderCoercionNote, } from "../model/ToolArgumentCoercion.js";
9
+ import { runtimeToolInputSchema } from "../prompt/FileNativeToolRegistry.js";
8
10
  import { PromptCompilationError, } from "../prompt/FileNativePromptCompiler.js";
9
11
  import {} from "../world/WorldDocumentStore.js";
10
12
  import { acceptWorldStateRevision, beginWorldStateRevision, fileNativeStateChanges, worldDocumentRevisionFailureMessage, } from "../world/WorldStateRevision.js";
@@ -76,6 +78,19 @@ export class FileNativePlayDocuments {
76
78
  return authorizeResolvedRead(this.#reads, this.#candidate.snapshot, shortRef, locator);
77
79
  }
78
80
  execute(call, history, options = {}) {
81
+ const schema = runtimeToolInputSchema(call.name);
82
+ if (schema === null)
83
+ return this.#dispatch(call, history, options);
84
+ const repaired = coerceToolArguments(schema, call.arguments);
85
+ const result = this.#dispatch({ ...call, arguments: repaired.arguments }, history, options);
86
+ if (repaired.coerced.length === 0 || result.markdown === undefined)
87
+ return result;
88
+ return {
89
+ ...result,
90
+ markdown: `${result.markdown}\n\n${renderCoercionNote(repaired.coerced, options.locale ?? "en")}`,
91
+ };
92
+ }
93
+ #dispatch(call, history, options) {
79
94
  let historyQuery;
80
95
  const query = () => (historyQuery ??= new HistoryQuery(options.historyScope ?? "local-document-context", historyInputs(history)));
81
96
  const render = (result) => renderHistoryResult(result, options.locale ?? "en");
@@ -232,6 +247,8 @@ function documentDescriptorByRef(snapshot, ref) {
232
247
  return result.kind === "read_document" && result.ok ? result.document : null;
233
248
  }
234
249
  function applyPlayCandidatePatch(candidate, reads, args) {
250
+ if (record(args) && typeof args.edits === "string")
251
+ return toolFailure("world_patch.edits must be a JSON array of 1 to 32 edits, not a string.");
235
252
  if (!record(args) ||
236
253
  typeof args.target !== "string" ||
237
254
  !Array.isArray(args.edits) ||
@@ -419,6 +436,8 @@ function applyPlayCandidateCreate(candidate, authorizations, args, declaredDirec
419
436
  const parentDirectory = record(args) && typeof args.parent === "string"
420
437
  ? parseStateDirectoryHandle(args.parent)
421
438
  : null;
439
+ if (record(args) && typeof args.aliases === "string")
440
+ throw new PlayDocumentToolFailure("world_create.aliases must be a JSON array of strings, not a string");
422
441
  if (!record(args) ||
423
442
  typeof args.parent !== "string" ||
424
443
  parentDirectory === null ||
@@ -6,7 +6,7 @@ import { effectiveFollowupDefinitions } from "../play/OrderedFollowups.js";
6
6
  import { legacyAuthorPrompts } from "../play/FileNativePlayPresetStore.js";
7
7
  import { builtinAuthorPrompts, authoringMechanics, } from "../../shared/ordered-author-prompts.js";
8
8
  import { builtinPlayPrompts, orderedPromptScope, orderedPromptTurnGuide, promptScopeApplies, } from "../../shared/ordered-play-prompts.js";
9
- import { turnGuideHeader, turnGuideText, } from "../../shared/turn-guide.js";
9
+ import { turnGuideHeader, turnGuideText, turnGuideWithTurnNote, } from "../../shared/turn-guide.js";
10
10
  import { parseOrderedPlayPrompts } from "../play/OrderedPlayPrompts.js";
11
11
  import { renderDocumentWritePosition } from "./WorldMaintenanceReport.js";
12
12
  import { parseDocument } from "yaml";
@@ -568,12 +568,12 @@ export class FileNativePromptCompiler {
568
568
  append("player_input", [
569
569
  { source: "player:input", markdown: effective.playerInput },
570
570
  ]);
571
- const turnGuide = turnGuideLines.length === 0
571
+ const turnGuide = turnGuideWithTurnNote(this.#locale, turnGuideLines.length === 0
572
572
  ? undefined
573
573
  : {
574
574
  text: turnGuideText(this.#locale, turnGuideLines),
575
575
  lines: turnGuideLines,
576
- };
576
+ }, effective.turnNote);
577
577
  scanRuntimeLeakage([
578
578
  ...logicalMessages,
579
579
  ...(turnGuide === undefined
@@ -11,6 +11,11 @@ import { resolutionCheckToolForScheme, resolutionToolDefinition, resolutionToolN
11
11
  * Tools that every current play and authoring surface may expose. Resolution
12
12
  * tools are registered below but never enter a surface until a per-world
13
13
  * selection (Phase 2) includes them.
14
+ *
15
+ * No declared name may end in `_<another declared name>`: a provider-mangled
16
+ * `mcp__<server>__<word>_<name>` is restored by that suffix (see
17
+ * ToolNameAlias.ts), so such a pair would make the restoration ambiguous.
18
+ * tests/runtime/tool-name-alias.test.ts enforces this across all surfaces.
14
19
  */
15
20
  export const baseRuntimeToolNames = [
16
21
  "state_list",
@@ -70,6 +75,16 @@ export function runtimeToolsForNames(names, locale = defaultAppLocale) {
70
75
  inputSchema: toolDefinitions[name].inputSchema,
71
76
  }));
72
77
  }
78
+ /**
79
+ * The original Runtime input schema of a registered tool. Argument repair reads
80
+ * this, not the portable wire projection, so every array or object position is
81
+ * exactly what Runtime validates.
82
+ */
83
+ export function runtimeToolInputSchema(name, locale = defaultAppLocale) {
84
+ if (!isRegisteredRuntimeToolName(name))
85
+ return null;
86
+ return createToolDefinitions(locale)[name].inputSchema;
87
+ }
73
88
  /**
74
89
  * Return a Provider-portable root-object schema for a Runtime-owned tool.
75
90
  *
@@ -0,0 +1,14 @@
1
+ export function resolveToolName(called, provided) {
2
+ if (provided.includes(called))
3
+ return { kind: "exact", name: called };
4
+ const candidates = provided.filter((name) => called.length > name.length && called.endsWith(`_${name}`));
5
+ if (candidates.length === 1)
6
+ return { kind: "alias", name: candidates[0], declared: called };
7
+ return { kind: "rejected", candidates };
8
+ }
9
+ /** The Runtime note appended to a result whose call was restored by alias. */
10
+ export function renderToolAliasNote(declared, name, locale) {
11
+ return locale === "zh-CN"
12
+ ? `Runtime 说明:"${declared}" 不是本次提供的工具,已按 "${name}" 执行。请使用声明的工具名。`
13
+ : `Runtime note: "${declared}" is not a declared tool; it was executed as "${name}". Call tools by their declared names.`;
14
+ }