@yeaft/webchat-agent 1.0.606 → 1.0.607

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.606",
3
+ "version": "1.0.607",
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",
@@ -18,7 +18,7 @@ export function personName(value) {
18
18
  return text(value.trim(), 160);
19
19
  }
20
20
  const pick = (value, keys) => value == null ? null : Object.fromEntries(keys.filter(key => Object.hasOwn(value, key)).map(key => [key, value[key]]));
21
- export const settingsView = value => pick(value, ['autonomyEnabled', 'modelCandidates']);
21
+ export const settingsView = value => pick(value, ['autonomyEnabled', 'modelCandidates', 'defaultModel']);
22
22
  export function messageView(value) {
23
23
  const result = pick(value, ['id', 'schemaVersion', 'revision', 'seq', 'episodeId', 'clientMessageId', 'role', 'text', 'createdAt']);
24
24
  if (result && Array.isArray(value.attachments)) result.attachments = value.attachments.slice(0, 4)
@@ -14,6 +14,29 @@ export function validateModelCandidates(refs) {
14
14
  new Set(refs).size !== refs.length) fail('MODEL_SELECTION');
15
15
  }
16
16
 
17
+ export function validateDefaultModel(ref) {
18
+ if (ref !== null) validateModelCandidates([ref]);
19
+ }
20
+
21
+ /** Resolve router ownership before allowlisting or bounding the Person catalog. */
22
+ export function resolveAgentDefaultModel(config) {
23
+ const models = config.availableModels || [], requested = config.primaryModel || config.model;
24
+ const model = models.find(m => (m.ref || m.id) === requested) || models.find(m => m.id === requested);
25
+ return model?.ref || (model?.id?.includes('/') ? model.id : null);
26
+ }
27
+
28
+ /** Shared validation for settings, status and runtime; never broaden an explicit subset. */
29
+ export function selectPersonModels({ availableModels, modelCandidates = [], defaultModel = null }) {
30
+ validateModelCandidates(modelCandidates);
31
+ validateDefaultModel(defaultModel);
32
+ if (modelCandidates.some(ref => !availableModels.some(model => model.id === ref))) fail('MODEL_SELECTION');
33
+ const catalog = modelCandidates.length ? availableModels.filter(model => modelCandidates.includes(model.id))
34
+ : availableModels.slice(0, MODEL_LIMITS.candidates);
35
+ if (defaultModel !== null && !catalog.some(model => model.id === defaultModel)) fail('MODEL_SELECTION');
36
+ if (!catalog.length) fail('MODEL_UNAVAILABLE');
37
+ return { catalog, defaultSelection: { model: defaultModel ?? catalog[0].id, effort: null } };
38
+ }
39
+
17
40
  // Bounds apply to the actual wire protocol, not a vision flag or compressed file size.
18
41
  // OpenAI tile models (including 2833 base tokens for gpt-4o-mini) have a fixed
19
42
  // cost only at explicit low detail. Known patch models below fit <=6144 patches
@@ -38,17 +61,13 @@ function imageInputBudget(modelId, protocol) {
38
61
  }
39
62
 
40
63
  /** Only configured native API models; no Session initialization or implicit credential fallback. */
