a2acalling 0.6.50 → 0.6.52

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.
@@ -4,7 +4,14 @@
4
4
  * Spawns `claude` CLI processes for real LLM-powered A2A conversations
5
5
  * as an alternative to OpenClaw for A2A conversations.
6
6
  *
7
- * Uses `claude -p` (print mode) with `--resume` for multi-turn context continuity.
7
+ * Design decision (A2A-29):
8
+ * We intentionally run Claude turns in stateless one-shot mode instead of `--resume`.
9
+ * In production we observed intermittent hangs during nested Claude startup/restore.
10
+ * Stateless calls cost more tokens but are operationally safer under load.
11
+ *
12
+ * Permissioning is still enforced:
13
+ * `--allowedTools` is derived per request from token capabilities + allowed topics.
14
+ * We do not hardcode one universal allowlist anymore.
8
15
  */
9
16
 
10
17
  const { execSync, spawn } = require('child_process');
@@ -14,6 +21,8 @@ const { HARD_FALLBACK_TURN_TIMEOUT_MS } = require('./turn-timeout');
14
21
  const logger = createLogger({ component: 'a2a.claude-subagent' });
15
22
 
16
23
  const A2A_RESPONSE_REGEX = /<a2a_response>\s*([\s\S]*?)\s*<\/a2a_response>/i;
24
+ const DEFAULT_CLAUDE_MODEL = 'claude-sonnet-4-5-20250929';
25
+ const LEGACY_DEFAULT_TOOLS = ['Bash(readonly)', 'Read', 'Grep', 'Glob', 'WebSearch', 'WebFetch'];
17
26
 
