@t2000/engine 0.46.7 → 0.46.9

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/index.d.ts CHANGED
@@ -888,6 +888,8 @@ declare class QueryEngine {
888
888
  private messages;
889
889
  private abortController;
890
890
  private guardEvents;
891
+ private readonly turnReadCache;
892
+ private turnPaused;
891
893
  constructor(config: EngineConfig);
892
894
  /**
893
895
  * Submit a user message and stream engine events.
@@ -1241,6 +1243,89 @@ interface MicrocompactResult extends Array<Message> {
1241
1243
  */
1242
1244
  declare function microcompact(messages: readonly Message[], tools?: readonly Tool[]): MicrocompactResult;
1243
1245
 
1246
+ /**
1247
+ * [v0.46.8] Intra-turn deduplication of read-only tool calls.
1248
+ *
1249
+ * # Problem
1250
+ * Two independent execution paths can call the same read-only tool within
1251
+ * the same user turn:
1252
+ * 1. Host pre-dispatch via `engine.invokeReadTool()` (deterministic — runs
1253
+ * before the LLM ever sees the message; injects a synthetic
1254
+ * `tool_use`+`tool_result` pair into the ledger so the card renders
1255
+ * immediately and the LLM has the data).
1256
+ * 2. The LLM itself, mid-turn, emitting a `tool_use` block for the same
1257
+ * tool (often because the prompt says "always call balance_check on
1258
+ * direct read questions" and the model doesn't trust the synthetic
1259
+ * pair).
1260
+ *
1261
+ * Both paths emit a `tool_result` SSE event, the host renders BOTH cards,
1262
+ * the user sees a duplicate. Coordinating these two paths via prompt rules
1263
+ * is probabilistic ("DO NOT re-call when you see a synthetic pair") and
1264
+ * has empirically shown ~30% miss rate — the LLM still re-calls anyway.
1265
+ *
1266
+ * # Fix
1267
+ * Idempotent intra-turn cache. Within one user turn:
1268
+ * - Calling the same read-only tool with the same args twice returns the
1269
+ * cached result on the second call.
1270
+ * - The second call yields a `tool_result` event with `resultDeduped:true`
1271
+ * so hosts can skip rendering a duplicate card while the LLM still gets
1272
+ * the data it needs to satisfy its `tool_use` id.
1273
+ *
1274
+ * # Lifecycle
1275
+ * - Cache lives on the `QueryEngine` instance.
1276
+ * - Populated by `invokeReadTool` (host pre-dispatch) AND by the agent
1277
+ * loop's tool-execution path (LLM-driven calls).
1278
+ * - Cleared on `turn_complete` (clean slate for the next user turn).
1279
+ * - Cleared whenever a WRITE tool executes successfully (writes mutate
1280
+ * on-chain state, so any subsequent read in the same turn must re-fetch
1281
+ * for freshness).
1282
+ * - Cleared on errors / abort (defensive cleanup).
1283
+ *
1284
+ * # Why not just extend microcompact?
1285
+ * `microcompact` does CROSS-turn dedup, but explicitly excludes
1286
+ * `cacheable: false` tools (balance_check, health_check, savings_info,
1287
+ * transaction_history) so post-write refreshes always surface fresh data.
1288
+ * Within a single turn (pre-write), those same tools are perfectly
1289
+ * dedup-able — state can't change. This cache fills that exact gap.
1290
+ *
1291
+ * # Invariants
1292
+ * - Read-only tools only. Write tools never enter the cache.
1293
+ * - Errored results are NEVER cached (the next call should retry).
1294
+ * - Cache key includes the full input, stably stringified — different
1295
+ * filter args (e.g. `transaction_history({minUsd:5})` vs
1296
+ * `transaction_history({})`) hit different cache entries.
1297
+ */
1298
+ declare class TurnReadCache {
1299
+ private readonly store;
1300
+ /**
1301
+ * Build the cache key for a (toolName, input) pair. Stable across object
1302
+ * key ordering so `{a:1,b:2}` and `{b:2,a:1}` map to the same entry.
1303
+ */
1304
+ static keyFor(toolName: string, input: unknown): string;
1305
+ has(key: string): boolean;
1306
+ get(key: string): {
1307
+ result: unknown;
1308
+ sourceToolUseId: string;
1309
+ } | undefined;
1310
+ /**
1311
+ * Populate the cache. Caller is responsible for ensuring the result was
1312
+ * a successful read (no errors). Overwrites any prior entry for the same
1313
+ * key — the most recent successful read wins, which is correct under our
1314
+ * "writes invalidate the whole cache" invariant.
1315
+ */
1316
+ set(key: string, value: {
1317
+ result: unknown;
1318
+ sourceToolUseId: string;
1319
+ }): void;
1320
+ /**
1321
+ * Drop every entry. Called at turn end and after every successful write.
1322
+ * Cheap and intentional — the cache is small (a handful of entries per
1323
+ * turn at most) and clearing is the correct response to any state mutation.
1324
+ */
1325
+ clear(): void;
1326
+ size(): number;
1327
+ }
1328
+
1244
1329
  /**
1245
1330
  * EarlyToolDispatcher — dispatches read-only tools mid-stream.
1246
1331
  *
@@ -1257,11 +1342,21 @@ declare class EarlyToolDispatcher {
1257
1342
  private entries;
1258
1343
  private readonly tools;
1259
1344
  private readonly context;
1345
+ private readonly turnReadCache;
1260
1346
  private abortController;
1261
- constructor(tools: Tool[], context: ToolContext);
1347
+ constructor(tools: Tool[], context: ToolContext, turnReadCache?: TurnReadCache);
1262
1348
  /**
1263
1349
  * Attempt to dispatch a tool call. Returns true if the tool was dispatched
1264
1350
  * (read-only + concurrency-safe), false if it should be queued for later.
1351
+ *
1352
+ * [v0.46.8] Cache-aware: if a `TurnReadCache` was supplied at
1353
+ * construction and a prior call this turn already produced a result
1354
+ * for the same `(toolName, input)`, the dispatcher returns true (the
1355
+ * call IS handled here, not queued for the post-stream loop) but
1356
+ * skips the tool execution entirely — `collectResults` will surface
1357
+ * the cached value with `resultDeduped: true`. On a cache miss for
1358
+ * a successful real execution, the result is written back to the
1359
+ * cache so any later call within the same turn dedups too.
1265
1360
  */
1266
1361
  tryDispatch(call: PendingToolCall): boolean;
1267
1362
  /** True if any tools have been dispatched. */
@@ -1662,7 +1757,7 @@ declare const balanceCheckTool: Tool<{}, {
1662
1757
  declare const savingsInfoTool: Tool<{}, SavingsResult>;
1663
1758
 
1664
1759
  declare const healthCheckTool: Tool<{}, {
1665
- healthFactor: number;
1760
+ healthFactor: number | null;
1666
1761
  supplied: number;
1667
1762
  borrowed: number;
1668
1763
  maxBorrow: number;
package/dist/index.js CHANGED
@@ -915,15 +915,28 @@ var savingsInfoTool = buildTool({
915
915
  return { data: result, displayText: formatSavingsDisplay(result) };
916
916
  }
917
917
  });
918
- function hfStatus(hf) {
918
+ var DEBT_DUST_USD = 0.01;
919
+ function hfStatus(hf, borrowed) {
920
+ if (borrowed <= DEBT_DUST_USD) return "healthy";
919
921
  if (hf >= 2) return "healthy";
920
922
  if (hf >= 1.5) return "moderate";
921
923
  if (hf >= 1.2) return "warning";
922
924
  return "critical";
923
925
  }
926
+ function serializeHf(hf, borrowed) {
927
+ if (borrowed <= DEBT_DUST_USD) return null;
928
+ if (hf == null || !Number.isFinite(hf)) return null;
929
+ return hf;
930
+ }
931
+ function displayHfText(hf, borrowed, status) {
932
+ if (hf == null) {
933
+ return `Health Factor: \u221E (${status} \u2014 no debt)`;
934
+ }
935
+ return `Health Factor: ${hf.toFixed(2)} (${status})`;
936
+ }
924
937
  var healthCheckTool = buildTool({
925
938
  name: "health_check",
926
- description: "Check the lending health factor: current HF ratio, total supplied collateral, total borrowed, max additional borrow capacity, and liquidation threshold. HF < 1.5 is risky, < 1.2 is critical.",
939
+ description: 'Check the lending health factor: current HF ratio, total supplied collateral, total borrowed, max additional borrow capacity, and liquidation threshold. HF < 1.5 is risky, < 1.2 is critical. When the user has no debt the tool returns healthFactor=null (semantically infinity) \u2014 render that as "Healthy" / \u221E, never as 0 or "Critical".',
927
940
  inputSchema: z.object({}),
928
941
  jsonSchema: { type: "object", properties: {}, required: [] },
929
942
  isReadOnly: true,
@@ -933,19 +946,20 @@ var healthCheckTool = buildTool({
933
946
  async call(_input, context) {
934
947
  if (context.positionFetcher && context.walletAddress) {
935
948
  const sp = await context.positionFetcher(context.walletAddress);
936
- const hfVal = sp.healthFactor ?? (sp.borrows > 0 ? 0 : Infinity);
937
- const status2 = hfStatus(hfVal);
938
- const displayHf = Number.isFinite(hfVal) ? hfVal.toFixed(2) : "\u221E";
949
+ const borrowed2 = sp.borrows;
950
+ const rawHf = sp.healthFactor ?? (borrowed2 > 0 ? 0 : Infinity);
951
+ const status2 = hfStatus(rawHf, borrowed2);
952
+ const transportHf2 = serializeHf(rawHf, borrowed2);
939
953
  return {
940
954
  data: {
941
- healthFactor: hfVal,
955
+ healthFactor: transportHf2,
942
956
  supplied: sp.savings,
943
- borrowed: sp.borrows,
957
+ borrowed: borrowed2,
944
958
  maxBorrow: sp.maxBorrow,
945
959
  liquidationThreshold: 0,
946
960
  status: status2
947
961
  },
948
- displayText: `Health Factor: ${displayHf} (${status2})`
962
+ displayText: displayHfText(transportHf2, borrowed2, status2)
949
963
  };
950
964
  }
951
965
  if (hasNaviMcp(context)) {
@@ -953,26 +967,29 @@ var healthCheckTool = buildTool({
953
967
  getMcpManager(context),
954
968
  getWalletAddress(context)
955
969
  );
956
- const status2 = hfStatus(hf2.healthFactor);
957
- const displayHf = Number.isFinite(hf2.healthFactor) ? hf2.healthFactor.toFixed(2) : "\u221E";
970
+ const borrowed2 = hf2.borrowed;
971
+ const status2 = hfStatus(hf2.healthFactor, borrowed2);
972
+ const transportHf2 = serializeHf(hf2.healthFactor, borrowed2);
958
973
  return {
959
- data: { ...hf2, status: status2 },
960
- displayText: `Health Factor: ${displayHf} (${status2})`
974
+ data: { ...hf2, healthFactor: transportHf2, status: status2 },
975
+ displayText: displayHfText(transportHf2, borrowed2, status2)
961
976
  };
962
977
  }
963
978
  const agent = requireAgent(context);
964
979
  const hf = await agent.healthFactor();
965
- const status = hfStatus(hf.healthFactor);
980
+ const borrowed = hf.borrowed;
981
+ const status = hfStatus(hf.healthFactor, borrowed);
982
+ const transportHf = serializeHf(hf.healthFactor, borrowed);
966
983
  return {
967
984
  data: {
968
- healthFactor: hf.healthFactor,
985
+ healthFactor: transportHf,
969
986
  supplied: hf.supplied,
970
- borrowed: hf.borrowed,
987
+ borrowed,
971
988
  maxBorrow: hf.maxBorrow,
972
989
  liquidationThreshold: hf.liquidationThreshold,
973
990
  status
974
991
  },
975
- displayText: `Health Factor: ${hf.healthFactor.toFixed(2)} (${status})`
992
+ displayText: displayHfText(transportHf, borrowed, status)
976
993
  };
977
994
  }
978
995
  });
@@ -1571,9 +1588,15 @@ var repayDebtTool = buildTool({
1571
1588
  };
1572
1589
  }
1573
1590
  });
1591
+ function formatAmount(amount) {
1592
+ if (!Number.isFinite(amount) || amount <= 0) return "0";
1593
+ if (amount >= 1) return amount.toFixed(4).replace(/\.?0+$/, "");
1594
+ if (amount >= 1e-4) return amount.toFixed(6).replace(/\.?0+$/, "");
1595
+ return amount.toExponential(2);
1596
+ }
1574
1597
  var claimRewardsTool = buildTool({
1575
1598
  name: "claim_rewards",
1576
- description: "Claim all pending protocol rewards across lending adapters. Returns claimed reward details and total USD value.",
1599
+ description: 'Claim all pending protocol rewards across lending adapters. Returns the claimed reward breakdown (per-asset symbol + amount), total USD value (best effort \u2014 may be 0 when oracle prices are unavailable), and the on-chain tx hash. When the rewards list is empty the response will explicitly say "no pending rewards"; when it is non-empty narrate the per-symbol amounts even if totalValueUsd is 0 (the on-chain credit still happened).',
1577
1600
  inputSchema: z.object({}),
1578
1601
  jsonSchema: { type: "object", properties: {}, required: [] },
1579
1602
  isReadOnly: false,
@@ -1582,15 +1605,36 @@ var claimRewardsTool = buildTool({
1582
1605
  async call(_input, context) {
1583
1606
  const agent = requireAgent(context);
1584
1607
  const result = await agent.claimRewards();
1608
+ const priceCache = context.priceCache;
1609
+ const enrichedRewards = result.rewards.map((r) => {
1610
+ if (r.estimatedValueUsd > 0) return r;
1611
+ const price = priceCache?.get(r.symbol.toUpperCase());
1612
+ if (!price || !Number.isFinite(price) || price <= 0) return r;
1613
+ return { ...r, estimatedValueUsd: r.amount * price };
1614
+ });
1615
+ const totalValueUsd = enrichedRewards.reduce(
1616
+ (s, r) => s + (Number.isFinite(r.estimatedValueUsd) ? r.estimatedValueUsd : 0),
1617
+ 0
1618
+ );
1619
+ const txShort = result.tx ? `${result.tx.slice(0, 8)}\u2026` : "";
1620
+ let displayText;
1621
+ if (enrichedRewards.length === 0) {
1622
+ displayText = "No pending rewards to claim.";
1623
+ } else {
1624
+ const breakdown = enrichedRewards.map((r) => `${formatAmount(r.amount)} ${r.symbol}`).join(", ");
1625
+ const usdSuffix = totalValueUsd > 0 ? ` (~$${totalValueUsd.toFixed(2)})` : "";
1626
+ const txSuffix = txShort ? ` (tx: ${txShort})` : "";
1627
+ displayText = `Claimed ${breakdown}${usdSuffix}${txSuffix}`;
1628
+ }
1585
1629
  return {
1586
1630
  data: {
1587
1631
  success: result.success,
1588
1632
  tx: result.tx || null,
1589
- rewards: result.rewards,
1590
- totalValueUsd: result.totalValueUsd,
1633
+ rewards: enrichedRewards,
1634
+ totalValueUsd,
1591
1635
  gasCost: result.gasCost
1592
1636
  },
1593
- displayText: result.rewards.length === 0 ? "No pending rewards to claim." : `Claimed $${result.totalValueUsd.toFixed(2)} in rewards (tx: ${result.tx.slice(0, 8)}\u2026)`
1637
+ displayText
1594
1638
  };
1595
1639
  }
1596
1640
  });
@@ -4257,27 +4301,116 @@ function safeNum(v) {
4257
4301
  return isNaN(n) ? 0 : n;
4258
4302
  }
4259
4303
 
4304
+ // src/turn-read-cache.ts
4305
+ var TurnReadCache = class {
4306
+ store = /* @__PURE__ */ new Map();
4307
+ /**
4308
+ * Build the cache key for a (toolName, input) pair. Stable across object
4309
+ * key ordering so `{a:1,b:2}` and `{b:2,a:1}` map to the same entry.
4310
+ */
4311
+ static keyFor(toolName, input) {
4312
+ return `${toolName}:${stableStringify2(input)}`;
4313
+ }
4314
+ has(key) {
4315
+ return this.store.has(key);
4316
+ }
4317
+ get(key) {
4318
+ return this.store.get(key);
4319
+ }
4320
+ /**
4321
+ * Populate the cache. Caller is responsible for ensuring the result was
4322
+ * a successful read (no errors). Overwrites any prior entry for the same
4323
+ * key — the most recent successful read wins, which is correct under our
4324
+ * "writes invalidate the whole cache" invariant.
4325
+ */
4326
+ set(key, value) {
4327
+ this.store.set(key, value);
4328
+ }
4329
+ /**
4330
+ * Drop every entry. Called at turn end and after every successful write.
4331
+ * Cheap and intentional — the cache is small (a handful of entries per
4332
+ * turn at most) and clearing is the correct response to any state mutation.
4333
+ */
4334
+ clear() {
4335
+ this.store.clear();
4336
+ }
4337
+ size() {
4338
+ return this.store.size;
4339
+ }
4340
+ };
4341
+ function stableStringify2(value) {
4342
+ if (value === null || value === void 0) return "";
4343
+ if (typeof value !== "object") return JSON.stringify(value);
4344
+ if (Array.isArray(value)) return JSON.stringify(value.map(stableStringifyForObject));
4345
+ return stableStringifyForObject(value);
4346
+ }
4347
+ function stableStringifyForObject(value) {
4348
+ if (value === null || value === void 0) return JSON.stringify(value);
4349
+ if (typeof value !== "object") return JSON.stringify(value);
4350
+ if (Array.isArray(value)) {
4351
+ return `[${value.map(stableStringifyForObject).join(",")}]`;
4352
+ }
4353
+ const sorted = Object.keys(value).sort();
4354
+ const parts = sorted.map(
4355
+ (k) => `${JSON.stringify(k)}:${stableStringifyForObject(value[k])}`
4356
+ );
4357
+ return `{${parts.join(",")}}`;
4358
+ }
4359
+
4260
4360
  // src/early-dispatcher.ts
4261
4361
  var EarlyToolDispatcher = class {
4262
4362
  entries = [];
4263
4363
  tools;
4264
4364
  context;
4365
+ turnReadCache;
4265
4366
  abortController;
4266
- constructor(tools, context) {
4367
+ constructor(tools, context, turnReadCache) {
4267
4368
  this.tools = tools;
4268
4369
  this.context = context;
4370
+ this.turnReadCache = turnReadCache;
4269
4371
  this.abortController = new AbortController();
4270
4372
  }
4271
4373
  /**
4272
4374
  * Attempt to dispatch a tool call. Returns true if the tool was dispatched
4273
4375
  * (read-only + concurrency-safe), false if it should be queued for later.
4376
+ *
4377
+ * [v0.46.8] Cache-aware: if a `TurnReadCache` was supplied at
4378
+ * construction and a prior call this turn already produced a result
4379
+ * for the same `(toolName, input)`, the dispatcher returns true (the
4380
+ * call IS handled here, not queued for the post-stream loop) but
4381
+ * skips the tool execution entirely — `collectResults` will surface
4382
+ * the cached value with `resultDeduped: true`. On a cache miss for
4383
+ * a successful real execution, the result is written back to the
4384
+ * cache so any later call within the same turn dedups too.
4274
4385
  */
4275
4386
  tryDispatch(call) {
4276
4387
  const tool = findTool(this.tools, call.name);
4277
4388
  if (!tool || !tool.isReadOnly || !tool.isConcurrencySafe) return false;
4389
+ if (this.turnReadCache) {
4390
+ const cacheKey = TurnReadCache.keyFor(call.name, call.input);
4391
+ const cached = this.turnReadCache.get(cacheKey);
4392
+ if (cached) {
4393
+ this.entries.push({
4394
+ call,
4395
+ tool,
4396
+ promise: Promise.resolve({ data: cached.result, isError: false }),
4397
+ deduped: true
4398
+ });
4399
+ return true;
4400
+ }
4401
+ }
4278
4402
  const childContext = { ...this.context, signal: this.abortController.signal };
4279
- const promise = executeTool(tool, call, childContext);
4280
- this.entries.push({ call, tool, promise });
4403
+ const promise = executeTool(tool, call, childContext).then((result) => {
4404
+ if (!result.isError && this.turnReadCache) {
4405
+ const cacheKey = TurnReadCache.keyFor(call.name, call.input);
4406
+ this.turnReadCache.set(cacheKey, {
4407
+ result: result.data,
4408
+ sourceToolUseId: call.id
4409
+ });
4410
+ }
4411
+ return result;
4412
+ });
4413
+ this.entries.push({ call, tool, promise, deduped: false });
4281
4414
  return true;
4282
4415
  }
4283
4416
  /** True if any tools have been dispatched. */
@@ -4303,7 +4436,8 @@ var EarlyToolDispatcher = class {
4303
4436
  toolUseId: entry.call.id,
4304
4437
  result: budgeted,
4305
4438
  isError: result.isError,
4306
- wasEarlyDispatched: true
4439
+ wasEarlyDispatched: true,
4440
+ ...entry.deduped ? { resultDeduped: true } : {}
4307
4441
  };
4308
4442
  } catch (err) {
4309
4443
  yield {
@@ -4375,6 +4509,18 @@ var QueryEngine = class {
4375
4509
  messages = [];
4376
4510
  abortController = null;
4377
4511
  guardEvents = [];
4512
+ // [v0.46.8] Intra-turn dedup cache for read-only tool calls. See
4513
+ // `turn-read-cache.ts` for the full lifecycle. Key takeaway: the cache
4514
+ // lives across the host's pre-dispatch (`invokeReadTool`) and the
4515
+ // agent loop's LLM-driven tool execution within ONE user turn, then
4516
+ // clears on `turn_complete` or after any successful write.
4517
+ turnReadCache = new TurnReadCache();
4518
+ // [v0.46.8] Set to `true` when the agent loop yields `pending_action`
4519
+ // and returns (turn is paused awaiting user confirmation). The
4520
+ // submitMessage / resumeWithToolResult wrappers consult this flag in
4521
+ // their `finally` block so they DON'T clear the cache mid-turn — the
4522
+ // pending write may resume, and the cache should survive the pause.
4523
+ turnPaused = false;
4378
4524
  constructor(config) {
4379
4525
  this.provider = config.provider;
4380
4526
  this.agent = config.agent;
@@ -4426,7 +4572,14 @@ var QueryEngine = class {
4426
4572
  role: "user",
4427
4573
  content: [{ type: "text", text: prompt }]
4428
4574
  });
4429
- yield* this.agentLoop(prompt, signal);
4575
+ this.turnPaused = false;
4576
+ try {
4577
+ yield* this.agentLoop(prompt, signal);
4578
+ } finally {
4579
+ if (!this.turnPaused) {
4580
+ this.turnReadCache.clear();
4581
+ }
4582
+ }
4430
4583
  }
4431
4584
  /**
4432
4585
  * Resume the conversation after a pending action is resolved.
@@ -4470,10 +4623,19 @@ var QueryEngine = class {
4470
4623
  };
4471
4624
  if (!response.approved) {
4472
4625
  yield { type: "turn_complete", stopReason: "end_turn" };
4626
+ this.turnReadCache.clear();
4473
4627
  return;
4474
4628
  }
4629
+ this.turnReadCache.clear();
4475
4630
  yield* this.runPostWriteRefresh(action, response, signal);
4476
- yield* this.agentLoop(null, signal, false);
4631
+ this.turnPaused = false;
4632
+ try {
4633
+ yield* this.agentLoop(null, signal, false);
4634
+ } finally {
4635
+ if (!this.turnPaused) {
4636
+ this.turnReadCache.clear();
4637
+ }
4638
+ }
4477
4639
  }
4478
4640
  /**
4479
4641
  * [v1.5] Auto-run configured read tools after a successful write,
@@ -4552,6 +4714,12 @@ var QueryEngine = class {
4552
4714
  }));
4553
4715
  this.messages.push({ role: "user", content: refreshResults });
4554
4716
  for (const r of refreshes) {
4717
+ if (!r.isError) {
4718
+ this.turnReadCache.set(
4719
+ TurnReadCache.keyFor(r.tool.name, {}),
4720
+ { result: r.data, sourceToolUseId: r.id }
4721
+ );
4722
+ }
4555
4723
  yield {
4556
4724
  type: "tool_result",
4557
4725
  toolName: r.tool.name,
@@ -4619,6 +4787,11 @@ var QueryEngine = class {
4619
4787
  `invokeReadTool: invalid input for ${toolName}: ${parsed.error.issues.map((i) => i.message).join(", ")}`
4620
4788
  );
4621
4789
  }
4790
+ const cacheKey = TurnReadCache.keyFor(toolName, parsed.data);
4791
+ const cached = this.turnReadCache.get(cacheKey);
4792
+ if (cached) {
4793
+ return { data: cached.result, isError: false };
4794
+ }
4622
4795
  const signal = options.signal ?? new AbortController().signal;
4623
4796
  const context = {
4624
4797
  agent: this.agent,
@@ -4635,6 +4808,10 @@ var QueryEngine = class {
4635
4808
  };
4636
4809
  try {
4637
4810
  const result = await tool.call(parsed.data, context);
4811
+ this.turnReadCache.set(cacheKey, {
4812
+ result: result.data,
4813
+ sourceToolUseId: "invokeReadTool"
4814
+ });
4638
4815
  return { data: result.data, isError: false };
4639
4816
  } catch (err) {
4640
4817
  return {
@@ -4687,7 +4864,7 @@ var QueryEngine = class {
4687
4864
  assistantBlocks: [],
4688
4865
  pendingToolCalls: []
4689
4866
  };
4690
- const dispatcher = new EarlyToolDispatcher(this.tools, context);
4867
+ const dispatcher = new EarlyToolDispatcher(this.tools, context, this.turnReadCache);
4691
4868
  try {
4692
4869
  const microcompacted = microcompact(this.messages, this.tools);
4693
4870
  this.messages = microcompacted;
@@ -4859,6 +5036,27 @@ ${recipeCtx}`;
4859
5036
  let pendingWrite = null;
4860
5037
  for (const call of acc.pendingToolCalls) {
4861
5038
  const tool = findTool(this.tools, call.name);
5039
+ if (tool && tool.isReadOnly) {
5040
+ const cacheKey = TurnReadCache.keyFor(call.name, call.input);
5041
+ const cached = this.turnReadCache.get(cacheKey);
5042
+ if (cached) {
5043
+ yield {
5044
+ type: "tool_result",
5045
+ toolName: call.name,
5046
+ toolUseId: call.id,
5047
+ result: cached.result,
5048
+ isError: false,
5049
+ resultDeduped: true
5050
+ };
5051
+ toolResultBlocks.push({
5052
+ type: "tool_result",
5053
+ toolUseId: call.id,
5054
+ content: JSON.stringify(cached.result),
5055
+ isError: false
5056
+ });
5057
+ continue;
5058
+ }
5059
+ }
4862
5060
  const needsConfirmation = (() => {
4863
5061
  if (!tool || tool.isReadOnly) return false;
4864
5062
  if (tool.permissionLevel === "explicit") return true;
@@ -4972,6 +5170,18 @@ ${recipeCtx}`;
4972
5170
  }
4973
5171
  }
4974
5172
  const finalEvent = enrichedResult !== toolEvent.result ? { ...toolEvent, result: enrichedResult } : toolEvent;
5173
+ if (!finalEvent.isError && tool) {
5174
+ if (tool.isReadOnly) {
5175
+ const inputForKey = originalCall?.input ?? {};
5176
+ const cacheKey = TurnReadCache.keyFor(finalEvent.toolName, inputForKey);
5177
+ this.turnReadCache.set(cacheKey, {
5178
+ result: finalEvent.result,
5179
+ sourceToolUseId: finalEvent.toolUseId
5180
+ });
5181
+ } else {
5182
+ this.turnReadCache.clear();
5183
+ }
5184
+ }
4975
5185
  yield finalEvent;
4976
5186
  if (finalEvent.type === "tool_result" && !finalEvent.isError) {
4977
5187
  const r = finalEvent.result;
@@ -5048,6 +5258,7 @@ ${recipeCtx}`;
5048
5258
  const writeGuardInjections = pendingWrite.call._guardInjections;
5049
5259
  const modifiableFields = getModifiableFields(pendingWrite.call.name);
5050
5260
  const turnIndex = this.messages.filter((m) => m.role === "assistant").length;
5261
+ this.turnPaused = true;
5051
5262
  yield {
5052
5263
  type: "pending_action",
5053
5264
  action: {