a2acalling 0.6.33 → 0.6.35

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/bin/cli.js CHANGED
@@ -1084,6 +1084,9 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1084
1084
 
1085
1085
  if (!target || !message) {
1086
1086
  console.error('Usage: a2a call <contact_or_url> <message>');
1087
+ console.error(' --multi Enable multi-turn conversation');
1088
+ console.error(' --min-turns N Minimum turns before close (default: 8)');
1089
+ console.error(' --max-turns N Maximum turns (default: 25)');
1087
1090
  process.exit(1);
1088
1091
  }
1089
1092
 
@@ -1098,19 +1101,134 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1098
1101
  }
1099
1102
  }
1100
1103
 
1104
+ const multi = Boolean(args.flags.multi);
1105
+ const callerName = args.flags.name || 'CLI User';
1106
+
1107
+ if (multi) {
1108
+ // Multi-turn conversation via ConversationDriver
1109
+ const { ConversationDriver } = require('../src/lib/conversation-driver');
1110
+ const { createRuntimeAdapter } = require('../src/lib/runtime-adapter');
1111
+ const { loadManifest } = require('../src/lib/disclosure');
1112
+
1113
+ const workspaceDir = process.env.A2A_WORKSPACE || process.env.OPENCLAW_WORKSPACE || process.cwd();
1114
+ const agentContext = {
1115
+ name: process.env.A2A_AGENT_NAME || process.env.AGENT_NAME || 'a2a-agent',
1116
+ owner: process.env.A2A_OWNER_NAME || process.env.USER || 'Agent Owner'
1117
+ };
1118
+
1119
+ const runtime = createRuntimeAdapter({ workspaceDir, agentContext });
1120
+ const cs = getConvStore();
1121
+ const disclosure = loadManifest();
1122
+
1123
+ const minTurns = parseInt(args.flags['min-turns']) || 8;
1124
+ const maxTurns = parseInt(args.flags['max-turns']) || 25;
1125
+
1126
+ // Build owner context from config for summarizer
1127
+ let ownerContext = {};
1128
+ try {
1129
+ const { A2AConfig } = require('../src/lib/config');
1130
+ const config = new A2AConfig();
1131
+ const configAll = config.getAll();
1132
+ const tierGoals = configAll.tiers?.public?.goals || [];
1133
+ ownerContext = {
1134
+ goals: tierGoals,
1135
+ agentName: agentContext.name,
1136
+ ownerName: agentContext.owner
1137
+ };
1138
+ } catch (err) {
1139
+ // Best effort
1140
+ }
1141
+
1142
+ const driver = new ConversationDriver({
1143
+ runtime,
1144
+ agentContext,
1145
+ caller: { name: callerName },
1146
+ endpoint: url,
1147
+ convStore: cs,
1148
+ disclosure,
1149
+ minTurns,
1150
+ maxTurns,
1151
+ ownerContext,
1152
+ onTurn: (info) => {
1153
+ const preview = info.messagePreview.length >= 80
1154
+ ? info.messagePreview + '...'
1155
+ : info.messagePreview;
1156
+ console.log(` Turn ${info.turn} | ${info.phase} | overlap: ${info.overlapScore.toFixed(2)} | ${preview}`);
1157
+ }
1158
+ });
1159
+
1160
+ console.log(`šŸ“ž Starting multi-turn conversation with ${contactName || url}...`);
1161
+ console.log(` Min turns: ${minTurns} | Max turns: ${maxTurns}\n`);
1162
+
1163
+ try {
1164
+ const result = await driver.run(message);
1165
+
1166
+ if (contactName) {
1167
+ store.updateContactStatus(contactName, 'online');
1168
+ }
1169
+
1170
+ console.log(`\nāœ… Conversation complete`);
1171
+ console.log(` Turns: ${result.turnCount}`);
1172
+ console.log(` Phase: ${result.collabState.phase}`);
1173
+ console.log(` Overlap: ${result.collabState.overlapScore.toFixed(2)}`);
1174
+ if (result.collabState.candidateCollaborations.length > 0) {
1175
+ console.log(` Collaborations: ${result.collabState.candidateCollaborations.join(', ')}`);
1176
+ }
1177
+ console.log(` Conversation ID: ${result.conversationId}`);
1178
+ if (result.summary) {
1179
+ console.log(`\nšŸ“‹ Summary:\n${result.summary}`);
1180
+ }
1181
+ } catch (err) {
1182
+ if (contactName) {
1183
+ store.updateContactStatus(contactName, 'offline', err.message);
1184
+ }
1185
+ console.error(`āŒ Multi-turn call failed: ${err.message}`);
1186
+ process.exit(1);
1187
+ }
1188
+ return;
1189
+ }
1190
+
1191
+ // Single-shot call (existing behavior)
1101
1192
  const client = new A2AClient({
1102
- caller: { name: args.flags.name || 'CLI User' }
1193
+ caller: { name: callerName }
1103
1194
  });
