@t2000/engine 0.41.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -432,6 +432,16 @@ type EngineEvent = {
432
432
  * actually re-running the tool.
433
433
  */
434
434
  resultDeduped?: boolean;
435
+ /**
436
+ * [v1.5] True when this result was produced by the engine's
437
+ * post-write refresh mechanism (see `EngineConfig.postWriteRefresh`).
438
+ * The engine auto-runs configured read tools immediately after a
439
+ * successful write so the LLM narrates from fresh on-chain state
440
+ * instead of inferring from a stale snapshot. Hosts should render
441
+ * these like any other tool result; the flag is for analytics and
442
+ * UI affordances (e.g. a subtle "auto-refreshed" badge).
443
+ */
444
+ wasPostWriteRefresh?: boolean;
435
445
  } | {
436
446
  type: 'pending_action';
437
447
  action: PendingAction;
@@ -615,6 +625,18 @@ interface Tool<TInput = unknown, TOutput = unknown> {
615
625
  maxResultSizeChars?: number;
616
626
  /** Custom truncation strategy. Falls back to generic slice + hint when omitted. */
617
627
  summarizeOnTruncate?: (result: string, maxChars: number) => string;
628
+ /**
629
+ * [v1.5.1] Whether `microcompact` may dedupe this tool's results across
630
+ * multiple calls with identical input. Default `true` — most tools are
631
+ * effectively pure within a session (price lookups, protocol info,
632
+ * yield pools). Set to `false` for tools whose result depends on
633
+ * mutable on-chain state and therefore changes after writes
634
+ * (`balance_check`, `savings_info`, `health_check`,
635
+ * `transaction_history`). Non-cacheable tools are excluded from the
636
+ * `seen` map entirely, so neither this call nor any later call with
637
+ * the same input gets replaced with a "[Same result …]" back-reference.
638
+ */
639
+ cacheable?: boolean;
618
640
  }
619
641
  type ThinkingEffort = 'low' | 'medium' | 'high' | 'max';
620
642
  type ThinkingConfig = {
@@ -699,6 +721,43 @@ interface EngineConfig {
699
721
  * verdict→action mapping. Errors thrown by the host are caught.
700
722
  */
701
723
  onGuardFired?: (guard: GuardMetric) => void;
724
+ /**
725
+ * [v1.5] Map of write tool name → list of read tool names whose state
726
+ * the write invalidates. After a successful write resumes via
727
+ * `resumeWithToolResult`, the engine auto-runs each configured read
728
+ * tool with empty input, pushes synthetic `tool_use` + `tool_result`
729
+ * messages into the conversation, and yields `tool_result` events
730
+ * with `wasPostWriteRefresh: true` BEFORE handing control back to the
731
+ * LLM for narration.
732
+ *
733
+ * Why: writes change on-chain state. Without a fresh read, the LLM
734
+ * narrates from the pre-write snapshot and frequently invents balance
735
+ * totals. Auto-injecting fresh reads makes the hallucination class
736
+ * physically impossible — the model has authoritative ground truth in
737
+ * its context before generating the post-write sentence.
738
+ *
739
+ * Constraints:
740
+ * - Refresh tools MUST be `isReadOnly` and `isConcurrencySafe`.
741
+ * - Refresh runs only when the write succeeded (executionResult is
742
+ * not `{ success: false }`); failed writes leave state unchanged
743
+ * and refreshing would be misleading.
744
+ * - Tools are invoked with empty input; refresh tools should accept
745
+ * an empty object schema (e.g. `balance_check`, `savings_info`).
746
+ * - Errors during refresh are non-fatal — a tool_result with
747
+ * `isError: true` is still pushed so the LLM knows refresh failed.
748
+ *
749
+ * Example:
750
+ * ```
751
+ * {
752
+ * save_deposit: ['balance_check', 'savings_info'],
753
+ * send_transfer: ['balance_check'],
754
+ * borrow: ['balance_check', 'savings_info', 'health_check'],
755
+ * }
756
+ * ```
757
+ *
758
+ * Omit (undefined / empty map) to disable post-write refresh entirely.
759
+ */
760
+ postWriteRefresh?: Record<string, string[]>;
702
761
  }
703
762
  interface LLMProvider {
704
763
  chat(params: ChatParams): AsyncGenerator<ProviderEvent>;
@@ -824,6 +883,7 @@ declare class QueryEngine {
824
883
  private readonly sessionSpendUsd;
825
884
  private readonly onAutoExecuted;
826
885
  private readonly onGuardFired;
886
+ private readonly postWriteRefresh;
827
887
  private matchedRecipe;
828
888
  private messages;
829
889
  private abortController;
@@ -845,6 +905,16 @@ declare class QueryEngine {
845
905
  * This is a separate HTTP request — no persistent connection from submitMessage.
846
906
  */
847
907
  resumeWithToolResult(action: PendingAction, response: PermissionResponse): AsyncGenerator<EngineEvent>;
908
+ /**
909
+ * [v1.5] Auto-run configured read tools after a successful write,
910
+ * push their results into the conversation, and yield `tool_result`
911
+ * events so hosts/UI render them in the timeline. See
912
+ * `EngineConfig.postWriteRefresh`.
913
+ *
914
+ * Pure injection — no LLM call here. The next `agentLoop` turn sees
915
+ * the fresh tool results and narrates from them.
916
+ */
917
+ private runPostWriteRefresh;
848
918
  interrupt(): void;
849
919
  getMessages(): readonly Message[];
850
920
  getMatchedRecipe(): Recipe | null;
@@ -887,6 +957,11 @@ interface BuildToolOptions<TInput, TOutput> {
887
957
  preflight?: (input: TInput) => PreflightResult;
888
958
  maxResultSizeChars?: number;
889
959
  summarizeOnTruncate?: (result: string, maxChars: number) => string;
960
+ /**
961
+ * [v1.5.1] See `Tool.cacheable`. Default `true`. Set `false` for
962
+ * tools whose results depend on mutable on-chain state.
963
+ */
964
+ cacheable?: boolean;
890
965
  }
891
966
  declare function buildTool<TInput, TOutput>(opts: BuildToolOptions<TInput, TOutput>): Tool<TInput, TOutput>;
892
967
  declare function toolsToDefinitions(tools: Tool[]): {
@@ -916,6 +991,9 @@ type SSEEvent = {
916
991
  toolUseId: string;
917
992
  result: unknown;
918
993
  isError: boolean;
994
+ wasEarlyDispatched?: boolean;
995
+ resultDeduped?: boolean;
996
+ wasPostWriteRefresh?: boolean;
919
997
  } | {
920
998
  type: 'pending_action';
921
999
  action: PendingAction;
@@ -1119,11 +1197,23 @@ interface MicrocompactResult extends Array<Message> {
1119
1197
  * the full prior result with a compact back-reference. Runs before any
1120
1198
  * LLM-based compaction and costs nothing.
1121
1199
  *
1200
+ * [v1.5.1] Tools may opt out of dedupe by setting `cacheable: false` on
1201
+ * their `Tool` definition. Non-cacheable tools (e.g. `balance_check`,
1202
+ * `savings_info`, `health_check`, `transaction_history`) are excluded
1203
+ * from the `seen` map entirely, so neither the current call nor any
1204
+ * later call with identical inputs gets replaced — necessary because
1205
+ * their results depend on mutable on-chain state that writes invalidate.
1206
+ *
1122
1207
  * Returns a new array — does not mutate the input. The returned array
1123
1208
  * carries a `dedupedToolUseIds` property listing every tool-use ID whose
1124
1209
  * tool_result block was replaced with a back-reference this pass.
1210
+ *
1211
+ * @param messages — conversation ledger to compact.
1212
+ * @param tools — optional tool registry consulted to resolve the
1213
+ * per-tool `cacheable` flag. Omit to dedupe every
1214
+ * tool (legacy behavior — back-compat).
1125
1215
  */
1126
- declare function microcompact(messages: readonly Message[]): MicrocompactResult;
1216
+ declare function microcompact(messages: readonly Message[], tools?: readonly Tool[]): MicrocompactResult;
1127
1217
 
1128
1218
  /**
1129
1219
  * EarlyToolDispatcher — dispatches read-only tools mid-stream.
package/dist/index.js CHANGED
@@ -23,7 +23,8 @@ function buildTool(opts) {
23
23
  flags: opts.flags ?? {},
24
24
  preflight: opts.preflight,
25
25
  maxResultSizeChars: opts.maxResultSizeChars,
26
- summarizeOnTruncate: opts.summarizeOnTruncate
26
+ summarizeOnTruncate: opts.summarizeOnTruncate,
27
+ cacheable: opts.cacheable
27
28
  };
28
29
  }
29
30
  function toolsToDefinitions(tools) {
@@ -557,6 +558,10 @@ var balanceCheckTool = buildTool({
557
558
  inputSchema: z.object({}),
558
559
  jsonSchema: { type: "object", properties: {}, required: [] },
559
560
  isReadOnly: true,
561
+ // [v1.5.1] Wallet contents change after every send/swap/save/etc.
562
+ // Microcompact must NEVER dedupe these calls — each one reflects a
563
+ // different on-chain state.
564
+ cacheable: false,
560
565
  async call(_input, context) {
561
566
  if (hasNaviMcp(context)) {
562
567
  const address = getWalletAddress(context);
@@ -850,6 +855,9 @@ var savingsInfoTool = buildTool({
850
855
  inputSchema: z.object({}),
851
856
  jsonSchema: { type: "object", properties: {}, required: [] },
852
857
  isReadOnly: true,
858
+ // [v1.5.1] NAVI deposits change on save_deposit / withdraw / claim.
859
+ // Each call reflects a fresh on-chain snapshot — never dedupe.
860
+ cacheable: false,
853
861
  async call(_input, context) {
854
862
  if (context.positionFetcher && context.walletAddress) {
855
863
  const sp = await context.positionFetcher(context.walletAddress);
@@ -910,6 +918,9 @@ var healthCheckTool = buildTool({
910
918
  inputSchema: z.object({}),
911
919
  jsonSchema: { type: "object", properties: {}, required: [] },
912
920
  isReadOnly: true,
921
+ // [v1.5.1] Health factor changes on every borrow / repay / collateral
922
+ // movement and even passively as oracle prices update. Never dedupe.
923
+ cacheable: false,
913
924
  async call(_input, context) {
914
925
  if (context.positionFetcher && context.walletAddress) {
915
926
  const sp = await context.positionFetcher(context.walletAddress);
@@ -1151,6 +1162,10 @@ var transactionHistoryTool = buildTool({
1151
1162
  },
1152
1163
  isReadOnly: true,
1153
1164
  maxResultSizeChars: 8e3,
1165
+ // [v1.5.1] New transactions land continuously. Even with an explicit
1166
+ // `date` filter the dedupe is wrong post-write because the just-
1167
+ // executed write may now be in history. Never dedupe.
1168
+ cacheable: false,
1154
1169
  async call(input, context) {
1155
1170
  const limit = input.limit ?? 10;
1156
1171
  const action = input.action;
@@ -3724,15 +3739,23 @@ function extractConversationText(messages) {
3724
3739
  }
3725
3740
 
3726
3741
  // src/compact/microcompact.ts
3727
- function microcompact(messages) {
3742
+ function microcompact(messages, tools) {
3728
3743
  const seen = /* @__PURE__ */ new Map();
3729
3744
  let toolUseIndex = 0;
3730
3745
  const toolUseInputs = /* @__PURE__ */ new Map();
3746
+ const cacheableByName = /* @__PURE__ */ new Map();
3747
+ if (tools) {
3748
+ for (const t of tools) {
3749
+ cacheableByName.set(t.name, t.cacheable ?? true);
3750
+ }
3751
+ }
3731
3752
  const dedupedToolUseIds = /* @__PURE__ */ new Set();
3753
+ const toolNameById = /* @__PURE__ */ new Map();
3732
3754
  for (const msg of messages) {
3733
3755
  for (const block of msg.content) {
3734
3756
  if (block.type === "tool_use") {
3735
3757
  toolUseInputs.set(block.id, `${block.name}:${stableStringify(block.input)}`);
3758
+ toolNameById.set(block.id, block.name);
3736
3759
  }
3737
3760
  }
3738
3761
  }
@@ -3744,6 +3767,11 @@ function microcompact(messages) {
3744
3767
  if (block.type !== "tool_result") return block;
3745
3768
  const key = toolUseInputs.get(block.toolUseId);
3746
3769
  if (!key) return block;
3770
+ const toolName = toolNameById.get(block.toolUseId);
3771
+ if (toolName && cacheableByName.get(toolName) === false) {
3772
+ toolUseIndex++;
3773
+ return block;
3774
+ }
3747
3775
  toolUseIndex++;
3748
3776
  const prior = seen.get(key);
3749
3777
  if (prior && !block.isError) {
@@ -4177,6 +4205,9 @@ var QueryEngine = class {
4177
4205
  sessionSpendUsd;
4178
4206
  onAutoExecuted;
4179
4207
  onGuardFired;
4208
+ // [v1.5] See `EngineConfig.postWriteRefresh` — drives the post-write
4209
+ // synthetic read injection in `resumeWithToolResult`.
4210
+ postWriteRefresh;
4180
4211
  matchedRecipe = null;
4181
4212
  messages = [];
4182
4213
  abortController = null;
@@ -4209,6 +4240,7 @@ var QueryEngine = class {
4209
4240
  this.sessionSpendUsd = config.sessionSpendUsd;
4210
4241
  this.onAutoExecuted = config.onAutoExecuted;
4211
4242
  this.onGuardFired = config.onGuardFired;
4243
+ this.postWriteRefresh = config.postWriteRefresh;
4212
4244
  this.tools = config.tools ?? (config.agent ? getDefaultTools() : []);
4213
4245
  }
4214
4246
  /**
@@ -4277,8 +4309,96 @@ var QueryEngine = class {
4277
4309
  yield { type: "turn_complete", stopReason: "end_turn" };
4278
4310
  return;
4279
4311
  }
4312
+ yield* this.runPostWriteRefresh(action, response, signal);
4280
4313
  yield* this.agentLoop(null, signal, false);
4281
4314
  }
4315
+ /**
4316
+ * [v1.5] Auto-run configured read tools after a successful write,
4317
+ * push their results into the conversation, and yield `tool_result`
4318
+ * events so hosts/UI render them in the timeline. See
4319
+ * `EngineConfig.postWriteRefresh`.
4320
+ *
4321
+ * Pure injection — no LLM call here. The next `agentLoop` turn sees
4322
+ * the fresh tool results and narrates from them.
4323
+ */
4324
+ async *runPostWriteRefresh(action, response, signal) {
4325
+ const refreshList = this.postWriteRefresh?.[action.toolName];
4326
+ if (!refreshList || refreshList.length === 0) return;
4327
+ const exec = response.executionResult;
4328
+ const writeFailed = exec != null && typeof exec === "object" && "success" in exec && exec.success === false;
4329
+ if (writeFailed) return;
4330
+ const refreshTools = refreshList.map((name) => findTool(this.tools, name)).filter(
4331
+ (t) => t !== void 0 && t.isReadOnly && t.isConcurrencySafe
4332
+ );
4333
+ if (refreshTools.length === 0) return;
4334
+ const context = {
4335
+ agent: this.agent,
4336
+ mcpManager: this.mcpManager,
4337
+ walletAddress: this.walletAddress,
4338
+ suiRpcUrl: this.suiRpcUrl,
4339
+ serverPositions: this.serverPositions,
4340
+ positionFetcher: this.positionFetcher,
4341
+ env: this.env,
4342
+ signal,
4343
+ priceCache: this.priceCache,
4344
+ permissionConfig: this.permissionConfig,
4345
+ sessionSpendUsd: this.sessionSpendUsd
4346
+ };
4347
+ const idStem = `pwr_${action.toolUseId.slice(-6)}`;
4348
+ const refreshes = await Promise.all(
4349
+ refreshTools.map(async (tool, idx) => {
4350
+ const id = `${idStem}_${idx}_${tool.name}`;
4351
+ try {
4352
+ const parsed = tool.inputSchema.safeParse({});
4353
+ if (!parsed.success) {
4354
+ return {
4355
+ tool,
4356
+ id,
4357
+ isError: true,
4358
+ data: {
4359
+ error: `Post-write refresh: invalid input for ${tool.name}`
4360
+ }
4361
+ };
4362
+ }
4363
+ const result = await tool.call(parsed.data, context);
4364
+ return { tool, id, isError: false, data: result.data };
4365
+ } catch (err) {
4366
+ return {
4367
+ tool,
4368
+ id,
4369
+ isError: true,
4370
+ data: {
4371
+ error: err instanceof Error ? err.message : "Post-write refresh failed"
4372
+ }
4373
+ };
4374
+ }
4375
+ })
4376
+ );
4377
+ const refreshUses = refreshes.map((r) => ({
4378
+ type: "tool_use",
4379
+ id: r.id,
4380
+ name: r.tool.name,
4381
+ input: {}
4382
+ }));
4383
+ this.messages.push({ role: "assistant", content: refreshUses });
4384
+ const refreshResults = refreshes.map((r) => ({
4385
+ type: "tool_result",
4386
+ toolUseId: r.id,
4387
+ content: typeof r.data === "string" ? r.data : JSON.stringify(r.data),
4388
+ isError: r.isError
4389
+ }));
4390
+ this.messages.push({ role: "user", content: refreshResults });
4391
+ for (const r of refreshes) {
4392
+ yield {
4393
+ type: "tool_result",
4394
+ toolName: r.tool.name,
4395
+ toolUseId: r.id,
4396
+ result: r.data,
4397
+ isError: r.isError,
4398
+ wasPostWriteRefresh: true
4399
+ };
4400
+ }
4401
+ }
4282
4402
  interrupt() {
4283
4403
  this.abortController?.abort();
4284
4404
  }
@@ -4350,7 +4470,7 @@ var QueryEngine = class {
4350
4470
  };
4351
4471
  const dispatcher = new EarlyToolDispatcher(this.tools, context);
4352
4472
  try {
4353
- const microcompacted = microcompact(this.messages);
4473
+ const microcompacted = microcompact(this.messages, this.tools);
4354
4474
  this.messages = microcompacted;
4355
4475
  for (const dedupedId of microcompacted.dedupedToolUseIds) {
4356
4476
  yield {