Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions workers/api/src/agent-do-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ describe("agent tool definition helpers", () => {
}
});

it("passes a registry tool's jsonSchema through verbatim to the LLM-facing definition", () => {
// A creator granting a connector tool: it appears in the built definitions with its
// authored draft-07 schema (properties + required), NOT a rebuilt ad-hoc map.
const declared: AgentCapabilities = { ...caps([]), tools: ["github_read_issue"] };
const def = buildAgentToolDefinitions({ capabilities: declared }).find(
(t) => t.function.name === "github_read_issue",
);
expect(def).toBeTruthy();
expect(def?.function.parameters.type).toBe("object");
expect(def?.function.parameters.properties).toHaveProperty("repo");
expect(def?.function.parameters.properties).toHaveProperty("number");
expect(def?.function.parameters.required).toEqual(["repo", "number"]);
});

it("prefers storage tool schemas when storage and base tools share a name", () => {
const searchKnowledge = buildAgentToolDefinitions().find(
(tool) => tool.function.name === "search_knowledge",
Expand Down
37 changes: 23 additions & 14 deletions workers/api/src/agent-do-tools.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AGENT_TOOLS } from "./lib/tools.js";
import { STORAGE_TOOLS } from "./lib/storage-tools.js";
import { registryConnectorGroups, registryToolDefs } from "./lib/tool-registry.js";
import { registryConnectorGroups, registryToolDefs, type JsonSchema } from "./lib/tool-registry.js";
import type { AgentCapabilities } from "./lib/agent-capabilities.js";

// ── Tool groups ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -126,7 +126,13 @@ export function buildAgentToolDefinitions(opts?: { emailEnabled?: boolean; capab
// Permission-gated tools are only offered to the model when the user granted them.
if (opts?.emailEnabled) enabled.add("find_confirmation_link");

const toolMap = new Map<string, { name: string; description: string; parameters: Record<string, { type: string; description: string; required?: boolean }> }>();
// Two def shapes are merged: the legacy AGENT_TOOLS/STORAGE_TOOLS carry an ad-hoc
// `parameters` map (rebuilt into a JSON Schema below); registry tools already carry a
// draft-07 `jsonSchema` and are passed through verbatim. Both yield the same
// {type,properties,required} object the LLM has always seen — no behaviour change.
type LegacyDef = { name: string; description: string; parameters: Record<string, { type: string; description: string; required?: boolean }> };
type SchemaDef = { name: string; description: string; jsonSchema: JsonSchema };
const toolMap = new Map<string, LegacyDef | SchemaDef>();
for (const t of [...AGENT_TOOLS, ...STORAGE_TOOLS, ...registryToolDefs()]) {
if (enabled.has(t.name)) toolMap.set(t.name, t);
}
Expand All @@ -135,18 +141,21 @@ export function buildAgentToolDefinitions(opts?: { emailEnabled?: boolean; capab
function: {
name: t.name,
description: t.description,
parameters: {
type: "object",
properties: Object.fromEntries(
Object.entries(t.parameters).map(([k, v]) => [
k,
{ type: v.type, description: v.description },
]),
),
required: Object.entries(t.parameters)
.filter(([, v]) => v.required)
.map(([k]) => k),
},
parameters:
"jsonSchema" in t
? t.jsonSchema
: {
type: "object",
properties: Object.fromEntries(
Object.entries(t.parameters).map(([k, v]) => [
k,
{ type: v.type, description: v.description },
]),
),
required: Object.entries(t.parameters)
.filter(([, v]) => v.required)
.map(([k]) => k),
},
},
}));
}
Expand Down
9 changes: 7 additions & 2 deletions workers/api/src/lib/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import type { Env, SessionPayload } from "../types.js";
import { agentCapabilities } from "./agent-capabilities.js";
import { registryTools } from "./tool-registry.js";

// toolName → { connector, scope } from the registry (built once).
const TOOL_META = new Map(registryTools().map((t) => [t.name, { connector: t.connector, scope: t.scope }] as const));
// toolName → { connector, scope } for connector-provided registry tools (built once).
// Only tools that declare a connector are relevant to the connector admin views.
const TOOL_META = new Map(
registryTools()
.filter((t): t is typeof t & { connector: string; scope: "read" | "write" } => !!t.connector && !!t.scope)
.map((t) => [t.name, { connector: t.connector, scope: t.scope }] as const),
);

