a2acalling 0.6.43 → 0.6.44
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/README.md +1 -5
- package/bin/cli.js +1 -2
- package/package.json +1 -1
- package/src/lib/claude-subagent.js +485 -0
- package/src/lib/conversation-driver.js +80 -29
- package/src/lib/disclosure.js +5 -6
- package/src/lib/runtime-adapter.js +221 -437
- package/src/routes/dashboard.js +5 -5
- package/src/server.js +5 -10
package/README.md
CHANGED
|
@@ -410,11 +410,7 @@ app.listen(3001);
|
|
|
410
410
|
| `A2A_PORT` | Server port (default: 3001) |
|
|
411
411
|
| `A2A_CONFIG_DIR` | Config directory (default: `~/.config/openclaw`) |
|
|
412
412
|
| `A2A_WORKSPACE` | Workspace root for context files like `USER.md` (default: current directory) |
|
|
413
|
-
| `A2A_RUNTIME` | Runtime mode: `auto` (default), `openclaw`, or `
|
|
414
|
-
| `A2A_RUNTIME_FAILOVER` | Fallback to generic runtime if OpenClaw runtime errors (default: `true`) |
|
|
415
|
-
| `A2A_AGENT_COMMAND` | Generic runtime command for inbound turn handling (reads JSON from stdin) |
|
|
416
|
-
| `A2A_SUMMARY_COMMAND` | Generic runtime command for call summaries (reads JSON from stdin) |
|
|
417
|
-
| `A2A_NOTIFY_COMMAND` | Generic runtime command for owner notifications (reads JSON from stdin) |
|
|
413
|
+
| `A2A_RUNTIME` | Runtime mode: `auto` (default), `openclaw`, or `claude` |
|
|
418
414
|
| `A2A_AGENT_NAME` | Override local agent display name |
|
|
419
415
|
| `A2A_OWNER_NAME` | Override owner display name |
|
|
420
416
|
| `A2A_COLLAB_MODE` | Conversation style: `adaptive` (default) or `deep_dive` |
|
package/bin/cli.js
CHANGED
|
@@ -518,8 +518,7 @@ async function handleDisclosureSubmit(args, commandLabel = 'onboard') {
|
|
|
518
518
|
return tierData.topics.map(t => String(t && t.topic || '').trim()).filter(Boolean);
|
|
519
519
|
}
|
|
520
520
|
|
|
521
|
-
|
|
522
|
-
const tiersData = manifest.tiers || manifest.topics || {};
|
|
521
|
+
const tiersData = manifest.tiers || {};
|
|
523
522
|
|
|
524
523
|
try {
|
|
525
524
|
config.setTier('public', {
|
package/package.json
CHANGED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Subagent — Lifecycle management for Claude CLI subagent sessions.
|
|
3
|
+
*
|
|
4
|
+
* Spawns `claude` CLI processes for real LLM-powered A2A conversations
|
|
5
|
+
* as an alternative to OpenClaw for A2A conversations.
|
|
6
|
+
*
|
|
7
|
+
* Uses `claude -p` (print mode) with `--resume` for multi-turn context continuity.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { execSync, spawn } = require('child_process');
|
|
11
|
+
const { createLogger } = require('./logger');
|
|
12
|
+
|
|
13
|
+
const logger = createLogger({ component: 'a2a.claude-subagent' });
|
|
14
|
+
|
|
15
|
+
const A2A_RESPONSE_REGEX = /<a2a_response>\s*([\s\S]*?)\s*<\/a2a_response>/i;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Check if `claude` CLI is available in PATH.
|
|
19
|
+
*/
|
|
20
|
+
function isClaudeAvailable() {
|
|
21
|
+
try {
|
|
22
|
+
execSync('command -v claude', { stdio: 'ignore' });
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build the system prompt for the Claude subagent.
|
|
31
|
+
*
|
|
32
|
+
* @param {Object} config
|
|
33
|
+
* @param {string} config.agentName
|
|
34
|
+
* @param {string} config.ownerName
|
|
35
|
+
* @param {string} config.otherAgentName
|
|
36
|
+
* @param {string} config.otherOwnerName
|
|
37
|
+
* @param {string} config.accessTier
|
|
38
|
+
* @param {string} config.tierTopics - formatted topics string
|
|
39
|
+
* @param {string} config.tierObjectives - formatted objectives string
|
|
40
|
+
* @param {string} config.doNotDiscuss - formatted do_not_discuss string
|
|
41
|
+
* @param {string} config.neverDisclose - formatted never_disclose string
|
|
42
|
+
* @param {string} config.personalityNotes
|
|
43
|
+
* @param {string} config.roleContext
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
function buildSubagentSystemPrompt(config) {
|
|
47
|
+
const {
|
|
48
|
+
agentName = 'Agent',
|
|
49
|
+
ownerName = 'the owner',
|
|
50
|
+
otherAgentName = 'Remote Agent',
|
|
51
|
+
otherOwnerName = 'their owner',
|
|
52
|
+
accessTier = 'public',
|
|
53
|
+
tierTopics = ' (none specified)',
|
|
54
|
+
tierObjectives = ' (none specified)',
|
|
55
|
+
doNotDiscuss = ' (none specified)',
|
|
56
|
+
neverDisclose = ' (none specified)',
|
|
57
|
+
personalityNotes = '',
|
|
58
|
+
roleContext = ''
|
|
59
|
+
} = config;
|
|
60
|
+
|
|
61
|
+
return `You are ${agentName}, the personal AI agent for ${ownerName}.
|
|
62
|
+
You are on a live A2A (agent-to-agent) call with ${otherAgentName}, who represents ${otherOwnerName}. ${roleContext}
|
|
63
|
+
|
|
64
|
+
== OUTPUT FORMAT ==
|
|
65
|
+
|
|
66
|
+
After your conversational reply, you MUST append exactly one structured response block:
|
|
67
|
+
|
|
68
|
+
<a2a_response>
|
|
69
|
+
{"message":"Your conversational reply here","statePatch":{"phase":"explore","overlapScore":0.3,"activeThreads":["thread1"],"candidateCollaborations":["idea1"],"closeSignal":false,"confidence":0.4},"flags":[]}
|
|
70
|
+
</a2a_response>
|
|
71
|
+
|
|
72
|
+
Rules for the response block:
|
|
73
|
+
- "message" (required): Your full conversational reply text. This is what the other agent sees.
|
|
74
|
+
- "statePatch" (optional): Collaboration state update with any of: phase, overlapScore (0-1), activeThreads (max 4), candidateCollaborations (max 4), closeSignal (boolean), confidence (0-1).
|
|
75
|
+
- "flags" (optional): Array of flag objects like {"type":"question_for_owner","content":"..."} or {"type":"opportunity_flagged","content":"..."}.
|
|
76
|
+
- Must be valid JSON (double quotes only).
|
|
77
|
+
- The message in the JSON block should match your visible conversational text.
|
|
78
|
+
|
|
79
|
+
Flag types:
|
|
80
|
+
- "question_for_owner": Something you want to ask ${ownerName} about before committing
|
|
81
|
+
- "opportunity_flagged": A concrete collaboration opportunity worth the owner's attention
|
|
82
|
+
- "boundary_touched": The other agent probed near a do_not_discuss or never_disclose topic
|
|
83
|
+
- "unverifiable_claim": The other agent made a claim you cannot verify
|
|
84
|
+
|
|
85
|
+
== DISCLOSURE CONTEXT ==
|
|
86
|
+
|
|
87
|
+
Access level: ${accessTier}
|
|
88
|
+
|
|
89
|
+
${ownerName}'s topics of interest:
|
|
90
|
+
${tierTopics}
|
|
91
|
+
|
|
92
|
+
Objectives:
|
|
93
|
+
${tierObjectives}
|
|
94
|
+
|
|
95
|
+
DO NOT DISCUSS (redirect naturally):
|
|
96
|
+
${doNotDiscuss}
|
|
97
|
+
|
|
98
|
+
NEVER disclose:
|
|
99
|
+
${neverDisclose}
|
|
100
|
+
|
|
101
|
+
== BEHAVIORAL MANDATE ==
|
|
102
|
+
|
|
103
|
+
You operate in three concurrent modes:
|
|
104
|
+
|
|
105
|
+
1. EXPLORING: Map the other agent's owner — capabilities, resources, blind spots, ambitions.
|
|
106
|
+
Ask probing questions. Don't accept surface-level answers. Dig into specifics.
|
|
107
|
+
|
|
108
|
+
2. ADVERSARIALLY QUALIFYING: Pressure-test claims. Push back respectfully.
|
|
109
|
+
"You say X, but that sounds like Y. What's actually different?"
|
|
110
|
+
"That's a crowded space. What makes their angle defensible?"
|
|
111
|
+
The best collaborations come from people who can handle scrutiny.
|
|
112
|
+
|
|
113
|
+
3. COLLABORATING: Look for concrete overlap and actionable next steps.
|
|
114
|
+
Complementary capabilities, shared challenges, non-obvious intersections.
|
|
115
|
+
Propose specific ideas, not vague "let's stay in touch."
|
|
116
|
+
|
|
117
|
+
== PHASE AWARENESS ==
|
|
118
|
+
|
|
119
|
+
Each turn you receive state including turn number, maxTurns, and current phase.
|
|
120
|
+
Adapt your behavior to the phase:
|
|
121
|
+
|
|
122
|
+
- handshake (turns 1-2): Establish context, introduce key topics, set one meaningful direction.
|
|
123
|
+
- exploring (turns 2-6): Map goals, capabilities, constraints. Stay here while new info surfaces.
|
|
124
|
+
- deepening (turns 5-10): Work through specific collaboration threads in detail.
|
|
125
|
+
- converging (turns 8+): Convert insights into concrete next steps. Set closeSignal when done.
|
|
126
|
+
|
|
127
|
+
These are guidelines, not hard locks. Stay in any phase as long as it's productive.
|
|
128
|
+
|
|
129
|
+
== PERSONALITY ==
|
|
130
|
+
|
|
131
|
+
${personalityNotes || "Direct, curious, slightly irreverent. You have opinions and share them. You're not a concierge — you're a sparring partner who represents someone."}
|
|
132
|
+
|
|
133
|
+
When unsure about your owner's position, say so: "I don't have ${ownerName}'s take on that — but here's what I think based on their work..."`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Build the turn prompt containing state and the inbound message.
|
|
138
|
+
*/
|
|
139
|
+
function buildTurnPrompt(options) {
|
|
140
|
+
const {
|
|
141
|
+
turnMessage,
|
|
142
|
+
turn,
|
|
143
|
+
maxTurns,
|
|
144
|
+
phase = 'handshake',
|
|
145
|
+
overlapScore = 0.15,
|
|
146
|
+
activeThreads = [],
|
|
147
|
+
candidateCollaborations = [],
|
|
148
|
+
closeSignal = false
|
|
149
|
+
} = options;
|
|
150
|
+
|
|
151
|
+
return `== TURN STATE ==
|
|
152
|
+
Turn: ${turn}/${maxTurns}
|
|
153
|
+
Phase: ${phase}
|
|
154
|
+
Overlap score: ${overlapScore}
|
|
155
|
+
Active threads: ${activeThreads.length > 0 ? activeThreads.join(', ') : '(none)'}
|
|
156
|
+
Candidate collaborations: ${candidateCollaborations.length > 0 ? candidateCollaborations.join(', ') : '(none)'}
|
|
157
|
+
Close signal: ${closeSignal}
|
|
158
|
+
|
|
159
|
+
== INBOUND MESSAGE ==
|
|
160
|
+
|
|
161
|
+
${turnMessage}
|
|
162
|
+
|
|
163
|
+
Respond naturally, then append your <a2a_response> block.`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Parse the structured response from Claude's output.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} resultText - Raw text output from claude CLI
|
|
170
|
+
* @returns {{ message: string, statePatch: object|null, flags: array }}
|
|
171
|
+
*/
|
|
172
|
+
function parseSubagentResponse(resultText) {
|
|
173
|
+
if (!resultText || typeof resultText !== 'string') {
|
|
174
|
+
return { message: '', statePatch: null, flags: [] };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const match = resultText.match(A2A_RESPONSE_REGEX);
|
|
178
|
+
if (!match) {
|
|
179
|
+
// Graceful degradation: treat entire result as the message
|
|
180
|
+
return { message: resultText.trim(), statePatch: null, flags: [] };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const jsonStr = (match[1] || '').trim();
|
|
184
|
+
if (!jsonStr) {
|
|
185
|
+
const cleanText = resultText.replace(A2A_RESPONSE_REGEX, '').trim();
|
|
186
|
+
return { message: cleanText || '', statePatch: null, flags: [] };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(jsonStr);
|
|
191
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
192
|
+
throw new Error('a2a_response must be a JSON object');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
message: typeof parsed.message === 'string' ? parsed.message : resultText.replace(A2A_RESPONSE_REGEX, '').trim(),
|
|
197
|
+
statePatch: parsed.statePatch && typeof parsed.statePatch === 'object' ? parsed.statePatch : null,
|
|
198
|
+
flags: Array.isArray(parsed.flags) ? parsed.flags : []
|
|
199
|
+
};
|
|
200
|
+
} catch (err) {
|
|
201
|
+
logger.warn('Failed to parse <a2a_response> JSON', {
|
|
202
|
+
event: 'subagent_response_parse_failed',
|
|
203
|
+
error: err,
|
|
204
|
+
data: { json_length: jsonStr.length }
|
|
205
|
+
});
|
|
206
|
+
// Fall back to using text outside the tags
|
|
207
|
+
const cleanText = resultText.replace(A2A_RESPONSE_REGEX, '').trim();
|
|
208
|
+
return { message: cleanText || resultText.trim(), statePatch: null, flags: [] };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Spawn `claude` CLI and collect output as a promise.
|
|
214
|
+
*
|
|
215
|
+
* @param {string[]} args - CLI arguments
|
|
216
|
+
* @param {number} timeoutMs - Timeout in milliseconds
|
|
217
|
+
* @returns {Promise<{ stdout: string, stderr: string }>}
|
|
218
|
+
*/
|
|
219
|
+
function spawnClaude(args, timeoutMs = 180000) {
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
const proc = spawn('claude', args, {
|
|
222
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
223
|
+
env: {
|
|
224
|
+
...process.env,
|
|
225
|
+
FORCE_COLOR: '0',
|
|
226
|
+
CLAUDECODE: '' // Unset to allow nested invocation
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
let stdout = '';
|
|
231
|
+
let stderr = '';
|
|
232
|
+
let killed = false;
|
|
233
|
+
|
|
234
|
+
const timer = setTimeout(() => {
|
|
235
|
+
killed = true;
|
|
236
|
+
proc.kill('SIGTERM');
|
|
237
|
+
// Give it 5s to clean up, then force kill
|
|
238
|
+
setTimeout(() => {
|
|
239
|
+
try { proc.kill('SIGKILL'); } catch (e) { /* already dead */ }
|
|
240
|
+
}, 5000);
|
|
241
|
+
}, timeoutMs);
|
|
242
|
+
|
|
243
|
+
proc.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
244
|
+
proc.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
245
|
+
|
|
246
|
+
proc.on('close', (code) => {
|
|
247
|
+
clearTimeout(timer);
|
|
248
|
+
if (killed) {
|
|
249
|
+
reject(new Error(`Claude CLI timed out after ${timeoutMs}ms`));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (code !== 0 && !stdout.trim()) {
|
|
253
|
+
reject(new Error(`Claude CLI exited with code ${code}: ${stderr.slice(0, 500)}`));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
resolve({ stdout, stderr });
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
proc.on('error', (err) => {
|
|
260
|
+
clearTimeout(timer);
|
|
261
|
+
reject(err);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Extract the result text from Claude's JSON output.
|
|
268
|
+
* Claude with --output-format json returns { type, subtype, cost_usd, duration_ms, duration_api_ms,
|
|
269
|
+
* is_error, num_turns, result, session_id, ... }
|
|
270
|
+
*/
|
|
271
|
+
function extractResultFromJson(stdout) {
|
|
272
|
+
const trimmed = stdout.trim();
|
|
273
|
+
if (!trimmed) return { result: '', sessionId: null };
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const parsed = JSON.parse(trimmed);
|
|
277
|
+
return {
|
|
278
|
+
result: typeof parsed.result === 'string' ? parsed.result : '',
|
|
279
|
+
sessionId: parsed.session_id || null
|
|
280
|
+
};
|
|
281
|
+
} catch (err) {
|
|
282
|
+
// If JSON parsing fails, treat entire output as result text
|
|
283
|
+
logger.debug('Claude output not valid JSON, using raw text', {
|
|
284
|
+
event: 'subagent_json_parse_fallback',
|
|
285
|
+
data: { output_length: trimmed.length }
|
|
286
|
+
});
|
|
287
|
+
return { result: trimmed, sessionId: null };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Run a single turn of the Claude subagent.
|
|
293
|
+
*
|
|
294
|
+
* @param {Object} options
|
|
295
|
+
* @param {string} options.sessionId - Conversation session ID (used for --resume on turn 2+)
|
|
296
|
+
* @param {string} options.systemPrompt - System prompt (used on turn 1 only)
|
|
297
|
+
* @param {string} options.turnMessage - The inbound message from the remote agent
|
|
298
|
+
* @param {number} options.turn - Current turn number (1-based)
|
|
299
|
+
* @param {number} options.maxTurns - Maximum turns allowed
|
|
300
|
+
* @param {string} options.phase - Current conversation phase
|
|
301
|
+
* @param {number} options.overlapScore - Current overlap score
|
|
302
|
+
* @param {Array} options.activeThreads - Active conversation threads
|
|
303
|
+
* @param {Array} options.candidateCollaborations - Candidate collaboration ideas
|
|
304
|
+
* @param {boolean} options.closeSignal - Whether close has been signaled
|
|
305
|
+
* @param {number} [options.timeoutMs=180000] - Timeout in milliseconds
|
|
306
|
+
* @returns {Promise<{ message: string, statePatch: object|null, flags: array, sessionId: string }>}
|
|
307
|
+
*/
|
|
308
|
+
async function runClaudeTurn(options) {
|
|
309
|
+
const {
|
|
310
|
+
sessionId,
|
|
311
|
+
systemPrompt,
|
|
312
|
+
turnMessage,
|
|
313
|
+
turn = 1,
|
|
314
|
+
maxTurns = 30,
|
|
315
|
+
phase = 'handshake',
|
|
316
|
+
overlapScore = 0.15,
|
|
317
|
+
activeThreads = [],
|
|
318
|
+
candidateCollaborations = [],
|
|
319
|
+
closeSignal = false,
|
|
320
|
+
timeoutMs = 180000
|
|
321
|
+
} = options;
|
|
322
|
+
|
|
323
|
+
const turnPrompt = buildTurnPrompt({
|
|
324
|
+
turnMessage,
|
|
325
|
+
turn,
|
|
326
|
+
maxTurns,
|
|
327
|
+
phase,
|
|
328
|
+
overlapScore,
|
|
329
|
+
activeThreads,
|
|
330
|
+
candidateCollaborations,
|
|
331
|
+
closeSignal
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const startAt = Date.now();
|
|
335
|
+
const allowedTools = 'Bash(readonly) Read Grep Glob WebSearch WebFetch';
|
|
336
|
+
|
|
337
|
+
let args;
|
|
338
|
+
if (turn === 1 || !sessionId) {
|
|
339
|
+
// First turn: create new session
|
|
340
|
+
args = [
|
|
341
|
+
'-p',
|
|
342
|
+
'--output-format', 'json',
|
|
343
|
+
'--system-prompt', systemPrompt,
|
|
344
|
+
'--allowedTools', allowedTools,
|
|
345
|
+
'--model', 'claude-sonnet-4-5-20250929',
|
|
346
|
+
turnPrompt
|
|
347
|
+
];
|
|
348
|
+
} else {
|
|
349
|
+
// Subsequent turns: resume existing session
|
|
350
|
+
args = [
|
|
351
|
+
'-p',
|
|
352
|
+
'--output-format', 'json',
|
|
353
|
+
'--resume', sessionId,
|
|
354
|
+
'--allowedTools', allowedTools,
|
|
355
|
+
turnPrompt
|
|
356
|
+
];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
logger.debug('Spawning Claude subagent turn', {
|
|
360
|
+
event: 'subagent_turn_start',
|
|
361
|
+
data: {
|
|
362
|
+
turn,
|
|
363
|
+
max_turns: maxTurns,
|
|
364
|
+
phase,
|
|
365
|
+
is_resume: turn > 1 && Boolean(sessionId),
|
|
366
|
+
timeout_ms: timeoutMs
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const { stdout } = await spawnClaude(args, timeoutMs);
|
|
371
|
+
const { result, sessionId: newSessionId } = extractResultFromJson(stdout);
|
|
372
|
+
const parsed = parseSubagentResponse(result);
|
|
373
|
+
|
|
374
|
+
logger.debug('Claude subagent turn completed', {
|
|
375
|
+
event: 'subagent_turn_complete',
|
|
376
|
+
data: {
|
|
377
|
+
turn,
|
|
378
|
+
duration_ms: Date.now() - startAt,
|
|
379
|
+
message_length: parsed.message.length,
|
|
380
|
+
has_state_patch: Boolean(parsed.statePatch),
|
|
381
|
+
flag_count: parsed.flags.length,
|
|
382
|
+
session_id: newSessionId || sessionId
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
message: parsed.message,
|
|
388
|
+
statePatch: parsed.statePatch,
|
|
389
|
+
flags: parsed.flags,
|
|
390
|
+
sessionId: newSessionId || sessionId
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Run a summary turn using the Claude subagent session.
|
|
396
|
+
*
|
|
397
|
+
* @param {string} sessionId - Session ID to resume
|
|
398
|
+
* @param {string} reason - Why the conversation is ending
|
|
399
|
+
* @param {number} [timeoutMs=120000] - Timeout in milliseconds
|
|
400
|
+
* @returns {Promise<{ summary: string, ownerSummary: string, actionItems: array, flags: array }>}
|
|
401
|
+
*/
|
|
402
|
+
async function runClaudeSummary(sessionId, reason, timeoutMs = 120000) {
|
|
403
|
+
if (!sessionId) {
|
|
404
|
+
throw new Error('Cannot summarize without a session ID');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const summaryPrompt = `The conversation is ending. Reason: ${reason || 'max turns reached'}.
|
|
408
|
+
|
|
409
|
+
Provide a structured summary. Respond with ONLY a JSON block:
|
|
410
|
+
|
|
411
|
+
<a2a_response>
|
|
412
|
+
{
|
|
413
|
+
"message": "Brief 1-2 sentence summary of the conversation.",
|
|
414
|
+
"statePatch": {"phase": "close", "closeSignal": true},
|
|
415
|
+
"flags": [],
|
|
416
|
+
"summary": "Detailed summary for the conversation record.",
|
|
417
|
+
"ownerSummary": "Summary written for the owner highlighting key findings and opportunities.",
|
|
418
|
+
"actionItems": ["Specific follow-up item 1", "Specific follow-up item 2"]
|
|
419
|
+
}
|
|
420
|
+
</a2a_response>`;
|
|
421
|
+
|
|
422
|
+
const args = [
|
|
423
|
+
'-p',
|
|
424
|
+
'--output-format', 'json',
|
|
425
|
+
'--resume', sessionId,
|
|
426
|
+
summaryPrompt
|
|
427
|
+
];
|
|
428
|
+
|
|
429
|
+
const startAt = Date.now();
|
|
430
|
+
|
|
431
|
+
logger.debug('Spawning Claude summary', {
|
|
432
|
+
event: 'subagent_summary_start',
|
|
433
|
+
data: { session_id: sessionId, reason }
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const { stdout } = await spawnClaude(args, timeoutMs);
|
|
437
|
+
const { result } = extractResultFromJson(stdout);
|
|
438
|
+
|
|
439
|
+
// Try to extract structured summary from <a2a_response>
|
|
440
|
+
const match = result.match(A2A_RESPONSE_REGEX);
|
|
441
|
+
if (match) {
|
|
442
|
+
try {
|
|
443
|
+
const parsed = JSON.parse(match[1].trim());
|
|
444
|
+
logger.debug('Claude summary completed', {
|
|
445
|
+
event: 'subagent_summary_complete',
|
|
446
|
+
data: {
|
|
447
|
+
session_id: sessionId,
|
|
448
|
+
duration_ms: Date.now() - startAt,
|
|
449
|
+
has_summary: Boolean(parsed.summary),
|
|
450
|
+
action_item_count: Array.isArray(parsed.actionItems) ? parsed.actionItems.length : 0
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
summary: parsed.summary || parsed.message || result.replace(A2A_RESPONSE_REGEX, '').trim(),
|
|
456
|
+
ownerSummary: parsed.ownerSummary || parsed.summary || parsed.message || '',
|
|
457
|
+
actionItems: Array.isArray(parsed.actionItems) ? parsed.actionItems : [],
|
|
458
|
+
flags: Array.isArray(parsed.flags) ? parsed.flags : []
|
|
459
|
+
};
|
|
460
|
+
} catch (err) {
|
|
461
|
+
logger.warn('Failed to parse summary JSON', {
|
|
462
|
+
event: 'subagent_summary_parse_failed',
|
|
463
|
+
error: err
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Fallback: use raw text as summary
|
|
469
|
+
const summaryText = result.replace(A2A_RESPONSE_REGEX, '').trim() || result.trim();
|
|
470
|
+
return {
|
|
471
|
+
summary: summaryText,
|
|
472
|
+
ownerSummary: summaryText,
|
|
473
|
+
actionItems: [],
|
|
474
|
+
flags: []
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
module.exports = {
|
|
479
|
+
isClaudeAvailable,
|
|
480
|
+
buildSubagentSystemPrompt,
|
|
481
|
+
buildTurnPrompt,
|
|
482
|
+
runClaudeTurn,
|
|
483
|
+
runClaudeSummary,
|
|
484
|
+
parseSubagentResponse
|
|
485
|
+
};
|
|
@@ -56,7 +56,7 @@ function detectRemoteTermination(text) {
|
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
58
|
* Infer collaboration state progression when the runtime doesn't emit
|
|
59
|
-
* <collab_state> tags (
|
|
59
|
+
* <collab_state> tags (e.g. OpenClaw without adaptive mode). Advances phase based on
|
|
60
60
|
* turn count and estimates overlap from remote text analysis.
|
|
61
61
|
*/
|
|
62
62
|
function inferStateProgression(collabState, remoteText, turn) {
|
|
@@ -128,8 +128,11 @@ class ConversationDriver {
|
|
|
128
128
|
this.tier = options.tier || 'public';
|
|
129
129
|
this.summarizer = options.summarizer || null;
|
|
130
130
|
this.ownerContext = options.ownerContext || {};
|
|
131
|
+
this.claudeMode = options.runtime?.mode === 'claude';
|
|
132
|
+
this.claudeTimeoutMs = options.claudeTimeoutMs || 180000;
|
|
131
133
|
|
|
132
|
-
|
|
134
|
+
const clientTimeout = this.claudeMode ? 200000 : 65000;
|
|
135
|
+
this.client = new A2AClient({ caller: this.caller, timeout: clientTimeout });
|
|
133
136
|
}
|
|
134
137
|
|
|
135
138
|
/**
|
|
@@ -351,18 +354,30 @@ Be concise but specific. No filler.`;
|
|
|
351
354
|
// 5. Call runtime.runTurn() to generate next message
|
|
352
355
|
const sessionId = `a2a-${conversationId}`;
|
|
353
356
|
let rawResponse;
|
|
357
|
+
const contextPayload = {
|
|
358
|
+
conversationId,
|
|
359
|
+
tier: this.tier,
|
|
360
|
+
ownerName: this.agentContext.owner,
|
|
361
|
+
agentName: this.agentContext.name,
|
|
362
|
+
roleContext: 'You initiated this call.'
|
|
363
|
+
};
|
|
364
|
+
if (this.claudeMode) {
|
|
365
|
+
contextPayload.turnCount = turn + 1;
|
|
366
|
+
contextPayload.maxTurns = this.maxTurns;
|
|
367
|
+
contextPayload.phase = collabState.phase;
|
|
368
|
+
contextPayload.overlapScore = collabState.overlapScore;
|
|
369
|
+
contextPayload.activeThreads = collabState.activeThreads;
|
|
370
|
+
contextPayload.candidateCollaborations = collabState.candidateCollaborations;
|
|
371
|
+
contextPayload.closeSignal = collabState.closeSignal;
|
|
372
|
+
}
|
|
354
373
|
try {
|
|
355
374
|
rawResponse = await this.runtime.runTurn({
|
|
356
375
|
sessionId,
|
|
357
376
|
prompt,
|
|
358
377
|
message: remoteText,
|
|
359
378
|
caller: this.caller,
|
|
360
|
-
timeoutMs: 65000,
|
|
361
|
-
context:
|
|
362
|
-
conversationId,
|
|
363
|
-
tier: this.tier,
|
|
364
|
-
ownerName: this.agentContext.owner
|
|
365
|
-
}
|
|
379
|
+
timeoutMs: this.claudeMode ? this.claudeTimeoutMs : 65000,
|
|
380
|
+
context: contextPayload
|
|
366
381
|
});
|
|
367
382
|
} catch (err) {
|
|
368
383
|
logger.error('Runtime turn failed', {
|
|
@@ -374,33 +389,59 @@ Be concise but specific. No filler.`;
|
|
|
374
389
|
}
|
|
375
390
|
|
|
376
391
|
// 6. Extract collab state from response
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
if (
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
392
|
+
// In claude mode, use the side channel (getLastTurnMeta) for state/flags
|
|
393
|
+
const turnMeta = this.claudeMode ? this.runtime.getLastTurnMeta?.(sessionId) : null;
|
|
394
|
+
|
|
395
|
+
if (turnMeta?.statePatch) {
|
|
396
|
+
// Claude subagent returned structured state — apply it directly
|
|
397
|
+
nextMessage = rawResponse;
|
|
398
|
+
const sp = turnMeta.statePatch;
|
|
399
|
+
if (sp.phase) collabState.phase = sp.phase;
|
|
400
|
+
if (sp.overlapScore != null) {
|
|
401
|
+
collabState.overlapScore = Math.max(0, Math.min(1, sp.overlapScore));
|
|
384
402
|
}
|
|
385
|
-
if (Array.isArray(
|
|
386
|
-
collabState.activeThreads =
|
|
403
|
+
if (Array.isArray(sp.activeThreads)) {
|
|
404
|
+
collabState.activeThreads = sp.activeThreads.slice(0, 4);
|
|
387
405
|
}
|
|
388
|
-
if (Array.isArray(
|
|
389
|
-
collabState.candidateCollaborations =
|
|
406
|
+
if (Array.isArray(sp.candidateCollaborations)) {
|
|
407
|
+
collabState.candidateCollaborations = sp.candidateCollaborations.slice(0, 4);
|
|
390
408
|
}
|
|
391
|
-
if (
|
|
392
|
-
collabState.closeSignal = Boolean(
|
|
409
|
+
if (sp.closeSignal != null) {
|
|
410
|
+
collabState.closeSignal = Boolean(sp.closeSignal);
|
|
393
411
|
}
|
|
394
|
-
if (
|
|
395
|
-
collabState.confidence = Math.max(0, Math.min(1,
|
|
412
|
+
if (sp.confidence != null) {
|
|
413
|
+
collabState.confidence = Math.max(0, Math.min(1, sp.confidence));
|
|
396
414
|
}
|
|
397
415
|
} else {
|
|
398
|
-
//
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
if (
|
|
403
|
-
|
|
416
|
+
// Non-claude path: extract from <collab_state> tags in response text
|
|
417
|
+
const parsed = extractCollaborationState(rawResponse);
|
|
418
|
+
nextMessage = parsed.cleanText || rawResponse;
|
|
419
|
+
|
|
420
|
+
if (parsed.hasState && parsed.statePatch) {
|
|
421
|
+
if (parsed.statePatch.phase) collabState.phase = parsed.statePatch.phase;
|
|
422
|
+
if (parsed.statePatch.overlapScore != null) {
|
|
423
|
+
collabState.overlapScore = Math.max(0, Math.min(1, parsed.statePatch.overlapScore));
|
|
424
|
+
}
|
|
425
|
+
if (Array.isArray(parsed.statePatch.activeThreads)) {
|
|
426
|
+
collabState.activeThreads = parsed.statePatch.activeThreads.slice(0, 4);
|
|
427
|
+
}
|
|
428
|
+
if (Array.isArray(parsed.statePatch.candidateCollaborations)) {
|
|
429
|
+
collabState.candidateCollaborations = parsed.statePatch.candidateCollaborations.slice(0, 4);
|
|
430
|
+
}
|
|
431
|
+
if (parsed.statePatch.closeSignal != null) {
|
|
432
|
+
collabState.closeSignal = Boolean(parsed.statePatch.closeSignal);
|
|
433
|
+
}
|
|
434
|
+
if (parsed.statePatch.confidence != null) {
|
|
435
|
+
collabState.confidence = Math.max(0, Math.min(1, parsed.statePatch.confidence));
|
|
436
|
+
}
|
|
437
|
+
} else {
|
|
438
|
+
// No <collab_state> in response (generic/fallback runtime) — infer progression
|
|
439
|
+
const inferred = inferStateProgression(collabState, remoteText, turn + 1);
|
|
440
|
+
if (inferred.phase) collabState.phase = inferred.phase;
|
|
441
|
+
if (inferred.overlapScore != null) collabState.overlapScore = inferred.overlapScore;
|
|
442
|
+
if (inferred.confidence != null) collabState.confidence = inferred.confidence;
|
|
443
|
+
if (inferred.closeSignal != null) collabState.closeSignal = inferred.closeSignal;
|
|
444
|
+
}
|
|
404
445
|
}
|
|
405
446
|
|
|
406
447
|
// 6b. Overlap flatline detection — if overlap hasn't changed significantly
|
|
@@ -426,6 +467,16 @@ Be concise but specific. No filler.`;
|
|
|
426
467
|
}
|
|
427
468
|
}
|
|
428
469
|
|
|
470
|
+
// 7b. Store flags from claude subagent responses
|
|
471
|
+
if (turnMeta?.flags?.length > 0 && this.convStore && dbConversationStarted) {
|
|
472
|
+
this.convStore.addMessage(conversationId, {
|
|
473
|
+
direction: 'outbound',
|
|
474
|
+
role: 'system',
|
|
475
|
+
content: `[flags] ${turnMeta.flags.map(f => f.content || f.type).join('; ')}`,
|
|
476
|
+
metadata: JSON.stringify({ flags: turnMeta.flags, turn: turn + 1 })
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
429
480
|
// onTurn callback for progress output
|
|
430
481
|
if (this.onTurn) {
|
|
431
482
|
try {
|