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>
91 lines
3.5 KiB
TypeScript
91 lines
3.5 KiB
TypeScript
import { useState } from 'react';
|
|
import { AlertCircle } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { api } from '@/api/client';
|
|
import type { Message } from '@/api/types';
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
interface Props {
|
|
message: Message;
|
|
// 1-indexed position among cap-hit sentinels in this chat. The first
|
|
// cap-hit is 1, second is 2, third is 3 (hard ceiling).
|
|
capHitPosition: number;
|
|
// Only the most recent sentinel shows the Continue button. Older ones
|
|
// render text-only — they've already been continued past.
|
|
isLatest: boolean;
|
|
}
|
|
|
|
// Hard ceiling = 3 cap-hits per chat ⇒ 2 continues max. Lives here in sync
|
|
// with insertCapHitSentinel's `canContinue = priorCount < 2` rule in
|
|
// services/inference.ts.
|
|
const MAX_CONTINUES = 2;
|
|
|
|
export function CapHitSentinel({ message, capHitPosition, isLatest }: Props) {
|
|
const meta = message.metadata;
|
|
// Defensive parse — if the row is somehow missing metadata we still render
|
|
// the bare text rather than crashing the chat.
|
|
const isCapHit =
|
|
meta !== null && typeof meta === 'object' && meta.kind === 'cap_hit';
|
|
|
|
const limit = isCapHit ? meta.limit : null;
|
|
const canContinue = isCapHit ? meta.can_continue : false;
|
|
const agentName = isCapHit ? meta.agent_name : null;
|
|
// `capHitPosition` is 1-indexed; `MAX_CONTINUES - (position - 1)` is the
|
|
// number of continues remaining including this one. Clamped to ≥0.
|
|
const remaining = Math.max(0, MAX_CONTINUES - (capHitPosition - 1));
|
|
|
|
const [continuing, setContinuing] = useState(false);
|
|
|
|
async function handleContinue() {
|
|
if (continuing || !canContinue || !isLatest) return;
|
|
setContinuing(true);
|
|
try {
|
|
await api.chats.continue(message.chat_id, message.id);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'continue failed');
|
|
} finally {
|
|
setContinuing(false);
|
|
}
|
|
}
|
|
|
|
// Tooltip wording from the v1.8.2 spec. Disabled state takes precedence —
|
|
// the spec text "Hard limit reached — start a new chat" matches what the
|
|
// server returns when canContinue is false.
|
|
const enabledTooltip = limit
|
|
? `Resumes with a fresh budget of ${limit} tool calls. ${remaining} continue${remaining === 1 ? '' : 's'} remaining on this chat.`
|
|
: undefined;
|
|
const disabledTooltip = 'Hard limit reached — start a new chat';
|
|
|
|
return (
|
|
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 text-sm">
|
|
<div className="px-3 py-2 flex items-start gap-2">
|
|
<AlertCircle className="size-4 text-amber-500 shrink-0 mt-0.5" />
|
|
<div className="flex-1 min-w-0 space-y-1">
|
|
<div className="text-xs font-medium text-amber-700 dark:text-amber-300">
|
|
{isCapHit && limit !== null
|
|
? `Reached tool budget (${limit}/${limit})${agentName ? ` — ${agentName}` : ''}.`
|
|
: 'Reached tool budget.'}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{message.content}
|
|
</div>
|
|
{isLatest && (
|
|
<div className="pt-1">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => void handleContinue()}
|
|
disabled={!canContinue || continuing}
|
|
title={canContinue ? enabledTooltip : disabledTooltip}
|
|
>
|
|
{continuing ? 'Continuing…' : 'Continue'}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|