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 +132 -5
- package/package.json +1 -1
- package/src/lib/conversation-driver.js +352 -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,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:
|
|
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
|
-
|
|
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
|
@@ -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 };
|
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,
|