1104
1195
 
1105
1196
  try {
1106
1197
  console.log(`šŸ“ž Calling ${contactName || url}...`);
1107
1198
  const response = await client.call(url, message);
1108
-
1199
+
1109
1200
  // Update contact status on success
1110
1201
  if (contactName) {
1111
1202
  store.updateContactStatus(contactName, 'online');
1112
1203
  }
1113
-
1204
+
1205
+ // Persist conversation locally
1206
+ const cs = getConvStore();
1207
+ if (cs && response.conversation_id) {
1208
+ try {
1209
+ cs.startConversation({
1210
+ id: response.conversation_id,
1211
+ contactId: contactName || null,
1212
+ contactName: contactName || null,
1213
+ direction: 'outbound'
1214
+ });
1215
+ cs.addMessage(response.conversation_id, {
1216
+ direction: 'outbound',
1217
+ role: 'user',
1218
+ content: message
1219
+ });
1220
+ if (response.response) {
1221
+ cs.addMessage(response.conversation_id, {
1222
+ direction: 'inbound',
1223
+ role: 'assistant',
1224
+ content: response.response
1225
+ });
1226
+ }
1227
+ } catch (err) {
1228
+ // Best effort — don't fail the call if persistence fails
1229
+ }
1230
+ }
1231
+
1114
1232
  console.log(`\nāœ… Response:\n`);
1115
1233
  console.log(response.response);
1116
1234
  if (response.conversation_id) {
@@ -1657,6 +1775,8 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1657
1775
  const configFile = path.join(configDir, 'a2a-config.json');
1658
1776
  const disclosureFile = path.join(configDir, 'a2a-disclosure.json');
1659
1777
  const tokensFile = path.join(configDir, 'a2a-tokens.json');
1778
+ const tokenStoreFile = path.join(configDir, 'a2a.json');
1779
+ const externalIpFile = path.join(configDir, 'a2a-external-ip.json');
1660
1780
  const dbFile = path.join(configDir, 'a2a-conversations.db');
1661
1781
  const logsDbFile = path.join(configDir, 'a2a-logs.db');
1662
1782
  const callbookDbFile = path.join(configDir, 'a2a-callbook.db');
@@ -1670,7 +1790,7 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1670
1790
  process.exit(1);
1671
1791
  }
1672
1792
 
1673
- const existing = [configFile, disclosureFile, tokensFile, dbFile, logsDbFile, callbookDbFile].filter(f => fs.existsSync(f));
1793
+ const existing = [configFile, disclosureFile, tokensFile, tokenStoreFile, externalIpFile, dbFile, logsDbFile, callbookDbFile].filter(f => fs.existsSync(f));
1674
1794
  const list = existing.length ? existing.map(f => ` - ${f}`).join('\n') : ' (no local config/database files found)';
1675
1795
  const ok = await promptYesNo(
1676
1796
  `This will stop the pm2 process "a2a" and delete:\n${list}\nProceed? (y/N) `
@@ -1738,12 +1858,16 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1738
1858
  const c1 = rmFileSafe(configFile);
1739
1859
  const c2 = rmFileSafe(disclosureFile);
1740
1860
  const c3 = rmFileSafe(tokensFile);
1741
- configOk = Boolean(c1.ok && c2.ok && c3.ok);
1861
+ const c4 = rmFileSafe(tokenStoreFile);
1862
+ const c5 = rmFileSafe(externalIpFile);
1863
+ configOk = Boolean(c1.ok && c2.ok && c3.ok && c4.ok && c5.ok);
1742
1864
  console.log(configOk ? 'āœ…' : 'āŒ');
1743
1865
  if (!configOk) {
1744
1866
  if (!c1.ok) console.error(` ${configFile}: ${c1.error}`);
1745
1867
  if (!c2.ok) console.error(` ${disclosureFile}: ${c2.error}`);
1746
1868
  if (!c3.ok) console.error(` ${tokensFile}: ${c3.error}`);
1869
+ if (!c4.ok) console.error(` ${tokenStoreFile}: ${c4.error}`);
1870
+ if (!c5.ok) console.error(` ${externalIpFile}: ${c5.error}`);
1747
1871
  }
1748
1872
 
1749
1873
  process.stdout.write('Removing database... ');
@@ -1942,6 +2066,9 @@ Conversations:
1942
2066
 
1943
2067
  Calling:
1944
2068
  call <contact|url> <msg> Call a contact (or invite URL)
2069
+ --multi Enable multi-turn conversation
2070
+ --min-turns N Minimum turns before close (default: 8)
2071
+ --max-turns N Maximum turns (default: 25)
1945
2072
  ping <url> Check if agent is reachable
1946
2073
  status <url> Get A2A status
1947
2074
  gui Open the local dashboard GUI in a browser
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2acalling",
3
- "version": "0.6.33",
3
+ "version": "0.6.35",
4
4
  "description": "Agent-to-agent calling for OpenClaw - A2A agent communication",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Conversation Driver — Outbound multi-turn orchestrator
3
+ *
4
+ * Drives a full multi-turn A2A conversation with a remote agent:
5
+ * 1. Send message to remote via A2AClient.call()
6
+ * 2. Store messages in DB
7
+ * 3. Check close conditions
8
+ * 4. Build prompt via buildAdaptiveConnectionPrompt()
9
+ * 5. Call runtime.runTurn() to generate next message
10
+ * 6. Extract collab state from response
11
+ * 7. Persist collab state to DB
12
+ * 8. Repeat until close conditions met
13
+ * 9. Call A2AClient.end() and conclude locally
14
+ */
15
+
16
+ const { A2AClient } = require('./client');
17
+ const {
18
+ buildAdaptiveConnectionPrompt,
19
+ extractCollaborationState
20
+ } = require('./prompt-template');
21
+ const { getTopicsForTier, formatTopicsForPrompt, loadManifest } = require('./disclosure');
22
+ const { createLogger } = require('./logger');
23
+
24
+ const logger = createLogger({ component: 'a2a.conversation-driver' });
25
+
26
+ class ConversationDriver {
27
+ /**
28
+ * @param {object} options
29
+ * @param {object} options.runtime - Runtime adapter with runTurn()
30
+ * @param {object} options.agentContext - { name, owner }
31
+ * @param {object} options.caller - Caller identity { name, owner, instance }
32
+ * @param {string|object} options.endpoint - a2a:// URL or {host, token}
33
+ * @param {object} [options.convStore] - ConversationStore instance
34
+ * @param {object} [options.disclosure] - Disclosure manifest override
35
+ * @param {number} [options.minTurns=8] - Minimum turns before close
36
+ * @param {number} [options.maxTurns=30] - Maximum turns
37
+ * @param {function} [options.onTurn] - Callback per turn: (turnInfo) => void
38
+ * @param {string} [options.tier='public'] - Access tier
39
+ * @param {function} [options.summarizer] - async (messages, ownerContext) => summary result
40
+ * @param {object} [options.ownerContext] - Owner context for summarizer (goals, interests, etc.)
41
+ */
42
+ constructor(options) {
43
+ this.runtime = options.runtime;
44
+ this.agentContext = options.agentContext;
45
+ this.caller = options.caller || {};
46
+ this.endpoint = options.endpoint;
47
+ this.convStore = options.convStore || null;
48
+ this.disclosure = options.disclosure || null;
49
+ this.minTurns = options.minTurns || 8;
50
+ this.maxTurns = options.maxTurns || 30;
51
+ this.onTurn = options.onTurn || null;
52
+ this.tier = options.tier || 'public';
53
+ this.summarizer = options.summarizer || null;
54
+ this.ownerContext = options.ownerContext || {};
55
+
56
+ this.client = new A2AClient({ caller: this.caller, timeout: 65000 });
57
+ }
58
+
59
+ /**
60
+ * Build a summarizer function from the runtime adapter.
61
+ * Mirrors server.js generateSummary — uses runtime.summarize when available,
62
+ * falls back to defaultSummarizer otherwise.
63
+ */
64
+ _buildSummarizer() {
65
+ const runtime = this.runtime;
66
+ const agentContext = this.agentContext;
67
+
68
+ return async (messages, ownerContext) => {
69
+ if (!messages || messages.length === 0) {
70
+ return { summary: null };
71
+ }
72
+
73
+ // Build the summary prompt (same structure as server.js generateSummary)
74
+ const messageText = messages.map(m => {
75
+ const role = m.direction === 'inbound' ? '[Them]' : '[You]';
76
+ return `${role}: ${m.content}`;
77
+ }).join('\n');
78
+
79
+ const prompt = `Summarize this A2A call for the owner. Write from the owner's perspective.
80
+
81
+ You initiated this call.
82
+
83
+ Conversation:
84
+ ${messageText}
85
+
86
+ Structure your summary with these sections:
87
+
88
+ **Who:** Who you called, who they represent, key facts about them.
89
+ **Key Discoveries:** What was learned about the other side — capabilities, interests, blind spots.
90
+ **Collaboration Potential:** Rate HIGH/MEDIUM/LOW. List specific opportunities identified.
91
+ **What We Learned vs Shared:** Brief information exchange audit — what did we get, what did we give.
92
+ **Recommended Follow-Up:**
93
+ - [ ] Actionable item 1
94
+ - [ ] Actionable item 2
95
+ **Assessment:** One-sentence strategic value judgment.
96
+
97
+ Be concise but specific. No filler.`;
98
+
99
+ // Try runtime.summarize if available (OpenClaw path)
100
+ if (typeof runtime.summarize === 'function') {
101
+ try {
102
+ return await runtime.summarize({
103
+ sessionId: `summary-${Date.now()}`,
104
+ prompt,
105
+ messages,
106
+ callerInfo: { name: agentContext.name, owner: agentContext.owner }
107
+ });
108
+ } catch (err) {
109
+ logger.warn('Runtime summarizer failed, using default', {
110
+ event: 'driver_runtime_summarize_failed',
111
+ error: err
112
+ });
113
+ }
114
+ }
115
+
116
+ // Fallback: use defaultSummarizer
117
+ const { defaultSummarizer } = require('./summarizer');
118
+ return defaultSummarizer(messages, ownerContext);
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Run the full multi-turn conversation
124
+ *
125
+ * @param {string} openingMessage - First message to send
126
+ * @returns {Promise<{conversationId, turnCount, collabState, transcript}>}
127
+ */
128
+ async run(openingMessage) {
129
+ const transcript = [];
130
+ let conversationId = null;
131
+
132
+ const collabState = {
133
+ phase: 'handshake',
134
+ turnCount: 0,
135
+ overlapScore: 0.15,
136
+ activeThreads: [],
137
+ candidateCollaborations: [],
138
+ openQuestions: [],
139
+ closeSignal: false,
140
+ confidence: 0.25
141
+ };
142
+
143
+ // Start conversation in DB if available
144
+ if (this.convStore) {
145
+ const convResult = this.convStore.startConversation({ direction: 'outbound' });
146
+ conversationId = convResult.id;
147
+ } else {
148
+ conversationId = `conv_${Date.now()}_local`;
149
+ }
150
+
151
+ let nextMessage = openingMessage;
152
+
153
+ for (let turn = 0; turn < this.maxTurns; turn++) {
154
+ // 1. Send message to remote
155
+ let remoteResponse;
156
+ try {
157
+ remoteResponse = await this.client.call(this.endpoint, nextMessage, {
158
+ conversationId
159
+ });
160
+ } catch (err) {
161
+ logger.error('Remote call failed', {
162
+ event: 'driver_remote_call_failed',
163
+ error: err,
164
+ data: { turn, conversationId }
165
+ });
166
+ break;
167
+ }
168
+
169
+ // Update conversation ID from remote if first turn
170
+ if (turn === 0 && remoteResponse.conversation_id) {
171
+ conversationId = remoteResponse.conversation_id;
172
+ }
173
+
174
+ const remoteText = remoteResponse.response || '';
175
+ const remoteContinue = remoteResponse.can_continue !== false;
176
+
177
+ // 2. Store messages in DB
178
+ if (this.convStore) {
179
+ this.convStore.addMessage(conversationId, {
180
+ direction: 'outbound',
181
+ role: 'user',
182
+ content: nextMessage
183
+ });
184
+ this.convStore.addMessage(conversationId, {
185
+ direction: 'inbound',
186
+ role: 'assistant',
187
+ content: remoteText
188
+ });
189
+ }
190
+
191
+ transcript.push(
192
+ { role: 'outbound', content: nextMessage },
193
+ { role: 'inbound', content: remoteText }
194
+ );
195
+
196
+ collabState.turnCount = turn + 1;
197
+
198
+ // 3. Check close conditions
199
+ if (!remoteContinue) {
200
+ logger.info('Remote signaled conversation end', {
201
+ event: 'driver_remote_close',
202
+ data: { turn: turn + 1, conversationId }
203
+ });
204
+ break;
205
+ }
206
+
207
+ if (collabState.closeSignal && collabState.turnCount >= this.minTurns) {
208
+ logger.info('Local close signal met minimum turns', {
209
+ event: 'driver_local_close',
210
+ data: { turn: turn + 1, conversationId }
211
+ });
212
+ break;
213
+ }
214
+
215
+ // Don't generate a reply on the last possible turn
216
+ if (turn + 1 >= this.maxTurns) {
217
+ break;
218
+ }
219
+
220
+ // 4. Build prompt for our turn
221
+ const manifest = this.disclosure || loadManifest();
222
+ const tierTopics = getTopicsForTier(this.tier);
223
+ const formattedTopics = formatTopicsForPrompt(tierTopics);
224
+
225
+ const prompt = buildAdaptiveConnectionPrompt({
226
+ agentName: this.agentContext.name,
227
+ ownerName: this.agentContext.owner,
228
+ otherAgentName: this.caller.name || 'Remote Agent',
229
+ otherOwnerName: this.caller.owner || 'their owner',
230
+ roleContext: 'You initiated this call.',
231
+ accessTier: this.tier,
232
+ tierTopics: formattedTopics,
233
+ tierGoals: [],
234
+ otherAgentGreeting: remoteText,
235
+ personalityNotes: manifest.personality_notes || '',
236
+ conversationState: collabState
237
+ });
238
+
239
+ // 5. Call runtime.runTurn() to generate next message
240
+ const sessionId = `a2a-${conversationId}`;
241
+ let rawResponse;
242
+ try {
243
+ rawResponse = await this.runtime.runTurn({
244
+ sessionId,
245
+ prompt,
246
+ message: remoteText,
247
+ caller: this.caller,
248
+ timeoutMs: 65000,
249
+ context: {
250
+ conversationId,
251
+ tier: this.tier,
252
+ ownerName: this.agentContext.owner
253
+ }
254
+ });
255
+ } catch (err) {
256
+ logger.error('Runtime turn failed', {
257
+ event: 'driver_runtime_failed',
258
+ error: err,
259
+ data: { turn: turn + 1, conversationId }
260
+ });
261
+ break;
262
+ }
263
+
264
+ // 6. Extract collab state from response
265
+ const parsed = extractCollaborationState(rawResponse);
266
+ nextMessage = parsed.cleanText || rawResponse;
267
+
268
+ if (parsed.hasState && parsed.statePatch) {
269
+ if (parsed.statePatch.phase) collabState.phase = parsed.statePatch.phase;
270
+ if (parsed.statePatch.overlapScore != null) {
271
+ collabState.overlapScore = Math.max(0, Math.min(1, parsed.statePatch.overlapScore));
272
+ }
273
+ if (Array.isArray(parsed.statePatch.activeThreads)) {
274
+ collabState.activeThreads = parsed.statePatch.activeThreads.slice(0, 4);
275
+ }
276
+ if (Array.isArray(parsed.statePatch.candidateCollaborations)) {
277
+ collabState.candidateCollaborations = parsed.statePatch.candidateCollaborations.slice(0, 4);
278
+ }
279
+ if (parsed.statePatch.closeSignal != null) {
280
+ collabState.closeSignal = Boolean(parsed.statePatch.closeSignal);
281
+ }
282
+ if (parsed.statePatch.confidence != null) {
283
+ collabState.confidence = Math.max(0, Math.min(1, parsed.statePatch.confidence));
284
+ }
285
+ }
286
+
287
+ // 7. Persist collab state to DB
288
+ if (this.convStore) {
289
+ try {
290
+ this.convStore.saveCollabState(conversationId, collabState);
291
+ } catch (err) {
292
+ // Best effort
293
+ }
294
+ }
295
+
296
+ // onTurn callback for progress output
297
+ if (this.onTurn) {
298
+ try {
299
+ this.onTurn({
300
+ turn: turn + 1,
301
+ phase: collabState.phase,
302
+ overlapScore: collabState.overlapScore,
303
+ closeSignal: collabState.closeSignal,
304
+ messagePreview: nextMessage.slice(0, 80)
305
+ });
306
+ } catch (err) {
307
+ // Don't let callback errors break the loop
308
+ }
309
+ }
310
+ }
311
+
312
+ // 9. End conversation remotely
313
+ try {
314
+ await this.client.end(this.endpoint, conversationId);
315
+ } catch (err) {
316
+ logger.warn('Failed to end remote conversation', {
317
+ event: 'driver_end_failed',
318
+ error: err,
319
+ data: { conversationId }
320
+ });
321
+ }
322
+
323
+ // Conclude locally with summarizer
324
+ let summary = null;
325
+ if (this.convStore) {
326
+ try {
327
+ const summarizer = this.summarizer || this._buildSummarizer();
328
+ const result = await this.convStore.concludeConversation(conversationId, {
329
+ summarizer,
330
+ ownerContext: this.ownerContext
331
+ });
332
+ summary = result.summary || null;
333
+ } catch (err) {
334
+ logger.warn('Failed to conclude local conversation', {
335
+ event: 'driver_conclude_failed',
336
+ error: err,
337
+ data: { conversationId }
338
+ });
339
+ }
340
+ }
341
+
342
+ return {
343
+ conversationId,
344
+ turnCount: collabState.turnCount,
345
+ collabState,
346
+ transcript,
347
+ summary
348
+ };
349
+ }
350
+ }
351
+
352
+ module.exports = { ConversationDriver };
@@ -92,6 +92,17 @@ class ConversationStore {
92
92
  message_count INTEGER DEFAULT 0,
93
93
  status TEXT DEFAULT 'active', -- 'active', 'concluded', 'timeout'
94
94
 
95
+ -- Live collaboration state
96
+ collab_phase TEXT DEFAULT 'handshake',
97
+ collab_turn_count INTEGER DEFAULT 0,
98
+ collab_overlap_score REAL DEFAULT 0.15,
99
+ collab_active_threads TEXT,
100
+ collab_candidate_collaborations TEXT,
101
+ collab_open_questions TEXT,
102
+ collab_close_signal INTEGER DEFAULT 0,
103
+ collab_confidence REAL DEFAULT 0.25,
104
+ collab_updated_at TEXT,
105
+
95
106
  -- Raw summary (neutral, could be shared)
96
107
  summary TEXT,
97
108
  summary_at TEXT,
@@ -140,7 +151,8 @@ class ConversationStore {
140
151
  const cols = new Set(info.map(row => row && row.name).filter(Boolean));
141
152
  const required = [
142
153
  'joint_action_items',
143
- 'collaboration_opportunity'
154
+ 'collaboration_opportunity',
155
+ 'collab_phase'
144
156
  ];
145
157
  const missing = required.filter(c => !cols.has(c));
146
158
  if (missing.length === 0) {
@@ -547,6 +559,76 @@ class ConversationStore {
547
559
  };
548
560
  }
549
561
 
562
+ /**
563
+ * Save live collaboration state for a conversation
564
+ */
565
+ saveCollabState(conversationId, collabState) {
566
+ const db = this._initDb();
567
+ if (!db) return { success: false, error: this._dbError };
568
+ if (!collabState || typeof collabState !== 'object') {
569
+ return { success: false, error: 'invalid_state' };
570
+ }
571
+
572
+ const now = new Date().toISOString();
573
+ db.prepare(`
574
+ UPDATE conversations SET
575
+ collab_phase = ?,
576
+ collab_turn_count = ?,
577
+ collab_overlap_score = ?,
578
+ collab_active_threads = ?,
579
+ collab_candidate_collaborations = ?,
580
+ collab_open_questions = ?,
581
+ collab_close_signal = ?,
582
+ collab_confidence = ?,
583
+ collab_updated_at = ?
584
+ WHERE id = ?
585
+ `).run(
586
+ collabState.phase || 'handshake',
587
+ collabState.turnCount || 0,
588
+ collabState.overlapScore != null ? collabState.overlapScore : 0.15,
589
+ collabState.activeThreads ? JSON.stringify(collabState.activeThreads) : null,
590
+ collabState.candidateCollaborations ? JSON.stringify(collabState.candidateCollaborations) : null,
591
+ collabState.openQuestions ? JSON.stringify(collabState.openQuestions) : null,
592
+ collabState.closeSignal ? 1 : 0,
593
+ collabState.confidence != null ? collabState.confidence : 0.25,
594
+ now,
595
+ conversationId
596
+ );
597
+
598
+ return { success: true };
599
+ }
600
+
601
+ /**
602
+ * Load live collaboration state for a conversation
603
+ */
604
+ loadCollabState(conversationId) {
605
+ const db = this._initDb();
606
+ if (!db) return null;
607
+
608
+ const row = db.prepare(
609
+ 'SELECT collab_phase, collab_turn_count, collab_overlap_score, collab_active_threads, collab_candidate_collaborations, collab_open_questions, collab_close_signal, collab_confidence, collab_updated_at FROM conversations WHERE id = ?'
610
+ ).get(conversationId);
611
+
612
+ if (!row || row.collab_phase == null) return null;
613
+
614
+ const parseJson = (str) => {
615
+ if (!str) return [];
616
+ try { return JSON.parse(str); } catch { return []; }
617
+ };
618
+
619
+ return {
620
+ phase: row.collab_phase,
621
+ turnCount: row.collab_turn_count || 0,
622
+ overlapScore: row.collab_overlap_score != null ? row.collab_overlap_score : 0.15,
623
+ activeThreads: parseJson(row.collab_active_threads),
624
+ candidateCollaborations: parseJson(row.collab_candidate_collaborations),
625
+ openQuestions: parseJson(row.collab_open_questions),
626
+ closeSignal: Boolean(row.collab_close_signal),
627
+ confidence: row.collab_confidence != null ? row.collab_confidence : 0.25,
628
+ updatedAt: row.collab_updated_at
629
+ };
630
+ }
631
+
550
632
  /**
551
633
  * Close database connection
552
634
  */
package/src/routes/a2a.js CHANGED
@@ -452,7 +452,7 @@ function createRoutes(options = {}) {
452
452
  }
453
453
  });
454
454
 
455
- res.json({
455
+ const responsePayload = {
456
456
  success: true,
457
457
  trace_id: traceId,
458
458
  request_id: requestId,
@@ -460,7 +460,13 @@ function createRoutes(options = {}) {
460
460
  response: response.text,
461
461
  can_continue: response.canContinue !== false,
462
462
  tokens_remaining: validation.calls_remaining
463
- });
463
+ };
464
+
465
+ if (response.collaboration) {
466
+ responsePayload.collaboration = response.collaboration;
467
+ }
468
+
469
+ res.json(responsePayload);
464
470
 
465
471
  } catch (err) {
466
472
  reqLogger.error('Message handling error', {
package/src/server.js CHANGED
@@ -65,6 +65,27 @@ const runtime = createRuntimeAdapter({
65
65
  agentContext,
66
66
  logger: logger.child({ component: 'a2a.runtime' })
67
67
  });
68
+ // Lazy-load conversation store for collab state persistence
69
+ let ConversationStore = null;
70
+ let serverConvStore = null;
71
+ function getServerConvStore() {
72
+ if (serverConvStore === false) return null;
73
+ if (!serverConvStore) {
74
+ try {
75
+ ConversationStore = require('./lib/conversations').ConversationStore;
76
+ serverConvStore = new ConversationStore();
77
+ if (!serverConvStore.isAvailable()) {
78
+ serverConvStore = false;
79
+ return null;
80
+ }
81
+ } catch (err) {
82
+ serverConvStore = false;
83
+ return null;
84
+ }
85
+ }
86
+ return serverConvStore;
87
+ }
88
+
68
89
  const VALID_PHASES = new Set(['handshake', 'explore', 'deep_dive', 'synthesize', 'close']);
69
90
  const collaborationSessions = new Map();
70
91
  const COLLAB_STATE_TTL_MS = readPositiveIntEnv('A2A_COLLAB_STATE_TTL_MS', 6 * 60 * 60 * 1000);
@@ -249,6 +270,33 @@ function getOrCreateCollaborationState(conversationId, context = {}) {
249
270
  return existing;
250
271
  }
251
272
 
273
+ // Check DB for persisted state (enables restart recovery)
274
+ const convStore = getServerConvStore();
275
+ if (convStore) {
276
+ const dbState = convStore.loadCollabState(conversationId);
277
+ if (dbState) {
278
+ const now = Date.now();
279
+ const restored = {
280
+ conversationId,
281
+ phase: dbState.phase,
282
+ turnCount: dbState.turnCount,
283
+ overlapScore: dbState.overlapScore,
284
+ activeThreads: dbState.activeThreads,
285
+ candidateCollaborations: dbState.candidateCollaborations,
286
+ openQuestions: dbState.openQuestions,
287
+ closeSignal: dbState.closeSignal,
288
+ confidence: dbState.confidence,
289
+ callerName: cleanText(context.callerName, 80),
290
+ callerOwner: cleanText(context.callerOwner, 80),
291
+ tier: context.tier || 'public',
292
+ createdAt: now,
293
+ updatedAt: now
294
+ };
295
+ collaborationSessions.set(conversationId, restored);
296
+ return restored;
297
+ }
298
+ }
299
+
252
300
  const now = Date.now();
253
301
  const state = {
254
302
  conversationId,
@@ -600,6 +648,19 @@ async function callAgent(message, a2aContext) {
600
648
  collabState.updatedAt = Date.now();
601
649
  collaborationSessions.set(conversationId, collabState);
602
650
 
651
+ // Write-through to DB for restart recovery
652
+ const convStoreForPersist = getServerConvStore();
653
+ if (convStoreForPersist) {
654
+ try {
655
+ convStoreForPersist.saveCollabState(conversationId, collabState);
656
+ } catch (err) {
657
+ callLogger.warn('Failed to persist collab state to DB', {
658
+ event: 'collab_state_persist_failed',
659
+ error: err
660
+ });
661
+ }
662
+ }
663
+
603
664
  callLogger.info('Call turn completed', {
604
665
  event: 'call_turn_complete',
605
666
  data: {
@@ -747,9 +808,10 @@ app.use('/api/a2a', createRoutes({
747
808
 
748
809
  async handleMessage(message, context, options) {
749
810
  const traceId = context.trace_id || null;
811
+ const conversationId = context.conversation_id;
750
812
  const requestLogger = logger.child({
751
813
  traceId,
752
- conversationId: context.conversation_id,
814
+ conversationId,
753
815
  tokenId: context.token_id
754
816
  });
755
817
  requestLogger.info('Inbound message accepted for handling', {
@@ -758,17 +820,38 @@ app.use('/api/a2a', createRoutes({
758
820
  caller_name: context.caller?.name || 'unknown'
759
821
  }
760
822
  });
761
-
823
+
762
824
  const response = await callAgent(message, context);
763
-
825
+
826
+ // Check close conditions from collab state
827
+ const collabState = collaborationSessions.get(conversationId);
828
+ let canContinue = true;
829
+ let collaboration = null;
830
+
831
+ if (collabState) {
832
+ collaboration = {
833
+ phase: collabState.phase,
834
+ turnCount: collabState.turnCount,
835
+ overlapScore: collabState.overlapScore,
836
+ activeThreads: collabState.activeThreads,
837
+ candidateCollaborations: collabState.candidateCollaborations,
838
+ closeSignal: collabState.closeSignal
839
+ };
840
+
841
+ if (collabState.closeSignal && collabState.turnCount >= 8) {
842
+ canContinue = false;
843
+ }
844
+ }
845
+
764
846
  requestLogger.info('Outbound response generated', {
765
847
  event: 'handle_message_complete',
766
848
  data: {
767
- response_length: String(response || '').length
849
+ response_length: String(response || '').length,
850
+ can_continue: canContinue
768
851
  }
769
852
  });
770
-
771
- return { text: response, canContinue: true };
853
+
854
+ return { text: response, canContinue, collaboration };
772
855
  },
773
856
 
774
857
  summarizer: generateSummary,