@yeaft/webchat-agent 1.0.610 → 1.0.612

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.610",
3
+ "version": "1.0.612",
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",
@@ -1,7 +1,10 @@
1
1
  import { createHash } from 'node:crypto';
2
2
 
3
3
  // 只允许应用层显式产物;不要求或保存 provider 的隐藏推理。
4
- export const LIMITS = Object.freeze({ inputBytes: 8192, outputBytes: 65536, contextBytes: 65536, calls: 16, timeoutMs: 120000, leaseMs: 15000 });
4
+ export const LIMITS = Object.freeze({ inputBytes: 8192, outputBytes: 65536, contextBytes: 65536, calls: 16, leaseMs: 15000 });
5
+ // Feedback cadence is not an execution deadline. Slow providers/tools remain cancellable.
6
+ export const FEEDBACK = Object.freeze({ minMs: 30000, maxMs: 60000 });
7
+ export const FEEDBACK_INSTRUCTIONS = "feedback describes user-visible communication, not a deadline. After about 30 seconds without a substantive reply, use reply for a concise, useful progress update at a natural execution boundary; aim for 30\u201360 seconds according to actual progress. Explain verified findings, limitations and what you are checking next, in the user's language. Do not repeat feedback.lastReply, invent progress, expose internal deliberation/decision/selfCheck, or claim an unexecuted tool succeeded. If no meaningful update exists, reply:null is appropriate; the host shows an honest waiting status around 60 seconds without interrupting the current request. With next non-null, a validated reply may be published as progress independently of cognitive state, and retained on cancellation/failure. Only next:null commits cognition and supplies the final reply. Do not call a tool or an extra model just to report progress. Each final reply should stand on its own and note unfinished work.";
5
8
  const ERRORS = {
6
9
  INVALID_REQUEST: 'Invalid digital person request.', NOT_CONFIGURED: 'Digital person storage configuration is missing.',
7
10
  STORAGE_UNAVAILABLE: 'Digital person storage is unavailable. Check instance storage and database configuration.',
@@ -20,7 +20,7 @@ export function personName(value) {
20
20
  const pick = (value, keys) => value == null ? null : Object.fromEntries(keys.filter(key => Object.hasOwn(value, key)).map(key => [key, value[key]]));
21
21
  export const settingsView = value => pick(value, ['autonomyEnabled', 'modelCandidates', 'defaultModel']);
22
22
  export function messageView(value) {
23
- const result = pick(value, ['id', 'schemaVersion', 'revision', 'seq', 'episodeId', 'clientMessageId', 'role', 'text', 'createdAt']);
23
+ const result = pick(value, ['id', 'schemaVersion', 'revision', 'seq', 'episodeId', 'clientMessageId', 'callId', 'replyKind', 'role', 'text', 'createdAt']);
24
24
  if (result && Array.isArray(value.attachments)) result.attachments = value.attachments.slice(0, 4)
25
25
  .map(file => pick(file, ['id', 'name', 'mimeType', 'size', 'sha256', 'kind']));
26
26
  return result;
@@ -1,16 +1,17 @@
1
1
  import { createPersonToolHost, createPersonNativeRegistry, isNativeTool, projectNativeResult } from './native-tools.js';
2
2
  import { PersonTaskHost } from './task-host.js';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { utf8PrefixWithinBytes } from '../utf8.js';
4
5
  import { CAPABILITY_MAP, CAPABILITY_LIMITS, foundationCapabilities, PersonCapabilities, catalogRevision as capabilityCatalogRevision } from './capabilities.js';
5
6
  import { attachmentMetadata } from './attachments.js';
6
7
  import { abortable, collectOutput } from './provider.js';
7
- import { bytes, digest, fail, LIMITS, PersonError, PROPOSAL_INSTRUCTIONS, reportedLineage, safeError, validateProposal, validateSelection } from './contracts.js';
8
+ import { bytes, digest, fail, FEEDBACK, FEEDBACK_INSTRUCTIONS, LIMITS, PersonError, PROPOSAL_INSTRUCTIONS, reportedLineage, safeError, validateProposal, validateSelection } from './contracts.js';
8
9
 
9
10
  const messageRef = m => `message:${m.id}:${m.revision}`;
10
11
  const conceptRef = c => `concept:${c.id}:${c.revision}`;
11
12
 
12
13
  /** Assemble bounded request copies. Omitting a record never deletes or truncates its durable original. */
13
- export function assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, remainingCalls, taskEvidence, dependencyRefs = [], activeCapabilities = foundationCapabilities(), capabilityMap = CAPABILITY_MAP, attachments = [], environment }) {
14
+ export function assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, remainingCalls, taskEvidence, dependencyRefs = [], activeCapabilities = foundationCapabilities(), capabilityMap = CAPABILITY_MAP, attachments = [], environment, feedback }) {
14
15
  const model = validateSelection(selection, provider.catalog);
15
16
  // UTF-8 bytes is a conservative text-token bound; reserve explicit envelope/output overhead.
16
17
  const images = attachments.filter(file => file.kind === 'image');
@@ -74,6 +75,16 @@ export function assembleContext({ snapshot, episode, provider, selection, previo
74
75
  omittedCapabilities.push({ id: contract.id, reason: 'context-budget', inspect: 'catalog.view' });
75
76
  }
76
77
  }
78
+ // Feedback is a request-only hint, never grounds to fail the next call.
79
+ // Keep durable replies intact; avoid duplicating up to 8 KiB from previousProposal.
80
+ if (feedback) {
81
+ const preview = utf8PrefixWithinBytes(feedback.lastReply ?? '', 512).text;
82
+ context.feedback = { ...feedback, instructions: FEEDBACK_INSTRUCTIONS, lastReply: preview || null,
83
+ lastReplyTruncated: bytes(feedback.lastReply ?? '') > bytes(preview) };
84
+ if (!fits()) { context.feedback.lastReply = null; context.feedback.lastReplyTruncated = Boolean(feedback.lastReply); }
85
+ if (!fits()) delete context.feedback.instructions;
86
+ if (!fits()) delete context.feedback;
87
+ }
77
88
  const add = (field, item, ref) => {
78
89
  if (renderedRefs.has(ref)) return;
79
90
  const inherited = sourceRefs.has(ref);
@@ -189,8 +200,8 @@ export class PersonRuntime {
189
200
  isOwnerRunning(ownerId) { return this.pendingCancellations.has(ownerId) || [...this.running.values()].some(job => job.episode.ownerId === ownerId); }
190
201
  async run(episode, controller) {
191
202
  const { signal } = controller;
192
- const timeout = setTimeout(() => controller.abort(new PersonError('TIMEOUT')), episode.budget.timeoutMs);
193
- timeout.unref?.();
203
+ let lastReplyAt = new Date(episode.createdAt).getTime() || Date.now();
204
+ let lastReply = null;
194
205
  let heartbeatRunning = false;
195
206
  const heartbeat = setInterval(async () => {
196
207
  if (heartbeatRunning || signal.aborted) return;
@@ -198,7 +209,7 @@ export class PersonRuntime {
198
209
  try { await this.repository.heartbeat(episode); }
199
210
  catch (error) { controller.abort(safeError(error)); }
200
211
  finally { heartbeatRunning = false; }
201
- }, Math.max(100, Math.floor(this.repository.leaseMs / 3)));
212
+ }, Math.max(100, Math.min(5000, Math.floor(this.repository.leaseMs / 3))));
202
213
  heartbeat.unref?.();
203
214
  try {
204
215
  const provider = await abortable(this.getProvider(episode.modelCandidates ?? [], episode.defaultModel ?? null), signal);
@@ -232,7 +243,11 @@ export class PersonRuntime {
232
243
  for (let index = 0; index < episode.budget.calls; index++) {
233
244
  signal.throwIfAborted();
234
245
  const callId = randomUUID();
235
- const context = assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, dependencyRefs, remainingCalls: episode.budget.calls - index, taskEvidence: projectTaskEvidence(this.tasks()?.snapshot(episode.ownerId)), attachments, activeCapabilities: capabilities.context(), capabilityMap: capabilities.catalog(), environment: toolHost.environment });
246
+ const context = assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, dependencyRefs, remainingCalls: episode.budget.calls - index, taskEvidence: projectTaskEvidence(this.tasks()?.snapshot(episode.ownerId)), attachments, activeCapabilities: capabilities.context(), capabilityMap: capabilities.catalog(), environment: toolHost.environment, feedback: {
247
+ minIntervalMs: FEEDBACK.minMs, maxIntervalMs: FEEDBACK.maxMs,
248
+ elapsedSinceReplyMs: Math.max(0, Date.now() - lastReplyAt),
249
+ due: Date.now() - lastReplyAt >= FEEDBACK.minMs, lastReply,
250
+ } });
236
251
  capabilities.activate(context.activeCapabilities);
237
252
  dependencyRefs = context.manifest.inputDependencyRefs;
238
253
  for (const [id, concept] of context.concepts) readConcepts.set(id, concept);
@@ -289,6 +304,11 @@ export class PersonRuntime {
289
304
  await this.repository.commit(episode, proposal, selection, callId, new Map(proposal.concepts.map(c => [c.id, c.epistemicState === 'reported' ? reportedLineage(c, readSources) : []])));
290
305
  return;
291
306
  }
307
+ if (proposal.reply) {
308
+ signal.throwIfAborted();
309
+ const published = await this.repository.publishProgress(episode, { callId, reply: proposal.reply });
310
+ if (published) { lastReply = published.text; lastReplyAt = new Date(published.createdAt).getTime(); }
311
+ }
292
312
  if (index + 1 >= episode.budget.calls) {
293
313
  await this.tasks()?.cancel({ ownerId: episode.ownerId, episodeId: episode.id, reason: 'CALL_BUDGET' });
294
314
  await this.repository.finish(episode, 'budget_exhausted', 'CALL_BUDGET');
@@ -345,7 +365,7 @@ export class PersonRuntime {
345
365
  // On authority failure no alternate memory store is used. Expired leases become durable interrupted records on next access.
346
366
  await this.repository.finish(episode, status, safe.code).catch(() => {});
347
367
  } finally {
348
- clearTimeout(timeout); clearInterval(heartbeat);
368
+ clearInterval(heartbeat);
349
369
  if (!signal.aborted) controller.abort(new PersonError('INTERRUPTED'));
350
370
  }
351
371
  }
@@ -22,9 +22,9 @@ export function createPersonService(options = {}) {
22
22
  identifier(namespace);
23
23
  const configured = typeof yeaftDir === 'string' && Boolean(yeaftDir.trim());
24
24
  const calls = options.maxCalls ?? LIMITS.calls;
25
- const timeoutMs = options.timeoutMs ?? LIMITS.timeoutMs;
25
+ // Legacy timeoutMs is intentionally ignored: an episode has no wall-clock deadline.
26
26
  const leaseMs = options.leaseMs ?? LIMITS.leaseMs;
27
- if (!Number.isInteger(calls) || calls < 1 || calls > 32 || !Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 300000 ||
27
+ if (!Number.isInteger(calls) || calls < 1 || calls > 32 ||
28
28
  !Number.isInteger(leaseMs) || leaseMs < 300 || leaseMs > 60000) fail('INVALID_REQUEST');
29
29
  if (allowedModels != null && (!Array.isArray(allowedModels) || !allowedModels.length || allowedModels.some(m => typeof m !== 'string'))) fail('INVALID_REQUEST');
30
30
  const repository = configured ? new SqlitePersonRepository({ yeaftDir, namespace, leaseMs }) : null;
@@ -43,7 +43,7 @@ export function createPersonService(options = {}) {
43
43
  ? memory.recall(ownerId, args, { signal }) : literalRecall(ownerId, args);
44
44
  // Config/adapter are loaded per explicit episode, not a permanent stale cache.
45
45
  const getProvider = (modelCandidates = [], defaultModel = null) => createPersonProvider({ yeaftDir, config, adapter, allowedModels, modelCandidates, defaultModel, effortEnabled: options.effortEnabled });
46
- const runtime = new PersonRuntime({ repository, getProvider, budget: { calls, timeoutMs }, workDir, yeaftDir, config, namespace });
46
+ const runtime = new PersonRuntime({ repository, getProvider, budget: { calls }, workDir, yeaftDir, config, namespace });
47
47
  let closed = false;
48
48
  const requests = new Set();
49
49
  async function handle({ ownerId, op, payload = {} } = {}) {
@@ -100,6 +100,7 @@ export class SqlitePersonRepository {
100
100
  async episodeAttachments(episode) { return this.request('episodeAttachments', [episode]); }
101
101
  async context(episode) { return this.request('context', [episode]); }
102
102
  async recall(ownerId, options) { return this.request('recall', [ownerId, options]); }
103
+ async publishProgress(episode, data) { return this.request('publishProgress', [episode, data]); }
103
104
  async commit(episode, proposal, selection, callId, reportedSources = new Map()) { return this.request('commit', [episode, proposal, selection, callId, reportedSources]); }
104
105
  async finish(episode, status, code) { return this.request('finish', [episode, status, code]); }
105
106
  async cancel(ownerId, episodeId) { return this.request('cancel', [ownerId, episodeId]); }
@@ -3,7 +3,7 @@ import { mkdirSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { isMainThread } from 'node:worker_threads';
6
- import { admissionReceipt, bytes, digest, fail, identifier, LIMITS, PersonError, text } from './contracts.js';
6
+ import { admissionReceipt, bytes, digest, fail, FEEDBACK, identifier, LIMITS, PersonError, text } from './contracts.js';
7
7
  import { CREATED_CAPABILITY_LIMITS, createdCapabilityRecord, validateCreatedCapability } from './created-capability-contract.js';
8
8
  import { SCHEMA, TABLES } from './sqlite-schema.js';
9
9
  import { attachmentMetadata, attachmentRequestHash, validateFiles } from './attachments.js';
@@ -53,7 +53,7 @@ const publicOutput = (output, failed) => {
53
53
  : { text: output.text, bytes: retainedBytes, complete: true, usage, stopReason };
54
54
  };
55
55
  const READS = new Set(['receipt', 'turns', 'inspect', 'search', 'getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories', 'createdCapabilities', 'episodeAttachments']);
56
- const WRITES = new Set(['open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'startCapability', 'finalizeCapability', 'commit', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
56
+ const WRITES = new Set(['open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'startCapability', 'finalizeCapability', 'commit', 'publishProgress', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
57
57
  const memoryKind = kind => { if (!['messages', 'concepts'].includes(kind)) fail('INVALID_REQUEST'); return kind; };
58
58
  const boundedLimit = (limit, max = 100) => { if (!Number.isSafeInteger(limit) || limit < 1 || limit > max) fail('INVALID_REQUEST'); return limit; };
59
59
  const sequence = value => {
@@ -257,6 +257,17 @@ export class SqlitePersonStore {
257
257
  own(episode) { const p = this.fenced(episode); if (!p) fail('STALE'); return p; }
258
258
  heartbeat(episode) {
259
259
  const p = this.own(episode); p.leaseUntil = new Date(this.now.getTime() + this.leaseMs); p.writeSerial++; this.put('persons', p);
260
+ const record = this.one('episodes', this.episodeScope(episode), ' AND id = ?', [episode.id]);
261
+ if (!record || record.status !== 'running') fail('STALE');
262
+ const lastAt = new Date(record.feedback?.at ?? record.lastReplyAt ?? record.createdAt).getTime();
263
+ // Heartbeats are at most 5s apart: publish the fallback between 55–60s.
264
+ // This updates one snapshot field, never adds messages or schedules cognition.
265
+ if (this.now.getTime() - lastAt >= FEEDBACK.maxMs - 5000 || record.feedback) {
266
+ const phase = record.openCall ? 'model' : record.openCapability ? 'capability' : 'preparing';
267
+ record.feedback = { at: this.now.getTime() - lastAt >= FEEDBACK.maxMs - 5000 ? this.now.toISOString() : record.feedback.at, phase };
268
+ if (phase === 'capability') record.feedback.capabilityId = record.openCapability.capability?.id;
269
+ this.put('episodes', record);
270
+ }
260
271
  }
261
272
  append(episode, kind, data) {
262
273
  // Call proof/publication events are emitted only by their transactional methods.
@@ -366,6 +377,32 @@ export class SqlitePersonStore {
366
377
  state: stateView(state), messages: this.rows('messages', scope, ' ORDER BY seq DESC LIMIT 12').reverse().map(messageView),
367
378
  concepts: [...new Map([...focused, ...recent].map(c => [c.id, conceptView(c)])).values()] };
368
379
  }
380
+ /** Publish only a validated, complete call's reply. This is communication, not
381
+ * a state/Concept commit. Fencing, dedupe and the memory journal share one transaction. */
382
+ publishProgress(episode, { callId, reply }) {
383
+ const p = this.own(episode), scope = this.episodeScope(episode);
384
+ identifier(callId); text(reply, 8192);
385
+ const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
386
+ if (!record || record.status !== 'running' || record.openCall || record.openCapability) fail('STALE');
387
+ const prior = this.rows('messages', scope, " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.replyKind') = 'progress'", [episode.id]);
388
+ const duplicate = prior.find(m => m.callId === callId);
389
+ if (duplicate) { if (duplicate.text !== reply) fail('INVALID_REQUEST'); return messageView(duplicate); }
390
+ const proof = this.one('traces', scope, " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') = 'call_output' AND json_extract(record, '$.callId') = ? AND json_extract(record, '$.output.complete') = 1", [episode.id, callId]);
391
+ const accepted = this.one('traces', scope, " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') = 'activity' AND json_extract(record, '$.callId') = ? AND json_extract(record, '$.disposition') = 'candidate'", [episode.id, callId]);
392
+ let proposal;
393
+ try { proposal = JSON.parse(proof?.output?.text); } catch { fail('INVALID_REQUEST'); }
394
+ if (!accepted || !proposal.next || proposal.reply !== reply) fail('INVALID_REQUEST');
395
+ // Provider latency counts too: a call started before 30s can finish after it.
396
+ if (this.now.getTime() - new Date(record.lastReplyAt ?? record.createdAt).getTime() < FEEDBACK.minMs ||
397
+ prior.some(m => m.text.trim() === reply.trim())) return null;
398
+ p.messageSeq++; p.writeSerial++; this.put('persons', p);
399
+ const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id,
400
+ callId, replyKind: 'progress', role: 'assistant', text: reply, createdAt: this.now });
401
+ this.put('messages', message, true); this.journal(scope, 'messages', message);
402
+ record.lastReplyAt = this.now.toISOString(); delete record.feedback; this.put('episodes', record);
403
+ this.trace(episode.ownerId, episode.id, 'progress_reply', { callId, messageId: message.id });
404
+ return messageView(message);
405
+ }
369
406
  commit(episode, proposal, selection, callId, reportedSources = new Map()) {
370
407
  const p = this.own(episode), scope = this.episodeScope(episode);
371
408
  if (proposal.baseStateVersion !== episode.baseStateVersion) fail('STALE');
@@ -391,7 +428,7 @@ export class SqlitePersonStore {
391
428
  this.put('state_commits', this.doc(scope, { id: randomUUID(), version: p.stateVersion, parentVersion: episode.baseStateVersion,
392
429
  episodeId: episode.id, callId, conceptRevisions: revisions, state: stateView(state), createdAt: this.now }), true);
393
430
  if (proposal.reply) {
394
- const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id, role: 'assistant', text: proposal.reply, createdAt: this.now });
431
+ const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id, callId, replyKind: 'final', role: 'assistant', text: proposal.reply, createdAt: this.now });
395
432
  this.put('messages', message, true); this.journal(scope, 'messages', message);
396
433
  }
397
434
  const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
@@ -504,7 +541,8 @@ export class SqlitePersonStore {
504
541
  const scope = this.scope(ownerId), p = this.getPerson(ownerId), state = this.one('states', scope);
505
542
  const messages = this.rows('messages', scope, ' ORDER BY seq DESC LIMIT 21');
506
543
  const episode = this.one('episodes', scope, ' ORDER BY inputWatermark DESC');
507
- const latestEpisode = episode ? { id: episode.id, status: episode.status,
544
+ const latestEpisode = episode ? { id: episode.id, status: episode.status, createdAt: episode.createdAt,
545
+ ...(episode.status === 'running' && episode.feedback ? { feedback: episode.feedback } : {}),
508
546
  ...(episode.terminalCode ? { terminalCode: episode.terminalCode } : {}), ...(episode.endedAt ? { endedAt: episode.endedAt } : {}) } : null;
509
547
  return { latestEpisode, person: this.personView(p), state: stateView(state), concepts: this.focused(scope, state.focusConceptIds).slice(0, 12).map(conceptView),
510
548
  messages: messages.slice(0, 20).reverse().map(messageView), nextMessagesCursor: messages.length > 20 ? String(messages[19].seq) : null,