@t2000/engine 0.46.7 → 0.46.8

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. */
package/dist/index.js CHANGED
@@ -4257,27 +4257,116 @@ function safeNum(v) {
4257
4257
  return isNaN(n) ? 0 : n;
4258
4258
  }
4259
4259
 
4260
+ // src/turn-read-cache.ts
4261
+ var TurnReadCache = class {
4262
+ store = /* @__PURE__ */ new Map();
4263
+ /**
4264
+ * Build the cache key for a (toolName, input) pair. Stable across object
4265
+ * key ordering so `{a:1,b:2}` and `{b:2,a:1}` map to the same entry.
4266
+ */
4267
+ static keyFor(toolName, input) {
4268
+ return `${toolName}:${stableStringify2(input)}`;
4269
+ }
4270
+ has(key) {
4271
+ return this.store.has(key);
4272
+ }
4273
+ get(key) {
4274
+ return this.store.get(key);
4275
+ }
4276
+ /**
4277
+ * Populate the cache. Caller is responsible for ensuring the result was
4278
+ * a successful read (no errors). Overwrites any prior entry for the same
4279
+ * key — the most recent successful read wins, which is correct under our
4280
+ * "writes invalidate the whole cache" invariant.
4281
+ */
4282
+ set(key, value) {
4283
+ this.store.set(key, value);
4284
+ }
4285
+ /**
4286
+ * Drop every entry. Called at turn end and after every successful write.
4287
+ * Cheap and intentional — the cache is small (a handful of entries per
4288
+ * turn at most) and clearing is the correct response to any state mutation.
4289
+ */
4290
+ clear() {
4291
+ this.store.clear();
4292
+ }
4293
+ size() {
4294
+ return this.store.size;
4295
+ }
4296
+ };
4297
+ function stableStringify2(value) {
4298
+ if (value === null || value === void 0) return "";
4299
+ if (typeof value !== "object") return JSON.stringify(value);
4300
+ if (Array.isArray(value)) return JSON.stringify(value.map(stableStringifyForObject));
4301
+ return stableStringifyForObject(value);
4302
+ }
4303
+ function stableStringifyForObject(value) {
4304
+ if (value === null || value === void 0) return JSON.stringify(value);
4305
+ if (typeof value !== "object") return JSON.stringify(value);
4306
+ if (Array.isArray(value)) {
4307
+ return `[${value.map(stableStringifyForObject).join(",")}]`;
4308
+ }
4309
+ const sorted = Object.keys(value).sort();
4310
+ const parts = sorted.map(
4311
+ (k) => `${JSON.stringify(k)}:${stableStringifyForObject(value[k])}`
4312
+ );
4313
+ return `{${parts.join(",")}}`;
4314
+ }
4315
+
4260
4316
  // src/early-dispatcher.ts
