agent-ecosystem/src/shared/utils/toolSummary.ts
iliya e431cfd02c fix(team): deduplicate permission_request processing across all entry paths
Root cause: handleTeammatePermissionRequest was called from 3 paths
(early inbox scan, Category 4 relay scan, stdout/native) but only
the early scan checked processedPermissionRequestIds, causing
duplicate ToolApprovalRequests and extra permission_responses.

Fix:
- Move processedPermissionRequestIds check INTO handleTeammatePermissionRequest
  so ALL callers are protected by the same dedup gate
- Remove duplicate Category 4 scan that re-processed inbox messages
  (early scan already covers all messages including read=true)
- Category 4 now only builds a filter Set to exclude permission_request
  from relay to lead
2026-03-28 20:32:42 +02:00

150 lines
4.8 KiB
TypeScript

import type { ToolCallMeta } from '@shared/types';
export interface ToolSummaryData {
total: number;
byName: Record<string, number>;
}
export function buildToolSummary(content: Record<string, unknown>[]): string | undefined {
const counts = new Map<string, number>();
for (const block of content) {
if (
block &&
typeof block === 'object' &&
block.type === 'tool_use' &&
typeof block.name === 'string'
) {
counts.set(block.name, (counts.get(block.name) ?? 0) + 1);
}
}
const total = Array.from(counts.values()).reduce((a, b) => a + b, 0);
if (total === 0) return undefined;
return `${total} ${total === 1 ? 'tool' : 'tools'}`;
}
export function parseToolSummary(summary: string | undefined): ToolSummaryData | null {
if (!summary) return null;
// Support new format: "3 tools"
const simpleMatch = /^(\d+)\s+tools?$/.exec(summary);
if (simpleMatch) {
return { total: parseInt(simpleMatch[1], 10), byName: {} };
}
// Support legacy format: "3 tools (Read, 2 Edit)"
const match = /^(\d+)\s+tools?\s+\(([^)]+)\)$/.exec(summary);
if (!match) return null;
const byName: Record<string, number> = {};
for (const part of match[2].split(', ')) {
const m =
// eslint-disable-next-line security/detect-unsafe-regex -- part from split, bounded by summary
/^(\d+)\s+(\S+(?:\s+\S+)*)$/.exec(part);
if (m) {
byName[m[2]] = parseInt(m[1], 10);
} else {
byName[part] = 1;
}
}
return { total: parseInt(match[1], 10), byName };
}
export function formatToolSummary(data: ToolSummaryData): string {
return `${data.total} ${data.total === 1 ? 'tool' : 'tools'}`;
}
/** Format tool summary directly from a Map<toolName, count>. */
export function formatToolSummaryFromMap(counts: Map<string, number>): string | undefined {
const total = Array.from(counts.values()).reduce((a, b) => a + b, 0);
if (total === 0) return undefined;
return `${total} ${total === 1 ? 'tool' : 'tools'}`;
}
/** Format tool summary from an array of ToolCallMeta. */
export function formatToolSummaryFromCalls(calls: ToolCallMeta[]): string | undefined {
if (calls.length === 0) return undefined;
const counts = new Map<string, number>();
for (const c of calls) counts.set(c.name, (counts.get(c.name) ?? 0) + 1);
return formatToolSummaryFromMap(counts);
}
function baseName(filePath: string): string {
return filePath.split(/[/\\]/).pop() ?? filePath;
}
function truncateStr(str: string, max: number): string {
return str.length <= max ? str : str.slice(0, max) + '...';
}
/** Extract a short human-readable preview from tool_use input arguments. */
export function extractToolPreview(
name: string,
input: Record<string, unknown>
): string | undefined {
switch (name) {
case 'Read':
case 'Edit':
case 'Write':
return typeof input.file_path === 'string' ? baseName(input.file_path) : undefined;
case 'Bash':
return typeof input.description === 'string'
? truncateStr(input.description, 60)
: typeof input.command === 'string'
? truncateStr(input.command, 60)
: undefined;
case 'Grep':
case 'Glob':
return typeof input.pattern === 'string' ? truncateStr(input.pattern, 40) : undefined;
case 'Agent':
case 'Task':
case 'TaskCreate':
return typeof input.prompt === 'string'
? input.prompt
: typeof input.description === 'string'
? input.description
: undefined;
case 'WebFetch':
if (typeof input.url === 'string') {
try {
return new URL(input.url).hostname;
} catch {
return truncateStr(input.url, 40);
}
}
return undefined;
case 'WebSearch':
return typeof input.query === 'string' ? truncateStr(input.query, 40) : undefined;
default: {
const v = input.name ?? input.path ?? input.file ?? input.query ?? input.command;
return typeof v === 'string' ? truncateStr(v, 50) : undefined;
}
}
}
function flattenToolResultContent(content: unknown): string[] {
if (typeof content === 'string') {
return [content];
}
if (!Array.isArray(content)) {
return [];
}
const parts: string[] = [];
for (const item of content) {
if (!item || typeof item !== 'object') continue;
const block = item as Record<string, unknown>;
if (typeof block.text === 'string') {
parts.push(block.text);
continue;
}
if (typeof block.content === 'string') {
parts.push(block.content);
}
}
return parts;
}
/** Extract a short human-readable preview from tool_result content. */
export function extractToolResultPreview(content: unknown, max = 80): string | undefined {
const joined = flattenToolResultContent(content).join(' ').replace(/\s+/g, ' ').trim();
if (!joined) return undefined;
return truncateStr(joined, max);
}