@yeaft/webchat-agent 1.0.350 → 1.0.352
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/connection/message-router.js +29 -1
- package/index.js +2 -0
- package/local-runtime/server/handlers/agent-sync.js +14 -0
- package/local-runtime/server/handlers/client-misc.js +21 -0
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +110 -97
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/config-api.js +53 -1
- package/yeaft/config.js +54 -0
- package/yeaft/conversation/history-index-worker.js +13 -10
- package/yeaft/conversation/internal-control.js +1 -0
- package/yeaft/debug-trace.js +164 -47
- package/yeaft/engine.js +318 -28
- package/yeaft/llm/adapter.js +38 -0
- package/yeaft/llm/anthropic.js +11 -8
- package/yeaft/llm/openai-responses.js +11 -8
- package/yeaft/llm/router.js +1 -1
- package/yeaft/perf-trace.js +156 -24
- package/yeaft/session.js +7 -0
- package/yeaft/sessions/session-crud.js +19 -4
- package/yeaft/sub-agent/runner.js +4 -0
- package/yeaft/tools/agent.js +4 -0
- package/yeaft/tools/ask-user.js +1 -0
- package/yeaft/tools/bash.js +4 -0
- package/yeaft/tools/create-work-item.js +3 -0
- package/yeaft/tools/file-read.js +1 -0
- package/yeaft/tools/glob.js +1 -0
- package/yeaft/tools/grep.js +1 -0
- package/yeaft/tools/history-search.js +74 -20
- package/yeaft/tools/js-repl.js +1 -0
- package/yeaft/tools/list-agents.js +1 -0
- package/yeaft/tools/list-dir.js +1 -0
- package/yeaft/tools/list-tasks.js +1 -0
- package/yeaft/tools/read-task-log.js +1 -0
- package/yeaft/tools/route-forward.js +4 -0
- package/yeaft/tools/send-message.js +3 -0
- package/yeaft/tools/types.js +8 -0
- package/yeaft/tools/wait-agent.js +1 -0
- package/yeaft/utf8.js +44 -0
- package/yeaft/web-bridge.js +6 -0
- package/yeaft/work-center/runner.js +1 -0
package/yeaft/engine.js
CHANGED
|
@@ -34,7 +34,7 @@ import { isVpForeign, readContent as readScopeContent } from './memory/store.js'
|
|
|
34
34
|
import { ActiveMemorySet } from './memory/ams.js';
|
|
35
35
|
import { cleanMemoryPromptText } from './memory/prompt-cleanup.js';
|
|
36
36
|
import { isVpSeedBackfillStub } from './memory/seed-backfill.js';
|
|
37
|
-
import { perfNowMs, recordAgentPerfTrace } from './perf-trace.js';
|
|
37
|
+
import { boundRawExchange, perfNowMs, recordAgentPerfTrace } from './perf-trace.js';
|
|
38
38
|
// Default thread marker for legacy / non-group flows. Group VP runtime may
|
|
39
39
|
// pass a real threadId per (sessionId, vpId, threadId) engine instance.
|
|
40
40
|
const MAIN_THREAD_ID = 'main';
|
|
@@ -89,6 +89,39 @@ const MAX_PROMPT_MEMORY_ITEMS = 8;
|
|
|
89
89
|
const MAX_RELATED_SESSION_MEMORY_ITEMS = 2;
|
|
90
90
|
const MAX_MEMORY_ITEM_TOKENS = 1600;
|
|
91
91
|
|
|
92
|
+
function toolDefinitionFor(engine, name) {
|
|
93
|
+
return engine.getToolDefinition(name);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isReadOnlyTool(engine, name, input) {
|
|
97
|
+
try {
|
|
98
|
+
return toolDefinitionFor(engine, name)?.isReadOnly?.(input) === true;
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isCacheableTool(engine, name, input) {
|
|
105
|
+
try {
|
|
106
|
+
const tool = toolDefinitionFor(engine, name);
|
|
107
|
+
if (!tool || !isReadOnlyTool(engine, name, input)) return false;
|
|
108
|
+
return typeof tool.cacheWithinQuery === 'function'
|
|
109
|
+
? tool.cacheWithinQuery(input) === true
|
|
110
|
+
: tool.cacheWithinQuery === true;
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function mayMutateWorkspaceAfterReturn(engine, name, input) {
|
|
117
|
+
try {
|
|
118
|
+
const value = toolDefinitionFor(engine, name)?.mayMutateWorkspaceAfterReturn;
|
|
119
|
+
return typeof value === 'function' ? value(input) === true : value === true;
|
|
120
|
+
} catch {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
92
125
|
// ─── LLM retry policy defaults ──────────────────────────────────
|
|
93
126
|
// Hard-coded floor / ceiling for retry behaviour. The engine reads the
|
|
94
127
|
// effective policy from `config.llmRetry` so users can dial these via
|
|
@@ -229,11 +262,9 @@ function sleepWithAbort(ms, signal) {
|
|
|
229
262
|
* - `toolCalls` on assistant turns (the LLM's function_call requests)
|
|
230
263
|
* - `toolCallId` + `isError` on tool turns (the paired tool_result)
|
|
231
264
|
*
|
|
232
|
-
* Content is
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
* bound is per-loop-count (see `MAX_YEAFT_DEBUG_LOOPS` in
|
|
236
|
-
* `web/stores/chat.js`), not per-payload mutilation here.
|
|
265
|
+
* Content is kept intact for the live protocol. The file-backed debug trace
|
|
266
|
+
* applies its own configured byte budget at persistence time so the model
|
|
267
|
+
* request path never pays an extra copy just for diagnostics.
|
|
237
268
|
*
|
|
238
269
|
* Pure function — no side effects on the input message.
|
|
239
270
|
*
|
|
@@ -243,6 +274,7 @@ function sleepWithAbort(ms, signal) {
|
|
|
243
274
|
export function mapDebugMessage(m) {
|
|
244
275
|
const out = { role: m.role };
|
|
245
276
|
out.content = m.content;
|
|
277
|
+
if (m.rawRequest != null) out.rawRequest = m.rawRequest;
|
|
246
278
|
if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
|
|
247
279
|
out.toolCalls = m.toolCalls.map(tc => ({
|
|
248
280
|
id: tc.id,
|
|
@@ -649,6 +681,18 @@ export class Engine {
|
|
|
649
681
|
/** @type {string} */
|
|
650
682
|
#currentThreadId = MAIN_THREAD_ID;
|
|
651
683
|
|
|
684
|
+
/** Wire turn id of the active query, used by late async completion rows. */
|
|
685
|
+
#currentQueryTurnId = null;
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Identity-bound hook into the active query's local read-only result cache.
|
|
689
|
+
* Async task terminal events can arrive while an adapter stream is already
|
|
690
|
+
* producing tool calls, so they must invalidate reuse immediately rather
|
|
691
|
+
* than waiting for the next queue-drain boundary.
|
|
692
|
+
* @type {{ owner: AbortController, invalidate: () => void }|null}
|
|
693
|
+
*/
|
|
694
|
+
#activeReadOnlyToolReuse = null;
|
|
695
|
+
|
|
652
696
|
/** @type {Array<{content:string|Array, preview:string}>} */
|
|
653
697
|
#pendingUserMessages = [];
|
|
654
698
|
|
|
@@ -679,7 +723,7 @@ export class Engine {
|
|
|
679
723
|
/**
|
|
680
724
|
* Terminal async task results that should be appended to the original
|
|
681
725
|
* tool_result message instead of injected as a synthetic user prompt.
|
|
682
|
-
* @type {Array<{taskId:string, toolCallId:string, content:string|Array, preview:string}>}
|
|
726
|
+
* @type {Array<{taskId:string, toolCallId:string, toolName?:string, content:string|Array, preview:string}>}
|
|
683
727
|
*/
|
|
684
728
|
#pendingTaskResultUpdates = [];
|
|
685
729
|
|
|
@@ -705,7 +749,7 @@ export class Engine {
|
|
|
705
749
|
* Async task ownership metadata captured when a tool registers a
|
|
706
750
|
* background task. Keyed by taskId so terminal events can update the
|
|
707
751
|
* original tool_result instead of fabricating a separate turn.
|
|
708
|
-
* @type {Map<string, { toolCallId?: string, toolName?: string, threadId?: string }>}
|
|
752
|
+
* @type {Map<string, { toolCallId?: string, toolName?: string, threadId?: string, sessionId?: string, vpId?: string, turnId?: string }>}
|
|
709
753
|
*/
|
|
710
754
|
#asyncTaskToolMeta = new Map();
|
|
711
755
|
|
|
@@ -1467,6 +1511,8 @@ export class Engine {
|
|
|
1467
1511
|
}
|
|
1468
1512
|
|
|
1469
1513
|
#conversationRecord(message, { sessionId, turnId, model, incomplete = false, stopReason = null, executionOrigin = null } = {}) {
|
|
1514
|
+
const effectiveTurnId = turnId || message.turnId || null;
|
|
1515
|
+
const effectiveVpId = message.speakerVpId || this.#vpId || null;
|
|
1470
1516
|
const record = {
|
|
1471
1517
|
role: message.role,
|
|
1472
1518
|
content: typeof message.content === 'string'
|
|
@@ -1483,6 +1529,7 @@ export class Engine {
|
|
|
1483
1529
|
if (message.isError) record.isError = true;
|
|
1484
1530
|
if (message.imageAssetAnchor) record.imageAssetAnchor = true;
|
|
1485
1531
|
if (message._reflection) record._reflection = true;
|
|
1532
|
+
if (message._asyncTaskCompletion === true) record._asyncTaskCompletion = true;
|
|
1486
1533
|
if (message.role === 'user') record.userAuthored = message.userAuthored === true;
|
|
1487
1534
|
if (message.internal === true) record.internal = true;
|
|
1488
1535
|
if (message.responseKind === 'progress' || message.responseKind === 'result') {
|
|
@@ -1491,11 +1538,15 @@ export class Engine {
|
|
|
1491
1538
|
if (Array.isArray(message.foldedMessageIds) && message.foldedMessageIds.length > 0) {
|
|
1492
1539
|
record.foldedMessageIds = [...message.foldedMessageIds];
|
|
1493
1540
|
}
|
|
1494
|
-
if (
|
|
1541
|
+
if (effectiveTurnId && (message.role === 'assistant' || message.role === 'tool' || message.internal === true)) {
|
|
1542
|
+
record.turnId = effectiveTurnId;
|
|
1543
|
+
}
|
|
1495
1544
|
if (executionOrigin === 'route_forward' && (message.role === 'assistant' || message.role === 'tool')) {
|
|
1496
1545
|
record.executionOrigin = executionOrigin;
|
|
1497
1546
|
}
|
|
1498
|
-
if (
|
|
1547
|
+
if (effectiveVpId && (message.role === 'assistant' || message.role === 'tool' || message.internal === true)) {
|
|
1548
|
+
record.speakerVpId = effectiveVpId;
|
|
1549
|
+
}
|
|
1499
1550
|
if (incomplete) record.incomplete = true;
|
|
1500
1551
|
if (stopReason) record.stopReason = stopReason;
|
|
1501
1552
|
return record;
|
|
@@ -1744,22 +1795,53 @@ export class Engine {
|
|
|
1744
1795
|
if (!update?.toolCallId) continue;
|
|
1745
1796
|
const appendText = this.#formatTaskResultUpdateContent(update.content);
|
|
1746
1797
|
if (!appendText.trim()) continue;
|
|
1747
|
-
const toolMsg =
|
|
1748
|
-
|
|
1749
|
-
|
|
1798
|
+
const toolMsg = update.folded
|
|
1799
|
+
? null
|
|
1800
|
+
: [...conversationMessages].reverse().find((msg) => (
|
|
1801
|
+
msg && msg.role === 'tool' && msg.toolCallId === update.toolCallId
|
|
1802
|
+
));
|
|
1750
1803
|
if (!toolMsg) {
|
|
1751
|
-
this
|
|
1752
|
-
|
|
1753
|
-
|
|
1804
|
+
// T1/T2 may have folded the original tool row before this task
|
|
1805
|
+
// completed. The reflection is the canonical history, so a late
|
|
1806
|
+
// completion must not recreate the hidden tool arc on disk or in the
|
|
1807
|
+
// provider transcript. This is engine control context, not a fresh
|
|
1808
|
+
// user-authored message; ConversationStore keeps it off the visible
|
|
1809
|
+
// transcript while retaining it for the next provider boundary.
|
|
1810
|
+
const contextContent = truncateToolResultIfNeeded(appendText, {
|
|
1811
|
+
toolName: update.toolName || 'async task result',
|
|
1812
|
+
language: this.#config?.language,
|
|
1813
|
+
});
|
|
1814
|
+
const continuation = {
|
|
1815
|
+
role: 'user',
|
|
1816
|
+
content: `[system note] Async task completion for ${update.toolName || 'a folded tool call'}:\n${contextContent}`,
|
|
1754
1817
|
internal: true,
|
|
1755
|
-
|
|
1818
|
+
_asyncTaskCompletion: true,
|
|
1819
|
+
turnId: update.turnId || null,
|
|
1820
|
+
speakerVpId: update.vpId || null,
|
|
1821
|
+
};
|
|
1822
|
+
const persistedContinuation = this.#persistConversationMessage(continuation, {
|
|
1823
|
+
sessionId: update.sessionId || this.#sessionId,
|
|
1824
|
+
turnId: update.turnId || null,
|
|
1756
1825
|
});
|
|
1826
|
+
if (persistedContinuation?.id) continuation._persistedMessageId = persistedContinuation.id;
|
|
1827
|
+
conversationMessages.push(continuation);
|
|
1828
|
+
applied.push(update);
|
|
1829
|
+
if (this.#acceptedAsyncTaskResults.has(update.taskId)) {
|
|
1830
|
+
this.#pendingAsyncTaskConfirmIds.add(update.taskId);
|
|
1831
|
+
}
|
|
1757
1832
|
continue;
|
|
1758
1833
|
}
|
|
1759
1834
|
const prior = typeof toolMsg.content === 'string'
|
|
1760
1835
|
? toolMsg.content
|
|
1761
1836
|
: this.#formatTaskResultUpdateContent(toolMsg.content);
|
|
1762
|
-
|
|
1837
|
+
// Keep the durable row complete below, but apply the same per-tool
|
|
1838
|
+
// context cap used for the initial result before the next provider
|
|
1839
|
+
// request. Async completions otherwise bypassed the model-facing tool
|
|
1840
|
+
// result budget after their producing call had already returned.
|
|
1841
|
+
toolMsg.content = truncateToolResultIfNeeded(`${prior}\n\n${appendText}`, {
|
|
1842
|
+
toolName: update.toolName || 'async task result',
|
|
1843
|
+
language: this.#config?.language,
|
|
1844
|
+
});
|
|
1763
1845
|
const persistedTool = this.#persistedToolMessages.get(update.toolCallId);
|
|
1764
1846
|
if (persistedTool && typeof this.#conversationStore?.update === 'function') {
|
|
1765
1847
|
const durablePrior = typeof persistedTool.content === 'string'
|
|
@@ -2027,10 +2109,16 @@ export class Engine {
|
|
|
2027
2109
|
try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
|
|
2028
2110
|
}
|
|
2029
2111
|
this.retireAsyncTasks(abortCtrl.signal.aborted ? 'query_aborted' : 'query_closed');
|
|
2112
|
+
// The closure is local to this query. Do not let a late callback from a
|
|
2113
|
+
// retired run touch a later query's independent read cache.
|
|
2114
|
+
if (this.#activeReadOnlyToolReuse?.owner === abortCtrl) {
|
|
2115
|
+
this.#activeReadOnlyToolReuse = null;
|
|
2116
|
+
}
|
|
2030
2117
|
// Clear current-run state so engine.isRunning flips back to false
|
|
2031
2118
|
// and a subsequent query() starts with a clean slate.
|
|
2032
2119
|
this.#currentAbortCtrl = null;
|
|
2033
2120
|
this.#abortReason = null;
|
|
2121
|
+
this.#currentQueryTurnId = null;
|
|
2034
2122
|
this.#currentThreadId = MAIN_THREAD_ID;
|
|
2035
2123
|
this.#pendingUserMessages.length = 0;
|
|
2036
2124
|
this.#externalUserWakePending = false;
|
|
@@ -2071,12 +2159,39 @@ export class Engine {
|
|
|
2071
2159
|
? 'route_forward'
|
|
2072
2160
|
: null;
|
|
2073
2161
|
const queryTurnId = randomUUID();
|
|
2162
|
+
this.#currentQueryTurnId = vpTurnId || queryTurnId;
|
|
2163
|
+
// Bind the live query scope before tools can register async work. The
|
|
2164
|
+
// constructor's Session id is only guaranteed for bridge-owned engines;
|
|
2165
|
+
// standalone/CLI callers pass it per query.
|
|
2166
|
+
this.#sessionId = runtimeSessionId || null;
|
|
2167
|
+
this.#currentThreadId = runtimeThreadId;
|
|
2074
2168
|
const queryStartedAt = Date.now();
|
|
2075
2169
|
const userQuestionPreview = String(prompt || '').slice(0, 200);
|
|
2076
2170
|
const queryVpId = vpPersona && typeof vpPersona === 'object'
|
|
2077
2171
|
&& typeof vpPersona.vpId === 'string'
|
|
2078
2172
|
? vpPersona.vpId
|
|
2079
2173
|
: (typeof senderVpId === 'string' ? senderVpId : null);
|
|
2174
|
+
// Exact read-only tool results are safe to reuse within one query only
|
|
2175
|
+
// when no intervening mutation can have changed the workspace. The map is
|
|
2176
|
+
// intentionally local to this query; cross-turn reuse belongs to the
|
|
2177
|
+
// persistent tool log and must not silently bypass new user work. A
|
|
2178
|
+
// detached operation can mutate after its tool call returns, so it disables
|
|
2179
|
+
// reuse for the remainder of this query rather than leaving a timing window
|
|
2180
|
+
// for stale entries to be repopulated.
|
|
2181
|
+
const readOnlyToolResults = new Map();
|
|
2182
|
+
let readOnlyToolReuseDisabled = false;
|
|
2183
|
+
const invalidateReadOnlyToolReuse = () => {
|
|
2184
|
+
readOnlyToolResults.clear();
|
|
2185
|
+
readOnlyToolReuseDisabled = true;
|
|
2186
|
+
};
|
|
2187
|
+
// Completion callbacks run outside this lexical loop. Publish an
|
|
2188
|
+
// identity-bound hook so an accepted completion can close the reuse window
|
|
2189
|
+
// even after the next provider stream has started.
|
|
2190
|
+
const readOnlyToolReuseOwner = this.#currentAbortCtrl;
|
|
2191
|
+
this.#activeReadOnlyToolReuse = {
|
|
2192
|
+
owner: readOnlyToolReuseOwner,
|
|
2193
|
+
invalidate: invalidateReadOnlyToolReuse,
|
|
2194
|
+
};
|
|
2080
2195
|
|
|
2081
2196
|
// Durability boundary: a valid user turn must exist on disk before any
|
|
2082
2197
|
// memory pre-flow or provider request can fail. The Web Session bridge
|
|
@@ -2485,6 +2600,12 @@ export class Engine {
|
|
|
2485
2600
|
// control to other VPs cleanly. Reset to null at the top of every
|
|
2486
2601
|
// outer-loop iteration so the flag never carries across turns.
|
|
2487
2602
|
let endTurnRequested = null;
|
|
2603
|
+
// StartPlan is a control tool. If the model emits only the checklist after
|
|
2604
|
+
// it, there is no new workspace fact to interpret: persist the plan and
|
|
2605
|
+
// close the turn instead of spending another provider request on a
|
|
2606
|
+
// TodoWrite-only control round. A later user turn can continue the first
|
|
2607
|
+
// pending step; a batch that includes a real work tool always continues.
|
|
2608
|
+
let planBootstrapPending = false;
|
|
2488
2609
|
|
|
2489
2610
|
// LLM retry bookkeeping (rate-limit / 5xx / transient network errors).
|
|
2490
2611
|
// Counts CONSECUTIVE retryable failures on the same turn — reset to 0
|
|
@@ -2574,12 +2695,17 @@ export class Engine {
|
|
|
2574
2695
|
const thinkingBlocks = []; // task-327d: collected from adapter for round-trip
|
|
2575
2696
|
let stopReason = 'end_turn';
|
|
2576
2697
|
const totalUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cacheInputDeltaTokens: 0 };
|
|
2577
|
-
// task-344: capture
|
|
2698
|
+
// task-344: capture bounded raw request / raw response for the debug
|
|
2699
|
+
// panel. Both exchanges obey the live telemetry budget before they
|
|
2700
|
+
// reach durable debug trace storage.
|
|
2578
2701
|
let rawRequest = null;
|
|
2579
2702
|
let rawResponse = null;
|
|
2703
|
+
const rawExchangeMaxBytes = Number.isFinite(Number(this.#config?.telemetry?.rawExchangeMaxBytes))
|
|
2704
|
+
? Math.max(0, Number(this.#config.telemetry.rawExchangeMaxBytes))
|
|
2705
|
+
: 512 * 1024;
|
|
2580
2706
|
const captureRawExchange = (exchange) => {
|
|
2581
|
-
if (exchange?.rawRequest) rawRequest = exchange.rawRequest;
|
|
2582
|
-
if (exchange?.rawResponse) rawResponse = exchange.rawResponse;
|
|
2707
|
+
if (exchange?.rawRequest) rawRequest = boundRawExchange(exchange.rawRequest, rawExchangeMaxBytes);
|
|
2708
|
+
if (exchange?.rawResponse) rawResponse = boundRawExchange(exchange.rawResponse, rawExchangeMaxBytes);
|
|
2583
2709
|
};
|
|
2584
2710
|
|
|
2585
2711
|
// task-704b: resolve the live model's context window for this turn.
|
|
@@ -2611,6 +2737,12 @@ export class Engine {
|
|
|
2611
2737
|
}
|
|
2612
2738
|
}
|
|
2613
2739
|
const taskResultUpdatesBeforeStream = this.#drainPendingTaskResultUpdates(conversationMessages);
|
|
2740
|
+
if (taskResultUpdatesBeforeStream.length > 0) {
|
|
2741
|
+
// A completed sub-agent can have mutated the shared workspace while
|
|
2742
|
+
// this query was parked. Its task-result update is the authoritative
|
|
2743
|
+
// synchronization point before the next provider loop.
|
|
2744
|
+
invalidateReadOnlyToolReuse();
|
|
2745
|
+
}
|
|
2614
2746
|
if (taskResultUpdatesBeforeStream.length > 0) {
|
|
2615
2747
|
for (const update of taskResultUpdatesBeforeStream) {
|
|
2616
2748
|
yield {
|
|
@@ -2784,6 +2916,7 @@ export class Engine {
|
|
|
2784
2916
|
effortSource: userEffort ? 'user' : 'auto',
|
|
2785
2917
|
signal,
|
|
2786
2918
|
onRawExchange: captureRawExchange,
|
|
2919
|
+
rawExchangeMaxBytes,
|
|
2787
2920
|
onRequestStart: () => startProviderRequest?.(activeProviderRequest),
|
|
2788
2921
|
})) {
|
|
2789
2922
|
// task-325a (abort-stop fix): per-event abort short-circuit.
|
|
@@ -2809,7 +2942,13 @@ export class Engine {
|
|
|
2809
2942
|
}
|
|
2810
2943
|
switch (event.type) {
|
|
2811
2944
|
case 'text_delta':
|
|
2812
|
-
if (ttfbMs === null)
|
|
2945
|
+
if (ttfbMs === null) {
|
|
2946
|
+
ttfbMs = Date.now() - startTime;
|
|
2947
|
+
traceRequest('llm.first_text', {
|
|
2948
|
+
durationMs: perfNowMs() - requestPerfStart,
|
|
2949
|
+
detail: { model: currentModel },
|
|
2950
|
+
});
|
|
2951
|
+
}
|
|
2813
2952
|
responseText += event.text;
|
|
2814
2953
|
yield event;
|
|
2815
2954
|
break;
|
|
@@ -2833,6 +2972,12 @@ export class Engine {
|
|
|
2833
2972
|
}
|
|
2834
2973
|
break;
|
|
2835
2974
|
case 'tool_call':
|
|
2975
|
+
if (toolCalls.length === 0) {
|
|
2976
|
+
traceRequest('llm.first_tool_call', {
|
|
2977
|
+
durationMs: perfNowMs() - requestPerfStart,
|
|
2978
|
+
detail: { name: event.name || null, model: currentModel },
|
|
2979
|
+
});
|
|
2980
|
+
}
|
|
2836
2981
|
toolCalls.push(event);
|
|
2837
2982
|
yield event;
|
|
2838
2983
|
break;
|
|
@@ -2909,6 +3054,8 @@ export class Engine {
|
|
|
2909
3054
|
stopReason,
|
|
2910
3055
|
inputTokens: totalUsage.inputTokens,
|
|
2911
3056
|
outputTokens: totalUsage.outputTokens,
|
|
3057
|
+
toolCallCount: toolCalls.length,
|
|
3058
|
+
responseTextBytes: Buffer.byteLength(responseText, 'utf8'),
|
|
2912
3059
|
},
|
|
2913
3060
|
});
|
|
2914
3061
|
// Stream completed without throwing — reset the retry counter so
|
|
@@ -3270,7 +3417,7 @@ export class Engine {
|
|
|
3270
3417
|
// yielding any post-stream diagnostics. A consumer may stop iterating at
|
|
3271
3418
|
// any yield; persistence therefore cannot wait for turn_end or even the
|
|
3272
3419
|
// debug `loop` event below.
|
|
3273
|
-
const assistantMsg = { role: 'assistant', content: responseText, responseKind: 'progress' };
|
|
3420
|
+
const assistantMsg = { role: 'assistant', content: responseText, responseKind: 'progress', ...(rawRequest ? { rawRequest } : {}) };
|
|
3274
3421
|
if (toolCalls.length > 0) {
|
|
3275
3422
|
assistantMsg.toolCalls = toolCalls.map(tc => ({
|
|
3276
3423
|
id: tc.id,
|
|
@@ -3483,6 +3630,10 @@ export class Engine {
|
|
|
3483
3630
|
if (!signal?.aborted) {
|
|
3484
3631
|
const taskResultUpdatesAfterAsyncWait = this.#drainPendingTaskResultUpdates(conversationMessages);
|
|
3485
3632
|
if (taskResultUpdatesAfterAsyncWait.length > 0) {
|
|
3633
|
+
// This drain bypasses the next loop's pre-stream drain. A task
|
|
3634
|
+
// that completed while we waited may have changed the workspace,
|
|
3635
|
+
// so it is the same cache synchronization boundary.
|
|
3636
|
+
invalidateReadOnlyToolReuse();
|
|
3486
3637
|
for (const update of taskResultUpdatesAfterAsyncWait) {
|
|
3487
3638
|
yield {
|
|
3488
3639
|
type: 'tool_result_update',
|
|
@@ -3737,8 +3888,12 @@ export class Engine {
|
|
|
3737
3888
|
let output;
|
|
3738
3889
|
let displayImages = [];
|
|
3739
3890
|
let isError = false;
|
|
3891
|
+
let reusedReadOnlyResult = false;
|
|
3892
|
+
let reusedReadOnlyCallId = null;
|
|
3740
3893
|
let toolErrorOutput = null;
|
|
3741
3894
|
let fatalToolError = null;
|
|
3895
|
+
let cacheableTool = false;
|
|
3896
|
+
let duplicateKey = null;
|
|
3742
3897
|
currentToolCallForAsyncTask = skipped
|
|
3743
3898
|
? null
|
|
3744
3899
|
: {
|
|
@@ -3777,7 +3932,35 @@ export class Engine {
|
|
|
3777
3932
|
isError = true;
|
|
3778
3933
|
yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true, threadId: this.currentThreadId };
|
|
3779
3934
|
} else {
|
|
3780
|
-
|
|
3935
|
+
duplicateKey = `${tc.name}\u001f${argsHashOf(tc.input)}`;
|
|
3936
|
+
const readOnlyTool = isReadOnlyTool(this, tc.name, tc.input);
|
|
3937
|
+
cacheableTool = isCacheableTool(this, tc.name, tc.input);
|
|
3938
|
+
// The cache is only valid while the workspace has not changed. Clear
|
|
3939
|
+
// it before every potential mutation, including a tool that later
|
|
3940
|
+
// reports or throws an error after making a partial change. Detached
|
|
3941
|
+
// operations can still mutate after returning, so disable reuse for
|
|
3942
|
+
// the rest of this query instead of allowing stale entries to refill.
|
|
3943
|
+
if (!readOnlyTool) readOnlyToolResults.clear();
|
|
3944
|
+
const mayMutateAfterReturn = mayMutateWorkspaceAfterReturn(this, tc.name, tc.input);
|
|
3945
|
+
if (mayMutateAfterReturn) invalidateReadOnlyToolReuse();
|
|
3946
|
+
const cachedReadOnly = readOnlyToolResults.get(duplicateKey);
|
|
3947
|
+
if (!readOnlyToolReuseDisabled && cachedReadOnly && cacheableTool) {
|
|
3948
|
+
output = cachedReadOnly.output;
|
|
3949
|
+
isError = Boolean(cachedReadOnly.isError);
|
|
3950
|
+
reusedReadOnlyResult = true;
|
|
3951
|
+
reusedReadOnlyCallId = cachedReadOnly.callId || null;
|
|
3952
|
+
yield { type: 'tool_start', id: tc.id, name: tc.name, input: tc.input, threadId: this.currentThreadId, reused: true };
|
|
3953
|
+
yield {
|
|
3954
|
+
type: 'tool_end',
|
|
3955
|
+
id: tc.id,
|
|
3956
|
+
name: tc.name,
|
|
3957
|
+
output,
|
|
3958
|
+
displayImages: [],
|
|
3959
|
+
isError: cachedReadOnly.isError,
|
|
3960
|
+
reused: true,
|
|
3961
|
+
threadId: this.currentThreadId,
|
|
3962
|
+
};
|
|
3963
|
+
} else try {
|
|
3781
3964
|
yield { type: 'tool_start', id: tc.id, name: tc.name, input: tc.input, threadId: this.currentThreadId };
|
|
3782
3965
|
if (this.#toolRegistry) {
|
|
3783
3966
|
toolErrorOutput = this.#toolRegistry.get(tc.name)?.errorOutput || null;
|
|
@@ -3855,6 +4038,7 @@ export class Engine {
|
|
|
3855
4038
|
durationMs: toolDurationMs,
|
|
3856
4039
|
isError,
|
|
3857
4040
|
toolOutput: output,
|
|
4041
|
+
...(reusedReadOnlyResult ? { reused: true, reusedCallId: reusedReadOnlyCallId } : {}),
|
|
3858
4042
|
...(skipped ? { skipped: true } : {}),
|
|
3859
4043
|
...(displayImages.length > 0 ? { displayImageCount: displayImages.length } : {}),
|
|
3860
4044
|
};
|
|
@@ -3882,8 +4066,21 @@ export class Engine {
|
|
|
3882
4066
|
durationMs: toolDurationMs,
|
|
3883
4067
|
isError,
|
|
3884
4068
|
skipped,
|
|
4069
|
+
reused: reusedReadOnlyResult,
|
|
4070
|
+
reusedCallId: reusedReadOnlyCallId,
|
|
3885
4071
|
});
|
|
3886
4072
|
|
|
4073
|
+
if (!skipped && !reusedReadOnlyResult && tc.name === 'StartPlan') {
|
|
4074
|
+
planBootstrapPending = true;
|
|
4075
|
+
}
|
|
4076
|
+
if (!skipped && !reusedReadOnlyResult && !readOnlyToolReuseDisabled && cacheableTool) {
|
|
4077
|
+
readOnlyToolResults.set(duplicateKey, {
|
|
4078
|
+
output,
|
|
4079
|
+
isError,
|
|
4080
|
+
callId: tc.id,
|
|
4081
|
+
});
|
|
4082
|
+
}
|
|
4083
|
+
|
|
3887
4084
|
// Append only the bounded copy to the model message history. Raw
|
|
3888
4085
|
// `output` is still used for debug traces, UI events, exec-log, and
|
|
3889
4086
|
// persistence so large tool results are not lost outside context.
|
|
@@ -3939,6 +4136,36 @@ export class Engine {
|
|
|
3939
4136
|
conversationMessages.push({ role: 'user', content: reminder });
|
|
3940
4137
|
}
|
|
3941
4138
|
|
|
4139
|
+
// A plan bootstrap that produced only a TodoWrite has no executable work
|
|
4140
|
+
// to feed back to the provider. Close it here. This is intentionally
|
|
4141
|
+
// narrow: StartPlan + TodoWrite is a valid planning result, but a batch
|
|
4142
|
+
// containing any other tool must continue so the model can inspect its
|
|
4143
|
+
// result before deciding what to do next.
|
|
4144
|
+
const onlyPlanControls = planBootstrapPending
|
|
4145
|
+
&& toolCalls.length > 0
|
|
4146
|
+
&& toolCalls.every(call => call.name === 'StartPlan' || call.name === 'TodoWrite')
|
|
4147
|
+
&& toolCalls.some(call => call.name === 'TodoWrite')
|
|
4148
|
+
&& !toolBatchBarrier
|
|
4149
|
+
&& !endTurnRequested
|
|
4150
|
+
&& !abortedDuringTools
|
|
4151
|
+
&& !signal?.aborted;
|
|
4152
|
+
if (onlyPlanControls) {
|
|
4153
|
+
const hasPendingStep = toolCalls
|
|
4154
|
+
.filter(call => call.name === 'TodoWrite')
|
|
4155
|
+
.some(call => Array.isArray(call.input?.todos)
|
|
4156
|
+
&& call.input.todos.some(todo => todo?.status === 'pending' || todo?.status === 'in_progress'));
|
|
4157
|
+
yield {
|
|
4158
|
+
type: 'turn_end',
|
|
4159
|
+
turnNumber,
|
|
4160
|
+
stopReason: 'plan_recorded',
|
|
4161
|
+
detail: { nextStep: hasPendingStep ? 'pending_work_tools' : 'none', toolCount: toolCalls.length },
|
|
4162
|
+
threadId,
|
|
4163
|
+
terminal: true,
|
|
4164
|
+
};
|
|
4165
|
+
break;
|
|
4166
|
+
}
|
|
4167
|
+
planBootstrapPending = false;
|
|
4168
|
+
|
|
3942
4169
|
// A batch barrier deliberately returns control to the provider. Any
|
|
3943
4170
|
// handoff requested by an earlier call belongs to the invalidated plan
|
|
3944
4171
|
// and must not leak into a later provider-generated batch.
|
|
@@ -4047,6 +4274,16 @@ export class Engine {
|
|
|
4047
4274
|
if (durableRowsInRange && !persistedReflection) {
|
|
4048
4275
|
throw new Error('T1 reflection could not publish its durable range replacement');
|
|
4049
4276
|
}
|
|
4277
|
+
// Raw tool rows inside the replacement range are now tombstoned in
|
|
4278
|
+
// durable history. Late async completions must follow the folded
|
|
4279
|
+
// continuation path rather than appending to those stale rows.
|
|
4280
|
+
const foldedToolCallIds = conversationMessages
|
|
4281
|
+
.slice(batchStart, batchEnd + 1)
|
|
4282
|
+
.filter(message => message?.role === 'tool' && message.toolCallId)
|
|
4283
|
+
.map(message => message.toolCallId);
|
|
4284
|
+
for (const toolCallId of foldedToolCallIds) {
|
|
4285
|
+
this.#persistedToolMessages.delete(toolCallId);
|
|
4286
|
+
}
|
|
4050
4287
|
conversationMessages.length = 0;
|
|
4051
4288
|
for (const m of next) conversationMessages.push(m);
|
|
4052
4289
|
// After collapse: the just-inserted reflection lives at
|
|
@@ -4164,6 +4401,16 @@ export class Engine {
|
|
|
4164
4401
|
/** @returns {import('./tools/registry.js').ToolRegistry|null} */
|
|
4165
4402
|
get toolRegistry() { return this.#toolRegistry; }
|
|
4166
4403
|
|
|
4404
|
+
/**
|
|
4405
|
+
* Return a registered tool definition for internal loop policy checks.
|
|
4406
|
+
* This keeps the duplicate-result fast path on the same registry metadata
|
|
4407
|
+
* used by normal execution, without exposing the private maps.
|
|
4408
|
+
*/
|
|
4409
|
+
getToolDefinition(name) {
|
|
4410
|
+
if (this.#toolRegistry) return this.#toolRegistry.get(name);
|
|
4411
|
+
return this.#tools.get(name) || null;
|
|
4412
|
+
}
|
|
4413
|
+
|
|
4167
4414
|
/** @returns {import('./skills.js').SkillManager|null} */
|
|
4168
4415
|
get skillManager() { return this.#skillManager; }
|
|
4169
4416
|
|
|
@@ -4241,6 +4488,16 @@ export class Engine {
|
|
|
4241
4488
|
},
|
|
4242
4489
|
);
|
|
4243
4490
|
if (durableRowsInRange && !persistedReflection) continue;
|
|
4491
|
+
// T2 replaces this arc in durable and in-memory history just like T1.
|
|
4492
|
+
// Forget its raw result handles before a late async task can append to a
|
|
4493
|
+
// tombstoned tool row at the next provider boundary.
|
|
4494
|
+
const foldedToolCallIds = conversationMessages
|
|
4495
|
+
.slice(startIdx, endIdx + 1)
|
|
4496
|
+
.filter(message => message?.role === 'tool' && message.toolCallId)
|
|
4497
|
+
.map(message => message.toolCallId);
|
|
4498
|
+
for (const toolCallId of foldedToolCallIds) {
|
|
4499
|
+
this.#persistedToolMessages.delete(toolCallId);
|
|
4500
|
+
}
|
|
4244
4501
|
// Mutate in place so caller's reference stays valid.
|
|
4245
4502
|
conversationMessages.length = 0;
|
|
4246
4503
|
for (const m of next) conversationMessages.push(m);
|
|
@@ -4381,7 +4638,7 @@ export class Engine {
|
|
|
4381
4638
|
*
|
|
4382
4639
|
* @param {string} taskId
|
|
4383
4640
|
* @param {string|Array} content — pre-formatted task result body
|
|
4384
|
-
* @param {{ preview?: string, sessionId?: string, vpId?: string, threadId?: string, taskKind?: string, taskStatus?: string }} [opts]
|
|
4641
|
+
* @param {{ preview?: string, sessionId?: string, vpId?: string, threadId?: string, taskKind?: string, taskStatus?: string, turnId?: string }} [opts]
|
|
4385
4642
|
* @returns {boolean}
|
|
4386
4643
|
*/
|
|
4387
4644
|
notifyAsyncTaskCompleted(taskId, content, opts = {}) {
|
|
@@ -4394,19 +4651,34 @@ export class Engine {
|
|
|
4394
4651
|
// forward and bill for. Production callers (formatTaskResultForVp)
|
|
4395
4652
|
// always emit a non-empty string today; this guards future refactors.
|
|
4396
4653
|
if (Array.isArray(content) && content.length === 0) return false;
|
|
4654
|
+
// This is the actual workspace-synchronization boundary. The next
|
|
4655
|
+
// provider stream may already be yielding tool calls, so waiting until a
|
|
4656
|
+
// later queue drain leaves one stale-cache reuse window open.
|
|
4657
|
+
const activeReadOnlyReuse = this.#activeReadOnlyToolReuse;
|
|
4658
|
+
if (activeReadOnlyReuse?.owner === this.#currentAbortCtrl) {
|
|
4659
|
+
activeReadOnlyReuse.invalidate();
|
|
4660
|
+
}
|
|
4397
4661
|
this.#pendingAsyncTaskIds.delete(taskId);
|
|
4398
4662
|
const preview = typeof opts.preview === 'string'
|
|
4399
4663
|
? opts.preview
|
|
4400
4664
|
: (typeof content === 'string' ? content.slice(0, 200) : '[task result]');
|
|
4401
4665
|
const meta = this.#asyncTaskToolMeta.get(taskId) || {};
|
|
4666
|
+
// A completion that lands while its original tool arc is still present
|
|
4667
|
+
// can update that tool result in-place. Once T1/T2 folded the arc, the
|
|
4668
|
+
// provider must receive only a continuation note; otherwise we recreate
|
|
4669
|
+
// a raw tool row after the reflection and invalidate the fold.
|
|
4670
|
+
const hasLiveToolRow = typeof meta.toolCallId === 'string'
|
|
4671
|
+
&& meta.toolCallId
|
|
4672
|
+
&& Boolean(this.#persistedToolMessages.get(meta.toolCallId));
|
|
4402
4673
|
const delivery = {
|
|
4403
4674
|
content,
|
|
4404
4675
|
preview,
|
|
4405
|
-
sessionId: opts.sessionId,
|
|
4406
|
-
vpId: opts.vpId,
|
|
4676
|
+
sessionId: opts.sessionId || meta.sessionId || this.#sessionId || null,
|
|
4677
|
+
vpId: opts.vpId || meta.vpId || this.#vpId || null,
|
|
4407
4678
|
threadId: opts.threadId || meta.threadId,
|
|
4408
4679
|
taskKind: opts.taskKind,
|
|
4409
4680
|
taskStatus: opts.taskStatus,
|
|
4681
|
+
turnId: opts.turnId || meta.turnId || null,
|
|
4410
4682
|
};
|
|
4411
4683
|
this.#acceptedAsyncTaskResults.set(taskId, delivery);
|
|
4412
4684
|
this.#asyncTaskToolMeta.delete(taskId);
|
|
@@ -4414,8 +4686,16 @@ export class Engine {
|
|
|
4414
4686
|
this.#pendingTaskResultUpdates.push({
|
|
4415
4687
|
taskId,
|
|
4416
4688
|
toolCallId: meta.toolCallId,
|
|
4689
|
+
toolName: meta.toolName,
|
|
4417
4690
|
content,
|
|
4418
4691
|
preview,
|
|
4692
|
+
sessionId: delivery.sessionId,
|
|
4693
|
+
vpId: delivery.vpId,
|
|
4694
|
+
turnId: delivery.turnId,
|
|
4695
|
+
// #persistedToolMessages is cleared as soon as folding publishes its
|
|
4696
|
+
// durable reflection. Capture that boundary at completion time so a
|
|
4697
|
+
// later drain cannot infer stale liveness from an unrelated row.
|
|
4698
|
+
folded: !hasLiveToolRow,
|
|
4419
4699
|
});
|
|
4420
4700
|
} else {
|
|
4421
4701
|
this.#pendingTaskResultMessages.push({
|
|
@@ -4433,7 +4713,7 @@ export class Engine {
|
|
|
4433
4713
|
* Register a result-producing async task as belonging to the current query.
|
|
4434
4714
|
* Called by tools such as SpawnAgent via `toolCtx.registerAsyncTask`.
|
|
4435
4715
|
* @param {string} taskId
|
|
4436
|
-
* @param {{ id?: string, name?: string, threadId?: string, toolCallId?: string, toolName?: string }} [meta]
|
|
4716
|
+
* @param {{ id?: string, name?: string, threadId?: string, toolCallId?: string, toolName?: string, sessionId?: string, vpId?: string, turnId?: string }} [meta]
|
|
4437
4717
|
* @returns {void}
|
|
4438
4718
|
*/
|
|
4439
4719
|
#registerAsyncTask(taskId, meta = {}) {
|
|
@@ -4447,6 +4727,16 @@ export class Engine {
|
|
|
4447
4727
|
toolCallId,
|
|
4448
4728
|
toolName: typeof meta.toolName === 'string' && meta.toolName ? meta.toolName : (typeof meta.name === 'string' ? meta.name : undefined),
|
|
4449
4729
|
threadId: typeof meta.threadId === 'string' && meta.threadId ? meta.threadId : undefined,
|
|
4730
|
+
sessionId: typeof meta.sessionId === 'string' && meta.sessionId
|
|
4731
|
+
? meta.sessionId
|
|
4732
|
+
: (this.#sessionId || null),
|
|
4733
|
+
vpId: typeof meta.vpId === 'string' && meta.vpId ? meta.vpId : (this.#vpId || null),
|
|
4734
|
+
// query() exposes the wire turn id while the task is registered.
|
|
4735
|
+
// Persist it with a late completion even after T1/T2 removed the
|
|
4736
|
+
// original tool row from the in-memory arc.
|
|
4737
|
+
turnId: typeof meta.turnId === 'string' && meta.turnId
|
|
4738
|
+
? meta.turnId
|
|
4739
|
+
: (this.#currentQueryTurnId || null),
|
|
4450
4740
|
});
|
|
4451
4741
|
}
|
|
4452
4742
|
try { this.#asyncTaskCoordinator?.onRegister?.(taskId, this); } catch { /* coord must not throw into tools */ }
|
package/yeaft/llm/adapter.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* The engine sees only unified types — it never knows which API is underneath.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { utf8PrefixWithinBytes } from '../utf8.js';
|
|
16
|
+
|
|
15
17
|
// ─── Unified Types ─────────────────────────────────────────────
|
|
16
18
|
|
|
17
19
|
/**
|
|
@@ -461,6 +463,42 @@ export function classifyFetchError(err, opts = {}) {
|
|
|
461
463
|
* @param {{ url: string, method: string, headers: object, body: any }} req
|
|
462
464
|
* @returns {{ url: string, method: string, headers: object, body: any }}
|
|
463
465
|
*/
|
|
466
|
+
|
|
467
|
+
export function createBoundedTextAccumulator(maxBytes = 512 * 1024) {
|
|
468
|
+
const limit = Number.isFinite(Number(maxBytes)) ? Math.max(0, Math.floor(Number(maxBytes))) : 512 * 1024;
|
|
469
|
+
const chunks = [];
|
|
470
|
+
let retainedBytes = 0;
|
|
471
|
+
let totalBytes = 0;
|
|
472
|
+
let truncated = false;
|
|
473
|
+
return {
|
|
474
|
+
push(value) {
|
|
475
|
+
const text = String(value ?? '');
|
|
476
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
477
|
+
totalBytes += bytes;
|
|
478
|
+
if (retainedBytes >= limit || bytes === 0) {
|
|
479
|
+
if (bytes > 0) truncated = true;
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const available = limit - retainedBytes;
|
|
483
|
+
if (bytes <= available) {
|
|
484
|
+
chunks.push(text);
|
|
485
|
+
retainedBytes += bytes;
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
// Walk UTF-16 code points once. The old slice(0, -1) loop repeatedly
|
|
489
|
+
// rescanned an oversized SSE chunk and went quadratic in its length.
|
|
490
|
+
const retained = utf8PrefixWithinBytes(text, available);
|
|
491
|
+
if (retained.text) chunks.push(retained.text);
|
|
492
|
+
retainedBytes += retained.bytes;
|
|
493
|
+
truncated = true;
|
|
494
|
+
},
|
|
495
|
+
text() { return chunks.join(''); },
|
|
496
|
+
get totalBytes() { return totalBytes; },
|
|
497
|
+
get truncated() { return truncated; },
|
|
498
|
+
get maxBytes() { return limit; },
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
464
502
|
export function redactRawRequest(req) {
|
|
465
503
|
if (!req || typeof req !== 'object') return req;
|
|
466
504
|
const headers = { ...(req.headers || {}) };
|