279 lines
8.8 KiB
TypeScript
279 lines
8.8 KiB
TypeScript
import type { ToolCallMeta } from '@shared/types';
|
|
|
|
export interface ToolSummaryData {
|
|
total: number;
|
|
byName: Record<string, number>;
|
|
}
|
|
|
|
export type AgentToolDuplicateSkipReason = 'already_running' | 'bootstrap_pending';
|
|
|
|
export interface ParsedAgentToolResultStatus {
|
|
status: 'duplicate_skipped';
|
|
reason?: AgentToolDuplicateSkipReason;
|
|
rawReason?: string;
|
|
name?: string;
|
|
teamName?: string;
|
|
}
|
|
|
|
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) + '...';
|
|
}
|
|
|
|
function formatProviderName(providerId: string): string {
|
|
switch (providerId.trim().toLowerCase()) {
|
|
case 'anthropic':
|
|
return 'Anthropic';
|
|
case 'codex':
|
|
return 'Codex';
|
|
case 'gemini':
|
|
return 'Gemini';
|
|
case 'opencode':
|
|
return 'OpenCode';
|
|
default:
|
|
return providerId;
|
|
}
|
|
}
|
|
|
|
function formatEffortName(effort: string): string {
|
|
const trimmed = effort.trim();
|
|
return trimmed ? trimmed.charAt(0).toUpperCase() + trimmed.slice(1) : trimmed;
|
|
}
|
|
|
|
export interface AgentToolDisplayDetails {
|
|
action: string;
|
|
teammateName?: string;
|
|
teamName?: string;
|
|
runtime?: string;
|
|
subagentType?: string;
|
|
}
|
|
|
|
export function getAgentToolDisplayDetails(
|
|
input: Record<string, unknown>
|
|
): AgentToolDisplayDetails {
|
|
const teammateName = typeof input.name === 'string' ? input.name.trim() || undefined : undefined;
|
|
const teamName =
|
|
typeof input.team_name === 'string' ? input.team_name.trim() || undefined : undefined;
|
|
const description =
|
|
typeof input.description === 'string' ? input.description.trim() || undefined : undefined;
|
|
const provider =
|
|
typeof input.provider === 'string'
|
|
? formatProviderName(input.provider)
|
|
: typeof input.providerId === 'string'
|
|
? formatProviderName(input.providerId)
|
|
: undefined;
|
|
const model = typeof input.model === 'string' ? input.model.trim() || undefined : undefined;
|
|
const effort = typeof input.effort === 'string' ? formatEffortName(input.effort) : undefined;
|
|
const subagentType =
|
|
typeof input.subagent_type === 'string'
|
|
? input.subagent_type.trim() || undefined
|
|
: typeof input.subagentType === 'string'
|
|
? input.subagentType.trim() || undefined
|
|
: undefined;
|
|
|
|
const runtimeParts = [provider, model, effort].filter(
|
|
(part): part is string => typeof part === 'string' && part.length > 0
|
|
);
|
|
const runtime = runtimeParts.length > 0 ? runtimeParts.join(' · ') : undefined;
|
|
|
|
return {
|
|
action: description ?? (teammateName ? `Spawn teammate ${teammateName}` : 'Spawn subagent'),
|
|
teammateName,
|
|
teamName,
|
|
runtime,
|
|
subagentType,
|
|
};
|
|
}
|
|
|
|
export function summarizeAgentToolInput(input: Record<string, unknown>, max = 60): string {
|
|
const details = getAgentToolDisplayDetails(input);
|
|
const text = details.runtime ? `${details.action} · ${details.runtime}` : details.action;
|
|
return truncateStr(text, 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':
|
|
if (name === 'Agent') {
|
|
return summarizeAgentToolInput(input, 80);
|
|
}
|
|
return typeof input.description === 'string'
|
|
? input.description
|
|
: typeof input.prompt === 'string'
|
|
? truncateStr(input.prompt, 80)
|
|
: 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.subject ??
|
|
input.name ??
|
|
input.description ??
|
|
input.prompt ??
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Parse machine-readable Agent tool_result status lines from the raw tool_result content.
|
|
* Returns null for any non-Agent or non-duplicate result.
|
|
*/
|
|
export function parseAgentToolResultStatus(content: unknown): ParsedAgentToolResultStatus | null {
|
|
const joined = flattenToolResultContent(content).join('\n').trim();
|
|
if (!joined) return null;
|
|
|
|
const fields = new Map<string, string>();
|
|
for (const rawLine of joined.split(/\r?\n/)) {
|
|
const line = rawLine.trim();
|
|
if (!line) continue;
|
|
const separatorIndex = line.indexOf(':');
|
|
if (separatorIndex <= 0) continue;
|
|
const key = line.slice(0, separatorIndex).trim().toLowerCase();
|
|
const value = line.slice(separatorIndex + 1).trim();
|
|
if (!value) continue;
|
|
fields.set(key, value);
|
|
}
|
|
|
|
if (fields.get('status') !== 'duplicate_skipped') {
|
|
return null;
|
|
}
|
|
|
|
const rawReason = fields.get('reason');
|
|
const reason =
|
|
rawReason === 'already_running' || rawReason === 'bootstrap_pending' ? rawReason : undefined;
|
|
|
|
return {
|
|
status: 'duplicate_skipped',
|
|
reason,
|
|
...(rawReason ? { rawReason } : {}),
|
|
name: fields.get('name'),
|
|
teamName: fields.get('team_name'),
|
|
};
|
|
}
|