import "server-only";
import { agentCommitter } from "../actor";
import { createBranch } from "../branches";
import { createFork, syncFork } from "../forks";
import { agentPushHint, AGENT_BRANCH_PREFIX } from "../branch-rules";
import { dispatchWorkflow, readWorkflows, rerunWorkflow } from "../ci";
import { encodePath } from "../format";
import { defaultBranch, diffCommit, getCommit, isBranch, lastCommit, listBranches, listFilesWithSizes, listTree, log, objectType, readBlob, revParse } from "../git";
import { cleanRepoPath, commitFiles, isValidBranchName, type FileChange } from "../git-write";
import { isClosedStatus, LABEL_COLORS, STATUS_IDS, type IssuePatch } from "../issue-meta";
import {
addComment,
createIssue,
createLabel,
getIssue,
listComments,
listIssues,
listLabels,
listMembers,
MAX_ISSUE_BODY,
openIssueCount,
updateIssue,
validateIssuePatch,
} from "../issues";
import { logTail } from "../logs";
import { assertCanCommit, listBranchRules } from "../protection";
import { countOpenPulls, headLabel, pullsLinkingIssue } from "../pulls";
import { signUpload, UPLOAD_URL_MINUTES } from "../release-upload";
import {
cleanAssetName,
createRelease,
deleteAsset,
deleteRelease,
findRelease,
listReleases,
releaseTitle,
storeAsset,
tagCommit,
updateRelease,
type Release,
type ReleaseInput,
} from "../releases";
import { cloneUrls } from "../repo";
import { canWrite, countForks, createRepo, listReposByOwner, listReposForUser, markPushed, repoAccess, searchRepos, updateRepo, type RepoRecord } from "../repos";
import { cancelRun, getRun, isFinished, listJobs, listRuns, listSteps, readJobLogs, type Run } from "../runs";
import { getUserByUsername } from "../users";
import { contentPages, findPage, listPages, pageSlug, readPage, savePage, wikiHead } from "../wiki";
import { pageHref } from "../wiki-links";
import { WORKFLOW_DIR } from "../workflows";
import { agentTools } from "./agent-tools";
import { codeSearchTools, editTools } from "./code-tools";
import {
actorOf,
afterPush,
covered,
iso,
MAX_FILE_BYTES,
MAX_OUTPUT,
openRepo,
owner,
ref,
renderDiff,
repo,
repoPath,
repoSummary,
resolveRef,
issueSummary,
tool,
truncate,
type Need,
type Tool,
type ToolContext,
} from "./kit";
import { array, boolean, integer, object, oneOf, string, ToolError, type Args } from "./params";
import { pullTools } from "./pull-tools";
export type { Tool, ToolContext };
// ---------------------------------------------------------------------------
// Account and repositories
/** Files that tell agents how to work in a repository, at the root of a branch. */
const INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md"];
const MAX_INSTRUCTIONS = 20_000;
async function agentInstructions(repo: RepoRecord, branch: string) {
const out: { path: string; content: string }[] = [];
for (const path of INSTRUCTION_FILES) {
if ((await objectType(repo.dir, `refs/heads/${branch}`, path)) !== "blob") continue;
const blob = await readBlob(repo.dir, `refs/heads/${branch}`, path);
if (blob.tooLarge || blob.binary) continue;
const text = blob.content.toString("utf8");
out.push({ path, content: text.length > MAX_INSTRUCTIONS ? `${text.slice(0, MAX_INSTRUCTIONS)}\n[Truncated; read the rest with read_file.]` : text });
}
return out;
}
const accountTools = [
tool({
name: "get_me",
title: "Who am I",
description: "The signed-in user, and what this connection may reach.",
access: "read",
params: {},
run: async (_args, ctx) => ({
username: ctx.user.username,
name: ctx.user.name || null,
email: ctx.user.email,
profile_url: `${ctx.origin}/${ctx.user.username}`,
// What you change is recorded as this agent's, done for the user above.
agent: ctx.agent.name,
connection: {
permission: ctx.scope.permission === "write" ? "read & write" : "read-only",
repositories: ctx.scope.allRepos ? "all" : ctx.scope.repos.map((r) => r.fullName),
},
}),
}),
tool({
name: "list_repositories",
title: "List repositories",
description:
"Repositories you own or collaborate on, most recently active first. With owner, that user's repositories that you can see instead.",
access: "read",
params: { owner: string("Only this user's repositories.", { max: 39 }).optional() },
run: async ({ owner }, ctx) => {
let repos: RepoRecord[];
if (owner) {
const user = await getUserByUsername(owner);
if (!user) throw new ToolError(`There's no user named ${owner}.`);
repos = await listReposByOwner(user.id, ctx.user.id);
} else {
repos = await listReposForUser(ctx.user.id);
}
return Promise.all(
covered(ctx, repos).map(async (r) => ({ ...repoSummary(ctx, r), your_access: await repoAccess(r, ctx.user.id) })),
);
},
}),
tool({
name: "search_repositories",
title: "Search repositories",
description: "Repositories you can see whose name, description or owner contains the query.",
access: "read",
params: {
query: string("Text to look for.", { min: 1, max: 100 }),
limit: integer("At most this many results.", { min: 1, max: 50 }).default(20),
},
run: async ({ query, limit }, ctx) => covered(ctx, await searchRepos(query, ctx.user.id, limit)).map((r) => repoSummary(ctx, r)),
}),
tool({
name: "get_repository",
title: "Get a repository",
description: [
"A repository's details: default branch, branches, open issues and pull requests, clone URLs, your access level,",
"where you (an agent) may push, its protected branches, and the instructions it has for agents (AGENTS.md or CLAUDE.md). Read those first.",
].join(" "),
access: "read",
params: { owner, repo },
run: async (args, ctx) => {
const { repo, access } = await openRepo(ctx, args.owner, args.repo, "read");
const [branches, def, issues, pulls, clone, rules, forks] = await Promise.all([
listBranches(repo.dir),
defaultBranch(repo.dir),
openIssueCount(repo.id),
countOpenPulls(repo.id),
cloneUrls(repo),
listBranchRules(repo.id),
countForks(repo.id, ctx.user.id),
]);
return {
...repoSummary(ctx, repo),
default_branch: def,
branches: branches.slice(0, 100).map((b) => b.name),
open_issues: issues,
open_pull_requests: pulls,
clone_urls: clone,
your_access: access,
...(access === "read" ? { to_propose_changes: "You can't push here: fork_repository, push to the fork, then create_pull_request with head_repo." } : {}),
forks,
empty: branches.length === 0,
agent_push: {
policy: repo.agentPush,
rule: repo.agentPush === "any" ? "You may push wherever your user may (the default branch included), under the branch rules. What you push is labeled as yours and your user can revert it." : agentPushHint(repo.agentPush),
suggested_branch_prefix: AGENT_BRANCH_PREFIX,
},
protected_branches: rules.map((r) => ({
pattern: r.pattern,
requires_pull_request: r.requirePr,
required_approvals: r.requiredApprovals,
requires_checks: r.requireCi,
agents_may_merge: r.agentsMayMerge,
})),
agent_instructions: def ? await agentInstructions(repo, def) : [],
};
},
}),
tool({
name: "create_repository",
title: "Create a repository",
description: "Creates a repository owned by you. With readme (the default), it starts with a README.md commit on main.",
access: "write",
params: {
name: string("Letters, digits, '.', '-' and '_'.", { min: 1, max: 100 }),
description: string("A short description.", { max: 350 }).default(""),
visibility: oneOf("Who can see it.", ["public", "private"] as const).default("private"),
readme: boolean("Start with a README.md.").default(true),
},
run: async ({ name, description, visibility, readme }, ctx) => {
if (!ctx.scope.allRepos) throw new ToolError("This connection is limited to selected repositories, so it can't create new ones.");
const created = await createRepo(ctx.user, { name, description, visibility, readme }, agentCommitter(ctx.agent));
return { ...repoSummary(ctx, created), clone_urls: await cloneUrls(created) };
},
}),
tool({
name: "fork_repository",
title: "Fork a repository",
description: [
"Makes a copy of a repository that you own and can push to, linked to the original: the way to propose changes to a repository you can't push to.",
"Push your changes to the fork, then open a pull request on the original with create_pull_request and head_repo set to the fork.",
"By default only the default branch is copied. Forks of private repositories stay private.",
].join(" "),
access: "write",
params: {
owner,
repo,
name: string("The fork's name. Defaults to the original's.", { min: 1, max: 100 }).optional(),
all_branches: boolean("Copy every branch, not just the default one.").default(false),
},
run: async (args, ctx) => {
if (!ctx.scope.allRepos) throw new ToolError("This connection is limited to selected repositories, so it can't create new ones.");
const { repo } = await openRepo(ctx, args.owner, args.repo, "read");
const fork = await createFork(ctx.user, repo, { name: args.name ?? repo.name, description: repo.description, defaultBranchOnly: !args.all_branches }, actorOf(ctx));
return { ...repoSummary(ctx, fork), default_branch: await defaultBranch(fork.dir), clone_urls: await cloneUrls(fork) };
},
}),
tool({
name: "sync_fork",
title: "Sync a fork",
description:
"Brings a fork's default branch up to date with the default branch of the repository it was forked from: a fast-forward, or a merge commit if the fork has commits of its own. Fails on conflicts.",
access: "write",
params: { owner, repo },
run: async (args, ctx) => {
const { repo, full } = await openRepo(ctx, args.owner, args.repo, "write");
if (!repo.forkOf) throw new ToolError(`${full} isn't a fork.`);
const result = await syncFork(repo, actorOf(ctx));
return { updated: result.updated, ...(result.sha ? { sha: result.sha } : { note: "Already up to date." }) };
},
}),
tool({
name: "update_repository",
title: "Update a repository",
description: "Changes a repository's description or visibility. Only its owner can.",
access: "write",
idempotent: true,
params: {
owner,
repo,
description: string("The new description.", { max: 350 }).optional(),
visibility: oneOf("Who can see it.", ["public", "private"] as const).optional(),
},
run: async (args, ctx) => {
const { repo } = await openRepo(ctx, args.owner, args.repo, "admin");
await updateRepo(repo.id, { description: args.description ?? repo.description, visibility: args.visibility ?? repo.visibility });
return repoSummary(ctx, { ...repo, description: args.description ?? repo.description, visibility: args.visibility ?? repo.visibility });
},
}),
];
// ---------------------------------------------------------------------------
// Code
const codeTools = [
tool({
name: "list_branches",
title: "List branches",
description: "A repository's branches, most recently committed to first, with their latest commit.",
access: "read",
params: { owner, repo },
run: async (args, ctx) => {
const { repo } = await openRepo(ctx, args.owner, args.repo, "read");
const def = await defaultBranch(repo.dir);
return (await listBranches(repo.dir)).map((b) => ({ name: b.name, default: b.name === def, sha: b.sha, date: iso(b.date), subject: b.subject }));
},
}),
tool({
name: "create_branch",
title: "Create a branch",
description: `Creates a branch at a commit (by default, the head of the default branch). commit_files can also create a branch as it commits. Agents name theirs ${AGENT_BRANCH_PREFIX}<topic>.`,
access: "write",
params: {
owner,
repo,
branch: string("The new branch's name.", { min: 1, max: 255 }),
from: string("Branch, tag or commit to start from. Defaults to the default branch.", { max: 255 }).optional(),
},
run: async (args, ctx) => {
const { repo, url } = await openRepo(ctx, args.owner, args.repo, "write");
const sha = await createBranch(repo, actorOf(ctx), args.branch, args.from);
return { branch: args.branch, sha, url: `${url}/tree/${encodeURIComponent(args.branch)}` };
},
}),
tool({
name: "list_files",
title: "List files",
description:
"The files and directories at a path (the root by default). With recursive, every file under the path, with sizes in bytes.",
access: "read",
params: {
owner,
repo,
path: string("A directory in the repository. Defaults to the root.", { max: 1024 }).optional(),
ref,
recursive: boolean("List every file below the path.").default(false),
},
run: async (args, ctx) => {
const { repo } = await openRepo(ctx, args.owner, args.repo, "read");
const { ref, sha } = await resolveRef(repo.dir, args.ref);
const path = repoPath(args.path);
const type = await objectType(repo.dir, sha, path);
if (type !== "tree") throw new ToolError(type ? `${path} is a file; read it with read_file.` : `There's no ${path} at ${ref}.`);
if (args.recursive) {
const prefix = path ? `${path}/` : "";
const files = (await listFilesWithSizes(repo.dir, sha)).filter((f) => f.path.startsWith(prefix));
const shown = files.slice(0, 5000);
return {
ref,
commit: sha,
path,
files: shown,
...(files.length > shown.length ? { truncated: `Only the first ${shown.length} of ${files.length} files. List a subdirectory.` } : {}),
};
}
const kinds = { blob: "file", tree: "dir", commit: "submodule" } as const;
const entries = (await listTree(repo.dir, sha, path)).map((e) => ({ path: e.path, type: kinds[e.type], ...(e.size !== null ? { size: e.size } : {}) }));
return { ref, commit: sha, path, entries };
},
}),
tool({
name: "read_file",
title: "Read a file",
description: "A text file's content. Long files are cut off; read them in parts with start_line and end_line (1-based, inclusive).",
access: "read",
params: {
owner,
repo,
path: string("The file's path in the repository.", { min: 1, max: 1024 }),
ref,
start_line: integer("First line to return.", { min: 1 }).optional(),
end_line: integer("Last line to return.", { min: 1 }).optional(),
},
run: async (args, ctx) => {
const { repo } = await openRepo(ctx, args.owner, args.repo, "read");
const { ref, sha } = await resolveRef(repo.dir, args.ref);
const path = repoPath(args.path);
const type = await objectType(repo.dir, sha, path);
if (type !== "blob") throw new ToolError(type === "tree" ? `${path} is a directory; list it with list_files.` : `There's no file ${path} at ${ref}.`);
const blob = await readBlob(repo.dir, sha, path);
if (blob.tooLarge) throw new ToolError(`${path} is ${blob.size} bytes, too large to read here. Clone the repository instead.`);
if (blob.binary) return `${path} is a binary file (${blob.size} bytes).`;
const text = blob.content.toString("utf8");
if (args.start_line === undefined && args.end_line === undefined) {
return truncate(text, "Read the rest with start_line and end_line.");
}
const lines = text.split("\n");
const start = args.start_line ?? 1;
const end = Math.min(args.end_line ?? lines.length, lines.length);
if (start > lines.length) throw new ToolError(`${path} has only ${lines.length} lines.`);
return truncate(lines.slice(start - 1, end).join("\n"), "Ask for fewer lines.");
},
}),
tool({
name: "commit_files",
title: "Commit files",
description: [
"Writes and deletes files in one commit on a branch, authored by your user and committed by you (the agent), and starts the workflows the push triggers.",
"If the branch doesn't exist, it's created from base (or the default branch). Commit to the default branch for small, safe changes if the repository lets you",
`(get_repository says where you may push), or to a branch like ${AGENT_BRANCH_PREFIX}<topic> with a pull request for anything that wants review;`,
"protected branches only change through pull requests. Pass expected_head (the commit you read the files at) to refuse the commit if the branch has moved since.",
"To change parts of files, edit_files and apply_patch are cheaper than sending whole files."
].join(" "),
access: "write",
params: {
owner,
repo,
branch: string("The branch to commit to.", { min: 1, max: 255 }),
message: string("The commit message.", { min: 1, max: 10_000 }),
files: array(
"Files to create or overwrite, with their whole new content.",
object("A file.", {
path: string("Path in the repository.", { min: 1, max: 1024 }),
content: string("The full content.", { max: 15 * 1024 * 1024 }),
encoding: oneOf("How content is encoded.", ["utf8", "base64"] as const).default("utf8"),
}),
{ max: 500 },
).default([]),
delete_paths: array("Files to delete.", string("Path in the repository.", { min: 1, max: 1024 }), { max: 500 }).default([]),
base: string("Where a new branch starts: a branch, tag or commit. Ignored if the branch exists.", { max: 255 }).optional(),
expected_head: string("The commit SHA the branch must still be at.", { max: 64 }).optional(),
},
run: async (args, ctx) => {
const { repo, url } = await openRepo(ctx, args.owner, args.repo, "write");
if (args.files.length + args.delete_paths.length === 0) throw new ToolError("Give at least one file to write or delete.");
const head = await revParse(repo.dir, `refs/heads/${args.branch}`);
let parent: string | null;
if (head) {
if (args.expected_head && (args.expected_head.length < 7 || !head.startsWith(args.expected_head.toLowerCase()))) {
throw new ToolError(`${args.branch} is at ${head} now, not ${args.expected_head}. Read the files again and retry.`);
}
parent = head;
} else {
if (!(await isValidBranchName(repo.dir, args.branch))) throw new ToolError(`${args.branch} isn't a valid branch name.`);
// A new branch starts from base, or the default branch; in an empty repository, from nothing.
const empty = args.base === undefined && !(await defaultBranch(repo.dir));
parent = empty ? null : (await resolveRef(repo.dir, args.base)).sha;
}
await assertCanCommit(repo, actorOf(ctx), args.branch, head);
const seen = new Set<string>();
const claim = (input: string) => {
const path = cleanRepoPath(input);
if (!path) throw new ToolError(`${input} isn't a valid path.`);
if (seen.has(path)) throw new ToolError(`${path} appears more than once.`);
seen.add(path);
return path;
};
const changes: FileChange[] = [];
for (const input of args.delete_paths) {
const path = claim(input);
const type = parent ? await objectType(repo.dir, parent, path) : null;
if (type === "tree") throw new ToolError(`${path} is a directory. List its files and delete those.`);
if (!type) throw new ToolError(`There's no file ${path} to delete.`);
changes.push({ path, delete: true });
}
for (const file of args.files) {
const content = Buffer.from(file.content, file.encoding);
if (content.length > MAX_FILE_BYTES) throw new ToolError(`${file.path} is larger than 10 MB.`);
changes.push({ path: claim(file.path), content });
}
const commit = await commitFiles(repo.dir, {
branch: args.branch,
parent,
expectedHead: head,
changes,
message: `${args.message.trim()}\n`,
author: { name: ctx.user.name || ctx.user.username, email: ctx.user.email },
committer: agentCommitter(ctx.agent),
});
await markPushed(repo.id);
afterPush(repo, ctx, `refs/heads/${args.branch}`, head, commit);
return { commit, branch: args.branch, created_branch: !head, url: `${url}/commit/${commit}` };
},
}),
tool({
name: "list_commits",
title: "List commits",
description: "Commits reachable from a ref, newest first, optionally only those touching a path.",
access: "read",
params: {
owner,
repo,
ref,
path: string("Only commits that change this file or directory.", { max: 1024 }).optional(),
limit: integer("At most this many commits.", { min: 1, max: 100 }).default(20),
skip: integer("Skip this many first (for paging).", { min: 0 }).default(0),
},
run: async (args, ctx) => {
const { repo } = await openRepo(ctx, args.owner, args.repo, "read");
const { sha } = await resolveRef(repo.dir, args.ref);
const commits = await log(repo.dir, sha, { path: repoPath(args.path) || undefined, limit: args.limit, skip: args.skip });
return commits.map((c) => ({ sha: c.sha, author: c.authorName, date: iso(c.date), subject: c.subject }));
},
}),
tool({
name: "get_commit",
title: "Get a commit",
description: "A commit's message, author and parents, and (unless diff is false) its changes as a unified diff.",
access: "read",
params: {
owner,
repo,
sha: string("A commit SHA, or a branch or tag for its latest commit.", { min: 1, max: 255 }),
diff: boolean("Include the changes.").default(true),
},
run: async (args, ctx) => {
const { repo, url } = await openRepo(ctx, args.owner, args.repo, "read");
const { sha } = await resolveRef(repo.dir, args.sha);
const commit = await getCommit(repo.dir, sha);
if (!commit) throw new ToolError(`There's no commit ${args.sha}.`);
const header = [
`commit ${commit.sha}`,
...(commit.parents.length ? [`parents ${commit.parents.join(" ")}`] : []),
`author ${commit.authorName} <${commit.authorEmail}>`,
`date ${iso(commit.date)}`,
`url ${url}/commit/${commit.sha}`,
"",
commit.subject,
...(commit.body ? ["", commit.body] : []),
].join("\n");
if (!args.diff) return header;
return truncate(`${header}\n\n${renderDiff(await diffCommit(repo.dir, commit))}`, "Read the changed files with read_file instead.");
},
}),
];
// ---------------------------------------------------------------------------
// Issues
const issueProps = {
status: oneOf("Workflow status. done and canceled close the issue.", STATUS_IDS).optional(),
priority: integer("0 no priority, 1 urgent, 2 high, 3 medium, 4 low.", { min: 0, max: 4 }).optional(),
assignee: string(
'Who works on it: "me" (you, this agent), the username of the owner or a collaborator, or the name of one of their connected agents. An empty string unassigns.',
{ max: 100 },
).optional(),
labels: array("Label names. Replaces the issue's labels.", string("A label name.", { max: 50 }), { max: 50 }).optional(),
estimate: integer("Points: 1, 2, 3, 5 or 8. 0 removes the estimate.", { min: 0, max: 8 }).optional(),
due_date: string("YYYY-MM-DD. An empty string removes it.", { max: 10 }).optional(),
};
type IssueProps = Partial<Args<typeof issueProps>> & { title?: string; body?: string };
/** Who "me", a username or an agent's name means as an assignee: a person's id or an agent's key. */
async function assigneeId(repo: RepoRecord, ctx: ToolContext, name: string) {
const members = await listMembers(repo.id, repo.ownerId);
if (name.toLowerCase() === "me") {
if (members.some((m) => m.id === ctx.agent.key)) return ctx.agent.key;
throw new ToolError(
"You can't be assigned here: only connected apps (OAuth) of the owner and collaborators with read & write access to this repository can. Assign your user instead.",
);
}
const lower = name.toLowerCase();
const people = members.filter((m) => !m.agentOf && m.username.toLowerCase() === lower);
const agents = members.filter((m) => m.agentOf && m.username.toLowerCase() === lower);
// An agent's name can be shared (two people's "Claude Code"): prefer the one acting for this user.
const match = people[0] ?? (agents.length > 1 ? agents.find((a) => a.agentOf === ctx.user.username) : agents[0]);
if (!match) {
const names = members.map((m) => (m.agentOf ? `${m.username} (agent of ${m.agentOf})` : m.username));
throw new ToolError(`${name} can't be assigned. Who can: ${names.join(", ")}.`);
}
return match.id;
}
/** Turns usernames, agent names and label names into the ids an issue stores, then validates like the web UI. */
async function issuePatch(repo: RepoRecord, ctx: ToolContext, args: IssueProps): Promise<IssuePatch> {
const patch: IssuePatch = { title: args.title, body: args.body, status: args.status, priority: args.priority };
if (args.assignee !== undefined) patch.assigneeId = args.assignee ? await assigneeId(repo, ctx, args.assignee) : null;
if (args.labels !== undefined) {
const all = await listLabels(repo.id);
patch.labelIds = args.labels.map((name) => {
const label = all.find((l) => l.name.toLowerCase() === name.toLowerCase());
if (!label) {
const known = all.length ? `Labels: ${all.map((l) => l.name).join(", ")}.` : "The repository has no labels yet.";
throw new ToolError(`There's no label named ${name}. ${known} create_label adds one.`);
}
return label.id;
});
}
if (args.estimate !== undefined) patch.estimate = args.estimate || null;
if (args.due_date !== undefined) patch.dueDate = args.due_date || null;
for (const key of Object.keys(patch) as (keyof IssuePatch)[]) if (patch[key] === undefined) delete patch[key];
return validateIssuePatch(repo, patch);
}
const number = integer("The issue's number.", { min: 1 });
const issueTools = [
tool({
name: "list_issues",
title: "List issues",
description: "A repository's issues, newest first, without their descriptions.",
access: "read",
params: {
owner,
repo,
state: oneOf("open: not done or canceled.", ["open", "closed", "all"] as const).default("open"),
status: oneOf("Only this status.", STATUS_IDS).optional(),
assignee: string('Only issues assigned to this username or agent name, "me" for ones assigned to you (this agent), or "none" for unassigned ones.', { max: 100 }).optional(),
label: string("Only issues with this label.", { max: 50 }).optional(),
query: string("Only issues whose title contains this.", { max: 200 }).optional(),
limit: integer("At most this many issues.", { min: 1, max: 200 }).default(50),
},
run: async (args, ctx) => {
const { repo } = await openRepo(ctx, args.owner, args.repo, "read");
const lower = (s: string | null | undefined) => (s ?? "").toLowerCase();
const issues = (await listIssues(repo.id)).filter(
(i) =>
(args.state === "all" || (args.state === "closed") === isClosedStatus(i.status)) &&
(!args.status || i.status === args.status) &&
(args.assignee === undefined ||
(lower(args.assignee) === "none" ? !i.assignee : lower(args.assignee) === "me" ? i.assigneeId === ctx.agent.key : lower(i.assignee) === lower(args.assignee))) &&
(!args.label || i.labels.some((l) => lower(l.name) === lower(args.label))) &&
(!args.query || lower(i.title).includes(lower(args.query))),
);
return { total: issues.length, issues: issues.slice(0, args.limit).map((i) => issueSummary(i, ctx)) };
},
}),
tool({
name: "get_issue",
title: "Get an issue",
description: "An issue with its description and comments.",
access: "read",
params: { owner, repo, number },
run: async (args, ctx) => {
const { repo, url } = await openRepo(ctx, args.owner, args.repo, "read");
const issue = await getIssue(repo.id, args.number);
if (!issue) throw new ToolError(`There's no issue #${args.number}.`);
const [comments, pulls] = await Promise.all([listComments(issue.id), pullsLinkingIssue(repo.id, issue.number)]);
return {
...issueSummary(issue, ctx),
state: issue.state,
url: `${url}/issues/${issue.number}`,
body: issue.body,
pull_requests: pulls.map((p) => ({ number: p.number, title: p.title, state: p.state, head: headLabel(p) })),
comment_list: comments.map((c) => ({ author: c.author, ...(c.agent ? { agent: c.agent } : {}), created_at: iso(c.createdAt), body: c.body })),
};
},
}),
tool({
name: "create_issue",
title: "Create an issue",
description: "Opens an issue. Anyone who can see the repository can; setting status, priority, assignee, labels, estimate or due date needs push access.",
access: "write",
params: {
owner,
repo,
title: string("A short summary.", { min: 1, max: 256 }),
body: string("The description, in Markdown.", { max: MAX_ISSUE_BODY }).default(""),
...issueProps,
},
run: async ({ owner, repo: name, title, body, ...properties }, ctx) => {
const { repo, access, url } = await openRepo(ctx, owner, name, "comment");
if (!canWrite(access) && Object.values(properties).some((v) => v !== undefined)) {
throw new ToolError("Only collaborators can set an issue's properties. Open it with just a title and body.");
}
const patch = await issuePatch(repo, ctx, { title, body, ...properties });
const created = await createIssue(repo, actorOf(ctx), { ...patch, title: patch.title!, body: patch.body ?? "" });
return { number: created, url: `${url}/issues/${created}` };
},
}),
tool({
name: "update_issue",
title: "Update an issue",
description: "Changes an issue's title, description or properties. Only the fields you pass change. Needs push access.",
access: "write",
idempotent: true,
params: {
owner,
repo,
number,
title: string("A new title.", { min: 1, max: 256 }).optional(),
body: string("A new description, in Markdown.", { max: MAX_ISSUE_BODY }).optional(),
...issueProps,
},
run: async ({ owner, repo: name, number, ...changes }, ctx) => {
const { repo, url } = await openRepo(ctx, owner, name, "write");
const issue = await getIssue(repo.id, number);
if (!issue) throw new ToolError(`There's no issue #${number}.`);
await updateIssue(repo, issue, await issuePatch(repo, ctx, changes), actorOf(ctx));
const updated = (await getIssue(repo.id, number))!;
return { ...issueSummary(updated, ctx), state: updated.state, url: `${url}/issues/${number}` };
},
}),
tool({
name: "comment_on_issue",
title: "Comment on an issue",
description: "Adds a comment, in Markdown, to an issue.",
access: "write",
params: { owner, repo, number, body: string("The comment, in Markdown.", { min: 1, max: MAX_ISSUE_BODY }) },
run: async (args, ctx) => {
const { repo, url } = await openRepo(ctx, args.owner, args.repo, "comment");
const issue = await getIssue(repo.id, args.number);
if (!issue) throw new ToolError(`There's no issue #${args.number}.`);
await addComment(repo, issue, actorOf(ctx), args.body.trim());
return { url: `${url}/issues/${issue.number}` };
},
}),
tool({
name: "list_labels",
title: "List labels",
description: "The labels issues in a repository can have.",
access: "read",
params: { owner, repo },
run: async (args, ctx) => {
const { repo } = await openRepo(ctx, args.owner, args.repo, "read");
return (await listLabels(repo.id)).map((l) => ({ name: l.name, color: l.color }));
},
}),
tool({
name: "create_label",
title: "Create a label",
description: "Adds a label for the repository's issues. Needs push access.",
access: "write",
params: {
owner,
repo,
name: string("The label's name.", { min: 1, max: 50 }),
color: string("A color like #4cb782. Picked for you if left out.", { max: 7 }).optional(),
},
run: async (args, ctx) => {
const { repo } = await openRepo(ctx, args.owner, args.repo, "write");
const used = new Set((await listLabels(repo.id)).map((l) => l.color));
const color = args.color ?? LABEL_COLORS.find((c) => !used.has(c)) ?? LABEL_COLORS[0];
const label = await createLabel(repo.id, { name: args.name, color });
return { name: label.name, color: label.color };
},
}),
];
// ---------------------------------------------------------------------------
// Actions
/** "ci.yml" means .vectrum/workflows/ci.yml. */
const workflowPath = (input: string) => (input.includes("/") ? input.replace(/^\/+/, "") : `${WORKFLOW_DIR}/${input}`);
const runSummary = (url: string, r: Run) => ({
number: r.number,
workflow: r.workflowName,
workflow_path: r.workflowPath,
event: r.event,
ref: r.ref.replace(/^refs\/(heads|tags)\//, ""),
sha: r.sha,
title: r.title,
status: r.status,
actor: r.actor,
error: r.error,
created_at: iso(r.createdAt),
started_at: iso(r.startedAt),
finished_at: iso(r.finishedAt),
url: `${url}/actions/runs/${r.number}`,
});
async function openRun(ctx: ToolContext, owner: string, name: string, number: number, need: Need) {
const opened = await openRepo(ctx, owner, name, need);
const run = await getRun(opened.repo.id, number);
if (!run) throw new ToolError(`There's no workflow run #${number}.`);
return { ...opened, run };
}
const runNumber = integer("The run's number.", { min: 1 });
const actionTools = [
tool({
name: "list_workflows",
title: "List workflows",
description: `The workflow files (${WORKFLOW_DIR}/*.yml) at a ref, whether they run on push or can be started by hand, and why they don't parse if they don't.`,
access: "read",
params: { owner, repo, ref },
run: async (args, ctx) => {
const { repo } = await openRepo(ctx, args.owner, args.repo, "read");
const { sha } = await resolveRef(repo.dir, args.ref);
return (await readWorkflows(repo.dir, sha)).map((f) =>
"workflow" in f
? { path: f.path, name: f.workflow.name, on_push: !!f.workflow.push, can_run_by_hand: f.workflow.dispatch, jobs: f.workflow.jobs.map((j) => j.key) }
: { path: f.path, name: f.name, error: f.error },
);
},
}),
tool({
name: "list_workflow_runs",
title: "List workflow runs",
description: "A repository's workflow runs, newest first.",
access: "read",
params: {
owner,
repo,
workflow: string("Only runs of this workflow file, e.g. ci.yml.", { max: 1000 }).optional(),
limit: integer("At most this many runs.", { min: 1, max: 100 }).default(20),
},
run: async (args, ctx) => {
const { repo, url } = await openRepo(ctx, args.owner, args.repo, "read");
const runs = await listRuns(repo.id, { workflowPath: args.workflow ? workflowPath(args.workflow) : null, limit: args.limit, offset: 0 });
return runs.map((r) => runSummary(url, r));
},
}),
tool({
name: "get_workflow_run",
title: "Get a workflow run",
description: "A workflow run with its jobs and their steps.",
access: "read",
params: { owner, repo, number: runNumber },
run: async (args, ctx) => {
const { url, run } = await openRun(ctx, args.owner, args.repo, args.number, "read");
const jobs = await listJobs(run.id);
return {
...runSummary(url, run),
jobs: await Promise.all(
jobs.map(async (j) => ({
key: j.key,
name: j.name,
status: j.status,
runs_on: j.runsOn,
needs: j.needs,
runner: j.runnerName,
started_at: iso(j.startedAt),
finished_at: iso(j.finishedAt),
steps: (await listSteps(j.id)).map((s) => ({ name: s.name, status: s.status, exit_code: s.exitCode })),
})),
),
};
},
}),
tool({
name: "get_job_logs",
title: "Get job logs",
description: "The output of a job's steps: the last tail_lines lines of each, without terminal colors.",
access: "read",
params: {
owner,
repo,
number: runNumber,
job: string("The job's key (its id in the workflow file) or name.", { min: 1, max: 1000 }),
tail_lines: integer("Lines to keep from the end of each step.", { min: 1, max: 5000 }).default(200),
},
run: async (args, ctx) => {
const { run } = await openRun(ctx, args.owner, args.repo, args.number, "read");
const jobs = await listJobs(run.id);
const job = jobs.find((j) => j.key === args.job) ?? jobs.find((j) => j.name === args.job);
if (!job) throw new ToolError(`Run #${run.number} has no job ${args.job}. Jobs: ${jobs.map((j) => j.key).join(", ")}.`);
const [logs, steps] = await Promise.all([readJobLogs(job.id), listSteps(job.id)]);
const text = steps
.map((s) => {
const { lines, omitted } = logTail(logs.get(s.index) ?? "", args.tail_lines);
const exit = s.exitCode !== null ? `, exit code ${s.exitCode}` : "";
return [`##[${s.status}${exit}] ${s.name}`, ...(omitted ? [`[${omitted} earlier lines left out]`] : []), ...lines].join("\n");
})
.join("\n\n");
// The end of a log is where failures show, so long logs keep their end.
return text.length <= MAX_OUTPUT ? text : `[Earlier output left out; ask for fewer tail_lines.]\n${text.slice(-MAX_OUTPUT)}`;
},
}),
tool({
name: "run_workflow",
title: "Run a workflow",
description: "Starts a workflow that has on: workflow_dispatch, on a branch (the default branch unless given).",
access: "write",
params: {
owner,
repo,
workflow: string("The workflow file, e.g. ci.yml or .vectrum/workflows/ci.yml.", { min: 1, max: 1000 }),
branch: string("The branch to run on.", { max: 255 }).optional(),
},
run: async (args, ctx) => {
const { repo, url } = await openRepo(ctx, args.owner, args.repo, "write");
const branch = args.branch ?? (await defaultBranch(repo.dir));
if (!branch) throw new ToolError("The repository is empty: it has no commits yet.");
const number = await dispatchWorkflow(repo, workflowPath(args.workflow), branch, actorOf(ctx));
return { number, url: `${url}/actions/runs/${number}` };
},
}),
tool({
name: "cancel_workflow_run",
title: "Cancel a workflow run",
description: "Cancels a run's waiting and queued jobs and asks the runners of running ones to stop.",
access: "write",
destructive: true,
idempotent: true,
params: { owner, repo, number: runNumber },
run: async (args, ctx) => {
const { run } = await openRun(ctx, args.owner, args.repo, args.number, "write");
if (isFinished(run.status)) return `Run #${run.number} already finished (${run.status}).`;
await cancelRun(run.id);
return `Cancelling run #${run.number}.`;
},
}),
tool({
name: "rerun_workflow_run",
title: "Re-run a workflow run",
description: "Runs a run's workflow again, as it was at the run's commit. Returns the new run's number.",
access: "write",
params: { owner, repo, number: runNumber },
run: async (args, ctx) => {
const { repo, url, run } = await openRun(ctx, args.owner, args.repo, args.number, "write");
const number = await rerunWorkflow(repo, run, actorOf(ctx));
return { number, url: `${url}/actions/runs/${number}` };
},
}),
];
// ---------------------------------------------------------------------------
// Releases
async function releaseSummary(url: string, repo: RepoRecord, r: Release) {
return {
tag: r.tag,
name: releaseTitle(r),
draft: r.draft,
prerelease: r.prerelease,
// Until a draft is published, its tag may not exist yet; it's created from target then.
tag_commit: await tagCommit(repo, r.tag),
target: r.target || null,
author: r.author,
created_at: iso(r.createdAt),
published_at: iso(r.publishedAt),
url: `${url}/releases/tag/${encodePath(r.tag)}`,
assets: r.assets.map((a) => ({
name: a.name,
size: a.size,
sha256: a.sha256,
downloads: a.downloads,
url: `${url}/releases/download/${encodePath(r.tag)}/${encodeURIComponent(a.name)}`,
})),
};
}
/** A release by tag. Drafts are only there for people who can edit them, as in the web UI. */
async function openRelease(repo: RepoRecord, access: Awaited<ReturnType<typeof openRepo>>["access"], tag: string) {
const release = await findRelease(repo.id, tag);
if (!release || (release.draft && !canWrite(access))) throw new ToolError(`There's no release for the tag ${tag}.`);
return release;
}
/**
* What a release's tag is created from if it doesn't exist yet. The release code takes a branch
* name or a full commit SHA, so any other ref is resolved to its commit.
*/
async function releaseTarget(repo: RepoRecord, target: string | undefined) {
if (target === undefined) return (await defaultBranch(repo.dir)) ?? "";
if (await isBranch(repo.dir, target)) return target;
return (await resolveRef(repo.dir, target)).sha;
}
/** A tag the release code created on publishing starts workflows like a push of it would. */
async function afterRelease(repo: RepoRecord, ctx: ToolContext, tag: string, createdTag: string | null) {
if (!createdTag) return;
await markPushed(repo.id);
afterPush(repo, ctx, `refs/tags/${tag}`, null, createdTag);
}
const tag = string("The release's tag, e.g. v1.2.0.", { min: 1, max: 255 });
/** Release files sent in a tool call travel through the agent's context, so they stay small. */
const MAX_INLINE_ASSET_MB = 10;
const assetUrl = (url: string, release: Release, name: string) => `${url}/releases/download/${encodePath(release.tag)}/${encodeURIComponent(name)}`;
function assetName(input: string) {
const name = cleanAssetName(input);
if (!name) throw new ToolError(`${input} isn't a valid file name. Use a plain name without slashes.`);
return name;
}
/** Strict base64: Buffer.from would silently skip anything else and store a broken file. */
function decodeBase64(input: string) {
const compact = input.replace(/\s+/g, "");
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(compact) || compact.length % 4 === 1) throw new ToolError("content isn't valid base64.");
return Buffer.from(compact, "base64");
}
const shellQuote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
const releaseTools = [
tool({
name: "list_releases",
title: "List releases",
description: "A repository's releases, newest first, with their files. Drafts are listed (first) if you have push access.",
access: "read",
params: { owner, repo, limit: integer("At most this many releases.", { min: 1, max: 100 }).default(20) },
run: async (args, ctx) => {
const { repo, access, url } = await openRepo(ctx, args.owner, args.repo, "read");
const { releases, total } = await listReleases(repo.id, { drafts: canWrite(access), page: 1, perPage: args.limit });
return { total, releases: await Promise.all(releases.map((r) => releaseSummary(url, repo, r))) };
},
}),
tool({
name: "get_release",
title: "Get a release",
description: "A release with its notes and downloadable files.",
access: "read",
params: { owner, repo, tag },
run: async (args, ctx) => {
const { repo, access, url } = await openRepo(ctx, args.owner, args.repo, "read");
const release = await openRelease(repo, access, args.tag);
return { ...(await releaseSummary(url, repo, release)), body: release.body };
},
}),
tool({
name: "create_release",
title: "Create a release",
description: [
"Publishes a release for a tag, with notes in Markdown. If the tag doesn't exist, it's created at target (by default the",
"default branch) and starts the workflows a push of the tag would. With draft, nothing is published and no tag is created",
"until update_release sets draft to false. Add files with upload_release_asset (small ones) or create_release_upload_url (anything else).",
].join(" "),
access: "write",
params: {
owner,
repo,
tag,
target: string("Branch or commit to create a missing tag from. Defaults to the default branch.", { max: 255 }).optional(),
title: string("The release's name. Defaults to the tag.", { max: 256 }).default(""),
body: string("Release notes, in Markdown.", { max: 65_536 }).default(""),
prerelease: boolean("Mark it as a pre-release, so it isn't the latest release.").default(false),
draft: boolean("Save it as a draft, visible only to people with push access.").default(false),
},
run: async (args, ctx) => {
const { repo, url } = await openRepo(ctx, args.owner, args.repo, "write");
const input: ReleaseInput = {
tag: args.tag.trim(),
target: await releaseTarget(repo, args.target),
title: args.title,
body: args.body,
prerelease: args.prerelease,
draft: args.draft,
};
const { release, createdTag } = await createRelease(repo, ctx.user.id, input);
await afterRelease(repo, ctx, release.tag, createdTag);
return { ...(await releaseSummary(url, repo, release)), created_tag: !!createdTag };
},
}),
tool({
name: "update_release",
title: "Update a release",
description:
"Changes a release's tag, name, notes or pre-release mark. Only the fields you pass change. draft: false publishes a draft (creating its tag if needed); a published release can't go back to being a draft.",
access: "write",
idempotent: true,
params: {
owner,
repo,
tag: string("The release's current tag.", { min: 1, max: 255 }),
new_tag: string("A different tag for the release. Created at target when published, if it doesn't exist.", { min: 1, max: 255 }).optional(),
target: string("Branch or commit to create a missing tag from.", { max: 255 }).optional(),
title: string("The release's name. An empty string shows the tag instead.", { max: 256 }).optional(),
body: string("Release notes, in Markdown. Replaces the old notes.", { max: 65_536 }).optional(),
prerelease: boolean("Mark it as a pre-release.").optional(),
draft: boolean("false publishes a draft.").optional(),
},
run: async (args, ctx) => {
const { repo, access, url } = await openRepo(ctx, args.owner, args.repo, "write");
const release = await openRelease(repo, access, args.tag);
if (args.draft === true && !release.draft) throw new ToolError(`The release for ${release.tag} is published already and can't go back to being a draft.`);
const newTag = args.new_tag?.trim() ?? release.tag;
// Without a new target, the old one stays; one is only made up when the tag still has to be created.
const target =
args.target !== undefined ? await releaseTarget(repo, args.target) : release.target || ((await tagCommit(repo, newTag)) ? "" : await releaseTarget(repo, undefined));
const input: ReleaseInput = {
tag: newTag,
target,
title: args.title ?? release.title,
body: args.body ?? release.body,
prerelease: args.prerelease ?? release.prerelease,
draft: args.draft ?? release.draft,
};
const { release: saved, createdTag } = await updateRelease(repo, release, input);
await afterRelease(repo, ctx, saved.tag, createdTag);
return { ...(await releaseSummary(url, repo, saved)), created_tag: !!createdTag };
},
}),
tool({
name: "delete_release",
title: "Delete a release",
description: "Deletes a release and its uploaded files. The tag itself stays in the repository.",
access: "write",
destructive: true,
params: { owner, repo, tag },
run: async (args, ctx) => {
const { repo, access } = await openRepo(ctx, args.owner, args.repo, "write");
const release = await openRelease(repo, access, args.tag);
await deleteRelease(release);
const files = release.assets.length;
return `Deleted the release for ${release.tag}${files ? ` and its ${files} file${files === 1 ? "" : "s"}` : ""}. The tag ${release.tag} is still there.`;
},
}),
tool({
name: "upload_release_asset",
title: "Upload a release file",
description: [
"Adds a file to a release, with its content in the call: text, or base64 for binary files. A file of the same name is replaced.",
`Only for small files (up to ${MAX_INLINE_ASSET_MB} MB), since the content passes through your context. For build outputs`,
"on disk, use create_release_upload_url and upload with curl instead.",
].join(" "),
access: "write",
idempotent: true,
params: {
owner,
repo,
tag,
name: string("The file name people download it as, e.g. checksums.txt.", { min: 1, max: 255 }),
content: string("The file's content.", { max: Math.ceil((MAX_INLINE_ASSET_MB * 1024 * 1024 * 4) / 3) + 4 }),
encoding: oneOf("How content is encoded.", ["utf8", "base64"] as const).default("utf8"),
},
run: async (args, ctx) => {
const { repo, access, url } = await openRepo(ctx, args.owner, args.repo, "write");
const release = await openRelease(repo, access, args.tag);
const name = assetName(args.name);
const data = args.encoding === "base64" ? decodeBase64(args.content) : Buffer.from(args.content, "utf8");
if (data.length > MAX_INLINE_ASSET_MB * 1024 * 1024) {
throw new ToolError(`That's more than ${MAX_INLINE_ASSET_MB} MB. Use create_release_upload_url and upload it with curl.`);
}
const asset = await storeAsset(release, name, new Response(new Uint8Array(data)).body!, ctx.user.id);
return { name: asset.name, size: asset.size, sha256: asset.sha256, url: assetUrl(url, release, asset.name) };
},
}),
tool({
name: "create_release_upload_url",
title: "Get a release upload URL",
description: [
`A URL to upload one file of a release to with a plain PUT, valid for ${UPLOAD_URL_MINUTES} minutes and needing no credentials:`,
'run `curl -fT <local file> "<upload_url>"` in a shell. The file streams straight to the server, so it can be any size.',
"Uploading again replaces the file.",
].join(" "),
access: "write",
params: { owner, repo, tag, name: string("The file name people download it as, e.g. app-linux-x64.tar.gz.", { min: 1, max: 255 }) },
run: async (args, ctx) => {
const { repo, access, url } = await openRepo(ctx, args.owner, args.repo, "write");
const release = await openRelease(repo, access, args.tag);
const name = assetName(args.name);
const download = assetUrl(url, release, name);
const upload = `${download}?upload=${signUpload({ repoId: repo.id, releaseId: release.id, name, userId: ctx.user.id })}`;
return {
upload_url: upload,
method: "PUT",
expires_at: iso(Date.now() + UPLOAD_URL_MINUTES * 60e3),
command: `curl -fT ${shellQuote(name)} ${shellQuote(upload)}`,
download_url: download,
};
},
}),
tool({
name: "delete_release_asset",
title: "Delete a release file",
description: "Removes a file from a release.",
access: "write",
destructive: true,
idempotent: true,
params: { owner, repo, tag, name: string("The file's name.", { min: 1, max: 255 }) },
run: async (args, ctx) => {
const { repo, access } = await openRepo(ctx, args.owner, args.repo, "write");
const release = await openRelease(repo, access, args.tag);
const asset = release.assets.find((a) => a.name === args.name);
if (!asset) {
const names = release.assets.map((a) => a.name);
throw new ToolError(`The release for ${release.tag} has no file ${args.name}.${names.length ? ` Files: ${names.join(", ")}.` : ""}`);
}
await deleteAsset(release, asset);
return `Deleted ${asset.name} from the release for ${release.tag}.`;
},
}),
];
// ---------------------------------------------------------------------------
// Wiki
/** A repository's wiki, its latest commit and pages; the wiki may not have any yet. */
async function openWiki(ctx: ToolContext, owner: string, name: string, need: Need) {
const opened = await openRepo(ctx, owner, name, need);
if (!opened.repo.wikiEnabled) throw new ToolError(`${opened.full} has its wiki turned off.`);
const { head } = await wikiHead(opened.repo);
const pages = head ? await listPages(opened.repo.wikiDir, head) : [];
return { ...opened, head, pages, wiki: `${opened.url}/wiki` };
}
const pageTitleParam = string("The page's title, e.g. Getting Started (or its name, Getting-Started).", { min: 1, max: 200 });
const wikiTools = [
tool({
name: "list_wiki_pages",
title: "List wiki pages",
description: "A repository's wiki pages, Home first. The wiki is a git repository of its own, cloned as <repo>.wiki.git.",
access: "read",
params: { owner, repo },
run: async (args, ctx) => {
const { repo, pages, wiki } = await openWiki(ctx, args.owner, args.repo, "read");
return {
pages: contentPages(pages).map((p) => ({ title: p.title, path: p.path, url: pageHref(wiki, p.slug) })),
has_sidebar: pages.some((p) => p.slug === "_Sidebar"),
has_footer: pages.some((p) => p.slug === "_Footer"),
clone_url: `${ctx.origin}/${repo.owner}/${repo.name}.wiki.git`,
};
},
}),
tool({
name: "read_wiki_page",
title: "Read a wiki page",
description: "A wiki page's Markdown, with who changed it last. _Sidebar and _Footer are the pages shown around every page.",
access: "read",
params: { owner, repo, title: pageTitleParam.default("Home") },
run: async (args, ctx) => {
const { repo, head, pages, wiki } = await openWiki(ctx, args.owner, args.repo, "read");
const page = findPage(pages, pageSlug(args.title));
if (!head || !page) throw new ToolError(`The wiki has no page ${args.title}.${pages.length ? " List them with list_wiki_pages." : " It has no pages yet."}`);
const [{ text, size }, edit] = await Promise.all([readPage(repo.wikiDir, head, page.path), lastCommit(repo.wikiDir, head, page.path)]);
if (text === null) throw new ToolError(`${page.title} is ${size} bytes, too large to read here. Clone the wiki instead.`);
return {
title: page.title,
path: page.path,
url: pageHref(wiki, page.slug),
last_edited_by: edit?.authorName ?? null,
last_edited_at: iso(edit?.date ?? null),
content: truncate(text, "Clone the wiki to read all of it."),
};
},
}),
tool({
name: "write_wiki_page",
title: "Write a wiki page",
description: [
"Creates a wiki page, or replaces an existing page's Markdown, in one commit to the wiki as you. The first page creates the wiki.",
"Link pages with [[Page Title]] or [[Text|Page Title]]. new_title renames the page.",
].join(" "),
access: "write",
idempotent: true,
params: {
owner,
repo,
title: pageTitleParam,
content: string("The page's Markdown. Replaces what the page had.", { max: 1024 * 1024 }),
new_title: string("A new title for an existing page.", { min: 1, max: 200 }).optional(),
message: string("A message for the page's history. Defaults to Create/Update <title>.", { max: 500 }).optional(),
},
run: async (args, ctx) => {
const { repo, pages, wiki } = await openWiki(ctx, args.owner, args.repo, "write");
const existing = findPage(pages, pageSlug(args.title));
if (args.new_title && !existing) throw new ToolError(`The wiki has no page ${args.title} to rename.`);
const slug = await savePage(
repo,
{ name: ctx.user.name || ctx.user.username, email: ctx.user.email },
{ originalPath: existing?.path ?? null, title: args.new_title ?? existing?.title ?? args.title, content: args.content, message: args.message },
agentCommitter(ctx.agent),
);
return { created: !existing, url: pageHref(wiki, slug) };
},
}),
];
export const TOOLS: Tool[] = [
...agentTools,
...accountTools,
...codeTools,
...codeSearchTools,
...editTools,
...issueTools,
...pullTools,
...releaseTools,
...actionTools,
...wikiTools,
];