import "server-only";
import { EVENT_KINDS, latestSeq, listEvents, waitForEvent, type RepoEvent } from "../events";
import { revParse } from "../git";
import { listIssuesAssignedToAgent } from "../issues";
import { logTail } from "../logs";
import { getPull, liveHead, listOpenPullsByAgent, listPullComments, pullStatus, threadsOf } from "../pulls";
import { findRepoById, listReposForUser, repoAccess, type RepoRecord } from "../repos";
import { getRun, isFinished, latestRunsForSha, listJobs, listSteps, readJobLogs, type Run } from "../runs";
import { tokenCovers } from "../tokens";
import { actorOf, covered, iso, issueSummary, openRepo, tool, type ToolContext } from "./kit";
import { array, integer, oneOf, string, ToolError } from "./params";
import { pullSummary } from "./pull-tools";
// What makes an agent able to work without polling: its own task list, the repositories' event
// feed with a cursor (that can wait for the next event), and waiting for workflows to finish.
const sleep = (ms: number, signal?: AbortSignal) =>
new Promise<void>((resolve) => {
const timer = setTimeout(resolve, ms);
signal?.addEventListener("abort", () => {
clearTimeout(timer);
resolve();
});
});
/** Longest a tool may wait. Proxies in front of the server may cut requests sooner, so the default is shorter. */
const MAX_WAIT_SECONDS = 300;
const waitParam = (what: string, fallback: number) =>
integer(`How long to wait for ${what}, in seconds. If it times out, call again.`, { min: 0, max: MAX_WAIT_SECONDS }).default(fallback);
/** Repositories this connection can read: the user's own and collaborations, or the ones it was limited to. */
async function reachableRepos(ctx: ToolContext) {
if (ctx.scope.allRepos) return covered(ctx, await listReposForUser(ctx.user.id));
const repos: RepoRecord[] = [];
for (const r of ctx.scope.repos) {
const repo = await findRepoById(r.id);
if (repo && (await repoAccess(repo, ctx.user.id))) repos.push(repo);
}
return repos;
}
/** The end of a failed job's failing steps' output, to see why without another call. */
async function failureExcerpts(run: Run, lines = 40) {
const out: { job: string; step: string; exit_code: number | null; log_tail: string }[] = [];
for (const job of await listJobs(run.id)) {
if (job.status !== "failure") continue;
const [steps, logs] = await Promise.all([listSteps(job.id), readJobLogs(job.id)]);
for (const step of steps.filter((s) => s.status === "failure")) {
out.push({ job: job.key, step: step.name, exit_code: step.exitCode, log_tail: logTail(logs.get(step.index) ?? "", lines).lines.join("\n") });
}
}
return out;
}
async function runReport(url: string, run: Run) {
return {
number: run.number,
workflow: run.workflowName,
event: run.event,
status: run.status,
finished: isFinished(run.status),
sha: run.sha,
ref: run.ref.replace(/^refs\/(heads|tags)\//, ""),
url: `${url}/actions/runs/${run.number}`,
jobs: (await listJobs(run.id)).map((j) => ({ key: j.key, status: j.status })),
...(run.status === "failure" ? { failures: await failureExcerpts(run), error: run.error } : {}),
};
}
const eventView = (e: RepoEvent, repos: Map<string, string>) => ({
seq: e.seq,
repository: repos.get(e.repoId) ?? null,
event: e.kind,
action: e.action,
subject: e.subject,
by: e.actor ? (e.agent ? `${e.agent.name} (agent of ${e.actor})` : e.actor) : null,
at: iso(e.createdAt),
payload: e.payload,
});
export const agentTools = [
tool({
name: "get_my_tasks",
title: "Get my tasks",
description: [
"Start here: the open issues assigned to you (this agent) in every repository you can reach, most urgent first,",
"and your open pull requests with what they wait for (changes requested, unresolved review threads, failing checks, approvals).",
].join(" "),
access: "read",
params: {},
run: async (_args, ctx) => {
const [issues, pulls] = await Promise.all([listIssuesAssignedToAgent(ctx.agent.key), listOpenPullsByAgent(ctx.agent.key)]);
const repos = new Map<string, RepoRecord | null>();
const repoFor = async (id: string) => {
if (!repos.has(id)) {
const repo = await findRepoById(id);
repos.set(id, repo && tokenCovers(ctx.scope, repo.id, false) && (await repoAccess(repo, ctx.user.id)) ? repo : null);
}
return repos.get(id) ?? null;
};
const assigned = [];
for (const issue of issues) {
const repo = await repoFor(issue.repoId);
if (repo) assigned.push({ repository: `${repo.owner}/${repo.name}`, ...issueSummary(issue, ctx), url: `${ctx.origin}/${repo.owner}/${repo.name}/issues/${issue.number}` });
}
const yours = [];
for (const pr of pulls) {
const repo = await repoFor(pr.repoId);
if (!repo) continue;
const url = `${ctx.origin}/${repo.owner}/${repo.name}`;
const status = await pullStatus(repo, pr, actorOf(ctx));
const unresolved = threadsOf(await listPullComments(pr.id), status.headSha).filter((t) => !t.root.resolved).length;
const waitingFor = [
...(status.changesRequestedBy.length ? [`changes requested by ${status.changesRequestedBy.join(", ")}`] : []),
...(unresolved ? [`${unresolved} unresolved review thread${unresolved === 1 ? "" : "s"}`] : []),
...status.requirements.filter((r) => !r.ok && r.key !== "changes").map((r) => r.label),
];
yours.push({ repository: `${repo.owner}/${repo.name}`, ...pullSummary(url, pr, ctx), mergeable: status.mergeable, checks: status.checks, waiting_for: waitingFor });
}
return {
assigned_issues: assigned,
your_pull_requests: yours,
hint:
assigned.length + yours.length === 0
? "Nothing is assigned to you. Someone can assign you an issue (you're listed under the name of this connection), or list_events can wait for something to happen."
: "Work on an issue on a branch, open a pull request that says Fixes #<number>, and answer its reviews.",
};
},
}),
tool({
name: "list_events",
title: "List events",
description: [
`What happened in your repositories (or one), oldest first: ${EVENT_KINDS.join(", ")}.`,
"Pass the cursor from the last answer as since to get only newer events; with wait_seconds, waits for the next one if there's none yet.",
"Without since, returns the latest events and a cursor to follow from.",
].join(" "),
access: "read",
params: {
owner: string("Only this repository's owner (with repo).", { max: 39 }).optional(),
repo: string("Only this repository.", { max: 100 }).optional(),
since: integer("The cursor from the last call: only events after it.", { min: 0 }).optional(),
kinds: array("Only these kinds of events.", oneOf("An event kind.", EVENT_KINDS), { max: 20 }).default([]),
limit: integer("At most this many events.", { min: 1, max: 200 }).default(50),
wait_seconds: waitParam("an event when there's none yet", 0),
},
run: async (args, ctx) => {
let repos: RepoRecord[];
if (args.owner || args.repo) {
if (!args.owner || !args.repo) throw new ToolError("Pass both owner and repo, or neither.");
repos = [(await openRepo(ctx, args.owner, args.repo, "read")).repo];
} else {
repos = (await reachableRepos(ctx)).slice(0, 200);
}
const ids = repos.map((r) => r.id);
const names = new Map(repos.map((r) => [r.id, `${r.owner}/${r.name}`]));
const query = () => listEvents({ repoIds: ids, since: args.since, kinds: args.kinds, limit: args.limit });
let events = await query();
const deadline = Date.now() + args.wait_seconds * 1000;
while (events.length === 0 && args.since !== undefined && Date.now() < deadline && !ctx.signal?.aborted) {
await waitForEvent(ids, deadline - Date.now(), ctx.signal);
events = await query();
}
const cursor = events.length ? events[events.length - 1].seq : (args.since ?? (await latestSeq(ids)));
return { events: events.map((e) => eventView(e, names)), cursor, more: events.length === args.limit };
},
}),
tool({
name: "wait_for_workflow_run",
title: "Wait for a workflow run",
description: "Waits until a workflow run finishes (or the time is up), then returns its result, with the end of the log of each failed step.",
access: "read",
params: {
owner: string("The repository's owner.", { min: 1, max: 39 }),
repo: string("The repository's name.", { min: 1, max: 100 }),
number: integer("The run's number.", { min: 1 }),
timeout_seconds: waitParam("the run to finish", 90),
},
run: async (args, ctx) => {
const { repo, url } = await openRepo(ctx, args.owner, args.repo, "read");
const deadline = Date.now() + args.timeout_seconds * 1000;
for (;;) {
const run = await getRun(repo.id, args.number);
if (!run) throw new ToolError(`There's no workflow run #${args.number}.`);
if (isFinished(run.status) || Date.now() >= deadline || ctx.signal?.aborted) {
return { ...(await runReport(url, run)), ...(isFinished(run.status) ? {} : { timed_out: true }) };
}
await sleep(Math.min(2000, deadline - Date.now()), ctx.signal);
}
},
}),
tool({
name: "wait_for_checks",
title: "Wait for checks",
description: [
"Waits until every workflow run for a commit has finished (or the time is up), then returns the combined result with the end of the logs of failed steps.",
"Give a branch, tag or commit as ref, or a pull request number for its latest commit. Use it after pushing instead of polling.",
].join(" "),
access: "read",
params: {
owner: string("The repository's owner.", { min: 1, max: 39 }),
repo: string("The repository's name.", { min: 1, max: 100 }),
ref: string("A branch, tag or commit SHA.", { max: 255 }).optional(),
pull_request: integer("A pull request's number, for its head commit.", { min: 1 }).optional(),
timeout_seconds: waitParam("the checks to finish", 90),
},
run: async (args, ctx) => {
const { repo, url } = await openRepo(ctx, args.owner, args.repo, "read");
let sha: string | null;
if (args.pull_request !== undefined) {
const pr = await getPull(repo.id, args.pull_request);
if (!pr) throw new ToolError(`There's no pull request #${args.pull_request}.`);
sha = (await liveHead(repo, pr)) ?? pr.headSha;
} else {
if (!args.ref) throw new ToolError("Pass ref or pull_request.");
sha = await revParse(repo.dir, args.ref);
if (!sha) throw new ToolError(`There's no branch, tag or commit named ${args.ref}.`);
}
const started = Date.now();
const deadline = started + args.timeout_seconds * 1000;
for (;;) {
const runs = await latestRunsForSha(repo.id, sha);
const done = runs.length > 0 && runs.every((r) => isFinished(r.status));
// Runs start a moment after a push; give them a little time to appear.
const nothing = runs.length === 0 && Date.now() - started > Math.min(15_000, args.timeout_seconds * 1000);
if (done || nothing || Date.now() >= deadline || ctx.signal?.aborted) {
const state = runs.length === 0 ? "none" : !done ? "pending" : runs.some((r) => r.status !== "success") ? "failure" : "success";
return {
commit: sha,
state,
...(state === "none" ? { note: "No workflow runs for this commit." } : {}),
...(state === "pending" ? { timed_out: true } : {}),
runs: await Promise.all(runs.map((r) => runReport(url, r))),
};
}
await sleep(Math.min(2000, deadline - Date.now()), ctx.signal);
}
},
}),
];