4261
4317
  var EarlyToolDispatcher = class {
4262
4318
  entries = [];
4263
4319
  tools;
4264
4320
  context;
4321
+ turnReadCache;
4265
4322
  abortController;
4266
- constructor(tools, context) {
4323
+ constructor(tools, context, turnReadCache) {
4267
4324
  this.tools = tools;
4268
4325
  this.context = context;
4326
+ this.turnReadCache = turnReadCache;
4269
4327
  this.abortController = new AbortController();
4270
4328
  }
4271
4329
  /**
4272
4330
  * Attempt to dispatch a tool call. Returns true if the tool was dispatched
4273
4331
  * (read-only + concurrency-safe), false if it should be queued for later.
4332
+ *
4333
+ * [v0.46.8] Cache-aware: if a `TurnReadCache` was supplied at
4334
+ * construction and a prior call this turn already produced a result
4335
+ * for the same `(toolName, input)`, the dispatcher returns true (the
4336
+ * call IS handled here, not queued for the post-stream loop) but
4337
+ * skips the tool execution entirely — `collectResults` will surface
4338
+ * the cached value with `resultDeduped: true`. On a cache miss for
4339
+ * a successful real execution, the result is written back to the
4340
+ * cache so any later call within the same turn dedups too.
4274
4341
  */
4275
4342
  tryDispatch(call) {
4276
4343
  const tool = findTool(this.tools, call.name);
4277
4344
  if (!tool || !tool.isReadOnly || !tool.isConcurrencySafe) return false;
4345
+ if (this.turnReadCache) {
4346
+ const cacheKey = TurnReadCache.keyFor(call.name, call.input);
4347
+ const cached = this.turnReadCache.get(cacheKey);
4348
+ if (cached) {
4349
+ this.entries.push({
4350
+ call,
4351
+ tool,
4352
+ promise: Promise.resolve({ data: cached.result, isError: false }),
4353
+ deduped: true
4354
+ });
4355
+ return true;
4356
+ }
4357
+ }
4278
4358
  const childContext = { ...this.context, signal: this.abortController.signal };
4279
- const promise = executeTool(tool, call, childContext);
4280
- this.entries.push({ call, tool, promise });
4359
+ const promise = executeTool(tool, call, childContext).then((result) => {
4360
+ if (!result.isError && this.turnReadCache) {
4361
+ const cacheKey = TurnReadCache.keyFor(call.name, call.input);
4362
+ this.turnReadCache.set(cacheKey, {
4363
+ result: result.data,
4364
+ sourceToolUseId: call.id
4365
+ });
4366
+ }
4367
+ return result;
4368
+ });
4369
+ this.entries.push({ call, tool, promise, deduped: false });
4281
4370
  return true;
4282
4371
  }
4283
4372
  /** True if any tools have been dispatched. */
@@ -4303,7 +4392,8 @@ var EarlyToolDispatcher = class {
4303
4392
  toolUseId: entry.call.id,
4304
4393
  result: budgeted,
4305
4394
  isError: result.isError,
4306
- wasEarlyDispatched: true
4395
+ wasEarlyDispatched: true,
4396
+ ...entry.deduped ? { resultDeduped: true } : {}
4307
4397
  };
4308
4398
  } catch (err) {
4309
4399
  yield {
@@ -4375,6 +4465,18 @@ var QueryEngine = class {
4375
4465
  messages = [];
4376
4466
  abortController = null;
4377
4467
  guardEvents = [];
4468
+ // [v0.46.8] Intra-turn dedup cache for read-only tool calls. See
4469
+ // `turn-read-cache.ts` for the full lifecycle. Key takeaway: the cache
4470
+ // lives across the host's pre-dispatch (`invokeReadTool`) and the
4471
+ // agent loop's LLM-driven tool execution within ONE user turn, then
4472
+ // clears on `turn_complete` or after any successful write.
4473
+ turnReadCache = new TurnReadCache();
4474
+ // [v0.46.8] Set to `true` when the agent loop yields `pending_action`
4475
+ // and returns (turn is paused awaiting user confirmation). The
4476
+ // submitMessage / resumeWithToolResult wrappers consult this flag in
4477
+ // their `finally` block so they DON'T clear the cache mid-turn — the
4478
+ // pending write may resume, and the cache should survive the pause.
4479
+ turnPaused = false;
4378
4480
  constructor(config) {
4379
4481
  this.provider = config.provider;
4380
4482
  this.agent = config.agent;
@@ -4426,7 +4528,14 @@ var QueryEngine = class {
4426
4528
  role: "user",
4427
4529
  content: [{ type: "text", text: prompt }]
4428
4530
  });
4429
- yield* this.agentLoop(prompt, signal);
4531
+ this.turnPaused = false;
4532
+ try {
4533
+ yield* this.agentLoop(prompt, signal);
4534
+ } finally {
4535
+ if (!this.turnPaused) {
4536
+ this.turnReadCache.clear();
4537
+ }
4538
+ }
4430
4539
  }
4431
4540
  /**
4432
4541
  * Resume the conversation after a pending action is resolved.
@@ -4470,10 +4579,19 @@ var QueryEngine = class {
4470
4579
  };
4471
4580
  if (!response.approved) {
4472
4581
  yield { type: "turn_complete", stopReason: "end_turn" };
4582
+ this.turnReadCache.clear();
4473
4583
  return;
4474
4584
  }
4585
+ this.turnReadCache.clear();
4475
4586
  yield* this.runPostWriteRefresh(action, response, signal);
4476
- yield* this.agentLoop(null, signal, false);
4587
+ this.turnPaused = false;
4588
+ try {
4589
+ yield* this.agentLoop(null, signal, false);
4590
+ } finally {
4591
+ if (!this.turnPaused) {
4592
+ this.turnReadCache.clear();
4593
+ }
4594
+ }
4477
4595
  }
4478
4596
  /**
4479
4597
  * [v1.5] Auto-run configured read tools after a successful write,
@@ -4552,6 +4670,12 @@ var QueryEngine = class {
4552
4670
  }));
4553
4671
  this.messages.push({ role: "user", content: refreshResults });
4554
4672
  for (const r of refreshes) {
4673
+ if (!r.isError) {
4674
+ this.turnReadCache.set(
4675
+ TurnReadCache.keyFor(r.tool.name, {}),
4676
+ { result: r.data, sourceToolUseId: r.id }
4677
+ );
4678
+ }
4555
4679
  yield {
4556
4680
  type: "tool_result",
4557
4681
  toolName: r.tool.name,
@@ -4619,6 +4743,11 @@ var QueryEngine = class {
4619
4743
  `invokeReadTool: invalid input for ${toolName}: ${parsed.error.issues.map((i) => i.message).join(", ")}`
4620
4744
  );
4621
4745
  }
4746
+ const cacheKey = TurnReadCache.keyFor(toolName, parsed.data);
4747
+ const cached = this.turnReadCache.get(cacheKey);
4748
+ if (cached) {
4749
+ return { data: cached.result, isError: false };
4750
+ }
4622
4751
  const signal = options.signal ?? new AbortController().signal;
4623
4752
  const context = {
4624
4753
  agent: this.agent,
@@ -4635,6 +4764,10 @@ var QueryEngine = class {
4635
4764
  };
4636
4765
  try {
4637
4766
  const result = await tool.call(parsed.data, context);
4767
+ this.turnReadCache.set(cacheKey, {
4768
+ result: result.data,
4769
+ sourceToolUseId: "invokeReadTool"
4770
+ });
4638
4771
  return { data: result.data, isError: false };
4639
4772
  } catch (err) {
4640
4773
  return {
@@ -4687,7 +4820,7 @@ var QueryEngine = class {
4687
4820
  assistantBlocks: [],
4688
4821
  pendingToolCalls: []
4689
4822
  };
4690
- const dispatcher = new EarlyToolDispatcher(this.tools, context);
4823
+ const dispatcher = new EarlyToolDispatcher(this.tools, context, this.turnReadCache);
4691
4824
  try {
4692
4825
  const microcompacted = microcompact(this.messages, this.tools);
4693
4826
  this.messages = microcompacted;
@@ -4859,6 +4992,27 @@ ${recipeCtx}`;
4859
4992
  let pendingWrite = null;
4860
4993
  for (const call of acc.pendingToolCalls) {
4861
4994
  const tool = findTool(this.tools, call.name);
4995
+ if (tool && tool.isReadOnly) {
4996
+ const cacheKey = TurnReadCache.keyFor(call.name, call.input);
4997
+ const cached = this.turnReadCache.get(cacheKey);
4998
+ if (cached) {
4999
+ yield {
5000
+ type: "tool_result",
5001
+ toolName: call.name,
5002
+ toolUseId: call.id,
5003
+ result: cached.result,
5004
+ isError: false,
5005
+ resultDeduped: true
5006
+ };
5007
+ toolResultBlocks.push({
5008
+ type: "tool_result",
5009
+ toolUseId: call.id,
5010
+ content: JSON.stringify(cached.result),
5011
+ isError: false
5012
+ });
5013
+ continue;
5014
+ }
5015
+ }
4862
5016
  const needsConfirmation = (() => {
4863
5017
  if (!tool || tool.isReadOnly) return false;
4864
5018
  if (tool.permissionLevel === "explicit") return true;
@@ -4972,6 +5126,18 @@ ${recipeCtx}`;
4972
5126
  }
4973
5127
  }
4974
5128
  const finalEvent = enrichedResult !== toolEvent.result ? { ...toolEvent, result: enrichedResult } : toolEvent;
5129
+ if (!finalEvent.isError && tool) {
5130
+ if (tool.isReadOnly) {
5131
+ const inputForKey = originalCall?.input ?? {};
5132
+ const cacheKey = TurnReadCache.keyFor(finalEvent.toolName, inputForKey);
5133
+ this.turnReadCache.set(cacheKey, {
5134
+ result: finalEvent.result,
5135
+ sourceToolUseId: finalEvent.toolUseId
5136
+ });
5137
+ } else {
5138
+ this.turnReadCache.clear();
5139
+ }
5140
+ }
4975
5141
  yield finalEvent;
4976
5142
  if (finalEvent.type === "tool_result" && !finalEvent.isError) {
4977
5143
  const r = finalEvent.result;
@@ -5048,6 +5214,7 @@ ${recipeCtx}`;
5048
5214
  const writeGuardInjections = pendingWrite.call._guardInjections;
5049
5215
  const modifiableFields = getModifiableFields(pendingWrite.call.name);
5050
5216
  const turnIndex = this.messages.filter((m) => m.role === "assistant").length;
5217
+ this.turnPaused = true;
5051
5218
  yield {
5052
5219
  type: "pending_action",
5053
5220
  action: {