feat(core,web,cli): add file tools and per-tool call descriptions (#62)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ import { Button } from "../../components/ui/button";
|
||||
import { toastError, toastInfo, toastSuccess } from "../../components/ui/toast";
|
||||
import { Input, Textarea } from "../../components/ui/input";
|
||||
import { OptionMenu, type OptionMenuChoice } from "../../components/ui/option-menu";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import { ConfirmModal, useSaveConfirm } from "../../components/ui/confirm-modal";
|
||||
import { Skeleton } from "../../components/ui/skeleton";
|
||||
import { VaultTab } from "./vault-tab";
|
||||
@@ -755,6 +756,12 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
|
||||
errs[`${i}-maxOutputLength`] = S.agent.toolFieldInvalid(row.base.name, "maxOutputLength");
|
||||
} else tool.maxOutputLength = n;
|
||||
}
|
||||
// call_description: missing = true, so flipping a stored-missing row back to on
|
||||
// rewinds to "not written" instead of writing the default explicitly.
|
||||
const origRow = data.config.toolsBuiltin[i];
|
||||
if (tool.call_description === true && origRow?.call_description === undefined) {
|
||||
delete tool.call_description;
|
||||
}
|
||||
tools.push(tool);
|
||||
}
|
||||
if (Object.keys(errs).length > 0) {
|
||||
@@ -772,7 +779,8 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
|
||||
return (
|
||||
t.permission !== o.permission ||
|
||||
t.timeoutMs !== o.timeoutMs ||
|
||||
t.maxOutputLength !== o.maxOutputLength
|
||||
t.maxOutputLength !== o.maxOutputLength ||
|
||||
t.call_description !== o.call_description
|
||||
);
|
||||
});
|
||||
if (!dirty) {
|
||||
@@ -782,16 +790,24 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
|
||||
requestSave(() => void onSave({ config: { toolsBuiltin: tools } }));
|
||||
};
|
||||
|
||||
/** Whether a tool's config schema declares the optional `description` call argument (only then does the per-row switch make sense). */
|
||||
const hasDescriptionProperty = (t: ToolDefinitionConfig): boolean => {
|
||||
const props = (t.parameters as { properties?: Record<string, unknown> } | undefined)
|
||||
?.properties;
|
||||
return props !== undefined && props !== null && props["description"] !== undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto overflow-y-clip rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
<table className="w-full min-w-[520px] text-left text-sm">
|
||||
<table className="w-full min-w-[640px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50/80 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900">
|
||||
<th className="px-3 py-2">{S.common.name}</th>
|
||||
<th className="px-3 py-2">{S.agent.toolPermission}</th>
|
||||
<th className="px-3 py-2">{S.agent.toolTimeout}</th>
|
||||
<th className="px-3 py-2">{S.agent.toolMaxOutput}</th>
|
||||
<th className="px-3 py-2">{S.agent.toolCallDescription}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -829,11 +845,25 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
|
||||
onChange={(e) => update(i, { maxOutputLength: e.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{/* Per-tool call_description switch (missing = on): shown only for tools whose
|
||||
config schema actually declares the description argument. */}
|
||||
{hasDescriptionProperty(row.base) ? (
|
||||
<Switch
|
||||
checked={row.base.call_description !== false}
|
||||
onChange={(v) => update(i, { base: { ...row.base, call_description: v } })}
|
||||
aria-label={`${row.base.name} ${S.agent.toolCallDescription}`}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-gray-300 dark:text-gray-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">{S.agent.callDescriptionHint}</p>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">{S.agent.mcpServers}</p>
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
* it also doesn't render if none of the candidates exist — the heuristic extraction inevitably
|
||||
* matches error message examples, external paths, and other strings that can't actually be
|
||||
* opened, so this card is only responsible for "if you click it, it really opens".
|
||||
* Doesn't include diff stats — the only built-in tool is exec_command (file writes happen inside
|
||||
* the shell), so the protocol has no structured edit signal; this is just an aggregated view of
|
||||
* Doesn't include diff stats — file writes may happen inside opaque exec_command shells, so
|
||||
* the protocol has no reliable structured edit signal; this is just an aggregated view of
|
||||
* text references, hence the neutral "N files" title.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
@@ -28,19 +28,113 @@ import { LiveDuration } from "./live-duration";
|
||||
import { SubagentCard } from "./subagent-card";
|
||||
import type { StreamRenderContext } from "./message-stream";
|
||||
|
||||
/** Tools that accept the optional model-written `description` argument. */
|
||||
const DESCRIBED_TOOLS = new Set([
|
||||
"exec_command",
|
||||
"input_command",
|
||||
"run_subagent",
|
||||
"input_subagent",
|
||||
]);
|
||||
|
||||
/** The three file tools: previewed by their `file_path` argument. */
|
||||
const FILE_TOOLS = new Set(["read_file", "edit_file", "write_file"]);
|
||||
|
||||
/**
|
||||
* Shortens a path for one-line display: at most one parent directory plus the filename
|
||||
* (`…/parent/file.ts`); paths already within that shape are shown as-is (same rule as the
|
||||
* CLI's tool-render). The full path stays in the expanded arguments block.
|
||||
*/
|
||||
export function shortenPath(p: string): string {
|
||||
const segments = p.split("/").filter((s) => s.length > 0);
|
||||
if (segments.length <= 2) return p;
|
||||
return `…/${segments[segments.length - 2]}/${segments[segments.length - 1]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Argument preview (same approach as the CLI's tool-render): exec_command shows `$ <cmd>`,
|
||||
* other tools show a single-line `name(args)` prefix. Arguments may be incomplete JSON
|
||||
* (mid-stream), so extraction is done leniently.
|
||||
* the file tools show their shortened file path, other tools show a single-line
|
||||
* `name(args)` prefix. Arguments may be incomplete JSON (mid-stream), so extraction is done
|
||||
* leniently. The preview deliberately keeps the real arguments (not the model-written
|
||||
* description): it heads the approval row, and the user must approve the actual command,
|
||||
* not the model's summary of it.
|
||||
*/
|
||||
function previewArguments(name: string, argsJson: string): string {
|
||||
export function previewArguments(name: string, argsJson: string): string {
|
||||
if (name === "exec_command") {
|
||||
const cmd = extractStringField(argsJson, "cmd");
|
||||
if (cmd !== null) return `$ ${cmd.replace(/\s+/g, " ").trim()}`;
|
||||
}
|
||||
if (FILE_TOOLS.has(name)) {
|
||||
const filePath = extractStringField(argsJson, "file_path");
|
||||
if (filePath !== null) return shortenPath(filePath.replace(/\s+/g, " ").trim());
|
||||
}
|
||||
return argsJson.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsed-header subtitle: the human-readable line next to the tool name — the
|
||||
* model-written `description` argument for the command/subagent tools (declared in their
|
||||
* config schema; per-tool `call_description: false` removes it, in which case the model
|
||||
* never sends it), or the shortened file path for the file tools. Null when there is
|
||||
* nothing beyond the raw arguments.
|
||||
*/
|
||||
export function headerSubtitle(name: string, argsJson: string): string | null {
|
||||
if (DESCRIBED_TOOLS.has(name)) {
|
||||
const desc = extractStringField(argsJson, "description");
|
||||
if (desc !== null) {
|
||||
const line = desc.replace(/\s+/g, " ").trim();
|
||||
if (line) return line;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (FILE_TOOLS.has(name)) {
|
||||
const filePath = extractStringField(argsJson, "file_path");
|
||||
if (filePath !== null) {
|
||||
const line = filePath.replace(/\s+/g, " ").trim();
|
||||
if (line) return shortenPath(line);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoded file-tool payload for the pending-approval block: the user is approving a
|
||||
* concrete rewrite (old_string/new_string/content), so the bare path is not enough — the
|
||||
* actual arguments are rendered in the scrollable expanded style while the call is PENDING.
|
||||
* Null for other tools or unparseable arguments (arguments are complete by approval time).
|
||||
*/
|
||||
export function pendingFilePayload(name: string, argsJson: string): string | null {
|
||||
if (!FILE_TOOLS.has(name)) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(argsJson);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object") return null;
|
||||
const args = parsed as Record<string, unknown>;
|
||||
const sections: string[] = [];
|
||||
const push = (label: string, value: unknown): void => {
|
||||
if (value === undefined) return;
|
||||
if (typeof value === "string" && value.includes("\n")) {
|
||||
sections.push(`${label}:\n${value}`);
|
||||
} else {
|
||||
sections.push(`${label}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
|
||||
}
|
||||
};
|
||||
push("file_path", args["file_path"]);
|
||||
if (name === "read_file") {
|
||||
push("offset", args["offset"]);
|
||||
push("limit", args["limit"]);
|
||||
} else if (name === "edit_file") {
|
||||
push("old_string", args["old_string"]);
|
||||
push("new_string", args["new_string"]);
|
||||
if (args["replace_all"] === true) push("replace_all", true);
|
||||
} else if (name === "write_file") {
|
||||
push("content", args["content"]);
|
||||
}
|
||||
return sections.join("\n");
|
||||
}
|
||||
|
||||
/** Extracts the current value of a string field from a possibly-incomplete JSON object string (a simplified version, good enough for preview purposes). */
|
||||
function extractStringField(argsJson: string, field: string): string | null {
|
||||
const key = `"${field}"`;
|
||||
@@ -94,6 +188,7 @@ export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRen
|
||||
}, [hasNestedPending]);
|
||||
|
||||
const preview = previewArguments(item.name, item.argumentsText);
|
||||
const subtitle = headerSubtitle(item.name, item.argumentsText);
|
||||
// Executing = the call has finished streaming, output hasn't arrived yet, and it's not waiting on approval (approval wait time doesn't count toward execution).
|
||||
const executing = item.callComplete && !item.outputComplete && !pending;
|
||||
// Argument-generation segment (settled): the live execution timer accumulates on top of this as a baseline, so the displayed duration doesn't shrink back once output arrives.
|
||||
@@ -135,6 +230,12 @@ export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRen
|
||||
<span className="shrink-0 truncate font-mono text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
{item.name || S.chat.unknownTool}
|
||||
</span>
|
||||
{/* Human-readable subtitle: the model-written call description (command/subagent tools) or the file path (file tools). */}
|
||||
{subtitle && (
|
||||
<span className="min-w-0 shrink truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 font-mono text-xs text-gray-500 dark:text-gray-400">
|
||||
{item.durationMs !== undefined ? (
|
||||
humanizeDuration(item.durationMs)
|
||||
@@ -189,6 +290,17 @@ export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRen
|
||||
{preview}
|
||||
</span>
|
||||
</div>
|
||||
{/* File tools: the one-line preview shows only the (shortened) path, but the user is
|
||||
approving a concrete rewrite — render the decoded payload (old_string/new_string/
|
||||
content) in the scrollable expanded style while pending. */}
|
||||
{(() => {
|
||||
const payload = pendingFilePayload(item.name, item.argumentsText);
|
||||
return payload !== null ? (
|
||||
<pre className="mb-2 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-md bg-white/70 px-2 py-1.5 text-xs leading-5 text-gray-700 dark:bg-gray-950/40 dark:text-gray-300">
|
||||
{payload}
|
||||
</pre>
|
||||
) : null;
|
||||
})()}
|
||||
<ApprovalButtons
|
||||
onDecide={(decision) => ctx.onApprove(item.toolCallId, decision, ctx.origin)}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* tree" navigation command (driven by clicking a file chip inside a message).
|
||||
*
|
||||
* The panel's content is just WorkspaceBrowser's single directory-tree view — the protocol has no
|
||||
* structured file-write signal at all (the only built-in tool is the opaque exec_command shell),
|
||||
* structured file-write signal at all (file writes can happen inside the opaque exec_command shell),
|
||||
* so there's no "Agent output" list to maintain; a file clicked in a message jumps straight to
|
||||
* locating it in the tree.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user