@yeaft/webchat-agent 1.0.205 → 1.0.206

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.205",
3
+ "version": "1.0.206",
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,9 @@ let shuttingDown = false;
18
18
  let shutdownPromise = null;
19
19
  let serviceFactory = null;
20
20
 
21
- const BROWSER_DETAIL_OPS = new Set(['get', 'create', 'update', 'start', 'cancel', 'action_input', 'guide', 'retry']);
21
+ const BROWSER_DETAIL_OPS = new Set([
22
+ 'get', 'create', 'update', 'start', 'cancel', 'work_item_message', 'action_input', 'retry_action', 'guide', 'retry',
23
+ ]);
22
24
  const BROWSER_ACTION_DEBUG_OPS = new Set(['get_action_messages', 'get_action_requests', 'get_action_request']);
23
25
  // `files` is an internal server-to-Agent field. The browser relay rejects any
24
26
  // client-supplied value and only emits files resolved from owned upload ids.
@@ -26,7 +28,9 @@ const BROWSER_FILE_FIELDS = Object.freeze({
26
28
  create: [
27
29
  'title', 'goal', 'acceptanceCriteria', 'workItemType', 'workDir', 'reuseMemory', 'files', 'start',
28
30
  ],
31
+ work_item_message: ['id', 'text', 'revision'],
29
32
  action_input: ['id', 'text', 'actionId', 'revision', 'generation', 'files'],
33
+ retry_action: ['id', 'actionId', 'revision', 'generation'],
30
34
  guide: ['id', 'guidance', 'actionId', 'revision', 'generation', 'files'],
31
35
  get_action_messages: ['id', 'actionId', 'cursor', 'limit'],
32
36
  get_action_requests: ['id', 'actionId'],
@@ -189,15 +189,34 @@ export class WorkflowController {
189
189
  return detail;
190
190
  }
191
191
 
192
+ message(id, input = {}) {
193
+ const text = typeof input.text === 'string' ? input.text.trim().slice(0, 8_000) : '';
194
+ if (!text) throw new Error('WorkItem message is required');
195
+ const revision = Number(input.revision);
196
+ if (!Number.isInteger(revision)) throw new Error('revision is required for WorkItem messages');
197
+ const detail = this.store.addWorkItemMessage(id, text, revision, (workItem, action) => (
198
+ actionInstruction(action, workItem, action.context || [], renderSessionContextSnapshot(workItem.sessionContext))
199
+ ));
200
+ if (!detail) throw new Error(`WorkItem not found: ${id}`);
201
+ return detail;
202
+ }
203
+
192
204
  input(id, input = {}) {
193
205
  const text = typeof input.text === 'string' ? input.text.trim().slice(0, 8_000) : '';
194
206
  const addedAttachmentCount = Math.max(0, Number(input.addedAttachmentCount) || 0);
195
207
  if (!text && addedAttachmentCount === 0) throw new Error('Action input or attachments are required');
196
208
  const workItem = this.store.getWorkItem(id);
197
209
  if (!workItem) throw new Error(`WorkItem not found: ${id}`);
198
- if (['ready', 'running'].includes(workItem.status)) {
199
- const activeAction = this.store.getAction(input.actionId);
200
- if (activeAction?.status === 'running' && addedAttachmentCount > 0) {
210
+ const targetAction = this.store.getAction(input.actionId);
211
+ const graphMode = workItem.workflowSnapshot?.executionMode === 'graph';
212
+ const targetMatches = graphMode
213
+ ? targetAction?.workItemId === id && targetAction.generation === input.generation
214
+ : workItem.currentActionId === input.actionId;
215
+ if (!targetMatches || workItem.revision !== input.revision) {
216
+ throw new Error('Action changed before input was applied; refresh and try again');
217
+ }
218
+ if (['ready', 'running'].includes(targetAction.status)) {
219
+ if (targetAction.status === 'running' && addedAttachmentCount > 0) {
201
220
  throw new Error('Files cannot be added while an Action is running; send text now or wait for the next Action boundary');
202
221
  }
203
222
  const inputSummary = text || `The user added ${addedAttachmentCount} attachment(s) as additional context for this Action.`;
@@ -205,20 +224,20 @@ export class WorkflowController {
205
224
  actionId: input.actionId,
206
225
  generation: input.generation,
207
226
  revision: input.revision,
208
- }, (current, action) => {
209
- const context = [...(action.context || []), {
227
+ }, (current, currentAction) => {
228
+ const context = [...(currentAction.context || []), {
210
229
  type: 'input', role: 'user', summary: inputSummary, evidence: [],
211
230
  }];
212
231
  const step = {
213
- type: action.type,
214
- stageId: action.stageId || action.type,
215
- assignmentPolicy: action.assignmentPolicy,
216
- modelPolicy: action.modelPolicy,
217
- requiredRole: action.requiredRole,
218
- dependsOnStageIds: action.dependsOnStageIds,
219
- workspaceMode: action.workspaceMode,
220
- changesRequestedStageId: action.changesRequestedStageId,
221
- brief: action.brief,
232
+ type: currentAction.type,
233
+ stageId: currentAction.stageId || currentAction.type,
234
+ assignmentPolicy: currentAction.assignmentPolicy,
235
+ modelPolicy: currentAction.modelPolicy,
236
+ requiredRole: currentAction.requiredRole,
237
+ dependsOnStageIds: currentAction.dependsOnStageIds,
238
+ workspaceMode: currentAction.workspaceMode,
239
+ changesRequestedStageId: currentAction.changesRequestedStageId,
240
+ brief: currentAction.brief,
222
241
  };
223
242
  return {
224
243
  context,
@@ -226,17 +245,8 @@ export class WorkflowController {
226
245
  };
227
246
  }, input.attachments, input.addedAttachments);
228
247
  }
229
- if (!['waiting', 'needs_attention'].includes(workItem.status)) {
230
- throw new Error(`WorkItem in ${workItem.status} cannot accept Action input`);
231
- }
232
- const targetAction = this.store.getAction(input.actionId);
233
- const graphMode = workItem.workflowSnapshot?.executionMode === 'graph';
234
- const targetMatches = graphMode
235
- ? targetAction?.workItemId === id && targetAction.generation === input.generation
236
- && ['waiting', 'failed'].includes(targetAction.status)
237
- : workItem.currentActionId === input.actionId;
238
- if (!targetMatches || workItem.revision !== input.revision) {
239
- throw new Error('Action changed before input was applied; refresh and try again');
248
+ if (!['waiting', 'failed'].includes(targetAction.status)) {
249
+ throw new Error(`Action in ${targetAction.status} cannot accept input`);
240
250
  }
241
251
  return this.retry(id, {
242
252
  answer: text,
@@ -254,8 +264,8 @@ export class WorkflowController {
254
264
  const answer = typeof input.answer === 'string' ? input.answer.trim().slice(0, 8_000) : '';
255
265
  const addedAttachmentCount = Math.max(0, Number(input.addedAttachmentCount) || 0);
256
266
  const detail = this.store.retryWorkItemAtomic(id, (workItem, previous, previousRun) => {
257
- if (workItem.status === 'waiting' && !answer && addedAttachmentCount === 0) {
258
- throw new Error('answer or attachments are required to resume a waiting WorkItem');
267
+ if (previous?.status === 'waiting' && !answer && addedAttachmentCount === 0) {
268
+ throw new Error('answer or attachments are required to resume a waiting Action');
259
269
  }
260
270
  const step = previous
261
271
  ? {
@@ -70,9 +70,23 @@ function clamp(value, minimum, maximum) {
70
70
  return Math.min(maximum, Math.max(minimum, value));
71
71
  }
72
72
 
73
- function guidanceView(events) {
73
+ function workItemMessageView(messages) {
74
+ return (Array.isArray(messages) ? messages : [])
75
+ .filter(message => typeof message?.text === 'string' && message.text)
76
+ .slice()
77
+ .sort((left, right) => count(left.createdAt) - count(right.createdAt)
78
+ || String(left.id).localeCompare(String(right.id)))
79
+ .map(message => ({
80
+ messageId: message.id,
81
+ text: message.text,
82
+ createdAt: count(message.createdAt),
83
+ }));
84
+ }
85
+
86
+ function guidanceView(events, actionId) {
74
87
  return (Array.isArray(events) ? events : [])
75
- .filter(event => ['action.guidance_added', 'action.input_added'].includes(event?.type))
88
+ .filter(event => event?.actionId === actionId
89
+ && ['action.guidance_added', 'action.input_added'].includes(event.type))
76
90
  .slice()
77
91
  .sort((left, right) => count(left.id) - count(right.id))
78
92
  .map(event => ({
@@ -223,6 +237,7 @@ export function buildMainlineContextSnapshot(detail, action, budgetInput = {}) {
223
237
  directDependencies: dependencies,
224
238
  userContext: {
225
239
  sessionContext: [],
240
+ workItemMessages: [],
226
241
  guidance: [],
227
242
  includedCount: 0,
228
243
  omittedCount: 0,
@@ -249,12 +264,26 @@ export function buildMainlineContextSnapshot(detail, action, budgetInput = {}) {
249
264
  return false;
250
265
  };
251
266
  const sessionContext = normalizeSessionContextSnapshot(detail.sessionContext);
252
- const guidance = guidanceView(detail.events);
253
- const userEntries = [
254
- ...sessionContext.map(value => ({ kind: 'sessionContext', value })),
267
+ const workItemMessages = workItemMessageView(detail.messages);
268
+ const guidance = guidanceView(detail.events, action.id);
269
+ const newestFirstMessages = workItemMessages.slice().reverse();
270
+ for (const [index, message] of newestFirstMessages.entries()) {
271
+ const next = {
272
+ ...snapshot.userContext,
273
+ workItemMessages: [message, ...snapshot.userContext.workItemMessages],
274
+ includedCount: snapshot.userContext.includedCount + 1,
275
+ omittedCount: 0,
276
+ };
277
+ const included = trySet('userContext', next);
278
+ if (index === 0 && !included) {
279
+ throw mainlineContextBlocked('Latest WorkItem message exceeds the Mainline prompt budget');
280
+ }
281
+ }
282
+ const otherUserEntries = [
255
283
  ...guidance.map(value => ({ kind: 'guidance', value })),
284
+ ...sessionContext.map(value => ({ kind: 'sessionContext', value })),
256
285
  ];
257
- for (const entry of userEntries) {
286
+ for (const entry of otherUserEntries) {
258
287
  const next = {
259
288
  ...snapshot.userContext,
260
289
  [entry.kind]: [...snapshot.userContext[entry.kind], entry.value],
@@ -263,7 +292,8 @@ export function buildMainlineContextSnapshot(detail, action, budgetInput = {}) {
263
292
  };
264
293
  trySet('userContext', next);
265
294
  }
266
- snapshot.userContext.omittedCount = userEntries.length - snapshot.userContext.includedCount;
295
+ snapshot.userContext.omittedCount = workItemMessages.length + otherUserEntries.length
296
+ - snapshot.userContext.includedCount;
267
297
 
268
298
  const siblingEntries = Object.entries(projection.canonicalActionResults)
269
299
  .filter(([actionId]) => actionId !== action.id && !dependencies.some(item => item.actionId === actionId))
@@ -610,6 +610,11 @@ export function projectWorkItemDetail(detail) {
610
610
 
611
611
  origin: detail.origin?.sessionId ? { sessionId: detail.origin.sessionId } : null,
612
612
  linkedSessionIds: Array.isArray(detail.linkedSessionIds) ? detail.linkedSessionIds : [],
613
+ messages: (Array.isArray(detail.messages) ? detail.messages : []).slice(-100).map(message => ({
614
+ id: String(message.id || ''),
615
+ text: truncateUtf8(message.text || '', MAX_ACTION_MESSAGE_CHARS),
616
+ createdAt: count(message.createdAt),
617
+ })),
613
618
  attachments: projectAttachments(detail.attachments),
614
619
  createdAt: detail.createdAt,
615
620
  updatedAt: detail.updatedAt,
@@ -207,6 +207,30 @@ export class WorkCenterService {
207
207
  this.#emit({ type: 'work_item.cancelled', workItem: detail });
208
208
  return detail;
209
209
  }
210
+ case 'work_item_message': {
211
+ const id = requiredString(payload.id, 'id');
212
+ const detail = this.controller.message(id, {
213
+ text: typeof payload.text === 'string' ? payload.text : '',
214
+ revision: payload.revision,
215
+ });
216
+ this.watcher.notifyWorkItemInput(id);
217
+ this.#emit({ type: 'work_item.message_added', workItem: detail });
218
+ return detail;
219
+ }
220
+ case 'retry_action': {
221
+ const id = requiredString(payload.id, 'id');
222
+ const detail = this.controller.retry(id, {
223
+ expected: {
224
+ actionId: typeof payload.actionId === 'string' ? payload.actionId : '',
225
+ revision: payload.revision,
226
+ generation: payload.generation,
227
+ statuses: ['failed'],
228
+ },
229
+ });
230
+ this.watcher.abortInvalidWorkItemRuns(id);
231
+ this.#emit({ type: 'action.retried', workItem: detail });
232
+ return detail;
233
+ }
210
234
  case 'action_input': {
211
235
  const id = requiredString(payload.id, 'id');
212
236
  const workItem = this.#requiredItem(id);
@@ -5,7 +5,7 @@ import { createHash, randomUUID } from 'node:crypto';
5
5
  import { normalizeEvidence } from './evidence.js';
6
6
  import { normalizeActionCheckpoint } from './action-checkpoint.js';
7
7
 
8
- const SCHEMA_VERSION = 15;
8
+ const SCHEMA_VERSION = 16;
9
9
  const OPEN_ACTION_STATUSES = "'ready','running','waiting'";
10
10
  const MAX_REUSABLE_CONTEXT_ITEMS = 12;
11
11
  const MAX_RUN_RESPONSE_CHARS = 65_536;
@@ -77,6 +77,7 @@ function mapWorkItem(row) {
77
77
  origin: parseJson(row.origin, null),
78
78
  linkedSessionIds: parseJson(row.linked_session_ids, []),
79
79
  sessionContext: parseJson(row.session_context, []),
80
+ messages: parseJson(row.messages, []),
80
81
  attachments: parseJson(row.attachments, []),
81
82
  executionStats: {
82
83
  llmRequestCount: Math.max(0, Number(row.usage_llm_request_count) || 0),
@@ -284,6 +285,7 @@ export class WorkItemStore {
284
285
  origin TEXT,
285
286
  linked_session_ids TEXT NOT NULL DEFAULT '[]',
286
287
  session_context TEXT NOT NULL DEFAULT '[]',
288
+ messages TEXT NOT NULL DEFAULT '[]',
287
289
  attachments TEXT NOT NULL DEFAULT '[]',
288
290
  created_at INTEGER NOT NULL,
289
291
  updated_at INTEGER NOT NULL
@@ -421,6 +423,9 @@ export class WorkItemStore {
421
423
  if (!hasColumn(this.db, 'work_items', 'reuse_memory')) {
422
424
  this.db.exec('ALTER TABLE work_items ADD COLUMN reuse_memory INTEGER NOT NULL DEFAULT 1');
423
425
  }
426
+ if (!hasColumn(this.db, 'work_items', 'messages')) {
427
+ this.db.exec("ALTER TABLE work_items ADD COLUMN messages TEXT NOT NULL DEFAULT '[]'");
428
+ }
424
429
  if (!hasColumn(this.db, 'actions', 'brief')) {
425
430
  this.db.exec('ALTER TABLE actions ADD COLUMN brief TEXT');
426
431
  }
@@ -555,13 +560,17 @@ export class WorkItemStore {
555
560
  return withTransaction(this.db, () => {
556
561
  const workItem = this.getWorkItem(id);
557
562
  if (!workItem) return null;
558
- if (!['ready', 'running'].includes(workItem.status)) {
563
+ const graphMode = workItem.workflowSnapshot?.executionMode === 'graph';
564
+ const inputStatuses = graphMode
565
+ ? ['ready', 'running', 'waiting', 'needs_attention']
566
+ : ['ready', 'running'];
567
+ if (!inputStatuses.includes(workItem.status)) {
559
568
  throw new Error(`WorkItem in ${workItem.status} cannot accept Action input`);
560
569
  }
561
570
  const action = this.getAction(expected.actionId);
562
- const graphMode = workItem.workflowSnapshot?.executionMode === 'graph';
563
571
  const actionMatches = graphMode
564
- ? action?.workItemId === id && ['ready', 'running'].includes(action.status)
572
+ ? action?.workItemId === id && action.generation === expected.generation
573
+ && ['ready', 'running'].includes(action.status)
565
574
  : action?.id === workItem.currentActionId && ['ready', 'running'].includes(action?.status);
566
575
  const activeRun = action?.currentRunId ? this.getRun(action.currentRunId) : null;
567
576
  if (!actionMatches || activeRun?.acceptingInput === false || workItem.revision !== expected.revision) {
@@ -1217,6 +1226,63 @@ export class WorkItemStore {
1217
1226
  return this.db.prepare(`SELECT * FROM events WHERE action_id = ? ORDER BY id`).all(actionId).map(mapEvent);
1218
1227
  }
1219
1228
 
1229
+ addWorkItemMessage(id, text, expectedRevision, updateActionInstruction) {
1230
+ return withTransaction(this.db, () => {
1231
+ const workItem = this.getWorkItem(id);
1232
+ if (!workItem) return null;
1233
+ if (['done', 'cancelled'].includes(workItem.status)) {
1234
+ throw new Error(`WorkItem in ${workItem.status} cannot accept messages`);
1235
+ }
1236
+ if (workItem.revision !== expectedRevision) {
1237
+ throw new Error('WorkItem changed before the message was applied; refresh and try again');
1238
+ }
1239
+ const openActions = this.db.prepare(`SELECT * FROM actions WHERE work_item_id = ?
1240
+ AND status IN ('ready', 'running') ORDER BY sequence`).all(id).map(mapAction);
1241
+ for (const action of openActions.filter(candidate => candidate.status === 'running')) {
1242
+ const run = action.currentRunId ? this.getRun(action.currentRunId) : null;
1243
+ if (!run || run.status !== 'running' || run.acceptingInput === false) {
1244
+ throw new Error('A running Action closed its input window before the WorkItem message was applied; refresh and try again');
1245
+ }
1246
+ }
1247
+ const now = this.now();
1248
+ const revision = workItem.revision + 1;
1249
+ const message = { id: randomUUID(), text, createdAt: now };
1250
+ const messages = [...(workItem.messages || []), message].slice(-100);
1251
+ this.db.prepare(`UPDATE work_items SET messages = ?, revision = ?, updated_at = ? WHERE id = ?`)
1252
+ .run(stringify(messages), revision, now, id);
1253
+ const updatedWorkItem = { ...workItem, messages, revision };
1254
+ for (const action of openActions) {
1255
+ if (action.status === 'ready') {
1256
+ const instruction = updateActionInstruction(updatedWorkItem, action);
1257
+ this.db.prepare(`UPDATE actions SET instruction = ?, spec_hash = ?, updated_at = ?
1258
+ WHERE id = ? AND status = 'ready'`).run(
1259
+ instruction,
1260
+ actionSpecHash({ ...action, instruction }),
1261
+ now,
1262
+ action.id,
1263
+ );
1264
+ continue;
1265
+ }
1266
+ const run = this.getRun(action.currentRunId);
1267
+ const eventId = this.appendEvent(id, 'work_item.message_applied', { message }, {
1268
+ actionId: action.id,
1269
+ runId: action.currentRunId,
1270
+ });
1271
+ this.db.prepare(`INSERT INTO pending_action_inputs
1272
+ (event_id, work_item_id, action_id, run_id, text, attachments, consumed_at)
1273
+ VALUES (?, ?, ?, ?, ?, '[]', NULL)`).run(
1274
+ eventId,
1275
+ id,
1276
+ action.id,
1277
+ action.currentRunId,
1278
+ `WorkItem-level message: ${text}`,
1279
+ );
1280
+ }
1281
+ this.appendEvent(id, 'work_item.message_added', { message });
1282
+ return this.getWorkItemDetail(id);
1283
+ });
1284
+ }
1285
+
1220
1286
  getReusableContext(workDir, excludeWorkItemId = null) {
1221
1287
  const workspaceKey = canonicalWorkspaceKey(workDir);
1222
1288
  if (!workspaceKey) return [];
@@ -1432,17 +1498,24 @@ export class WorkItemStore {
1432
1498
  return withTransaction(this.db, () => {
1433
1499
  const workItem = this.getWorkItem(id);
1434
1500
  if (!workItem) return null;
1435
- if (!['waiting', 'needs_attention'].includes(workItem.status)) {
1501
+ const graphMode = isGraphWorkItem(workItem);
1502
+ const retryableWorkItemStatuses = graphMode
1503
+ ? ['ready', 'running', 'waiting', 'needs_attention']
1504
+ : ['waiting', 'needs_attention'];
1505
+ if (!retryableWorkItemStatuses.includes(workItem.status)) {
1436
1506
  throw new Error(`WorkItem in ${workItem.status} does not need retry`);
1437
1507
  }
1438
- const graphMode = isGraphWorkItem(workItem);
1439
1508
  let previous = workItem.currentActionId ? this.getAction(workItem.currentActionId) : null;
1440
1509
  if (options.expected) {
1441
1510
  const expectedAction = this.getAction(options.expected.actionId);
1511
+ const allowedExpectedStatuses = Array.isArray(options.expected.statuses)
1512
+ ? options.expected.statuses
1513
+ : ['waiting', 'failed'];
1442
1514
  const expectedMatches = graphMode
1443
1515
  ? expectedAction?.workItemId === id && expectedAction.generation === options.expected.generation
1444
- && ['waiting', 'failed'].includes(expectedAction.status)
1445
- : workItem.currentActionId === options.expected.actionId;
1516
+ && allowedExpectedStatuses.includes(expectedAction.status)
1517
+ : workItem.currentActionId === options.expected.actionId
1518
+ && allowedExpectedStatuses.includes(expectedAction?.status);
1446
1519
  if (!expectedMatches || workItem.revision !== options.expected.revision) {
1447
1520
  throw new Error('Action changed before input was applied; refresh and try again');
1448
1521
  }
@@ -95,6 +95,13 @@ export class WorkItemWatcher {
95
95
  }
96
96
  }
97
97
 
98
+ notifyWorkItemInput(workItemId) {
99
+ for (const entry of this.activeRuns.values()) {
100
+ if (entry.workItemId !== workItemId) continue;
101
+ try { entry.wakeForPendingUserMessage?.(); } catch {}
102
+ }
103
+ }
104
+
98
105
  #recoverExpiredRuns() {
99
106
  const recovered = this.store.recoverInterruptedRuns?.(this.ownerBootId) || 0;
100
107
  if (recovered > 0) {
@@ -675,7 +675,10 @@ function renderContext(context = []) {
675
675
 
676
676
  export function actionInstruction(stage, workItem, context = [], sessionContextBlock = renderSessionContextSnapshot(workItem?.sessionContext)) {
677
677
  const criteria = (workItem.acceptanceCriteria || []).map(item => `- ${item}`).join('\n') || '- No explicit criteria';
678
- const common = `WorkItem: ${workItem.title}\nGoal: ${workItem.goal}\nAcceptance criteria:\n${criteria}${sessionContextBlock}${renderContext(context)}`;
678
+ const workItemMessages = Array.isArray(workItem.messages) && workItem.messages.length > 0
679
+ ? `\n\nWorkItem-level user messages (apply to every unfinished Action):\n${workItem.messages.map(message => `- ${message.text}`).join('\n')}`
680
+ : '';
681
+ const common = `WorkItem: ${workItem.title}\nGoal: ${workItem.goal}\nAcceptance criteria:\n${criteria}${sessionContextBlock}${workItemMessages}${renderContext(context)}`;
679
682
  const policy = stage.instruction || defaultWorkCenterStageInstruction(stage.type);
680
683
  const brief = normalizeActionBrief(stage.brief || stage, stage.type);
681
684
  const contract = `Action type: ${stage.type}\nWhat to do:\n${brief.objective}\n\nHow to do it:\n${brief.approach}\n\nExpected result:\n${brief.expectedOutcome}`;