Old hardcoded MAX_TOOL_LOOP_DEPTH=15 replaced by per-agent max_tool_calls (1-100, AGENTS.md frontmatter) with defaults: 30 for read-only-only agents, 10 for agents that include any non-read-only tool, 15 for raw chat. When the loop hits cap, fire one final summary call with tools disabled, stream the wrap-up into the in-flight assistant message, then insert a system sentinel with metadata.kind='cap_hit'. The sentinel renders an amber bubble with a Continue button (latest sentinel only) that POSTs to a new /api/chats/:id/continue route to extend. Hard ceiling: 3 cap-hits per chat (2 continues max) — third sentinel reports can_continue=false. Error frames carry a machine-readable reason code alongside human error text. Failed messages persist the reason via metadata.kind='error' so the bubble renders specifics on reload (WS error frame is one-shot). Tool call UI rewired: ToolCallLine renders inline (↳ name args spinner/check/✗, expand-on-tap for args+result); ToolCallGroup collapses 3+ consecutive same-tool runs into a compact card. MessageList owns a three-pass pre-render (flatten + fold tool results onto matching runs by id + group same-tool runs + number sentinels). MessageBubble drops tool rendering and adds the sentinel / error-reason branches. ToolCallCard deleted. Roadmap follow-up logged: add explicit max_tool_calls: 30 to the 6 agents in /data/AGENTS.md and /opt/boocode/AGENTS.md post-ship for discoverability (defaults handle behavior identically). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
168 lines
6.0 KiB
TypeScript
168 lines
6.0 KiB
TypeScript
import { useState } from 'react';
|
|
import type { ReactNode } from 'react';
|
|
import { Check, ChevronRight, Loader2, X } from 'lucide-react';
|
|
import type { ToolCall, ToolResult } from '@/api/types';
|
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
|
|
|
// v1.8.2: cap on the inline arg-summary length. Expanded view shows full
|
|
// args + full result, so this is purely a single-line render budget.
|
|
const ARG_SUMMARY_MAX = 60;
|
|
|
|
export interface ToolRun {
|
|
call: ToolCall;
|
|
// null while the call is in flight or the matching tool result hasn't
|
|
// arrived yet on the WS stream.
|
|
result: ToolResult | null;
|
|
}
|
|
|
|
function truncate(s: string, n: number): string {
|
|
return s.length > n ? s.slice(0, n - 1) + '…' : s;
|
|
}
|
|
|
|
// Per-tool argument summary mapping from the v1.8.2 spec. Goal is a single
|
|
// scannable line that surfaces the *what* (path / pattern) without
|
|
// overwhelming the chat with full JSON.
|
|
export function formatToolArgs(name: string, args: Record<string, unknown>): string {
|
|
if (name === 'view_file') {
|
|
const path = String(args.path ?? '');
|
|
const start = args.start_line;
|
|
const end = args.end_line;
|
|
if (typeof start === 'number' && typeof end === 'number') {
|
|
return truncate(`${path}:${start}-${end}`, ARG_SUMMARY_MAX);
|
|
}
|
|
if (typeof start === 'number') {
|
|
return truncate(`${path}:${start}`, ARG_SUMMARY_MAX);
|
|
}
|
|
return truncate(path, ARG_SUMMARY_MAX);
|
|
}
|
|
if (name === 'list_dir') {
|
|
return truncate(String(args.path ?? '.'), ARG_SUMMARY_MAX);
|
|
}
|
|
if (name === 'grep') {
|
|
const pattern = String(args.pattern ?? '');
|
|
const path = args.path ? ` ${String(args.path)}` : '';
|
|
return truncate(`"${pattern}"${path}`, ARG_SUMMARY_MAX);
|
|
}
|
|
if (name === 'find_files') {
|
|
return truncate(String(args.pattern ?? ''), ARG_SUMMARY_MAX);
|
|
}
|
|
if (name === 'git_status') {
|
|
return '';
|
|
}
|
|
// Unknown tool — surface first arg value or the literal {} so the user can
|
|
// see something happened. Forward-compatible with future tools.
|
|
const keys = Object.keys(args);
|
|
if (keys.length === 0) return '{}';
|
|
const first = keys[0]!;
|
|
return truncate(`${first}: ${String(args[first])}`, ARG_SUMMARY_MAX);
|
|
}
|
|
|
|
export function runStatus(run: ToolRun): 'pending' | 'success' | 'error' {
|
|
if (run.result === null) return 'pending';
|
|
if (run.result.error) return 'error';
|
|
return 'success';
|
|
}
|
|
|
|
// Path-shaped paths in tool output text get a click handler so users can
|
|
// jump to the file. Same heuristic as MessageBubble.linkifyPaths.
|
|
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
|
|
function linkifyOutput(text: string): ReactNode[] {
|
|
const out: ReactNode[] = [];
|
|
let lastIdx = 0;
|
|
let idx = 0;
|
|
for (const match of text.matchAll(PATH_REGEX)) {
|
|
const matchedText = match[0];
|
|
const start = match.index ?? 0;
|
|
if (!matchedText.includes('/')) continue;
|
|
if (start > lastIdx) out.push(text.slice(lastIdx, start));
|
|
out.push(
|
|
<button
|
|
key={idx}
|
|
type="button"
|
|
onClick={() =>
|
|
sessionEvents.emit({ type: 'open_file_in_browser', path: matchedText })
|
|
}
|
|
className="text-primary underline cursor-pointer hover:text-primary/80"
|
|
>
|
|
{matchedText}
|
|
</button>
|
|
);
|
|
lastIdx = start + matchedText.length;
|
|
idx += 1;
|
|
}
|
|
if (lastIdx < text.length) out.push(text.slice(lastIdx));
|
|
return out.length > 0 ? out : [text];
|
|
}
|
|
|
|
interface Props {
|
|
run: ToolRun;
|
|
// When rendered inside a ToolCallGroup the line is already nested under a
|
|
// shared header, so the leading arrow is dropped to avoid double indent.
|
|
insideGroup?: boolean;
|
|
}
|
|
|
|
export function ToolCallLine({ run, insideGroup }: Props) {
|
|
const [open, setOpen] = useState(false);
|
|
const status = runStatus(run);
|
|
const args = run.call.args ?? {};
|
|
const summary = formatToolArgs(run.call.name, args);
|
|
|
|
return (
|
|
<div className="text-xs">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
className="flex items-center gap-1.5 w-full text-left hover:bg-muted/40 rounded px-1 py-0.5 -mx-1"
|
|
>
|
|
{!insideGroup && (
|
|
<span className="text-muted-foreground/60 select-none shrink-0">↳</span>
|
|
)}
|
|
<ChevronRight
|
|
className={`size-3 text-muted-foreground/60 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`}
|
|
/>
|
|
<span className="font-mono text-foreground/90 shrink-0">{run.call.name}</span>
|
|
{summary && (
|
|
<span className="font-mono text-muted-foreground truncate min-w-0 flex-1">
|
|
{summary}
|
|
</span>
|
|
)}
|
|
{!summary && <span className="flex-1" />}
|
|
<span className="shrink-0 ml-1">
|
|
{status === 'pending' && (
|
|
<Loader2 className="size-3 text-muted-foreground animate-spin" aria-label="running" />
|
|
)}
|
|
{status === 'success' && (
|
|
<Check className="size-3 text-emerald-500" aria-label="success" />
|
|
)}
|
|
{status === 'error' && (
|
|
<X className="size-3 text-destructive" aria-label="error" />
|
|
)}
|
|
</span>
|
|
</button>
|
|
{open && (
|
|
<div className="ml-5 mt-1 mb-1 space-y-1">
|
|
<pre className="text-[10px] text-muted-foreground font-mono whitespace-pre-wrap break-all bg-muted/30 rounded px-2 py-1">
|
|
{JSON.stringify(args, null, 2)}
|
|
</pre>
|
|
{run.result && (
|
|
<pre className="text-[11px] font-mono whitespace-pre-wrap bg-muted/30 rounded px-2 py-1 max-h-72 overflow-y-auto">
|
|
{run.result.error ? (
|
|
<span className="text-destructive">{run.result.error}</span>
|
|
) : (
|
|
linkifyOutput(
|
|
typeof run.result.output === 'string'
|
|
? run.result.output
|
|
: JSON.stringify(run.result.output, null, 2)
|
|
)
|
|
)}
|
|
{run.result.truncated && (
|
|
<div className="text-muted-foreground/60 mt-1">— output truncated —</div>
|
|
)}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|