18
27
  /**
19
28
  * Check if `claude` CLI is available in PATH.
@@ -289,11 +298,144 @@ function extractResultFromJson(stdout) {
289
298
  }
290
299
  }
291
300
 
301
+ function normalizePermissionList(values) {
302
+ if (!Array.isArray(values)) return [];
303
+ return values
304
+ .map(v => String(v || '').trim().toLowerCase())
305
+ .filter(Boolean);
306
+ }
307
+
308
+ function hasPermissionMatch(values, key) {
309
+ if (!key) return false;
310
+ return values.some(value => value === key || value.startsWith(`${key}.`));
311
+ }
312
+
313
+ /**
314
+ * Resolve Claude tool allowlist from token-derived permissions.
315
+ *
316
+ * Notes:
317
+ * - We preserve legacy behavior when no permission context is provided, because
318
+ * outbound CLI flows may run without token metadata.
319
+ * - When permissions are present, we derive tools deterministically so runtime
320
+ * allowlists remain variable and auditable per token.
321
+ */
322
+ function resolveClaudeAllowedTools({ capabilities = [], allowedTopics = [] } = {}) {
323
+ const normalizedCaps = normalizePermissionList(capabilities);
324
+ const normalizedTopics = normalizePermissionList(allowedTopics);
325
+ const hasPermissionContext = normalizedCaps.length > 0 || normalizedTopics.length > 0;
326
+
327
+ if (!hasPermissionContext) {
328
+ return [...LEGACY_DEFAULT_TOOLS];
329
+ }
330
+
331
+ const hasContextRead = hasPermissionMatch(normalizedCaps, 'context-read')
332
+ || normalizedTopics.includes('chat');
333
+ const hasSearch = hasPermissionMatch(normalizedCaps, 'search')
334
+ || normalizedTopics.includes('search');
335
+ const hasToolsRead = hasPermissionMatch(normalizedCaps, 'tools')
336
+ || hasPermissionMatch(normalizedCaps, 'tools-read')
337
+ || normalizedTopics.includes('tools');
338
+ const hasToolsWrite = hasPermissionMatch(normalizedCaps, 'tools-write')
339
+ || hasPermissionMatch(normalizedCaps, 'tools.write')
340
+ || normalizedTopics.includes('tools-write')
341
+ || normalizedTopics.includes('tools.write');
342
+
343
+ const tools = [];
344
+
345
+ // Keep read-only introspection available for context-aware tiers.
346
+ if (hasContextRead || hasSearch || hasToolsRead || hasToolsWrite) {
347
+ tools.push('Read', 'Grep', 'Glob');
348
+ }
349
+
350
+ // Web tools are explicitly tied to search-style permissions.
351
+ if (hasSearch) {
352
+ tools.push('WebSearch', 'WebFetch');
353
+ }
354
+
355
+ // Shell access is gated behind tool permissions, with explicit writable opt-in.
356
+ if (hasToolsWrite) {
357
+ tools.unshift('Bash');
358
+ } else if (hasToolsRead) {
359
+ tools.unshift('Bash(readonly)');
360
+ }
361
+
362
+ // Fail closed to read-only file inspection if metadata is custom/unknown.
363
+ if (tools.length === 0) {
364
+ return ['Read', 'Grep', 'Glob'];
365
+ }
366
+
367
+ return tools;
368
+ }
369
+
370
+ function buildClaudeToolArg(allowedTools) {
371
+ return Array.isArray(allowedTools) ? allowedTools.join(' ').trim() : '';
372
+ }
373
+
374
+ function parseSummaryPayload(resultText) {
375
+ const text = String(resultText || '').trim();
376
+ if (!text) return null;
377
+
378
+ // Backwards-compatible: older prompts wrapped JSON in <a2a_response>.
379
+ const tagged = text.match(A2A_RESPONSE_REGEX);
380
+ if (tagged && tagged[1]) {
381
+ try {
382
+ return JSON.parse(tagged[1].trim());
383
+ } catch (err) {
384
+ logger.warn('Failed to parse tagged summary JSON', {
385
+ event: 'subagent_summary_tag_parse_failed',
386
+ error: err
387
+ });
388
+ }
389
+ }
390
+
391
+ // Preferred path for unified summary prompt: direct JSON object.
392
+ try {
393
+ const parsed = JSON.parse(text);
394
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
395
+ return parsed;
396
+ }
397
+ } catch (err) {
398
+ logger.debug('Summary result is not direct JSON; falling back to plain text summary', {
399
+ event: 'subagent_summary_raw_fallback',
400
+ data: { output_length: text.length }
401
+ });
402
+ }
403
+
404
+ return null;
405
+ }
406
+
407
+ function summarizeFromPayload(payload, fallbackText) {
408
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
409
+ return null;
410
+ }
411
+
412
+ // Native A2A summary payload shape.
413
+ if (typeof payload.summary === 'string' || typeof payload.ownerSummary === 'string') {
414
+ return {
415
+ summary: payload.summary || payload.message || fallbackText || '',
416
+ ownerSummary: payload.ownerSummary || payload.summary || payload.message || fallbackText || '',
417
+ actionItems: Array.isArray(payload.actionItems) ? payload.actionItems : [],
418
+ flags: Array.isArray(payload.flags) ? payload.flags : []
419
+ };
420
+ }
421
+
422
+ // Unified summary schema shape (headline/assessment/nextSteps).
423
+ if (typeof payload.headline === 'string') {
424
+ return {
425
+ summary: payload.headline,
426
+ ownerSummary: typeof payload.assessment === 'string' ? payload.assessment : payload.headline,
427
+ actionItems: Array.isArray(payload.nextSteps) ? payload.nextSteps : [],
428
+ flags: []
429
+ };
430
+ }
431
+
432
+ return null;
433
+ }
434
+
292
435
  /**
293
436
  * Run a single turn of the Claude subagent.
294
437
  *
295
438
  * @param {Object} options
296
- * @param {string} options.sessionId - Conversation session ID (used for --resume on turn 2+)
297
439
  * @param {string} options.systemPrompt - System prompt (used on turn 1 only)
298
440
  * @param {string} options.turnMessage - The inbound message from the remote agent
299
441
  * @param {number} options.turn - Current turn number (1-based)
@@ -303,12 +445,14 @@ function extractResultFromJson(stdout) {
303
445
  * @param {Array} options.activeThreads - Active conversation threads
304
446
  * @param {Array} options.candidateCollaborations - Candidate collaboration ideas
305
447
  * @param {boolean} options.closeSignal - Whether close has been signaled
448
+ * @param {Array<string>} [options.capabilities] - Token capabilities (permission source of truth)
449
+ * @param {Array<string>} [options.allowedTopics] - Token allowed topics (permission source of truth)
450
+ * @param {function} [options.spawnFn] - Injectable process runner for tests
306
451
  * @param {number} [options.timeoutMs=300000] - Timeout in milliseconds
307
- * @returns {Promise<{ message: string, statePatch: object|null, flags: array, sessionId: string }>}
452
+ * @returns {Promise<{ message: string, statePatch: object|null, flags: array }>}
308
453
  */
