@yeaft/webchat-agent 1.0.637 → 1.0.638

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.637",
3
+ "version": "1.0.638",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/engine.js CHANGED
@@ -3311,7 +3311,9 @@ export class Engine {
3311
3311
  totalUsage.cacheInputDeltaTokens += cacheInputDeltaTokens;
3312
3312
  cumulativeInputTokens += inputTokens + cacheInputDeltaTokens;
3313
3313
  cumulativeOutputTokens += outputTokens;
3314
+ queryProgress.usageObserved(event);
3314
3315
  yield event;
3316
+ yield queryProgress.snapshot(progressIdentity);
3315
3317
  break;
3316
3318
  }
3317
3319
  case 'stop':
@@ -4856,6 +4858,7 @@ export class Engine {
4856
4858
  reused: reusedReadOnlyResult || Boolean(readyParallelExecution?.shared),
4857
4859
  skipped: skipped || needsProjectDocReload,
4858
4860
  policy: duplicateCallPolicy,
4861
+ pendingOutcome: pendingPresentations.has(tc.id),
4859
4862
  });
4860
4863
  const toolDurationMs = readyParallelExecution?.durationMs ?? (Date.now() - toolStartTime);
4861
4864
 
@@ -4994,9 +4997,11 @@ export class Engine {
4994
4997
  if (toolMessage) Object.assign(toolMessage, { content: output, isError: true });
4995
4998
  const toolRecord = this.#persistedToolMessages.get(callId);
4996
4999
  try { this.#conversationStore?.update?.(toolRecord, { content: output, isError: true }); } catch { /* original storage failure */ }
5000
+ queryProgress.finalizeOutcome(callId, true);
4997
5001
  yield { type: 'tool_end', id: callId, name: 'PresentResponse', output, isError: true, threadId: runtimeThreadId };
4998
5002
  continue;
4999
5003
  }
5004
+ queryProgress.finalizeOutcome(callId, false);
5000
5005
  publishedResponses.set(callId, record);
5001
5006
  lastPersistedAssistantTextMessage = record;
5002
5007
  // Metadata is transcript/UI-only. Providers receive the summary as a
@@ -5137,8 +5142,39 @@ export class Engine {
5137
5142
  loopRange: [batchStart, batchEnd],
5138
5143
  toolCount: pairs.length,
5139
5144
  };
5145
+ // Observe only this synchronous query-owned call. Do not tap the
5146
+ // shared accounting adapter (also used by children), or feed these
5147
+ // costs into the main loop's token/dispatch budget. Capture raw usage
5148
+ // before reflection diagnostics normalize away cache-inclusion flags.
5149
+ const reflectionAdapter = typeof this.#adapter.call === 'function' ? {
5150
+ call: async params => {
5151
+ let dispatches = 0;
5152
+ let result;
5153
+ let failure;
5154
+ try {
5155
+ result = await this.#adapter.call({
5156
+ ...params,
5157
+ onRequestStart: (...args) => {
5158
+ dispatches++;
5159
+ queryProgress.requestStarted();
5160
+ params.onRequestStart?.(...args);
5161
+ },
5162
+ });
5163
+ return result;
5164
+ } catch (error) {
5165
+ failure = error;
5166
+ throw error;
5167
+ } finally {
5168
+ // Native adapters report every dispatch, including a refreshed
5169
+ // credential retry. Legacy adapters without the hook count once.
5170
+ if (dispatches === 0) queryProgress.requestStarted();
5171
+ const usage = result?.usage || failure?.usage;
5172
+ if (usage) queryProgress.usageObserved(usage);
5173
+ }
5174
+ },
5175
+ } : this.#adapter;
5140
5176
  const { content, durationMs } = await runT1Reflection({
5141
- adapter: this.#adapter,
5177
+ adapter: reflectionAdapter,
5142
5178
  model: this.#config.model,
5143
5179
  originalUserMsg: prompt,
5144
5180
  toolPairs: pairs,
@@ -5228,6 +5264,7 @@ export class Engine {
5228
5264
  // when every T1 attempt has errored.
5229
5265
  lastT1AtLoopCount = completedToolLoops;
5230
5266
  }
5267
+ yield queryProgress.snapshot(progressIdentity);
5231
5268
  }
5232
5269
  }
5233
5270
 
@@ -7,6 +7,7 @@ import { createHash } from 'node:crypto';
7
7
  import { argsHashOf } from './tool-folding/exec-log.js';
8
8
 
9
9
  const MAX_ENTRIES = 1024;
