a2acalling 0.6.32 → 0.6.34

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,114 @@ 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
+ const driver = new ConversationDriver({
1127
+ runtime,
1128
+ agentContext,
1129
+ caller: { name: callerName },
1130
+ endpoint: url,
1131
+ convStore: cs,
1132
+ disclosure,
1133
+ minTurns,
1134
+ maxTurns,
1135
+ onTurn: (info) => {
1136
+ const preview = info.messagePreview.length >= 80
1137
+ ? info.messagePreview + '...'
1138
+ : info.messagePreview;
1139
+ console.log(` Turn ${info.turn} | ${info.phase} | overlap: ${info.overlapScore.toFixed(2)} | ${preview}`);
1140
+ }
1141
+ });
1142
+
1143
+ console.log(`šŸ“ž Starting multi-turn conversation with ${contactName || url}...`);
1144
+ console.log(` Min turns: ${minTurns} | Max turns: ${maxTurns}\n`);
1145
+
1146
+ try {
1147
+ const result = await driver.run(message);
1148
+
1149
+ if (contactName) {
1150
+ store.updateContactStatus(contactName, 'online');
1151
+ }
1152
+
1153
+ console.log(`\nāœ… Conversation complete`);
1154
+ console.log(` Turns: ${result.turnCount}`);
1155
+ console.log(` Phase: ${result.collabState.phase}`);
1156
+ console.log(` Overlap: ${result.collabState.overlapScore.toFixed(2)}`);
1157
+ if (result.collabState.candidateCollaborations.length > 0) {
1158
+ console.log(` Collaborations: ${result.collabState.candidateCollaborations.join(', ')}`);
1159
+ }
1160
+ console.log(` Conversation ID: ${result.conversationId}`);
1161
+ } catch (err) {
1162
+ if (contactName) {
1163
+ store.updateContactStatus(contactName, 'offline', err.message);
1164
+ }
1165
+ console.error(`āŒ Multi-turn call failed: ${err.message}`);
1166
+ process.exit(1);
1167
+ }
1168
+ return;
1169
+ }
1170
+
1171
+ // Single-shot call (existing behavior)
1101
1172
  const client = new A2AClient({
1102
- caller: { name: args.flags.name || 'CLI User' }
1173
+ caller: { name: callerName }
1103
1174
  });
1104
1175
 
