feat(web): tools and MCP section in Agent settings, tool call IO drawer

Settings > Agent: MCP server card (endpoint, enable switch, allowed API keys,
live sessions, recent calls, copyable client config without secrets) and the
capability list (scope, risk, idempotency/replay, authorization state, 7-day
calls and failure rate, last call, schema sheet). Agent session tool cards get
a drawer with input/output schema and the redacted input/output of the call.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 20:28:41 +08:00
1 parent 653100ceb4
commit b3cc5a8313
5 files changed
+656

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
CapabilityStatsResponse,
CapabilityToolsResponse,
McpServerConfig,
type McpServerConfigUpdate,
McpServerStatus,
} from "@umt/contracts";
import { TTL_MINUTE, TTL_SECOND } from "~/lib/api.ts";
import { apiParsed } from "./parse.ts";
// 能力清单 / 调用统计 / 对外 MCP 服务端(AG-1):GET /capabilities/tools、/capabilities/stats、/mcp/status,PATCH /mcp/config。
const KEY = {
tools: ["capabilities", "tools"] as const,
stats: (days: number) => ["capabilities", "stats", days] as const,
mcp: ["mcp", "status"] as const,
};
/** 能力清单(描述符 + JSON Schema + 授权状态);会话页工具卡片与设置页共用,改动少,缓存久一点 */
export function useCapabilityTools(enabled = true) {
return useQuery({
queryKey: KEY.tools,
queryFn: ({ signal }) => apiParsed(CapabilityToolsResponse, "/capabilities/tools", { signal }),
staleTime: TTL_MINUTE,
enabled,
});
}
export function useCapabilityStats(days = 7) {
return useQuery({
queryKey: KEY.stats(days),
queryFn: ({ signal }) => apiParsed(CapabilityStatsResponse, "/capabilities/stats", { params: { days }, signal }),
staleTime: 30 * TTL_SECOND,
placeholderData: keepPreviousData,
});
}
export function useMcpStatus() {
return useQuery({
queryKey: KEY.mcp,
queryFn: ({ signal }) => apiParsed(McpServerStatus, "/mcp/status", { signal }),
refetchInterval: 15 * TTL_SECOND,
});
}
export function useUpdateMcpConfig() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: McpServerConfigUpdate) => apiParsed(McpServerConfig, "/mcp/config", { method: "PATCH", body }),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY.mcp }),
});
}
/**
* 给 Claude Code 等 MCP 客户端的接入配置(Streamable HTTP)。密钥用环境变量占位,不含明文:
* 客户端侧把 UMT_API_KEY 设为在「API Key」分区新建的 Key。
*/
export function mcpClientConfig(origin: string, path: string, serverName: string) {
return {
mcpServers: {
[serverName]: {
type: "http",
url: `${origin}${path}`,
// biome-ignore lint/suspicious/noTemplateCurlyInString: 客户端侧的环境变量占位,不是本地模板
headers: { Authorization: "Bearer ${UMT_API_KEY}" },
},
},
};
}
@@ -52,6 +52,7 @@ import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/component
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { ToolCallIo } from "./tool-call-card-io.tsx";
import type { AgentStreamState, StreamItem, ToolCallView } from "./use-agent-stream.ts";
// Agent 会话的展示层:把 use-agent-stream 折叠出的 StreamItem 映射到 agentui 组件(components/agentui/,
@@ -230,6 +231,7 @@ export function ToolCallCard({
</>
) : null}
</dl>
<ToolCallIo call={call} />
</ToolResult>
);
}
@@ -0,0 +1,87 @@
import { Braces } from "lucide-react";
import { useState } from "react";
import { useCapabilityTools } from "~/api/mcp.ts";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { Button } from "~/components/ui/button.tsx";
import { formatDateTime } from "~/lib/utils.ts";
import type { ToolCallView } from "./use-agent-stream.ts";
// 会话页工具卡片的"查看 schema 与本次输入输出"抽屉。
// 本次输入输出取事件流里的摘要:服务端已脱敏(凭证键一律遮盖),超长部分截断;schema 取能力清单(GET /capabilities/tools)。
/** 摘要尽量按 JSON 美化;截断后的摘要不是合法 JSON,原样显示 */
export function parseSummary(text: string | null): { value: unknown; truncated: boolean } {
if (text === null) return { value: null, truncated: false };
const truncated = /…\(\d+ more\)$/.test(text);
if (truncated) return { value: text, truncated };
try {
return { value: JSON.parse(text), truncated };
} catch {
return { value: text, truncated };
}
}
export function ToolCallIo({ call }: { call: ToolCallView }) {
const [open, setOpen] = useState(false);
return (
<>
<Button size="sm" variant="ghost" className="justify-self-start" onClick={() => setOpen(true)}>
<Braces />
查看 schema 与输入输出
</Button>
{open ? <ToolCallIoSheet call={call} onClose={() => setOpen(false)} /> : null}
</>
);
}
function ToolCallIoSheet({ call, onClose }: { call: ToolCallView; onClose: () => void }) {
const tools = useCapabilityTools();
const tool = tools.data?.items.find((t) => t.id === call.capabilityId) ?? null;
const input = parseSummary(call.inputSummary);
const output = parseSummary(call.outputSummary);
const failed = call.status === "failed" || call.status === "denied";
return (
<DetailSheet
open
onOpenChange={(o) => !o && onClose()}
title={tool?.title ?? call.capabilityId}
description={call.capabilityId}
width="lg"
>
<KvList
items={[
{ label: "调用 ID", value: call.toolCallId, mono: true, wide: true },
{ label: "开始", value: formatDateTime(call.startedAt) },
{ label: "结束", value: call.endedAt ? formatDateTime(call.endedAt) : "—" },
]}
/>
<DetailSection title="本次输入(已脱敏)">
<JsonView value={input.value} maxHeight="18rem" />
</DetailSection>
<DetailSection title={failed ? "本次错误" : "本次输出(已脱敏)"}>
<JsonView value={output.value ?? call.reason} maxHeight="18rem" tone={failed ? "destructive" : "neutral"} />
</DetailSection>
{input.truncated || output.truncated ? (
<p className="text-caption text-muted-foreground">事件流只保存 500 字符以内的摘要,超出部分已截断。</p>
) : null}
<DetailSection title="输入 schema">
{tools.isPending ? (
<LoadingState rows={2} />
) : tools.error ? (
<ErrorState title="读取能力清单失败" error={tools.error} onRetry={() => tools.refetch()} plain />
) : tool ? (
<JsonView value={tool.inputJsonSchema} maxHeight="20rem" />
) : (
<p className="text-caption text-muted-foreground">这个能力没有注册,没有 schema。</p>
)}
</DetailSection>
{tool ? (
<JsonView value={tool.outputJsonSchema} maxHeight="20rem" defaultOpen={false} label="输出 schema" />
) : null}
</DetailSheet>
);
}
@@ -0,0 +1,495 @@
import type {
CapabilityAuthState,
CapabilityCaller,
CapabilityKind,
CapabilityStatsItem,
CapabilityToolInfo,
McpServerStatus,
RiskLevel,
} from "@umt/contracts";
import { Check, Copy } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { useApiKeys } from "~/api/api-keys.ts";
import {
mcpClientConfig,
useCapabilityStats,
useCapabilityTools,
useMcpStatus,
useUpdateMcpConfig,
} from "~/api/mcp.ts";
import { DataTable } from "~/components/console/data-table.tsx";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatRelative } from "~/lib/labels.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { SettingsBlock } from "./settings-block.tsx";
// 设置 → Agent → 工具与 MCP:对外 MCP 服务端(端点、启用、允许的 API Key、会话、最近调用、接入配置)
// + 能力清单(作用域 / 风险 / 幂等与重放 / 授权状态 / 7 天调用与失败率 / 最近一次,点开看 schema)。
export const KIND_LABEL: Record<CapabilityKind, string> = {
read: "只读",
preview: "预览",
propose: "提案",
approve: "审批",
execute: "执行",
};
const RISK: Record<RiskLevel, { label: string; tone: StatusTone }> = {
low: { label: "低风险", tone: "neutral" },
medium: { label: "中风险", tone: "warning" },
high: { label: "高风险", tone: "destructive" },
};
const AUTH: Record<CapabilityAuthState, { label: string; tone: StatusTone; hint: string }> = {
read_only: { label: "无需授权", tone: "neutral", hint: "只读或预览,不落变更,Key 有对应 scope 即可调用" },
granted: { label: "有持续授权", tone: "success", hint: "Agent、MCP、API Key 调用按持续授权放行" },
needs_approval: {
label: "需审批",
tone: "warning",
hint: "没有覆盖它的持续授权,Agent、MCP、API Key 调用需要单次审批",
},
agent_only: { label: "仅 Agent 会话", tone: "neutral", hint: "只在 Agent 会话内可用,不经 MCP 暴露" },
};
export const CALLER_LABEL: Record<CapabilityCaller, string> = {
agent: "内置 Agent",
mcp: "MCP",
rest: "REST",
other: "其他",
};
const percent = (n: number, d: number) => (d > 0 ? `${Math.round((n / d) * 100)}%` : "—");
export function AgentToolsBlock() {
return (
<div className="grid gap-8">
<McpServerBlock />
<CapabilitiesBlock />
</div>
);
}
// ── MCP 服务端 ──
function McpServerBlock() {
const status = useMcpStatus();
return (
<SettingsBlock
section="agent"
anchor="mcp"
title="MCP 服务端"
description="外部 Agent 用 API Key 经 MCP 调用同一套能力。授权、审计、幂等与控制台一致,有副作用的工具没有持续授权或单次审批时被拒绝。"
>
{status.isPending ? (
<LoadingState rows={3} />
) : status.error ? (
<ErrorState title="读取 MCP 状态失败" error={status.error} onRetry={() => status.refetch()} plain />
) : (
<McpServerBody s={status.data} />
)}
</SettingsBlock>
);
}
function McpServerBody({ s }: { s: McpServerStatus }) {
const update = useUpdateMcpConfig();
const keys = useApiKeys();
const origin = typeof window !== "undefined" ? window.location.origin : "";
const endpoint = `${origin}${s.path}`;
const config = useMemo(() => mcpClientConfig(origin, s.path, s.serverName), [origin, s.path, s.serverName]);
const activeKeys = (keys.data?.items ?? []).filter((k) => !k.revokedAt);
const restricted = s.allowedApiKeyIds !== null;
const save = (patch: Parameters<typeof update.mutate>[0], ok: string) =>
update.mutate(patch, {
onSuccess: () => toast.success(ok),
onError: (e) => toast.error(errorMessage(e, "保存失败")),
});
const toggleKey = (id: string, on: boolean) => {
const cur = new Set(s.allowedApiKeyIds ?? []);
if (on) cur.add(id);
else cur.delete(id);
save({ allowedApiKeyIds: [...cur] }, "已更新允许的 API Key");
};
return (
<div className="grid gap-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3 text-sm">
<Switch
checked={s.enabled}
disabled={update.isPending}
onCheckedChange={(v) => save({ enabled: v }, v ? "MCP 服务端已启用" : "MCP 服务端已停用,现有会话已断开")}
aria-labelledby="mcp-enabled-label"
/>
<span id="mcp-enabled-label" className="font-medium">
{s.enabled ? "已启用" : "已停用"}
</span>
</div>
<StatusBadge tone={s.sessions.length > 0 ? "success" : "neutral"}>{s.sessions.length} 个会话在线</StatusBadge>
</div>
<KvList
items={[
{ label: "端点", value: <CopyText text={endpoint} />, wide: true },
{ label: "传输", value: "Streamable HTTP(POST / GET / DELETE)" },
{ label: "鉴权", value: "Authorization: Bearer <API Key>" },
{
label: "限速",
value: `每个 Key 每分钟 ${s.rateLimitPerMinute} 次工具调用,最多 ${s.maxSessionsPerKey} 个会话`,
},
{
label: "可见工具",
value: `read ${s.toolsByScope.read} 个 · read + organize.execute ${s.toolsByScope.readExecute} 个 · admin ${s.toolsByScope.admin} 个`,
},
]}
/>
<DetailSection title="允许的 API Key">
<div className="grid gap-2 text-sm">
<label className="flex items-center gap-2">
<input
type="radio"
name="mcp-keys"
checked={!restricted}
disabled={update.isPending}
onChange={() => save({ allowedApiKeyIds: null }, "所有 API Key 都可以连接")}
/>
所有未撤销的 API Key
</label>
<label className="flex items-center gap-2">
<input
type="radio"
name="mcp-keys"
checked={restricted}
disabled={update.isPending}
onChange={() => save({ allowedApiKeyIds: [] }, "只允许勾选的 API Key")}
/>
只允许勾选的 Key
</label>
{restricted ? (
activeKeys.length === 0 ? (
<p className="pl-6 text-caption text-muted-foreground">还没有 API Key。在「API Key」分区新建。</p>
) : (
<ul className="grid gap-1.5 pl-6">
{activeKeys.map((k) => (
<li key={k.id}>
<label className="flex flex-wrap items-center gap-2">
<input
type="checkbox"
checked={s.allowedApiKeyIds?.includes(k.id) ?? false}
disabled={update.isPending}
onChange={(e) => toggleKey(k.id, e.target.checked)}
/>
<span className="font-medium">{k.name}</span>
<span className="text-meta text-muted-foreground">{k.scopes.join(" · ")}</span>
</label>
</li>
))}
</ul>
)
) : null}
<p className="text-caption text-muted-foreground">
Key 的 scope 决定能看到哪些工具:read 只有只读和预览类,organize.execute 或 admin 才有执行类。
</p>
</div>
</DetailSection>
<DetailSection title="当前会话">
{s.sessions.length === 0 ? (
<p className="text-caption text-muted-foreground">
没有在线会话。客户端连上后出现在这里,空闲 30 分钟自动断开。
</p>
) : (
<ul className="grid gap-1.5 text-sm">
{s.sessions.map((x) => (
<li key={x.idPrefix} className="flex flex-wrap items-center gap-x-3 gap-y-0.5">
<code className="text-meta">{x.idPrefix}</code>
<span className="font-medium">{x.apiKeyName}</span>
<span className="text-meta text-muted-foreground">{x.clientName ?? "未知客户端"}</span>
<span className="text-meta text-muted-foreground">
{x.toolCalls} 次调用 · 最近 {formatRelative(x.lastSeenAt)}
</span>
</li>
))}
</ul>
)}
</DetailSection>
<DetailSection title="最近调用">
{s.recentCalls.length === 0 ? (
<p className="text-caption text-muted-foreground">还没有经 MCP 的调用。</p>
) : (
<ul className="grid gap-1.5 text-sm">
{s.recentCalls.slice(0, 8).map((c) => (
<li key={`${c.at}-${c.capabilityId}`} className="flex flex-wrap items-center gap-x-3 gap-y-0.5">
<code className="text-meta">{c.capabilityId}</code>
<OutcomeBadge outcome={c.outcome} />
<span className="text-meta text-muted-foreground" title={formatDateTime(c.at)}>
{formatRelative(c.at)}
{c.durationMs !== null ? ` · ${c.durationMs} ms` : ""}
</span>
</li>
))}
</ul>
)}
</DetailSection>
<DetailSection
title="接入配置"
actions={<CopyButton text={JSON.stringify(config, null, 2)} label="复制接入配置" />}
>
<p className="mb-2 text-caption text-muted-foreground">
适用于 Claude Code 等支持 Streamable HTTP 的客户端。密钥用环境变量 UMT_API_KEY 占位,配置里不含明文。
</p>
<JsonView value={config} maxHeight="14rem" />
</DetailSection>
</div>
);
}
function OutcomeBadge({ outcome }: { outcome: string | null }) {
if (!outcome) return null;
const map: Record<string, { label: string; tone: StatusTone }> = {
ok: { label: "成功", tone: "success" },
denied: { label: "被拒绝", tone: "warning" },
invalid: { label: "参数不符", tone: "destructive" },
error: { label: "失败", tone: "destructive" },
};
const m = map[outcome] ?? { label: outcome, tone: "neutral" as const };
return <StatusBadge tone={m.tone}>{m.label}</StatusBadge>;
}
function CopyText({ text }: { text: string }) {
return (
<span className="flex min-w-0 items-center gap-2">
<code className="min-w-0 break-all text-meta">{text}</code>
<CopyButton text={text} label="复制" />
</span>
);
}
function CopyButton({ text, label }: { text: string; label: string }) {
const [copied, setCopied] = useState(false);
return (
<Button
size="sm"
variant="outline"
onClick={async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
toast.error("复制失败,手动选中文本复制");
}
}}
>
{copied ? <Check /> : <Copy />}
{copied ? "已复制" : label}
</Button>
);
}
// ── 能力清单 ──
type Row = CapabilityToolInfo & { stats: CapabilityStatsItem | null };
function CapabilitiesBlock() {
const tools = useCapabilityTools();
const stats = useCapabilityStats(7);
const [openId, setOpenId] = useState<string | null>(null);
const byId = useMemo(() => new Map((stats.data?.items ?? []).map((s) => [s.capabilityId, s])), [stats.data]);
const rows: Row[] | undefined = tools.data?.items.map((t) => ({ ...t, stats: byId.get(t.id) ?? null }));
const open = rows?.find((r) => r.id === openId) ?? null;
const automationOff = tools.data && (!tools.data.automationEnabled || tools.data.readOnlyReconcile);
return (
<SettingsBlock
section="agent"
anchor="capabilities"
title="能力清单"
description="Agent、MCP、REST 共用这些能力。调用数与失败率为最近 7 天,含被拒绝的调用。"
plain
>
{automationOff ? (
<p className="mb-3 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-caption text-warning">
{tools.data?.readOnlyReconcile ? "只读对账模式" : "自动化总开关已关闭"}
:执行类能力对 Agent、MCP、API Key 一律拒绝,持续授权暂不生效。
</p>
) : null}
<DataTable
rows={rows}
rowKey={(r) => r.id}
loading={tools.isPending}
error={tools.error}
onRetry={() => tools.refetch()}
onRowClick={(r) => setOpenId(r.id)}
empty={{ title: "没有已注册的能力" }}
columns={[
{
key: "id",
header: "能力",
cell: (r) => (
<div className="min-w-0">
<p className="truncate font-medium">{r.title}</p>
<code className="text-meta break-all text-muted-foreground">{r.id}</code>
</div>
),
},
{
key: "kind",
header: "类别 / scope",
hideBelow: "md",
cell: (r) => (
<div className="grid gap-0.5">
<span>{KIND_LABEL[r.kind]}</span>
<code className="text-meta text-muted-foreground">{r.scope}</code>
</div>
),
},
{
key: "risk",
header: "风险",
hideBelow: "sm",
cell: (r) => <StatusBadge tone={RISK[r.riskLevel].tone}>{RISK[r.riskLevel].label}</StatusBadge>,
},
{
key: "idem",
header: "幂等 / 重放",
hideBelow: "lg",
cell: (r) => (
<span className="text-meta">
{r.idempotent ? "幂等" : "非幂等"} · {r.replay === "safe" ? "可重放" : "不重放"}
</span>
),
},
{
key: "auth",
header: "授权状态",
cell: (r) => (
<span title={AUTH[r.authState].hint}>
<StatusBadge tone={AUTH[r.authState].tone}>{AUTH[r.authState].label}</StatusBadge>
</span>
),
},
{
key: "calls",
header: "7 天调用",
align: "right",
hideBelow: "sm",
cell: (r) =>
r.stats ? (
<div className="grid justify-items-end gap-0.5 tabular-nums">
<span>{r.stats.calls}</span>
<span
className={
r.stats.failures + r.stats.denied > 0
? "text-meta text-warning"
: "text-meta text-muted-foreground"
}
>
失败 {percent(r.stats.failures, r.stats.calls)}
{r.stats.denied > 0 ? ` · 拒绝 ${r.stats.denied}` : ""}
</span>
</div>
) : (
<span className="text-muted-foreground">0</span>
),
},
{
key: "last",
header: "最近一次",
hideBelow: "md",
cell: (r) =>
r.stats?.lastCalledAt ? (
<div className="grid gap-0.5">
<span title={formatDateTime(r.stats.lastCalledAt)}>{formatRelative(r.stats.lastCalledAt)}</span>
<span className="text-meta text-muted-foreground">
{r.stats.lastCaller ? CALLER_LABEL[r.stats.lastCaller] : ""}
</span>
</div>
) : (
<span className="text-muted-foreground">—</span>
),
},
]}
/>
<CapabilitySheet row={open} onClose={() => setOpenId(null)} />
</SettingsBlock>
);
}
function CapabilitySheet({ row, onClose }: { row: Row | null; onClose: () => void }) {
return (
<DetailSheet
open={!!row}
onOpenChange={(o) => !o && onClose()}
title={row?.title ?? ""}
description={row?.id}
width="lg"
>
{row ? (
<>
<p className="text-sm">{row.description}</p>
<div className="flex flex-wrap gap-1.5">
<StatusBadge>{KIND_LABEL[row.kind]}</StatusBadge>
<StatusBadge>scope · {row.scope}</StatusBadge>
<StatusBadge tone={RISK[row.riskLevel].tone}>{RISK[row.riskLevel].label}</StatusBadge>
<StatusBadge tone={AUTH[row.authState].tone}>{AUTH[row.authState].label}</StatusBadge>
</div>
<KvList
items={[
{ label: "版本", value: `v${row.version}` },
{ label: "MCP 工具名", value: row.mcpExposed ? row.toolName : "不经 MCP 暴露", mono: row.mcpExposed },
{ label: "幂等", value: row.idempotent ? "是" : "否" },
{ label: "崩溃后重放", value: row.replay === "safe" ? "可以" : "不重放" },
{ label: "支持预览", value: row.supportsPreview ? "是" : "否" },
{
label: "有效持续授权",
value: row.kind === "read" || row.kind === "preview" ? "—" : `${row.activeGrants} 条`,
},
{ label: "授权说明", value: AUTH[row.authState].hint, wide: true },
]}
/>
{row.stats ? (
<DetailSection title="最近 7 天">
<KvList
items={[
{ label: "调用", value: row.stats.calls },
{ label: "失败", value: `${row.stats.failures}(${percent(row.stats.failures, row.stats.calls)})` },
{ label: "被拒绝", value: row.stats.denied },
{
label: "平均耗时",
value: row.stats.avgDurationMs !== null ? `${row.stats.avgDurationMs} ms` : "—",
},
{
label: "调用来源",
value: (Object.keys(CALLER_LABEL) as CapabilityCaller[])
.filter((k) => row.stats!.byCaller[k] > 0)
.map((k) => `${CALLER_LABEL[k]} ${row.stats!.byCaller[k]}`)
.join(" · "),
wide: true,
},
]}
/>
</DetailSection>
) : null}
<DetailSection title="输入 schema">
<JsonView value={row.inputJsonSchema} maxHeight="24rem" />
</DetailSection>
<JsonView value={row.outputJsonSchema} maxHeight="24rem" defaultOpen={false} label="输出 schema" />
</>
) : null}
</DetailSheet>
);
}
@@ -1,3 +1,4 @@
import { AgentToolsBlock } from "./agent-tools.tsx";
import { PiUpstreamsBlock } from "./pi-upstreams.tsx";
import type { FieldValue } from "./resource-section.tsx";
import { num, str } from "./resource-section.tsx";
@@ -194,6 +195,7 @@ export function AgentSection() {
</>
)}
</SettingsGate>
<AgentToolsBlock />
</div>
);
}