10
+ const MAX_TOOL_STATS = 128;
10
11
  const WARNING_BATCHES = 4;
11
12
  const RECOVERY_BATCHES = 8;
12
13
  const REPORT_BATCHES = 12;
@@ -61,6 +62,10 @@ export class QueryProgress {
61
62
  #results = new Map();
62
63
  #mutations = new Map();
63
64
  #failures = new Map();
65
+ #toolStats = new Map();
66
+ #pendingOutcomes = new Map();
67
+ #usageTotals = { inputTokens: 0, outputTokens: 0 };
68
+ #toolStatsTruncated = false;
64
69
  #batch = { evidence: false, repeated: false, failed: false };
65
70
  #revision = 0;
66
71
  #state = {
@@ -70,6 +75,23 @@ export class QueryProgress {
70
75
 
71
76
  requestStarted() { this.#state.modelRequests += 1; }
72
77
 
78
+ /** Query-local telemetry only: include synchronous side calls, not billing or loop budgets. */
79
+ usageObserved(usage) {
80
+ const reported = key => (!Array.isArray(usage.reportedTokenFields) || usage.reportedTokenFields.includes(key))
81
+ && Number.isSafeInteger(usage[key]) && usage[key] >= 0;
82
+ const cacheInputDelta = usage.cacheTokensAreIncludedInInput ? 0
83
+ : (usage.cacheReadTokens || 0) + (usage.cacheWriteTokens || 0);
84
+ this.#usageTotals.inputTokens += (usage.inputTokens || 0) + cacheInputDelta;
85
+ this.#usageTotals.outputTokens += usage.outputTokens || 0;
86
+ const inputReported = reported('inputTokens') || (!usage.cacheTokensAreIncludedInInput
87
+ && ['cacheReadTokens', 'cacheWriteTokens'].some(key => reported(key) && usage[key] > 0));
88
+ for (const [key, known] of [['inputTokens', inputReported], ['outputTokens', reported('outputTokens')]]) {
89
+ if (!known) continue;
90
+ if (Number.isSafeInteger(this.#usageTotals[key]) && this.#usageTotals[key] >= 0) this.#state[key] = this.#usageTotals[key];
91
+ else delete this.#state[key];
92
+ }
93
+ }
94
+
73
95
  /** Caller feedback/new owned task results may invalidate prior observations. */
74
96
  resetEvidence() {
75
97
  this.#results.clear();
@@ -93,11 +115,33 @@ export class QueryProgress {
93
115
  return `Blocked unchanged failed call to ${call.name} after ${failure.count} equivalent failures. This call was NOT executed. Use the earlier error to correct the arguments or choose a different supported path. If an earlier operation timed out or disconnected, its side effects may already have happened: verify the target state before retrying. Otherwise report the concrete blocker. Previous failed call: ${failure.callId}.`;
94
116
  }
95
117
 
96
- observe({ call, output, isError = false, readOnly = false, executed = true, reused = false, skipped = false, policy = 'warn' }) {
118
+ observe({ call, output, isError = false, readOnly = false, executed = true, reused = false, skipped = false, policy = 'warn', pendingOutcome = false }) {
97
119
  if (skipped) return;
98
120
  if (executed) this.#state.toolExecutions += 1;
99
121
  if (reused) this.#state.reusedCalls += 1;
100
122
  if (isError) this.#state.failedCalls += 1;
123
+ // Keep this bounded independently of the evidence caches. Existing rows
124
+ // remain cumulative even after the catalogue limit or an evidence reset.
125
+ const name = call.name;
126
+ const validName = typeof name === 'string' && /^[^\u0000-\u001f\u007f]{1,128}$/.test(name) && name.trim() === name;
127
+ // Never rewrite names: truncation/normalization can collide with another tool.
128
+ let stats = this.#toolStats.get(name);
129
+ if (!stats && validName && this.#toolStats.size < MAX_TOOL_STATS) {
130
+ stats = { name, executions: 0, failedCalls: 0, blockedCalls: 0 };
131
+ this.#toolStats.set(name, stats);
132
+ }
133
+ if (!stats) this.#toolStatsTruncated = true;
134
+ if (stats) {
135
+ if (executed) stats.executions += 1;
136
+ if (isError) {
137
+ stats.failedCalls += 1;
138
+ if (!executed) stats.blockedCalls += 1;
139
+ }
140
+ }
141
+ // Only publications with a delayed durable acknowledgement need correction.
142
+ // Keep it separate from evidence/failure caches so final telemetry cannot
143
+ // change duplicate detection, replay protection or loop-guard decisions.
144
+ if (pendingOutcome) this.#pendingOutcomes.set(call.id, { stats, isError, executed });
101
145
  const key = `${call.name}:${argsHashOf(call.input)}`;
102
146
  const hash = resultHash(output);
103
147
  if (isError) {
@@ -166,8 +210,22 @@ export class QueryProgress {
166
210
  }
167
211
  }
168
212
 
213
+ /** Final acknowledgement may change status, but must never count another execution. */
214
+ finalizeOutcome(callId, isError) {
215
+ const pending = this.#pendingOutcomes.get(callId);
216
+ if (!pending) return;
217
+ this.#pendingOutcomes.delete(callId);
218
+ const delta = Number(isError) - Number(pending.isError);
219
+ this.#state.failedCalls += delta;
220
+ if (pending.stats) {
221
+ pending.stats.failedCalls += delta;
222
+ if (!pending.executed) pending.stats.blockedCalls += delta;
223
+ }
224
+ }
225
+
169
226
  /** Called only AFTER every tool result is paired and persisted. */
170
227
  finishBatch() {
228
+ this.#pendingOutcomes.clear();
171
229
  const previousStage = this.#state.stage;
172
230
  if (this.#batch.evidence) {
173
231
  this.#state.stagnantBatches = 0;
@@ -188,7 +246,9 @@ export class QueryProgress {
188
246
  }
189
247
 
190
248
  snapshot(identity = {}) {
191
- return { type: 'query_progress', ...identity, revision: ++this.#revision, ...this.#state };
249
+ return { type: 'query_progress', ...identity, revision: ++this.#revision, ...this.#state,
250
+ toolStats: [...this.#toolStats.values()].map(stats => ({ ...stats })),
251
+ ...(this.#toolStatsTruncated ? { toolStatsTruncated: true } : {}) };
192
252
  }
193
253
  }
194
254
 
@@ -4168,9 +4168,26 @@ function handleEngineEvent(event, hctx) {
4168
4168
  hctx.queryProgressRevision = event.revision;
4169
4169
  const { turnId, threadId, sessionId, vpId, revision, modelRequests, toolExecutions, reusedCalls,
4170
4170
  repeatedCalls, failedCalls, stage, reason, lastEvidenceAt, stagnantBatches } = event;
4171
+ // Optional enhancements must never invalidate a legacy progress frame or
4172
+ // carry arbitrary provider/tool payloads across this projection boundary.
4173
+ const enhanced = {};
4174
+ for (const key of ['inputTokens', 'outputTokens']) {
4175
+ if (Number.isSafeInteger(event[key]) && event[key] >= 0) enhanced[key] = event[key];
4176
+ }
4177
+ if (Array.isArray(event.toolStats) && event.toolStats.length <= 128) {
4178
+ const names = new Set();
4179
+ if (event.toolStats.every(row => row && typeof row.name === 'string'
4180
+ && /^[^\u0000-\u001f\u007f]{1,128}$/.test(row.name) && row.name.trim() === row.name
4181
+ && !names.has(row.name) && names.add(row.name)
4182
+ && ['executions', 'failedCalls', 'blockedCalls'].every(key => Number.isSafeInteger(row[key]) && row[key] >= 0)
4183
+ && row.blockedCalls <= row.failedCalls)) {
4184
+ enhanced.toolStats = event.toolStats.map(({ name, executions, failedCalls, blockedCalls }) => ({ name, executions, failedCalls, blockedCalls }));
4185
+ if (typeof event.toolStatsTruncated === 'boolean') enhanced.toolStatsTruncated = event.toolStatsTruncated;
4186
+ }
4187
+ }
4171
4188
  sendToServer({ type: 'yeaft_output', conversationId: yeaftConversationId, sessionId, vpId, turnId, threadId,
4172
4189
  event: { type: 'query_progress', turnId, threadId, sessionId, vpId, revision, modelRequests,
4173
- toolExecutions, reusedCalls, repeatedCalls, failedCalls, stage, reason, lastEvidenceAt, stagnantBatches } });
4190
+ toolExecutions, reusedCalls, repeatedCalls, failedCalls, stage, reason, lastEvidenceAt, stagnantBatches, ...enhanced } });
4174
4191
  return;
4175
4192
  }
4176
4193
  if (event.type === 'aborted' || (event.type === 'turn_end' && event.terminal === true)) hctx.queryProgressTerminal = true;