refactor(coding-agent): consolidated tool surface onto xd:// devices and hub

- Added the `xd://` virtual device protocol (`internal-urls/xd-protocol.ts`, `tools/xdev.ts`): tools declaring `loadMode: "discoverable"` are unmounted from the request tools array and driven via `read xd://` (list/docs+schema) and `write xd://<tool>` (execute), gated by the `tools.xdev` setting (default on) and inlined into the system prompt.
- Merged the `irc`, `job`, and `launch` tools into a single `hub` tool (`tools/hub/`, `async/job-manager.ts`): messaging keeps `send`/`inbox`/`list`, job control maps to `wait`/`cancel`/`jobs`, process supervision keeps `start`/`logs`/`stop`/`restart`/`describe` with `ps`, and the unified `wait` races background jobs against peer messages; SDK `IrcTool`/`JobTool`/`LaunchTool` are replaced by `HubTool`.
- Removed the hidden `resolve` tool in favor of the `xd://resolve`/`xd://reject`/`xd://propose` resolution devices, auto-including `write` whenever a deferrable tool or plan mode is present.
- Removed the BM25 tool-discovery system: the `search_tool_bm25` tool, the `tool-discovery` module, the `tools.discoveryMode`/`mcp.discoveryMode`/`mcp.discoveryDefaultServers`/`tools.essentialOverride` settings, per-tool MCP selection, and the `mcp_tool_selection` message type.
- Unified tool presentation on `ToolLoadMode` (`essential`|`discoverable`), replacing the custom-tool `xdev?: boolean` opt-out; custom, extension, MCP, RPC host, image-generation, and TTS tools now default to `discoverable`, and added a `satisfies` predicate to `SoftToolRequirement`.
- Removed the standalone `ssh` command tool and `ssh/ssh-executor` (the `ssh://` read/write/search protocol stays), and made `--tools` address hidden built-ins.
- Updated collab-web to render `xd://` dispatches and `hub` op families, dropped the `search_tool_bm25`/`ssh`/`report-finding` renderers, refreshed tool docs and prompts, and migrated the affected tests and changelogs.
This commit is contained in:
can1357
2026-07-15 15:16:29 +02:00
parent d0f2feaa52
commit 5ff277349c
187 changed files with 5722 additions and 11337 deletions
+9
View File
@@ -2,6 +2,15 @@
## [Unreleased]
### Changed
- Replaced the `irc`/`job` tool renderers (and their legacy `await`/`poll`/`cancel_job` aliases) with a single `hub` renderer that dispatches per op family — messaging, background jobs, and process supervision — keeping the existing irc/job visuals.
- Collab-web renders successful `xd://` device dispatches through the inner tool's renderer: a `write` result carrying `details.xdev` (execute mode) resolves the renderer by `xdev.tool`, uses `xdev.args`, and swaps in `xdev.inner` as details, with the card labeled `xd://<tool>` (preserves generated-image thumbnails and MCP/autoresearch presentation).
### Removed
- Removed custom visualization for `search_tool_bm25` tool; falls back to generic rendering
## [16.5.1] - 2026-07-14
### Fixed
@@ -36,15 +36,33 @@ function normalizeArgs(raw: unknown): { args: Record<string, unknown>; intent: s
return { args, intent };
}
interface XdevDispatch {
tool: string;
args: Record<string, unknown>;
inner: unknown;
}
function executeXdevDispatch(props: ToolViewProps): XdevDispatch | null {
if (props.name !== "write" || props.result?.isError === true || !isRecord(props.result?.details)) return null;
const xdev = props.result.details.xdev;
if (!isRecord(xdev) || xdev.mode !== "execute" || typeof xdev.tool !== "string") return null;
return { tool: xdev.tool, args: isRecord(xdev.args) ? xdev.args : {}, inner: xdev.inner };
}
export function ToolView(props: ToolViewProps): ReactNode {
const [open, setOpen] = useState(props.defaultOpen ?? false);
const xdev = executeXdevDispatch(props);
const { args, intent: argIntent } = normalizeArgs(props.args);
const intent = props.intent?.trim() || argIntent;
const renderer = resolveToolRenderer(props.name);
const name = xdev?.tool ?? props.name;
const result = xdev
? { content: props.result!.content, details: xdev.inner, isError: props.result!.isError }
: props.result;
const renderer = resolveToolRenderer(name);
const renderProps: ToolRenderProps = {
name: props.name,
args,
result: props.result,
name,
args: xdev?.args ?? args,
result,
running: props.running,
host: props.host,
};
@@ -67,7 +85,7 @@ export function ToolView(props: ToolViewProps): ReactNode {
) : (
<span className={`tv-status tv-status--${status}`} aria-hidden="true" />
)}
<span className="tv-name">{props.name}</span>
<span className="tv-name">{xdev ? `xd://${name}` : name}</span>
<span className="tv-sum">
<renderer.Summary {...renderProps} />
</span>
@@ -17,19 +17,15 @@ import { githubRenderer } from "./tools/github";
import { globRenderer } from "./tools/glob";
import { goalRenderer } from "./tools/goal";
import { grepRenderer } from "./tools/grep";
import { hubRenderer } from "./tools/hub";
import { inspectImageRenderer } from "./tools/inspect-image";
import { ircRenderer } from "./tools/irc";
import { jobRenderer } from "./tools/job";
import { lspRenderer } from "./tools/lsp";
import { recallRenderer } from "./tools/memory-recall";
import { reflectRenderer } from "./tools/memory-reflect";
import { retainRenderer } from "./tools/memory-retain";
import { readRenderer } from "./tools/read";
import { reportFindingRenderer } from "./tools/report-finding";
import { reportToolIssueRenderer } from "./tools/report-tool-issue";
import { resolveRenderer } from "./tools/resolve";
import { searchBm25Renderer } from "./tools/search-bm25";
import { sshRenderer } from "./tools/ssh";
import { taskRenderer } from "./tools/task";
import { todoRenderer } from "./tools/todo";
import { webSearchRenderer } from "./tools/web-search";
@@ -58,23 +54,16 @@ const RENDERERS: Record<string, ToolRenderer> = {
github: githubRenderer,
goal: goalRenderer,
inspect_image: inspectImageRenderer,
irc: ircRenderer,
job: jobRenderer,
await: jobRenderer,
poll: jobRenderer,
cancel_job: jobRenderer,
hub: hubRenderer,
lsp: lspRenderer,
recall: recallRenderer,
reflect: reflectRenderer,
retain: retainRenderer,
read: readRenderer,
report_finding: reportFindingRenderer,
report_tool_issue: reportToolIssueRenderer,
resolve: resolveRenderer,
grep: grepRenderer,
search: grepRenderer,
search_tool_bm25: searchBm25Renderer,
ssh: sshRenderer,
task: taskRenderer,
todo: todoRenderer,
web_search: webSearchRenderer,
@@ -0,0 +1,84 @@
/**
* `hub` — merged agent-coordination surface: peer messaging (irc rendering),
* background jobs (job rendering), and process supervision (generic rendering,
* matching the pre-merge launch tool). Dispatches per op family so each
* result keeps its original look.
*/
import type { ReactNode } from "react";
import { genericRenderer } from "../generic";
import type { ToolRenderer, ToolRenderProps } from "../types";
import { detailsRecord } from "../util";
import { ircRenderer } from "./irc";
import { jobRenderer } from "./job";
const LAUNCH_OPS = new Set(["start", "ps", "logs", "stop", "restart", "describe"]);
type Family = "launch" | "jobs" | "messaging";
/** Results dispatch on what actually happened; calls fall back to the arg shape. */
function familyOf({ args, result }: ToolRenderProps): Family {
const details = detailsRecord(result);
if (details) {
// `state`/`cursor` cover logs results, which may carry neither a daemon
// snapshot nor terminal rows; coordination details never define these keys.
if (
"daemon" in details ||
"daemons" in details ||
"terminalRows" in details ||
"spec" in details ||
"state" in details ||
"cursor" in details
) {
return "launch";
}
if (Array.isArray(details.jobs) || Array.isArray(details.agents)) return "jobs";
if ("receipts" in details || "waited" in details || "inbox" in details || "peers" in details) {
return "messaging";
}
}
const op = typeof args.op === "string" ? args.op : "";
if (LAUNCH_OPS.has(op)) return "launch";
const name = typeof args.name === "string" && args.name.length > 0;
const to = typeof args.to === "string" && args.to.length > 0;
const from = typeof args.from === "string" && args.from.length > 0;
if ((op === "send" || op === "wait") && name && !to && !from) return "launch";
if (op === "jobs" || op === "cancel") return "jobs";
if (op === "wait" && !from) return "jobs";
return "messaging";
}
/** Hub args → legacy job-renderer arg shape (`poll`/`cancel`/`list`). */
function toJobProps(props: ToolRenderProps): ToolRenderProps {
const op = typeof props.args.op === "string" ? props.args.op : "";
const args: Record<string, unknown> =
op === "cancel" ? { cancel: props.args.ids } : op === "jobs" ? { list: true } : { poll: props.args.ids };
return { ...props, args };
}
function Summary(props: ToolRenderProps): ReactNode {
switch (familyOf(props)) {
case "launch":
return <genericRenderer.Summary {...props} />;
case "jobs": {
const jobProps = toJobProps(props);
return <jobRenderer.Summary {...jobProps} />;
}
default:
return <ircRenderer.Summary {...props} />;
}
}
function Body(props: ToolRenderProps): ReactNode {
switch (familyOf(props)) {
case "launch":
return genericRenderer.Body ? <genericRenderer.Body {...props} /> : null;
case "jobs": {
const jobProps = toJobProps(props);
return jobRenderer.Body ? <jobRenderer.Body {...jobProps} /> : null;
}
default:
return ircRenderer.Body ? <ircRenderer.Body {...props} /> : null;
}
}
export const hubRenderer: ToolRenderer = { Summary, Body };
@@ -1,54 +0,0 @@
/** `report_finding` — a structured code-review finding from a reviewer agent. */
import type { ReactNode } from "react";
import type { Tone } from "../parts";
import { Badge, Badges, Output, PathText, ResultText } from "../parts";
import type { ToolRenderer, ToolRenderProps } from "../types";
import { num, str, truncate } from "../util";
function priorityTone(priority: string): Tone | undefined {
switch (priority) {
case "P0":
return "err";
case "P1":
return "warn";
case "P3":
return "accent";
default:
return undefined;
}
}
function Summary({ args }: ToolRenderProps): ReactNode {
const priority = str(args.priority);
const title = str(args.title);
return (
<>
{priority && <Badge tone={priorityTone(priority)}>{priority}</Badge>}
{title && <span> {truncate(title.replace(/^\[P\d\]\s*/, ""), 80)}</span>}
</>
);
}
function Body({ args, result }: ToolRenderProps): ReactNode {
const priority = str(args.priority);
const confidence = num(args.confidence);
const filePath = str(args.file_path);
const lineStart = num(args.line_start);
const lineEnd = num(args.line_end);
const body = str(args.body);
return (
<>
<Badges
items={[
priority && <Badge tone={priorityTone(priority)}>{priority}</Badge>,
confidence !== null && <Badge>confidence {(confidence * 100).toFixed(0)}%</Badge>,
filePath && <PathText path={filePath} from={lineStart ?? undefined} to={lineEnd ?? undefined} />,
]}
/>
{body && <Output text={body} maxLines={12} />}
{result?.isError && <ResultText result={result} maxLines={6} />}
</>
);
}
export const reportFindingRenderer: ToolRenderer = { Summary, Body };
@@ -1,118 +0,0 @@
/** `search_tool_bm25` — BM25 tool discovery: query in, ranked tool matches out. */
import type { ReactNode } from "react";
import { Badge, Badges, InvalidArg, Kv, KvGrid, Note, ResultText, Row } from "../parts";
import type { ToolRenderer, ToolRenderProps } from "../types";
import { detailsRecord, isRecord, normalizeWs, num, str, truncate } from "../util";
interface Bm25Match {
name: string;
label: string;
description: string;
serverName: string | null;
score: number | null;
}
function matchOf(value: unknown): Bm25Match | null {
if (!isRecord(value)) return null;
const name = str(value.name) ?? "";
const label = str(value.label) ?? name;
if (!label) return null;
return {
name,
label,
description: str(value.description) ?? "",
serverName: str(value.server_name),
score: num(value.score),
};
}
function strList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const out: string[] = [];
for (const item of value) {
if (typeof item === "string" && item) out.push(item);
}
return out;
}
function Summary({ args, result }: ToolRenderProps): ReactNode {
const query = str(args.query);
const limit = num(args.limit);
const details = detailsRecord(result);
const tools = details && Array.isArray(details.tools) ? details.tools : null;
return (
<>
{query !== null ? (
<span className="tv-pattern">{truncate(normalizeWs(query), 64) || "(empty query)"}</span>
) : (
<InvalidArg what="query" />
)}
{limit !== null && <Badge>limit:{limit}</Badge>}
{tools && (
<Badge tone={tools.length > 0 ? "ok" : "warn"}>
{tools.length} match{tools.length === 1 ? "" : "es"}
</Badge>
)}
</>
);
}
function Body({ args, result }: ToolRenderProps): ReactNode {
const details = detailsRecord(result);
const query = str(details?.query) ?? str(args.query);
const limit = num(details?.limit) ?? num(args.limit);
const totalTools = num(details?.total_tools);
const activated = strList(details?.activated_tools);
const activeSelected = strList(details?.active_selected_tools);
const matches: Bm25Match[] = [];
if (details && Array.isArray(details.tools)) {
for (const item of details.tools) {
const match = matchOf(item);
if (match) matches.push(match);
}
}
return (
<>
<KvGrid>
<Kv k="query">
{query !== null ? <span className="tv-pattern">{query}</span> : <InvalidArg what="query" />}
</Kv>
{limit !== null && <Kv k="limit">{limit}</Kv>}
{details && (
<Kv k="tools">
{matches.length} matched
{activeSelected.length > 0 && ` · ${activeSelected.length} active`}
{totalTools !== null && ` · ${totalTools} total`}
</Kv>
)}
{activated.length > 0 && (
<Kv k="activated">
<Badges items={activated} />
</Kv>
)}
</KvGrid>
{details && !result?.isError && matches.length === 0 && (
<Note tone="warn">
{totalTools === 0 ? "No discoverable tools are currently loaded." : "No matching tools found."}
</Note>
)}
{matches.length > 0 && (
<div className="tv-list">
{matches.map((match, i) => (
<Row key={i} k={match.score !== null ? match.score.toFixed(3) : undefined}>
<span className="tv-pattern">{match.label}</span>
{match.serverName && <Badge>{match.serverName}</Badge>}
{match.name && match.name !== match.label && <span className="tv-faint"> {match.name}</span>}
{match.description && (
<span className="tv-muted"> — {truncate(normalizeWs(match.description), 140)}</span>
)}
</Row>
))}
</div>
)}
{(!details || result?.isError) && <ResultText result={result} maxLines={10} />}
</>
);
}
export const searchBm25Renderer: ToolRenderer = { Summary, Body };
@@ -1,104 +0,0 @@
/** `ssh` — remote command execution on a configured SSH host. */
import type { ReactNode } from "react";
import { Badge, Badges, InvalidArg, Note, Output, ResultText } from "../parts";
import type { ToolRenderer, ToolRenderProps } from "../types";
import {
detailsRecord,
isRecord,
normalizeWs,
num,
replaceTabs,
resultTextOf,
shortenPath,
str,
truncate,
} from "../util";
/** Subset of the coding-agent `TruncationMeta` we surface. */
interface TruncationInfo {
totalLines: number | null;
outputLines: number | null;
artifactId: string | null;
}
function truncationOf(result: ToolRenderProps["result"]): TruncationInfo | null {
const meta = detailsRecord(result)?.meta;
if (!isRecord(meta) || !isRecord(meta.truncation)) return null;
const t = meta.truncation;
return {
totalLines: num(t.totalLines),
outputLines: num(t.outputLines),
artifactId: str(t.artifactId),
};
}
/**
* The tool wrapper bakes a trailing `[Showing lines …]` notice into the
* LLM-facing text. Like the TUI, drop it when we render our own styled
* truncation note. Only called when truncation meta is present.
*/
function stripTruncationNotice(text: string): string {
const trimmed = text.trimEnd();
if (!trimmed.endsWith("]")) return trimmed;
const idx = trimmed.lastIndexOf("\n[Showing ");
if (idx >= 0) return trimmed.slice(0, idx).trimEnd();
return trimmed.startsWith("[Showing ") && !trimmed.includes("\n") ? "" : trimmed;
}
function Summary({ args }: ToolRenderProps): ReactNode {
const host = str(args.host);
const command = str(args.command);
return (
<>
<Badge tone="accent">{host ?? "…"}</Badge>{" "}
{command !== null
? truncate(normalizeWs(command), 80)
: args.command !== undefined && <InvalidArg what="command" />}
</>
);
}
function Body({ args, result }: ToolRenderProps): ReactNode {
const host = str(args.host);
const command = str(args.command);
const cwd = str(args.cwd);
const timeout = num(args.timeout);
const trunc = truncationOf(result);
const stripped =
trunc !== null && result?.isError !== true ? stripTruncationNotice(resultTextOf(result).trim()) : null;
return (
<>
<Badges
items={[
host !== null ? <Badge tone="accent">{host}</Badge> : <InvalidArg what="host" />,
cwd !== null && <Badge>cwd {shortenPath(cwd)}</Badge>,
timeout !== null && <Badge>timeout {timeout}s</Badge>,
]}
/>
{command !== null ? (
<div className="tv-cmd">
<span className="tv-cmd-prompt">$</span>
<span className="tv-cmd-text">{replaceTabs(command)}</span>
</div>
) : (
<InvalidArg what="command" />
)}
{stripped !== null ? (
stripped !== "" && <Output text={stripped} maxLines={12} />
) : (
<ResultText result={result} maxLines={12} />
)}
{trunc !== null && (
<Note tone="warn">
Output truncated
{trunc.outputLines !== null &&
trunc.totalLines !== null &&
` — showing ${trunc.outputLines} of ${trunc.totalLines} lines`}
{trunc.artifactId !== null && ` · full output at artifact://${trunc.artifactId}`}
</Note>
)}
</>
);
}
export const sshRenderer: ToolRenderer = { Summary, Body };
@@ -0,0 +1,31 @@
import { describe, expect, it } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { ToolView } from "../src/tool-render/ToolView";
describe("ToolView xd:// dispatches", () => {
it("renders successful execute-mode xdev writes as the inner generate_image tool", () => {
const html = renderToStaticMarkup(
<ToolView
name="write"
defaultOpen
result={{
content: [],
details: {
xdev: {
tool: "generate_image",
mode: "execute",
args: { subject: "alpine lake" },
inner: {
images: [{ data: "aW1hZ2U=", mimeType: "image/png" }],
},
},
},
}}
/>,
);
expect(html).toContain("xd://generate_image");
expect(html).toContain("alpine lake");
expect(html).toContain('src="data:image/png;base64,aW1hZ2U="');
});
});