/** Distinct connector names an agent uses, derived from its declared capability tools. */
function connectorsForTools(tools?: string[]): string[] {
Expand Down
54 changes: 37 additions & 17 deletions workers/api/src/lib/connectors/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// installation token (`installationTokenForOwner`), so access is naturally scoped to
// the repos the owner's installation covers. Writes (create issue/PR, trigger) come
// later behind consent (#90). These replace Coder's hard-wired GH logic over time.
import type { RegistryTool, RegistryToolCtx } from "../tool-registry.js";
import type { ToolDef, RegistryToolCtx } from "../tool-registry.js";
import { githubAppConfigured, installationTokenForOwner } from "../github-app.js";
import { listIssues, readIssue } from "../github-issues.js";

Expand All @@ -29,15 +29,20 @@ async function resolveRepo(ctx: RegistryToolCtx, repo: string): Promise<{ token:
return { token };
}

export const GITHUB_TOOLS: RegistryTool[] = [
export const GITHUB_TOOLS: ToolDef[] = [
{
name: "github_workflow_runs",
tier: "connector",
connector: "github",
scope: "read",
description: "List recent GitHub Actions workflow runs for a repo (status, conclusion, branch, url) — check CI / deploy status.",
parameters: {
repo: { type: "string", description: 'The repository, "owner/name".', required: true },
per_page: { type: "number", description: "How many recent runs to return (default 5, max 20)." },
jsonSchema: {
type: "object",
properties: {
repo: { type: "string", description: 'The repository, "owner/name".' },
per_page: { type: "number", description: "How many recent runs to return (default 5, max 20)." },
},
required: ["repo"],
},
handler: async (ctx, input) => {
const repo = String(input.repo || "");
Expand All @@ -62,13 +67,18 @@ export const GITHUB_TOOLS: RegistryTool[] = [
},
{
name: "github_list_issues",
tier: "connector",
connector: "github",
scope: "read",
description: "List issues for a repo (excludes pull requests). Filter by state and labels.",
parameters: {
repo: { type: "string", description: 'The repository, "owner/name".', required: true },
state: { type: "string", description: '"open" | "closed" | "all" (default open).' },
labels: { type: "string", description: "Comma-separated label filter." },
jsonSchema: {
type: "object",
properties: {
repo: { type: "string", description: 'The repository, "owner/name".' },
state: { type: "string", description: '"open" | "closed" | "all" (default open).' },
labels: { type: "string", description: "Comma-separated label filter." },
},
required: ["repo"],
},
handler: async (ctx, input) => {
const repo = String(input.repo || "");
Expand All @@ -81,12 +91,17 @@ export const GITHUB_TOOLS: RegistryTool[] = [
},
{
name: "github_read_issue",
tier: "connector",
connector: "github",
scope: "read",
description: "Read one issue (title, body, labels, state) by number.",
parameters: {
repo: { type: "string", description: 'The repository, "owner/name".', required: true },
number: { type: "number", description: "The issue number.", required: true },
jsonSchema: {
type: "object",
properties: {
repo: { type: "string", description: 'The repository, "owner/name".' },
number: { type: "number", description: "The issue number." },
},
required: ["repo", "number"],
},
handler: async (ctx, input) => {
const repo = String(input.repo || "");
Expand All @@ -100,14 +115,19 @@ export const GITHUB_TOOLS: RegistryTool[] = [
},
{
name: "github_create_issue",
tier: "connector",
connector: "github",
scope: "write",
description: "Open a new GitHub issue in a repo. WRITE — requires the GitHub connector's write consent for this instance.",
parameters: {
repo: { type: "string", description: 'The repository, "owner/name".', required: true },
title: { type: "string", description: "Issue title.", required: true },
body: { type: "string", description: "Issue body (markdown)." },
labels: { type: "string", description: "Comma-separated labels to apply." },
jsonSchema: {
type: "object",
properties: {
repo: { type: "string", description: 'The repository, "owner/name".' },
title: { type: "string", description: "Issue title." },
body: { type: "string", description: "Issue body (markdown)." },
labels: { type: "string", description: "Comma-separated labels to apply." },
},
required: ["repo", "title"],
},
handler: async (ctx, input) => {
const repo = String(input.repo || "");
Expand Down
32 changes: 21 additions & 11 deletions workers/api/src/lib/connectors/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
// template, not free text. `text` works only inside an open window.
// - Instagram: business/creator account linked to a FB Page; messaging windows apply;
// no cold-DM.
import type { RegistryTool, RegistryToolCtx } from "../tool-registry.js";
import type { ToolDef, RegistryToolCtx } from "../tool-registry.js";

const GRAPH = "https://graph.facebook.com/v20.0";

Expand All @@ -29,19 +29,24 @@ async function graphPost(token: string, path: string, body: unknown): Promise<{
return { ok: true, data };
}

export const META_TOOLS: RegistryTool[] = [
export const META_TOOLS: ToolDef[] = [
{
name: "whatsapp_send_message",
tier: "connector",
connector: "meta",
scope: "write",
description:
"Send a WhatsApp message via the WhatsApp Business Cloud API. Use `text` only inside a 24h reply window; otherwise supply `template_name` (a pre-approved template) with `template_lang` and optional `template_params`. WRITE — requires the meta connector's write consent.",
parameters: {
to: { type: "string", description: "Recipient phone in E.164, e.g. +14155552671.", required: true },
text: { type: "string", description: "Message text (valid only inside an open 24h window)." },
template_name: { type: "string", description: "Approved template name (required outside the 24h window)." },
template_lang: { type: "string", description: "Template language code, e.g. en_US (default en_US)." },
template_params: { type: "string", description: "Comma-separated body variables for the template, in order." },
jsonSchema: {
type: "object",
properties: {
to: { type: "string", description: "Recipient phone in E.164, e.g. +14155552671." },
text: { type: "string", description: "Message text (valid only inside an open 24h window)." },
template_name: { type: "string", description: "Approved template name (required outside the 24h window)." },
template_lang: { type: "string", description: "Template language code, e.g. en_US (default en_US)." },
template_params: { type: "string", description: "Comma-separated body variables for the template, in order." },
},
required: ["to"],
},
handler: async (ctx, input) => {
const token = metaToken(ctx);
Expand Down Expand Up @@ -74,13 +79,18 @@ export const META_TOOLS: RegistryTool[] = [
},
{
name: "instagram_send_dm",
tier: "connector",
connector: "meta",
scope: "write",
description:
"Send/reply to an Instagram Direct Message from a connected Instagram Business account (Instagram Messaging API). Messaging-window rules apply — you can reply to people who messaged you; no cold-DM. WRITE — requires the meta connector's write consent.",
parameters: {
recipient_id: { type: "string", description: "The recipient's Instagram-scoped ID (IGSID) from an incoming message.", required: true },
text: { type: "string", description: "The message text.", required: true },
jsonSchema: {
type: "object",
properties: {
recipient_id: { type: "string", description: "The recipient's Instagram-scoped ID (IGSID) from an incoming message." },
text: { type: "string", description: "The message text." },
},
required: ["recipient_id", "text"],
},
handler: async (ctx, input) => {
const token = metaToken(ctx);
Expand Down
64 changes: 45 additions & 19 deletions workers/api/src/lib/connectors/tmux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// them unless the instance has "tmux" write-consent (instance_connector_consent, 0051).
// This is the terminal surface any permitted agent can use to drive shells, git, and
// even other CLIs (Claude/Codex) running in the user's own tmux sessions.
import type { RegistryTool, RegistryToolCtx } from "../tool-registry.js";
import type { ToolDef, RegistryToolCtx } from "../tool-registry.js";
import { callRunner, getBoundRunnerConn, READ_TIMEOUT_MS, type RunnerConn } from "../runner-client.js";

/** Resolve the live runner for this instance, or a helpful error string. */
Expand All @@ -26,14 +26,15 @@ function requireSession(input: Record<string, unknown>): string {
return s;
}

export const TMUX_TOOLS: RegistryTool[] = [
export const TMUX_TOOLS: ToolDef[] = [
{
name: "tmux_list_sessions",
tier: "connector",
connector: "tmux",
scope: "read",
description:
"List every live tmux session on the connected machine (name, window count, whether it's attached, and the command running in its active pane). Use this first to discover which session to read or drive.",
parameters: {},
jsonSchema: { type: "object", properties: {} },
handler: async (ctx) => {
const r = await resolveRunner(ctx);
if ("error" in r) return { content: r.error, success: false };
Expand All @@ -43,13 +44,18 @@ export const TMUX_TOOLS: RegistryTool[] = [
},
{
name: "tmux_capture_pane",
tier: "connector",
connector: "tmux",
scope: "read",
description:
"Read the current output of a tmux session's active pane (ANSI-stripped, with scrollback). Use this to see what a shell, build, server, or CLI is showing right now.",
parameters: {
session: { type: "string", description: "The tmux session name (from tmux_list_sessions).", required: true },
lines: { type: "number", description: "How many lines of scrollback to include (default 200, max 2000)." },
jsonSchema: {
type: "object",
properties: {
session: { type: "string", description: "The tmux session name (from tmux_list_sessions)." },
lines: { type: "number", description: "How many lines of scrollback to include (default 200, max 2000)." },
},
required: ["session"],
},
handler: async (ctx, input) => {
const r = await resolveRunner(ctx);
Expand All @@ -66,13 +72,18 @@ export const TMUX_TOOLS: RegistryTool[] = [
},
{
name: "tmux_run_command",
tier: "connector",
connector: "tmux",
scope: "write",
description:
"Type a command line into a tmux session's active pane and press Enter — for shell commands, git, build/test runs, etc. WRITE: runs on the user's machine; requires the tmux connector's write consent. Returns the pane right after sending; capture again after a moment to read the result.",
parameters: {
session: { type: "string", description: "The tmux session name to run the command in.", required: true },
command: { type: "string", description: "The command line to type and execute (sent literally, then Enter).", required: true },
jsonSchema: {
type: "object",
properties: {
session: { type: "string", description: "The tmux session name to run the command in." },
command: { type: "string", description: "The command line to type and execute (sent literally, then Enter)." },
},
required: ["session", "command"],
},
handler: async (ctx, input) => {
const r = await resolveRunner(ctx);
Expand All @@ -86,14 +97,19 @@ export const TMUX_TOOLS: RegistryTool[] = [
},
{
name: "tmux_send_keys",
tier: "connector",
connector: "tmux",
scope: "write",
description:
"Send literal text and/or named keys to a tmux session's active pane WITHOUT auto-pressing Enter — for answering a running CLI's prompt (e.g. an interactive Claude/Codex session), pressing Escape/Enter, or sending Ctrl keys. WRITE: requires the tmux connector's write consent. Keys use tmux names like \"Enter\", \"Escape\", \"C-c\", \"Up\".",
parameters: {
session: { type: "string", description: "The tmux session name.", required: true },
text: { type: "string", description: "Literal text to type (optional)." },
keys: { type: "string", description: "Comma-separated named keys sent after the text, e.g. \"Enter\" or \"C-c\" (optional)." },
jsonSchema: {
type: "object",
properties: {
session: { type: "string", description: "The tmux session name." },
text: { type: "string", description: "Literal text to type (optional)." },
keys: { type: "string", description: "Comma-separated named keys sent after the text, e.g. \"Enter\" or \"C-c\" (optional)." },
},
required: ["session"],
},
handler: async (ctx, input) => {
const r = await resolveRunner(ctx);
Expand All @@ -108,14 +124,19 @@ export const TMUX_TOOLS: RegistryTool[] = [
},
{
name: "tmux_new_session",
tier: "connector",
connector: "tmux",
scope: "write",
description:
"Create a new detached tmux session (optionally running a command in a working directory). WRITE: requires the tmux connector's write consent. No-op if a session with that name already exists.",
parameters: {
session: { type: "string", description: "Name for the new session.", required: true },
workDir: { type: "string", description: "Working directory to start in (default home; ~ is expanded)." },
command: { type: "string", description: "Optional command to run on start (e.g. \"claude\")." },
jsonSchema: {
type: "object",
properties: {
session: { type: "string", description: "Name for the new session." },
workDir: { type: "string", description: "Working directory to start in (default home; ~ is expanded)." },
command: { type: "string", description: "Optional command to run on start (e.g. \"claude\")." },
},
required: ["session"],
},
handler: async (ctx, input) => {
const r = await resolveRunner(ctx);
Expand All @@ -132,12 +153,17 @@ export const TMUX_TOOLS: RegistryTool[] = [
},
{
name: "tmux_kill_session",
tier: "connector",
connector: "tmux",
scope: "write",
description:
"Kill a tmux session by name. WRITE: requires the tmux connector's write consent. Destroys whatever is running in it — use with care.",
parameters: {
session: { type: "string", description: "The tmux session name to kill.", required: true },
jsonSchema: {
type: "object",
properties: {
session: { type: "string", description: "The tmux session name to kill." },
},
required: ["session"],
},
handler: async (ctx, input) => {
const r = await resolveRunner(ctx);
Expand Down
Loading
Loading