@yeaft/webchat-agent 0.1.570 → 0.1.573

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.
@@ -36,7 +36,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
36
36
  import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
37
37
  import { loadMcpServers, updateMcpConfig } from '../mcp.js';
38
38
  import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings } from '../unify/config-api.js';
39
- import { handleUnifyChat, handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyTaskMessage, handleUnifyUserMemoryWrite, handleUnifyUserMemoryRemove, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyArchiveGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger } from '../unify/web-bridge.js';
39
+ import { handleUnifyChat, handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyTaskMessage, handleUnifyUserMemoryWrite, handleUnifyUserMemoryRemove, handleUnifyMemoryScopeList, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyArchiveGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger } from '../unify/web-bridge.js';
40
40
 
41
41
  export async function handleMessage(msg) {
42
42
  switch (msg.type) {
@@ -440,6 +440,11 @@ export async function handleMessage(msg) {
440
440
  case 'unify_user_memory_remove':
441
441
  handleUnifyUserMemoryRemove(msg);
442
442
  break;
443
+ // task-fix: list MemoryStore entries (scope-tree folder view) for the
444
+ // Unify "User Memory" page. Replies with `memory_scope_snapshot`.
445
+ case 'unify_memory_scope_list':
446
+ handleUnifyMemoryScopeList(msg);
447
+ break;
443
448
 
444
449
  // task-334m: Group CRUD + D1 seed wiring (§Δ10 334m + R6 §Δ31.2).
445
450
  // All handlers reply via `group_crud_result`; mutating ops additionally
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.570",
3
+ "version": "0.1.573",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -71,11 +71,24 @@ function serializeMessage(msg) {
71
71
  fm.push(`tokens_est: ${tokensEst}`);
72
72
 
73
73
  // Tool calls as YAML array (simplified)
74
+ // task-fix: persist `input` as base64-encoded JSON so multi-line tool
75
+ // arguments round-trip safely (YAML string escaping is brittle for
76
+ // JSON blobs with newlines / quotes). Paired with the parser below.
74
77
  if (msg.toolCalls && msg.toolCalls.length > 0) {
75
78
  fm.push(`toolCalls:`);
76
79
  for (const tc of msg.toolCalls) {
77
80
  fm.push(` - id: ${tc.id}`);
78
81
  fm.push(` name: ${tc.name}`);
82
+ if (tc.input !== undefined) {
83
+ try {
84
+ const b64 = Buffer.from(JSON.stringify(tc.input)).toString('base64');
85
+ fm.push(` inputB64: ${b64}`);
86
+ } catch {
87
+ // best-effort: if input isn't JSON-serializable, skip it;
88
+ // restoring a tool_call without input is still better than
89
+ // dropping the whole record.
90
+ }
91
+ }
79
92
  }
80
93
  }
81
94
 
@@ -138,13 +151,21 @@ export function parseMessage(raw) {
138
151
  for (const entry of entries) {
139
152
  const tc = {};
140
153
  for (const line of entry.split('\n')) {
141
- const trimmed = line.trim();
154
+ // task-fix: the split regex only strips `\n - ` between
155
+ // entries, leaving a leading `- ` on the first line of the
156
+ // first entry. Strip it here so `- id: xxx` parses as `id`.
157
+ const trimmed = line.trim().replace(/^-\s+/, '');
142
158
  const ci = trimmed.indexOf(':');
143
159
  if (ci === -1) continue;
144
160
  const k = trimmed.slice(0, ci).trim();
145
161
  const v = trimmed.slice(ci + 1).trim();
146
162
  if (k === 'id') tc.id = v;
147
163
  if (k === 'name') tc.name = v;
164
+ if (k === 'inputB64') {
165
+ try {
166
+ tc.input = JSON.parse(Buffer.from(v, 'base64').toString('utf8'));
167
+ } catch { /* best-effort: leave input undefined */ }
168
+ }
148
169
  }
149
170
  if (tc.id && tc.name) toolCalls.push(tc);
150
171
  }
@@ -65,19 +65,53 @@ export async function runStopHooks(context) {
65
65
  }
66
66
 
67
67
  // 1. Persist latest messages
68
+ //
69
+ // task-fix: we must persist the complete new turn — including the
70
+ // assistant's `toolCalls` and each paired `role:'tool'` result —
71
+ // otherwise restoring history on session reload drops the pairing
72
+ // and causes "No tool output found for function call" 400s on the
73
+ // next chat-completions request. We walk back from the end of
74
+ // `messages` to find the first `role:'user'` that marks the start
75
+ // of the current turn, then persist everything from there forward.
68
76
  try {
69
77
  if (conversationStore && messages.length > 0) {
70
- const recentMessages = messages.slice(-2); // last user + assistant pair
78
+ // Find the start of the latest turn the last `role:'user'`
79
+ // message in the array. Everything from that index onward is
80
+ // new this turn (user + assistant[+toolCalls] + tool results).
81
+ let turnStart = messages.length - 1;
82
+ for (let i = messages.length - 1; i >= 0; i--) {
83
+ if (messages[i] && messages[i].role === 'user') {
84
+ turnStart = i;
85
+ break;
86
+ }
87
+ }
88
+ const recentMessages = messages.slice(turnStart);
71
89
  for (const msg of recentMessages) {
72
- if (msg.role && msg.content) {
73
- conversationStore.append({
74
- role: msg.role,
75
- content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
76
- mode,
77
- model: persistModel,
78
- });
79
- result.messagesPersisted++;
90
+ if (!msg || !msg.role) continue;
91
+ // Allow empty assistant content when toolCalls are present;
92
+ // tool messages have content by construction.
93
+ const hasContent =
94
+ (typeof msg.content === 'string' && msg.content.length > 0) ||
95
+ (msg.content && typeof msg.content !== 'string') ||
96
+ (Array.isArray(msg.toolCalls) && msg.toolCalls.length > 0) ||
97
+ msg.role === 'tool';
98
+ if (!hasContent) continue;
99
+
100
+ const record = {
101
+ role: msg.role,
102
+ content: typeof msg.content === 'string'
103
+ ? msg.content
104
+ : JSON.stringify(msg.content ?? ''),
105
+ mode,
106
+ model: persistModel,
107
+ };
108
+ if (msg.toolCallId) record.toolCallId = msg.toolCallId;
109
+ if (Array.isArray(msg.toolCalls) && msg.toolCalls.length > 0) {
110
+ record.toolCalls = msg.toolCalls;
80
111
  }
112
+ if (msg.isError) record.isError = true;
113
+ conversationStore.append(record);
114
+ result.messagesPersisted++;
81
115
  }
82
116
  }
83
117
  } catch (err) {
@@ -86,6 +86,39 @@ function getThreadMessages(threadId) {
86
86
  return arr;
87
87
  }
88
88
 
89
+ /**
90
+ * Restore per-thread message history from persisted conversation store.
91
+ *
92
+ * task-fix: accept `role:'tool'` messages AND preserve `toolCalls` /
93
+ * `toolCallId` fields. Without this, chat-completions serialization
94
+ * emits `tool_calls` without paired `role:'tool'` results, causing
95
+ * "No tool output found for function call" 400s after the first tool
96
+ * use across any restart / session-ready / model-switch event.
97
+ *
98
+ * @param {Array<object>} recent — output of conversationStore.loadRecent()
99
+ */
100
+ function restoreThreadHistoryFromRecent(recent) {
101
+ messagesByThread.clear();
102
+ for (const m of recent) {
103
+ // Keep user, assistant, AND tool messages. Tool messages are required
104
+ // for the chat-completions `tool_call_id` pairing.
105
+ if (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'tool') continue;
106
+ const tid = m.threadId || MAIN_THREAD_ID;
107
+ const bucket = getThreadMessages(tid);
108
+ const entry = { role: m.role, content: m.content };
109
+ if (m.toolCallId) entry.toolCallId = m.toolCallId;
110
+ if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
111
+ entry.toolCalls = m.toolCalls.map(tc => ({
112
+ id: tc.id,
113
+ name: tc.name,
114
+ input: tc.input,
115
+ }));
116
+ }
117
+ if (m.isError) entry.isError = true;
118
+ bucket.push(entry);
119
+ }
120
+ }
121
+
89
122
  /** Whether we've already sent a permission warning to the UI */
90
123
  let _permissionDiagnosticSent = false;
91
124
 
@@ -257,6 +290,41 @@ export function handleUnifyUserMemoryRemove(msg) {
257
290
  _handleUnifyUserMemoryRemove(msg, sendUnifyEvent);
258
291
  }
259
292
 
293
+ /**
294
+ * task-fix: list MemoryStore entries as a scope-tree for the
295
+ * UserMemoryPage "folder view". Entries come from MemoryStore.listEntries()
296
+ * and are grouped client-side by their `scope` (a `/`-separated path like
297
+ * `work/claude-web-chat/auth`).
298
+ *
299
+ * Request shape: { type: 'unify_memory_scope_list', requestId? }
300
+ * Reply shape: { type: 'memory_scope_snapshot',
301
+ * entries: Array<{name,scope,kind,tags,importance,
302
+ * frequency,created_at,updated_at,
303
+ * content}>,
304
+ * requestId? }
305
+ */
306
+ export function handleUnifyMemoryScopeList(msg) {
307
+ const requestId = msg && typeof msg.requestId === 'string' ? msg.requestId : undefined;
308
+ try {
309
+ const store = session && session.memoryStore;
310
+ const entries = store && typeof store.listEntries === 'function'
311
+ ? store.listEntries()
312
+ : [];
313
+ sendUnifyEvent({
314
+ type: 'memory_scope_snapshot',
315
+ entries,
316
+ ...(requestId ? { requestId } : {}),
317
+ });
318
+ } catch (err) {
319
+ sendUnifyEvent({
320
+ type: 'memory_scope_snapshot',
321
+ entries: [],
322
+ error: String(err && err.message || err),
323
+ ...(requestId ? { requestId } : {}),
324
+ });
325
+ }
326
+ }
327
+
260
328
  export function handleUnifyVpRead(msg) {
261
329
  const requestId = msg && msg.requestId;
262
330
  const vpId = msg && msg.vpId;
@@ -754,7 +822,7 @@ function forwardPipelineEvent(ev, ctx) {
754
822
  *
755
823
  * @param {object} event — engine event (text_delta / tool_call / …)
756
824
  * @param {string} threadId — owning thread id (from envelope)
757
- * @param {{assistantTextParts:string[], resetQueryTimer:Function}} hctx
825
+ * @param {{assistantTextParts:string[], toolCallsAccum:Array, toolResultsAccum:Array, resetQueryTimer:Function}} hctx
758
826
  */
759
827
  function handleEngineEvent(event, threadId, hctx) {
760
828
  hctx.resetQueryTimer();
@@ -790,6 +858,16 @@ function handleEngineEvent(event, threadId, hctx) {
790
858
  break;
791
859
 
792
860
  case 'tool_call':
861
+ // Capture tool_call for the assistant message's toolCalls array so the
862
+ // next turn's history correctly pairs `tool_calls` with `role:'tool'`
863
+ // results (fixes "No tool output found for function call" 400s).
864
+ if (hctx.toolCallsAccum) {
865
+ hctx.toolCallsAccum.push({
866
+ id: event.id,
867
+ name: event.name,
868
+ input: event.input,
869
+ });
870
+ }
793
871
  // Finish any in-progress text streaming so UI shows typing dots
794
872
  sendUnifyOutput({
795
873
  type: 'assistant',
@@ -820,6 +898,17 @@ function handleEngineEvent(event, threadId, hctx) {
820
898
  break;
821
899
 
822
900
  case 'tool_end':
901
+ // Capture tool result for the next-turn history so the paired
902
+ // `role:'tool'` message is included when we hand `messages` back to
903
+ // engine.query() (chat-completions requires tool_call_id pairing).
904
+ if (hctx.toolResultsAccum) {
905
+ hctx.toolResultsAccum.push({
906
+ role: 'tool',
907
+ toolCallId: event.id,
908
+ content: typeof event.output === 'string' ? event.output : JSON.stringify(event.output ?? ''),
909
+ isError: !!event.isError,
910
+ });
911
+ }
823
912
  sendUnifyOutput({
824
913
  type: 'user',
825
914
  tool_use_result: [{
@@ -1041,6 +1130,11 @@ export async function handleUnifyGroupChat(msg) {
1041
1130
  // Per target: emit `group_message` tagged with `speakerVpId` (the VP
1042
1131
  // being addressed — F3's GroupSelector binding consumes this) + dispatch
1043
1132
  // through the Engine via handleUnifyChat with an `@vp-<id>` prompt prefix.
1133
+ //
1134
+ // task-fix: bracket each per-VP dispatch with `vp_typing_start` /
1135
+ // `vp_typing_end` events so the frontend can render a per-speaker typing
1136
+ // dot next to that VP's avatar (matching IM apps). Avoids the old
1137
+ // "one global running cat for N concurrent speakers" ambiguity.
1044
1138
  for (const { vpId, envelope } of captured) {
1045
1139
  try {
1046
1140
  sendUnifyEvent({
@@ -1055,6 +1149,15 @@ export async function handleUnifyGroupChat(msg) {
1055
1149
  });
1056
1150
  } catch { /* never crash WS pipeline */ }
1057
1151
 
1152
+ try {
1153
+ sendUnifyEvent({
1154
+ type: 'vp_typing_start',
1155
+ groupId,
1156
+ vpId,
1157
+ ts: Date.now(),
1158
+ });
1159
+ } catch { /* never crash WS pipeline */ }
1160
+
1058
1161
  try {
1059
1162
  await handleUnifyChat({
1060
1163
  ...msg,
@@ -1065,6 +1168,15 @@ export async function handleUnifyGroupChat(msg) {
1065
1168
  });
1066
1169
  } catch (err) {
1067
1170
  console.warn('[Unify] unify_group_chat: per-vp dispatch failed', vpId, err?.message || err);
1171
+ } finally {
1172
+ try {
1173
+ sendUnifyEvent({
1174
+ type: 'vp_typing_end',
1175
+ groupId,
1176
+ vpId,
1177
+ ts: Date.now(),
1178
+ });
1179
+ } catch { /* never crash WS pipeline */ }
1068
1180
  }
1069
1181
  }
1070
1182
  }
@@ -1113,14 +1225,9 @@ export async function handleUnifyChat(msg) {
1113
1225
 
1114
1226
  // Restore per-thread history from persisted conversation store.
1115
1227
  // task-320: bucket by threadId so each thread keeps its own context.
1116
- messagesByThread.clear();
1117
- const recent = session.conversationStore.loadRecent(50);
1118
- for (const m of recent) {
1119
- if (m.role !== 'user' && m.role !== 'assistant') continue;
1120
- const tid = m.threadId || MAIN_THREAD_ID;
1121
- const bucket = getThreadMessages(tid);
1122
- bucket.push({ role: m.role, content: m.content });
1123
- }
1228
+ // task-fix: use restoreThreadHistoryFromRecent() so tool messages
1229
+ // and toolCalls/toolCallId survive the restore.
1230
+ restoreThreadHistoryFromRecent(session.conversationStore.loadRecent(50));
1124
1231
 
1125
1232
  // Notify UI: session is ready with model info + conversationId
1126
1233
  sendUnifyEvent({
@@ -1176,6 +1283,11 @@ export async function handleUnifyChat(msg) {
1176
1283
  try {
1177
1284
  // ─── Collect assistant response for conversation history ──
1178
1285
  let assistantTextParts = [];
1286
+ // task-fix: preserve toolCalls + tool_result pairings across turns so
1287
+ // the next engine.query({messages}) handoff stays valid for OpenAI
1288
+ // chat-completions (avoids "No tool output found for function call").
1289
+ const toolCallsAccum = [];
1290
+ const toolResultsAccum = [];
1179
1291
 
1180
1292
  // task-310: route via Dispatcher pipeline (queue → router → registry →
1181
1293
  // EngineInstance). The input is enqueued first so the UI observes the
@@ -1203,6 +1315,8 @@ export async function handleUnifyChat(msg) {
1203
1315
  const pipelineCtx = {
1204
1316
  onEngineEvent: (event, threadId) => handleEngineEvent(event, threadId, {
1205
1317
  assistantTextParts,
1318
+ toolCallsAccum,
1319
+ toolResultsAccum,
1206
1320
  resetQueryTimer,
1207
1321
  }),
1208
1322
  onError: (err) => { throw err; },
@@ -1226,13 +1340,38 @@ export async function handleUnifyChat(msg) {
1226
1340
 
1227
1341
  // ─── Query complete — accumulate messages for context continuity ──
1228
1342
  // task-320: per-thread history (no cross-thread contamination).
1343
+ // task-fix: when the turn made tool calls, the assistant message MUST
1344
+ // carry `toolCalls` AND each paired `role:'tool'` result must be
1345
+ // appended — otherwise the next turn's chat-completions serializer
1346
+ // emits `tool_calls` without matching `tool` messages → proxy 400
1347
+ // "No tool output found for function call call_xxx".
1229
1348
  const historyThread = resolvedThreadId || MAIN_THREAD_ID;
1230
1349
  const threadMessages = getThreadMessages(historyThread);
1231
1350
  threadMessages.push({ role: 'user', content: cleanedPrompt });
1232
1351
 
1233
1352
  const fullText = assistantTextParts.join('');
1234
- if (fullText) {
1235
- threadMessages.push({ role: 'assistant', content: fullText });
1353
+ if (fullText || toolCallsAccum.length > 0) {
1354
+ const assistantMsg = { role: 'assistant', content: fullText };
1355
+ if (toolCallsAccum.length > 0) {
1356
+ assistantMsg.toolCalls = toolCallsAccum.map(tc => ({
1357
+ id: tc.id,
1358
+ name: tc.name,
1359
+ input: tc.input,
1360
+ }));
1361
+ }
1362
+ threadMessages.push(assistantMsg);
1363
+
1364
+ // Append paired tool results, in order. Chat-completions requires
1365
+ // one `role:'tool'` message per `tool_call_id` right after the
1366
+ // assistant message that emitted them.
1367
+ for (const tr of toolResultsAccum) {
1368
+ threadMessages.push({
1369
+ role: 'tool',
1370
+ toolCallId: tr.toolCallId,
1371
+ content: tr.content,
1372
+ isError: tr.isError,
1373
+ });
1374
+ }
1236
1375
  }
1237
1376
 
1238
1377
  // ─── Signal turn end to UI ──
@@ -1627,14 +1766,9 @@ export async function handleUnifyLoadHistory(msg) {
1627
1766
 
1628
1767
  // Restore per-thread history from persisted conversation store.
1629
1768
  // task-320: bucket by threadId so each thread keeps its own context.
1630
- messagesByThread.clear();
1631
- const recent = session.conversationStore.loadRecent(50);
1632
- for (const m of recent) {
1633
- if (m.role !== 'user' && m.role !== 'assistant') continue;
1634
- const tid = m.threadId || MAIN_THREAD_ID;
1635
- const bucket = getThreadMessages(tid);
1636
- bucket.push({ role: m.role, content: m.content });
1637
- }
1769
+ // task-fix: use restoreThreadHistoryFromRecent() so tool messages
1770
+ // and toolCalls/toolCallId survive the restore.
1771
+ restoreThreadHistoryFromRecent(session.conversationStore.loadRecent(50));
1638
1772
  }
1639
1773
 
1640
1774
  // task-322: replay `session_ready` + `thread_list_updated` UNCONDITIONALLY
@@ -1739,12 +1873,9 @@ export async function resetUnifySession() {
1739
1873
  unifyConversationId = `unify-${Date.now()}`;
1740
1874
 
1741
1875
  // Restore per-thread history for LLM context (task-320).
1742
- const recent = session.conversationStore.loadRecent(50);
1743
- for (const m of recent) {
1744
- if (m.role !== 'user' && m.role !== 'assistant') continue;
1745
- const tid = m.threadId || MAIN_THREAD_ID;
1746
- getThreadMessages(tid).push({ role: m.role, content: m.content });
1747
- }
1876
+ // task-fix: use restoreThreadHistoryFromRecent() so tool messages
1877
+ // and toolCalls/toolCallId survive the restore.
1878
+ restoreThreadHistoryFromRecent(session.conversationStore.loadRecent(50));
1748
1879
 
1749
1880
  sendUnifyEvent({
1750
1881
  type: 'session_ready',