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 +104 -3
- package/package.json +1 -1
- package/src/lib/client.js +2 -2
- package/src/lib/conversation-driver.js +274 -0
- package/src/lib/conversations.js +83 -1
- package/src/routes/a2a.js +8 -2
- package/src/server.js +89 -6
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:
|
|
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
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 :
|
|
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 };
|
package/src/lib/conversations.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
853
|
+
|
|
854
|
+
return { text: response, canContinue, collaboration };
|
|
772
855
|
},
|
|
773
856
|
|
|
774
857
|
summarizer: generateSummary,
|