a2acalling 0.6.42 → 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 +146 -34
- package/package.json +1 -1
- package/src/lib/claude-subagent.js +485 -0
- package/src/lib/conversation-driver.js +109 -28
- 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
|
@@ -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
|
+
};
|
|
@@ -33,6 +33,11 @@ const TERMINATION_PATTERNS = [
|
|
|
33
33
|
/\[DISCONNECT/i,
|
|
34
34
|
/\[END.?CALL\]/i,
|
|
35
35
|
/\[CLOSING\]/i,
|
|
36
|
+
/\bEND.?CALL\b/i,
|
|
37
|
+
/\bcall\s+closed\b/i,
|
|
38
|
+
/\bwrapping\s+up\b/i,
|
|
39
|
+
/\[No\s+further\b/i,
|
|
40
|
+
/\bno\s+further\b/i,
|
|
36
41
|
/\bREFUSING\s+(TO\s+)?(CONTINU|RESPOND|ENGAG)/i,
|
|
37
42
|
/\bcall\s+complet(ed|e)\b/i,
|
|
38
43
|
/\bconversation\s+(is\s+)?(over|ended|closed|complet)/i,
|
|
@@ -43,12 +48,15 @@ const TERMINATION_PATTERNS = [
|
|
|
43
48
|
|
|
44
49
|
function detectRemoteTermination(text) {
|
|
45
50
|
if (!text || typeof text !== 'string') return false;
|
|
51
|
+
// Very short responses (single dot, empty-ish) indicate dead conversation
|
|
52
|
+
const trimmed = text.trim();
|
|
53
|
+
if (trimmed.length <= 1) return true;
|
|
46
54
|
return TERMINATION_PATTERNS.some(pattern => pattern.test(text));
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
/**
|
|
50
58
|
* Infer collaboration state progression when the runtime doesn't emit
|
|
51
|
-
* <collab_state> tags (
|
|
59
|
+
* <collab_state> tags (e.g. OpenClaw without adaptive mode). Advances phase based on
|
|
52
60
|
* turn count and estimates overlap from remote text analysis.
|
|
53
61
|
*/
|
|
54
62
|
function inferStateProgression(collabState, remoteText, turn) {
|
|
@@ -82,6 +90,12 @@ function inferStateProgression(collabState, remoteText, turn) {
|
|
|
82
90
|
// Confidence increases over turns
|
|
83
91
|
patch.confidence = Math.min(0.9, 0.25 + turn * 0.08);
|
|
84
92
|
|
|
93
|
+
// In converging phase, signal close — conversation has run its natural course
|
|
94
|
+
const effectivePhase = patch.phase || collabState.phase;
|
|
95
|
+
if (effectivePhase === 'converging') {
|
|
96
|
+
patch.closeSignal = true;
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
return patch;
|
|
86
100
|
}
|
|
87
101
|
|
|
@@ -114,8 +128,11 @@ class ConversationDriver {
|
|
|
114
128
|
this.tier = options.tier || 'public';
|
|
115
129
|
this.summarizer = options.summarizer || null;
|
|
116
130
|
this.ownerContext = options.ownerContext || {};
|
|
131
|
+
this.claudeMode = options.runtime?.mode === 'claude';
|
|
132
|
+
this.claudeTimeoutMs = options.claudeTimeoutMs || 180000;
|
|
117
133
|
|
|
118
|
-
|
|
134
|
+
const clientTimeout = this.claudeMode ? 200000 : 65000;
|
|
135
|
+
this.client = new A2AClient({ caller: this.caller, timeout: clientTimeout });
|
|
119
136
|
}
|
|
120
137
|
|
|
121
138
|
/**
|
|
@@ -207,6 +224,7 @@ Be concise but specific. No filler.`;
|
|
|
207
224
|
conversationId = `conv_${Date.now()}_local`;
|
|
208
225
|
|
|
209
226
|
let nextMessage = openingMessage;
|
|
227
|
+
const overlapHistory = [];
|
|
210
228
|
|
|
211
229
|
for (let turn = 0; turn < this.maxTurns; turn++) {
|
|
212
230
|
// 1. Send message to remote
|
|
@@ -336,18 +354,30 @@ Be concise but specific. No filler.`;
|
|
|
336
354
|
// 5. Call runtime.runTurn() to generate next message
|
|
337
355
|
const sessionId = `a2a-${conversationId}`;
|
|
338
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
|
+
}
|
|
339
373
|
try {
|
|
340
374
|
rawResponse = await this.runtime.runTurn({
|
|
341
375
|
sessionId,
|
|
342
376
|
prompt,
|
|
343
377
|
message: remoteText,
|
|
344
378
|
caller: this.caller,
|
|
345
|
-
timeoutMs: 65000,
|
|
346
|
-
context:
|
|
347
|
-
conversationId,
|
|
348
|
-
tier: this.tier,
|
|
349
|
-
ownerName: this.agentContext.owner
|
|
350
|
-
}
|
|
379
|
+
timeoutMs: this.claudeMode ? this.claudeTimeoutMs : 65000,
|
|
380
|
+
context: contextPayload
|
|
351
381
|
});
|
|
352
382
|
} catch (err) {
|
|
353
383
|
logger.error('Runtime turn failed', {
|
|
@@ -359,32 +389,73 @@ Be concise but specific. No filler.`;
|
|
|
359
389
|
}
|
|
360
390
|
|
|
361
391
|
// 6. Extract collab state from response
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
if (
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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));
|
|
369
402
|
}
|
|
370
|
-
if (Array.isArray(
|
|
371
|
-
collabState.activeThreads =
|
|
403
|
+
if (Array.isArray(sp.activeThreads)) {
|
|
404
|
+
collabState.activeThreads = sp.activeThreads.slice(0, 4);
|
|
372
405
|
}
|
|
373
|
-
if (Array.isArray(
|
|
374
|
-
collabState.candidateCollaborations =
|
|
406
|
+
if (Array.isArray(sp.candidateCollaborations)) {
|
|
407
|
+
collabState.candidateCollaborations = sp.candidateCollaborations.slice(0, 4);
|
|
375
408
|
}
|
|
376
|
-
if (
|
|
377
|
-
collabState.closeSignal = Boolean(
|
|
409
|
+
if (sp.closeSignal != null) {
|
|
410
|
+
collabState.closeSignal = Boolean(sp.closeSignal);
|
|
378
411
|
}
|
|
379
|
-
if (
|
|
380
|
-
collabState.confidence = Math.max(0, Math.min(1,
|
|
412
|
+
if (sp.confidence != null) {
|
|
413
|
+
collabState.confidence = Math.max(0, Math.min(1, sp.confidence));
|
|
381
414
|
}
|
|
382
415
|
} else {
|
|
383
|
-
//
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if (
|
|
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
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// 6b. Overlap flatline detection — if overlap hasn't changed significantly
|
|
448
|
+
// for 3+ consecutive turns while in converging phase, the conversation is dead
|
|
449
|
+
overlapHistory.push(collabState.overlapScore);
|
|
450
|
+
if (collabState.phase === 'converging' && overlapHistory.length >= 3) {
|
|
451
|
+
const recent = overlapHistory.slice(-3);
|
|
452
|
+
const maxDelta = Math.max(
|
|
453
|
+
Math.abs(recent[1] - recent[0]),
|
|
454
|
+
Math.abs(recent[2] - recent[1])
|
|
455
|
+
);
|
|
456
|
+
if (maxDelta < 0.02) {
|
|
457
|
+
collabState.closeSignal = true;
|
|
458
|
+
}
|
|
388
459
|
}
|
|
389
460
|
|
|
390
461
|
// 7. Persist collab state to DB
|
|
@@ -396,6 +467,16 @@ Be concise but specific. No filler.`;
|
|
|
396
467
|
}
|
|
397
468
|
}
|
|
398
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
|
+
|
|
399
480
|
// onTurn callback for progress output
|
|
400
481
|
if (this.onTurn) {
|
|
401
482
|
try {
|