@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 +97 -2
- package/dist/index.js +238 -27
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
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:
|
|
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
|
|
937
|
-
const
|
|
938
|
-
const
|
|
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:
|
|
955
|
+
healthFactor: transportHf2,
|
|
942
956
|
supplied: sp.savings,
|
|
943
|
-
borrowed:
|
|
957
|
+
borrowed: borrowed2,
|
|
944
958
|
maxBorrow: sp.maxBorrow,
|
|
945
959
|
liquidationThreshold: 0,
|
|
946
960
|
status: status2
|
|
947
961
|
},
|
|
948
|
-
displayText:
|
|
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
|
|
957
|
-
const
|
|
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:
|
|
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
|
|
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:
|
|
985
|
+
healthFactor: transportHf,
|
|
969
986
|
supplied: hf.supplied,
|
|
970
|
-
borrowed
|
|
987
|
+
borrowed,
|
|
971
988
|
maxBorrow: hf.maxBorrow,
|
|
972
989
|
liquidationThreshold: hf.liquidationThreshold,
|
|
973
990
|
status
|
|
974
991
|
},
|
|
975
|
-
displayText:
|
|
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:
|
|
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:
|
|
1590
|
-
totalValueUsd
|
|
1633
|
+
rewards: enrichedRewards,
|
|
1634
|
+
totalValueUsd,
|
|
1591
1635
|
gasCost: result.gasCost
|
|
1592
1636
|
},
|
|
1593
|
-
displayText
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|