1105
1176
  try {
1106
1177
  console.log(`šŸ“ž Calling ${contactName || url}...`);
1107
1178
  const response = await client.call(url, message);
1108
-
1179
+
1109
1180
  // Update contact status on success
1110
1181
  if (contactName) {
1111
1182
  store.updateContactStatus(contactName, 'online');
1112
1183
  }
1113
-
1184
+
1185
+ // Persist conversation locally
1186
+ const cs = getConvStore();
1187
+ if (cs && response.conversation_id) {
1188
+ try {
1189
+ cs.startConversation({
1190
+ id: response.conversation_id,
1191
+ contactId: contactName || null,
1192
+ contactName: contactName || null,
1193
+ direction: 'outbound'
1194
+ });
1195
+ cs.addMessage(response.conversation_id, {
1196
+ direction: 'outbound',
1197
+ role: 'user',
1198
+ content: message
1199
+ });
1200
+ if (response.response) {
1201
+ cs.addMessage(response.conversation_id, {
1202
+ direction: 'inbound',
1203
+ role: 'assistant',
1204
+ content: response.response
1205
+ });
1206
+ }
1207
+ } catch (err) {
1208
+ // Best effort — don't fail the call if persistence fails
1209
+ }
1210
+ }
1211
+
1114
1212
  console.log(`\nāœ… Response:\n`);
1115
1213
  console.log(response.response);
1116
1214
  if (response.conversation_id) {
@@ -1942,6 +2040,9 @@ Conversations:
1942
2040
 
1943
2041
  Calling:
1944
2042
  call <contact|url> <msg> Call a contact (or invite URL)
2043
+ --multi Enable multi-turn conversation
2044
+ --min-turns N Minimum turns before close (default: 8)
2045
+ --max-turns N Maximum turns (default: 25)
1945
2046
  ping <url> Check if agent is reachable
1946
2047
  status <url> Get A2A status
1947
2048
  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.32",
3
+ "version": "0.6.34",
4
4
  "description": "Agent-to-agent calling for OpenClaw - A2A agent communication",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/lib/client.js CHANGED
@@ -42,9 +42,9 @@ function resolveProtocolAndPort(host) {
42
42
  hostname === '::1' ||
43
43
  hostname.startsWith('127.');
44
44
 
45
- const port = hasExplicitPort ? parsed.port : (isLocalhost ? 80 : 443);
45
+ const port = hasExplicitPort ? parsed.port : 80;
46
46
  // Use HTTP for localhost or explicit non-443 ports, HTTPS otherwise.
47
- const useHttp = isLocalhost || (hasExplicitPort && port !== 443);
47
+ const useHttp = isLocalhost || port === 80 || (hasExplicitPort && port !== 443);
48
48
  const protocol = useHttp ? http : https;
49
49
 
50
50
  return { protocol, hostname, port };
@@ -0,0 +1,274 @@
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
+ */
40
+ constructor(options) {
41
+ this.runtime = options.runtime;
42
+ this.agentContext = options.agentContext;
43
+ this.caller = options.caller || {};
44
+ this.endpoint = options.endpoint;
45
+ this.convStore = options.convStore || null;
46
+ this.disclosure = options.disclosure || null;
47
+ this.minTurns = options.minTurns || 8;
48
+ this.maxTurns = options.maxTurns || 30;
49
+ this.onTurn = options.onTurn || null;
50
+ this.tier = options.tier || 'public';
51
+
52
+ this.client = new A2AClient({ caller: this.caller, timeout: 65000 });
53
+ }
54
+
55
+ /**
56
+ * Run the full multi-turn conversation
57
+ *
58
+ * @param {string} openingMessage - First message to send
59
+ * @returns {Promise<{conversationId, turnCount, collabState, transcript}>}
60
+ */
61
+ async run(openingMessage) {
62
+ const transcript = [];
63
+ let conversationId = null;
64
+
65
+ const collabState = {
66
+ phase: 'handshake',
67
+ turnCount: 0,
68
+ overlapScore: 0.15,
69
+ activeThreads: [],
70
+ candidateCollaborations: [],
71
+ openQuestions: [],
72
+ closeSignal: false,
73
+ confidence: 0.25
74
+ };
75
+
76
+ // Start conversation in DB if available
77
+ if (this.convStore) {
78
+ const convResult = this.convStore.startConversation({ direction: 'outbound' });
79
+ conversationId = convResult.id;
80
+ } else {
81
+ conversationId = `conv_${Date.now()}_local`;
82
+ }
83
+
84
+ let nextMessage = openingMessage;
85
+
86
+ for (let turn = 0; turn < this.maxTurns; turn++) {
87
+ // 1. Send message to remote
88
+ let remoteResponse;
89
+ try {
90
+ remoteResponse = await this.client.call(this.endpoint, nextMessage, {
91
+ conversationId
92
+ });
93
+ } catch (err) {
94
+ logger.error('Remote call failed', {
95
+ event: 'driver_remote_call_failed',
96
+ error: err,
97
+ data: { turn, conversationId }
98
+ });
99
+ break;
100
+ }
101
+
102
+ // Update conversation ID from remote if first turn
103
+ if (turn === 0 && remoteResponse.conversation_id) {
104
+ conversationId = remoteResponse.conversation_id;
105
+ }
106
+
107
+ const remoteText = remoteResponse.response || '';
108
+ const remoteContinue = remoteResponse.can_continue !== false;
109
+
110
+ // 2. Store messages in DB
111
+ if (this.convStore) {
112
+ this.convStore.addMessage(conversationId, {
113
+ direction: 'outbound',
114
+ role: 'user',
115
+ content: nextMessage
116
+ });
117
+ this.convStore.addMessage(conversationId, {
118
+ direction: 'inbound',
119
+ role: 'assistant',
120
+ content: remoteText
121
+ });
122
+ }
123
+
124
+ transcript.push(
125
+ { role: 'outbound', content: nextMessage },
126
+ { role: 'inbound', content: remoteText }
127
+ );
128
+
129
+ collabState.turnCount = turn + 1;
130
+
131
+ // 3. Check close conditions
132
+ if (!remoteContinue) {
133
+ logger.info('Remote signaled conversation end', {
134
+ event: 'driver_remote_close',
135
+ data: { turn: turn + 1, conversationId }
136
+ });
137
+ break;
138
+ }
139
+
140
+ if (collabState.closeSignal && collabState.turnCount >= this.minTurns) {
141
+ logger.info('Local close signal met minimum turns', {
142
+ event: 'driver_local_close',
143
+ data: { turn: turn + 1, conversationId }
144
+ });
145
+ break;
146
+ }
147
+
148
+ // Don't generate a reply on the last possible turn
149
+ if (turn + 1 >= this.maxTurns) {
150
+ break;
151
+ }
152
+
153
+ // 4. Build prompt for our turn
154
+ const manifest = this.disclosure || loadManifest();
155
+ const tierTopics = getTopicsForTier(this.tier);
156
+ const formattedTopics = formatTopicsForPrompt(tierTopics);
157
+
158
+ const prompt = buildAdaptiveConnectionPrompt({
159
+ agentName: this.agentContext.name,
160
+ ownerName: this.agentContext.owner,
161
+ otherAgentName: this.caller.name || 'Remote Agent',
162
+ otherOwnerName: this.caller.owner || 'their owner',
163
+ roleContext: 'You initiated this call.',
164
+ accessTier: this.tier,
165
+ tierTopics: formattedTopics,
166
+ tierGoals: [],
167
+ otherAgentGreeting: remoteText,
168
+ personalityNotes: manifest.personality_notes || '',
169
+ conversationState: collabState
170
+ });
171
+
172
+ // 5. Call runtime.runTurn() to generate next message
173
+ const sessionId = `a2a-${conversationId}`;
174
+ let rawResponse;
175
+ try {
176
+ rawResponse = await this.runtime.runTurn({
177
+ sessionId,
178
+ prompt,
179
+ message: remoteText,
180
+ caller: this.caller,
181
+ timeoutMs: 65000,
182
+ context: {
183
+ conversationId,
184
+ tier: this.tier,
185
+ ownerName: this.agentContext.owner
186
+ }
187
+ });
188
+ } catch (err) {
189
+ logger.error('Runtime turn failed', {
190
+ event: 'driver_runtime_failed',
191
+ error: err,
192
+ data: { turn: turn + 1, conversationId }
193
+ });
194
+ break;
195
+ }
196
+
197
+ // 6. Extract collab state from response
198
+ const parsed = extractCollaborationState(rawResponse);
199
+ nextMessage = parsed.cleanText || rawResponse;
200
+
201
+ if (parsed.hasState && parsed.statePatch) {
202
+ if (parsed.statePatch.phase) collabState.phase = parsed.statePatch.phase;
203
+ if (parsed.statePatch.overlapScore != null) {
204
+ collabState.overlapScore = Math.max(0, Math.min(1, parsed.statePatch.overlapScore));
205
+ }
206
+ if (Array.isArray(parsed.statePatch.activeThreads)) {
207
+ collabState.activeThreads = parsed.statePatch.activeThreads.slice(0, 4);
208
+ }
209
+ if (Array.isArray(parsed.statePatch.candidateCollaborations)) {
210
+ collabState.candidateCollaborations = parsed.statePatch.candidateCollaborations.slice(0, 4);
211
+ }
212
+ if (parsed.statePatch.closeSignal != null) {
213
+ collabState.closeSignal = Boolean(parsed.statePatch.closeSignal);
214
+ }
215
+ if (parsed.statePatch.confidence != null) {
216
+ collabState.confidence = Math.max(0, Math.min(1, parsed.statePatch.confidence));
217
+ }
218
+ }
219
+
220
+ // 7. Persist collab state to DB
221
+ if (this.convStore) {
222
+ try {
223
+ this.convStore.saveCollabState(conversationId, collabState);
224
+ } catch (err) {
225
+ // Best effort
226
+ }
227
+ }
228
+
229
+ // onTurn callback for progress output
230
+ if (this.onTurn) {
231
+ try {
232
+ this.onTurn({
233
+ turn: turn + 1,
234
+ phase: collabState.phase,
235
+ overlapScore: collabState.overlapScore,
236
+ closeSignal: collabState.closeSignal,
237
+ messagePreview: nextMessage.slice(0, 80)
238
+ });
239
+ } catch (err) {
240
+ // Don't let callback errors break the loop
241
+ }
242
+ }
243
+ }
244
+
245
+ // 9. End conversation remotely
246
+ try {
247
+ await this.client.end(this.endpoint, conversationId);
248
+ } catch (err) {
249
+ logger.warn('Failed to end remote conversation', {
250
+ event: 'driver_end_failed',
251
+ error: err,
252
+ data: { conversationId }
253
+ });
254
+ }
255
+
256
+ // Conclude locally
257
+ if (this.convStore) {
258
+ try {
259
+ await this.convStore.concludeConversation(conversationId);
260
+ } catch (err) {
261
+ // Best effort
262
+ }
263
+ }
264
+
265
+ return {
266
+ conversationId,
267
+ turnCount: collabState.turnCount,
268
+ collabState,
269
+ transcript
270
+ };
271
+ }
272
+ }
273
+
274
+ 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,