41
- export async function createPersonProvider({ yeaftDir, config: suppliedConfig, adapter: suppliedAdapter, allowedModels, effortEnabled = process.env.YEAFT_THINKING_V1 === '1', modelCandidates = [] }) {
64
+ export async function createPersonProvider({ yeaftDir, config: suppliedConfig, adapter: suppliedAdapter, allowedModels, effortEnabled = process.env.YEAFT_THINKING_V1 === '1', modelCandidates = [], defaultModel = null }) {
42
65
  const config = structuredClone(suppliedConfig || loadConfig({ dir: yeaftDir }));
43
66
  if (!config.providers?.length && !suppliedAdapter) fail('MODEL_UNAVAILABLE');
44
67
  const models = config.availableModels || [];
45
- const requestedDefault = config.primaryModel || config.model;
46
- // Match router ownership: exact qualified ref first; bare IDs use the first
47
- // configured provider. Resolve before allowlisting, sorting or truncating.
48
- const defaultModel = models.find(m => (m.ref || m.id) === requestedDefault)
49
- || models.find(m => m.id === requestedDefault);
50
- const defaultRef = defaultModel?.ref || defaultModel?.id;
68
+ const agentDefaultModel = resolveAgentDefaultModel(config);
51
69
  validateModelCandidates(modelCandidates);
70
+ validateDefaultModel(defaultModel);
52
71
  const available = models.filter(m => !allowedModels || allowedModels.includes(m.ref || m.id));
53
72
  const routingProviders = (config.providers || []).map(normalizeKnownProviderForRuntime);
54
73
  const safeModels = available.map(m => {
@@ -76,19 +95,16 @@ export async function createPersonProvider({ yeaftDir, config: suppliedConfig, a
76
95
  return { id: m.ref || m.id, efforts, maxOutput, supportsImages: imageBudget !== null, imageBudget,
77
96
  contextWindow: Math.floor(resolveContextWindow(m.id, { ...config, modelInfo: m })) };
78
97
  }).filter(m => typeof m.id === 'string' && m.id.length <= 256 && m.contextWindow > m.maxOutput + 1024 && m.maxOutput >= 256);
79
- safeModels.sort((a, b) => Number(b.id === defaultRef) - Number(a.id === defaultRef));
98
+ safeModels.sort((a, b) => Number(b.id === agentDefaultModel) - Number(a.id === agentDefaultModel));
80
99
  const seen = new Set();
81
100
  const uniqueModels = safeModels.filter(model => !seen.has(model.id) && seen.add(model.id));
82
101
  const availableModels = uniqueModels.slice(0, MODEL_LIMITS.available);
83
102
  // A UI catalog must not be the bounded episode choice set: model 9+ remains selectable.
84
103
  // Explicit lists are validated, never silently truncated or broadened on stale config.
85
- if (modelCandidates.some(ref => !availableModels.some(model => model.id === ref))) fail('MODEL_SELECTION');
86
- const catalog = modelCandidates.length ? availableModels.filter(model => modelCandidates.includes(model.id))
87
- : availableModels.slice(0, MODEL_LIMITS.candidates);
88
- if (!catalog.length) fail('MODEL_UNAVAILABLE');
104
+ const { catalog, defaultSelection } = selectPersonModels({ availableModels, modelCandidates, defaultModel });
89
105
  const adapter = suppliedAdapter || await createLLMAdapter(config);
90
106
  return { adapter, catalog, availableModels, availableModelsTruncated: uniqueModels.length > MODEL_LIMITS.available,
91
- catalogRevision: digest(catalog), defaultSelection: { model: catalog[0].id, effort: null }, effortEnabled };
107
+ catalogRevision: digest(catalog), defaultSelection, agentDefaultModel, effortEnabled };
92
108
  }
93
109
 
94
110
  export function abortable(promise, signal) {
@@ -109,7 +109,7 @@ export class MongoPersonRepository {
109
109
  if (!p) {
110
110
  p = this.doc(scope, {
111
111
  name, soul: 'A continuous, curious and honest digital person. Preserve uncertainty, reconsider your judgments, distinguish imagination from experience, and respect permissions. You may disagree without acting without authority.',
112
- soulRevision: 1, createdAt: new Date(), settings: { autonomyEnabled: false },
112
+ soulRevision: 1, createdAt: new Date(), settings: { autonomyEnabled: false, defaultModel: null },
113
113
  epoch: 0, writeSerial: 0, inputWatermark: 0, controlVersion: 0, stateVersion: 0,
114
114
  traceSeq: 0, messageSeq: 0, activeEpisodeId: null, leaseOwner: null, leaseUntil: new Date(0),
115
115
  });
@@ -198,7 +198,7 @@ export class MongoPersonRepository {
198
198
  messageId = randomUUID();
199
199
  await this.collections.messages.insertOne(this.doc(scope, { id: messageId, revision: 1, seq: p.messageSeq, episodeId: id, role: 'user', text, attachments, createdAt: new Date(), clientMessageId }), { session });
200
200
  }
201
- const episode = this.doc(scope, { id, clientMessageId, requestHash, kind, text, messageId, attachments, modelCandidates: [...(p.settings.modelCandidates ?? [])], status: 'running', workerId, epoch: p.epoch,
201
+ const episode = this.doc(scope, { id, clientMessageId, requestHash, kind, text, messageId, attachments, modelCandidates: [...(p.settings.modelCandidates ?? [])], defaultModel: p.settings.defaultModel ?? null, status: 'running', workerId, epoch: p.epoch,
202
202
  baseStateVersion: p.stateVersion, inputWatermark: p.inputWatermark, controlVersion: p.controlVersion, budget, createdAt: new Date() });
203
203
  await this.collections.episodes.insertOne(episode, { session });
204
204
  await this.trace(session, p, id, 'accepted', { trigger: { kind, text, messageId, attachments }, baseStateVersion: p.stateVersion, budget });
@@ -455,12 +455,13 @@ export class MongoPersonRepository {
455
455
  return { cancelled: true, episodeId: p.activeEpisodeId };
456
456
  });
457
457
  }
458
- async settings(ownerId, settings) {
458
+ async settings(ownerId, settings, expectedControlVersion) {
459
459
  return this.transaction(async session => {
460
460
  const scope = this.scope(ownerId);
461
461
  const p = await this.collections.persons.findOne(scope, { session });
462
462
  if (!p) fail('NOT_OPEN');
463
463
  if (p.activeEpisodeId) fail('BUSY'); // No implicit background API or deferred control effects.
464
+ if (expectedControlVersion !== undefined && p.controlVersion !== expectedControlVersion) fail('STALE');
464
465
  const { name, ...patch } = settings;
465
466
  if (Object.hasOwn(settings, 'name')) p.name = personName(name);
466
467
  // A partial update must preserve durable fields omitted from the public view.
@@ -201,7 +201,7 @@ export class PersonRuntime {
201
201
  }, Math.max(100, Math.floor(this.repository.leaseMs / 3)));
202
202
  heartbeat.unref?.();
203
203
  try {
204
- const provider = await abortable(this.getProvider(episode.modelCandidates ?? []), signal);
204
+ const provider = await abortable(this.getProvider(episode.modelCandidates ?? [], episode.defaultModel ?? null), signal);
205
205
  const snapshot = await abortable(this.repository.context(episode), signal);
206
206
  const attachments = episode.attachments?.length ? await abortable(this.repository.episodeAttachments(episode), signal) : [];
207
207
  // Bind recovered evidence using the repository's authoritative Person ID,
@@ -209,7 +209,7 @@ export class PersonRuntime {
209
209
  await this.tasks()?.context({ episode, parentToolRegistry: this.parentToolRegistry });
210
210
  signal.throwIfAborted();
211
211
  let selection = { ...provider.defaultSelection, reason: 'configured-default', origin: 'bootstrap' };
212
- if (snapshot.state.lastSelection) {
212
+ if (episode.defaultModel == null && snapshot.state.lastSelection) {
213
213
  try { validateSelection(snapshot.state.lastSelection, provider.catalog); selection = { ...snapshot.state.lastSelection, reason: 'last-accepted-choice', origin: 'persisted' }; }
214
214
  catch { await this.repository.append(episode, 'selection_rejected', { requested: snapshot.state.lastSelection, code: 'MODEL_SELECTION', fallback: 'configured-default' }); }
215
215
  }
@@ -6,7 +6,7 @@ import { PersonRuntime } from './runtime.js';
6
6
  import { allowedNativeToolIds } from './native-tools.js';
7
7
  import { loadConfig } from '../config.js';
8
8
  import { validateFiles } from './attachments.js';
9
- import { createPersonProvider, validateModelCandidates } from './provider.js';
9
+ import { createPersonProvider, resolveAgentDefaultModel, selectPersonModels, validateDefaultModel, validateModelCandidates } from './provider.js';
10
10
  import { fail, identifier, LIMITS, object, page, personTaskRequest, safeError, text } from './contracts.js';
11
11
  import { inspectRequest, personName, searchRequest } from './inspection.js';
12
12
 
@@ -46,7 +46,7 @@ export function createPersonService(options = {}) {
46
46
  if (memory) repository.recall = (ownerId, args = {}, { signal } = {}) => args.query?.trim()
47
47
  ? memory.recall(ownerId, args, { signal }) : literalRecall(ownerId, args);
48
48
  // Config/adapter are loaded per explicit episode, not a permanent stale cache.
49
- const getProvider = (modelCandidates = []) => createPersonProvider({ yeaftDir, config, adapter, allowedModels, modelCandidates, effortEnabled: options.effortEnabled });
49
+ const getProvider = (modelCandidates = [], defaultModel = null) => createPersonProvider({ yeaftDir, config, adapter, allowedModels, modelCandidates, defaultModel, effortEnabled: options.effortEnabled });
50
50
  const runtime = new PersonRuntime({ repository, getProvider, budget: { calls, timeoutMs }, workDir, yeaftDir, config, namespace });
51
51
  let closed = false;
52
52
  const requests = new Set();
@@ -56,23 +56,32 @@ export function createPersonService(options = {}) {
56
56
  if (typeof op !== 'string') fail('INVALID_REQUEST');
57
57
  if (op === 'status') {
58
58
  object(payload, []);
59
- if (!configured) return { renameSupported: true, configured: false, storage, reason: 'Digital person storage configuration is missing.', storageReady: false, modelReady: false };
59
+ let agentDefaultModel = null;
60
+ try { agentDefaultModel = resolveAgentDefaultModel(config ?? loadConfig({ dir: yeaftDir })); } catch { /* Invalid/missing native config. */ }
61
+ const status = { renameSupported: true, defaultModelSupported: true, configured, storage, modelReady: false,
62
+ defaultModel: null, agentDefaultModel, modelCandidates: [], effectiveModelCandidates: [], effectiveDefaultModel: null };
63
+ if (!configured) return { ...status, reason: 'Digital person storage configuration is missing.', storageReady: false };
60
64
  try { await repository.init(); }
61
- catch (error) { return { renameSupported: true, configured: true, storage, reason: safeError(error).message, storageReady: false, modelReady: false }; }
62
- let modelCandidates = [];
63
- try { modelCandidates = (await repository.getPerson(ownerId)).settings.modelCandidates ?? []; }
64
- catch (error) { if (error.code !== 'NOT_OPEN') throw error; }
65
+ catch (error) { return { ...status, reason: safeError(error).message, storageReady: false }; }
66
+ try {
67
+ const settings = (await repository.getPerson(ownerId)).settings;
68
+ status.modelCandidates = settings.modelCandidates ?? [];
69
+ status.defaultModel = settings.defaultModel ?? null;
70
+ } catch (error) { if (error.code !== 'NOT_OPEN') throw error; }
65
71
  try {
66
72
  const provider = await getProvider();
67
- let modelReady = true;
68
73
  try {
69
- validateModelCandidates(modelCandidates);
70
- if (modelCandidates.some(ref => !provider.availableModels.some(model => model.id === ref))) modelReady = false;
71
- } catch { modelReady = false; }
72
- return { renameSupported: true, configured: true, storage, reason: modelReady ? null : 'Saved model candidates are unavailable; choose models or reset to Agent defaults.',
73
- storageReady: true, modelReady, models: provider.availableModels, availableModels: provider.availableModels,
74
- availableModelsTruncated: provider.availableModelsTruncated, modelCandidates, autonomySupported: false };
75
- } catch { return { renameSupported: true, configured: true, storage, reason: 'No permitted native model is configured for digital person.', storageReady: true, modelReady: false, models: [], availableModels: [], modelCandidates }; }
74
+ const selected = selectPersonModels({ availableModels: provider.availableModels,
75
+ modelCandidates: status.modelCandidates, defaultModel: status.defaultModel });
76
+ status.modelReady = true;
77
+ status.effectiveModelCandidates = selected.catalog.map(model => model.id);
78
+ status.effectiveDefaultModel = selected.defaultSelection.model;
79
+ } catch { /* Stale settings remain visible, but do not grant fallback candidates. */ }
80
+ return { ...status, agentDefaultModel: provider.agentDefaultModel,
81
+ reason: status.modelReady ? null : 'Saved model settings are unavailable; choose models or reset to Agent defaults.',
82
+ storageReady: true, models: provider.availableModels, availableModels: provider.availableModels,
83
+ availableModelsTruncated: provider.availableModelsTruncated, autonomySupported: false };
84
+ } catch { return { ...status, reason: 'No permitted native model is configured for digital person.', storageReady: true, models: [], availableModels: [] }; }
76
85
  }
77
86
  if (!configured) fail('NOT_CONFIGURED');
78
87
  switch (op) {
@@ -143,21 +152,30 @@ export function createPersonService(options = {}) {
143
152
  } finally { release(); }
144
153
  }
145
154
  case 'settings': {
146
- object(payload, ['name', 'autonomyEnabled', 'modelCandidates'], []);
155
+ object(payload, ['name', 'autonomyEnabled', 'modelCandidates', 'defaultModel'], []);
147
156
  const patch = { ...payload };
148
157
  if (Object.hasOwn(patch, 'name')) patch.name = personName(patch.name);
149
158
  if (Object.hasOwn(payload, 'autonomyEnabled') && typeof payload.autonomyEnabled !== 'boolean') fail('INVALID_REQUEST');
150
- if (Object.hasOwn(payload, 'modelCandidates')) {
151
- const refs = payload.modelCandidates;
152
- validateModelCandidates(refs);
153
- if (refs.length) {
159
+ if (Object.hasOwn(payload, 'modelCandidates')) validateModelCandidates(payload.modelCandidates);
160
+ if (Object.hasOwn(payload, 'defaultModel')) validateDefaultModel(payload.defaultModel);
161
+ // Timer-driven autonomy is deliberately not claimed or silently enabled.
162
+ if (payload.autonomyEnabled === true) fail('UNSUPPORTED');
163
+ let expectedControlVersion;
164
+ if (Object.hasOwn(payload, 'modelCandidates') || Object.hasOwn(payload, 'defaultModel')) {
165
+ const prior = await repository.getPerson(ownerId);
166
+ if (prior.activeEpisodeId) fail('BUSY');
167
+ const merged = { modelCandidates: [], defaultModel: null, ...prior.settings, ...patch };
168
+ validateModelCandidates(merged.modelCandidates);
169
+ validateDefaultModel(merged.defaultModel);
170
+ // An automatic reset must also work when no model is currently available.
171
+ if (merged.modelCandidates.length || merged.defaultModel !== null) {
154
172
  const provider = await getProvider();
155
- if (refs.some(ref => !provider.availableModels.some(model => model.id === ref))) fail('MODEL_SELECTION');
173
+ selectPersonModels({ availableModels: provider.availableModels, modelCandidates: merged.modelCandidates, defaultModel: merged.defaultModel });
156
174
  }
175
+ // Fence the merge base across concurrent services; retry from fresh settings.
176
+ expectedControlVersion = prior.controlVersion;
157
177
  }
158
- // Timer-driven autonomy is deliberately not claimed or silently enabled.
159
- if (payload.autonomyEnabled === true) fail('UNSUPPORTED');
160
- return repository.settings(ownerId, patch);
178
+ return repository.settings(ownerId, patch, expectedControlVersion);
161
179
  }
162
180
  default: fail('INVALID_REQUEST');
163
181
  }
@@ -103,7 +103,7 @@ export class SqlitePersonRepository {
103
103
  async commit(episode, proposal, selection, callId, reportedSources = new Map()) { return this.request('commit', [episode, proposal, selection, callId, reportedSources]); }
104
104
  async finish(episode, status, code) { return this.request('finish', [episode, status, code]); }
105
105
  async cancel(ownerId, episodeId) { return this.request('cancel', [ownerId, episodeId]); }
106
- async settings(ownerId, settings) { return this.request('settings', [ownerId, settings]); }
106
+ async settings(ownerId, settings, expectedControlVersion) { return this.request('settings', [ownerId, settings, expectedControlVersion]); }
107
107
  async list(ownerId, collection, options, filter) { return this.request('list', [ownerId, collection, options, filter]); }
108
108
  async snapshot(ownerId) { return this.request('snapshot', [ownerId]); }
109
109
  async inspect(ownerId, options, nativeToolIds) { return this.request('inspect', [ownerId, options, nativeToolIds]); }
@@ -151,7 +151,7 @@ export class SqlitePersonStore {
151
151
  if (!p) {
152
152
  p = this.doc(scope, {
153
153
  name, soul: 'A continuous, curious and honest digital person. Preserve uncertainty, reconsider your judgments, distinguish imagination from experience, and respect permissions. You may disagree without acting without authority.',
154
- soulRevision: 1, createdAt: this.now, settings: { autonomyEnabled: false },
154
+ soulRevision: 1, createdAt: this.now, settings: { autonomyEnabled: false, defaultModel: null },
155
155
  epoch: 0, writeSerial: 0, inputWatermark: 0, controlVersion: 0, stateVersion: 0,
156
156
  traceSeq: 0, messageSeq: 0, activeEpisodeId: null, leaseOwner: null, leaseUntil: new Date(0),
157
157
  });
@@ -223,7 +223,7 @@ export class SqlitePersonStore {
223
223
  const message = this.doc(scope, { id: messageId, revision: 1, seq: p.messageSeq, episodeId: id, role: 'user', text: input, attachments, createdAt: this.now, clientMessageId });
224
224
  this.put('messages', message, true); this.journal(scope, 'messages', message);
225
225
  }
226
- const episode = this.doc(scope, { id, clientMessageId, requestHash, kind, text: input, messageId, attachments, modelCandidates: [...(p.settings.modelCandidates ?? [])], status: 'running', workerId, epoch: p.epoch,
226
+ const episode = this.doc(scope, { id, clientMessageId, requestHash, kind, text: input, messageId, attachments, modelCandidates: [...(p.settings.modelCandidates ?? [])], defaultModel: p.settings.defaultModel ?? null, status: 'running', workerId, epoch: p.epoch,
227
227
  baseStateVersion: p.stateVersion, inputWatermark: p.inputWatermark, controlVersion: p.controlVersion, budget, createdAt: this.now });
228
228
  this.put('episodes', episode, true);
229
229
  this.trace(ownerId, id, 'accepted', { trigger: { kind, text: input, messageId, attachments }, baseStateVersion: p.stateVersion, budget });
@@ -419,9 +419,10 @@ export class SqlitePersonStore {
419
419
  this.trace(ownerId, id, 'cancelled', { code: 'CANCELLED' });
420
420
  return { cancelled: true, episodeId: id };
421
421
  }
422
- settings(ownerId, settings) {
422
+ settings(ownerId, settings, expectedControlVersion) {
423
423
  const p = this.getPerson(ownerId);
424
424
  if (p.activeEpisodeId) fail('BUSY');
425
+ if (expectedControlVersion !== undefined && p.controlVersion !== expectedControlVersion) fail('STALE');
425
426
  const { name, ...patch } = settings;
426
427
  if (Object.hasOwn(settings, 'name')) p.name = personName(name);
427
428
  // A partial update must preserve durable fields omitted from the public view.