309
454
  async function runClaudeTurn(options) {
310
455
  const {
311
- sessionId,
312
456
  systemPrompt,
313
457
  turnMessage,
314
458
  turn = 1,
@@ -318,6 +462,9 @@ async function runClaudeTurn(options) {
318
462
  activeThreads = [],
319
463
  candidateCollaborations = [],
320
464
  closeSignal = false,
465
+ capabilities = [],
466
+ allowedTopics = [],
467
+ spawnFn = spawnClaude,
321
468
  timeoutMs = HARD_FALLBACK_TURN_TIMEOUT_MS
322
469
  } = options;
323
470
 
@@ -333,29 +480,21 @@ async function runClaudeTurn(options) {
333
480
  });
334
481
 
335
482
  const startAt = Date.now();
336
- const allowedTools = 'Bash(readonly) Read Grep Glob WebSearch WebFetch';
337
-
338
- let args;
339
- if (turn === 1 || !sessionId) {
340
- // First turn: create new session
341
- args = [
342
- '-p',
343
- '--output-format', 'json',
344
- '--system-prompt', systemPrompt,
345
- '--allowedTools', allowedTools,
346
- '--model', 'claude-sonnet-4-5-20250929',
347
- turnPrompt
348
- ];
349
- } else {
350
- // Subsequent turns: resume existing session
351
- args = [
352
- '-p',
353
- '--output-format', 'json',
354
- '--resume', sessionId,
355
- '--allowedTools', allowedTools,
356
- turnPrompt
357
- ];
483
+ const allowedTools = resolveClaudeAllowedTools({ capabilities, allowedTopics });
484
+ const allowedToolsArg = buildClaudeToolArg(allowedTools);
485
+ const args = [
486
+ '-p',
487
+ '--output-format', 'json',
488
+ '--system-prompt', systemPrompt,
489
+ '--model', DEFAULT_CLAUDE_MODEL
490
+ ];
491
+
492
+ // We always provide --allowedTools explicitly so token permissioning stays
493
+ // enforced in Claude mode even after moving to stateless turns.
494
+ if (allowedToolsArg) {
495
+ args.push('--allowedTools', allowedToolsArg);
358
496
  }
497
+ args.push(turnPrompt);
359
498
 
360
499
  logger.debug('Spawning Claude subagent turn', {
361
500
  event: 'subagent_turn_start',
@@ -363,13 +502,14 @@ async function runClaudeTurn(options) {
363
502
  turn,
364
503
  max_turns: maxTurns,
365
504
  phase,
366
- is_resume: turn > 1 && Boolean(sessionId),
505
+ is_stateless: true,
506
+ allowed_tools: allowedTools,
367
507
  timeout_ms: timeoutMs
368
508
  }
369
509
  });
370
510
 
371
- const { stdout } = await spawnClaude(args, timeoutMs);
372
- const { result, sessionId: newSessionId } = extractResultFromJson(stdout);
511
+ const { stdout } = await spawnFn(args, timeoutMs);
512
+ const { result } = extractResultFromJson(stdout);
373
513
  const parsed = parseSubagentResponse(result);
374
514
 
375
515
  logger.debug('Claude subagent turn completed', {
@@ -379,91 +519,87 @@ async function runClaudeTurn(options) {
379
519
  duration_ms: Date.now() - startAt,
380
520
  message_length: parsed.message.length,
381
521
  has_state_patch: Boolean(parsed.statePatch),
382
- flag_count: parsed.flags.length,
383
- session_id: newSessionId || sessionId
522
+ flag_count: parsed.flags.length
384
523
  }
385
524
  });
386
525
 
387
526
  return {
388
527
  message: parsed.message,
389
528
  statePatch: parsed.statePatch,
390
- flags: parsed.flags,
391
- sessionId: newSessionId || sessionId
529
+ flags: parsed.flags
392
530
  };
393
531
  }
394
532
 
395
533
  /**
396
- * Run a summary turn using the Claude subagent session.
534
+ * Run a summary turn in stateless Claude mode.
397
535
  *
398
- * @param {string} sessionId - Session ID to resume
399
- * @param {string} reason - Why the conversation is ending
536
+ * @param {Object} options
537
+ * @param {string} options.prompt - Unified summary prompt
538
+ * @param {string} [options.reason] - Why the conversation is ending
539
+ * @param {Array<string>} [options.capabilities] - Token capabilities for summary turn tooling
540
+ * @param {Array<string>} [options.allowedTopics] - Token allowed topics for summary turn tooling
541
+ * @param {function} [options.spawnFn] - Injectable process runner for tests
400
542
  * @param {number} [timeoutMs=300000] - Timeout in milliseconds
401
543
  * @returns {Promise<{ summary: string, ownerSummary: string, actionItems: array, flags: array }>}
402
544
  */
403
- async function runClaudeSummary(sessionId, reason, timeoutMs = HARD_FALLBACK_TURN_TIMEOUT_MS) {
404
- if (!sessionId) {
405
- throw new Error('Cannot summarize without a session ID');
406
- }
407
-
408
- const summaryPrompt = `The conversation is ending. Reason: ${reason || 'max turns reached'}.
545
+ async function runClaudeSummary(options = {}) {
546
+ const {
547
+ prompt,
548
+ reason,
549
+ capabilities = [],
550
+ allowedTopics = [],
551
+ spawnFn = spawnClaude,
552
+ timeoutMs = HARD_FALLBACK_TURN_TIMEOUT_MS
553
+ } = options;
409
554
 
410
- Provide a structured summary. Respond with ONLY a JSON block:
555
+ const summaryPrompt = String(prompt || '').trim();
556
+ if (!summaryPrompt) {
557
+ throw new Error('Cannot summarize without a prompt');
558
+ }
411
559
 
412
- <a2a_response>
413
- {
414
- "message": "Brief 1-2 sentence summary of the conversation.",
415
- "statePatch": {"phase": "close", "closeSignal": true},
416
- "flags": [],
417
- "summary": "Detailed summary for the conversation record.",
418
- "ownerSummary": "Summary written for the owner highlighting key findings and opportunities.",
419
- "actionItems": ["Specific follow-up item 1", "Specific follow-up item 2"]
420
- }
421
- </a2a_response>`;
560
+ const allowedTools = resolveClaudeAllowedTools({ capabilities, allowedTopics });
561
+ const allowedToolsArg = buildClaudeToolArg(allowedTools);
422
562
 
423
563
  const args = [
424
564
  '-p',
425
565
  '--output-format', 'json',
426
- '--resume', sessionId,
427
- summaryPrompt
566
+ '--model', DEFAULT_CLAUDE_MODEL,
428
567
  ];
429
568
 
569
+ if (allowedToolsArg) {
570
+ args.push('--allowedTools', allowedToolsArg);
571
+ }
572
+ args.push(
573
+ '--append-system-prompt',
574
+ `Conversation summary mode. Reason: ${reason || 'conversation ended'}. Return only structured summary JSON.`,
575
+ summaryPrompt
576
+ );
577
+
430
578
  const startAt = Date.now();
431
579
 
432
580
  logger.debug('Spawning Claude summary', {
433
581
  event: 'subagent_summary_start',
434
- data: { session_id: sessionId, reason }
582
+ data: {
583
+ reason: reason || 'conversation ended',
584
+ allowed_tools: allowedTools
585
+ }
435
586
  });
436
587
 
437
- const { stdout } = await spawnClaude(args, timeoutMs);
588
+ const { stdout } = await spawnFn(args, timeoutMs);
438
589
  const { result } = extractResultFromJson(stdout);
439
-
440
- // Try to extract structured summary from <a2a_response>
441
- const match = result.match(A2A_RESPONSE_REGEX);
442
- if (match) {
443
- try {
444
- const parsed = JSON.parse(match[1].trim());
445
- logger.debug('Claude summary completed', {
446
- event: 'subagent_summary_complete',
447
- data: {
448
- session_id: sessionId,
449
- duration_ms: Date.now() - startAt,
450
- has_summary: Boolean(parsed.summary),
451
- action_item_count: Array.isArray(parsed.actionItems) ? parsed.actionItems.length : 0
452
- }
453
- });
454
-
455
- return {
456
- summary: parsed.summary || parsed.message || result.replace(A2A_RESPONSE_REGEX, '').trim(),
457
- ownerSummary: parsed.ownerSummary || parsed.summary || parsed.message || '',
458
- actionItems: Array.isArray(parsed.actionItems) ? parsed.actionItems : [],
459
- flags: Array.isArray(parsed.flags) ? parsed.flags : []
460
- };
461
- } catch (err) {
462
- logger.warn('Failed to parse summary JSON', {
463
- event: 'subagent_summary_parse_failed',
464
- error: err
465
- });
466
- }
590
+ const summaryPayload = parseSummaryPayload(result);
591
+ const parsedSummary = summarizeFromPayload(summaryPayload, result.trim());
592
+
593
+ if (parsedSummary) {
594
+ logger.debug('Claude summary completed', {
595
+ event: 'subagent_summary_complete',
596
+ data: {
597
+ duration_ms: Date.now() - startAt,
598
+ has_summary: Boolean(parsedSummary.summary),
599
+ action_item_count: parsedSummary.actionItems.length
600
+ }
601
+ });
602
+ return parsedSummary;
467
603
  }
468
604
 
469
605
  // Fallback: use raw text as summary
@@ -480,6 +616,7 @@ module.exports = {
480
616
  isClaudeAvailable,
481
617
  buildSubagentSystemPrompt,
482
618
  buildTurnPrompt,
619
+ resolveClaudeAllowedTools,
483
620
  runClaudeTurn,
484
621
  runClaudeSummary,
485
622
  parseSubagentResponse
@@ -128,6 +128,10 @@ class ConversationDriver {
128
128
  this.maxTurns = options.maxTurns || 30;
129
129
  this.onTurn = options.onTurn || null;
130
130
  this.tier = options.tier || 'public';
131
+ // Optional permission envelope for runtimes (primarily Claude mode).
132
+ // If provided by caller, this keeps tool allowlists variable per token/profile.
133
+ this.capabilities = Array.isArray(options.capabilities) ? options.capabilities : [];
134
+ this.allowedTopics = Array.isArray(options.allowedTopics) ? options.allowedTopics : [];
131
135
  this.summarizer = options.summarizer || null;
132
136
  this.ownerContext = options.ownerContext || {};
133
137
  this.claudeMode = options.runtime?.mode === 'claude';
@@ -407,7 +411,10 @@ class ConversationDriver {
407
411
  tier: this.tier,
408
412
  ownerName: this.agentContext.owner,
409
413
  agentName: this.agentContext.name,
410
- roleContext: 'You initiated this call.'
414
+ roleContext: 'You initiated this call.',
415
+ capabilities: this.capabilities,
416
+ allowedTopics: this.allowedTopics,
417
+ allowed_topics: this.allowedTopics
411
418
  };
412
419
  if (this.claudeMode) {
413
420
  contextPayload.turnCount = turn + 1;
@@ -18,9 +18,10 @@ const DB_FILENAME = 'a2a-conversations.db';
18
18
  const logger = createLogger({ component: 'a2a.conversations' });
19
19
 
20
20
  class ConversationStore {
21
- constructor(configDir = DEFAULT_CONFIG_DIR) {
21
+ constructor(configDir = DEFAULT_CONFIG_DIR, options = {}) {
22
22
  this.configDir = configDir;
23
23
  this.dbPath = path.join(configDir, DB_FILENAME);
24
+ this.eventStore = options.eventStore || null;
24
25
  this.db = null;
25
26
  this._ensureDir();
26
27
  }
@@ -253,6 +254,19 @@ class ConversationStore {
253
254
  VALUES (?, ?, ?, ?, ?, ?, ?, 'active')
254
255
  `).run(id, contactId, contactName, tokenId, direction, now, now);
255
256
 
257
+ if (this.eventStore && this.eventStore.isAvailable && this.eventStore.isAvailable()) {
258
+ this.eventStore.emitEvent('call.updated', {
259
+ conversation_id: id,
260
+ status: 'active',
261
+ direction,
262
+ contact_id: contactId || null,
263
+ contact_name: contactName || null
264
+ }, {
265
+ conversationId: id,
266
+ contactId: contactId || null
267
+ });
268
+ }
269
+
256
270
  return { id, resumed: false };
257
271
  }
258
272
 
@@ -284,6 +298,14 @@ class ConversationStore {
284
298
  WHERE id = ?
285
299
  `).run(now, conversationId);
286
300
 
301
+ if (this.eventStore && this.eventStore.isAvailable && this.eventStore.isAvailable()) {
302
+ this.eventStore.emitEvent('call.updated', {
303
+ conversation_id: conversationId,
304
+ status: 'active',
305
+ direction
306
+ }, { conversationId });
307
+ }
308
+
287
309
  return { id, timestamp: now };
288
310
  }
289
311
 
@@ -450,6 +472,31 @@ class ConversationStore {
450
472
  `).run(now, conversationId);
451
473
  }
452
474
 
475
+ if (this.eventStore && this.eventStore.isAvailable && this.eventStore.isAvailable()) {
476
+ this.eventStore.emitEvent('call.updated', {
477
+ conversation_id: conversationId,
478
+ status: 'concluded',
479
+ contact_id: conversation.contact_id || null,
480
+ contact_name: conversation.contact_name || null
481
+ }, {
482
+ conversationId,
483
+ contactId: conversation.contact_id || null
484
+ });
485
+
486
+ if (summary || ownerSummary) {
487
+ this.eventStore.emitEvent('summary.completed', {
488
+ conversation_id: conversationId,
489
+ contact_id: conversation.contact_id || null,
490
+ contact_name: conversation.contact_name || null,
491
+ has_summary: Boolean(summary),
492
+ has_owner_summary: Boolean(ownerSummary)
493
+ }, {
494
+ conversationId,
495
+ contactId: conversation.contact_id || null
496
+ });
497
+ }
498
+ }
499
+
453
500
  return {
454
501
  success: true,
455
502
  conversationId,
@@ -472,6 +519,13 @@ class ConversationStore {
472
519
  WHERE id = ?
473
520
  `).run(now, conversationId);
474
521
 
522
+ if (this.eventStore && this.eventStore.isAvailable && this.eventStore.isAvailable()) {
523
+ this.eventStore.emitEvent('call.updated', {
524
+ conversation_id: conversationId,
525
+ status: 'timeout'
526
+ }, { conversationId });
527
+ }
528
+
475
529
  return { success: true };
476
530
  }
477
531