tool call observability
This commit is contained in:
parent
d1e77f52f1
commit
69f45867b2
@ -1,6 +1,7 @@
|
||||
import { memo, useState, useCallback } from "react";
|
||||
import { marked } from "marked";
|
||||
import type { ChatTurn as ChatTurnType, ChatTurnBlockTool } from "../lib/api";
|
||||
import ToolCallDisplay from "./ToolCallDisplay";
|
||||
|
||||
interface ChatTurnProps {
|
||||
turn: ChatTurnType;
|
||||
@ -151,16 +152,13 @@ const ChatTurn = memo(function ChatTurn({ turn }: ChatTurnProps) {
|
||||
const subagent = toolCall.subagent;
|
||||
return (
|
||||
<div key={idx} className="tool-call">
|
||||
<div className="flex items-center gap-2 text-xs font-mono text-text-secondary">
|
||||
<span className="text-brand">●</span>
|
||||
<span>{toolCall.name}</span>
|
||||
{toolCall.response && !subagent && (
|
||||
<span className="text-green-500">✓</span>
|
||||
)}
|
||||
{subagent && subagent.stats && (
|
||||
<span className="text-green-500">✓</span>
|
||||
)}
|
||||
</div>
|
||||
<ToolCallDisplay
|
||||
callId={toolCall.callId}
|
||||
name={toolCall.name}
|
||||
parameters={toolCall.parameters}
|
||||
response={toolCall.response}
|
||||
createdAt={block.createdAt}
|
||||
/>
|
||||
{subagent && <SubagentDisplay subagent={subagent} />}
|
||||
</div>
|
||||
);
|
||||
@ -238,14 +236,14 @@ function SubagentDisplay({
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{subagent.toolCalls.map((tc, i) => (
|
||||
<div
|
||||
<ToolCallDisplay
|
||||
key={tc.callId || i}
|
||||
className="flex items-center gap-2 text-xs font-mono text-text-secondary"
|
||||
>
|
||||
<span className="text-yellow-500">◆</span>
|
||||
<span>{tc.name}</span>
|
||||
{tc.response && <span className="text-green-500">✓</span>}
|
||||
</div>
|
||||
callId={tc.callId}
|
||||
name={tc.name}
|
||||
parameters={tc.parameters}
|
||||
response={tc.response}
|
||||
defaultExpanded={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
130
gadget-code/frontend/src/components/ToolCallDisplay.tsx
Normal file
130
gadget-code/frontend/src/components/ToolCallDisplay.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { marked } from "marked";
|
||||
import { formatToolCallSignature } from "../lib/toolCallFormat";
|
||||
|
||||
// Configure marked with breaks enabled (consistent with ChatTurn)
|
||||
marked.use({ breaks: true });
|
||||
|
||||
interface ToolCallDisplayProps {
|
||||
callId: string;
|
||||
name: string;
|
||||
parameters?: string;
|
||||
response?: string;
|
||||
createdAt?: string;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single tool call with expand/collapse behavior.
|
||||
*
|
||||
* Collapsed: ▸ toolName(param: value, ...)
|
||||
* Expanded: shows response (raw/markdown toggle) + metadata
|
||||
*/
|
||||
export default function ToolCallDisplay({
|
||||
callId,
|
||||
name,
|
||||
parameters,
|
||||
response,
|
||||
createdAt,
|
||||
defaultExpanded = false,
|
||||
}: ToolCallDisplayProps) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const [renderMarkdown, setRenderMarkdown] = useState(false);
|
||||
const toggle = useCallback(() => setExpanded((p) => !p), []);
|
||||
|
||||
const signature = formatToolCallSignature(name, parameters);
|
||||
const hasResponse = !!response;
|
||||
|
||||
return (
|
||||
<div className="tool-call">
|
||||
{/* Header row — always visible, clickable to toggle */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="tool-call-header flex items-start gap-1.5 text-xs font-mono text-text-secondary hover:text-text-primary transition-colors w-full text-left"
|
||||
>
|
||||
<span className="text-brand shrink-0">{expanded ? "▾" : "▸"}</span>
|
||||
<span className="break-all">{signature}</span>
|
||||
{hasResponse && !expanded && (
|
||||
<span className="text-green-500 shrink-0 ml-1">✓</span>
|
||||
)}
|
||||
{expanded && createdAt && (
|
||||
<span className="text-text-muted shrink-0 ml-auto text-[10px]">
|
||||
{new Date(createdAt).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded detail panel */}
|
||||
{expanded && (
|
||||
<div className="tool-call-detail ml-4 mt-1 space-y-1">
|
||||
{/* Metadata row */}
|
||||
<div className="flex items-center gap-3 text-[10px] font-mono text-text-muted">
|
||||
<span>id: {callId}</span>
|
||||
{createdAt && (
|
||||
<span>{new Date(createdAt).toLocaleTimeString()}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Response block */}
|
||||
{hasResponse && (
|
||||
<div className="tool-call-response-container">
|
||||
{/* Response header with RAW/MD toggle */}
|
||||
<div className="flex items-center justify-between text-[10px] font-mono text-text-muted mb-0.5">
|
||||
<span>Response</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenderMarkdown(false);
|
||||
}}
|
||||
className={`px-1.5 py-0.5 rounded text-[10px] transition-colors ${
|
||||
!renderMarkdown
|
||||
? "bg-bg-elevated text-text-primary border border-border-default"
|
||||
: "text-text-muted hover:text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
RAW
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenderMarkdown(true);
|
||||
}}
|
||||
className={`px-1.5 py-0.5 rounded text-[10px] transition-colors ${
|
||||
renderMarkdown
|
||||
? "bg-bg-elevated text-text-primary border border-border-default"
|
||||
: "text-text-muted hover:text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
MD
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response content */}
|
||||
{renderMarkdown ? (
|
||||
<div
|
||||
className="tool-call-response-md gadget-markdown text-xs"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: marked.parse(response!) as string,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="tool-call-response-raw text-xs">
|
||||
{response}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No response indicator */}
|
||||
{!hasResponse && (
|
||||
<div className="text-[10px] font-mono text-text-muted italic">
|
||||
No response
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -56,14 +56,44 @@ button {
|
||||
}
|
||||
|
||||
.tool-call {
|
||||
margin-top: theme("spacing.4");
|
||||
margin-bottom: theme("spacing.4");
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.tool-call + .tool-call {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Tool call detail panel (expanded) */
|
||||
.tool-call-detail {
|
||||
border-left: 2px solid var(--color-border-default);
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Raw response display — fixed-width, scrollable */
|
||||
.tool-call-response-raw {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8em;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
}
|
||||
|
||||
/* Markdown-rendered response display — scrollable */
|
||||
.tool-call-response-md {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
|
||||
82
gadget-code/frontend/src/lib/toolCallFormat.ts
Normal file
82
gadget-code/frontend/src/lib/toolCallFormat.ts
Normal file
@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Tool call formatting utilities.
|
||||
*
|
||||
* Formats tool call parameters as function-call signature syntax:
|
||||
* toolName(param1: "value", param2: 42, param3: {...})
|
||||
*/
|
||||
|
||||
/**
|
||||
* Truncate a string to maxLength, appending "..." if truncated.
|
||||
*/
|
||||
export function truncate(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
return value.slice(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single parameter value for display in a function-call signature.
|
||||
*
|
||||
* - strings: quoted with truncation at 120 chars
|
||||
* - numbers/booleans/null: bare
|
||||
* - objects: "{...}" truncated
|
||||
* - arrays: "[...]" truncated
|
||||
*/
|
||||
export function formatParamValue(value: unknown): string {
|
||||
if (value === null) return "null";
|
||||
if (value === undefined) return "undefined";
|
||||
if (typeof value === "boolean") return String(value);
|
||||
if (typeof value === "number") return String(value);
|
||||
if (typeof value === "string") {
|
||||
const truncated = truncate(value, 120);
|
||||
// Only quote if it contains spaces or special chars, or if truncated
|
||||
// Always quote for consistency — function-call syntax expects it
|
||||
return `"${truncated}"`;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return "[...]";
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return "{...}";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JSON parameters string and format it as a function-call signature.
|
||||
*
|
||||
* @example
|
||||
* formatToolCallSignature("file_read", '{"path": "src/foo.ts", "recursive": true}')
|
||||
* // → 'file_read(path: "src/foo.ts", recursive: true)'
|
||||
*
|
||||
* @example
|
||||
* formatToolCallSignature("list_files", undefined)
|
||||
* // → 'list_files()'
|
||||
*/
|
||||
export function formatToolCallSignature(
|
||||
name: string,
|
||||
parameters?: string
|
||||
): string {
|
||||
if (!parameters) return `${name}()`;
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(parameters);
|
||||
} catch {
|
||||
// If it's not valid JSON, show it as a raw string argument
|
||||
return `${name}(${truncate(parameters, 200)})`;
|
||||
}
|
||||
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
// Non-object JSON — show as single argument
|
||||
return `${name}(${formatParamValue(parsed)})`;
|
||||
}
|
||||
|
||||
const entries = Object.entries(parsed);
|
||||
if (entries.length === 0) return `${name}()`;
|
||||
|
||||
const params = entries
|
||||
.map(([key, val]) => `${key}: ${formatParamValue(val)}`)
|
||||
.join(", ");
|
||||
|
||||
return `${name}(${params})`;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user